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
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '10'
|
const SCHEMA_VERSION = '11'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -114,6 +114,15 @@ export function initDatabase(dbPath?: string): void {
|
||||||
event_created_at INTEGER NOT NULL,
|
event_created_at INTEGER NOT NULL,
|
||||||
applied_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
|
// 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')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
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.
|
// 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 = {
|
export type OperatorCassettesPayload = {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue