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>
This commit is contained in:
parent
14d62e4c34
commit
9935807f8c
5 changed files with 72 additions and 5 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||
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: (
|
||||
payload: {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
|
|
@ -234,6 +239,8 @@ declare global {
|
|||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue