feat(machine): resolve signer from spire seed / bunker binding at bootstrap
New signer-resolver.ts turns the ATM's pairing state into a Signer: - seed present, fingerprint differs from stored binding → pair: generate a transport key, redeem the one-shot connect secret, persist the binding, reset the bootstrap gate (re-publish hello to the new operator, #56); - seed matches binding, or binding-only → resume (no re-redeem); - neither → ephemeral LocalSigner (dev) or throw (strict/prod). lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the Phase-A Signer seam means nothing downstream changes. App.vue's maintenance beacon resolves the same way (best-effort, skips if unpaired). Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
209e4c3e20
commit
82a9e79d0e
3 changed files with 138 additions and 47 deletions
|
|
@ -51,14 +51,13 @@ 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, LocalSigner, loadIdentityFromHex, createSignedEvent } = await import(
|
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
||||||
'@bitSpire/nostr-client'
|
const { resolveSigner } = await import('@/services/signer-resolver')
|
||||||
)
|
|
||||||
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
|
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||||
if (privKey && relayUrl) {
|
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
||||||
const signer = new LocalSigner(loadIdentityFromHex(privKey))
|
// If the ATM isn't paired yet, skip the beacon rather than fail the screen.
|
||||||
|
const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null)
|
||||||
|
if (signer && relayUrl) {
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
const publishBeacon = async () => {
|
const publishBeacon = async () => {
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,8 @@
|
||||||
* the customer's invoice.
|
* the customer's invoice.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
||||||
NostrClient,
|
import { resolveSigner } from './signer-resolver.js'
|
||||||
LocalSigner,
|
|
||||||
generateIdentity,
|
|
||||||
loadIdentityFromHex,
|
|
||||||
type Signer,
|
|
||||||
type MachineIdentity,
|
|
||||||
} from '@bitSpire/nostr-client'
|
|
||||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||||
import { CLINKClient } from '@bitSpire/clink'
|
import { CLINKClient } from '@bitSpire/clink'
|
||||||
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
||||||
|
|
@ -39,14 +33,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
||||||
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
* - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
|
||||||
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
|
* - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
|
||||||
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret
|
* - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
|
||||||
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
|
|
||||||
*/
|
*/
|
||||||
interface LightningConfig {
|
interface LightningConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
atmPrivateKey: string
|
|
||||||
appId: string
|
appId: string
|
||||||
operatorPubkeys: string[]
|
operatorPubkeys: string[]
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
|
|
@ -63,7 +55,6 @@ interface LightningConfig {
|
||||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
const defaults: LightningConfig = {
|
const defaults: LightningConfig = {
|
||||||
relayUrl: 'ws://localhost:7777',
|
relayUrl: 'ws://localhost:7777',
|
||||||
atmPrivateKey: '',
|
|
||||||
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||||
operatorPubkeys: [],
|
operatorPubkeys: [],
|
||||||
lnbitsServerPubkey: '',
|
lnbitsServerPubkey: '',
|
||||||
|
|
@ -72,10 +63,8 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
if (isElectron && window.electronAPI) {
|
if (isElectron && window.electronAPI) {
|
||||||
try {
|
try {
|
||||||
const rc = await window.electronAPI.getConfig()
|
const rc = await window.electronAPI.getConfig()
|
||||||
const sec = await window.electronAPI.getAtmSecrets()
|
|
||||||
return {
|
return {
|
||||||
relayUrl: rc.relayUrl || defaults.relayUrl,
|
relayUrl: rc.relayUrl || defaults.relayUrl,
|
||||||
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
|
|
||||||
appId: rc.appId || defaults.appId,
|
appId: rc.appId || defaults.appId,
|
||||||
operatorPubkeys: rc.operatorPubkeys
|
operatorPubkeys: rc.operatorPubkeys
|
||||||
? rc.operatorPubkeys
|
? rc.operatorPubkeys
|
||||||
|
|
@ -92,7 +81,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
|
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
|
||||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
|
||||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||||
lnbitsServerPubkey:
|
lnbitsServerPubkey:
|
||||||
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
|
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
|
||||||
|
|
@ -416,15 +404,14 @@ export async function initializeLightningServices(options?: {
|
||||||
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
|
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
|
||||||
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
|
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
|
||||||
|
|
||||||
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
|
// Strict mode: validate config is production-ready (no localhost). The
|
||||||
|
// signing-identity check (a bunker pairing must exist) is enforced by
|
||||||
|
// resolveSigner below via allowEphemeral=false.
|
||||||
if (options?.strict) {
|
if (options?.strict) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
||||||
errors.push('VITE_RELAY_URL contains localhost')
|
errors.push('VITE_RELAY_URL contains localhost')
|
||||||
}
|
}
|
||||||
if (!CONFIG.atmPrivateKey) {
|
|
||||||
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
|
|
||||||
}
|
|
||||||
if (!CONFIG.lnbitsServerPubkey) {
|
if (!CONFIG.lnbitsServerPubkey) {
|
||||||
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
|
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
|
||||||
}
|
}
|
||||||
|
|
@ -441,22 +428,13 @@ export async function initializeLightningServices(options?: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load or generate ATM identity
|
// Resolve the signing identity. In production this is a BunkerSigner over
|
||||||
let identity: MachineIdentity
|
// NIP-46 (the ATM holds only a transport key; the operator's nsecbunkerd
|
||||||
if (CONFIG.atmPrivateKey) {
|
// holds the signing key); in dev it falls back to an in-process LocalSigner.
|
||||||
identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
|
// The Phase-A Signer seam means nothing downstream changes. See
|
||||||
console.log('[Lightning] Loaded ATM identity from config')
|
// aiolabs/bitspire#52.
|
||||||
} else {
|
const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict })
|
||||||
identity = generateIdentity()
|
console.log('[Lightning] ATM pubkey:', signer.pubkey)
|
||||||
console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
|
|
||||||
console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
|
|
||||||
}
|
|
||||||
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({
|
||||||
|
|
@ -491,7 +469,7 @@ export async function initializeLightningServices(options?: {
|
||||||
// commands; it has no Lightning.Pub dependency.
|
// commands; it has no Lightning.Pub dependency.
|
||||||
const clink = new CLINKClient({
|
const clink = new CLINKClient({
|
||||||
nostrClient,
|
nostrClient,
|
||||||
identity,
|
signer,
|
||||||
operatorPubkey: CONFIG.operatorPubkeys,
|
operatorPubkey: CONFIG.operatorPubkeys,
|
||||||
relays: [CONFIG.relayUrl],
|
relays: [CONFIG.relayUrl],
|
||||||
})
|
})
|
||||||
|
|
@ -581,7 +559,6 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const atmServices = createATMServices(
|
const atmServices = createATMServices(
|
||||||
identity,
|
|
||||||
(preimage) => {
|
(preimage) => {
|
||||||
if (paymentReceivedCallback) {
|
if (paymentReceivedCallback) {
|
||||||
paymentReceivedCallback(preimage)
|
paymentReceivedCallback(preimage)
|
||||||
|
|
@ -624,7 +601,6 @@ export async function initializeLightningServices(options?: {
|
||||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||||
*/
|
*/
|
||||||
function createATMServices(
|
function createATMServices(
|
||||||
_identity: MachineIdentity,
|
|
||||||
onPaymentSuccess: (preimage: string) => void,
|
onPaymentSuccess: (preimage: string) => void,
|
||||||
lnbits: LnbitsClient,
|
lnbits: LnbitsClient,
|
||||||
lnbitsWalletId: string,
|
lnbitsWalletId: string,
|
||||||
|
|
|
||||||
116
apps/machine/src/services/signer-resolver.ts
Normal file
116
apps/machine/src/services/signer-resolver.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/**
|
||||||
|
* Signer resolution — turns the ATM's pairing state into a live `Signer`.
|
||||||
|
*
|
||||||
|
* Three outcomes, in priority order (aiolabs/bitspire#52, model A1):
|
||||||
|
* 1. A seed is present whose fingerprint differs from the stored binding
|
||||||
|
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
|
||||||
|
* the one-shot connect secret, persist the binding, and reset the
|
||||||
|
* bootstrap gate so the (possibly new) operator gets a hello-event (#56).
|
||||||
|
* 2. A seed is present matching the stored binding, OR no seed but a stored
|
||||||
|
* binding exists → resume the bunker session with the persisted transport
|
||||||
|
* key (no re-redeem — the binding is server-persistent).
|
||||||
|
* 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode
|
||||||
|
* this throws instead: no pairing means no signing identity.
|
||||||
|
*
|
||||||
|
* Runs in the renderer (where the relay I/O lives); state.db reads/writes go
|
||||||
|
* through the one-shot get-atm-secrets channel + the binding IPC handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LocalSigner,
|
||||||
|
connectNewSeed,
|
||||||
|
resumeFromBinding,
|
||||||
|
generateClientTransportKey,
|
||||||
|
generateIdentity,
|
||||||
|
loadIdentityFromHex,
|
||||||
|
parseSpireSeed,
|
||||||
|
seedFingerprint,
|
||||||
|
type Signer,
|
||||||
|
} from '@bitSpire/nostr-client'
|
||||||
|
import type { BunkerBindingRecord } from '@/types/electron'
|
||||||
|
|
||||||
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
||||||
|
export interface ResolveSignerOptions {
|
||||||
|
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
|
||||||
|
allowEphemeral: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PairingState {
|
||||||
|
spireSeed: string
|
||||||
|
binding: BunkerBindingRecord | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gather the seed + persisted binding from Electron, or env in browser dev. */
|
||||||
|
async function loadPairingState(): Promise<PairingState> {
|
||||||
|
if (isElectron && window.electronAPI) {
|
||||||
|
const secrets = await window.electronAPI.getAtmSecrets()
|
||||||
|
return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null }
|
||||||
|
}
|
||||||
|
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer> {
|
||||||
|
const { spireSeed, binding } = await loadPairingState()
|
||||||
|
|
||||||
|
if (spireSeed) {
|
||||||
|
const seed = parseSpireSeed(spireSeed)
|
||||||
|
const fingerprint = seedFingerprint(spireSeed)
|
||||||
|
|
||||||
|
if (binding && binding.seedFingerprint === fingerprint) {
|
||||||
|
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
||||||
|
return resumeFromBinding({
|
||||||
|
clientSecretHex: binding.clientSecretHex,
|
||||||
|
spirePubkey: binding.spirePubkey,
|
||||||
|
bunkerUrl: binding.bunkerUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pair or re-pair: redeem the one-shot connect secret.
|
||||||
|
console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey)
|
||||||
|
const transport = generateClientTransportKey()
|
||||||
|
const signer = await connectNewSeed({
|
||||||
|
spirePubkey: seed.spirePubkey,
|
||||||
|
bunkerUrl: seed.bunkerUrl,
|
||||||
|
clientSecretHex: transport.secretHex,
|
||||||
|
})
|
||||||
|
if (isElectron && window.electronAPI) {
|
||||||
|
await window.electronAPI.saveBunkerBinding({
|
||||||
|
clientSecretHex: transport.secretHex,
|
||||||
|
spirePubkey: seed.spirePubkey,
|
||||||
|
bunkerUrl: seed.bunkerUrl,
|
||||||
|
seedFingerprint: fingerprint,
|
||||||
|
pairedAt: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||||
|
await window.electronAPI.resetBootstrapGate()
|
||||||
|
}
|
||||||
|
return signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// No seed in this boot but a binding survives → resume.
|
||||||
|
if (binding) {
|
||||||
|
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
||||||
|
return resumeFromBinding({
|
||||||
|
clientSecretHex: binding.clientSecretHex,
|
||||||
|
spirePubkey: binding.spirePubkey,
|
||||||
|
bunkerUrl: binding.bunkerUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.allowEphemeral) {
|
||||||
|
// Dev-only: a hex key gives a stable dev identity; otherwise ephemeral.
|
||||||
|
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
||||||
|
if (devKey) {
|
||||||
|
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
|
||||||
|
return new LocalSigner(loadIdentityFromHex(devKey))
|
||||||
|
}
|
||||||
|
console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)')
|
||||||
|
return new LocalSigner(generateIdentity())
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' +
|
||||||
|
'Set VITE_SPIRE_SEED or pair the ATM.'
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue