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:
parent
627d5e63e5
commit
d6b22e1156
16 changed files with 300 additions and 260 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue