refactor(nostr): route signing + encryption through a Signer abstraction

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 19:56:35 +02:00
commit d6b22e1156
16 changed files with 300 additions and 260 deletions

View file

@ -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<void>
} {
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<void>((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<void>
} {
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<void>
} {
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<void>((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)

View file

@ -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<LnbitsConfig>
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<string> {
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<T> {
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<string, unknown>
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<void> {
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
}