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
334cb86771
commit
53a0c2db51
3 changed files with 107 additions and 40 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
import { ingestScannedSeed } from '../ingest'
|
import { ingestScannedSeed } from '../ingest'
|
||||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
||||||
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||||
function makeSeed(json: unknown): string {
|
function makeSeed(json: unknown): string {
|
||||||
|
|
@ -15,9 +16,9 @@ function makeSeed(json: unknown): string {
|
||||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||||
const VALID_SEED = makeSeed({
|
const VALID_SEED = makeSeed({
|
||||||
v: 1,
|
v: 1,
|
||||||
spire_npub: 'npub1example',
|
spire_npub: npubEncode(SPIRE_PUBKEY),
|
||||||
spire_pubkey: SPIRE_PUBKEY,
|
lnbits_npub: npubEncode('b'.repeat(64)),
|
||||||
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`,
|
bunker_secret: 'deadbeef',
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
/** 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 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 = {
|
const VALID = {
|
||||||
v: 1,
|
v: 1,
|
||||||
spire_npub: 'npub1example',
|
spire_npub: SPIRE_NPUB,
|
||||||
spire_pubkey: SPIRE_PUBKEY,
|
lnbits_npub: LNBITS_NPUB,
|
||||||
bunker_url: BUNKER_URL,
|
bunker_secret: 'deadbeef',
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('parseSpireSeed', () => {
|
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))
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
expect(seed).toEqual({
|
expect(seed).toEqual({
|
||||||
v: 1,
|
v: 1,
|
||||||
spirePubkey: SPIRE_PUBKEY,
|
spirePubkey: SPIRE_PUBKEY,
|
||||||
bunkerUrl: BUNKER_URL,
|
lnbitsServerPubkey: LNBITS_PUBKEY,
|
||||||
|
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('re-pads stripped base64url of any residue length', () => {
|
it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
|
||||||
// Vary a field so the encoded payload lands on each mod-4 residue.
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
|
||||||
const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` })
|
|
||||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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))
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
||||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
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([
|
it.each([
|
||||||
['wrong scheme', 'spire-seed:v2:abc'],
|
['wrong scheme', 'spire-seed:v2:abc'],
|
||||||
['not a seed', 'bunker://whatever'],
|
['not a seed', 'bunker://whatever'],
|
||||||
|
|
@ -56,10 +72,15 @@ describe('parseSpireSeed', () => {
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['bad version', { ...VALID, v: 2 }],
|
['bad version', { ...VALID, v: 2 }],
|
||||||
['short pubkey', { ...VALID, spire_pubkey: 'abc' }],
|
['missing spire_npub', { ...VALID, spire_npub: undefined }],
|
||||||
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }],
|
['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: [] }],
|
['empty relays', { ...VALID, relays: [] }],
|
||||||
['non-string relay', { ...VALID, relays: [123] }],
|
['non-string relay', { ...VALID, relays: [123] }],
|
||||||
|
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
|
||||||
])('rejects %s', (_label, json) => {
|
])('rejects %s', (_label, json) => {
|
||||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,52 @@
|
||||||
*
|
*
|
||||||
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
||||||
* one-time seed URL that encodes the bunker connection + the spire's signing
|
* 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:<base64url(json, no padding)>
|
* spire-seed:v1:<base64url(json, no padding)>
|
||||||
* json = {
|
* json = {
|
||||||
* "v": 1,
|
* "v": 1,
|
||||||
* "spire_npub": "npub1…", // informational, ignored here
|
* "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
|
||||||
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity
|
* "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
|
||||||
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>",
|
* "bunker_secret": "<sec>", // one-shot NIP-46 connect token
|
||||||
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078)
|
* "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
|
* Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
|
||||||
* of 4 before decoding.
|
* The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
|
||||||
* - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them
|
* a full bunker_url), which bloats a QR that's already hard to scan. Here:
|
||||||
* is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we
|
*
|
||||||
* keep `bunker_url` verbatim.
|
* - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
|
||||||
* - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire
|
* as hex but carries a bech32 checksum — real error-detection for a value
|
||||||
* publishes its own events. They may differ — both must be spire-reachable.
|
* 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 { sha256 } from '@noble/hashes/sha2.js'
|
||||||
import { bytesToHex } from 'nostr-tools/utils'
|
import { bytesToHex } from 'nostr-tools/utils'
|
||||||
|
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
||||||
|
|
||||||
export interface SpireSeed {
|
export interface SpireSeed {
|
||||||
/** Seed format version (always 1 for this scheme). */
|
/** Seed format version (always 1 for this scheme). */
|
||||||
v: number
|
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
|
spirePubkey: string
|
||||||
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */
|
/** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
||||||
relays: string[]
|
relays: string[]
|
||||||
|
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
|
||||||
|
lnbitsServerPubkey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEX64 = /^[0-9a-f]{64}$/
|
const HEX64 = /^[0-9a-f]{64}$/
|
||||||
|
|
@ -50,6 +62,23 @@ function base64urlDecode(input: string): string {
|
||||||
return Buffer.from(padded, 'base64').toString('binary')
|
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<typeof nip19Decode>
|
||||||
|
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 —
|
* 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
|
* 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)}`)
|
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const spirePubkey = obj.spire_pubkey
|
const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
|
||||||
if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) {
|
const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
|
||||||
throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
const bunkerUrl = obj.bunker_url
|
const bunkerSecret = obj.bunker_secret
|
||||||
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) {
|
if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
|
||||||
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL')
|
throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = obj.relays
|
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')
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue