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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 66 additions & 13 deletions src/components/DevicePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import {
PAIRING_BACKUP_EVENT, pairingBackupStatus, type PairingBackupStatus,
} from '../lib/pairing-backup.js'
import { inferUnlockMode, type UnlockMode } from '../lib/phone-unlock.js'
import { resolveUnlockMode, shouldRetireEncryptionKnown, type UnlockMode } from '../lib/phone-unlock.js'
import Connectivity from './Connectivity.svelte'
import UnlockPhones from './UnlockPhones.svelte'
import OtaUpdate from './OtaUpdate.svelte'
Expand Down Expand Up @@ -74,6 +74,7 @@
max_sign_bytes?: number; max_sign_bytes_object?: number
free_heap?: number; largest_block?: number
nvs_used_entries?: number; nvs_free_entries?: number; nvs_total_entries?: number
at_rest?: string; unlock_phone_count?: number | null
} | null>(null)
$effect(() => {
if (device.connected && device.mode === 'serial') {
Expand Down Expand Up @@ -196,11 +197,15 @@
? (pinValue ? 'PIN set.' : 'PIN cleared. The signer stores its keys in plaintext again, and no phone can unlock it.')
: 'The device rejected the PIN change.'
if (frame.type === FrameType.ACK) {
encryptionKnown = !!pinValue
setEncryptionKnown(!!pinValue)
// Clearing turns encryption off whichever secret held it, so a vault
// key this browser kept no longer opens anything.
if (!pinValue && vaultDeviceKey) { removeVaultKey(vaultDeviceKey); vaultStored = null }
phonesRefresh++
// FIRMWARE_INFO is only fetched at connect, so re-read it now: without
// this, usbHealth's at_rest would stay stale until the next reconnect
// and the override above would never get to stand down.
void getFirmwareVersion().then((info) => { if (info) usbHealth = info })
}
pinValue = ''
clearPinAck = false
Expand Down Expand Up @@ -307,7 +312,11 @@
vaultStored = key
vaultEscrowKey = null
vaultShowKey = true
encryptionKnown = true
setEncryptionKnown(true)
// FIRMWARE_INFO is only fetched at connect, so re-read it now: without
// this, usbHealth's at_rest would stay stale until the next reconnect
// and the override above would never get to stand down.
void getFirmwareVersion().then((info) => { if (info) usbHealth = info })
vaultStatus = 'Encryption at rest is on. The key stays in this browser — keep your off-site copy safe.'
} catch (e) {
// The key is already stored (and visible below) whatever happened; a
Expand All @@ -333,8 +342,9 @@
removeVaultKey(vaultDeviceKey)
vaultStored = null
vaultShowKey = false
encryptionKnown = false
setEncryptionKnown(false)
phonesRefresh++
void getFirmwareVersion().then((info) => { if (info) usbHealth = info })
vaultStatus = 'Encryption at rest is off. The signer stores its keys in plaintext again.'
} catch (e) {
vaultStatus = e instanceof Error ? e.message : 'Failed'
Expand Down Expand Up @@ -372,16 +382,52 @@
}

// --- After a power cut: the three modes ---
// The signer does not report whether it is encrypted, so the current mode
// is inferred (inferUnlockMode) and left unmarked when it cannot be told.
// "No encryption" is never a default: turning encryption off, by either
// route, waits for the owner to confirm the sentence that says what it costs.
// Firmware ≥ #192 reports `at_rest`/`unlock_phone_count` directly (over USB
// on FIRMWARE_INFO, over the relay on get_status), so the mode is read
// rather than inferred where a signer sends it. Older firmware (released
// beta.17) sends neither: resolveUnlockMode falls back to its side-effect
// guess (inferUnlockMode) and the mode is left unmarked when even that
// cannot tell. "No encryption" is never a default: turning encryption off,
// by either route, waits for the owner to confirm the sentence that says
// what it costs.
const atRestReport = $derived(
device.mode === 'relay' ? device.relayStatus?.at_rest
: device.mode === 'serial' ? usbHealth?.at_rest
: undefined,
)
// The firmware's own phone count is available even while locked (before
// any PIN/vault secret is entered), unlike the enumerated list below, which
// needs an authenticated session. Prefer it when the signer sends it.
const unlockPhoneCountReport = $derived(
device.mode === 'relay' ? device.relayStatus?.unlock_phone_count
: device.mode === 'serial' ? usbHealth?.unlock_phone_count
: undefined,
)
let phoneCount = $state<number | null>(null)
const effectivePhoneCount = $derived(unlockPhoneCountReport !== undefined ? unlockPhoneCountReport : phoneCount)
/** Bumped when this page changed encryption: turning it off drops every phone. */
let phonesRefresh = $state(0)
/** What this session saw the signer accept; null until then. */
let encryptionKnown = $state<boolean | null>(null)
const currentMode = $derived(inferUnlockMode(phoneCount, !!vaultStored, encryptionKnown))
/** The firmware's `at_rest` value at the moment `encryptionKnown` was set,
* so a later report that differs (a relay poll catching up, or a fresh USB
* read) can retire the override rather than have it block the firmware's
* own answer indefinitely. */
let encryptionKnownBaseline = $state<string | undefined>(undefined)
function setEncryptionKnown(value: boolean | null) {
encryptionKnown = value
encryptionKnownBaseline = atRestReport
}
$effect(() => {
if (encryptionKnown !== null && shouldRetireEncryptionKnown(atRestReport, encryptionKnownBaseline)) {
encryptionKnown = null
encryptionKnownBaseline = undefined
}
})
const currentMode = $derived(resolveUnlockMode(atRestReport, effectivePhoneCount, !!vaultStored, encryptionKnown))
/** The mode is unknown specifically because the firmware sent an `at_rest`
* value this build does not recognise, not for lack of any report. */
const atRestUnrecognised = $derived(currentMode === null && encryptionKnown === null && atRestReport !== undefined)
let chosenMode = $state<UnlockMode | null>(null)
let modesSection = $state<HTMLElement | null>(null)
let noEncryptionAck = $state(false)
Expand Down Expand Up @@ -511,7 +557,11 @@
updateVersion: updateInfo?.latest ?? null,
runningVersion,
unlockMode: currentMode,
phoneCount,
// The firmware's own count (available even while locked) beats the
// enumerated list, which needs an authenticated session and is null until
// then: a locked signer with phones enrolled must not report "0 phones".
phoneCount: effectivePhoneCount,
atRestUnrecognised,
overUsb,
needsBackup: pairingBackup.needsBackup,
lastExportAt: pairingBackup.lastExportAt,
Expand Down Expand Up @@ -615,7 +665,7 @@
<p class="hint-sm">Add a phone below{overUsb ? '' : ', with the signer on the USB cable'}. Encryption is already on.</p>
{/if}
{:else if mode.id === 'sapwood'}
{#if (phoneCount ?? 0) > 0}
{#if (effectivePhoneCount ?? 0) > 0}
<p class="hint-sm">Revoke each phone below. Encryption stays on.</p>
{:else}
<p class="hint-sm">Turn on Encrypt at rest (Security{overUsb ? ', below' : ', over the USB cable'}), or set a boot PIN.</p>
Expand All @@ -626,7 +676,7 @@
{:else}
<label class="hint-sm ack">
<input type="checkbox" bind:checked={noEncryptionAck} disabled={vaultPending} />
{NO_ENCRYPTION_RISK}{#if (phoneCount ?? 0) > 0}&#32;Every phone stops being able to unlock it.{/if}
{NO_ENCRYPTION_RISK}{#if (effectivePhoneCount ?? 0) > 0}&#32;Every phone stops being able to unlock it.{/if}
</label>
{#if vaultStored}
<button class="btn btn-danger btn-sm" disabled={!noEncryptionAck || vaultPending}
Expand All @@ -651,7 +701,10 @@
{/each}
</div>
{#if currentMode === null}
{#if overUsb}
{#if atRestUnrecognised}
<p class="hint-sm">Your signer reported a setting this version of Sapwood doesn't know.
Update Sapwood.</p>
{:else if overUsb}
<p class="hint-sm">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.</p>
{:else}
Expand Down
116 changes: 116 additions & 0 deletions src/components/DevicePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
import { nip19 } from 'nostr-tools'
import DevicePanel from './DevicePanel.svelte'
import { device, serialTransport } from '../lib/device.svelte.js'
import { FrameType } from '../lib/frame.js'

// device.svelte is mocked (no transport). UnlockPhones only needs
// listUnlockPhones/PhoneUnlockAuthRequired/supportsPhoneEnrolRelay wired up;
// a locked signer never calls any of them (it short-circuits to 'locked').
// Reactive, like Connectivity.test.ts and UnlockPhones.test.ts, so a test can
// mutate `device` mid-flow and see the component react.
vi.mock('../lib/device.svelte.js', async () => {
const { createSubscriber } = await import('svelte/reactivity')
class PhoneUnlockAuthRequired extends Error {}
let notify = () => {}
const subscribe = createSubscriber((update) => {
notify = update
return () => { notify = () => {} }
})
const state: Record<string, unknown> = {
connected: true, mode: 'relay', error: null, masters: [], slots: [],
relayStatus: null, bridgeAuthed: false, connectionGeneration: 0,
}
const device = new Proxy(state, {
get(target, property, receiver) {
subscribe()
return Reflect.get(target, property, receiver)
},
set(target, property, value, receiver) {
const changed = Reflect.get(target, property, receiver) !== value
const ok = Reflect.set(target, property, value, receiver)
if (changed) notify()
return ok
},
})
return {
device,
serialTransport: { sendAndReceive: vi.fn() },
httpTransport: { clearClients: vi.fn(), factoryReset: vi.fn() },
bridgeRestart: vi.fn(),
mgmtRevokeClient: vi.fn(),
relaySetLogQuiet: vi.fn(),
ensureBridgeAuth: vi.fn().mockResolvedValue(undefined),
usbDisplayFlip: vi.fn().mockResolvedValue(null),
setDisplayFlip: vi.fn(),
getFirmwareVersion: vi.fn().mockResolvedValue(null),
listUnlockPhones: vi.fn(),
revokeUnlockPhone: vi.fn(),
setAnnounceOperator: vi.fn(),
enrolUnlockPhone: vi.fn(),
supportsPhoneEnrolRelay: vi.fn(() => false),
PhoneUnlockAuthRequired,
}
})

const NPUB = nip19.npubEncode('a'.repeat(64))

beforeEach(() => {
localStorage.clear()
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline in tests')))
;(device as { masters: unknown[] }).masters = []
;(device as { mode: string }).mode = 'relay'
;(device as { relayStatus: unknown }).relayStatus = null
vi.mocked(serialTransport.sendAndReceive).mockReset()
})

describe('DevicePanel summary: locked signer with phones', () => {
it('reports the firmware-known phone count, not the (unlisted) enumerated one', async () => {
// Locked: UnlockPhones cannot authenticate to list phones, so its own
// count binding stays null. The firmware's own unlock_phone_count (sent
// even while locked) must still reach the summary row.
;(device as { masters: unknown[] }).masters = [{ slot: 0, locked: true, npub: NPUB }]
;(device as { relayStatus: unknown }).relayStatus = { at_rest: 'encrypted', unlock_phone_count: 2 }

render(DevicePanel)

const row = await screen.findByText(/Unlocks from your phone/)
expect(row.textContent).toBe('Unlocks from your phone (2 phones)')
expect(row.textContent).not.toContain('(0 phones)')
})
})

describe('DevicePanel: the session override on a fresh firmware report', () => {
it('stands down once a later FIRMWARE_INFO report disagrees with the value seen when the PIN was set', async () => {
;(device as { mode: string }).mode = 'serial'
;(device as { masters: unknown[] }).masters = [{ slot: 0, npub: NPUB }]
// No report yet at mount (older behaviour, or just not fetched): the
// firmware's own answer is unknown, so the panel starts on "Unknown".
const getFirmwareVersion = (await import('../lib/device.svelte.js')).getFirmwareVersion
vi.mocked(getFirmwareVersion).mockResolvedValueOnce(null)
vi.mocked(serialTransport.sendAndReceive).mockResolvedValue({ type: FrameType.ACK, payload: new Uint8Array() })

const { container } = render(DevicePanel)
await waitFor(() => expect(screen.getAllByText(/After a power cut/).length).toBeGreaterThan(0))

const pinInput = container.querySelector('input.field-input[type="password"]') as HTMLInputElement
expect(pinInput).toBeTruthy()
await fireEvent.input(pinInput, { target: { value: '1234' } })

// Setting the PIN wins immediately: the session knows encryption just
// turned on, well ahead of a re-read of FIRMWARE_INFO.
const buttons = Array.from(container.querySelectorAll('button')).filter((b) => b.textContent?.trim() === 'Set PIN')
// The re-read of FIRMWARE_INFO that the PIN action triggers reports
// `at_rest: 'none'`, deliberately disagreeing with the PIN just set, so a
// pass demonstrates the fresh report winning rather than the session's own
// snapshot ("PIN set" => 'sapwood') sticking forever.
vi.mocked(getFirmwareVersion).mockResolvedValueOnce({ version: '0.18.0', board: 'heltec-v4', at_rest: 'none', unlock_phone_count: 0 })
await fireEvent.click(buttons[0])

// 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())
})
})
11 changes: 11 additions & 0 deletions src/lib/device-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function base(overrides: Partial<DeviceSummaryInput> = {}): DeviceSummaryInput {
runningVersion: '0.18.0',
unlockMode: 'sapwood',
phoneCount: null,
atRestUnrecognised: false,
overUsb: true,
needsBackup: false,
lastExportAt: Date.parse('2026-09-01T00:00:00Z'),
Expand Down Expand Up @@ -73,6 +74,16 @@ describe('deviceSummaryRows', () => {
expect(row.text).toBe('Unknown: no vault key held here')
})

it('names an unrecognised firmware report distinctly, over USB or WiFi', () => {
const usb = deviceSummaryRows(base({ unlockMode: null, overUsb: true, atRestUnrecognised: true }))
const wifi = deviceSummaryRows(base({ unlockMode: null, overUsb: false, modeLabel: 'WiFi', atRestUnrecognised: true }))
for (const rows of [usb, wifi]) {
const row = rows.find((r) => r.id === 'power-cut')!
expect(row.dot).toBe('unknown')
expect(row.text).toBe("Your signer reported a setting this version of Sapwood doesn't know. Update Sapwood.")
}
})

it('reads a fresh backup with its date, green', () => {
const rows = deviceSummaryRows(base({ lastExportAt: Date.parse('2026-09-01T00:00:00Z') }))
const row = rows.find((r) => r.id === 'backup')!
Expand Down
16 changes: 12 additions & 4 deletions src/lib/device-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ export interface DeviceSummaryInput {
updateAvailable: boolean
updateVersion: string | null
runningVersion: string | null
/** The inferred after-a-power-cut mode; null when it cannot be told. */
/** The after-a-power-cut mode: read from the firmware's own `at_rest`
* report when it sends one, otherwise inferred from side effects; null
* when it cannot be told either way. */
unlockMode: UnlockMode | null
phoneCount: number | null
/** The firmware sent an `at_rest` value this build does not recognise, so
* `unlockMode` is null for that reason rather than for lack of any report
* at all. Distinguishes "update Sapwood" from "connect by USB to check". */
atRestUnrecognised: boolean
overUsb: boolean
needsBackup: boolean
lastExportAt: number | null
Expand Down Expand Up @@ -84,9 +90,11 @@ function powerCutRow(input: DeviceSummaryInput): SummaryRow {
actionLabel: 'Change',
}
}
const text = input.overUsb
? 'Unknown: no vault key held here'
: `Unknown over ${input.modeLabel}. Connect by USB to check`
const text = input.atRestUnrecognised
? "Your signer reported a setting this version of Sapwood doesn't know. Update Sapwood."
: input.overUsb
? 'Unknown: no vault key held here'
: `Unknown over ${input.modeLabel}. Connect by USB to check`
return { id: 'power-cut', label, dot: 'unknown', text, actionLabel: 'Change' }
}

Expand Down
Loading
Loading