From 9935807f8c98aa985b1b1ef0ca6f5a09d310525f Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:07:18 +0200 Subject: [PATCH] feat(machine): persist scanned spire-seed + signal unpaired state for wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the on-machine QR-pairing wizard (aiolabs/bitspire#52). An unpaired ATM can now have a seed planted at runtime rather than only via provisioning: - electron IPC `state:save-spire-seed` writes VITE_SPIRE_SEED into the runtime .env (0600), and `app:relaunch` restarts the kiosk so the normal boot path (signer-resolver → connectNewSeed) does the actual bunker pairing. We deliberately do NOT pair in-renderer — persist + relaunch reuses the single, hardware-tested pairing path. - signer-resolver throws a typed `NoPairingError` (distinct `.name`, survives the bundle boundary) when there's no seed and no binding, instead of a generic Error. - init-error maps NoPairingError → `unpaired`, so the renderer can route a fresh machine to the interactive wizard (next commit) rather than a dead-end fault screen. Revoked/TTL bindings already map there too — re-pair is the same scan-a-fresh-seed flow. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 44 ++++++++++++++++++++ apps/machine/electron/preload.ts | 7 ++++ apps/machine/src/services/init-error.ts | 5 ++- apps/machine/src/services/signer-resolver.ts | 19 +++++++-- apps/machine/src/types/electron.d.ts | 2 + 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index c35382d..516f32a 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -353,6 +353,50 @@ ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a +// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the +// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed) +// exactly as if it had been provisioned. We deliberately do NOT pair here — +// persisting + relaunching reuses the single, tested pairing path rather than +// duplicating it in the renderer. +function runtimeEnvPath(): string { + const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd() + return path.join(base, '.env') +} + +ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => { + const trimmed = (seed || '').trim() + if (!trimmed) throw new Error('save-spire-seed: empty seed') + const envPath = runtimeEnvPath() + const line = `VITE_SPIRE_SEED=${trimmed}` + let lines: string[] = [] + if (fs.existsSync(envPath)) { + lines = fs.readFileSync(envPath, 'utf8').split('\n') + } + const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED=')) + if (idx >= 0) { + lines[idx] = line + } else { + // Drop a trailing empty element so we don't accumulate blank lines. + if (lines.length && lines[lines.length - 1] === '') lines.pop() + lines.push(line) + } + fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 }) + // Keep this process's view in sync so get-atm-secrets reflects the new seed + // even before relaunch (belt-and-suspenders; relaunch re-reads from disk). + process.env.VITE_SPIRE_SEED = trimmed + console.log('[Pairing] Spire seed persisted to', envPath) +}) + +// Relaunch the kiosk so the new seed is picked up by a clean boot. Under +// systemd (bitspire.service) the exit triggers an automatic restart; in dev +// Electron's relaunch re-spawns the process. +ipcMain.handle('app:relaunch', (): void => { + console.log('[Pairing] Relaunching to apply new pairing') + app.relaunch() + app.exit(0) +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f439de9..f1320fb 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', { clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, + // then relaunch so the normal boot flow pairs it. + saveSpireSeed: (seed: string): Promise => ipcRenderer.invoke('state:save-spire-seed', seed), + relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -234,6 +239,8 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts index f9b0c84..bbeb117 100644 --- a/apps/machine/src/services/init-error.ts +++ b/apps/machine/src/services/init-error.ts @@ -3,14 +3,17 @@ * (see App.vue's MAINTENANCE_SCREENS). * * Bunker failures (aiolabs/bitspire#52) get dedicated screens: + * - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the + * interactive QR-pairing wizard so the operator can scan a spire-seed. * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → - * `unpaired` — the operator must re-pair the machine. + * `unpaired` too — re-pairing is the same scan-a-fresh-seed flow. * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, * a transient condition. * Everything else surfaces its raw message (or the caller's fallback). */ export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { const name = (error as { name?: string } | null)?.name + if (name === 'NoPairingError') return 'unpaired' if (name === 'BunkerRejectedError') return 'unpaired' if (name === 'BunkerTimeoutError') return 'signer-unreachable' return error instanceof Error ? error.message : fallback diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 9bd0d5b..44aa4b5 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -31,6 +31,20 @@ import type { BunkerBindingRecord } from '@/types/electron' const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined +/** + * Thrown in strict mode when the machine has no seed and no binding — it is + * genuinely unpaired, not misconfigured. The renderer catches this to show the + * QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen. + * Distinct `.name` so it survives the bundle boundary (instanceof is fragile + * across the electron/renderer split). See services/init-error.ts. + */ +export class NoPairingError extends Error { + override readonly name = 'NoPairingError' + constructor() { + super('[Signer] Machine is unpaired — no spire seed and no bunker binding.') + } +} + export interface ResolveSignerOptions { /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ allowEphemeral: boolean @@ -109,8 +123,5 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise return new LocalSigner(generateIdentity()) } - throw new Error( - '[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' + - 'Set VITE_SPIRE_SEED or pair the ATM.' - ) + throw new NoPairingError() } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 17fe9af..64af629 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -98,6 +98,8 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number