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.' + ) +}