refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-01 13:14:59 +02:00 • committed by padreug
commit 98bdd92044
3 changed files with 107 additions and 40 deletions

View file

@ -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()
})