diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index fb5c84b..c35382d 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -26,14 +26,19 @@ import { getLastKnownConfigCreatedAt, getBootstrapPublishedAt, markBootstrapPublished, + resetBootstrapGate, applyOperatorCassettesConfig, getFeeConfig, getLastKnownFeeConfigCreatedAt, applyFeeConfig, + getBunkerBinding, + saveBunkerBinding, + clearBunkerBinding, type OperatorCassettesPayload, type FeeConfigPayload, type FeeConfigRow, type ApplyResult, + type StoredBunkerBinding, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' @@ -323,14 +328,31 @@ let secretsConsumed = false ipcMain.handle('get-atm-secrets', () => { if (secretsConsumed) { console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed') - return { atmPrivateKey: '' } + return { spireSeed: '', bunkerBinding: null } } secretsConsumed = true + // The spire pairing seed (one-shot connect token inside) + the persisted + // bunker binding (transport key). The renderer resolves these into a + // BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52). return { - atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', + spireSeed: process.env.VITE_SPIRE_SEED || '', + bunkerBinding: getBunkerBinding(), } }) +// Bunker binding persistence — the renderer writes the binding after a +// successful pairing (connectNewSeed), and resets the bootstrap gate so the +// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56). +ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => { + saveBunkerBinding(binding) +}) +ipcMain.handle('state:clear-bunker-binding', (): void => { + clearBunkerBinding() +}) +ipcMain.handle('state:reset-bootstrap-gate', (): void => { + resetBootstrapGate() +}) + // 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 836b98d..f439de9 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -42,13 +42,25 @@ export interface BrandingConfig { logoDarkDataUrl: string | null } +/** + * Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). + */ +export interface BunkerBindingRecord { + clientSecretHex: string + spirePubkey: string + bunkerUrl: string + seedFingerprint: string + pairedAt: number +} + /** * ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls. + * The spire pairing seed (carries the one-shot connect token) plus the persisted + * bunker binding; the renderer resolves these into a signer. */ export interface AtmSecrets { - atmPrivateKey: string - /** Legacy LP admin token — retained until 3d removes the LP backend. */ - adminToken?: string + spireSeed: string + bunkerBinding: BunkerBindingRecord | null } // Expose protected methods to renderer @@ -100,6 +112,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-bootstrap-published-at'), markBootstrapPublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + + // Bunker binding persistence (aiolabs/bitspire#52) + saveBunkerBinding: (binding: BunkerBindingRecord): Promise => + ipcRenderer.invoke('state:save-bunker-binding', binding), + clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), + resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -212,6 +231,9 @@ declare global { getLastKnownConfigCreatedAt: () => Promise getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise + saveBunkerBinding: (binding: BunkerBindingRecord) => Promise + clearBunkerBinding: () => Promise + resetBootstrapGate: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 8705aec..d9282ac 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -483,6 +483,16 @@ export function clearBunkerBinding(): void { db.prepare('DELETE FROM bunker_binding WHERE id = 1').run() } +/** + * Reset the bootstrap-publish gate so the ATM re-publishes its + * `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so + * the new operator receives the spire's current state (aiolabs/bitspire#56). + */ +export function resetBootstrapGate(): void { + if (!db) throw new Error('Database not initialized') + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') +} + export type OperatorCassettesPayload = { positions: Record } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 7830360..17fe9af 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -39,10 +39,20 @@ export interface BrandingConfig { logoDarkDataUrl: string | null } +/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */ +export interface BunkerBindingRecord { + clientSecretHex: string + spirePubkey: string + bunkerUrl: string + seedFingerprint: string + pairedAt: number +} + export interface AtmSecrets { - atmPrivateKey: string - /** Legacy LP admin token — retained until 3d removes the LP backend. */ - adminToken?: string + /** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */ + spireSeed: string + /** Persisted bunker binding, or null when the ATM is unpaired. */ + bunkerBinding: BunkerBindingRecord | null } declare global { @@ -85,6 +95,9 @@ declare global { getLastKnownConfigCreatedAt: () => Promise getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise + saveBunkerBinding: (binding: BunkerBindingRecord) => Promise + clearBunkerBinding: () => Promise + resetBootstrapGate: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number