diff --git a/src/components/UnlockPhones.svelte b/src/components/UnlockPhones.svelte index 5a99dac..53892d0 100644 --- a/src/components/UnlockPhones.svelte +++ b/src/components/UnlockPhones.svelte @@ -3,20 +3,43 @@ // signer after a restart with a tap. Add one by scanning the code Cambium // shows, list them, revoke one. No secret passes through Sapwood: the board // seals each phone's unlock secret to a key only that phone holds. - import { untrack } from 'svelte' + import { onDestroy, untrack } from 'svelte' import { SimplePool } from 'nostr-tools/pool' + import { encodeQR } from '@paulmillr/qr' import { device, listUnlockPhones, revokeUnlockPhone, setAnnounceOperator, enrolUnlockPhone, PhoneUnlockAuthRequired, supportsPhoneEnrolRelay, } from '../lib/device.svelte.js' import { parseEnrolmentCode, enrolPhone, HandOffUndelivered, markCodeSpent, isCodeSpent, - requestWords, fitLabel, friendlyEnrolRefusal, findOrphanedPhoneId, + requestWords, fitLabel, friendlyEnrolRefusal, findOrphanedPhoneId, openRelays, + HANDOFF_KIND, type EnrolmentCode, type EnrolResult, type UnlockPhoneList, } from '../lib/phone-unlock.js' + import { + createInvite, openInviteReply, reduceInvite, initialInviteState, zeroInviteSecret, + type Invite, type InviteCollectorState, type InviteReplyEvent, + } from '../lib/enrol-invite.js' import ConfirmButton from './ConfirmButton.svelte' import QrScanner from './QrScanner.svelte' + /** Fallback relays for a fresh invite: the ones Sapwood already uses for + * this signer over the relay, its USB-reported WiFi relays, or failing + * both the project relay (the same one Cambium's own enrolment code + * defaults to). */ + function defaultInviteRelays(): string[] { + if (device.mode === 'relay' && device.relayConfiguredRelays?.length) return [...device.relayConfiguredRelays] + if (device.mode === 'serial' && device.usbNetworkState?.relays?.length) return [...device.usbNetworkState.relays] + return ['wss://relay.trotters.cc'] + } + + function formatCountdown(totalSeconds: number): string { + const clamped = Math.max(0, totalSeconds) + const m = Math.floor(clamped / 60) + const s = clamped % 60 + return `${m}:${s.toString().padStart(2, '0')}` + } + interface Props { /** Enrolled phones, or null while unknown (for the mode chooser). */ count?: number | null @@ -132,7 +155,9 @@ } // --- Add a phone --- - type Step = 'idle' | 'scan' | 'paste' | 'confirm' | 'working' | 'done' | 'failed' | 'orphan' + type Step = + | 'idle' | 'invite' | 'invite-expired' | 'invite-aborted' + | 'scan' | 'paste' | 'confirm' | 'working' | 'done' | 'failed' | 'orphan' let step = $state('idle') let pasted = $state('') let code = $state(null) @@ -151,7 +176,99 @@ const pastedSpent = $derived(pastedCode ? isCodeSpent(pastedCode) : false) let scanSpent = $state(false) + // --- Invite: Sapwood shows a QR, the phone scans it and replies over the + // relay (the default "Add a phone" path, see enrol-invite.ts). --- + // $state.raw, not $state: this holds a Uint8Array secret, and identity + // (not deep reactivity) is what the invite-superseded checks below rely on. + let invite = $state.raw(null) + let inviteState = $state(initialInviteState) + let invitePool: SimplePool | null = null + let inviteCloser: { close: () => void } | null = null + let inviteTicker: ReturnType | null = null + let inviteNow = $state(Date.now()) + + const inviteQr = $derived(invite ? encodeQR(invite.uri, 'svg') : '') + const inviteSecondsLeft = $derived(invite ? Math.ceil((invite.expiresAt * 1000 - inviteNow) / 1000) : 0) + + /** Close the invite's subscription and pool, and zero its secret. Safe to + * call at any time, including when no invite is open. */ + function stopInvite(): void { + if (inviteTicker) { clearInterval(inviteTicker); inviteTicker = null } + try { inviteCloser?.close() } catch { /* already closed */ } + inviteCloser = null + try { invitePool?.destroy() } catch { /* already closed */ } + invitePool = null + if (invite) zeroInviteSecret(invite) + invite = null + inviteState = initialInviteState + } + + function handleInviteEvent(event: InviteReplyEvent, forInvite: Invite): void { + if (invite !== forInvite) return // superseded or already closed + const parsed = openInviteReply(event, forInvite) + if (!parsed) return + inviteState = reduceInvite(inviteState, { type: 'reply', code: parsed }) + if (inviteState.status === 'received' && step === 'invite') { + code = inviteState.code + step = 'confirm' + } else if (inviteState.status === 'aborted') { + addError = 'Two phones answered this code. Someone else may have seen it. Nothing was added; start again.' + stopInvite() + step = 'invite-aborted' + } + } + + async function startInvite(): Promise { + stopInvite() + step = 'invite' + pasted = '' + code = null + result = null + addError = null + codeSpent = false + scanSpent = false + orphanId = null + inviteState = initialInviteState + + const relays = defaultInviteRelays() + const made = createInvite(relays) + const pool = new SimplePool() + invitePool = pool + const live = await openRelays(pool, relays) + if (step !== 'invite' || invitePool !== pool) { + try { pool.destroy() } catch { /* already closed */ } + return // cancelled, or superseded by a newer invite, while opening + } + if (!live.length) { + zeroInviteSecret(made) + stopInvite() + addError = 'None of the relays answered, so the phone could not reply. Check this computer\'s connection and try again.' + step = 'invite-aborted' + return + } + invite = made + const since = Math.floor(Date.now() / 1000) - 60 + inviteCloser = pool.subscribe( + relays, + { kinds: [HANDOFF_KIND], '#h': [made.rendezvous], since }, + { onevent: (event) => handleInviteEvent(event, made) }, + ) + inviteNow = Date.now() + inviteTicker = setInterval(() => { + inviteNow = Date.now() + if (invite !== made || inviteNow < made.expiresAt * 1000) return + inviteState = reduceInvite(inviteState, { type: 'expire' }) + if (inviteState.status === 'expired') { + stopInvite() + step = 'invite-expired' + } + }, 1000) + } + + onDestroy(stopInvite) + function startAdd(mode: 'scan' | 'paste') { + stopInvite() step = mode pasted = '' code = null @@ -213,6 +330,14 @@ } } + /** Continue from the confirm step: the invite (if this code came from one) + * is single-use, so its subscription closes and its secret is zeroed + * before the existing enrol path runs, unchanged. */ + function continueFromConfirm() { + stopInvite() + void add() + } + async function add() { if (!code) return step = 'working' @@ -305,11 +430,18 @@ {#if step === 'idle'} {#if canAdd}
- - +
+
+ Phone shows a code instead? Paste it +
+ + +
+
{:else}

Adding a phone needs the signer on the USB cable, or over the relay once its firmware serves that, and a press on its button.

{/if} @@ -335,7 +467,30 @@ {#if step !== 'idle'}
- {#if step === 'scan' || step === 'paste'} + {#if step === 'invite'} + {#if !invite} +

Opening relays…

+ {:else} +

Scan this with Cambium

+

On the phone, open Cambium, go to this signer's screen, and tap + "Set up phone unlock", then "Scan Sapwood's code".

+
{@html inviteQr}
+

Waiting for the phone. Expires in {formatCountdown(inviteSecondsLeft)}.

+ {/if} + + {:else if step === 'invite-expired'} +

Code expired, make a new one.

+
+ + +
+ {:else if step === 'invite-aborted'} +

{addError}

+
+ + +
+ {:else if step === 'scan' || step === 'paste'}

On the phone, open Cambium and tap “Set up phone unlock” under this signer. It shows a code; nothing in it is secret. Its “Copy code” button gives the text to paste here.

@@ -367,19 +522,19 @@

The signer shows ADD PHONE and five words, two at a time over about - 12 seconds before it will accept a hold. Compare those five words with your phone's own - screen before holding the button: that is the check that matters, since whoever relayed - the request could have swapped the words shown here. A check code appears afterwards too, - but it only confirms the phone got the hand-off; it does not defend against a swapped - phone.

+ 12 seconds before it will accept a hold.

-

Sapwood's own copy, for reference only. Compare the words on your - signer with your phone, not with this page:

+

Check your phone shows these same five words

{requestWords(code.enrolPubkey).join(' ')}

+

Compare them with your phone's own screen, and with the signer's + card, before holding the button: that is the check that matters, since whoever + relayed the request could have swapped the words shown here. A check code appears + afterwards too, but it only confirms the phone got the hand-off; it does not defend + against a swapped phone.

- - + +
{:else if step === 'working'}

{working}

@@ -431,9 +586,14 @@ .code-input { width: 100%; box-sizing: border-box; font-size: 0.8rem; resize: vertical; } .check-code { font-size: 2rem; letter-spacing: 0.2em; color: var(--green); margin: 0.2rem 0; } .words-preview { border-top: 1px solid var(--border); padding-top: 0.6rem; margin-top: 0.2rem; } - .words { font-size: 1.1rem; letter-spacing: 0.05em; } + .words-headline { font-weight: 700; margin: 0 0 0.3rem; } + .words { font-size: 1.4rem; font-weight: 700; letter-spacing: 0.05em; margin: 0 0 0.5rem; } .status { margin-top: 0.6rem; color: var(--text-dim); } .announce { margin-top: 1rem; } .lq-buttons { display: flex; gap: 0.5rem; margin: 0.4rem 0; } .lq-on { border-color: var(--green-dim); color: var(--green); background: #08130d; } + .invite-title { font-size: 1.1rem; font-weight: 700; margin: 0; color: var(--text); } + .qr { width: 220px; padding: 12px; background: #fff; border-radius: 6px; margin: 0.4rem 0; } + .qr :global(svg) { display: block; width: 100%; height: auto; } + .countdown { font-weight: 600; } diff --git a/src/components/UnlockPhones.test.ts b/src/components/UnlockPhones.test.ts index 10fd226..f86bc3b 100644 --- a/src/components/UnlockPhones.test.ts +++ b/src/components/UnlockPhones.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' +import { getConversationKey, encrypt as nip44Encrypt } from 'nostr-tools/nip44' +import { hexToBytes } from '@noble/hashes/utils.js' const P = 'a1'.repeat(32) const R = 'b2'.repeat(16) @@ -27,6 +30,11 @@ const relays = vi.hoisted(() => ({ ensureRelay: vi.fn(async () => ({})), publish: vi.fn((urls: string[]) => urls.map(() => Promise.resolve(''))), destroy: vi.fn(), + subscribe: vi.fn(( + _relays: string[], + _filter: Record, + _opts: { onevent: (event: unknown) => void }, + ) => ({ close: vi.fn() })), })) vi.mock('nostr-tools/pool', () => ({ @@ -34,9 +42,24 @@ vi.mock('nostr-tools/pool', () => ({ ensureRelay = relays.ensureRelay publish = relays.publish destroy = relays.destroy + subscribe = relays.subscribe }, })) +// A fixed invite keypair, so a test can build a genuine encrypted reply +// without reading anything out of the rendered QR. Everything else +// (openInviteReply, reduceInvite) is the real implementation. +const INVITE_SECRET = hexToBytes('44'.repeat(32)) +const INVITE_PUBKEY = getPublicKey(INVITE_SECRET) +const INVITE_RENDEZVOUS = '77'.repeat(16) + +const invite = vi.hoisted(() => ({ createInvite: vi.fn() })) + +vi.mock('../lib/enrol-invite.js', async () => { + const actual = await vi.importActual('../lib/enrol-invite.js') + return { ...actual, createInvite: invite.createInvite } +}) + vi.mock('../lib/device.svelte.js', async () => { const { createSubscriber } = await import('svelte/reactivity') let notify = () => {} @@ -87,8 +110,33 @@ beforeEach(() => { Object.assign(device, { connected: true, mode: 'serial', bridgeAuthed: true, awaitingButton: null, masters: [] }) api.listUnlockPhones.mockResolvedValue(LIST) api.supportsPhoneEnrolRelay.mockReturnValue(false) + relays.subscribe.mockReturnValue({ close: vi.fn() }) + invite.createInvite.mockImplementation((relayUrls: string[], now: number = Date.now()) => ({ + uri: `heartwood-unlock:invite?v=1&k=${INVITE_PUBKEY}&r=${INVITE_RENDEZVOUS}&x=${Math.floor(now / 1000) + 600}` + + relayUrls.map((r) => `&relay=${encodeURIComponent(r)}`).join(''), + // A fresh copy each call: the component zeroes this in place on cancel, + // confirm and unmount, and the shared constant must survive that. + secret: INVITE_SECRET.slice(), + pubkey: INVITE_PUBKEY, + rendezvous: INVITE_RENDEZVOUS, + expiresAt: Math.floor(now / 1000) + 600, + })) }) +/** A genuine kind-24137 invite reply, as Cambium would publish it: signed by + * a fresh throwaway key, encrypted to the fixed invite pubkey above. */ +function inviteReply(plaintext: string, rendezvous = INVITE_RENDEZVOUS) { + const throwaway = generateSecretKey() + const ck = getConversationKey(throwaway, INVITE_PUBKEY) + const content = nip44Encrypt(plaintext, ck) + return finalizeEvent({ + kind: 24137, + created_at: Math.floor(Date.now() / 1000), + tags: [['h', rendezvous]], + content, + }, throwaway) +} + afterEach(() => cleanup()) describe('UnlockPhones', () => { @@ -122,10 +170,11 @@ describe('UnlockPhones', () => { it('adds a phone from a pasted code and shows the check code', async () => { api.enrolUnlockPhone.mockResolvedValue({ id: 77, ephemeral_pubkey: 'ab'.repeat(32), sealed: 'ciphertext' }) render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Add Pixel 8' })) + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) @@ -137,7 +186,8 @@ describe('UnlockPhones', () => { // Finished, then the same code again: refused here, not sent twice. await fireEvent.click(screen.getByRole('button', { name: 'Finished' })) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE } }) expect(screen.getByText(/already used here/)).toBeTruthy() expect((screen.getByRole('button', { name: 'Use this code' }) as HTMLButtonElement).disabled).toBe(true) @@ -145,7 +195,8 @@ describe('UnlockPhones', () => { it('refuses text that is not an enrolment code', async () => { render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: 'bunker://nope' } }) expect(screen.getByText(/not a phone-unlock code/)).toBeTruthy() expect((screen.getByRole('button', { name: 'Use this code' }) as HTMLButtonElement).disabled).toBe(true) @@ -154,10 +205,11 @@ describe('UnlockPhones', () => { it('offers a new code, not a retry, once the board has seen the code', async () => { api.enrolUnlockPhone.mockRejectedValue(new Error('The signer refused: declined on the board.')) render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: CODE.replace(P, 'c3'.repeat(32)) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Add Pixel 8' })) + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(await screen.findByText(/Each code works once/)).toBeTruthy() expect(screen.getByRole('button', { name: 'Scan a new code' })).toBeTruthy() expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() @@ -197,13 +249,14 @@ describe('UnlockPhones', () => { api.supportsPhoneEnrolRelay.mockReturnValue(true) api.enrolUnlockPhone.mockResolvedValue({ id: 77, ephemeral_pubkey: 'ab'.repeat(32), sealed: 'ciphertext' }) render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) // Sapwood's own copy of the request-code words: for reference only. expect(screen.getByText('release jar chimney acoustic depart')).toBeTruthy() - expect(screen.getByText(/Compare the words on your signer with your phone, not with this page/)).toBeTruthy() - await fireEvent.click(screen.getByRole('button', { name: 'Add Pixel 8' })) + expect(screen.getByText(/Compare them with your phone's own screen/)).toBeTruthy() + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) @@ -225,10 +278,11 @@ describe('UnlockPhones', () => { phones: [...LIST.phones, { id: 99, label: 'phone' }], }) render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Add Pixel 8' })) + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(await screen.findByRole('button', { name: 'Revoke record 99' })).toBeTruthy() expect(screen.getByText(/nobody holds/)).toBeTruthy() await fireEvent.click(screen.getByRole('button', { name: 'Revoke record 99' })) @@ -242,11 +296,85 @@ describe('UnlockPhones', () => { api.enrolUnlockPhone.mockRejectedValue(new Error('timeout waiting for device (enrol_unlock_phone)')) api.listUnlockPhones.mockResolvedValue(LIST) render(UnlockPhones) - await fireEvent.click(await screen.findByRole('button', { name: 'Paste a code instead' })) + await fireEvent.click(await screen.findByText('Phone shows a code instead? Paste it')) + await fireEvent.click(screen.getByRole('button', { name: 'Paste a code' })) await fireEvent.input(screen.getByRole('textbox'), { target: { value: codeWith(p) } }) await fireEvent.click(screen.getByRole('button', { name: 'Use this code' })) - await fireEvent.click(screen.getByRole('button', { name: 'Add Pixel 8' })) + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) expect(await screen.findByText(/never answered in time/)).toBeTruthy() expect(screen.queryByText(/nobody holds/)).toBeNull() }) + + describe('the invite (Sapwood shows a QR)', () => { + it('shows a QR, opens the phone\'s relays, and moves to confirm once the phone replies', async () => { + api.enrolUnlockPhone.mockResolvedValue({ id: 61, ephemeral_pubkey: 'ab'.repeat(32), sealed: 'ciphertext' }) + render(UnlockPhones) + await fireEvent.click(await screen.findByRole('button', { name: 'Add a phone' })) + await screen.findByText('Scan this with Cambium') + expect(relays.ensureRelay).toHaveBeenCalledWith('wss://relay.trotters.cc', expect.anything()) + expect(relays.subscribe).toHaveBeenCalledOnce() + const [subRelays, filter, opts] = relays.subscribe.mock.calls[0] + expect(subRelays).toEqual(['wss://relay.trotters.cc']) + expect(filter).toMatchObject({ kinds: [24137], '#h': [INVITE_RENDEZVOUS] }) + + const p = '11'.repeat(32) + opts.onevent(inviteReply(codeWith(p))) + + expect(await screen.findByText('Check your phone shows these same five words')).toBeTruthy() + expect(screen.getByText('Pixel 8')).toBeTruthy() + await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(await screen.findByText('9B6 164')).toBeTruthy() + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + }) + + it('ignores a repeat of the identical reply', async () => { + render(UnlockPhones) + await fireEvent.click(await screen.findByRole('button', { name: 'Add a phone' })) + await screen.findByText('Scan this with Cambium') + const opts = relays.subscribe.mock.calls[0][2] + + const p = '22'.repeat(32) + opts.onevent(inviteReply(codeWith(p))) + await screen.findByText('Check your phone shows these same five words') + opts.onevent(inviteReply(codeWith(p))) + expect(screen.getByText('Check your phone shows these same five words')).toBeTruthy() + expect(screen.queryByText(/Two phones answered/)).toBeNull() + }) + + it('aborts when a second, different phone answers the same code', async () => { + render(UnlockPhones) + await fireEvent.click(await screen.findByRole('button', { name: 'Add a phone' })) + await screen.findByText('Scan this with Cambium') + const opts = relays.subscribe.mock.calls[0][2] + + opts.onevent(inviteReply(codeWith('33'.repeat(32)))) + await screen.findByText('Check your phone shows these same five words') + opts.onevent(inviteReply(codeWith('44'.repeat(32)))) + + expect(await screen.findByText(/Two phones answered this code/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'New code' })).toBeTruthy() + expect(api.enrolUnlockPhone).not.toHaveBeenCalled() + }) + + it('closes the subscription and pool on cancel', async () => { + const closer = { close: vi.fn() } + relays.subscribe.mockReturnValue(closer) + render(UnlockPhones) + await fireEvent.click(await screen.findByRole('button', { name: 'Add a phone' })) + await screen.findByText('Scan this with Cambium') + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(closer.close).toHaveBeenCalledOnce() + expect(relays.destroy).toHaveBeenCalledOnce() + expect(await screen.findByRole('button', { name: 'Add a phone' })).toBeTruthy() + }) + + it('ignores a reply with the wrong rendezvous', async () => { + render(UnlockPhones) + await fireEvent.click(await screen.findByRole('button', { name: 'Add a phone' })) + await screen.findByText('Scan this with Cambium') + const opts = relays.subscribe.mock.calls[0][2] + opts.onevent(inviteReply(codeWith('55'.repeat(32)), 'ff'.repeat(16))) + expect(screen.queryByText('Check your phone shows these same five words')).toBeNull() + }) + }) }) diff --git a/src/lib/enrol-invite.test.ts b/src/lib/enrol-invite.test.ts new file mode 100644 index 0000000..7907ca8 --- /dev/null +++ b/src/lib/enrol-invite.test.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { finalizeEvent } from 'nostr-tools/pure' +import { getConversationKey, encrypt as nip44Encrypt, decrypt as nip44Decrypt } from 'nostr-tools/nip44' +import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js' +import { + buildInviteUri, createInvite, parseInviteUri, openInviteReply, reduceInvite, + initialInviteState, zeroInviteSecret, INVITE_TTL_SECONDS, + type InviteReplyEvent, +} from './enrol-invite.js' + +const fixturesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../test/fixtures') +const vector = JSON.parse( + readFileSync(path.join(fixturesDir, 'enrol-invite-v1.json'), 'utf8'), +) as { + invSecretHex: string + invPubkeyHex: string + throwawaySecretHex: string + throwawayPubkeyHex: string + rendezvousHex: string + expiresAt: number + relays: string[] + inviteUri: string + nonceHex: string + plaintext: string + eventContent: string +} + +describe('buildInviteUri / parseInviteUri', () => { + it('round-trips a fresh invite', () => { + const uri = buildInviteUri('a1'.repeat(32), 'b2'.repeat(16), 1_700_000_600, ['wss://one.example', 'wss://two.example']) + expect(parseInviteUri(uri)).toEqual({ + pubkey: 'a1'.repeat(32), + rendezvous: 'b2'.repeat(16), + expiresAt: 1_700_000_600, + relays: ['wss://one.example', 'wss://two.example'], + }) + }) + + it('reads the shared vector', () => { + expect(parseInviteUri(vector.inviteUri)).toEqual({ + pubkey: vector.invPubkeyHex, + rendezvous: vector.rendezvousHex, + expiresAt: vector.expiresAt, + relays: vector.relays, + }) + }) + + it.each([ + ['another scheme', vector.inviteUri.replace('heartwood-unlock:', 'nostr:')], + ['another version', vector.inviteUri.replace('v=1', 'v=2')], + ['a short key', vector.inviteUri.replace(`k=${vector.invPubkeyHex}`, `k=${vector.invPubkeyHex.slice(2)}`)], + ['a short rendezvous', vector.inviteUri.replace(`r=${vector.rendezvousHex}`, `r=${vector.rendezvousHex.slice(2)}`)], + ['a non-numeric expiry', vector.inviteUri.replace(`x=${vector.expiresAt}`, 'x=soon')], + ['a repeated key', `${vector.inviteUri}&k=${vector.invPubkeyHex}`], + ['no relay', vector.inviteUri.split('&relay=')[0]], + ['a relay that is not a websocket', vector.inviteUri.replace('wss%3A%2F%2Frelay.example', 'https%3A%2F%2Frelay.example')], + ])('refuses %s', (_, uri) => { + expect(parseInviteUri(uri)).toBeNull() + }) +}) + +describe('createInvite', () => { + it('makes a keypair, a rendezvous, and an expiry INVITE_TTL_SECONDS out', () => { + const now = 1_700_000_000_000 + const invite = createInvite(['wss://relay.example'], now) + expect(invite.pubkey).toMatch(/^[0-9a-f]{64}$/) + expect(invite.rendezvous).toMatch(/^[0-9a-f]{32}$/) + expect(invite.secret).toBeInstanceOf(Uint8Array) + expect(invite.secret.length).toBe(32) + expect(invite.expiresAt).toBe(Math.floor(now / 1000) + INVITE_TTL_SECONDS) + expect(parseInviteUri(invite.uri)).toEqual({ + pubkey: invite.pubkey, + rendezvous: invite.rendezvous, + expiresAt: invite.expiresAt, + relays: ['wss://relay.example'], + }) + }) + + it('makes a different invite each time', () => { + const a = createInvite(['wss://relay.example']) + const b = createInvite(['wss://relay.example']) + expect(a.pubkey).not.toBe(b.pubkey) + expect(a.rendezvous).not.toBe(b.rendezvous) + }) +}) + +describe('zeroInviteSecret', () => { + it('overwrites the secret bytes', () => { + const invite = createInvite(['wss://relay.example']) + expect(invite.secret.some((b) => b !== 0)).toBe(true) + zeroInviteSecret(invite) + expect(invite.secret.every((b) => b === 0)).toBe(true) + zeroInviteSecret(invite) // safe to call twice + }) +}) + +function replyEvent(overrides: Partial<{ + throwawaySecret: Uint8Array + h: string + createdAt: number + content: string +}> = {}): InviteReplyEvent { + const throwawaySecret = overrides.throwawaySecret ?? hexToBytes(vector.throwawaySecretHex) + const h = overrides.h ?? vector.rendezvousHex + const createdAt = overrides.createdAt ?? vector.expiresAt - 60 + const content = overrides.content ?? vector.eventContent + return finalizeEvent({ + kind: 24137, + created_at: createdAt, + tags: [['h', h], ['expiration', String(vector.expiresAt)]], + content, + }, throwawaySecret) +} + +const inviteView = { secret: hexToBytes(vector.invSecretHex), rendezvous: vector.rendezvousHex, expiresAt: vector.expiresAt } + +describe('openInviteReply', () => { + it('opens a genuine reply, matching the shared vector plaintext', () => { + const event = replyEvent() + const code = openInviteReply(event, inviteView, (vector.expiresAt - 60) * 1000) + expect(code).toEqual({ + enrolPubkey: 'a1'.repeat(32), + rendezvous: 'b2'.repeat(16), + label: 'Pixel 8 Pro', + relays: ['wss://relay.example'], + }) + }) + + it('refuses a bad signature', () => { + const event = replyEvent() + // finalizeEvent caches its own "verified" result as a symbol property on + // the object; a plain spread copies that symbol along with everything + // else, so a JSON round trip is needed to actually drop it before the + // tampered signature is checked fresh. + const tampered = { ...JSON.parse(JSON.stringify(event)), sig: '00'.repeat(64) } + expect(openInviteReply(tampered, inviteView, (vector.expiresAt - 60) * 1000)).toBeNull() + }) + + it('refuses the wrong h', () => { + const event = replyEvent({ h: 'ff'.repeat(16) }) + expect(openInviteReply(event, inviteView, (vector.expiresAt - 60) * 1000)).toBeNull() + }) + + it('refuses an event outside the invite window', () => { + const event = replyEvent({ createdAt: vector.expiresAt - 60 }) + // "now" is past the invite's expiry, even though the event itself is not. + expect(openInviteReply(event, inviteView, (vector.expiresAt + 60) * 1000)).toBeNull() + }) + + it('refuses an event created after the invite expired', () => { + const event = replyEvent({ createdAt: vector.expiresAt + 60 }) + expect(openInviteReply(event, inviteView, (vector.expiresAt - 60) * 1000)).toBeNull() + }) + + it('refuses garbage plaintext', () => { + const throwawaySecret = hexToBytes(vector.throwawaySecretHex) + const ck = getConversationKey(throwawaySecret, vector.invPubkeyHex) + const garbage = nip44Encrypt('not an enrolment code', ck) + const event = replyEvent({ content: garbage }) + expect(openInviteReply(event, inviteView, (vector.expiresAt - 60) * 1000)).toBeNull() + }) + + it('refuses ciphertext this key cannot open', () => { + const wrongSecret = hexToBytes('33'.repeat(32)) + const ck = getConversationKey(wrongSecret, vector.invPubkeyHex) + const wrongCipher = nip44Encrypt(vector.plaintext, ck) + const event = replyEvent({ content: wrongCipher }) + expect(openInviteReply(event, inviteView, (vector.expiresAt - 60) * 1000)).toBeNull() + }) +}) + +describe('reduceInvite', () => { + const codeA = { enrolPubkey: 'a1'.repeat(32), rendezvous: 'b2'.repeat(16), label: 'Pixel', relays: ['wss://relay.example'] } + const codeB = { enrolPubkey: 'c3'.repeat(32), rendezvous: 'b2'.repeat(16), label: 'Other phone', relays: ['wss://relay.example'] } + + it('shows the first valid reply', () => { + const state = reduceInvite(initialInviteState, { type: 'reply', code: codeA }) + expect(state).toEqual({ status: 'received', code: codeA }) + }) + + it('does not abort on a repeat of the identical reply', () => { + const first = reduceInvite(initialInviteState, { type: 'reply', code: codeA }) + const second = reduceInvite(first, { type: 'reply', code: { ...codeA } }) + expect(second).toEqual({ status: 'received', code: codeA }) + }) + + it('aborts on a second reply with a different enrolment key', () => { + const first = reduceInvite(initialInviteState, { type: 'reply', code: codeA }) + const second = reduceInvite(first, { type: 'reply', code: codeB }) + expect(second).toEqual({ status: 'aborted' }) + }) + + it('expires while nothing has arrived yet', () => { + expect(reduceInvite(initialInviteState, { type: 'expire' })).toEqual({ status: 'expired' }) + }) + + it('ignores expiry once a reply is already shown', () => { + const received = reduceInvite(initialInviteState, { type: 'reply', code: codeA }) + expect(reduceInvite(received, { type: 'expire' })).toEqual(received) + }) + + it('is terminal once aborted', () => { + const aborted = { status: 'aborted' } as const + expect(reduceInvite(aborted, { type: 'reply', code: codeA })).toEqual(aborted) + expect(reduceInvite(aborted, { type: 'expire' })).toEqual(aborted) + }) + + it('is terminal once expired', () => { + const expired = { status: 'expired' } as const + expect(reduceInvite(expired, { type: 'reply', code: codeA })).toEqual(expired) + }) +}) + +describe('the shared vector', () => { + it('decrypts to the plaintext enrolment code', () => { + const ck = getConversationKey(hexToBytes(vector.invSecretHex), vector.throwawayPubkeyHex) + expect(nip44Decrypt(vector.eventContent, ck)).toBe(vector.plaintext) + }) + + it('reproduces the ciphertext byte for byte with the fixed nonce', () => { + const ck = getConversationKey(hexToBytes(vector.throwawaySecretHex), vector.invPubkeyHex) + const reproduced = nip44Encrypt(vector.plaintext, ck, hexToBytes(vector.nonceHex)) + expect(reproduced).toBe(vector.eventContent) + }) + + it('derives the same conversation key from either end', () => { + const fromInv = getConversationKey(hexToBytes(vector.invSecretHex), vector.throwawayPubkeyHex) + const fromThrowaway = getConversationKey(hexToBytes(vector.throwawaySecretHex), vector.invPubkeyHex) + expect(bytesToHex(fromInv)).toBe(bytesToHex(fromThrowaway)) + }) +}) diff --git a/src/lib/enrol-invite.ts b/src/lib/enrol-invite.ts new file mode 100644 index 0000000..331657d --- /dev/null +++ b/src/lib/enrol-invite.ts @@ -0,0 +1,197 @@ +// Enrol invite: Sapwood makes a QR, the phone (Cambium) scans it and replies +// over the relay. This reverses phone-unlock.ts's optical step: there the +// phone shows a code and Sapwood reads it (usually pasted, since a desktop +// rarely has a camera); here Sapwood shows a code and the phone reads it, +// since a phone always has one. The firmware and the board's hand-off back to +// the phone do not change: this only replaces how the phone's enrolment code +// reaches Sapwood. +// +// Flow: +// 1. Sapwood makes a one-off keypair `inv` and a rendezvous `ri`, opens the +// invite's relays, then shows a QR: heartwood-unlock:invite?v=1 +// &k=&r=&x=&relay=... Nothing in it is +// secret except that `inv`'s secret half never leaves this page and is +// zeroed once the invite is used, cancelled, or expires. +// 2. Cambium scans it, builds its usual enrolment code exactly as today, +// and publishes ONE kind-24137 event from a throwaway key that is +// dropped after signing: tags [["h", ri], ["expiration", x]], content = +// NIP-44 v2 encrypt(ECDH(throwaway, inv pubkey)) of the enrolment code +// string (`heartwood-unlock:enrol?v=1&p=...&r=...&label=...&relay=...`). +// 3. Sapwood decrypts each matching reply with `inv`'s secret, verifies +// the signature and the invite window, and shows the FIRST valid one. +// A second valid reply with a different enrolment key (`p`) aborts: +// more than one phone answered, and nobody can tell which one the owner +// meant. A repeat of the identical reply (Cambium's own retry) does not. +// 4. The owner compares the five request words (existing `requestWords`, +// derived from the phone's enrolment key) against the phone's own +// screen, then continues into the existing `enrolPhone` path unchanged: +// board button, hand-off, check code. +// +// Kind 24137 is shared with the hand-off in phone-unlock.ts, but an invite +// reply and a hand-off never share an `h` value, so a relay cannot mistake +// one for the other. + +import { generateSecretKey, getPublicKey, verifyEvent, type Event as NostrEvent } from 'nostr-tools/pure' +import { getConversationKey, decrypt as nip44Decrypt } from 'nostr-tools/nip44' +import { bytesToHex } from '@noble/hashes/utils.js' +import { parseEnrolmentCode, type EnrolmentCode } from './phone-unlock.js' + +/** How long an invite stays live, in seconds. */ +export const INVITE_TTL_SECONDS = 600 + +const INVITE_PREFIX = 'heartwood-unlock:invite?' +const HEX64 = /^[0-9a-f]{64}$/ +const HEX32 = /^[0-9a-f]{32}$/ + +function isRelayUrl(url: string): boolean { + return /^wss?:\/\/\S+$/.test(url) && url.length <= 256 +} + +/** An invite Sapwood is holding open, waiting for a phone to answer. */ +export interface Invite { + uri: string + /** The one-off secret half. Never sent anywhere; zero it with + * `zeroInviteSecret` once the invite is used, cancelled, or expires. */ + secret: Uint8Array + pubkey: string + rendezvous: string + /** Unix seconds. */ + expiresAt: number +} + +/** What the QR carries: everything but the secret half of `inv`. */ +export interface InviteUri { + pubkey: string + rendezvous: string + expiresAt: number + relays: string[] +} + +/** The QR text Sapwood shows: `heartwood-unlock:invite?v=1&k=...&r=...&x=...&relay=...`. */ +export function buildInviteUri(pubkey: string, rendezvous: string, expiresAt: number, relays: string[]): string { + const params = [`v=1`, `k=${pubkey}`, `r=${rendezvous}`, `x=${expiresAt}`] + for (const relay of relays) params.push(`relay=${encodeURIComponent(relay)}`) + return `${INVITE_PREFIX}${params.join('&')}` +} + +/** + * Make a fresh invite: a one-off keypair, a rendezvous, and an expiry + * `INVITE_TTL_SECONDS` from `now`. The secret stays only in the returned + * object; nothing here stores or transmits it. + */ +export function createInvite(relays: string[], now: number = Date.now()): Invite { + const secret = generateSecretKey() + const pubkey = getPublicKey(secret) + const rendezvous = bytesToHex(crypto.getRandomValues(new Uint8Array(16))) + const expiresAt = Math.floor(now / 1000) + INVITE_TTL_SECONDS + const uri = buildInviteUri(pubkey, rendezvous, expiresAt, relays) + return { uri, secret, pubkey, rendezvous, expiresAt } +} + +/** Zero the invite's secret half in place. Safe to call more than once. */ +export function zeroInviteSecret(invite: Pick): void { + invite.secret.fill(0) +} + +/** + * Parse the QR Sapwood shows, for round-trip tests and Cambium-side parity. + * Returns null for anything that is not a complete, well-formed v1 invite; a + * repeated parameter (other than relay) is refused rather than guessed at. + */ +export function parseInviteUri(input: string): InviteUri | null { + const text = typeof input === 'string' ? input.trim() : '' + if (!text.startsWith(INVITE_PREFIX)) return null + const params: [string, string][] = [] + for (const pair of text.slice(INVITE_PREFIX.length).split('&')) { + const eq = pair.indexOf('=') + if (eq <= 0) continue + try { + params.push([pair.slice(0, eq), decodeURIComponent(pair.slice(eq + 1).replace(/\+/g, ' '))]) + } catch { + return null + } + } + const one = (name: string): string | null => { + const found = params.filter(([key]) => key === name) + return found.length === 1 ? found[0][1] : null + } + if (one('v') !== '1') return null + const pubkey = one('k') + const rendezvous = one('r') + const xRaw = one('x') + if (!pubkey || !HEX64.test(pubkey) || !rendezvous || !HEX32.test(rendezvous)) return null + if (!xRaw || !/^\d+$/.test(xRaw)) return null + const expiresAt = Number(xRaw) + if (!Number.isSafeInteger(expiresAt)) return null + const relays = params.filter(([key]) => key === 'relay').map(([, value]) => value) + if (!relays.length || !relays.every(isRelayUrl)) return null + return { pubkey, rendezvous, expiresAt, relays: [...new Set(relays)] } +} + +/** The shape of a kind-24137 invite reply, exactly what nostr-tools hands + * `onevent` (or a raw JSON event read back from a relay). */ +export type InviteReplyEvent = Pick + +/** + * Try to read one reply to `invite`. Returns null for anything that does not + * check out: a bad signature, a rendezvous that does not match, an event + * outside the invite's window, ciphertext this key cannot open, or plaintext + * that is not a valid enrolment code. Never throws: a relay can carry + * anything, forged or malformed. + */ +export function openInviteReply( + event: InviteReplyEvent, + invite: Pick, + now: number = Date.now(), +): EnrolmentCode | null { + try { + if (!verifyEvent(event as NostrEvent)) return null + } catch { + return null + } + const h = event.tags.find((tag) => tag[0] === 'h')?.[1] + if (h !== invite.rendezvous) return null + const nowSeconds = Math.floor(now / 1000) + if (nowSeconds > invite.expiresAt || event.created_at > invite.expiresAt) return null + let plaintext: string + try { + const conversationKey = getConversationKey(invite.secret, event.pubkey) + plaintext = nip44Decrypt(event.content, conversationKey) + } catch { + return null + } + return parseEnrolmentCode(plaintext) +} + +/** The collector's state while an invite is live. Terminal once aborted or + * expired: nothing resurrects it, a fresh invite is required instead. */ +export type InviteCollectorState = + | { status: 'waiting' } + | { status: 'received'; code: EnrolmentCode } + | { status: 'aborted' } + | { status: 'expired' } + +export type InviteCollectorEvent = + | { type: 'reply'; code: EnrolmentCode } + | { type: 'expire' } + +export const initialInviteState: InviteCollectorState = { status: 'waiting' } + +/** + * Pure reducer for the collector: the FIRST valid reply is shown to the + * owner. Cambium may resend the identical reply (its own retry after a relay + * refused the first publish) without harm. A SECOND valid reply carrying a + * different enrolment key means more than one phone answered the same code, + * so it aborts rather than guess which one the owner meant. Expiry only + * matters while nothing has arrived yet: once a reply is shown, the owner's + * confirm step (or cancel) decides what happens next, not the clock. + */ +export function reduceInvite(state: InviteCollectorState, event: InviteCollectorEvent): InviteCollectorState { + if (state.status === 'aborted' || state.status === 'expired') return state + if (event.type === 'expire') { + return state.status === 'waiting' ? { status: 'expired' } : state + } + if (state.status === 'waiting') return { status: 'received', code: event.code } + if (state.code.enrolPubkey === event.code.enrolPubkey) return state + return { status: 'aborted' } +} diff --git a/src/lib/phone-unlock.ts b/src/lib/phone-unlock.ts index 0cd5818..4ce60af 100644 --- a/src/lib/phone-unlock.ts +++ b/src/lib/phone-unlock.ts @@ -1,8 +1,9 @@ // Phones that can unlock the signer after a restart: the Sapwood side of // heartwood-esp32's phone-unlock design (firmware 0.18.0-beta.17, frame 0x64) -// and Cambium's enrolment screen. +// and Cambium's enrolment screen. Two ways for the phone's enrolment code to +// reach this page, both ending at the same `add()` in UnlockPhones.svelte: // -// Enrolment, end to end: +// Phone-shows-a-code (paste or scan the phone's screen), end to end: // 1. Cambium shows a code: heartwood-unlock:enrol?v=1&p= // &r=&label=&relay=... Nothing in it is // secret. It waits on those relays for the answer. @@ -17,8 +18,23 @@ // spoken-token from the board's one-off hand-off key. A mismatch means // someone else answered the phone first. // +// Sapwood-shows-a-code (the invite, default path, `enrol-invite.ts`), reverses +// the optical step for a desktop with no camera: +// 1. Sapwood makes a one-off invite keypair and rendezvous, and shows a QR +// of heartwood-unlock:invite?v=1&k=&r= +// &x=&relay=... +// 2. Cambium scans it, builds the same enrolment code as above, and +// publishes it as ONE kind-24137 event from a throwaway key: tagged +// ["h", rendezvous] (the invite's, never the hand-off's), content is that +// enrolment code string, NIP-44 encrypted to the invite's public key. +// 3. Sapwood decrypts the first valid reply (`openInviteReply`) and shows +// the phone's five request words for the owner to compare, then feeds +// the resulting `EnrolmentCode` into the exact same `enrolPhone` as +// above: from here on both paths are identical. +// // Nothing here names the phone on the wire: the hand-off author is thrown -// away, and the only tag is the one-off rendezvous value the phone chose. +// away, and the only tag on either kind-24137 event is a one-off rendezvous +// value, chosen by whichever side is waiting for the answer. import { deriveToken } from 'spoken-token' import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure' diff --git a/test/fixtures/enrol-invite-v1.json b/test/fixtures/enrol-invite-v1.json new file mode 100644 index 0000000..0475228 --- /dev/null +++ b/test/fixtures/enrol-invite-v1.json @@ -0,0 +1,16 @@ +{ + "v": 1, + "invSecretHex": "1111111111111111111111111111111111111111111111111111111111111111", + "invPubkeyHex": "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa", + "throwawaySecretHex": "2222222222222222222222222222222222222222222222222222222222222222", + "throwawayPubkeyHex": "466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27", + "rendezvousHex": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "expiresAt": 1700000600, + "relays": [ + "wss://relay.example" + ], + "inviteUri": "heartwood-unlock:invite?v=1&k=4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa&r=c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3&x=1700000600&relay=wss%3A%2F%2Frelay.example", + "nonceHex": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "plaintext": "heartwood-unlock:enrol?v=1&p=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1&r=b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2&label=Pixel+8+Pro&relay=wss%3A%2F%2Frelay.example", + "eventContent": "Au7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u0TDw30nArD+b9g3ZhAyoBhzPzJhbrpXUgEX7lQdBe8cqghOn4bOanto8zvp2HlLfZzo4jVDcOM5E34V9nnuK4E3EtVmdF4sSunx+7hzokB2LSJtLywBY/tbQxJm7L+Qo27pLG5dqFJCMUhf6HxlAlnGaB1VQqi6osw6+UoPIg4GqPFlikxyGYHOEeWbbe2TE9LtYRUPnEqm174W0kzYr3Z5Lhbv/wJnQZ317DqPIDQyU1Ywzh7g3AaH0u8K5cMJITFHDa/XguyZYWyNqTTCWbHjo36ho/AGqUPooZ9PGHuxfsw==" +}