feat(machine): persist NIP-46 bunker binding (state.db schema v11)
Adds a bunker_binding singleton table + get/save/clearBunkerBinding accessors holding the ATM's own NIP-46 transport key (client_nsec), the spire signing pubkey, the bunker URL, and the seed fingerprint. Persisted so a restart resumes the bunker session without re-redeeming the one-shot connect secret; a changed fingerprint signals a re-pair. The v10→v11 migration is idempotent (CREATE TABLE IF NOT EXISTS), and the v9→v10 block now advances existing.value so a v9 install chains straight through to v11 in one boot (matching the v6→v8 blocks). Phase B of aiolabs/bitspire#52. The IPC bridge + bootstrap resolution that consume these accessors land in Phase C. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c9009af31
commit
2b8e951de5
1 changed files with 103 additions and 1 deletions
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
|||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '10'
|
||||
const SCHEMA_VERSION = '11'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -114,6 +114,15 @@ export function initDatabase(dbPath?: string): void {
|
|||
event_created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
// Seed meta + cashbox if first run, or run migrations
|
||||
|
|
@ -320,6 +329,29 @@ export function initDatabase(dbPath?: string): void {
|
|||
)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
||||
existing.value = '10'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '10') {
|
||||
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
|
||||
// - bunker_binding singleton — the ATM's own NIP-46 transport key
|
||||
// (client_nsec) plus the spire signing identity, bunker URL, and a
|
||||
// fingerprint of the seed it was paired from. Persisted so a restart
|
||||
// resumes the bunker session without re-redeeming the one-shot connect
|
||||
// secret. A new/changed seed_fingerprint signals a re-pair (which also
|
||||
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
|
|
@ -381,6 +413,76 @@ export function markBootstrapPublished(unixTimestamp: number): void {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StoredBunkerBinding {
|
||||
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
|
||||
clientSecretHex: string
|
||||
/** The spire's signing pubkey (hex) — the identity events are signed as. */
|
||||
spirePubkey: string
|
||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||||
bunkerUrl: string
|
||||
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
|
||||
seedFingerprint: string
|
||||
/** Unix seconds when the pairing was redeemed. */
|
||||
pairedAt: number
|
||||
}
|
||||
|
||||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
||||
export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1'
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
client_secret_hex: string
|
||||
spire_pubkey: string
|
||||
bunker_url: string
|
||||
seed_fingerprint: string
|
||||
paired_at: number
|
||||
}
|
||||
| undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
clientSecretHex: row.client_secret_hex,
|
||||
spirePubkey: row.spire_pubkey,
|
||||
bunkerUrl: row.bunker_url,
|
||||
seedFingerprint: row.seed_fingerprint,
|
||||
pairedAt: row.paired_at,
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_secret_hex = excluded.client_secret_hex,
|
||||
spire_pubkey = excluded.spire_pubkey,
|
||||
bunker_url = excluded.bunker_url,
|
||||
seed_fingerprint = excluded.seed_fingerprint,
|
||||
paired_at = excluded.paired_at`
|
||||
).run(
|
||||
binding.clientSecretHex,
|
||||
binding.spirePubkey,
|
||||
binding.bunkerUrl,
|
||||
binding.seedFingerprint,
|
||||
binding.pairedAt
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
|
||||
export function clearBunkerBinding(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
||||
}
|
||||
|
||||
export type OperatorCassettesPayload = {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue