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

@ -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)
})
})

View 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')
})
})

View file

@ -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)
}
}

View file

@ -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: [

View file

@ -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 {

View 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))
}
}

View file

@ -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 */