Merge pull request 'Phase C: resolve signer from spire seed / bunker binding at bootstrap (#52)' (#60) from phase-c-bunker-bootstrap into dev
Reviewed-on: #60
This commit is contained in:
commit
6281c811f6
13 changed files with 306 additions and 121 deletions
|
|
@ -84,7 +84,8 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||||
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||||
| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) |
|
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. See aiolabs/bitspire#52. |
|
||||||
|
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
|
||||||
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
||||||
|
|
||||||
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
|
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
|
||||||
|
|
@ -188,7 +189,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
||||||
|
|
||||||
## Security priorities
|
## Security priorities
|
||||||
|
|
||||||
1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`.
|
1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback.
|
||||||
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
|
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
|
||||||
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
|
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
|
||||||
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
|
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,23 @@ VITE_LNBITS_SERVER_PUBKEY=
|
||||||
# aiolabs/withdraw#1 / commit e9d911e.)
|
# aiolabs/withdraw#1 / commit e9d911e.)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ATM Identity
|
# ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# The spire pairing seed produced by the operator dashboard (spirekeeper):
|
||||||
|
# spire-seed:v1:<base64url>
|
||||||
|
# It carries a one-shot NIP-46 connect token + the spire's signing pubkey +
|
||||||
|
# the bunker URL. On first boot the ATM redeems the token, generates its own
|
||||||
|
# transport key, and persists the binding to state.db; thereafter it resumes
|
||||||
|
# from the binding (the seed can stay set — it's matched by fingerprint).
|
||||||
|
# A changed seed re-pairs (and re-publishes the cassette-state hello).
|
||||||
|
VITE_SPIRE_SEED=
|
||||||
|
|
||||||
# pragma: allowlist secret
|
# pragma: allowlist secret
|
||||||
# ATM's Nostr private key (hex format, 64 characters). This signing
|
# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
|
||||||
# key IS the credential — LNbits derives the account from it on first
|
# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
|
||||||
# contact (issue aiolabs/lnbits#9 alignment).
|
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
# If not set, generates ephemeral identity on each restart (dev only).
|
# VITE_ATM_PRIVATE_KEY=
|
||||||
VITE_ATM_PRIVATE_KEY=
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Operator Identity
|
# Operator Identity
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client'
|
import {
|
||||||
|
NostrClient,
|
||||||
|
LocalSigner,
|
||||||
|
loadIdentityFromHex,
|
||||||
|
resumeFromBinding,
|
||||||
|
type Signer,
|
||||||
|
} from '@bitSpire/nostr-client'
|
||||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||||
|
import { initDatabase, getBunkerBinding } from './state-store.js'
|
||||||
|
|
||||||
// @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
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
|
|
@ -56,15 +63,34 @@ async function main() {
|
||||||
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
||||||
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
||||||
|
|
||||||
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) {
|
if (!relayUrl || !lnbitsServerPubkey) {
|
||||||
console.error('Missing required config in', envPath)
|
console.error('Missing required config in', envPath)
|
||||||
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY')
|
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`Generating invoice for ${amountSats} sats...`)
|
console.error(`Generating invoice for ${amountSats} sats...`)
|
||||||
|
|
||||||
const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
|
// Resolve the signer. Prod: resume the bunker binding from state.db (the
|
||||||
|
// ATM's transport key — the connect token was already redeemed by the main
|
||||||
|
// app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY.
|
||||||
|
let signer: Signer
|
||||||
|
if (atmPrivateKey) {
|
||||||
|
signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
|
||||||
|
} else {
|
||||||
|
initDatabase()
|
||||||
|
const binding = getBunkerBinding()
|
||||||
|
if (!binding) {
|
||||||
|
console.error('ATM is not paired (no bunker binding in state.db) and no')
|
||||||
|
console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
signer = await resumeFromBinding({
|
||||||
|
clientSecretHex: binding.clientSecretHex,
|
||||||
|
spirePubkey: binding.spirePubkey,
|
||||||
|
bunkerUrl: binding.bunkerUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const nostrClient = new NostrClient({
|
const nostrClient = new NostrClient({
|
||||||
relays: [{ url: relayUrl }],
|
relays: [{ url: relayUrl }],
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,19 @@ import {
|
||||||
getLastKnownConfigCreatedAt,
|
getLastKnownConfigCreatedAt,
|
||||||
getBootstrapPublishedAt,
|
getBootstrapPublishedAt,
|
||||||
markBootstrapPublished,
|
markBootstrapPublished,
|
||||||
|
resetBootstrapGate,
|
||||||
applyOperatorCassettesConfig,
|
applyOperatorCassettesConfig,
|
||||||
getFeeConfig,
|
getFeeConfig,
|
||||||
getLastKnownFeeConfigCreatedAt,
|
getLastKnownFeeConfigCreatedAt,
|
||||||
applyFeeConfig,
|
applyFeeConfig,
|
||||||
|
getBunkerBinding,
|
||||||
|
saveBunkerBinding,
|
||||||
|
clearBunkerBinding,
|
||||||
type OperatorCassettesPayload,
|
type OperatorCassettesPayload,
|
||||||
type FeeConfigPayload,
|
type FeeConfigPayload,
|
||||||
type FeeConfigRow,
|
type FeeConfigRow,
|
||||||
type ApplyResult,
|
type ApplyResult,
|
||||||
|
type StoredBunkerBinding,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||||
|
|
||||||
|
|
@ -323,14 +328,31 @@ let secretsConsumed = false
|
||||||
ipcMain.handle('get-atm-secrets', () => {
|
ipcMain.handle('get-atm-secrets', () => {
|
||||||
if (secretsConsumed) {
|
if (secretsConsumed) {
|
||||||
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
||||||
return { atmPrivateKey: '' }
|
return { spireSeed: '', bunkerBinding: null }
|
||||||
}
|
}
|
||||||
secretsConsumed = true
|
secretsConsumed = true
|
||||||
|
// The spire pairing seed (one-shot connect token inside) + the persisted
|
||||||
|
// bunker binding (transport key). The renderer resolves these into a
|
||||||
|
// BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52).
|
||||||
return {
|
return {
|
||||||
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
spireSeed: process.env.VITE_SPIRE_SEED || '',
|
||||||
|
bunkerBinding: getBunkerBinding(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bunker binding persistence — the renderer writes the binding after a
|
||||||
|
// successful pairing (connectNewSeed), and resets the bootstrap gate so the
|
||||||
|
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
||||||
|
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
||||||
|
saveBunkerBinding(binding)
|
||||||
|
})
|
||||||
|
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||||
|
clearBunkerBinding()
|
||||||
|
})
|
||||||
|
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
||||||
|
resetBootstrapGate()
|
||||||
|
})
|
||||||
|
|
||||||
// State persistence IPC handlers
|
// State persistence IPC handlers
|
||||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,25 @@ export interface BrandingConfig {
|
||||||
logoDarkDataUrl: string | null
|
logoDarkDataUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding).
|
||||||
|
*/
|
||||||
|
export interface BunkerBindingRecord {
|
||||||
|
clientSecretHex: string
|
||||||
|
spirePubkey: string
|
||||||
|
bunkerUrl: string
|
||||||
|
seedFingerprint: string
|
||||||
|
pairedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
|
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
|
||||||
|
* The spire pairing seed (carries the one-shot connect token) plus the persisted
|
||||||
|
* bunker binding; the renderer resolves these into a signer.
|
||||||
*/
|
*/
|
||||||
export interface AtmSecrets {
|
export interface AtmSecrets {
|
||||||
atmPrivateKey: string
|
spireSeed: string
|
||||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
bunkerBinding: BunkerBindingRecord | null
|
||||||
adminToken?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose protected methods to renderer
|
// Expose protected methods to renderer
|
||||||
|
|
@ -100,6 +112,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||||
|
|
||||||
|
// Bunker binding persistence (aiolabs/bitspire#52)
|
||||||
|
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||||
|
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||||
|
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
||||||
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
|
|
@ -212,6 +231,9 @@ declare global {
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
|
clearBunkerBinding: () => Promise<void>
|
||||||
|
resetBootstrapGate: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,16 @@ export function clearBunkerBinding(): void {
|
||||||
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the bootstrap-publish gate so the ATM re-publishes its
|
||||||
|
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
|
||||||
|
* the new operator receives the spire's current state (aiolabs/bitspire#56).
|
||||||
|
*/
|
||||||
|
export function resetBootstrapGate(): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||||
|
}
|
||||||
|
|
||||||
export type OperatorCassettesPayload = {
|
export type OperatorCassettesPayload = {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs",
|
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||||
"build:electron": "pnpm build && electron-builder",
|
"build:electron": "pnpm build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/machine/src/types/electron.d.ts
vendored
19
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -39,10 +39,20 @@ export interface BrandingConfig {
|
||||||
logoDarkDataUrl: string | null
|
logoDarkDataUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */
|
||||||
|
export interface BunkerBindingRecord {
|
||||||
|
clientSecretHex: string
|
||||||
|
spirePubkey: string
|
||||||
|
bunkerUrl: string
|
||||||
|
seedFingerprint: string
|
||||||
|
pairedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AtmSecrets {
|
export interface AtmSecrets {
|
||||||
atmPrivateKey: string
|
/** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */
|
||||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
spireSeed: string
|
||||||
adminToken?: string
|
/** Persisted bunker binding, or null when the ATM is unpaired. */
|
||||||
|
bunkerBinding: BunkerBindingRecord | null
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -85,6 +95,9 @@ declare global {
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
|
clearBunkerBinding: () => Promise<void>
|
||||||
|
resetBootstrapGate: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
|
|
@ -10,34 +10,30 @@
|
||||||
* Uses NIP-44v2 encryption for all messages.
|
* Uses NIP-44v2 encryption for all messages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event, UnsignedEvent } from 'nostr-tools'
|
import type { Event, EventTemplate } from 'nostr-tools'
|
||||||
import { finalizeEvent } from 'nostr-tools'
|
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
||||||
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
|
|
||||||
import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client'
|
|
||||||
|
|
||||||
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
||||||
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt content using NIP-44 v2 (required for CLINK events)
|
* Encrypt content using NIP-44 v2 (required for CLINK events).
|
||||||
|
* Routes through the Signer so the spire identity can live in a bunker.
|
||||||
*/
|
*/
|
||||||
function encryptCLINK(
|
function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise<string> {
|
||||||
identity: MachineIdentity,
|
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
||||||
recipientPubkey: string,
|
return signer.nip44Encrypt(recipientPubkey, plaintext)
|
||||||
content: unknown
|
|
||||||
): string {
|
|
||||||
return encryptContentV2(identity, recipientPubkey, content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt and parse JSON content using NIP-44 v2
|
* Decrypt and parse JSON content using NIP-44 v2.
|
||||||
*/
|
*/
|
||||||
function decryptCLINKJSON<T = unknown>(
|
async function decryptCLINKJSON<T = unknown>(
|
||||||
identity: MachineIdentity,
|
signer: Signer,
|
||||||
senderPubkey: string,
|
senderPubkey: string,
|
||||||
ciphertext: string
|
ciphertext: string
|
||||||
): T {
|
): Promise<T> {
|
||||||
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
|
const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext)
|
||||||
return JSON.parse(plaintext) as T
|
return JSON.parse(plaintext) as T
|
||||||
}
|
}
|
||||||
import {
|
import {
|
||||||
|
|
@ -67,8 +63,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js'
|
||||||
export interface CLINKClientOptions {
|
export interface CLINKClientOptions {
|
||||||
/** Nostr client for communication */
|
/** Nostr client for communication */
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
/** Machine identity */
|
/** Signer for the spire identity (local nsec or remote bunker) */
|
||||||
identity: MachineIdentity
|
signer: Signer
|
||||||
/** Operator pubkey(s) for management commands */
|
/** Operator pubkey(s) for management commands */
|
||||||
operatorPubkey: string | string[]
|
operatorPubkey: string | string[]
|
||||||
/** Relays to use for offers */
|
/** Relays to use for offers */
|
||||||
|
|
@ -102,7 +98,7 @@ export type ManagementHandler = (
|
||||||
*/
|
*/
|
||||||
export class CLINKClient {
|
export class CLINKClient {
|
||||||
private nostrClient: NostrClient
|
private nostrClient: NostrClient
|
||||||
private identity: MachineIdentity
|
private signer: Signer
|
||||||
private operatorPubkeys: string[]
|
private operatorPubkeys: string[]
|
||||||
private relays: string[]
|
private relays: string[]
|
||||||
private generateInvoice?: GenerateInvoice
|
private generateInvoice?: GenerateInvoice
|
||||||
|
|
@ -120,7 +116,7 @@ export class CLINKClient {
|
||||||
|
|
||||||
constructor(options: CLINKClientOptions) {
|
constructor(options: CLINKClientOptions) {
|
||||||
this.nostrClient = options.nostrClient
|
this.nostrClient = options.nostrClient
|
||||||
this.identity = options.identity
|
this.signer = options.signer
|
||||||
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
||||||
? options.operatorPubkey
|
? options.operatorPubkey
|
||||||
: [options.operatorPubkey]
|
: [options.operatorPubkey]
|
||||||
|
|
@ -144,7 +140,7 @@ export class CLINKClient {
|
||||||
currency?: string
|
currency?: string
|
||||||
}): string {
|
}): string {
|
||||||
const offer: CLINKOffer = {
|
const offer: CLINKOffer = {
|
||||||
pubkey: this.identity.publicKey,
|
pubkey: this.signer.pubkey,
|
||||||
relays: this.relays,
|
relays: this.relays,
|
||||||
priceType: options.priceType,
|
priceType: options.priceType,
|
||||||
offerId: options.offerId,
|
offerId: options.offerId,
|
||||||
|
|
@ -193,7 +189,7 @@ export class CLINKClient {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
||||||
'#p': [this.identity.publicKey],
|
'#p': [this.signer.pubkey],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
@ -234,9 +230,9 @@ export class CLINKClient {
|
||||||
expires_in_seconds: options?.expiresInSeconds,
|
expires_in_seconds: options?.expiresInSeconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = encryptCLINK(this.identity, offer.pubkey, request)
|
const content = await encryptCLINK(this.signer, offer.pubkey, request)
|
||||||
|
|
||||||
const event = this.createSignedEvent({
|
const event = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Offer,
|
kind: CLINKEventKind.Offer,
|
||||||
content,
|
content,
|
||||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -269,9 +265,9 @@ export class CLINKClient {
|
||||||
description: options?.description,
|
description: options?.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||||
|
|
||||||
const event = this.createSignedEvent({
|
const event = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Debit,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -303,9 +299,9 @@ export class CLINKClient {
|
||||||
description: options?.description,
|
description: options?.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||||
|
|
||||||
const event = this.createSignedEvent({
|
const event = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Debit,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -324,9 +320,9 @@ export class CLINKClient {
|
||||||
targetPubkey: string,
|
targetPubkey: string,
|
||||||
request: ManagementRequest
|
request: ManagementRequest
|
||||||
): Promise<ManagementResponse> {
|
): Promise<ManagementResponse> {
|
||||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||||
|
|
||||||
const event = this.createSignedEvent({
|
const event = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Manage,
|
kind: CLINKEventKind.Manage,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -394,15 +390,15 @@ export class CLINKClient {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = decryptCLINKJSON<OfferRequest>(this.identity, event.pubkey, event.content)
|
const request = await decryptCLINKJSON<OfferRequest>(this.signer, event.pubkey, event.content)
|
||||||
|
|
||||||
const response = await this.offerHandler(request, event.pubkey)
|
const response = await this.offerHandler(request, event.pubkey)
|
||||||
if (!response) return
|
if (!response) return
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// Send encrypted response with clink_version tag
|
||||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||||
|
|
||||||
const responseEvent = this.createSignedEvent({
|
const responseEvent = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Offer,
|
kind: CLINKEventKind.Offer,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -425,14 +421,14 @@ export class CLINKClient {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = decryptCLINKJSON<DebitRequest>(this.identity, event.pubkey, event.content)
|
const request = await decryptCLINKJSON<DebitRequest>(this.signer, event.pubkey, event.content)
|
||||||
|
|
||||||
const response = await this.debitHandler(request, event.pubkey)
|
const response = await this.debitHandler(request, event.pubkey)
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// Send encrypted response with clink_version tag
|
||||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||||
|
|
||||||
const responseEvent = this.createSignedEvent({
|
const responseEvent = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Debit,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -491,15 +487,15 @@ export class CLINKClient {
|
||||||
if (first) this.processedManageEvents.delete(first)
|
if (first) this.processedManageEvents.delete(first)
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
|
const request = await decryptCLINKJSON<ManagementRequest>(this.signer, event.pubkey, event.content)
|
||||||
|
|
||||||
const response = await this.managementHandler(request, event.pubkey)
|
const response = await this.managementHandler(request, event.pubkey)
|
||||||
if (!response) return
|
if (!response) return
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// Send encrypted response with clink_version tag
|
||||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||||
|
|
||||||
const responseEvent = this.createSignedEvent({
|
const responseEvent = await this.createSignedEvent({
|
||||||
kind: CLINKEventKind.Manage,
|
kind: CLINKEventKind.Manage,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -524,7 +520,7 @@ export class CLINKClient {
|
||||||
{
|
{
|
||||||
kinds: [kind],
|
kinds: [kind],
|
||||||
authors: [fromPubkey],
|
authors: [fromPubkey],
|
||||||
'#p': [this.identity.publicKey],
|
'#p': [this.signer.pubkey],
|
||||||
'#e': [requestEventId],
|
'#e': [requestEventId],
|
||||||
since: Math.floor(Date.now() / 1000) - 5,
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
},
|
},
|
||||||
|
|
@ -540,12 +536,7 @@ export class CLINKClient {
|
||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
this.nostrClient.unsubscribe(subId)
|
this.nostrClient.unsubscribe(subId)
|
||||||
try {
|
decryptCLINKJSON<T>(this.signer, fromPubkey, event.content).then(resolve).catch(reject)
|
||||||
const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
|
|
||||||
resolve(response)
|
|
||||||
} catch (e) {
|
|
||||||
reject(e)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -553,11 +544,11 @@ export class CLINKClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a signed event
|
* Create a signed event via the signer (sets pubkey/id/sig). Async because
|
||||||
|
* a BunkerSigner is a relay round-trip.
|
||||||
*/
|
*/
|
||||||
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
private createSignedEvent(template: EventTemplate): Promise<Event> {
|
||||||
// finalizeEvent derives pubkey from the secret key
|
return this.signer.signEvent(template)
|
||||||
return finalizeEvent(event, this.identity.privateKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ import type { Signer } from './signer.js'
|
||||||
const DEFAULT_BUNKER_TIMEOUT_MS = 10_000
|
const DEFAULT_BUNKER_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raised when the bunker actively rejects a request (e.g. the operator
|
* Raised when the bunker actively rejects a request. Post-bind causes
|
||||||
* revoked the spire's binding, or a kind/method is outside the policy).
|
* (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the
|
||||||
* Callers should treat this as "unpaired" and surface a re-pair prompt.
|
* binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed,
|
||||||
|
* or the requested kind/method is outside the policy. Callers should treat
|
||||||
|
* this as "unpaired" and surface a re-pair prompt.
|
||||||
*/
|
*/
|
||||||
export class BunkerRejectedError extends Error {
|
export class BunkerRejectedError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue