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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
58
packages/nostr-client/src/__tests__/signer.test.ts
Normal file
58
packages/nostr-client/src/__tests__/signer.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UnsignedEvent, 'pubkey'>
|
||||
): 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<VerifiedEvent> {
|
||||
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<VerifiedEvent> {
|
||||
return signer.signEvent({
|
||||
kind: LamassuEventKind.Auth,
|
||||
content: '',
|
||||
tags: [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
64
packages/nostr-client/src/signer.ts
Normal file
64
packages/nostr-client/src/signer.ts
Normal file
|
|
@ -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<VerifiedEvent>
|
||||
/** NIP-44 v2 encrypt `plaintext` for `peerPubkey`. */
|
||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>
|
||||
/** NIP-44 v2 decrypt `ciphertext` from `peerPubkey`. */
|
||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<VerifiedEvent> {
|
||||
return Promise.resolve(finalizeEvent(template, this.#privateKey))
|
||||
}
|
||||
|
||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey)
|
||||
return Promise.resolve(nip44.v2.encrypt(plaintext, conversationKey))
|
||||
}
|
||||
|
||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey)
|
||||
return Promise.resolve(nip44.v2.decrypt(ciphertext, conversationKey))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue