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

@ -13,7 +13,7 @@
*/ */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client' import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
@ -64,11 +64,11 @@ async function main() {
console.error(`Generating invoice for ${amountSats} sats...`) console.error(`Generating invoice for ${amountSats} sats...`)
const identity = loadIdentityFromHex(atmPrivateKey) const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: relayUrl }], relays: [{ url: relayUrl }],
identity, signer,
}) })
await nostrClient.connect() await nostrClient.connect()
@ -76,7 +76,7 @@ async function main() {
serverPubkey: lnbitsServerPubkey, serverPubkey: lnbitsServerPubkey,
relays: [relayUrl], relays: [relayUrl],
}) })
lnbits.initialize(nostrClient, identity) lnbits.initialize(nostrClient, signer)
const wallets = await lnbits.listWallets() const wallets = await lnbits.listWallets()
const wallet = wallets[0] const wallet = wallets[0]

View file

@ -51,18 +51,18 @@ onMounted(async () => {
atmStore.initError = 'maintenance' atmStore.initError = 'maintenance'
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
try { try {
const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import( const { NostrClient, LocalSigner, loadIdentityFromHex, createSignedEvent } = await import(
'@bitSpire/nostr-client' '@bitSpire/nostr-client'
) )
const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null
const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
if (privKey && relayUrl) { if (privKey && relayUrl) {
const identity = loadIdentityFromHex(privKey) const signer = new LocalSigner(loadIdentityFromHex(privKey))
const client = new NostrClient({ relays: [{ url: relayUrl }], identity }) const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
await client.connect() await client.connect()
const publishBeacon = () => { const publishBeacon = async () => {
const event = createSignedEvent(identity, { const event = await createSignedEvent(signer, {
kind: 30078, kind: 30078,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']], tags: [['d', 'atm-availability']],
@ -77,8 +77,8 @@ onMounted(async () => {
}) })
client.publish(event).catch(() => {}) client.publish(event).catch(() => {})
} }
publishBeacon() void publishBeacon()
setInterval(publishBeacon, 5 * 60 * 1000) setInterval(() => void publishBeacon(), 5 * 60 * 1000)
} }
} catch (e) { } catch (e) {
console.warn('[App] Failed to start maintenance beacon:', e) console.warn('[App] Failed to start maintenance beacon:', e)

View file

@ -13,7 +13,7 @@
import { watch, type Ref } from 'vue' import { watch, type Ref } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client' import type { NostrClient, Signer } from '@bitSpire/nostr-client'
import { createSignedEvent } from '@bitSpire/nostr-client' import { createSignedEvent } from '@bitSpire/nostr-client'
type CashLevel = 'none' | 'low' | 'good' | 'full' type CashLevel = 'none' | 'low' | 'good' | 'full'
@ -26,7 +26,7 @@ interface AvailabilitySnapshot {
interface UseAvailabilityBroadcastOptions { interface UseAvailabilityBroadcastOptions {
nostrClient: NostrClient nostrClient: NostrClient
identity: MachineIdentity signer: Signer
/** Reactive inventory: denomination -> count */ /** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>> inventory: Ref<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */ /** Reactive Lightning.Pub balance in sats (null = unknown) */
@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
} }
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options
let lastSnapshot: AvailabilitySnapshot | null = null let lastSnapshot: AvailabilitySnapshot | null = null
@ -73,7 +73,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
model, model,
}) })
const event = createSignedEvent(identity, { const event = await createSignedEvent(signer, {
kind: 30078, kind: 30078,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']], tags: [['d', 'atm-availability']],

View file

@ -14,8 +14,10 @@
import { import {
NostrClient, NostrClient,
LocalSigner,
generateIdentity, generateIdentity,
loadIdentityFromHex, loadIdentityFromHex,
type Signer,
type MachineIdentity, type MachineIdentity,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
@ -234,7 +236,7 @@ interface LightningServices {
nostrClient: NostrClient nostrClient: NostrClient
lightningPub: LightningBackend lightningPub: LightningBackend
clink: CLINKClient clink: CLINKClient
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */ /** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[] operatorPubkeys: string[]
atmServices: ATMServices atmServices: ATMServices
@ -451,10 +453,15 @@ export async function initializeLightningServices(options?: {
} }
console.log('[Lightning] ATM pubkey:', identity.publicKey) console.log('[Lightning] ATM pubkey:', identity.publicKey)
// Wrap the identity in a signer. Phase A always uses LocalSigner (in-process
// nsec); Phase B swaps in a BunkerSigner here without touching the call
// sites below. See aiolabs/bitspire#52.
const signer: Signer = new LocalSigner(identity)
// Create Nostr client // Create Nostr client
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: CONFIG.relayUrl }], relays: [{ url: CONFIG.relayUrl }],
identity, signer,
}) })
await nostrClient.connect() await nostrClient.connect()
@ -465,7 +472,7 @@ export async function initializeLightningServices(options?: {
serverPubkey: CONFIG.lnbitsServerPubkey, serverPubkey: CONFIG.lnbitsServerPubkey,
relays: [CONFIG.relayUrl], relays: [CONFIG.relayUrl],
}) })
lnbits.initialize(nostrClient, identity) lnbits.initialize(nostrClient, signer)
_lnbitsRef = lnbits _lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized') console.log('[Lightning] LNbits client initialized')
@ -588,7 +595,7 @@ export async function initializeLightningServices(options?: {
nostrClient, nostrClient,
lightningPub, lightningPub,
clink, clink,
identity, signer,
operatorPubkeys: CONFIG.operatorPubkeys, operatorPubkeys: CONFIG.operatorPubkeys,
atmServices, atmServices,
onOfferRequest: (callback: OfferRequestCallback) => { onOfferRequest: (callback: OfferRequestCallback) => {

View file

@ -23,12 +23,10 @@
*/ */
import { import {
type MachineIdentity, type Signer,
type NostrClient, type NostrClient,
type Event, type Event,
createSignedEvent, createSignedEvent,
decryptContentV2,
encryptContentV2,
validateEvent, validateEvent,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
@ -47,11 +45,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorConfigServiceConfig { export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */ /** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient nostrClient: NostrClient
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */ /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string machineId?: string
} }
@ -72,7 +70,7 @@ export async function startOperatorConfigService(
return { stop: () => {} } return { stop: () => {} }
} }
const api = window.electronAPI const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.identity.publicKey const machineId = cfg.machineId ?? cfg.signer.pubkey
// Bootstrap hello-event on first boot (best-effort — failure leaves the // Bootstrap hello-event on first boot (best-effort — failure leaves the
// gate null so the next boot retries). // gate null so the next boot retries).
@ -88,7 +86,7 @@ export async function startOperatorConfigService(
[ [
{ {
kinds: [KIND_NIP78], kinds: [KIND_NIP78],
'#p': [cfg.identity.publicKey], '#p': [cfg.signer.pubkey],
'#d': [dTag], '#d': [dTag],
authors: cfg.operatorPubkeys, authors: cfg.operatorPubkeys,
}, },
@ -150,7 +148,7 @@ async function handleOperatorConfigEvent(
// 4. Decrypt content (NIP-44 v2). // 4. Decrypt content (NIP-44 v2).
let parsed: { positions: Record<string, { denomination: number; count: number }> } let parsed: { positions: Record<string, { denomination: number; count: number }> }
try { try {
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed parsed = JSON.parse(plaintext) as typeof parsed
} catch (err) { } catch (err) {
console.error('[OperatorConfig] Decrypt/parse failed:', err) console.error('[OperatorConfig] Decrypt/parse failed:', err)
@ -223,10 +221,10 @@ async function maybePublishBootstrap(
for (const c of cassettes) { for (const c of cassettes) {
positions[String(c.position)] = { denomination: c.denomination, count: c.count } positions[String(c.position)] = { denomination: c.denomination, count: c.count }
} }
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions }) const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
const dTag = atmStateDTag(machineId) const dTag = atmStateDTag(machineId)
const event = createSignedEvent(cfg.identity, { const event = await createSignedEvent(cfg.signer, {
kind: KIND_NIP78, kind: KIND_NIP78,
content: ciphertext, content: ciphertext,
tags: [ tags: [

View file

@ -56,10 +56,9 @@
*/ */
import { import {
type MachineIdentity, type Signer,
type NostrClient, type NostrClient,
type Event, type Event,
decryptContentV2,
validateEvent, validateEvent,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
@ -80,11 +79,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorFeesServiceConfig { export interface OperatorFeesServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */ /** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient nostrClient: NostrClient
/** ATM's nostr identity. Used to decrypt operator events. */ /** Signer for the ATM identity. Decrypts operator events. */
identity: MachineIdentity signer: Signer
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */ /** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string machineId?: string
/** /**
* Called when a valid fee-config event is applied. Renderer should * Called when a valid fee-config event is applied. Renderer should
@ -112,7 +111,7 @@ export async function startOperatorFeesService(
return { stop: () => {} } return { stop: () => {} }
} }
const api = window.electronAPI const api = window.electronAPI
const machineId = cfg.machineId ?? cfg.identity.publicKey const machineId = cfg.machineId ?? cfg.signer.pubkey
// Subscribe to operator-published fee config events. // Subscribe to operator-published fee config events.
const dTag = feeConfigDTag(machineId) const dTag = feeConfigDTag(machineId)
@ -120,7 +119,7 @@ export async function startOperatorFeesService(
[ [
{ {
kinds: [KIND_NIP78], kinds: [KIND_NIP78],
'#p': [cfg.identity.publicKey], '#p': [cfg.signer.pubkey],
'#d': [dTag], '#d': [dTag],
authors: cfg.operatorPubkeys, authors: cfg.operatorPubkeys,
}, },
@ -189,7 +188,7 @@ async function handleFeeConfigEvent(
// fields (v2 forward-compat — future promo payloads). // fields (v2 forward-compat — future promo payloads).
let parsed: ParsedFeePayload let parsed: ParsedFeePayload
try { try {
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
const raw = JSON.parse(plaintext) as Record<string, unknown> const raw = JSON.parse(plaintext) as Record<string, unknown>
parsed = parseV1Payload(raw) parsed = parseV1Payload(raw)
} catch (err) { } catch (err) {

View file

@ -676,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => {
initialize(servicesWithInventory) initialize(servicesWithInventory)
// Start broadcasting availability (Kind 30078) with 5-minute heartbeat // Start broadcasting availability (Kind 30078) with 5-minute heartbeat
startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value) startAvailabilityBroadcast(services.nostrClient, services.signer, machineModel.value)
// Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes // Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes
// to kind-30078 cassette config events + publishes one-shot bootstrap // to kind-30078 cassette config events + publishes one-shot bootstrap
operatorConfigSvc?.stop() operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({ operatorConfigSvc = await startOperatorConfigService({
nostrClient: services.nostrClient, nostrClient: services.nostrClient,
identity: services.identity, signer: services.signer,
operatorPubkeys: services.operatorPubkeys, operatorPubkeys: services.operatorPubkeys,
}) })
@ -692,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop() operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({ operatorFeesSvc = await startOperatorFeesService({
nostrClient: services.nostrClient, nostrClient: services.nostrClient,
identity: services.identity, signer: services.signer,
operatorPubkeys: services.operatorPubkeys, operatorPubkeys: services.operatorPubkeys,
onApply: applyFeeConfig, onApply: applyFeeConfig,
}) })
@ -989,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => {
}) })
// Start broadcasting availability (Kind 30078) // Start broadcasting availability (Kind 30078)
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value)
// Operator-config consumer (aiolabs/lamassu-next#56) // Operator-config consumer (aiolabs/lamassu-next#56)
operatorConfigSvc?.stop() operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({ operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
identity: lightning.identity, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
}) })
@ -1003,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop() operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({ operatorFeesSvc = await startOperatorFeesService({
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
identity: lightning.identity, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
onApply: applyFeeConfig, onApply: applyFeeConfig,
}) })
@ -1295,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => {
// Real hardware connected — disable mock bill simulator // Real hardware connected — disable mock bill simulator
debugMode.value = false debugMode.value = false
// Start broadcasting availability (Kind 30078) // Start broadcasting availability (Kind 30078)
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value)
// Operator-config consumer (aiolabs/lamassu-next#56) // Operator-config consumer (aiolabs/lamassu-next#56)
operatorConfigSvc?.stop() operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({ operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
identity: lightning.identity, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
}) })
@ -1309,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop() operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({ operatorFeesSvc = await startOperatorFeesService({
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
identity: lightning.identity, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
onApply: applyFeeConfig, onApply: applyFeeConfig,
}) })
@ -1437,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => {
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */ /** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
let stopAvailabilityBroadcast: (() => void) | null = null let stopAvailabilityBroadcast: (() => void) | null = null
async function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) { async function startAvailabilityBroadcast(nostrClient: any, signer: any, model: string) {
if (stopAvailabilityBroadcast) return if (stopAvailabilityBroadcast) return
// Ensure persisted inventory is loaded before first broadcast // Ensure persisted inventory is loaded before first broadcast
@ -1445,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => {
const { stop } = useAvailabilityBroadcast({ const { stop } = useAvailabilityBroadcast({
nostrClient, nostrClient,
identity, signer,
inventory: persistedInventory, inventory: persistedInventory,
balanceSats, balanceSats,
fiatCode: fiatCode.value, fiatCode: fiatCode.value,

View file

@ -17,6 +17,7 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { import {
encryptContentV2, encryptContentV2,
LocalSigner,
type MachineIdentity, type MachineIdentity,
type NostrClient, type NostrClient,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
@ -152,7 +153,7 @@ describe('isAuthenticServerEvent', () => {
describe('LnbitsClient.handleReply wiring', () => { describe('LnbitsClient.handleReply wiring', () => {
function makeMockNostr(): { function makeMockNostr(): {
nostr: NostrClient nostr: NostrClient
triggerEvent: (ev: NostrEvent) => void triggerEvent: (ev: NostrEvent) => Promise<void>
} { } {
let captured: ((ev: NostrEvent) => void) | null = null let captured: ((ev: NostrEvent) => void) | null = null
const nostr = { const nostr = {
@ -168,9 +169,12 @@ describe('LnbitsClient.handleReply wiring', () => {
} as unknown as NostrClient } as unknown as NostrClient
return { return {
nostr, 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') if (!captured) throw new Error('handleReply not wired yet')
captured(ev) captured(ev)
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}, },
} }
} }
@ -188,7 +192,7 @@ describe('LnbitsClient.handleReply wiring', () => {
client: LnbitsClient client: LnbitsClient
serverIdentity: MachineIdentity serverIdentity: MachineIdentity
recipientIdentity: MachineIdentity recipientIdentity: MachineIdentity
triggerEvent: (ev: NostrEvent) => void triggerEvent: (ev: NostrEvent) => Promise<void>
} { } {
const serverIdentity = makeIdentity() const serverIdentity = makeIdentity()
const recipientIdentity = makeIdentity() const recipientIdentity = makeIdentity()
@ -197,11 +201,11 @@ describe('LnbitsClient.handleReply wiring', () => {
serverPubkey: serverIdentity.publicKey, serverPubkey: serverIdentity.publicKey,
relays: ['ws://test/'], relays: ['ws://test/'],
}) })
client.initialize(nostr, recipientIdentity) client.initialize(nostr, new LocalSigner(recipientIdentity))
return { client, serverIdentity, recipientIdentity, triggerEvent } 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() const { client, serverIdentity, triggerEvent } = setupClient()
// Pre-register a pending entry as `sendRpc` would have. // Pre-register a pending entry as `sendRpc` would have.
@ -233,7 +237,7 @@ describe('LnbitsClient.handleReply wiring', () => {
attackerKey, attackerKey,
) )
triggerEvent(forged) await triggerEvent(forged)
expect(resolveCalls).toBe(0) expect(resolveCalls).toBe(0)
expect(rejectCalls).toBe(0) expect(rejectCalls).toBe(0)
@ -241,7 +245,7 @@ describe('LnbitsClient.handleReply wiring', () => {
expect((client as any).pending.has('req-forged')).toBe(true) 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 } = const { client, serverIdentity, recipientIdentity, triggerEvent } =
setupClient() setupClient()
@ -277,7 +281,7 @@ describe('LnbitsClient.handleReply wiring', () => {
serverIdentity.privateKey, serverIdentity.privateKey,
) )
triggerEvent(reply) await triggerEvent(reply)
expect(resolved).toMatchObject({ expect(resolved).toMatchObject({
status: 'OK', status: 'OK',
@ -290,7 +294,7 @@ describe('LnbitsClient.handleReply wiring', () => {
// fine, we only need to assert resolve fired with the right payload. // 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 // This is the test scenario where the #49 guard's contribution
// actually shows up: ev.id is the dedup key for the client-global // actually shows up: ev.id is the dedup key for the client-global
// exact-replay cache. WITHOUT the guard, an attacker could publish // exact-replay cache. WITHOUT the guard, an attacker could publish
@ -320,7 +324,7 @@ describe('LnbitsClient.handleReply wiring', () => {
attackerKey, attackerKey,
) )
triggerEvent(forged) await triggerEvent(forged)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((client as any).seenEventIds.size).toBe(0) expect((client as any).seenEventIds.size).toBe(0)
@ -345,7 +349,7 @@ describe('LnbitsClient.handleReply wiring', () => {
describe('LnbitsClient subscribe-payments dedup (#50)', () => { describe('LnbitsClient subscribe-payments dedup (#50)', () => {
function makeMockNostr(): { function makeMockNostr(): {
nostr: NostrClient nostr: NostrClient
triggerEvent: (ev: NostrEvent) => void triggerEvent: (ev: NostrEvent) => Promise<void>
} { } {
let captured: ((ev: NostrEvent) => void) | null = null let captured: ((ev: NostrEvent) => void) | null = null
const nostr = { const nostr = {
@ -361,9 +365,12 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
} as unknown as NostrClient } as unknown as NostrClient
return { return {
nostr, 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') if (!captured) throw new Error('handleReply not wired yet')
captured(ev) 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 serverIdentity = makeIdentity()
const recipientIdentity = makeIdentity() const recipientIdentity = makeIdentity()
const { nostr, triggerEvent } = makeMockNostr() const { nostr, triggerEvent } = makeMockNostr()
@ -444,7 +451,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
serverPubkey: serverIdentity.publicKey, serverPubkey: serverIdentity.publicKey,
relays: ['ws://test/'], relays: ['ws://test/'],
}) })
client.initialize(nostr, recipientIdentity) client.initialize(nostr, new LocalSigner(recipientIdentity))
const { received } = preregisterSub(client, 'sub-1') const { received } = preregisterSub(client, 'sub-1')
@ -455,14 +462,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
paymentHash: 'hash-aaa', paymentHash: 'hash-aaa',
}) })
// Same bytes both times — same ev.id, same payment_hash. // Same bytes both times — same ev.id, same payment_hash.
triggerEvent(ev) await triggerEvent(ev)
triggerEvent(ev) await triggerEvent(ev)
expect(received).toHaveLength(1) expect(received).toHaveLength(1)
expect(received[0]!.payment_hash).toBe('hash-aaa') 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 serverIdentity = makeIdentity()
const recipientIdentity = makeIdentity() const recipientIdentity = makeIdentity()
const { nostr, triggerEvent } = makeMockNostr() const { nostr, triggerEvent } = makeMockNostr()
@ -470,7 +477,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
serverPubkey: serverIdentity.publicKey, serverPubkey: serverIdentity.publicKey,
relays: ['ws://test/'], relays: ['ws://test/'],
}) })
client.initialize(nostr, recipientIdentity) client.initialize(nostr, new LocalSigner(recipientIdentity))
const { received } = preregisterSub(client, 'sub-1') const { received } = preregisterSub(client, 'sub-1')
@ -493,14 +500,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
createdAt: now + 1, createdAt: now + 1,
}) })
expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this
triggerEvent(ev1) await triggerEvent(ev1)
triggerEvent(ev2) await triggerEvent(ev2)
expect(received).toHaveLength(1) expect(received).toHaveLength(1)
expect(received[0]!.payment_hash).toBe('hash-bbb') 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 serverIdentity = makeIdentity()
const recipientIdentity = makeIdentity() const recipientIdentity = makeIdentity()
const { nostr, triggerEvent } = makeMockNostr() const { nostr, triggerEvent } = makeMockNostr()
@ -508,7 +515,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
serverPubkey: serverIdentity.publicKey, serverPubkey: serverIdentity.publicKey,
relays: ['ws://test/'], relays: ['ws://test/'],
}) })
client.initialize(nostr, recipientIdentity) client.initialize(nostr, new LocalSigner(recipientIdentity))
const { received } = preregisterSub(client, 'sub-1') const { received } = preregisterSub(client, 'sub-1')
@ -525,14 +532,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
paymentHash: 'hash-2', paymentHash: 'hash-2',
createdAt: Math.floor(Date.now() / 1000) + 2, createdAt: Math.floor(Date.now() / 1000) + 2,
}) })
triggerEvent(ev1) await triggerEvent(ev1)
triggerEvent(ev2) await triggerEvent(ev2)
expect(received).toHaveLength(2) expect(received).toHaveLength(2)
expect(received.map((p) => p.payment_hash)).toEqual(['hash-1', 'hash-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 serverIdentity = makeIdentity()
const recipientIdentity = makeIdentity() const recipientIdentity = makeIdentity()
const { nostr, triggerEvent } = makeMockNostr() const { nostr, triggerEvent } = makeMockNostr()
@ -540,7 +547,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
serverPubkey: serverIdentity.publicKey, serverPubkey: serverIdentity.publicKey,
relays: ['ws://test/'], relays: ['ws://test/'],
}) })
client.initialize(nostr, recipientIdentity) client.initialize(nostr, new LocalSigner(recipientIdentity))
const a = preregisterSub(client, 'sub-A') const a = preregisterSub(client, 'sub-A')
const b = preregisterSub(client, 'sub-B') const b = preregisterSub(client, 'sub-B')
@ -561,8 +568,8 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
paymentHash: 'hash-shared', paymentHash: 'hash-shared',
createdAt: Math.floor(Date.now() / 1000) + 1, createdAt: Math.floor(Date.now() / 1000) + 1,
}) })
triggerEvent(evA) await triggerEvent(evA)
triggerEvent(evB) await triggerEvent(evB)
// Each subscription sees its own push exactly once. // Each subscription sees its own push exactly once.
expect(a.received).toHaveLength(1) expect(a.received).toHaveLength(1)

View file

@ -22,12 +22,10 @@
import { import {
type NostrClient, type NostrClient,
type MachineIdentity, type Signer,
type Event as NostrEvent, type Event as NostrEvent,
encryptContentV2,
decryptContentV2,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
import { finalizeEvent, verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import type { import type {
LnbitsConfig, LnbitsConfig,
@ -121,7 +119,7 @@ const SEEN_PAYMENT_HASHES_MAX = 500
export class LnbitsClient { export class LnbitsClient {
private readonly config: Required<LnbitsConfig> private readonly config: Required<LnbitsConfig>
private nostr: NostrClient | null = null private nostr: NostrClient | null = null
private identity: MachineIdentity | null = null private signer: Signer | null = null
private requestCounter = 0 private requestCounter = 0
private readonly pending = new Map< private readonly pending = new Map<
string, string,
@ -150,9 +148,9 @@ export class LnbitsClient {
} }
} }
initialize(nostr: NostrClient, identity: MachineIdentity): void { initialize(nostr: NostrClient, signer: Signer): void {
this.nostr = nostr this.nostr = nostr
this.identity = identity this.signer = signer
this.startReplyListener() this.startReplyListener()
} }
@ -240,7 +238,7 @@ export class LnbitsClient {
onPush: PaymentPushCallback, onPush: PaymentPushCallback,
onClose?: SubscriptionCloseCallback, onClose?: SubscriptionCloseCallback,
): Promise<string> { ): Promise<string> {
if (!this.nostr || !this.identity) { if (!this.nostr || !this.signer) {
throw new Error('LnbitsClient.subscribePayments: client not initialized') throw new Error('LnbitsClient.subscribePayments: client not initialized')
} }
const requestId = this.nextRequestId('sub') const requestId = this.nextRequestId('sub')
@ -431,7 +429,7 @@ export class LnbitsClient {
requestId?: string requestId?: string
}, },
): Promise<T> { ): Promise<T> {
if (!this.nostr || !this.identity) { if (!this.nostr || !this.signer) {
throw new Error(`LnbitsClient.${rpcName}: client not initialized`) throw new Error(`LnbitsClient.${rpcName}: client not initialized`)
} }
const requestId = args.requestId ?? this.nextRequestId(rpcName) 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> if (args.query !== undefined) request.query = args.query as Record<string, unknown>
const plaintext = JSON.stringify(request) 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 // Build + sign the kind-21000 event via the signer. The server reads
// pubkey directly off the signature, so there's no separate // our pubkey directly off the signature, so there's no separate
// authIdentifier in the envelope (unlike LightningPubClient). // authIdentifier in the envelope (unlike LightningPubClient).
// //
// NIP-40 expiration: 5 minutes past now. Defence-in-depth at the // 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 // attacker can't bypass this by stripping the tag; the tag just
// lets the relay short-circuit earlier. // lets the relay short-circuit earlier.
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const event = finalizeEvent( const event = await this.signer.signEvent({
{ kind: LNBITS_KIND_RPC,
kind: LNBITS_KIND_RPC, content: encrypted,
content: encrypted, tags: [
tags: [ ['p', this.config.serverPubkey],
['p', this.config.serverPubkey], ['expiration', String(now + 300)],
['expiration', String(now + 300)], ],
], created_at: now,
created_at: now, })
},
this.identity.privateKey,
)
// The pending entry MUST be registered before publish so we don't race // The pending entry MUST be registered before publish so we don't race
// an extremely fast reply. // an extremely fast reply.
@ -508,8 +503,8 @@ export class LnbitsClient {
* based on `request_id` and `subscription_id`. * based on `request_id` and `subscription_id`.
*/ */
private startReplyListener(): void { private startReplyListener(): void {
if (!this.nostr || !this.identity) return if (!this.nostr || !this.signer) return
const myPubkey = this.identity.publicKey const myPubkey = this.signer.pubkey
const since = Math.floor(Date.now() / 1000) - 5 const since = Math.floor(Date.now() / 1000) - 5
this.relaySubIdForReplies = this.nostr.subscribe( 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 { private async handleReply(ev: NostrEvent): Promise<void> {
if (!this.identity) return const signer = this.signer
if (!signer) return
if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return
// Exact-replay dedup. Skip if we've already processed this event id. // Exact-replay dedup. Skip if we've already processed this event id.
// Safe to trust `ev.id` here because `isAuthenticServerEvent` just // Safe to trust `ev.id` here because `isAuthenticServerEvent` just
// Schnorr-verified the event (`verifyEvent` recomputes the id and // Schnorr-verified the event (`verifyEvent` recomputes the id and
// confirms it matches the signed pubkey + body). Without that // confirms it matches the signed pubkey + body). Without that
// guarantee an attacker could pre-poison this set with chosen ids. // 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 if (this.seenEventIds.has(ev.id)) return
this.recordSeenEventId(ev.id) this.recordSeenEventId(ev.id)
let plaintext: string let plaintext: string
try { try {
plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content) plaintext = await signer.nip44Decrypt(this.config.serverPubkey, ev.content)
} catch { } catch {
return // not our peer or wrong key return // not our peer or wrong key
} }

View file

@ -1,19 +1,15 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js' import { generateIdentity } from '../identity.js'
import { import { LocalSigner } from '../signer.js'
createSignedEvent, import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
createMachineStatusEvent, import { LamassuEventKind } from '../types.js'
createAuthEvent,
validateEvent,
generateTxId,
} from '../events.js'
import { LamassuEventKind, type MachineStatus } from '../types.js'
describe('events', () => { describe('events', () => {
describe('createSignedEvent', () => { describe('createSignedEvent', () => {
it('should create a properly signed event', () => { it('should create a properly signed event via the signer', async () => {
const identity = generateIdentity() const identity = generateIdentity()
const event = createSignedEvent(identity, { const signer = new LocalSigner(identity)
const event = await createSignedEvent(signer, {
kind: 1, kind: 1,
content: 'test', content: 'test',
tags: [], tags: [],
@ -25,48 +21,23 @@ describe('events', () => {
expect(event.content).toBe('test') expect(event.content).toBe('test')
expect(event.id).toMatch(/^[0-9a-f]{64}$/) expect(event.id).toMatch(/^[0-9a-f]{64}$/)
expect(event.sig).toMatch(/^[0-9a-f]{128}$/) expect(event.sig).toMatch(/^[0-9a-f]{128}$/)
}) expect(validateEvent(event)).toBe(true)
})
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()
}) })
}) })
describe('createAuthEvent', () => { describe('createAuthEvent', () => {
it('should create NIP-42 auth event', () => { it('should create a signed NIP-42 auth event (kind 22242)', async () => {
const identity = generateIdentity() const signer = new LocalSigner(generateIdentity())
const relayUrl = 'wss://relay.test.com' const relayUrl = 'wss://relay.test.com'
const challenge = 'random-challenge-string' 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.kind).toBe(LamassuEventKind.Auth)
expect(event.content).toBe('') expect(event.content).toBe('')
expect(event.tags).toContainEqual(['relay', relayUrl]) expect(event.tags).toContainEqual(['relay', relayUrl])
expect(event.tags).toContainEqual(['challenge', challenge]) 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 * - Automatic reconnection
*/ */
import { import { type Event, type Filter, Relay, SimplePool, verifyEvent, nip19 } from 'nostr-tools'
type Event,
type Filter,
type VerifiedEvent,
Relay,
SimplePool,
verifyEvent,
} from 'nostr-tools'
import { createAuthEvent } from './events.js' import { createAuthEvent } from './events.js'
import type { import type {
NostrClientConfig, NostrClientConfig,
@ -156,10 +149,15 @@ export class NostrClient {
// We need to extract the challenge and create our auth response // We need to extract the challenge and create our auth response
const challenge = const challenge =
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? '' evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge) const authEvent = await createAuthEvent(
// Verify the event to get a VerifiedEvent type 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)) { if (verifyEvent(authEvent)) {
return authEvent as VerifiedEvent return authEvent
} }
throw new Error('Failed to create valid auth event') throw new Error('Failed to create valid auth event')
}) })
@ -393,13 +391,13 @@ export class NostrClient {
* Get the machine's public key * Get the machine's public key
*/ */
get publicKey(): string { get publicKey(): string {
return this.config.identity.publicKey return this.config.signer.pubkey
} }
/** /**
* Get the machine's npub * Get the machine's npub
*/ */
get npub(): string { 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 * Event creation utilities for Lamassu ATM
*/ */
import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools' import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
import { encryptContent } from './encryption.js' import type { Signer } from './signer.js'
import { import { LamassuEventKind } from './types.js'
type MachineIdentity,
type MachineStatus,
type TransactionRecord,
LamassuEventKind,
} from './types.js'
/** /**
* Create a signed event * Sign an event template with the given signer.
*/
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)
* *
* This is a replaceable event that represents the current machine state. * Thin async wrapper over `Signer.signEvent` — the signer sets `pubkey`,
* Content is encrypted with NIP-44 for the operator. * `id` and `sig`. With a `BunkerSigner` this is a relay round-trip.
*/ */
export function createMachineStatusEvent( export function createSignedEvent(signer: Signer, template: EventTemplate): Promise<VerifiedEvent> {
identity: MachineIdentity, return signer.signEvent(template)
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),
})
} }
/** /**
* Create a transaction record event (Kind 30079) * Create a NIP-42 auth event for relay authentication.
* *
* Replaceable event for each transaction, identified by txid. * Signed as the spire identity (kind 22242). Under the bunker this kind
* Content is encrypted with NIP-44 for the operator. * must be present in the signer policy (`SPIRE_POLICY_RULES`) or the sign
*/ * request is rejected — see aiolabs/spirekeeper#26.
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
*/ */
export function createAuthEvent( export function createAuthEvent(
identity: MachineIdentity, signer: Signer,
relayUrl: string, relayUrl: string,
challenge: string challenge: string
): Event { ): Promise<VerifiedEvent> {
return createSignedEvent(identity, { return signer.signEvent({
kind: LamassuEventKind.Auth, kind: LamassuEventKind.Auth,
content: '', content: '',
tags: [ tags: [

View file

@ -15,30 +15,32 @@
* import { * import {
* NostrClient, * NostrClient,
* generateIdentity, * generateIdentity,
* createMachineStatusEvent * LocalSigner,
* createSignedEvent
* } from '@bitSpire/nostr-client' * } from '@bitSpire/nostr-client'
* *
* // Create or load identity * // Create or load identity, wrap it in a signer
* const identity = generateIdentity() * const signer = new LocalSigner(generateIdentity())
* *
* // Create client * // Create client
* const client = new NostrClient({ * const client = new NostrClient({
* relays: [ * relays: [
* { url: 'wss://relay.youratm.company', requiresAuth: true } * { url: 'wss://relay.youratm.company', requiresAuth: true }
* ], * ],
* identity * signer
* }) * })
* *
* // Connect * // Connect
* await client.connect() * await client.connect()
* *
* // Publish machine status * // Sign + publish an event
* const statusEvent = createMachineStatusEvent( * const event = await createSignedEvent(signer, {
* identity, * kind: 30078,
* operatorPubkey, * created_at: Math.floor(Date.now() / 1000),
* { online: true, ... } * tags: [['d', 'status']],
* ) * content: '...'
* await client.publish(statusEvent) * })
* await client.publish(event)
* ``` * ```
*/ */
@ -55,25 +57,15 @@ export {
bytesToHex, bytesToHex,
} from './identity.js' } from './identity.js'
// Event creation // Signing abstraction
export { export { LocalSigner } from './signer.js'
createSignedEvent, export type { Signer } from './signer.js'
createMachineStatusEvent,
createTransactionEvent,
createAuthEvent,
validateEvent,
generateTxId,
} from './events.js'
// Encryption // Event creation
export { export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js'
encryptContent,
decryptContent, // Encryption — NIP-44 v2 (used by the dormant CLINK client + tests)
decryptJSON, export { encryptContentV2, decryptContentV2 } from './encryption.js'
// NIP-44 v2 (standard, for CLINK protocol)
encryptContentV2,
decryptContentV2,
} from './encryption.js'
// Types // Types
export type { 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 * 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 */ /** Connection states for relay */
export type ConnectionState = export type ConnectionState =
@ -25,6 +26,7 @@ export interface RelayConfig {
/** Machine identity configuration */ /** Machine identity configuration */
export interface MachineIdentity { export interface MachineIdentity {
// pragma: allowlist secret
/** Private key in hex format */ /** Private key in hex format */
privateKey: Uint8Array privateKey: Uint8Array
/** Public key in hex format */ /** Public key in hex format */
@ -37,8 +39,8 @@ export interface MachineIdentity {
export interface NostrClientConfig { export interface NostrClientConfig {
/** Relays to connect to */ /** Relays to connect to */
relays: RelayConfig[] relays: RelayConfig[]
/** Machine identity (keypair) */ /** Signer for the machine identity (local nsec or remote bunker) */
identity: MachineIdentity signer: Signer
/** Connection timeout in ms (default: 10000) */ /** Connection timeout in ms (default: 10000) */
connectionTimeout?: number connectionTimeout?: number
/** Reconnect automatically on disconnect */ /** Reconnect automatically on disconnect */