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
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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']],
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
58
packages/nostr-client/src/__tests__/signer.test.ts
Normal file
58
packages/nostr-client/src/__tests__/signer.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||||
|
import { generateIdentity } from '../identity.js'
|
||||||
|
import { LocalSigner } from '../signer.js'
|
||||||
|
import { encryptContentV2, decryptContentV2 } from '../encryption.js'
|
||||||
|
|
||||||
|
describe('LocalSigner', () => {
|
||||||
|
it('exposes the identity pubkey synchronously', () => {
|
||||||
|
const identity = generateIdentity()
|
||||||
|
const signer = new LocalSigner(identity)
|
||||||
|
expect(signer.pubkey).toBe(identity.publicKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signEvent produces a valid signature equivalent to finalizeEvent', async () => {
|
||||||
|
const identity = generateIdentity()
|
||||||
|
const signer = new LocalSigner(identity)
|
||||||
|
const template = {
|
||||||
|
kind: 21000,
|
||||||
|
content: 'rpc',
|
||||||
|
tags: [['p', identity.publicKey]],
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = await signer.signEvent(template)
|
||||||
|
const reference = finalizeEvent(template, identity.privateKey)
|
||||||
|
|
||||||
|
expect(verifyEvent(signed)).toBe(true)
|
||||||
|
expect(signed.pubkey).toBe(identity.publicKey)
|
||||||
|
// Same template + same key ⇒ same id (id is deterministic over content).
|
||||||
|
expect(signed.id).toBe(reference.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nip44Encrypt round-trips with the counterparty signer', async () => {
|
||||||
|
const alice = generateIdentity()
|
||||||
|
const bob = generateIdentity()
|
||||||
|
const aliceSigner = new LocalSigner(alice)
|
||||||
|
const bobSigner = new LocalSigner(bob)
|
||||||
|
|
||||||
|
const ciphertext = await aliceSigner.nip44Encrypt(bob.publicKey, 'secret')
|
||||||
|
const plaintext = await bobSigner.nip44Decrypt(alice.publicKey, ciphertext)
|
||||||
|
|
||||||
|
expect(plaintext).toBe('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nip44 output interops with the standalone encryptContentV2 helper', async () => {
|
||||||
|
const alice = generateIdentity()
|
||||||
|
const bob = generateIdentity()
|
||||||
|
const aliceSigner = new LocalSigner(alice)
|
||||||
|
|
||||||
|
const viaSigner = await aliceSigner.nip44Encrypt(bob.publicKey, 'hello')
|
||||||
|
// The helper and the signer share NIP-44 v2 conversation-key derivation,
|
||||||
|
// so each can decrypt the other's ciphertext.
|
||||||
|
expect(decryptContentV2(bob, alice.publicKey, viaSigner)).toBe('hello')
|
||||||
|
|
||||||
|
const viaHelper = encryptContentV2(alice, bob.publicKey, 'hello')
|
||||||
|
expect(await aliceSigner.nip44Decrypt(bob.publicKey, viaHelper)).toBe('hello')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
64
packages/nostr-client/src/signer.ts
Normal file
64
packages/nostr-client/src/signer.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue