From 8cc6cb5b30c316dea10d9d87d76423436e775a1b Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 26 Sep 2026 10:16:00 +0100 Subject: [PATCH 1/4] fix: order pre-release firmware versions below their own release version.ts dropped pre-release suffixes entirely, so a signer on 0.18.0-beta.17 was told "Up to date" against a bundled 0.18.0-beta.19: the page stated something false about the owner's own beta channel. Compare pre-release identifiers per SemVer 2.0 section 11 instead, so beta.17 < beta.19 < 0.18.0. --- src/lib/version.test.ts | 23 ++++++++++++++-- src/lib/version.ts | 60 +++++++++++++++++++++++++++++++++-------- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts index c59bb46..5fd3a35 100644 --- a/src/lib/version.test.ts +++ b/src/lib/version.test.ts @@ -16,11 +16,18 @@ describe('compareVersions', () => { expect(compareVersions('v0.14.0', '0.14.0')).toBe(0) }) - it('ignores a pre-release suffix for ordering', () => { - expect(compareVersions('0.14.0-rc1', '0.14.0')).toBe(0) + it('orders a pre-release below its own release (SemVer 2.0 §11)', () => { + expect(compareVersions('0.14.0-rc1', '0.14.0')).toBeLessThan(0) + expect(compareVersions('0.14.0', '0.14.0-rc1')).toBeGreaterThan(0) expect(compareVersions('0.14.0-rc1', '0.13.9')).toBeGreaterThan(0) }) + it('orders pre-release identifiers numerically, not lexically', () => { + expect(compareVersions('0.18.0-beta.17', '0.18.0-beta.19')).toBeLessThan(0) + expect(compareVersions('0.18.0-beta.9', '0.18.0-beta.10')).toBeLessThan(0) + expect(compareVersions('0.18.0-beta.19', '0.18.0-beta.19')).toBe(0) + }) + it('returns null rather than guessing at unparseable input', () => { expect(compareVersions('', '0.14.0')).toBeNull() expect(compareVersions('unknown', '0.14.0')).toBeNull() @@ -48,4 +55,16 @@ describe('isUpgrade', () => { expect(isUpgrade('0.14.0', null)).toBe(false) expect(isUpgrade('unknown', '0.14.0')).toBe(false) }) + + it('treats a newer beta as an upgrade over an older beta on the same release', () => { + expect(isUpgrade('0.18.0-beta.17', '0.18.0-beta.19')).toBe(true) + }) + + it('treats the final release as an upgrade over its own beta', () => { + expect(isUpgrade('0.18.0-beta.19', '0.18.0')).toBe(true) + }) + + it('does not offer a beta as an upgrade over its own final release', () => { + expect(isUpgrade('0.18.0', '0.18.0-beta.19')).toBe(false) + }) }) diff --git a/src/lib/version.ts b/src/lib/version.ts index 8cbf06d..3935d84 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -7,16 +7,48 @@ // so silently reverts whatever the newer build fixed. Compare properly and only // offer a genuine upgrade. -/** Parse "0.14.0" (or "v0.14.0", or "0.14.0-rc1") into comparable parts, or +interface ParsedVersion { + core: number[] + /** Dot-separated pre-release identifiers, or null when there is no pre-release. */ + prerelease: string[] | null +} + +/** Parse "0.14.0" (or "v0.14.0", or "0.14.0-beta.17") into comparable parts, or * null when it is not a version we can reason about. */ -function parse(version: string): number[] | null { +function parse(version: string): ParsedVersion | null { const cleaned = version.trim().replace(/^v/i, '') - // Pre-release suffixes are dropped for ordering: the numeric release is what - // the manifest and the device agree on, and treating "0.14.0-rc1" as 0.14.0 - // is closer to right than refusing to compare at all. - const core = cleaned.split(/[-+]/)[0] ?? '' - if (!/^\d+(\.\d+)*$/.test(core)) return null - return core.split('.').map(Number) + const [core, ...rest] = cleaned.split('-') + if (!core || !/^\d+(\.\d+)*$/.test(core)) return null + // A build-metadata suffix (+...) plays no part in precedence (SemVer 2.0 §10). + const prereleaseRaw = rest.length > 0 ? rest.join('-').split('+')[0] : null + return { + core: core.split('.').map(Number), + prerelease: prereleaseRaw ? prereleaseRaw.split('.') : null, + } +} + +/** Compare a single pair of pre-release identifiers per SemVer 2.0 §11.4. */ +function compareIdentifier(a: string, b: string): number { + const aNumeric = /^\d+$/.test(a) + const bNumeric = /^\d+$/.test(b) + if (aNumeric && bNumeric) return Number(a) - Number(b) + // Numeric identifiers always have lower precedence than alphanumeric ones. + if (aNumeric) return -1 + if (bNumeric) return 1 + if (a === b) return 0 + return a < b ? -1 : 1 +} + +/** Compare two pre-release identifier lists per SemVer 2.0 §11.4. */ +function comparePrerelease(a: string[], b: string[]): number { + const len = Math.max(a.length, b.length) + for (let i = 0; i < len; i++) { + if (a[i] === undefined) return -1 // fewer fields: lower precedence + if (b[i] === undefined) return 1 + const cmp = compareIdentifier(a[i], b[i]) + if (cmp !== 0) return cmp + } + return 0 } /** @@ -24,18 +56,24 @@ function parse(version: string): number[] | null { * than `b`, zero when they are equal, positive when `a` is newer. Returns null * when either side is unparseable, so callers can decline to act rather than * guess. + * + * A pre-release has lower precedence than its release (SemVer 2.0 §11): + * `0.18.0-beta.17` < `0.18.0-beta.19` < `0.18.0`. */ export function compareVersions(a: string, b: string): number | null { const pa = parse(a) const pb = parse(b) if (!pa || !pb) return null - const len = Math.max(pa.length, pb.length) + const len = Math.max(pa.core.length, pb.core.length) for (let i = 0; i < len; i++) { // "0.14" and "0.14.0" are the same version. - const diff = (pa[i] ?? 0) - (pb[i] ?? 0) + const diff = (pa.core[i] ?? 0) - (pb.core[i] ?? 0) if (diff !== 0) return diff } - return 0 + if (pa.prerelease === null && pb.prerelease === null) return 0 + if (pa.prerelease === null) return 1 // a is a release, b is a pre-release: a is newer + if (pb.prerelease === null) return -1 + return comparePrerelease(pa.prerelease, pb.prerelease) } /** From 9580d8b7d1a7f31e0db299128ac18c64e8af840f Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Sat, 26 Sep 2026 10:16:16 +0100 Subject: [PATCH 2/4] refactor: redesign the Device tab around one question and one action The tab used to show every concern twice (a "Your signer" summary list, then collapsible sections repeating the same names), split encryption controls across two sections that each pointed at the other, hid the five compare words behind "Waiting..." during the phone-unlock bench flow, and left most controls under the 44px touch target on a phone. Restructure around "is my signer all right, and if not, what do I do?": a Needs-attention block (present only when something needs a decision) above one grouped section list in a fixed order (After a power cut, Firmware, Backup, Network, Display and light, Diagnostics, Bridge), with Security folded into After a power cut next to the mode it changes and the bridge secret moved into Network. Keep every handler and device call unchanged. For the phone-unlock bench flow (UnlockPhones.svelte): keep the five words on screen throughout, including while waiting on the signer's button; fix the word-wrap that could split a word across lines; add a heading and a live region so step changes announce themselves and move focus; add a "Codes don't match: revoke" button on the done step, wired to the existing revoke(). Add WordPairs.svelte (the five-word compare, shared by the confirm and working steps so the words never move) and TogglePair.svelte (a shared pressed/unpressed pattern with aria-pressed for the Upright/Flipped, Detailed/Quiet and On/Off pairs). Scope new 44px touch targets to the Device tab only, so other tabs are unaffected. Fix the ambiguous numeric dates, the checkbox text with no space, an em dash in VaultUnlock's relay copy, and the Firmware row showing green for an unknown version. --- e2e/admin-home.spec.ts | 10 +- src/app.css | 1 + src/components/DevicePanel.svelte | 1198 +++++++++++++++------------ src/components/DevicePanel.test.ts | 7 +- src/components/PhoneHandoff.svelte | 3 +- src/components/TogglePair.svelte | 50 ++ src/components/UnlockPhones.svelte | 347 +++++--- src/components/UnlockPhones.test.ts | 39 +- src/components/VaultUnlock.svelte | 12 +- src/components/VaultUnlock.test.ts | 2 +- src/components/WordPairs.svelte | 34 + src/lib/device-summary.test.ts | 207 +++-- src/lib/device-summary.ts | 186 +++-- 13 files changed, 1225 insertions(+), 871 deletions(-) create mode 100644 src/components/TogglePair.svelte create mode 100644 src/components/WordPairs.svelte diff --git a/e2e/admin-home.spec.ts b/e2e/admin-home.spec.ts index d2e1de5..5842e5d 100644 --- a/e2e/admin-home.spec.ts +++ b/e2e/admin-home.spec.ts @@ -243,17 +243,17 @@ test.describe('mobile', () => { await advancedToggle(page).click() await page.getByRole('button', { name: 'Device', exact: true }).click() - // Network and Security are collapsible, closed by default; open both. + // Network and After a power cut are collapsible, closed by default; open both. await page.getByText('Network', { exact: true }).click() await expect(page.getByLabel('WiFi SSID')).toBeVisible() await expect(page.getByLabel('WiFi password')).toBeVisible() await expect(page.getByRole('button', { name: 'Save to device', exact: true })).toBeVisible() - await page.getByText('Security', { exact: true }).click() - await expect(page.getByRole('heading', { name: 'Boot PIN', exact: true })).toBeVisible() - const unattendedWarning = page.getByText(/A boot PIN is typed over the USB cable/) + await page.getByText('After a power cut', { exact: true }).click() + await page.locator('summary', { hasText: 'Boot PIN' }).click() + const unattendedWarning = page.getByText(/a signer with a boot PIN stays locked/) await expect(unattendedWarning).toBeVisible() - await expect(unattendedWarning).toContainText('signing and remote management cannot resume') + await expect(unattendedWarning).toContainText('Signing and remote management stop') await expect(unattendedWarning).toContainText('use a vault key with phone unlock instead') const overflow = await page.evaluate( diff --git a/src/app.css b/src/app.css index 818920b..09744ad 100644 --- a/src/app.css +++ b/src/app.css @@ -145,6 +145,7 @@ body { } .field-input::placeholder { color: #555; } .field-input:focus { outline: none; border-color: var(--green-dim); } +.field-input:focus-visible { outline: 2px solid var(--green); outline-offset: 1px; } .field-input:disabled { opacity: 0.4; } select.field-input { cursor: pointer; } .field-hint { font-size: 0.8rem; color: var(--text-dim); line-height: 1.5; } diff --git a/src/components/DevicePanel.svelte b/src/components/DevicePanel.svelte index 256ae51..6d22953 100644 --- a/src/components/DevicePanel.svelte +++ b/src/components/DevicePanel.svelte @@ -1,14 +1,17 @@
- {#if pairingBackup.needsBackup} -
-

Pairing backup required

-

- {#if pairingBackup.lastExportAt} - App pairings changed after this browser's recorded encrypted backup ({backupTime(pairingBackup.lastExportAt)}). - {:else} - App pairings changed and this browser has not recorded a completed encrypted pairing backup. - {/if} - A reset or reflash would make the affected apps pair again. {#if overUsb}Export a fresh backup below and keep its passphrase separately.{:else}Connect this signer by USB to export a fresh backup; the signer will ask for a physical button confirmation.{/if} -

-
- {/if} + - -
-

Your signer

-
- {#each summaryRows as row (row.id)} -
- + {#if attention.length} +
+ {#each attention as row, i (row.id)} +
+
- {row.label} - - {row.text} + {row.label} +
+

{row.text}

{#if row.actionLabel} - + {/if}
{/each} -
-
- - -
- Firmware{updateInfo?.upgrade ? 'Update available' : 'Up to date'} - -
+ + {/if} - - {#if overUsb || device.mode === 'relay'} -
- After a power cut{powerCutStateWord} -

How this signer recovers its keys after a restart. Pick the option that suits - where it lives.

-
- {#each MODES as mode (mode.id)} -
-
- {mode.name} - {#if currentMode === mode.id}current{/if} -
-

After a power cut: {mode.after}

-

If someone takes the board: {mode.taken}

- {#if currentMode !== mode.id && chosenMode !== mode.id} - - {/if} - {#if chosenMode === mode.id && currentMode !== mode.id} -
- {#if mode.id === 'phone'} - {#if currentMode === null} -

Phone unlock needs encryption on. Turn on Encrypt at rest - (Security{overUsb ? ', below' : ', over the USB cable'}), then add a phone - below over the cable. Add two if you can, so one lost abroad does not leave - the signer locked.

- {:else} -

Add a phone below{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.

- {/if} - {:else if mode.id === 'sapwood'} - {#if (effectivePhoneCount ?? 0) > 0} -

Revoke each phone below. Encryption stays on.

- {:else} -

Turn on Encrypt at rest (Security{overUsb ? ', below' : ', over the USB cable'}), or set a boot PIN.

- {/if} - {:else} - {#if !overUsb} -

Turning encryption off needs the signer on the USB cable.

- {:else} - - {#if vaultStored} - - {#if vaultStatus}

{vaultStatus}

{/if} - {:else} -

This browser holds no vault key for the signer. If it has a - boot PIN, clear the PIN in Security, below.

{/if} +
+

After a power cut: {mode.after}

+

If someone takes the board: {mode.taken}

+ {#if chosenMode === mode.id && currentMode !== mode.id} +
+ {#if mode.id === 'phone'} + {#if currentMode === null} +

Phone unlock needs encryption on. + {#if overUsb}Turn it on under Vault key, below,{:else}Connect by USB to turn encryption on, then{/if} + then add a phone over the cable. Add two if you can, so losing one does + not leave the signer locked.

+ {:else} +

Add a phone above{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.

+ {/if} + {:else if mode.id === 'sapwood'} + {#if (effectivePhoneCount ?? 0) > 0} +

Revoke each phone above. Encryption stays on.

+ {:else} +

Turn on encryption under Vault key, below, or set a boot PIN.

+ {/if} + {:else} + {#if !overUsb} +

Turning encryption off needs the signer on the USB cable.

+ {:else} + + {#if vaultStored} + + {#if vaultStatus}

{vaultStatus}

{/if} + {:else} +

This browser holds no vault key for the signer. If it + has a boot PIN, clear the PIN below.

+ {/if} + {/if} + {/if} + +
+ {/if} +
+ {/each} +
+ {#if currentMode === null} + {#if atRestUnrecognised} +

Your signer reported a setting this version of Sapwood doesn't + know. Update Sapwood.

+ {:else if overUsb} +

This browser holds no vault key for the signer, and the signer + does not say whether it has a boot PIN. With neither, it runs without encryption.

+ {:else} +

Over WiFi, Sapwood can tell only from the phones listed to the + side. Connect by USB to see whether this browser holds the signer's vault key.

+ {/if} + {/if} +
+ Why is there no automatic phone unlock? +

Anyone holding the board can make it ask for its key, and a phone + that answered by itself would hand it over: no safer than no encryption, while + looking safer.

+
+ + {#if !overUsb} +

Vault key

+

The boot PIN and vault key are changed over USB. Plug the signer into + this computer and connect by cable.

+ {:else} +

Vault key

+

Encrypts the keys stored on the signer. The key lives in this browser, + never on the signer, so a stolen signer yields only ciphertext.

+ {#if vaultLocked} +

This signer is locked. Unlock it above before changing + encryption.

+ {:else if !vaultDeviceKey} +

Add an identity to the signer first.

+ {:else if vaultEscrowKey} +
+

Store this vault key somewhere safe outside this browser, + such as a password manager or on paper, before you seal the signer. Without it, + a sealed signer cannot be unlocked.

+
{vaultEscrowKey}
+
+ + +
+ +
+ + +
+
+ {:else if vaultStored} +

This browser holds a vault key for this signer.

+
+

Store this somewhere safe off-site (e.g. password manager). It + unlocks your signer if this browser's storage is lost.

+ {#if vaultShowKey} +
{vaultStored}
{/if} +
+ + + +
+
+ + {:else} + +
+ Restore a vault key saved elsewhere +

Paste a vault key you escrowed from another browser so this one + can unlock the signer too.

+
+ + +
+
+ {/if} + {#if vaultStatus}

{vaultStatus}

{/if} + +
+ Boot PIN +

After any restart, a signer with a boot PIN stays locked until + someone types the PIN over the USB cable. Signing and remote management stop, and + it refuses remote network changes. For a signer you leave running elsewhere, use a + vault key with phone unlock instead.

+
+ +
+
+ { clearPinAck = false; clearPinConfirming = false }} + placeholder="4 to 8 digits" + maxlength="8" + disabled={pinPending} + /> + +
+ + +
+

4 to 8 digits

+
+ {#if clearPinConfirming} + +
+ + +
{/if} - -
+ {#if pinStatus}

{pinStatus}

{/if} + {/if} - {/each} - - {#if currentMode === null} - {#if atRestUnrecognised} -

Your signer reported a setting this version of Sapwood doesn't know. - Update Sapwood.

- {:else if overUsb} -

This browser holds no vault key for the signer, and the signer does not - say whether it has a boot PIN. With neither, it runs without encryption.

- {:else} -

Over WiFi, Sapwood can tell only from the phones listed below. Connect - by USB to see whether this browser holds the signer's vault key.

- {/if} - {/if} -
- Why is there no automatic phone unlock? -

Anyone holding the board can make it ask for its key, and a phone that - answered by itself would hand it over: no safer than no encryption, while looking safer.

+
-
- -
+ {/if} + + +
+ +

Firmware

+ {firmwareStateWord} +
+
- {/if} - -
- Security{securityStateWord} - {#if !overUsb} -

The boot PIN, bridge secret and encryption at rest are changed over USB. - Plug the signer into this computer and connect by cable.

+ + {#if overUsb} +
+ +

Backup

+ {backupStateWord} +
+ +
{:else} -

Boot PIN

-

Locks the device at boot. It must be unlocked before it signs anything. - The device asks for its button to confirm.

-

A boot PIN is typed over the USB cable, so after any reboot or power cut - a signer in another location stays locked until someone reaches it: signing and remote - management cannot resume, and the signer refuses remote network activation in this mode. - For an unattended signer, use a vault key with phone unlock instead (After a power cut, - above).

-
-
- { clearPinAck = false; clearPinConfirming = false }} - placeholder="4–8 digits (empty to clear)" - maxlength="8" - disabled={pinPending} - /> - -
- {#if pinValue || !clearPinConfirming} - - {/if} -
- {#if !pinValue && clearPinConfirming} - -
- - -
- {/if} - {#if pinStatus}

{pinStatus}

{/if} - -

Encrypt at rest

-

Encrypts the signer's stored keys with a vault key held by this browser, never - by the device: a stolen device yields only ciphertext.

- {#if vaultLocked} -

This signer is locked. Unlock it from the banner on Home before - changing encryption.

- {:else if !vaultDeviceKey} -

Add an identity to the signer first.

- {:else if vaultEscrowKey} -
-

Your vault key: store it somewhere safe off this browser - (password manager, printed) before the signer is sealed. Without it, - a sealed signer cannot be unlocked.

-
{vaultEscrowKey}
-
- - -
- -
- - -
-
- {:else if vaultStored} -

This browser holds a vault key for this signer.

-
-

Store this somewhere safe off-site (e.g. password manager). It - unlocks your signer if this browser's storage is lost.

- {#if vaultShowKey} -
{vaultStored}
- {/if} -
- - - -
-
- - {:else} - -
- Restore a vault key saved elsewhere -

Paste a vault key you escrowed from another browser so this one can - unlock the signer too.

-
- - -
-
- {/if} - {#if vaultStatus}

{vaultStatus}

{/if} - -
- Advanced: bridge secret -

Shared secret for bridge authentication (device-decrypts mode). Needs the - button; cannot be set while a bridge session is active.

-
-
- - -
- -
- {#if secretStatus}

{secretStatus}

{/if} +
+ +

Backup

+ {backupStateWord} +
+

App pairings and the bridge secret are backed up over USB. The signer asks + for a button press.

{/if} -
- - {#if overUsb} -
- Backup{backupStateWord} - + +
+ +

Network

+ {networkStateWord} +
+ + {#if overUsb} +
+ Advanced: bridge secret +

Shared secret for bridge authentication (device-decrypts mode). Needs the + button; cannot be set while a bridge session is active.

+
+ +
+
+ + +
+ +
+

64 hexadecimal characters

+
+ {#if secretStatus}

{secretStatus}

{/if} +
+ {/if}
- {/if} - -
- Network{networkStateWord} - -
- - -
- Display and light{displayStateWord} -

Screen orientation and the activity light's log detail.

- {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} -
- Activity light and log detail -
- - + +
+ +

Display and light

+ {displayStateWord} +
+

Screen orientation and the activity light's log detail.

+ {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} +
+ Activity light and log detail + setLogQuiet(!detailed)} + /> +

The signer's blue activity light flashes with its log output. Quiet keeps + warnings only, so the light stays dark in normal use.

-

The signer's blue activity light flashes with its log output. Quiet keeps - warnings only, so the light stays dark in normal use.

-
- {/if} - {#if screenFlip !== null} -
- Screen orientation -
- - + {/if} + {#if screenFlip !== null} +
+ Screen orientation + turnScreen(!upright)} + /> +

Turn the picture round if the signer sits the other way up, for the + other hand or a case. The button labels on its cards move with it. On the signer itself: hold its + button on the DEVICE page.

-

Turn the picture round if the signer sits the other way up, for the - other hand or a case. The button labels on its cards move with it. On the signer itself: hold its - button on the DEVICE page.

-
- {/if} -
+ {/if} +
- -
- Diagnostics{diagnosticsStateWord} -

Live numbers from the signer: connection, memory and storage.

- - - - - - {#if typeof health.uptime_s === 'number'} - + +
+ +

Diagnostics

+ {diagnosticsStateWord} +
+

Live numbers from the signer.

+
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
+ + + + + {#if typeof health.uptime_s === 'number'} + + {/if} + {#if lastReset} + + {/if} + {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} + + + + + {/if} + {#if typeof maxSignBytes === 'number'} + + {/if} + {#if storage} + + + + + {/if} +
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text}
Free memory + {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} +
Max signed message + {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} + · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} +
Identity and app storage + + {storage.label} +
+ {#if storage?.state === 'warn'} +

The signer's storage is filling up. Identities, personas, app pairings + and settings share it. Removing an unused persona or app pairing frees space.

{/if} - {#if lastReset} - Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text} + {#if storage?.state === 'full'} +

The signer's storage is nearly full. It will refuse new personas before + app pairings stop working, so remove an unused persona or app pairing now.

{/if} - {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} - - Free memory - - {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} - - + {#if lastReset?.crash} +

The signer's last restart was not planned.{#if health.crashed_during} It crashed while + handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log + below restarts empty each boot.

{/if} - {#if typeof maxSignBytes === 'number'} - Max signed message - {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} - · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} - + {#if fragmented} +
+ Why is the memory fragmented? +

The signer's largest free block is small relative to total free. + This can happen after a burst of decryptions; it clears on the next restart. Newer firmware + frees the TLS buffers between messages to avoid it.

+
{/if} - {#if storage} - - Identity & app storage - - - {storage.label} - - + {#if trimmed} +

The signer trimmed this status to its vital fields because its memory + was too fragmented to send the full report. The request log paused this poll instead of the signer + crashing, and resumes once the memory recovers.

{/if} - - {#if storage?.state === 'warn'} -

The signer's storage is filling up. Identities, personas, app pairings - and settings share it. Removing an unused persona or app pairing frees space.

- {/if} - {#if storage?.state === 'full'} -

The signer's storage is nearly full. It will refuse new personas before - app pairings stop working, so remove an unused persona or app pairing now.

- {/if} - {#if lastReset?.crash} -

The signer's last restart was not planned.{#if health.crashed_during} It crashed while - handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log - below restarts empty each boot.

- {/if} - {#if fragmented} -
- Why is the memory fragmented? -

The signer's largest free block is small relative to total free. - This can happen after a burst of decryptions; it clears on the next restart. Newer firmware - frees the TLS buffers between messages to avoid it.

-
- {/if} - {#if trimmed} -

The signer trimmed this status to its vital fields because its memory - was too fragmented to send the full report. The request log paused this poll instead of the signer - crashing, and resumes once the memory recovers.

- {/if} - {#if recoveryReason} -
- Why did the signer restart itself? -

Its relay service was unusable for several minutes - ({recoveryReason}), usually memory too fragmented to place TLS or publish - buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if - this repeats often, the memory readings above tell the story.

+ {#if recoveryReason} +
+ Why did the signer restart itself? +

Its relay service was unusable for several minutes + ({recoveryReason}), usually memory too fragmented to place TLS or publish + buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if + this repeats often, the memory readings above tell the story.

+
+ {/if} +
+ + + {#if overBridge && device.bridgeInfo} +
+ +

Bridge

+ {bridgeStateWord} +
+ + + + + + + +
Mode{device.bridgeInfo.mode}
Uptime{formatUptime(device.bridgeInfo.uptime_secs as number)}
Relays + {#each (device.bridgeInfo.relays as string[]) as relay} +
{relay}
+ {/each} +
+

The bridge holds the USB port. Stop it to connect directly over USB + (then reload Sapwood and connect by cable).

+
+ + +
{/if} -
- - - {#if overBridge && device.bridgeInfo} -
- Bridge{bridgeStateWord} - - - - - - - -
Mode{device.bridgeInfo.mode}
Uptime{formatUptime(device.bridgeInfo.uptime_secs as number)}
Relays - {#each (device.bridgeInfo.relays as string[]) as relay} -
{relay}
- {/each} -
-

The bridge holds the USB port. Stop it to connect directly over USB - (then reload Sapwood and connect by cable).

-
- - -
-
- {/if} +

Flash another board: install or re-flash firmware in the guided @@ -1155,50 +1216,71 @@ .log-quiet { margin-top: 0.9rem; } .lq-label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.4rem; } - .lq-buttons { display: flex; gap: 0.5rem; margin-bottom: 0.4rem; } - .lq-on { border-color: var(--green-dim); color: var(--green); background: #08130d; } .device-panel { - display: flex; flex-direction: column; gap: 1.75rem; + display: flex; flex-direction: column; gap: 1.5rem; /* Room above the docked mobile tab bar so the last section (Danger zone) is never hidden behind it. */ padding-bottom: 2rem; } - .pairing-backup-warning { border-color: var(--amber); background: #1a1508; } - .pairing-backup-warning .section-title { color: var(--amber); } - - /* "Your signer": bold, spaced-out rows, worst concern first. The heading - reads as the headline: same weight as a section summary, a touch bigger. */ - .your-signer { display: flex; flex-direction: column; gap: 0.25rem; } - .your-signer > .section-title { font-size: 1.2rem; font-weight: 700; color: #fff; margin-bottom: 1rem; } - .summary-rows { display: flex; flex-direction: column; gap: 1rem; } - .summary-row { - display: flex; align-items: center; gap: 0.75rem; - padding: 0.9rem 1rem; - background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + + /* Needs attention: 0 to n cards, worst first, spaced not boxed-within-boxed. */ + .attention-block { display: flex; flex-direction: column; gap: 0.75rem; } + .attention-card { + display: flex; flex-direction: column; gap: 0.35rem; align-items: flex-start; + border-left-width: 3px; border-left-style: solid; } - .summary-lead { flex: 0 0 13.5rem; display: flex; align-items: center; gap: 0.6rem; min-width: 0; } - .dot { - flex: none; width: 0.7rem; height: 0.7rem; border-radius: 50%; + .attention-problem { border-left-color: var(--red); } + .attention-attention { border-left-color: var(--amber); } + .attention-lead { display: flex; align-items: center; gap: 0.5rem; } + .attention-label { font-size: 1rem; font-weight: 600; color: var(--text); } + .attention-text { margin: 0; font-size: 0.85rem; color: var(--text-dim); } + .attention-action { margin-top: 0.2rem; } + + @media (min-width: 1024px) { + .attention-card { flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; } + .attention-lead { flex: 0 0 auto; } + .attention-text { flex: 1 1 auto; } + .attention-action { margin-top: 0; margin-left: auto; } } + + .dot { flex: none; width: 0.6rem; height: 0.6rem; border-radius: 50%; } .dot-ok { background: var(--green); } - .dot-unknown { background: var(--text-muted); } + .dot-unknown { background: transparent; border: 2px solid var(--text-muted); } .dot-attention { background: var(--amber); } .dot-problem { background: var(--red); } - .summary-label { font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .summary-text { flex: 1 1 auto; min-width: 0; font-size: 1rem; font-weight: 600; color: var(--text); } - .summary-action { flex: none; margin-left: auto; } - - /* Phone width: dot+label on their own line, then the state text, then a - full-width button. The dot never sits alone on a line. */ - @media (max-width: 480px) { - .summary-row { flex-direction: column; align-items: stretch; gap: 0.4rem; } - .summary-lead { flex: none; } - .summary-text { flex: none; } - .summary-action { margin-left: 0; width: 100%; } + + /* Grouped section list: one border, rows split by dividers. */ + .section-group { + border: 1px solid var(--border); border-radius: 6px; + } + .section-row { padding: 0 1rem; } + .section-row + .section-row { border-top: 1px solid var(--border); } + .section-row > summary { + display: flex; align-items: center; justify-content: space-between; gap: 1rem; + padding: 0.9rem 0; cursor: pointer; list-style: none; + } + .section-row > summary::-webkit-details-marker { display: none; } + .section-row-title { + margin: 0; font-size: 1rem; font-weight: 600; color: var(--text); display: inline-flex; align-items: center; + } + .section-row-title::before { + content: '▸'; display: inline-block; margin-right: 0.6rem; color: var(--text-muted); + transition: transform 0.15s; + } + .section-row[open] > summary .section-row-title::before { transform: rotate(90deg); } + .section-row-state { + font-size: 0.85rem; color: var(--text-dim); white-space: nowrap; + display: inline-flex; align-items: center; gap: 0.4rem; + } + .section-row > :not(summary) { padding: 0 0 1.25rem; } + .section-row :global(.hint), + .section-row :global(.hint-sm) { max-width: 68ch; } + + @media (prefers-reduced-motion: reduce) { + .section-row-title::before { transition: none; } } - /* Collapsible sections: bold, roomy summaries with a state word docked right. */ .device-section { border: 1px solid var(--border); border-radius: 8px; padding: 0 1.1rem; @@ -1210,18 +1292,13 @@ .device-section > summary::-webkit-details-marker { display: none; } .device-section--danger { border-color: #442222; } .summary-title { font-size: 1.05rem; font-weight: 700; color: #fff; } - .summary-title::before { - content: '▸'; display: inline-block; margin-right: 0.6rem; color: var(--text-muted); - transition: transform 0.15s; - } - .device-section[open] > summary .summary-title::before { transform: rotate(90deg); } - .summary-state { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; } .device-section > :not(summary) { padding-bottom: 1.1rem; } .advanced-block { margin-top: 1.1rem; } .flash-line { margin: 0; } .sub-title { font-size: 0.9rem; font-weight: 600; color: var(--text); margin: 1.1rem 0 0.4rem; } .sub-title:first-of-type { margin-top: 0; } + .sub-title:focus { outline: 2px solid var(--green); outline-offset: 2px; } .inline-form { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .inline-form .field-input { font-size: 0.85rem; padding: 0.45rem 0.7rem; } @@ -1229,17 +1306,24 @@ .bridge-hint { margin-top: 0.8rem; } - .modes { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.75rem; margin: 0.8rem 0; } - .mode { display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; } - .mode-current { border-color: var(--green-dim); } - .mode-head { display: flex; gap: 0.5rem; align-items: center; } + .power-cut-grid { display: flex; flex-direction: column; gap: 1.5rem; } + .power-cut-left, .power-cut-right { min-width: 0; } + @media (min-width: 1024px) { + .power-cut-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; } + } + + .mode-list { display: flex; flex-direction: column; margin: 0.6rem 0; } + .mode-row { padding: 0.7rem 0; border-top: 1px solid var(--border); } + .mode-row:first-child { border-top: none; } + .mode-row-head { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; } .mode-name { font-weight: 600; color: var(--text); } + .mode-switch { margin-left: auto; } .mode-label { color: var(--text); } - .mode-steps { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; margin-top: 0.3rem; } + .mode-steps { display: flex; flex-direction: column; gap: 0.5rem; align-items: flex-start; margin-top: 0.5rem; } .ack { display: flex; gap: 0.5rem; align-items: flex-start; margin-top: 0.4rem; } .ack input { margin-top: 0.2rem; flex: none; } - .phones { margin-top: 1.2rem; } + .boot-pin { margin-top: 1.1rem; } .vault-escrow { display: flex; flex-direction: column; gap: 0.6rem; margin: 0.6rem 0 0.9rem; @@ -1262,7 +1346,17 @@ @media (max-width: 640px) { .danger-row { flex-wrap: wrap; } .inline-form .pw-wrap { width: 100%; } - .summary-row { padding: 0.8rem 0.85rem; } + .section-row { padding: 0 0.85rem; } .device-section { padding: 0 0.85rem; } } + + /* Touch targets: scoped to this tab only (Q4). */ + @media (any-pointer: coarse) { + .device-panel :global(.btn) { min-height: 44px; } + .device-panel :global(button.btn-sm) { min-height: 44px; } + .device-panel :global(summary) { min-height: 44px; display: flex; align-items: center; } + .device-panel :global(button), + .device-panel :global(summary) { touch-action: manipulation; } + .device-panel :global(.btn:active:not(:disabled)) { transform: translateY(1px); } + } diff --git a/src/components/DevicePanel.test.ts b/src/components/DevicePanel.test.ts index 824cea8..988edcd 100644 --- a/src/components/DevicePanel.test.ts +++ b/src/components/DevicePanel.test.ts @@ -21,6 +21,7 @@ vi.mock('../lib/device.svelte.js', async () => { const state: Record = { connected: true, mode: 'relay', error: null, masters: [], slots: [], relayStatus: null, bridgeAuthed: false, connectionGeneration: 0, + vaultRelayTarget: null, vaultUnlockRequest: null, vaultReconnect: null, } const device = new Proxy(state, { get(target, property, receiver) { @@ -45,6 +46,9 @@ vi.mock('../lib/device.svelte.js', async () => { usbDisplayFlip: vi.fn().mockResolvedValue(null), setDisplayFlip: vi.fn(), getFirmwareVersion: vi.fn().mockResolvedValue(null), + refreshMasters: vi.fn().mockResolvedValue(undefined), + sendVaultKeyOverRelay: vi.fn().mockResolvedValue(undefined), + vaultRelayDevicePub: vi.fn(() => ''), listUnlockPhones: vi.fn(), revokeUnlockPhone: vi.fn(), setAnnounceOperator: vi.fn(), @@ -111,6 +115,7 @@ describe('DevicePanel: the session override on a fresh firmware report', () => { // Without the fix, the session's own "PIN set" snapshot would win forever // and this would stay "Waits for Sapwood" even once FIRMWARE_INFO // disagreed; the retired override lets the fresh report show through. - await waitFor(() => expect(screen.getByText('Not encrypted: anyone holding it can read the keys')).toBeTruthy()) + await waitFor(() => expect(screen.getAllByText('Not encrypted').length).toBeGreaterThan(0)) + expect(screen.getByText('Anyone holding it can read the keys.')).toBeTruthy() }) }) diff --git a/src/components/PhoneHandoff.svelte b/src/components/PhoneHandoff.svelte index 93e9cdb..3a928ea 100644 --- a/src/components/PhoneHandoff.svelte +++ b/src/components/PhoneHandoff.svelte @@ -338,8 +338,9 @@ .handoff { margin-top: 1.5rem; } .handoff .section-title { font-size: 0.95rem; color: var(--green); } .field { margin-bottom: 0.9rem; } - .qr { width: 184px; padding: 12px; background: #fff; border-radius: 6px; margin-bottom: 0.9rem; } + .qr { width: 220px; padding: 12px; background: #fff; border-radius: 6px; margin-bottom: 0.9rem; } .qr :global(svg) { display: block; width: 100%; height: auto; } + @media (max-height: 500px) { .qr { width: 180px; } } .handoff-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; } .plain-link { display: block; margin: 0.9rem 0 0; padding: 0; width: 100%; diff --git a/src/components/TogglePair.svelte b/src/components/TogglePair.svelte new file mode 100644 index 0000000..4284d55 --- /dev/null +++ b/src/components/TogglePair.svelte @@ -0,0 +1,50 @@ + + +

+ + +
+ + diff --git a/src/components/UnlockPhones.svelte b/src/components/UnlockPhones.svelte index 53892d0..4ee4792 100644 --- a/src/components/UnlockPhones.svelte +++ b/src/components/UnlockPhones.svelte @@ -3,7 +3,7 @@ // 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 { onDestroy, untrack } from 'svelte' + import { onDestroy, tick, untrack } from 'svelte' import { SimplePool } from 'nostr-tools/pool' import { encodeQR } from '@paulmillr/qr' import { @@ -22,6 +22,8 @@ } from '../lib/enrol-invite.js' import ConfirmButton from './ConfirmButton.svelte' import QrScanner from './QrScanner.svelte' + import WordPairs from './WordPairs.svelte' + import TogglePair from './TogglePair.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 @@ -164,6 +166,9 @@ let working = $state(null) let result = $state(null) let addError = $state(null) + /** Which of the two "invite-aborted" stories to tell: someone else answered, + * or nobody did. */ + let abortReason = $state<'two-phones' | 'no-relays' | null>(null) /** Set once the board has been asked: its enrolment key is then spent. */ let codeSpent = $state(false) /** A record the board may have kept with nobody holding its secret: the @@ -212,7 +217,8 @@ 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.' + addError = 'Someone else may have seen the code. Nothing was added. Cancel on your phone, then make a new code.' + abortReason = 'two-phones' stopInvite() step = 'invite-aborted' } @@ -225,6 +231,7 @@ code = null result = null addError = null + abortReason = null codeSpent = false scanSpent = false orphanId = null @@ -243,6 +250,7 @@ zeroInviteSecret(made) stopInvite() addError = 'None of the relays answered, so the phone could not reply. Check this computer\'s connection and try again.' + abortReason = 'no-relays' step = 'invite-aborted' return } @@ -274,6 +282,7 @@ code = null result = null addError = null + abortReason = null codeSpent = false scanSpent = false orphanId = null @@ -353,8 +362,7 @@ enrol: async (enrolPubkey) => { codeSpent = true if (code) markCodeSpent(code) - return enrolUnlockPhone(enrolPubkey, label, - `Check the signer: it shows ADD PHONE and five words, two at a time over about 12 seconds before it will accept a hold. Compare the five words with your phone, then hold its button.`) + return enrolUnlockPhone(enrolPubkey, label, 'Signer: compare the five words, then hold its button.') }, onBoard: () => { working = isRelay @@ -378,13 +386,55 @@ if (codeSpent) void load(true) } } + + // --- 7.2 Add a phone: the constant enrolment card --- + // The heading and live region exist from the first render of the card (any + // step but 'idle'). Every step change sets both and moves focus to the + // heading, so the compare-and-confirm bench flow announces itself to + // assistive tech without the owner hunting for what changed. + const confirmWords = $derived(code ? requestWords(code.enrolPubkey) : []) + + const phase = $derived( + step === 'invite' ? (invite ? 'invite-ready' : 'invite-opening') : step, + ) + + let cardHeadingEl = $state(null) + let cardHeadingText = $state('') + let liveText = $state('') + + $effect(() => { + const p = phase + const label = code?.label ?? 'The phone' + if (p === 'invite-opening') { cardHeadingText = 'Add a phone'; liveText = 'Opening relays.' } + else if (p === 'invite-ready') { cardHeadingText = 'Scan this with Cambium'; liveText = 'Code ready. It expires in 10 minutes.' } + else if (p === 'invite-expired') { cardHeadingText = 'Code expired'; liveText = 'Code expired.' } + else if (p === 'invite-aborted') { + cardHeadingText = abortReason === 'no-relays' ? 'Could not reach the relays' : 'Two phones answered' + liveText = abortReason === 'no-relays' ? 'Could not reach the relays.' : 'Stopped: two phones answered.' + } else if (p === 'scan' || p === 'paste') { cardHeadingText = 'Add a phone'; liveText = '' } + else if (p === 'confirm') { cardHeadingText = `${label} wants to unlock this signer`; liveText = `${label} answered. Compare the five words.` } + else if (p === 'working') { cardHeadingText = 'Check the signer'; liveText = 'Sent to the signer. Hold its button when the words match.' } + else if (p === 'done') { cardHeadingText = `${label} added`; liveText = result ? `${label} added. Check code ${result.checkCode}.` : '' } + else if (p === 'orphan') { cardHeadingText = 'A stray record may be left'; liveText = '' } + else if (p === 'failed') { cardHeadingText = 'Not added'; liveText = '' } + if (p !== 'idle') void tick().then(() => cardHeadingEl?.focus()) + }) + + // Announce the countdown only at its two milestones and at expiry (the + // heading effect above covers expiry): a live region that spoke every + // second would be unusable. + $effect(() => { + const s = inviteSecondsLeft + if (phase !== 'invite-ready') return + if (s === 300) liveText = 'Five minutes left to scan the code.' + else if (s === 60) liveText = 'One minute left to scan the code.' + })

Phones that can unlock

-

A phone running Cambium can unlock this signer after a power cut: it shows - a notification, and one tap plus the phone's screen lock sends the unlock. Nothing on the - wire names the phone. A lost phone is revoked here without changing anything else.

+

A phone running Cambium can unlock this signer after a power cut. Nothing on the + wire names the phone. Revoking a lost phone changes nothing else.

{#if support === 'checking'}

Asking the signer…

@@ -421,16 +471,17 @@ {/each} -

{list.phones.length} of {list.max}.{#if list.phones.length === 1} +

{list.phones.length} of {list.max} phones.{#if list.phones.length === 1} Add a second device (a spare, or a family member's phone) so losing this one abroad does not - leave the signer locked.{/if}

+ leave the signer locked.{:else if list.phones.length >= list.max} + This signer holds {list.max} phones, its limit. Revoke one to add another.{/if}

{/if} {#if revokeStatus}

{revokeStatus}

{/if} {#if step === 'idle'} {#if canAdd}
-
@@ -447,6 +498,138 @@ {/if} {/if} + {#if step !== 'idle'} +
+

{cardHeadingText}

+

{liveText}

+ + {#if step === 'invite'} + {#if !invite} +

Opening relays…

+ {:else} +
+
{@html inviteQr}
+
+
    +
  1. Open Cambium on the phone.
  2. +
  3. Open this signer's screen.
  4. +
  5. Tap Set up phone unlock, then Scan Sapwood's code.
  6. +
+

Expires in {formatCountdown(inviteSecondsLeft)}

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

Codes last 10 minutes. Cancel on the phone, then make a new code.

+
+ + +
+ {: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.

+ {#if step === 'scan'} + { step = 'paste' }} /> + {#if scanSpent} +

That code was already used here. Start again on the phone for a new one.

+ {/if} + {:else} + + {#if pasted.trim() && !pastedCode} +

That is not a phone-unlock code from Cambium.

+ {:else if pastedSpent} +

This code was already used here. Start again on the phone for a new one.

+ {/if} +
+ + +
+ {/if} + + {:else if step === 'confirm' && code} +

Check Cambium shows these five words, in this order.

+ +

Next, the signer shows the same words, two at a time. Hold its button + only if all three screens match.

+
+ + +
+
+ Why compare with the phone, not only this page? +

Whoever relayed the request could have swapped the words shown here. + The phone and the signer cannot both be swapped, so their match is the check that + counts. The check code afterwards only confirms the phone received the hand-off.

+ + + +
Phone{code.label}
Relays + {#each code.relays as relay}
{relay}
{/each} +
+
+ {:else if step === 'working'} +

Wait for ADD PHONE. When its five words match these and the phone, hold + its button.

+ + {#if overRelay} +

This can take up to about two and a half minutes: the card queues + behind any other approval.

+ {/if} + {#if working}

{working}

{/if} + {:else if step === 'done' && result} +

Check code

+

{result.checkCode}

+

Cambium and the signer show the same code. If all three match, confirm + on the phone with its screen lock.

+
+ + { + const r = result + const label = code?.label ?? 'the phone' + if (r) void revoke(r.id, label) + step = 'idle' + }} + /> +
+ {:else if step === 'orphan' && orphanId !== null} +

{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}

+

Revoke it, then start again with a new code from the phone.

+
+ + +
+ {:else if step === 'failed'} +

{addError}

+
+ {#if codeSpent} + + {:else} + + {/if} + +
+ {/if} +
+ {/if} +
Sapwood's own unlock message

When it is locked, the signer also posts a message tagged with your operator @@ -454,121 +637,18 @@ can link the signer's restarts. Phone messages carry no such tag. If your phones are enough, turn it off; Sapwood can still unlock over USB. It stays on while no phone is set up, or a locked signer could only be unlocked over the cable.

-
- - -
+ {#if announceStatus}

{announceStatus}

{/if}
{/if} - - {#if step !== 'idle'} -
- {#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.

- {#if step === 'scan'} - { step = 'paste' }} /> - {#if scanSpent} -

That code was already used here. Start again on the phone for a new one.

- {/if} - {:else} - - {#if pasted.trim() && !pastedCode} -

That is not a phone-unlock code from Cambium.

- {:else if pastedSpent} -

This code was already used here. Start again on the phone for a new one.

- {/if} -
- - -
- {/if} - - {:else if step === 'confirm' && code} - - - -
Phone{code.label}
Waiting on - {#each code.relays as relay}
{relay}
{/each} -
-

The signer shows ADD PHONE and five words, two at a time over about - 12 seconds before it will accept a hold.

-
-

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}

- {:else if step === 'done' && result} -

The signer added {code?.label} as record {result.id}.

-

This check code confirms the phone got it: the five words you compared - before holding the button are what defended against a swapped phone. The phone should show - the same six characters:

-

{result.checkCode}

-

If they match, confirm on the phone with its screen lock. If they do - not, someone else answered the phone first: revoke record {result.id} now and start - again.

- - {:else if step === 'orphan' && orphanId !== null} -

{addError ?? `The signer never answered in time, but it may have kept a record nobody holds (record ${orphanId}): its secret never reached this browser, so it cannot reach the phone either.`}

-

Revoke it, then start again with a new code from the phone.

-
- - -
- {:else if step === 'failed'} -

{addError}

-
- {#if codeSpent} - - {:else} - - {/if} - -
- {/if} -
- {/if}
diff --git a/src/components/UnlockPhones.test.ts b/src/components/UnlockPhones.test.ts index f86bc3b..6918ad7 100644 --- a/src/components/UnlockPhones.test.ts +++ b/src/components/UnlockPhones.test.ts @@ -174,10 +174,10 @@ describe('UnlockPhones', () => { 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: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(P, 'Pixel 8', expect.stringMatching(/compare the five words/i)) expect(relays.ensureRelay).toHaveBeenCalledWith('wss://relay.example', expect.anything()) const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }] expect(urls).toEqual(['wss://relay.example']) @@ -185,7 +185,7 @@ describe('UnlockPhones', () => { expect(event.tags).toEqual([['h', R]]) // Finished, then the same code again: refused here, not sent twice. - await fireEvent.click(screen.getByRole('button', { name: 'Finished' })) + await fireEvent.click(screen.getByRole('button', { name: 'Done' })) 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 } }) @@ -209,7 +209,7 @@ describe('UnlockPhones', () => { 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: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) 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() @@ -254,12 +254,13 @@ describe('UnlockPhones', () => { 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 them with your phone's own screen/)).toBeTruthy() - await fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(screen.getByText('release')).toBeTruthy() + expect(screen.getByText('depart')).toBeTruthy() + expect(screen.getByText('Check Cambium shows these five words, in this order.')).toBeTruthy() + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() expect(api.enrolUnlockPhone).toHaveBeenCalledOnce() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i)) // Sent exactly once: the hand-off is only ever published the once. const [urls, event] = relays.publish.mock.calls[0] as unknown as [string[], { kind: number; tags: string[][] }] expect(urls).toEqual(['wss://relay.example']) @@ -282,7 +283,7 @@ describe('UnlockPhones', () => { 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: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) 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' })) @@ -300,7 +301,7 @@ describe('UnlockPhones', () => { 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: 'Continue' })) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText(/never answered in time/)).toBeTruthy() expect(screen.queryByText(/nobody holds/)).toBeNull() }) @@ -320,11 +321,11 @@ describe('UnlockPhones', () => { 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('Pixel 8 wants to unlock this signer')).toBeTruthy() + expect(screen.getAllByText('Pixel 8').length).toBeGreaterThan(0) + await fireEvent.click(screen.getByRole('button', { name: 'Send to the signer' })) expect(await screen.findByText('9B6 164')).toBeTruthy() - expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/ADD PHONE/)) + expect(api.enrolUnlockPhone).toHaveBeenCalledWith(p, 'Pixel 8', expect.stringMatching(/compare the five words/i)) }) it('ignores a repeat of the identical reply', async () => { @@ -335,9 +336,9 @@ describe('UnlockPhones', () => { const p = '22'.repeat(32) opts.onevent(inviteReply(codeWith(p))) - await screen.findByText('Check your phone shows these same five words') + await screen.findByText('Pixel 8 wants to unlock this signer') opts.onevent(inviteReply(codeWith(p))) - expect(screen.getByText('Check your phone shows these same five words')).toBeTruthy() + expect(screen.getByText('Pixel 8 wants to unlock this signer')).toBeTruthy() expect(screen.queryByText(/Two phones answered/)).toBeNull() }) @@ -348,10 +349,10 @@ describe('UnlockPhones', () => { 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') + await screen.findByText('Pixel 8 wants to unlock this signer') opts.onevent(inviteReply(codeWith('44'.repeat(32)))) - expect(await screen.findByText(/Two phones answered this code/)).toBeTruthy() + expect(await screen.findByText('Two phones answered')).toBeTruthy() expect(screen.getByRole('button', { name: 'New code' })).toBeTruthy() expect(api.enrolUnlockPhone).not.toHaveBeenCalled() }) @@ -374,7 +375,7 @@ describe('UnlockPhones', () => { 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() + expect(screen.queryByText('Pixel 8 wants to unlock this signer')).toBeNull() }) }) }) diff --git a/src/components/VaultUnlock.svelte b/src/components/VaultUnlock.svelte index 86ff4f3..218db49 100644 --- a/src/components/VaultUnlock.svelte +++ b/src/components/VaultUnlock.svelte @@ -110,7 +110,7 @@ } const seconds = Math.round(vaultUnlockTimeoutMs(lockedSlots) / 1000) const count = lockedSlots === null ? 'each identity' : `${lockedSlots} ${lockedSlots === 1 ? 'identity' : 'identities'}` - device.awaitingButton = `Unlocking — the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.` + device.awaitingButton = `Unlocking: the signer unseals ${count} with a slow key derivation on purpose, about 25 seconds each. This waits up to ${seconds} seconds; leave the cable in.` try { await serialVaultUnlock(serialTransport, keyHex, lockedSlots) } finally { @@ -134,7 +134,7 @@ async function unlockRelay(keyHex: string | undefined, remember: boolean) { busy = true status = null - device.awaitingButton = 'Sending the vault key — the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.' + device.awaitingButton = 'Sending the vault key: the signer unseals each identity with a slow key derivation on purpose, about 25 seconds each, then rejoins its relays.' try { await sendVaultKeyOverRelay(keyHex) if (remember && keyHex && deviceKey) { @@ -158,7 +158,7 @@

Signer is locked

Its keys are encrypted at rest, so it signs nothing until it is unlocked. - It shows “Locked — Await unlock”. + It shows “Locked: Await unlock”.

{#if storedKey}
@@ -196,8 +196,8 @@
{:else if step === 'working'} -

Wait for ADD PHONE. When its five words match these and the phone, hold - its button.

+

Wait for ADD PHONE. It shows the words two at a time, and its button only + works once all five have shown. Hold it if they match these and the phone.

- {#if overRelay} -

This can take up to about two and a half minutes: the card queues - behind any other approval.

- {/if} {#if working}

{working}

{/if} {:else if step === 'done' && result}

Check code

@@ -666,7 +662,8 @@ .enrol { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.8rem; align-items: flex-start; } .enrol-heading { font-size: 1.1rem; font-weight: 700; margin: 0; color: var(--text); } .enrol-heading:focus { outline: 2px solid var(--green); outline-offset: 2px; } - .enrol-live:empty { display: none; } + /* Announced, not shown: every step already says the same on screen. */ + .enrol-live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .code-input { width: 100%; box-sizing: border-box; font-size: 0.8rem; resize: vertical; } .check-code { font-size: 2rem; font-weight: 600; letter-spacing: 0.2em; color: var(--green); margin: 0.2rem 0; font-variant-numeric: slashed-zero; } .status { margin-top: 0.6rem; color: var(--text-dim); }