From d6b22e1156b86f723b785a8d86de943895aa962e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:56:35 +0200 Subject: [PATCH] refactor(nostr): route signing + encryption through a Signer abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt + sync pubkey) with an in-process LocalSigner backed by an nsec, and route every signing/encryption call site through it. Behaviour is unchanged — LocalSigner wraps the same MachineIdentity the code used directly before. This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap without touching any call site. The whole chain becomes async (the bunker path is a relay round-trip; LocalSigner resolves immediately). Sites moved onto the signer: - packages/nostr-client: createSignedEvent / createAuthEvent (now async), NostrClient config (signer not identity), AUTH challenge handler. - packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup still runs synchronously before the awaited decrypt, so replay safety and per-subscription hash dedup are preserved). - apps/machine: lightning.ts builds a LocalSigner and exposes it on LightningServices; operator-config / operator-fees / availability beacon / maintenance beacon / fund-atm all sign + encrypt via the signer. NIP-42 auth (kind 22242) is included — under the bunker it must be in the spire policy (aiolabs/spirekeeper#26, already merged). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/fund-atm.ts | 8 +- apps/machine/src/App.vue | 14 ++-- .../composables/useAvailabilityBroadcast.ts | 8 +- apps/machine/src/services/lightning.ts | 15 +++- apps/machine/src/services/operator-config.ts | 20 ++--- apps/machine/src/services/operator-fees.ts | 15 ++-- apps/machine/src/stores/atm.ts | 22 ++--- packages/lnbits/src/__tests__/client.test.ts | 63 +++++++------- packages/lnbits/src/client.ts | 58 +++++++------ .../nostr-client/src/__tests__/events.test.ts | 51 +++-------- .../nostr-client/src/__tests__/signer.test.ts | 58 +++++++++++++ packages/nostr-client/src/client.ts | 24 +++--- packages/nostr-client/src/events.ts | 84 ++++--------------- packages/nostr-client/src/index.ts | 50 +++++------ packages/nostr-client/src/signer.ts | 64 ++++++++++++++ packages/nostr-client/src/types.ts | 8 +- 16 files changed, 301 insertions(+), 261 deletions(-) create mode 100644 packages/nostr-client/src/__tests__/signer.test.ts create mode 100644 packages/nostr-client/src/signer.ts diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index ce8b1e0..bce6b4c 100644 --- a/apps/machine/electron/fund-atm.ts +++ b/apps/machine/electron/fund-atm.ts @@ -13,7 +13,7 @@ */ import { readFileSync } from 'node:fs' -import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client' +import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed @@ -64,11 +64,11 @@ async function main() { console.error(`Generating invoice for ${amountSats} sats...`) - const identity = loadIdentityFromHex(atmPrivateKey) + const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) const nostrClient = new NostrClient({ relays: [{ url: relayUrl }], - identity, + signer, }) await nostrClient.connect() @@ -76,7 +76,7 @@ async function main() { serverPubkey: lnbitsServerPubkey, relays: [relayUrl], }) - lnbits.initialize(nostrClient, identity) + lnbits.initialize(nostrClient, signer) const wallets = await lnbits.listWallets() const wallet = wallets[0] diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index c5730e3..37882ba 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -51,18 +51,18 @@ onMounted(async () => { atmStore.initError = 'maintenance' // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) try { - const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import( + 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 relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL if (privKey && relayUrl) { - const identity = loadIdentityFromHex(privKey) - const client = new NostrClient({ relays: [{ url: relayUrl }], identity }) + const signer = new LocalSigner(loadIdentityFromHex(privKey)) + const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() - const publishBeacon = () => { - const event = createSignedEvent(identity, { + const publishBeacon = async () => { + const event = await createSignedEvent(signer, { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'atm-availability']], @@ -77,8 +77,8 @@ onMounted(async () => { }) client.publish(event).catch(() => {}) } - publishBeacon() - setInterval(publishBeacon, 5 * 60 * 1000) + void publishBeacon() + setInterval(() => void publishBeacon(), 5 * 60 * 1000) } } catch (e) { console.warn('[App] Failed to start maintenance beacon:', e) diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index b36f6f2..a4968f7 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -13,7 +13,7 @@ import { watch, type Ref } from 'vue' import { useDebounceFn } from '@vueuse/core' -import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client' +import type { NostrClient, Signer } from '@bitSpire/nostr-client' import { createSignedEvent } from '@bitSpire/nostr-client' type CashLevel = 'none' | 'low' | 'good' | 'full' @@ -26,7 +26,7 @@ interface AvailabilitySnapshot { interface UseAvailabilityBroadcastOptions { nostrClient: NostrClient - identity: MachineIdentity + signer: Signer /** Reactive inventory: denomination -> count */ inventory: Ref> /** Reactive Lightning.Pub balance in sats (null = unknown) */ @@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions { } export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { - const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options + const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options let lastSnapshot: AvailabilitySnapshot | null = null @@ -73,7 +73,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption model, }) - const event = createSignedEvent(identity, { + const event = await createSignedEvent(signer, { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'atm-availability']], diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 1466e20..8154d4b 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -14,8 +14,10 @@ import { NostrClient, + LocalSigner, generateIdentity, loadIdentityFromHex, + type Signer, type MachineIdentity, } from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' @@ -234,7 +236,7 @@ interface LightningServices { nostrClient: NostrClient lightningPub: LightningBackend clink: CLINKClient - identity: MachineIdentity + signer: Signer /** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */ operatorPubkeys: string[] atmServices: ATMServices @@ -451,10 +453,15 @@ export async function initializeLightningServices(options?: { } 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) + // Create Nostr client const nostrClient = new NostrClient({ relays: [{ url: CONFIG.relayUrl }], - identity, + signer, }) await nostrClient.connect() @@ -465,7 +472,7 @@ export async function initializeLightningServices(options?: { serverPubkey: CONFIG.lnbitsServerPubkey, relays: [CONFIG.relayUrl], }) - lnbits.initialize(nostrClient, identity) + lnbits.initialize(nostrClient, signer) _lnbitsRef = lnbits console.log('[Lightning] LNbits client initialized') @@ -588,7 +595,7 @@ export async function initializeLightningServices(options?: { nostrClient, lightningPub, clink, - identity, + signer, operatorPubkeys: CONFIG.operatorPubkeys, atmServices, onOfferRequest: (callback: OfferRequestCallback) => { diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index c9b26f8..ba9ddcb 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -23,12 +23,10 @@ */ import { - type MachineIdentity, + type Signer, type NostrClient, type Event, createSignedEvent, - decryptContentV2, - encryptContentV2, validateEvent, } from '@bitSpire/nostr-client' @@ -47,11 +45,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorConfigServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ nostrClient: NostrClient - /** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */ - identity: MachineIdentity + /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */ + signer: Signer /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] - /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ + /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string } @@ -72,7 +70,7 @@ export async function startOperatorConfigService( return { stop: () => {} } } const api = window.electronAPI - const machineId = cfg.machineId ?? cfg.identity.publicKey + const machineId = cfg.machineId ?? cfg.signer.pubkey // Bootstrap hello-event on first boot (best-effort — failure leaves the // gate null so the next boot retries). @@ -88,7 +86,7 @@ export async function startOperatorConfigService( [ { kinds: [KIND_NIP78], - '#p': [cfg.identity.publicKey], + '#p': [cfg.signer.pubkey], '#d': [dTag], authors: cfg.operatorPubkeys, }, @@ -150,7 +148,7 @@ async function handleOperatorConfigEvent( // 4. Decrypt content (NIP-44 v2). let parsed: { positions: Record } try { - const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) + const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content) parsed = JSON.parse(plaintext) as typeof parsed } catch (err) { console.error('[OperatorConfig] Decrypt/parse failed:', err) @@ -223,10 +221,10 @@ async function maybePublishBootstrap( for (const c of cassettes) { positions[String(c.position)] = { denomination: c.denomination, count: c.count } } - const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions }) + const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) const dTag = atmStateDTag(machineId) - const event = createSignedEvent(cfg.identity, { + const event = await createSignedEvent(cfg.signer, { kind: KIND_NIP78, content: ciphertext, tags: [ diff --git a/apps/machine/src/services/operator-fees.ts b/apps/machine/src/services/operator-fees.ts index 8581ce8..4e699a3 100644 --- a/apps/machine/src/services/operator-fees.ts +++ b/apps/machine/src/services/operator-fees.ts @@ -56,10 +56,9 @@ */ import { - type MachineIdentity, + type Signer, type NostrClient, type Event, - decryptContentV2, validateEvent, } from '@bitSpire/nostr-client' @@ -80,11 +79,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorFeesServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ nostrClient: NostrClient - /** ATM's nostr identity. Used to decrypt operator events. */ - identity: MachineIdentity + /** Signer for the ATM identity. Decrypts operator events. */ + signer: Signer /** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] - /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ + /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string /** * Called when a valid fee-config event is applied. Renderer should @@ -112,7 +111,7 @@ export async function startOperatorFeesService( return { stop: () => {} } } const api = window.electronAPI - const machineId = cfg.machineId ?? cfg.identity.publicKey + const machineId = cfg.machineId ?? cfg.signer.pubkey // Subscribe to operator-published fee config events. const dTag = feeConfigDTag(machineId) @@ -120,7 +119,7 @@ export async function startOperatorFeesService( [ { kinds: [KIND_NIP78], - '#p': [cfg.identity.publicKey], + '#p': [cfg.signer.pubkey], '#d': [dTag], authors: cfg.operatorPubkeys, }, @@ -189,7 +188,7 @@ async function handleFeeConfigEvent( // fields (v2 forward-compat — future promo payloads). let parsed: ParsedFeePayload try { - const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) + const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content) const raw = JSON.parse(plaintext) as Record parsed = parseV1Payload(raw) } catch (err) { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index d0e2005..efc80cf 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -676,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => { initialize(servicesWithInventory) // Start broadcasting availability (Kind 30078) with 5-minute heartbeat - startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value) + startAvailabilityBroadcast(services.nostrClient, services.signer, machineModel.value) // Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes // to kind-30078 cassette config events + publishes one-shot bootstrap operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: services.nostrClient, - identity: services.identity, + signer: services.signer, operatorPubkeys: services.operatorPubkeys, }) @@ -692,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: services.nostrClient, - identity: services.identity, + signer: services.signer, operatorPubkeys: services.operatorPubkeys, onApply: applyFeeConfig, }) @@ -989,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => { }) // Start broadcasting availability (Kind 30078) - startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) + startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value) // Operator-config consumer (aiolabs/lamassu-next#56) operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, }) @@ -1003,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, onApply: applyFeeConfig, }) @@ -1295,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => { // Real hardware connected — disable mock bill simulator debugMode.value = false // Start broadcasting availability (Kind 30078) - startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) + startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value) // Operator-config consumer (aiolabs/lamassu-next#56) operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, }) @@ -1309,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, onApply: applyFeeConfig, }) @@ -1437,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => { /** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */ let stopAvailabilityBroadcast: (() => void) | null = null - async function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) { + async function startAvailabilityBroadcast(nostrClient: any, signer: any, model: string) { if (stopAvailabilityBroadcast) return // Ensure persisted inventory is loaded before first broadcast @@ -1445,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => { const { stop } = useAvailabilityBroadcast({ nostrClient, - identity, + signer, inventory: persistedInventory, balanceSats, fiatCode: fiatCode.value, diff --git a/packages/lnbits/src/__tests__/client.test.ts b/packages/lnbits/src/__tests__/client.test.ts index 187e948..903bcb7 100644 --- a/packages/lnbits/src/__tests__/client.test.ts +++ b/packages/lnbits/src/__tests__/client.test.ts @@ -17,6 +17,7 @@ import { } from 'nostr-tools' import { encryptContentV2, + LocalSigner, type MachineIdentity, type NostrClient, } from '@bitSpire/nostr-client' @@ -152,7 +153,7 @@ describe('isAuthenticServerEvent', () => { describe('LnbitsClient.handleReply wiring', () => { function makeMockNostr(): { nostr: NostrClient - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { let captured: ((ev: NostrEvent) => void) | null = null const nostr = { @@ -168,9 +169,12 @@ describe('LnbitsClient.handleReply wiring', () => { } as unknown as NostrClient return { nostr, - triggerEvent: (ev) => { + // `handleReply` is async (the signer's nip44Decrypt is a promise), + // so flush microtasks + a macrotask tick before the caller asserts. + triggerEvent: async (ev) => { if (!captured) throw new Error('handleReply not wired yet') captured(ev) + await new Promise((resolve) => setTimeout(resolve, 0)) }, } } @@ -188,7 +192,7 @@ describe('LnbitsClient.handleReply wiring', () => { client: LnbitsClient serverIdentity: MachineIdentity recipientIdentity: MachineIdentity - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() @@ -197,11 +201,11 @@ describe('LnbitsClient.handleReply wiring', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) return { client, serverIdentity, recipientIdentity, triggerEvent } } - it('drops a forged event without resolving any pending RPC', () => { + it('drops a forged event without resolving any pending RPC', async () => { const { client, serverIdentity, triggerEvent } = setupClient() // Pre-register a pending entry as `sendRpc` would have. @@ -233,7 +237,7 @@ describe('LnbitsClient.handleReply wiring', () => { attackerKey, ) - triggerEvent(forged) + await triggerEvent(forged) expect(resolveCalls).toBe(0) expect(rejectCalls).toBe(0) @@ -241,7 +245,7 @@ describe('LnbitsClient.handleReply wiring', () => { expect((client as any).pending.has('req-forged')).toBe(true) }) - it('processes a legitimate server-signed reply (positive sanity)', () => { + it('processes a legitimate server-signed reply (positive sanity)', async () => { const { client, serverIdentity, recipientIdentity, triggerEvent } = setupClient() @@ -277,7 +281,7 @@ describe('LnbitsClient.handleReply wiring', () => { serverIdentity.privateKey, ) - triggerEvent(reply) + await triggerEvent(reply) expect(resolved).toMatchObject({ status: 'OK', @@ -290,7 +294,7 @@ describe('LnbitsClient.handleReply wiring', () => { // fine, we only need to assert resolve fired with the right payload. }) - it('does not poison the seenEventIds cache with a forged event', () => { + it('does not poison the seenEventIds cache with a forged event', async () => { // This is the test scenario where the #49 guard's contribution // actually shows up: ev.id is the dedup key for the client-global // exact-replay cache. WITHOUT the guard, an attacker could publish @@ -320,7 +324,7 @@ describe('LnbitsClient.handleReply wiring', () => { attackerKey, ) - triggerEvent(forged) + await triggerEvent(forged) // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((client as any).seenEventIds.size).toBe(0) @@ -345,7 +349,7 @@ describe('LnbitsClient.handleReply wiring', () => { describe('LnbitsClient subscribe-payments dedup (#50)', () => { function makeMockNostr(): { nostr: NostrClient - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { let captured: ((ev: NostrEvent) => void) | null = null const nostr = { @@ -361,9 +365,12 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { } as unknown as NostrClient return { nostr, - triggerEvent: (ev) => { + // `handleReply` is async (the signer's nip44Decrypt is a promise), + // so flush microtasks + a macrotask tick before the caller asserts. + triggerEvent: async (ev) => { if (!captured) throw new Error('handleReply not wired yet') captured(ev) + await new Promise((resolve) => setTimeout(resolve, 0)) }, } } @@ -436,7 +443,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { ) } - it('fires onPush once when the same event is injected twice (exact-replay dedup)', () => { + it('fires onPush once when the same event is injected twice (exact-replay dedup)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -444,7 +451,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -455,14 +462,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-aaa', }) // Same bytes both times — same ev.id, same payment_hash. - triggerEvent(ev) - triggerEvent(ev) + await triggerEvent(ev) + await triggerEvent(ev) expect(received).toHaveLength(1) expect(received[0]!.payment_hash).toBe('hash-aaa') }) - it('fires onPush once when two distinct ev.ids carry the same payment_hash', () => { + it('fires onPush once when two distinct ev.ids carry the same payment_hash', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -470,7 +477,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -493,14 +500,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { createdAt: now + 1, }) expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this - triggerEvent(ev1) - triggerEvent(ev2) + await triggerEvent(ev1) + await triggerEvent(ev2) expect(received).toHaveLength(1) expect(received[0]!.payment_hash).toBe('hash-bbb') }) - it('fires onPush for each distinct payment_hash (negative dedup case)', () => { + it('fires onPush for each distinct payment_hash (negative dedup case)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -508,7 +515,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -525,14 +532,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-2', createdAt: Math.floor(Date.now() / 1000) + 2, }) - triggerEvent(ev1) - triggerEvent(ev2) + await triggerEvent(ev1) + await triggerEvent(ev2) expect(received).toHaveLength(2) expect(received.map((p) => p.payment_hash)).toEqual(['hash-1', 'hash-2']) }) - it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', () => { + it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -540,7 +547,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const a = preregisterSub(client, 'sub-A') const b = preregisterSub(client, 'sub-B') @@ -561,8 +568,8 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-shared', createdAt: Math.floor(Date.now() / 1000) + 1, }) - triggerEvent(evA) - triggerEvent(evB) + await triggerEvent(evA) + await triggerEvent(evB) // Each subscription sees its own push exactly once. expect(a.received).toHaveLength(1) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index b30f469..76b796e 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -22,12 +22,10 @@ import { type NostrClient, - type MachineIdentity, + type Signer, type Event as NostrEvent, - encryptContentV2, - decryptContentV2, } from '@bitSpire/nostr-client' -import { finalizeEvent, verifyEvent } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' import type { LnbitsConfig, @@ -121,7 +119,7 @@ const SEEN_PAYMENT_HASHES_MAX = 500 export class LnbitsClient { private readonly config: Required private nostr: NostrClient | null = null - private identity: MachineIdentity | null = null + private signer: Signer | null = null private requestCounter = 0 private readonly pending = new Map< string, @@ -150,9 +148,9 @@ export class LnbitsClient { } } - initialize(nostr: NostrClient, identity: MachineIdentity): void { + initialize(nostr: NostrClient, signer: Signer): void { this.nostr = nostr - this.identity = identity + this.signer = signer this.startReplyListener() } @@ -240,7 +238,7 @@ export class LnbitsClient { onPush: PaymentPushCallback, onClose?: SubscriptionCloseCallback, ): Promise { - if (!this.nostr || !this.identity) { + if (!this.nostr || !this.signer) { throw new Error('LnbitsClient.subscribePayments: client not initialized') } const requestId = this.nextRequestId('sub') @@ -431,7 +429,7 @@ export class LnbitsClient { requestId?: string }, ): Promise { - if (!this.nostr || !this.identity) { + if (!this.nostr || !this.signer) { throw new Error(`LnbitsClient.${rpcName}: client not initialized`) } const requestId = args.requestId ?? this.nextRequestId(rpcName) @@ -444,10 +442,10 @@ export class LnbitsClient { if (args.query !== undefined) request.query = args.query as Record const plaintext = JSON.stringify(request) - const encrypted = encryptContentV2(this.identity, this.config.serverPubkey, plaintext) + const encrypted = await this.signer.nip44Encrypt(this.config.serverPubkey, plaintext) - // Build + sign the kind-21000 event ourselves. The server reads our - // pubkey directly off the signature, so there's no separate + // Build + sign the kind-21000 event via the signer. The server reads + // our pubkey directly off the signature, so there's no separate // authIdentifier in the envelope (unlike LightningPubClient). // // NIP-40 expiration: 5 minutes past now. Defence-in-depth at the @@ -457,18 +455,15 @@ export class LnbitsClient { // attacker can't bypass this by stripping the tag; the tag just // lets the relay short-circuit earlier. const now = Math.floor(Date.now() / 1000) - const event = finalizeEvent( - { - kind: LNBITS_KIND_RPC, - content: encrypted, - tags: [ - ['p', this.config.serverPubkey], - ['expiration', String(now + 300)], - ], - created_at: now, - }, - this.identity.privateKey, - ) + const event = await this.signer.signEvent({ + kind: LNBITS_KIND_RPC, + content: encrypted, + tags: [ + ['p', this.config.serverPubkey], + ['expiration', String(now + 300)], + ], + created_at: now, + }) // The pending entry MUST be registered before publish so we don't race // an extremely fast reply. @@ -508,8 +503,8 @@ export class LnbitsClient { * based on `request_id` and `subscription_id`. */ private startReplyListener(): void { - if (!this.nostr || !this.identity) return - const myPubkey = this.identity.publicKey + if (!this.nostr || !this.signer) return + const myPubkey = this.signer.pubkey const since = Math.floor(Date.now() / 1000) - 5 this.relaySubIdForReplies = this.nostr.subscribe( @@ -522,25 +517,28 @@ export class LnbitsClient { }, ], { - onEvent: (ev: NostrEvent) => this.handleReply(ev), + onEvent: (ev: NostrEvent) => void this.handleReply(ev), }, ) } - private handleReply(ev: NostrEvent): void { - if (!this.identity) return + private async handleReply(ev: NostrEvent): Promise { + const signer = this.signer + if (!signer) return if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return // Exact-replay dedup. Skip if we've already processed this event id. // Safe to trust `ev.id` here because `isAuthenticServerEvent` just // Schnorr-verified the event (`verifyEvent` recomputes the id and // confirms it matches the signed pubkey + body). Without that // guarantee an attacker could pre-poison this set with chosen ids. + // Runs before the async decrypt so concurrent re-deliveries of the + // same id still dedup synchronously. if (this.seenEventIds.has(ev.id)) return this.recordSeenEventId(ev.id) let plaintext: string try { - plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content) + plaintext = await signer.nip44Decrypt(this.config.serverPubkey, ev.content) } catch { return // not our peer or wrong key } diff --git a/packages/nostr-client/src/__tests__/events.test.ts b/packages/nostr-client/src/__tests__/events.test.ts index 7f1d2ea..f072788 100644 --- a/packages/nostr-client/src/__tests__/events.test.ts +++ b/packages/nostr-client/src/__tests__/events.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect } from 'vitest' import { generateIdentity } from '../identity.js' -import { - createSignedEvent, - createMachineStatusEvent, - createAuthEvent, - validateEvent, - generateTxId, -} from '../events.js' -import { LamassuEventKind, type MachineStatus } from '../types.js' +import { LocalSigner } from '../signer.js' +import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js' +import { LamassuEventKind } from '../types.js' describe('events', () => { describe('createSignedEvent', () => { - it('should create a properly signed event', () => { + it('should create a properly signed event via the signer', async () => { const identity = generateIdentity() - const event = createSignedEvent(identity, { + const signer = new LocalSigner(identity) + const event = await createSignedEvent(signer, { kind: 1, content: 'test', tags: [], @@ -25,48 +21,23 @@ describe('events', () => { expect(event.content).toBe('test') expect(event.id).toMatch(/^[0-9a-f]{64}$/) expect(event.sig).toMatch(/^[0-9a-f]{128}$/) - }) - }) - - describe('createMachineStatusEvent', () => { - it('should create encrypted status event', () => { - const machine = generateIdentity() - const operator = generateIdentity() - - const status: MachineStatus = { - online: true, - lastTransaction: Date.now(), - cashLevels: { - validator: 1000, - dispenser: [{ denomination: 20, count: 100, capacity: 500 }], - }, - errors: [], - version: '1.0.0', - } - - const event = createMachineStatusEvent(machine, operator.publicKey, status) - - expect(event.kind).toBe(LamassuEventKind.MachineStatus) - expect(event.pubkey).toBe(machine.publicKey) - expect(event.tags).toContainEqual(['d', 'status']) - expect(event.tags).toContainEqual(['p', operator.publicKey]) - // Content should be encrypted (not readable JSON) - expect(() => JSON.parse(event.content)).toThrow() + expect(validateEvent(event)).toBe(true) }) }) describe('createAuthEvent', () => { - it('should create NIP-42 auth event', () => { - const identity = generateIdentity() + it('should create a signed NIP-42 auth event (kind 22242)', async () => { + const signer = new LocalSigner(generateIdentity()) const relayUrl = 'wss://relay.test.com' const challenge = 'random-challenge-string' - const event = createAuthEvent(identity, relayUrl, challenge) + const event = await createAuthEvent(signer, relayUrl, challenge) expect(event.kind).toBe(LamassuEventKind.Auth) expect(event.content).toBe('') expect(event.tags).toContainEqual(['relay', relayUrl]) expect(event.tags).toContainEqual(['challenge', challenge]) + expect(event.pubkey).toBe(signer.pubkey) }) }) diff --git a/packages/nostr-client/src/__tests__/signer.test.ts b/packages/nostr-client/src/__tests__/signer.test.ts new file mode 100644 index 0000000..120346f --- /dev/null +++ b/packages/nostr-client/src/__tests__/signer.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { finalizeEvent, verifyEvent } from 'nostr-tools' +import { generateIdentity } from '../identity.js' +import { LocalSigner } from '../signer.js' +import { encryptContentV2, decryptContentV2 } from '../encryption.js' + +describe('LocalSigner', () => { + it('exposes the identity pubkey synchronously', () => { + const identity = generateIdentity() + const signer = new LocalSigner(identity) + expect(signer.pubkey).toBe(identity.publicKey) + }) + + it('signEvent produces a valid signature equivalent to finalizeEvent', async () => { + const identity = generateIdentity() + const signer = new LocalSigner(identity) + const template = { + kind: 21000, + content: 'rpc', + tags: [['p', identity.publicKey]], + created_at: 1_700_000_000, + } + + const signed = await signer.signEvent(template) + const reference = finalizeEvent(template, identity.privateKey) + + expect(verifyEvent(signed)).toBe(true) + expect(signed.pubkey).toBe(identity.publicKey) + // Same template + same key ⇒ same id (id is deterministic over content). + expect(signed.id).toBe(reference.id) + }) + + it('nip44Encrypt round-trips with the counterparty signer', async () => { + const alice = generateIdentity() + const bob = generateIdentity() + const aliceSigner = new LocalSigner(alice) + const bobSigner = new LocalSigner(bob) + + const ciphertext = await aliceSigner.nip44Encrypt(bob.publicKey, 'secret') + const plaintext = await bobSigner.nip44Decrypt(alice.publicKey, ciphertext) + + expect(plaintext).toBe('secret') + }) + + it('nip44 output interops with the standalone encryptContentV2 helper', async () => { + const alice = generateIdentity() + const bob = generateIdentity() + const aliceSigner = new LocalSigner(alice) + + const viaSigner = await aliceSigner.nip44Encrypt(bob.publicKey, 'hello') + // The helper and the signer share NIP-44 v2 conversation-key derivation, + // so each can decrypt the other's ciphertext. + expect(decryptContentV2(bob, alice.publicKey, viaSigner)).toBe('hello') + + const viaHelper = encryptContentV2(alice, bob.publicKey, 'hello') + expect(await aliceSigner.nip44Decrypt(bob.publicKey, viaHelper)).toBe('hello') + }) +}) diff --git a/packages/nostr-client/src/client.ts b/packages/nostr-client/src/client.ts index 0d35b4d..e9067be 100644 --- a/packages/nostr-client/src/client.ts +++ b/packages/nostr-client/src/client.ts @@ -7,14 +7,7 @@ * - Automatic reconnection */ -import { - type Event, - type Filter, - type VerifiedEvent, - Relay, - SimplePool, - verifyEvent, -} from 'nostr-tools' +import { type Event, type Filter, Relay, SimplePool, verifyEvent, nip19 } from 'nostr-tools' import { createAuthEvent } from './events.js' import type { NostrClientConfig, @@ -156,10 +149,15 @@ export class NostrClient { // We need to extract the challenge and create our auth response const challenge = evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? '' - const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge) - // Verify the event to get a VerifiedEvent type + const authEvent = await createAuthEvent( + this.config.signer, + connection.config.url, + challenge + ) + // The signer returns a fully-signed event; re-verify defensively + // (a remote bunker could in principle return a malformed reply). if (verifyEvent(authEvent)) { - return authEvent as VerifiedEvent + return authEvent } throw new Error('Failed to create valid auth event') }) @@ -393,13 +391,13 @@ export class NostrClient { * Get the machine's public key */ get publicKey(): string { - return this.config.identity.publicKey + return this.config.signer.pubkey } /** * Get the machine's npub */ get npub(): string { - return this.config.identity.npub + return nip19.npubEncode(this.config.signer.pubkey) } } diff --git a/packages/nostr-client/src/events.ts b/packages/nostr-client/src/events.ts index 7b04d2f..82cc604 100644 --- a/packages/nostr-client/src/events.ts +++ b/packages/nostr-client/src/events.ts @@ -2,87 +2,33 @@ * Event creation utilities for Lamassu ATM */ -import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools' -import { encryptContent } from './encryption.js' -import { - type MachineIdentity, - type MachineStatus, - type TransactionRecord, - LamassuEventKind, -} from './types.js' +import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools' +import type { Signer } from './signer.js' +import { LamassuEventKind } from './types.js' /** - * Create a signed event - */ -export function createSignedEvent( - identity: MachineIdentity, - event: Omit -): Event { - const unsigned: UnsignedEvent = { - ...event, - pubkey: identity.publicKey, - } - - return finalizeEvent(unsigned, identity.privateKey) -} - -/** - * Create a machine status event (Kind 30078) + * Sign an event template with the given signer. * - * This is a replaceable event that represents the current machine state. - * Content is encrypted with NIP-44 for the operator. + * Thin async wrapper over `Signer.signEvent` — the signer sets `pubkey`, + * `id` and `sig`. With a `BunkerSigner` this is a relay round-trip. */ -export function createMachineStatusEvent( - identity: MachineIdentity, - operatorPubkey: string, - status: MachineStatus -): Event { - const encryptedContent = encryptContent(identity, operatorPubkey, status) - - return createSignedEvent(identity, { - kind: LamassuEventKind.MachineStatus, - content: encryptedContent, - tags: [ - ['d', 'status'], - ['p', operatorPubkey], - ], - created_at: Math.floor(Date.now() / 1000), - }) +export function createSignedEvent(signer: Signer, template: EventTemplate): Promise { + return signer.signEvent(template) } /** - * Create a transaction record event (Kind 30079) + * Create a NIP-42 auth event for relay authentication. * - * Replaceable event for each transaction, identified by txid. - * Content is encrypted with NIP-44 for the operator. - */ -export function createTransactionEvent( - identity: MachineIdentity, - operatorPubkey: string, - transaction: TransactionRecord -): Event { - const encryptedContent = encryptContent(identity, operatorPubkey, transaction) - - return createSignedEvent(identity, { - kind: LamassuEventKind.TransactionRecord, - content: encryptedContent, - tags: [ - ['d', `tx:${transaction.txid}`], - ['p', operatorPubkey], - ], - created_at: Math.floor(Date.now() / 1000), - }) -} - -/** - * Create a NIP-42 auth event for relay authentication + * Signed as the spire identity (kind 22242). Under the bunker this kind + * must be present in the signer policy (`SPIRE_POLICY_RULES`) or the sign + * request is rejected — see aiolabs/spirekeeper#26. */ export function createAuthEvent( - identity: MachineIdentity, + signer: Signer, relayUrl: string, challenge: string -): Event { - return createSignedEvent(identity, { +): Promise { + return signer.signEvent({ kind: LamassuEventKind.Auth, content: '', tags: [ diff --git a/packages/nostr-client/src/index.ts b/packages/nostr-client/src/index.ts index 417f666..eb16f5f 100644 --- a/packages/nostr-client/src/index.ts +++ b/packages/nostr-client/src/index.ts @@ -15,30 +15,32 @@ * import { * NostrClient, * generateIdentity, - * createMachineStatusEvent + * LocalSigner, + * createSignedEvent * } from '@bitSpire/nostr-client' * - * // Create or load identity - * const identity = generateIdentity() + * // Create or load identity, wrap it in a signer + * const signer = new LocalSigner(generateIdentity()) * * // Create client * const client = new NostrClient({ * relays: [ * { url: 'wss://relay.youratm.company', requiresAuth: true } * ], - * identity + * signer * }) * * // Connect * await client.connect() * - * // Publish machine status - * const statusEvent = createMachineStatusEvent( - * identity, - * operatorPubkey, - * { online: true, ... } - * ) - * await client.publish(statusEvent) + * // Sign + publish an event + * const event = await createSignedEvent(signer, { + * kind: 30078, + * created_at: Math.floor(Date.now() / 1000), + * tags: [['d', 'status']], + * content: '...' + * }) + * await client.publish(event) * ``` */ @@ -55,25 +57,15 @@ export { bytesToHex, } from './identity.js' -// Event creation -export { - createSignedEvent, - createMachineStatusEvent, - createTransactionEvent, - createAuthEvent, - validateEvent, - generateTxId, -} from './events.js' +// Signing abstraction +export { LocalSigner } from './signer.js' +export type { Signer } from './signer.js' -// Encryption -export { - encryptContent, - decryptContent, - decryptJSON, - // NIP-44 v2 (standard, for CLINK protocol) - encryptContentV2, - decryptContentV2, -} from './encryption.js' +// Event creation +export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js' + +// Encryption — NIP-44 v2 (used by the dormant CLINK client + tests) +export { encryptContentV2, decryptContentV2 } from './encryption.js' // Types export type { diff --git a/packages/nostr-client/src/signer.ts b/packages/nostr-client/src/signer.ts new file mode 100644 index 0000000..3d3571b --- /dev/null +++ b/packages/nostr-client/src/signer.ts @@ -0,0 +1,64 @@ +/** + * Signing + NIP-44 abstraction. + * + * Decouples every signing / encryption call site from the concrete key + * material. Two implementations: + * + * - `LocalSigner` holds an nsec in-process. Used for dev / ephemeral + * identities and as the transitional fallback when no bunker pairing + * exists. The underlying crypto is synchronous. + * - `BunkerSigner` (Phase B, aiolabs/bitspire#52) routes to a remote + * NIP-46 nsecbunkerd so no operator key ever lives on the ATM. + * + * `pubkey` is the *signing* identity and is always known synchronously — + * from the local nsec, or from the spire seed before the bunker connects — + * so subscription filters and `p` tags need no refactor when the backing + * implementation changes. + * + * All methods are async: the bunker path is a relay round-trip. The local + * path satisfies the contract with immediately-resolved promises so call + * sites are bunker-ready without further change. + */ + +import { type EventTemplate, type VerifiedEvent, finalizeEvent, nip44 } from 'nostr-tools' +import type { MachineIdentity } from './types.js' + +export interface Signer { + /** Hex pubkey of the signing identity. */ + readonly pubkey: string + /** Sign an unsigned event template, returning a fully-signed event. */ + signEvent(template: EventTemplate): Promise + /** NIP-44 v2 encrypt `plaintext` for `peerPubkey`. */ + nip44Encrypt(peerPubkey: string, plaintext: string): Promise + /** NIP-44 v2 decrypt `ciphertext` from `peerPubkey`. */ + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise +} + +/** + * In-process signer backed by a local nsec. The crypto is synchronous; + * the async surface is satisfied by immediately-resolved promises so call + * sites are identical whether the signer is local or a remote bunker. + */ +export class LocalSigner implements Signer { + readonly pubkey: string + readonly #privateKey: Uint8Array + + constructor(identity: MachineIdentity) { + this.pubkey = identity.publicKey + this.#privateKey = identity.privateKey + } + + signEvent(template: EventTemplate): Promise { + return Promise.resolve(finalizeEvent(template, this.#privateKey)) + } + + nip44Encrypt(peerPubkey: string, plaintext: string): Promise { + const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey) + return Promise.resolve(nip44.v2.encrypt(plaintext, conversationKey)) + } + + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise { + const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey) + return Promise.resolve(nip44.v2.decrypt(ciphertext, conversationKey)) + } +} diff --git a/packages/nostr-client/src/types.ts b/packages/nostr-client/src/types.ts index 068a9e2..43b44bc 100644 --- a/packages/nostr-client/src/types.ts +++ b/packages/nostr-client/src/types.ts @@ -2,7 +2,8 @@ * Nostr client type definitions for Lamassu ATM */ -import type { Event, UnsignedEvent } from 'nostr-tools' +import type { Event } from 'nostr-tools' +import type { Signer } from './signer.js' /** Connection states for relay */ export type ConnectionState = @@ -25,6 +26,7 @@ export interface RelayConfig { /** Machine identity configuration */ export interface MachineIdentity { + // pragma: allowlist secret /** Private key in hex format */ privateKey: Uint8Array /** Public key in hex format */ @@ -37,8 +39,8 @@ export interface MachineIdentity { export interface NostrClientConfig { /** Relays to connect to */ relays: RelayConfig[] - /** Machine identity (keypair) */ - identity: MachineIdentity + /** Signer for the machine identity (local nsec or remote bunker) */ + signer: Signer /** Connection timeout in ms (default: 10000) */ connectionTimeout?: number /** Reconnect automatically on disconnect */