From 40239aa0758c8441e37d422ef73b922148e30d12 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:17 +0200 Subject: [PATCH 1/5] refactor(clink): route CLINK signing + encryption through the Signer Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event / nip44 now go through the signer (async), so the spire identity can live in a NIP-46 bunker. The kind-21003 management path (operator-driven manual dispense, the one live CLINK path on dev) decrypts as the spire via the bunker; the dormant offer/debit paths are migrated too so they're bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec (shocknet/CLINK#7, #8). Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/clink/src/client.ts | 91 ++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/packages/clink/src/client.ts b/packages/clink/src/client.ts index 333461f..e2b2e88 100644 --- a/packages/clink/src/client.ts +++ b/packages/clink/src/client.ts @@ -10,34 +10,30 @@ * Uses NIP-44v2 encryption for all messages. */ -import type { Event, UnsignedEvent } from 'nostr-tools' -import { finalizeEvent } from 'nostr-tools' -import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client' -import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client' +import type { Event, EventTemplate } from 'nostr-tools' +import type { NostrClient, Signer } from '@bitSpire/nostr-client' /** CLINK protocol version tag (mandatory per CLINK spec) */ const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1'] /** - * Encrypt content using NIP-44 v2 (required for CLINK events) + * Encrypt content using NIP-44 v2 (required for CLINK events). + * Routes through the Signer so the spire identity can live in a bunker. */ -function encryptCLINK( - identity: MachineIdentity, - recipientPubkey: string, - content: unknown -): string { - return encryptContentV2(identity, recipientPubkey, content) +function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise { + const plaintext = typeof content === 'string' ? content : JSON.stringify(content) + return signer.nip44Encrypt(recipientPubkey, plaintext) } /** - * Decrypt and parse JSON content using NIP-44 v2 + * Decrypt and parse JSON content using NIP-44 v2. */ -function decryptCLINKJSON( - identity: MachineIdentity, +async function decryptCLINKJSON( + signer: Signer, senderPubkey: string, ciphertext: string -): T { - const plaintext = decryptContentV2(identity, senderPubkey, ciphertext) +): Promise { + const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext) return JSON.parse(plaintext) as T } import { @@ -67,8 +63,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js' export interface CLINKClientOptions { /** Nostr client for communication */ nostrClient: NostrClient - /** Machine identity */ - identity: MachineIdentity + /** Signer for the spire identity (local nsec or remote bunker) */ + signer: Signer /** Operator pubkey(s) for management commands */ operatorPubkey: string | string[] /** Relays to use for offers */ @@ -102,7 +98,7 @@ export type ManagementHandler = ( */ export class CLINKClient { private nostrClient: NostrClient - private identity: MachineIdentity + private signer: Signer private operatorPubkeys: string[] private relays: string[] private generateInvoice?: GenerateInvoice @@ -120,7 +116,7 @@ export class CLINKClient { constructor(options: CLINKClientOptions) { this.nostrClient = options.nostrClient - this.identity = options.identity + this.signer = options.signer this.operatorPubkeys = Array.isArray(options.operatorPubkey) ? options.operatorPubkey : [options.operatorPubkey] @@ -144,7 +140,7 @@ export class CLINKClient { currency?: string }): string { const offer: CLINKOffer = { - pubkey: this.identity.publicKey, + pubkey: this.signer.pubkey, relays: this.relays, priceType: options.priceType, offerId: options.offerId, @@ -193,7 +189,7 @@ export class CLINKClient { [ { kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage], - '#p': [this.identity.publicKey], + '#p': [this.signer.pubkey], }, ], { @@ -234,9 +230,9 @@ export class CLINKClient { expires_in_seconds: options?.expiresInSeconds, } - const content = encryptCLINK(this.identity, offer.pubkey, request) + const content = await encryptCLINK(this.signer, offer.pubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Offer, content, tags: [['p', offer.pubkey], CLINK_VERSION_TAG], @@ -269,9 +265,9 @@ export class CLINKClient { description: options?.description, } - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -303,9 +299,9 @@ export class CLINKClient { description: options?.description, } - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -324,9 +320,9 @@ export class CLINKClient { targetPubkey: string, request: ManagementRequest ): Promise { - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Manage, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -394,15 +390,15 @@ export class CLINKClient { return } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.offerHandler(request, event.pubkey) if (!response) return // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Offer, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -425,14 +421,14 @@ export class CLINKClient { return } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.debitHandler(request, event.pubkey) // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -491,15 +487,15 @@ export class CLINKClient { if (first) this.processedManageEvents.delete(first) } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.managementHandler(request, event.pubkey) if (!response) return // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Manage, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -524,7 +520,7 @@ export class CLINKClient { { kinds: [kind], authors: [fromPubkey], - '#p': [this.identity.publicKey], + '#p': [this.signer.pubkey], '#e': [requestEventId], since: Math.floor(Date.now() / 1000) - 5, }, @@ -540,12 +536,7 @@ export class CLINKClient { clearTimeout(timeout) this.nostrClient.unsubscribe(subId) - try { - const response = decryptCLINKJSON(this.identity, fromPubkey, event.content) - resolve(response) - } catch (e) { - reject(e) - } + decryptCLINKJSON(this.signer, fromPubkey, event.content).then(resolve).catch(reject) }, } ) @@ -553,11 +544,11 @@ export class CLINKClient { } /** - * Create a signed event + * Create a signed event via the signer (sets pubkey/id/sig). Async because + * a BunkerSigner is a relay round-trip. */ - private createSignedEvent(event: Omit): Event { - // finalizeEvent derives pubkey from the secret key - return finalizeEvent(event, this.identity.privateKey) + private createSignedEvent(template: EventTemplate): Promise { + return this.signer.signEvent(template) } } -- 2.55.0 From 209e4c3e20e1fa1f8eaf64be90db742fc1294388 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:31 +0200 Subject: [PATCH 2/5] feat(machine): seed + bunker-binding IPC bridge get-atm-secrets now returns { spireSeed, bunkerBinding } instead of the raw nsec (one-shot semantics kept). Adds IPC handlers + preload bindings for saveBunkerBinding / clearBunkerBinding / resetBootstrapGate so the renderer can persist a pairing and re-arm the cassette-state hello on re-pair (#56). resetBootstrapGate added to state-store. Types mirrored in electron.d.ts. Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/main.ts | 26 ++++++++++++++++++++++++-- apps/machine/electron/preload.ts | 28 +++++++++++++++++++++++++--- apps/machine/electron/state-store.ts | 10 ++++++++++ apps/machine/src/types/electron.d.ts | 19 ++++++++++++++++--- 4 files changed, 75 insertions(+), 8 deletions(-) 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 -- 2.55.0 From 82a9e79d0ec5d2ebba33d345cfb610e3ea531207 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:45 +0200 Subject: [PATCH 3/5] feat(machine): resolve signer from spire seed / bunker binding at bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New signer-resolver.ts turns the ATM's pairing state into a Signer: - seed present, fingerprint differs from stored binding → pair: generate a transport key, redeem the one-shot connect secret, persist the binding, reset the bootstrap gate (re-publish hello to the new operator, #56); - seed matches binding, or binding-only → resume (no re-redeem); - neither → ephemeral LocalSigner (dev) or throw (strict/prod). lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the Phase-A Signer seam means nothing downstream changes. App.vue's maintenance beacon resolves the same way (best-effort, skips if unpaired). Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/App.vue | 13 +-- apps/machine/src/services/lightning.ts | 56 +++------ apps/machine/src/services/signer-resolver.ts | 116 +++++++++++++++++++ 3 files changed, 138 insertions(+), 47 deletions(-) create mode 100644 apps/machine/src/services/signer-resolver.ts diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 37882ba..652f708 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -51,14 +51,13 @@ onMounted(async () => { atmStore.initError = 'maintenance' // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) try { - const { NostrClient, LocalSigner, loadIdentityFromHex, createSignedEvent } = await import( - '@bitSpire/nostr-client' - ) - const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null - const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY + const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') + const { resolveSigner } = await import('@/services/signer-resolver') const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL - if (privKey && relayUrl) { - const signer = new LocalSigner(loadIdentityFromHex(privKey)) + // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). + // If the ATM isn't paired yet, skip the beacon rather than fail the screen. + const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null) + if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() const publishBeacon = async () => { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 8154d4b..e975277 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -12,14 +12,8 @@ * the customer's invoice. */ -import { - NostrClient, - LocalSigner, - generateIdentity, - loadIdentityFromHex, - type Signer, - type MachineIdentity, -} from '@bitSpire/nostr-client' +import { NostrClient, type Signer } from '@bitSpire/nostr-client' +import { resolveSigner } from './signer-resolver.js' import { LnbitsClient } from '@bitSpire/lnbits' import { CLINKClient } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' @@ -39,14 +33,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef * * Environment variables: * - VITE_RELAY_URL: Nostr relay WebSocket URL - * - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub) - * - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL - * - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret - * - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only) + * - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex) + * - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts + * - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex) */ interface LightningConfig { relayUrl: string - atmPrivateKey: string appId: string operatorPubkeys: string[] /** LNbits nostr-transport server pubkey (hex, 64 chars). */ @@ -63,7 +55,6 @@ interface LightningConfig { async function loadLightningConfig(): Promise { const defaults: LightningConfig = { relayUrl: 'ws://localhost:7777', - atmPrivateKey: '', appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', @@ -72,10 +63,8 @@ async function loadLightningConfig(): Promise { if (isElectron && window.electronAPI) { try { const rc = await window.electronAPI.getConfig() - const sec = await window.electronAPI.getAtmSecrets() return { relayUrl: rc.relayUrl || defaults.relayUrl, - atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey, appId: rc.appId || defaults.appId, operatorPubkeys: rc.operatorPubkeys ? rc.operatorPubkeys @@ -92,7 +81,6 @@ async function loadLightningConfig(): Promise { return { relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl, - atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, appId: import.meta.env.VITE_APP_ID || defaults.appId, lnbitsServerPubkey: (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || @@ -416,15 +404,14 @@ export async function initializeLightningServices(options?: { console.log('[Lightning] Relay URL:', CONFIG.relayUrl) console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') - // Strict mode: validate config is production-ready (no localhost, no ephemeral identity) + // Strict mode: validate config is production-ready (no localhost). The + // signing-identity check (a bunker pairing must exist) is enforced by + // resolveSigner below via allowEphemeral=false. if (options?.strict) { const errors: string[] = [] if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { errors.push('VITE_RELAY_URL contains localhost') } - if (!CONFIG.atmPrivateKey) { - errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)') - } if (!CONFIG.lnbitsServerPubkey) { errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') } @@ -441,22 +428,13 @@ export async function initializeLightningServices(options?: { ) } - // Load or generate ATM identity - let identity: MachineIdentity - if (CONFIG.atmPrivateKey) { - identity = loadIdentityFromHex(CONFIG.atmPrivateKey) - console.log('[Lightning] Loaded ATM identity from config') - } else { - identity = generateIdentity() - console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity') - console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts') - } - console.log('[Lightning] ATM pubkey:', identity.publicKey) - - // Wrap the identity in a signer. Phase A always uses LocalSigner (in-process - // nsec); Phase B swaps in a BunkerSigner here without touching the call - // sites below. See aiolabs/bitspire#52. - const signer: Signer = new LocalSigner(identity) + // Resolve the signing identity. In production this is a BunkerSigner over + // NIP-46 (the ATM holds only a transport key; the operator's nsecbunkerd + // holds the signing key); in dev it falls back to an in-process LocalSigner. + // The Phase-A Signer seam means nothing downstream changes. See + // aiolabs/bitspire#52. + const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict }) + console.log('[Lightning] ATM pubkey:', signer.pubkey) // Create Nostr client const nostrClient = new NostrClient({ @@ -491,7 +469,7 @@ export async function initializeLightningServices(options?: { // commands; it has no Lightning.Pub dependency. const clink = new CLINKClient({ nostrClient, - identity, + signer, operatorPubkey: CONFIG.operatorPubkeys, relays: [CONFIG.relayUrl], }) @@ -581,7 +559,6 @@ export async function initializeLightningServices(options?: { } const atmServices = createATMServices( - identity, (preimage) => { if (paymentReceivedCallback) { paymentReceivedCallback(preimage) @@ -624,7 +601,6 @@ export async function initializeLightningServices(options?: { * Create ATMServices implementation using the LNbits nostr-transport. */ function createATMServices( - _identity: MachineIdentity, onPaymentSuccess: (preimage: string) => void, lnbits: LnbitsClient, lnbitsWalletId: string, diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts new file mode 100644 index 0000000..9bd0d5b --- /dev/null +++ b/apps/machine/src/services/signer-resolver.ts @@ -0,0 +1,116 @@ +/** + * Signer resolution — turns the ATM's pairing state into a live `Signer`. + * + * Three outcomes, in priority order (aiolabs/bitspire#52, model A1): + * 1. A seed is present whose fingerprint differs from the stored binding + * (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem + * the one-shot connect secret, persist the binding, and reset the + * bootstrap gate so the (possibly new) operator gets a hello-event (#56). + * 2. A seed is present matching the stored binding, OR no seed but a stored + * binding exists → resume the bunker session with the persisted transport + * key (no re-redeem — the binding is server-persistent). + * 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode + * this throws instead: no pairing means no signing identity. + * + * Runs in the renderer (where the relay I/O lives); state.db reads/writes go + * through the one-shot get-atm-secrets channel + the binding IPC handlers. + */ + +import { + LocalSigner, + connectNewSeed, + resumeFromBinding, + generateClientTransportKey, + generateIdentity, + loadIdentityFromHex, + parseSpireSeed, + seedFingerprint, + type Signer, +} from '@bitSpire/nostr-client' +import type { BunkerBindingRecord } from '@/types/electron' + +const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined + +export interface ResolveSignerOptions { + /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ + allowEphemeral: boolean +} + +interface PairingState { + spireSeed: string + binding: BunkerBindingRecord | null +} + +/** Gather the seed + persisted binding from Electron, or env in browser dev. */ +async function loadPairingState(): Promise { + if (isElectron && window.electronAPI) { + const secrets = await window.electronAPI.getAtmSecrets() + return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null } + } + return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null } +} + +export async function resolveSigner(opts: ResolveSignerOptions): Promise { + const { spireSeed, binding } = await loadPairingState() + + if (spireSeed) { + const seed = parseSpireSeed(spireSeed) + const fingerprint = seedFingerprint(spireSeed) + + if (binding && binding.seedFingerprint === fingerprint) { + console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey) + return resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } + + // First pair or re-pair: redeem the one-shot connect secret. + console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey) + const transport = generateClientTransportKey() + const signer = await connectNewSeed({ + spirePubkey: seed.spirePubkey, + bunkerUrl: seed.bunkerUrl, + clientSecretHex: transport.secretHex, + }) + if (isElectron && window.electronAPI) { + await window.electronAPI.saveBunkerBinding({ + clientSecretHex: transport.secretHex, + spirePubkey: seed.spirePubkey, + bunkerUrl: seed.bunkerUrl, + seedFingerprint: fingerprint, + pairedAt: Math.floor(Date.now() / 1000), + }) + // Re-pair → re-publish the cassette-state hello to the new operator (#56). + await window.electronAPI.resetBootstrapGate() + } + return signer + } + + // No seed in this boot but a binding survives → resume. + if (binding) { + console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)') + return resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } + + if (opts.allowEphemeral) { + // Dev-only: a hex key gives a stable dev identity; otherwise ephemeral. + const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : '' + if (devKey) { + console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)') + return new LocalSigner(loadIdentityFromHex(devKey)) + } + console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)') + 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.' + ) +} -- 2.55.0 From 0391dbaeb029fad707d2632cfb26b15c32623ddf Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:59 +0200 Subject: [PATCH 4/5] chore(machine): fund-atm resumes from binding; VITE_SPIRE_SEED docs/env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fund-atm resolves its signer by resuming the bunker binding from state.db (the connect token is already spent by the main app, so it can't re-pair); falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked external in the esbuild bundle. .env.example + CLAUDE.md document VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only. (fund-atm is slated for deprecation in favour of the operator funding the wallet directly via the LNbits UI — kept working for now.) Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +++-- apps/machine/.env.example | 19 +++++++++++------ apps/machine/electron/fund-atm.ts | 34 +++++++++++++++++++++++++++---- apps/machine/package.json | 2 +- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20ba0df..4f86331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,8 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): |---|---|---| | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. See aiolabs/bitspire#52. | +| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. @@ -188,7 +189,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l ## Security priorities -1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`. +1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback. 2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter. 3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort. 4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden. diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 73b6281..e691e46 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -36,16 +36,23 @@ VITE_LNBITS_SERVER_PUBKEY= # aiolabs/withdraw#1 / commit e9d911e.) # ============================================================================= -# ATM Identity +# ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52) # ============================================================================= +# The spire pairing seed produced by the operator dashboard (spirekeeper): +# spire-seed:v1: +# It carries a one-shot NIP-46 connect token + the spire's signing pubkey + +# the bunker URL. On first boot the ATM redeems the token, generates its own +# transport key, and persists the binding to state.db; thereafter it resumes +# from the binding (the seed can stay set — it's matched by fingerprint). +# A changed seed re-pairs (and re-publishes the cassette-state hello). +VITE_SPIRE_SEED= + # pragma: allowlist secret -# ATM's Nostr private key (hex format, 64 characters). This signing -# key IS the credential — LNbits derives the account from it on first -# contact (issue aiolabs/lnbits#9 alignment). +# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running +# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists. # Generate with: openssl rand -hex 32 -# If not set, generates ephemeral identity on each restart (dev only). -VITE_ATM_PRIVATE_KEY= +# VITE_ATM_PRIVATE_KEY= # ============================================================================= # Operator Identity diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index bce6b4c..cd0f901 100644 --- a/apps/machine/electron/fund-atm.ts +++ b/apps/machine/electron/fund-atm.ts @@ -13,8 +13,15 @@ */ import { readFileSync } from 'node:fs' -import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client' +import { + NostrClient, + LocalSigner, + loadIdentityFromHex, + resumeFromBinding, + type Signer, +} from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' +import { initDatabase, getBunkerBinding } from './state-store.js' // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed import QRCode from 'qrcode' @@ -56,15 +63,34 @@ async function main() { const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY'] const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY'] - if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) { + if (!relayUrl || !lnbitsServerPubkey) { console.error('Missing required config in', envPath) - console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY') + console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY') process.exit(1) } console.error(`Generating invoice for ${amountSats} sats...`) - const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) + // Resolve the signer. Prod: resume the bunker binding from state.db (the + // ATM's transport key — the connect token was already redeemed by the main + // app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY. + let signer: Signer + if (atmPrivateKey) { + signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) + } else { + initDatabase() + const binding = getBunkerBinding() + if (!binding) { + console.error('ATM is not paired (no bunker binding in state.db) and no') + console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.') + process.exit(1) + } + signer = await resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } const nostrClient = new NostrClient({ relays: [{ url: relayUrl }], diff --git a/apps/machine/package.json b/apps/machine/package.json index dfc3336..844211c 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -14,7 +14,7 @@ "dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"", "dev:vite": "vite", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", - "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs", + "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs", "build:electron": "pnpm build && electron-builder", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", -- 2.55.0 From 09ed5e95deb94fea224d88e4a1ffebee2c4af42f Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:19:58 +0200 Subject: [PATCH 5/5] docs(nostr-client): TTL expiry is now a post-bind deauth cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired token (`expiresAt`) now stops signing post-bind, not just at connect — reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL now surfaces as the same BunkerRejectedError as a revoke, so the Phase D re-pair handling covers both. Docstring corrected to say so. refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nostr-client/src/bunker-signer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nostr-client/src/bunker-signer.ts b/packages/nostr-client/src/bunker-signer.ts index ed80f02..642b570 100644 --- a/packages/nostr-client/src/bunker-signer.ts +++ b/packages/nostr-client/src/bunker-signer.ts @@ -29,9 +29,11 @@ import type { Signer } from './signer.js' const DEFAULT_BUNKER_TIMEOUT_MS = 10_000 /** - * Raised when the bunker actively rejects a request (e.g. the operator - * revoked the spire's binding, or a kind/method is outside the policy). - * Callers should treat this as "unpaired" and surface a re-pair prompt. + * Raised when the bunker actively rejects a request. Post-bind causes + * (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the + * binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed, + * or the requested kind/method is outside the policy. Callers should treat + * this as "unpaired" and surface a re-pair prompt. */ export class BunkerRejectedError extends Error { constructor(message: string) { -- 2.55.0