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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']],
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue