From 91921760f0dcebd6135677b1915eb22b4d8cac9d Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 25 Sep 2026 08:40:36 +0100 Subject: [PATCH 1/6] feat: read the firmware's at-rest report instead of inferring it heartwood-esp32 #192 adds at_rest ("none"/"pin"/"vault"/"encrypted") and unlock_phone_count to FIRMWARE_INFO and get_status, so Sapwood no longer has to guess the power-cut recovery mode from side effects it happened to witness this session. resolveUnlockMode reads the new fields when a signer sends them, treats an unrecognised value as unknown rather than guessing, and falls back to the existing inferUnlockMode heuristic for firmware that predates the report (released beta.17). --- src/components/DevicePanel.svelte | 35 ++++++++++++++++------ src/lib/device.svelte.test.ts | 24 ++++++++++++++++ src/lib/device.svelte.ts | 28 ++++++++++++++++++ src/lib/phone-unlock.test.ts | 48 ++++++++++++++++++++++++++++++- src/lib/phone-unlock.ts | 44 ++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 9 deletions(-) diff --git a/src/components/DevicePanel.svelte b/src/components/DevicePanel.svelte index 8cca18a..bb16d5c 100644 --- a/src/components/DevicePanel.svelte +++ b/src/components/DevicePanel.svelte @@ -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, type UnlockMode } from '../lib/phone-unlock.js' import Connectivity from './Connectivity.svelte' import UnlockPhones from './UnlockPhones.svelte' import OtaUpdate from './OtaUpdate.svelte' @@ -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') { @@ -372,16 +373,34 @@ } // --- 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(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(null) - const currentMode = $derived(inferUnlockMode(phoneCount, !!vaultStored, encryptionKnown)) + const currentMode = $derived(resolveUnlockMode(atRestReport, effectivePhoneCount, !!vaultStored, encryptionKnown)) let chosenMode = $state(null) let modesSection = $state(null) let noEncryptionAck = $state(false) @@ -615,7 +634,7 @@

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

{/if} {:else if mode.id === 'sapwood'} - {#if (phoneCount ?? 0) > 0} + {#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.

@@ -626,7 +645,7 @@ {:else} {#if vaultStored}