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
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue