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:
parent
a73f345606
commit
98bdd92044
3 changed files with 107 additions and 40 deletions
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue