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

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

View file

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

View file

@ -14,8 +14,10 @@
import {
NostrClient,
LocalSigner,
generateIdentity,
loadIdentityFromHex,
type Signer,
type MachineIdentity,
} from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits'
@ -234,7 +236,7 @@ interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningBackend
clink: CLINKClient
identity: MachineIdentity
signer: Signer
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[]
atmServices: ATMServices
@ -451,10 +453,15 @@ export async function initializeLightningServices(options?: {
}
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
const nostrClient = new NostrClient({
relays: [{ url: CONFIG.relayUrl }],
identity,
signer,
})
await nostrClient.connect()
@ -465,7 +472,7 @@ export async function initializeLightningServices(options?: {
serverPubkey: CONFIG.lnbitsServerPubkey,
relays: [CONFIG.relayUrl],
})
lnbits.initialize(nostrClient, identity)
lnbits.initialize(nostrClient, signer)
_lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized')
@ -588,7 +595,7 @@ export async function initializeLightningServices(options?: {
nostrClient,
lightningPub,
clink,
identity,
signer,
operatorPubkeys: CONFIG.operatorPubkeys,
atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {

View file

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

View file

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

View file

@ -676,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => {
initialize(servicesWithInventory)
// 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
// to kind-30078 cassette config events + publishes one-shot bootstrap
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: services.nostrClient,
identity: services.identity,
signer: services.signer,
operatorPubkeys: services.operatorPubkeys,
})
@ -692,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({
nostrClient: services.nostrClient,
identity: services.identity,
signer: services.signer,
operatorPubkeys: services.operatorPubkeys,
onApply: applyFeeConfig,
})
@ -989,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => {
})
// 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)
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
})
@ -1003,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onApply: applyFeeConfig,
})
@ -1295,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => {
// Real hardware connected — disable mock bill simulator
debugMode.value = false
// 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)
operatorConfigSvc?.stop()
operatorConfigSvc = await startOperatorConfigService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
})
@ -1309,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => {
operatorFeesSvc?.stop()
operatorFeesSvc = await startOperatorFeesService({
nostrClient: lightning.nostrClient,
identity: lightning.identity,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onApply: applyFeeConfig,
})
@ -1437,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => {
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
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
// Ensure persisted inventory is loaded before first broadcast
@ -1445,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => {
const { stop } = useAvailabilityBroadcast({
nostrClient,
identity,
signer,
inventory: persistedInventory,
balanceSats,
fiatCode: fiatCode.value,