feat(machine): on-machine QR-pairing wizard #68

Merged
padreug merged 6 commits from qr-pairing-wizard into dev 2026-06-24 22:40:16 +00:00
5 changed files with 72 additions and 5 deletions
Showing only changes of commit 9935807f8c - Show all commits

feat(machine): persist scanned spire-seed + signal unpaired state for wizard

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 <noreply@anthropic.com>
Padreug 2026-06-23 23:07:18 +02:00

View file

@ -353,6 +353,50 @@ ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate() 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 // State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))

View file

@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'), clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'), resetBootstrapGate: (): Promise<void> => 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<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { payload: {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
@ -234,6 +239,8 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> }, payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number eventCreatedAt: number

View file

@ -3,14 +3,17 @@
* (see App.vue's MAINTENANCE_SCREENS). * (see App.vue's MAINTENANCE_SCREENS).
* *
* Bunker failures (aiolabs/bitspire#52) get dedicated 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) → * - `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`, * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
* a transient condition. * a transient condition.
* Everything else surfaces its raw message (or the caller's fallback). * Everything else surfaces its raw message (or the caller's fallback).
*/ */
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
const name = (error as { name?: string } | null)?.name const name = (error as { name?: string } | null)?.name
if (name === 'NoPairingError') return 'unpaired'
if (name === 'BunkerRejectedError') return 'unpaired' if (name === 'BunkerRejectedError') return 'unpaired'
if (name === 'BunkerTimeoutError') return 'signer-unreachable' if (name === 'BunkerTimeoutError') return 'signer-unreachable'
return error instanceof Error ? error.message : fallback return error instanceof Error ? error.message : fallback

View file

@ -31,6 +31,20 @@ import type { BunkerBindingRecord } from '@/types/electron'
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined 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 { export interface ResolveSignerOptions {
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
allowEphemeral: boolean allowEphemeral: boolean
@ -109,8 +123,5 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
return new LocalSigner(generateIdentity()) return new LocalSigner(generateIdentity())
} }
throw new Error( throw new NoPairingError()
'[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' +
'Set VITE_SPIRE_SEED or pair the ATM.'
)
} }

View file

@ -98,6 +98,8 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> }, payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number eventCreatedAt: number