Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions forge-web/components/auth/unlock-more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { errorMessage } from '@/lib/utils'

/**
* The prompt over members-only content this tab cannot read yet (DESIGN §4.8, §10):
* `<UnlockMore title={UNLOCK_MEMBERS_ONLY} />`. The unlock is per tab, not per repo: one gesture
* opens the encryption key for every repo in the tab (`encryptionKeyState` says whether it is
* needed), and every private read re-resolves when `useAuth().unlockScope` turns `full`.
*/
export const UNLOCK_MEMBERS_ONLY = 'Unlock to read members-only content'

export function UnlockMore({
title,
testId = 'unlock-more',
Expand Down
26 changes: 23 additions & 3 deletions forge-web/components/auth/wallet-connect-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
// @vitest-environment jsdom
/**
* Wallet sign-in over an unfinished renewal whose key is locked: the renewal's passphrase input is
* uncontrolled, so the typed passphrase never lands in the DOM's `value` attribute.
* uncontrolled, so the typed passphrase never lands in the DOM's `value` attribute. The wallet's
* encryption key goes to the sign-in with every attempt, and is wiped once it is done (D27).
*/

import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PendingRenewalChoiceError, PendingRenewalLockedError } from '@/lib/auth/controller'

const { ID, FORGE } = vi.hoisted(() => ({
const { ID, FORGE, answered } = vi.hoisted(() => ({
ID: '9r27eDsuXEqoMNymW1A2MKFrpBhzSkepVKwXrGzq9dUD',
FORGE: { core: 'CoreContract111', collab: 'CollabContract111', community: 'CommunityContract111', group: 'Group111' },
/** The encryption keys of the last answer the fake wallet gave. */
answered: { keys: [] as Uint8Array[] },
}))
const FAKE_VAULT_PASSPHRASE = 'fake-vault-passphrase-000'
const FAKE_RENEWAL_PASSPHRASE = 'fake-renewal-passphrase-789'
Expand All @@ -30,7 +33,10 @@ vi.mock('@/lib/auth/app-connect', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/auth/app-connect')>()),
responseSources: async () => [{}],
newLoginRequest: () => ({ uri: 'dash-key:fake', expiresAt: Date.now() + 60_000 }),
awaitWalletAnswer: async () => ({ kind: 'keys', identityId: ID, keys: [{ scope: {}, limits: {} }] }),
awaitWalletAnswer: async () => {
answered.keys = [new Uint8Array(32).fill(0x42)]
return { kind: 'keys', identityId: ID, keys: [{ scope: {}, limits: {} }], encryptionKeys: answered.keys }
},
}))
vi.mock('@/lib/auth/key-registration', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/auth/key-registration')>()),
Expand Down Expand Up @@ -99,7 +105,21 @@ describe('WalletConnectFlow: a locked unfinished renewal', () => {
expect(adoptWalletKeys).toHaveBeenLastCalledWith(ID, expect.anything(), expect.anything(), {
discardPendingRenewal: true,
renewalUnlock: { passphrase: FAKE_RENEWAL_PASSPHRASE },
encryptionKeys: answered.keys,
justRegistered: false,
})
expect(onDone).toHaveBeenCalled()
// Passed intact on each of the three attempts, and wiped once signed in.
expect(adoptWalletKeys).toHaveBeenCalledTimes(3)
expect(answered.keys[0]!.every((b) => b === 0)).toBe(true)
})

it("wipes the wallet's encryption key when the sheet closes before signing in", async () => {
act(() => root.render(<WalletConnectFlow onDone={vi.fn()} />))
await flush()
expect(host.querySelector('[data-testid="wallet-confirm"]')).not.toBeNull()
expect(answered.keys[0]!.every((b) => b === 0x42)).toBe(true)
act(() => root.render(<></>))
expect(answered.keys[0]!.every((b) => b === 0)).toBe(true)
})
})
79 changes: 54 additions & 25 deletions forge-web/components/auth/wallet-connect-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
* wallet grants one contract per approval) and adds it to the session: no confirmation step,
* since only answers from the signed-in identity are read.
*
* Granted keys are held in refs, never React state, and dropped when the sheet closes.
* Granted keys are held in refs, never React state, and dropped when the sheet closes. So is the
* encryption key the wallet's login key stands for: signing in (or a grant) seals it into the vault
* beside the wallet key when it is the identity's (DESIGN D27), and the bytes are wiped once the
* sheet is done.
*/

import { useCallback, useEffect, useRef, useState } from 'react'
Expand All @@ -35,7 +38,18 @@ import { Qr } from '@/components/ui/qr'
import { ErrorBox, useProtection } from '@/components/auth/protection-fields'
import { Waiting } from '@/components/auth/step-status'
import { ACTIVE_NETWORK } from '@/lib/constants'
import { REQUEST_TTL_MS, RequestExpired, awaitRegisteredKey, awaitWalletAnswer, newLoginRequest, responseSources, walletSignInSupported, type PollStatus } from '@/lib/auth/app-connect'
import {
REQUEST_TTL_MS,
RequestExpired,
awaitRegisteredKey,
awaitWalletAnswer,
newLoginRequest,
responseSources,
walletSignInSupported,
wipeAnswer,
type PollStatus,
type WalletAnswer,
} from '@/lib/auth/app-connect'
import { isUnlimited, keyRegistrationUri, scopeCovers, type WalletKey } from '@/lib/auth/key-registration'
import { responderProfile, type ResponderProfile } from '@/lib/auth/responder-profile'
import { isAbort } from '@/lib/sdk/facade'
Expand Down Expand Up @@ -96,7 +110,12 @@ export function WalletConnectFlow({ onDone, mode = 'login', contractId }: { onDo
const [preparing, setPreparing] = useState(PHASE_TEXT.connecting)
const [attempt, setAttempt] = useState(0)
const [confirmed, setConfirmed] = useState(false)
const grant = useRef<{ identityId: string; keys: readonly WalletKey[] } | null>(null)
const grant = useRef<{ identityId: string; keys: readonly WalletKey[]; answer: WalletAnswer } | null>(null)
/** Wipe the held answer's private key bytes and drop the grant. */
const dropGrant = useCallback(() => {
if (grant.current) wipeAnswer(grant.current.answer)
grant.current = null
}, [])
const mobile = onMobile()
const unlimited = step?.kind === 'confirm' && step.unlimited
const { fields, protection, problem } = useProtection({ preferPasskey: unlimited })
Expand Down Expand Up @@ -126,8 +145,10 @@ export function WalletConnectFlow({ onDone, mode = 'login', contractId }: { onDo
discardPendingRenewal: discard,
...(renewalUnlock ? { renewalUnlock } : {}),
...(drop ? { dropUnopened: true } : {}),
encryptionKeys: g.answer.encryptionKeys,
justRegistered: g.answer.kind === 'register',
})
grant.current = null
dropGrant()
setPendingRenewal(null)
setRenewalLocked(null)
if (renewalPassphraseRef.current) renewalPassphraseRef.current.value = ''
Expand Down Expand Up @@ -173,27 +194,35 @@ export function WalletConnectFlow({ onDone, mode = 'login', contractId }: { onDo
...(mode === 'grant' && grantFor ? { identityId: grantFor } : {}),
})
let keys: readonly WalletKey[]
if (answer.kind === 'register') {
// First login from this wallet: QR #2 registers the key, then wait for it on chain.
const until = Date.now() + REQUEST_TTL_MS
const uri = await keyRegistrationUri(sdk, { identityId: answer.identityId, keys: answer.keys, contractId: target, network: ACTIVE_NETWORK.network })
if (signal.aborted) return
setStep({ kind: 'register', uri, expiresAt: until })
keys = [await awaitRegisteredKey(sdk, { identityId: answer.identityId, wif: answer.wif, network: ACTIVE_NETWORK.network, forge, until, signal })]
} else {
keys = answer.keys
}
if (signal.aborted) return
if (mode === 'grant') {
// The key that covers what was asked for (a wallet may grant several, or the wrong one).
const key = keys.find((k) => scopeCovers(k.scope, forge, target))
if (!key) throw new Error("The wallet's answer does not cover issues and pull requests. Try again, or sign in with your identity file.")
await latest.current.addWalletGrant(answer.identityId, key, target)
// Its private key bytes are wiped here unless the confirm step holds them (`grant`).
let held = false
try {
if (answer.kind === 'register') {
// First login from this wallet: QR #2 registers the key, then wait for it on chain.
const until = Date.now() + REQUEST_TTL_MS
const uri = await keyRegistrationUri(sdk, { identityId: answer.identityId, keys: answer.keys, contractId: target, network: ACTIVE_NETWORK.network })
if (signal.aborted) return
setStep({ kind: 'register', uri, expiresAt: until })
keys = [await awaitRegisteredKey(sdk, { identityId: answer.identityId, wif: answer.wif, network: ACTIVE_NETWORK.network, forge, until, signal })]
} else {
keys = answer.keys
}
if (signal.aborted) return
latest.current.onDone()
return
if (mode === 'grant') {
// The key that covers what was asked for (a wallet may grant several, or the wrong one).
const key = keys.find((k) => scopeCovers(k.scope, forge, target))
if (!key) throw new Error("The wallet's answer does not cover issues and pull requests. Try again, or sign in with your identity file.")
// Its first approval registered an encryption key too: the sign-in keeps it.
await latest.current.addWalletGrant(answer.identityId, key, target, { encryptionKeys: answer.encryptionKeys, justRegistered: answer.kind === 'register' })
if (signal.aborted) return
latest.current.onDone()
return
}
grant.current = { identityId: answer.identityId, keys, answer }
held = true
} finally {
if (!held) wipeAnswer(answer)
}
grant.current = { identityId: answer.identityId, keys }
const profile = await responderProfile(sdk, answer.identityId, ACTIVE_NETWORK.network, latest.current.stored)
if (signal.aborted) return
setStep({ kind: 'confirm', profile, unlimited: keys.some(isUnlimited), unbounded: keys.some((k) => k.scope.unbounded) })
Expand All @@ -205,10 +234,10 @@ export function WalletConnectFlow({ onDone, mode = 'login', contractId }: { onDo
})()
return () => {
controller.abort()
grant.current = null
dropGrant()
}
// `attempt` restarts the whole request (a new ephemeral key and QR).
}, [attempt, forge, target, mode, grantFor])
}, [attempt, forge, target, mode, grantFor, dropGrant])

const restart = useCallback(() => setAttempt((a) => a + 1), [])

Expand Down
30 changes: 20 additions & 10 deletions forge-web/components/encryption-key-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
usableEncryptionKey,
wipeMaterial,
} from '@/lib/auth/encryption-key'
import { onEncryptionKeyChange, removeEncryptionKey, storedEncryptionKeyId } from '@/lib/auth/vault'
import { onEncryptionKeyChange, removeEncryptionKey, storedEncryptionKeyIds } from '@/lib/auth/vault'
import { errorMessage } from '@/lib/utils'
import { otherIdentityFileMessage } from '@/lib/auth/controller'
import { PRIVATE_REPOS_ANCHOR } from '@/lib/settings-links'
Expand All @@ -42,14 +42,22 @@ import { useConfirmAction } from '@/components/ui/confirm-action'

type Mode = 'phrase' | 'file' | 'paste' | 'register'

/** "Encryption key 6 is" / "Encryption keys 6 and 7 are" / "Encryption keys 4, 6 and 7 are". */
function heldKeys(ids: readonly number[]): string {
const sorted = [...ids].sort((a, b) => a - b)
if (sorted.length === 1) return `Encryption key ${sorted[0]} is`
return `Encryption keys ${sorted.slice(0, -1).join(', ')} and ${sorted[sorted.length - 1]} are`
}

/** An identity update adding one key (measured like the limited-key registration). */
const REGISTER_COST = '~0.0005 DASH'

export function EncryptionKeyPanel(): JSX.Element | null {
const { identity, storage, controller, unlockScope } = useAuth()
const { sdk, ready, network } = useSdk()
const core = NETWORKS[network].v2?.core ?? null
const [keyId, setKeyId] = useState<number | null | undefined>(undefined)
/** The key ids this browser holds for the identity (null: none; undefined: not read yet). */
const [keyIds, setKeyIds] = useState<readonly number[] | null | undefined>(undefined)
const [mode, setMode] = useState<Mode>('phrase')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
Expand All @@ -63,9 +71,9 @@ export function EncryptionKeyPanel(): JSX.Element | null {
if (identity === null) return
let live = true
const read = (): void => {
storedEncryptionKeyId(network, identity).then(
(k) => live && setKeyId(k),
() => live && setKeyId(null),
storedEncryptionKeyIds(network, identity).then(
(k) => live && setKeyIds(k.length > 0 ? k : null),
() => live && setKeyIds(null),
)
}
read()
Expand Down Expand Up @@ -181,12 +189,12 @@ export function EncryptionKeyPanel(): JSX.Element | null {
<div className="mt-3">
<UnlockMore title="Unlock this tab to manage your encryption key" testId="encryption-unlock" />
</div>
) : keyId !== null && keyId !== undefined ? (
) : keyIds !== null && keyIds !== undefined ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<span className="text-dense" data-testid="encryption-key-stored">
{storage === 'session'
? `Encryption key ${keyId} is held for this tab only; it is forgotten on reload or lock.`
: `Encryption key ${keyId} is stored in this browser and unlocked now: private repos you're a member of open here. It locks again with this browser's key (Lock, or after 12 hours).`}
? `${heldKeys(keyIds)} held for this tab only; ${keyIds.length === 1 ? 'it is' : 'they are'} forgotten on reload or lock.`
: `${heldKeys(keyIds)} stored in this browser and unlocked now: private repos you're a member of open here. It locks again with this browser's key (Lock, or after 12 hours).`}
</span>
<Button
size="sm"
Expand All @@ -204,9 +212,11 @@ export function EncryptionKeyPanel(): JSX.Element | null {
<Trash2 className="h-3.5 w-3.5" aria-hidden /> Remove from this browser
</Button>
</div>
) : keyId === null ? (
) : null}
{/* Another key can always be added: one held here never blocks a newer one (DESIGN D27). */}
{unlockScope !== 'signing' && keyIds !== undefined ? (
<div className="mt-3 space-y-3">
<p className="text-dense font-medium">Enable private repos</p>
<p className="text-dense font-medium">{keyIds === null ? 'Enable private repos' : 'Add another encryption key'}</p>
<div role="tablist" className="inline-flex rounded-md border border-anvil-200 p-0.5 dark:border-anvil-750">
{(
[
Expand Down
14 changes: 12 additions & 2 deletions forge-web/components/repo/private-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Author } from '@/components/author'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PRIVATE_REPOS_SETTINGS } from '@/lib/settings-links'
import { ENCRYPTION_KEY_ELSEWHERE, ENCRYPTION_KEY_OTHER_APPROVAL } from '@/lib/auth/encryption-key'

function Note({ tone, icon, children, testId }: { tone: 'caution' | 'danger' | 'info'; icon: React.ReactNode; children: React.ReactNode; testId?: string }): JSX.Element {
const klass =
Expand Down Expand Up @@ -99,7 +100,9 @@ function MemberAlerts({ home, session }: { home: RepoHome; session: PrivateSessi
const maintainer = isMaintainer(session, identity)
const alerts = r.alerts.filter((a) => a.kind !== 'rotationRequired')
const closed = currentBurned(r)
const cannotReadCurrent = r.currentEpoch !== null && r.writeEpoch === null && !closed
// Wrapped to this reader, but only to keys this browser does not hold: say which way to get one.
const missing = session.missingKey ?? null
const cannotReadCurrent = r.currentEpoch !== null && r.writeEpoch === null && !closed && missing === null
const repair = identity === null ? null : planRepair(session, identity, home.repo.forge.core)
const parts: JSX.Element[] = []
if (alerts.length > 0) {
Expand All @@ -111,6 +114,13 @@ function MemberAlerts({ home, session }: { home: RepoHome; session: PrivateSessi
</Note>,
)
}
if (missing !== null) {
parts.push(
<Note key="missing-key" tone="caution" icon={<KeyRound className="h-4 w-4 text-caution-700 dark:text-caution-400" aria-hidden />} testId="private-missing-key">
{missing.otherApproval ? ENCRYPTION_KEY_OTHER_APPROVAL : ENCRYPTION_KEY_ELSEWHERE}
</Note>,
)
}
if (cannotReadCurrent) {
const setBy = r.currentEpoch === null ? undefined : session.anchors.get(r.currentEpoch)?.owner
parts.push(
Expand Down Expand Up @@ -156,7 +166,7 @@ function RepairNote({ home, session, self, plan }: { home: RepoHome; session: Pr
const [open, setOpen] = useState(false)
let cost = null
try {
cost = write.context === null ? null : repairCost(session, plan, self, home.repo.forge.core, write.context.ops.keyId)
cost = write.context === null ? null : repairCost(session, plan, self, home.repo.forge.core, write.context.ops.keyIds)
} catch {
cost = null
}
Expand Down
2 changes: 1 addition & 1 deletion forge-web/components/repo/private-members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function PrivateMembers({ home, session }: { home: RepoHome; session: Pri
try {
const exclude = effect === 'rotate-exclude' ? [removing.member] : []
const from = chainFrom(session, removing.member, removing.role)
return { plan: planRotation(session, identity, exclude, repo.forge.core, write.context.ops.keyId, from), error: null }
return { plan: planRotation(session, identity, exclude, repo.forge.core, write.context.ops.keyIds, from), error: null }
} catch (e) {
return { plan: null, error: e instanceof Error ? e.message : String(e) }
}
Expand Down
2 changes: 1 addition & 1 deletion forge-web/contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ interface AuthContextValue {
/** Store the keys a wallet granted (verified on chain) and open the session. */
adoptWalletKeys: (identityId: string, keys: readonly WalletKey[], protection: Protection, options?: Parameters<AuthController['adoptWalletKeys']>[3]) => Promise<void>
/** Add a wallet grant for another Forge contract to the signed-in identity. */
addWalletGrant: (identityId: string, key: WalletKey, requested: string) => Promise<void>
addWalletGrant: (identityId: string, key: WalletKey, requested: string, options?: Parameters<AuthController['addWalletGrant']>[3]) => Promise<void>
/** Which Forge contracts the session's keys cover, and whether a held key is unlimited. */
readonly grants: AuthSession['grants'] | null
readonly unlimitedKey: boolean
Expand Down
Loading
Loading