From 53a0c2db51b18b6fd4adbc95c05b64ae905f2446 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 13:14:59 +0200 Subject: [PATCH] refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey (hex), and again inside a full bunker_url — which bloats a QR that's already hard to scan off the machine's camera. Carry it once, as an npub, and derive the rest: - spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but carries a bech32 checksum, so a mis-scanned character is caught instead of yielding a wrong-but-valid-looking key. - bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay. - bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the common case where the bunker shares the event relay, explicit when it differs. - lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2). Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot pairing token, no bitspire machine has shipped, and a paired machine resumes from its stored binding, not by re-parsing the seed. Roughly a third smaller encoded — ~180-200 fewer chars in the QR. Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub + lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig) and a resolver-resilience guard for machines holding an old-shape seed land separately. Co-Authored-By: Claude Opus 4.8 --- .../services/pairing/__tests__/ingest.test.ts | 7 +- .../nostr-client/src/__tests__/seed.test.ts | 51 +++++++---- packages/nostr-client/src/seed.ts | 89 ++++++++++++++----- 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/apps/machine/src/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts index 7392de3..f6c069e 100644 --- a/apps/machine/src/services/pairing/__tests__/ingest.test.ts +++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { ingestScannedSeed } from '../ingest' import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' +import { npubEncode } from 'nostr-tools/nip19' /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ function makeSeed(json: unknown): string { @@ -15,9 +16,9 @@ function makeSeed(json: unknown): string { const SPIRE_PUBKEY = 'a'.repeat(64) const VALID_SEED = makeSeed({ v: 1, - spire_npub: 'npub1example', - spire_pubkey: SPIRE_PUBKEY, - bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`, + spire_npub: npubEncode(SPIRE_PUBKEY), + lnbits_npub: npubEncode('b'.repeat(64)), + bunker_secret: 'deadbeef', relays: ['wss://events.relay/'], }) diff --git a/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts index 0deef80..3e3bba7 100644 --- a/packages/nostr-client/src/__tests__/seed.test.ts +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { npubEncode } from 'nostr-tools/nip19' import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js' /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ @@ -12,41 +13,56 @@ function makeSeed(json: unknown): string { } const SPIRE_PUBKEY = 'a'.repeat(64) -const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef` +const LNBITS_PUBKEY = 'b'.repeat(64) +const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY) +const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY) const VALID = { v: 1, - spire_npub: 'npub1example', - spire_pubkey: SPIRE_PUBKEY, - bunker_url: BUNKER_URL, + spire_npub: SPIRE_NPUB, + lnbits_npub: LNBITS_NPUB, + bunker_secret: 'deadbeef', relays: ['wss://events.relay/'], } describe('parseSpireSeed', () => { - it('parses a well-formed seed (snake_case → camelCase)', () => { + it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => { const seed = parseSpireSeed(makeSeed(VALID)) expect(seed).toEqual({ v: 1, spirePubkey: SPIRE_PUBKEY, - bunkerUrl: BUNKER_URL, + lnbitsServerPubkey: LNBITS_PUBKEY, + bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`, relays: ['wss://events.relay/'], }) }) - it('re-pads stripped base64url of any residue length', () => { - // Vary a field so the encoded payload lands on each mod-4 residue. - for (const suffix of ['', 'a', 'ab', 'abc']) { - const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` }) - expect(() => parseSpireSeed(seed)).not.toThrow() - } + it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => { + const seed = parseSpireSeed(makeSeed(VALID)) + expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`) }) - it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => { + it('uses an explicit bunker_relay when present (distinct from event relays)', () => { + const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' })) + expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`) + // event relays are unchanged + expect(seed.relays).toEqual(['wss://events.relay/']) + }) + + it('percent-encodes relay + secret for parseBunkerInput to decode', () => { const seed = parseSpireSeed(makeSeed(VALID)) expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F') expect(seed.bunkerUrl).toContain('secret=deadbeef') }) + it('re-pads stripped base64url of any residue length', () => { + // Vary the secret so the encoded payload lands on each mod-4 residue. + for (const suffix of ['', 'a', 'ab', 'abc']) { + const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` }) + expect(() => parseSpireSeed(seed)).not.toThrow() + } + }) + it.each([ ['wrong scheme', 'spire-seed:v2:abc'], ['not a seed', 'bunker://whatever'], @@ -56,10 +72,15 @@ describe('parseSpireSeed', () => { it.each([ ['bad version', { ...VALID, v: 2 }], - ['short pubkey', { ...VALID, spire_pubkey: 'abc' }], - ['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }], + ['missing spire_npub', { ...VALID, spire_npub: undefined }], + ['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }], + ['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }], + ['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }], + ['empty bunker_secret', { ...VALID, bunker_secret: '' }], + ['missing bunker_secret', { ...VALID, bunker_secret: undefined }], ['empty relays', { ...VALID, relays: [] }], ['non-string relay', { ...VALID, relays: [123] }], + ['empty bunker_relay', { ...VALID, bunker_relay: '' }], ])('rejects %s', (_label, json) => { expect(() => parseSpireSeed(makeSeed(json))).toThrow() }) diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts index e2ab902..482d6b9 100644 --- a/packages/nostr-client/src/seed.ts +++ b/packages/nostr-client/src/seed.ts @@ -3,40 +3,52 @@ * * The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a * one-time seed URL that encodes the bunker connection + the spire's signing - * identity. Wire contract (model A1): + * identity. Wire contract (model A1, minimal encoding): * * spire-seed:v1: * json = { * "v": 1, - * "spire_npub": "npub1…", // informational, ignored here - * "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity - * "bunker_url": "bunker://?relay=&secret=", - * "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078) + * "spire_npub": "npub1…", // spire signing identity (bech32; hex derived) + * "lnbits_npub": "npub1…", // LNbits nostr-transport server identity + * "bunker_secret": "", // one-shot NIP-46 connect token + * "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078) + * "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0] * } * - * - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple - * of 4 before decoding. - * - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them - * is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we - * keep `bunker_url` verbatim. - * - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire - * publishes its own events. They may differ — both must be spire-reachable. + * Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub. + * The old shape spelled it three times (spire_npub + spire_pubkey hex + inside + * a full bunker_url), which bloats a QR that's already hard to scan. Here: + * + * - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length + * as hex but carries a bech32 checksum — real error-detection for a value + * read off a camera). + * - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or + * `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools + * `parseBunkerInput` (see bunker-signer.ts). + * - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired + * machine needs nothing else provisioned to reach the backend (#70 part 2). + * + * base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4 + * before decoding. */ import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from 'nostr-tools/utils' +import { decode as nip19Decode } from 'nostr-tools/nip19' export const SPIRE_SEED_SCHEME = 'spire-seed:v1:' export interface SpireSeed { /** Seed format version (always 1 for this scheme). */ v: number - /** The spire's signing identity — 64-char hex. Every event is signed as this. */ + /** The spire's signing identity — 64-char hex, derived from `spire_npub`. */ spirePubkey: string - /** `bunker://?relay=&secret=` — handed to nostr-tools parseBunkerInput. */ + /** `bunker://?relay=&secret=` — reconstructed, handed to parseBunkerInput. */ bunkerUrl: string /** Relays where the spire publishes its own events (kind 21000 / 30078). */ relays: string[] + /** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */ + lnbitsServerPubkey: string } const HEX64 = /^[0-9a-f]{64}$/ @@ -50,6 +62,23 @@ function base64urlDecode(input: string): string { return Buffer.from(padded, 'base64').toString('binary') } +/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */ +function hexFromNpub(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`parseSpireSeed: ${field} must be a string`) + } + let decoded: ReturnType + try { + decoded = nip19Decode(value) + } catch (err) { + throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`) + } + if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) { + throw new Error(`parseSpireSeed: ${field} must be an npub`) + } + return decoded.data +} + /** * Parse + validate a `spire-seed:v1:` URL. Throws on any malformation — * the seed is a trust root, so we fail closed rather than connect to a @@ -77,14 +106,12 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`) } - const spirePubkey = obj.spire_pubkey - if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) { - throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex') - } + const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub') + const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub') - const bunkerUrl = obj.bunker_url - if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) { - throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL') + const bunkerSecret = obj.bunker_secret + if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) { + throw new Error('parseSpireSeed: bunker_secret must be a non-empty string') } const relays = obj.relays @@ -92,7 +119,25 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { throw new Error('parseSpireSeed: relays must be a non-empty string array') } - return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] } + // Optional bunker relay; default to the first event relay. Keeps the common + // case (bunker on the same relay) one field lighter, while still allowing a + // distinct NIP-46 relay when the operator runs one. + let bunkerRelay = relays[0] as string + if (obj.bunker_relay !== undefined) { + if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) { + throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string') + } + bunkerRelay = obj.bunker_relay + } + + // Reconstruct the bunker URL nostr-tools expects. relay + secret are + // percent-encoded here; parseBunkerInput decodes them downstream. + const bunkerUrl = + `bunker://${spirePubkey}` + + `?relay=${encodeURIComponent(bunkerRelay)}` + + `&secret=${encodeURIComponent(bunkerSecret)}` + + return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey } } /**