feat(nostr-client): NIP-46 bunker signer + spire pairing seed
Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46 transport key; the signing identity lives in the bunker). - seed.ts: parseSpireSeed for the `spire-seed:v1:<base64url>` contract from spirekeeper pairing.py — re-pads stripped base64url, validates {v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair. - bunker-signer.ts: BunkerSigner implements Signer by delegating sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey is the spire identity, known synchronously from the seed. connectNewSeed redeems the one-shot connect secret; resumeFromBinding reuses the persisted transport key WITHOUT re-redeeming (the binding is server-persistent). Per-RPC timeout + typed BunkerRejectedError / BunkerTimeoutError so callers can distinguish revoked-binding (re-pair) from a transient outage. Unit-tested against a fake inner client (delegation, sync pubkey, timeout, error mapping) + seed round-trip/validation fixtures. Live-relay wiring is Phase C; live bunker integration is Phase F. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
787de5bff1
commit
9c9009af31
5 changed files with 457 additions and 0 deletions
89
packages/nostr-client/src/__tests__/bunker-signer.test.ts
Normal file
89
packages/nostr-client/src/__tests__/bunker-signer.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
||||
import {
|
||||
BunkerSigner,
|
||||
BunkerRejectedError,
|
||||
BunkerTimeoutError,
|
||||
generateClientTransportKey,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
type Nip46Inner,
|
||||
} from '../bunker-signer.js'
|
||||
|
||||
const SPIRE_PUBKEY = 'b'.repeat(64)
|
||||
|
||||
function fakeInner(overrides: Partial<Nip46Inner> = {}): Nip46Inner {
|
||||
return {
|
||||
connect: vi.fn(async () => {}),
|
||||
signEvent: vi.fn(async (t: EventTemplate) => ({ ...t, id: 'id', sig: 'sig', pubkey: SPIRE_PUBKEY }) as unknown as VerifiedEvent),
|
||||
nip44Encrypt: vi.fn(async (_pk: string, pt: string) => `enc(${pt})`),
|
||||
nip44Decrypt: vi.fn(async (_pk: string, ct: string) => ct.replace(/^enc\((.*)\)$/, '$1')),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BunkerSigner', () => {
|
||||
it('exposes the spire pubkey synchronously', () => {
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, fakeInner())
|
||||
expect(signer.pubkey).toBe(SPIRE_PUBKEY)
|
||||
})
|
||||
|
||||
it('delegates sign / encrypt / decrypt to the inner client', async () => {
|
||||
const inner = fakeInner()
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
||||
|
||||
const tmpl: EventTemplate = { kind: 21000, tags: [], content: 'x', created_at: 1 }
|
||||
await signer.signEvent(tmpl)
|
||||
expect(inner.signEvent).toHaveBeenCalledWith(tmpl)
|
||||
|
||||
expect(await signer.nip44Encrypt('peer', 'hi')).toBe('enc(hi)')
|
||||
expect(inner.nip44Encrypt).toHaveBeenCalledWith('peer', 'hi')
|
||||
|
||||
expect(await signer.nip44Decrypt('peer', 'enc(hi)')).toBe('hi')
|
||||
})
|
||||
|
||||
it('maps an inner rejection to BunkerRejectedError (revoked / off-policy)', async () => {
|
||||
const inner = fakeInner({
|
||||
signEvent: vi.fn(async () => {
|
||||
throw new Error('not authorized to sign kind 9999')
|
||||
}),
|
||||
})
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
||||
await expect(signer.signEvent({ kind: 9999, tags: [], content: '', created_at: 1 })).rejects.toBeInstanceOf(
|
||||
BunkerRejectedError
|
||||
)
|
||||
})
|
||||
|
||||
it('times out a non-responding bunker with BunkerTimeoutError', async () => {
|
||||
vi.useFakeTimers()
|
||||
const inner = fakeInner({ signEvent: vi.fn(() => new Promise<VerifiedEvent>(() => {})) })
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner, { timeoutMs: 50 })
|
||||
|
||||
const p = signer.signEvent({ kind: 21000, tags: [], content: '', created_at: 1 })
|
||||
const assertion = expect(p).rejects.toBeInstanceOf(BunkerTimeoutError)
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
await assertion
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transport key + factory guards', () => {
|
||||
it('generates a hex transport keypair', () => {
|
||||
const key = generateClientTransportKey()
|
||||
expect(key.secretHex).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(key.publicHex).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(key.secretHex).not.toBe(key.publicHex)
|
||||
})
|
||||
|
||||
it('connectNewSeed rejects an unparseable bunker_url', async () => {
|
||||
await expect(
|
||||
connectNewSeed({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
||||
).rejects.toThrow(/unparseable bunker_url/)
|
||||
})
|
||||
|
||||
it('resumeFromBinding rejects an unparseable bunker_url', async () => {
|
||||
await expect(
|
||||
resumeFromBinding({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
||||
).rejects.toThrow(/unparseable bunker_url/)
|
||||
})
|
||||
})
|
||||
76
packages/nostr-client/src/__tests__/seed.test.ts
Normal file
76
packages/nostr-client/src/__tests__/seed.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
function makeSeed(json: unknown): string {
|
||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return SPIRE_SEED_SCHEME + b64
|
||||
}
|
||||
|
||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`
|
||||
|
||||
const VALID = {
|
||||
v: 1,
|
||||
spire_npub: 'npub1example',
|
||||
spire_pubkey: SPIRE_PUBKEY,
|
||||
bunker_url: BUNKER_URL,
|
||||
relays: ['wss://events.relay/'],
|
||||
}
|
||||
|
||||
describe('parseSpireSeed', () => {
|
||||
it('parses a well-formed seed (snake_case → camelCase)', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed).toEqual({
|
||||
v: 1,
|
||||
spirePubkey: SPIRE_PUBKEY,
|
||||
bunkerUrl: BUNKER_URL,
|
||||
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('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wrong scheme', 'spire-seed:v2:abc'],
|
||||
['not a seed', 'bunker://whatever'],
|
||||
])('rejects %s', (_label, url) => {
|
||||
expect(() => parseSpireSeed(url)).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bad version', { ...VALID, v: 2 }],
|
||||
['short pubkey', { ...VALID, spire_pubkey: 'abc' }],
|
||||
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }],
|
||||
['empty relays', { ...VALID, relays: [] }],
|
||||
['non-string relay', { ...VALID, relays: [123] }],
|
||||
])('rejects %s', (_label, json) => {
|
||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('seedFingerprint', () => {
|
||||
it('is stable for the same seed and differs across seeds', () => {
|
||||
const a = makeSeed(VALID)
|
||||
const b = makeSeed({ ...VALID, relays: ['wss://other.relay/'] })
|
||||
expect(seedFingerprint(a)).toBe(seedFingerprint(a))
|
||||
expect(seedFingerprint(a)).not.toBe(seedFingerprint(b))
|
||||
expect(seedFingerprint(a)).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
})
|
||||
174
packages/nostr-client/src/bunker-signer.ts
Normal file
174
packages/nostr-client/src/bunker-signer.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* NIP-46 (nsecbunkerd) signer.
|
||||
*
|
||||
* Implements the `Signer` contract by delegating sign / nip44 to a remote
|
||||
* bunker over NIP-46, so no operator key lives on the ATM. The ATM holds
|
||||
* only its own *transport* keypair (`client_nsec`); the signing identity
|
||||
* (`spire_pubkey`) is held by the operator's nsecbunkerd. See
|
||||
* aiolabs/bitspire#52 (model A1) and lnbits `nip46_bunker_client.py`.
|
||||
*
|
||||
* Two lifecycle entry points:
|
||||
* - `connectNewSeed` — first pairing: generate a transport key, redeem the
|
||||
* one-shot connect secret, bind `client_pubkey → spire_key` on the bunker.
|
||||
* - `resumeFromBinding` — restart: reuse the persisted transport key. The
|
||||
* binding is server-persistent, so we do NOT re-redeem (the secret is
|
||||
* spent); we just re-open the relay subscription.
|
||||
*
|
||||
* `pubkey` is the spire identity, known synchronously from the seed/binding,
|
||||
* so subscription filters and `p` tags work before any round-trip.
|
||||
*/
|
||||
|
||||
import { BunkerSigner as Nip46BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
||||
import type { Signer } from './signer.js'
|
||||
|
||||
/** Default per-RPC timeout. nostr-tools' nip46 sendRequest has none — a dead
|
||||
* bunker would hang forever — so we race every call against this. */
|
||||
const DEFAULT_BUNKER_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Raised when the bunker actively rejects a request (e.g. the operator
|
||||
* revoked the spire's binding, or a kind/method is outside the policy).
|
||||
* Callers should treat this as "unpaired" and surface a re-pair prompt.
|
||||
*/
|
||||
export class BunkerRejectedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BunkerRejectedError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when the bunker does not answer within the timeout (transient). */
|
||||
export class BunkerTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BunkerTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The subset of nostr-tools' nip46 BunkerSigner this wrapper drives. */
|
||||
export interface Nip46Inner {
|
||||
connect(): Promise<void>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string>
|
||||
nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
|
||||
export interface BunkerSignerOptions {
|
||||
/** Per-RPC timeout in ms (default 10000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class BunkerSigner implements Signer {
|
||||
readonly pubkey: string
|
||||
readonly #inner: Nip46Inner
|
||||
readonly #timeoutMs: number
|
||||
|
||||
constructor(spirePubkey: string, inner: Nip46Inner, opts: BunkerSignerOptions = {}) {
|
||||
this.pubkey = spirePubkey
|
||||
this.#inner = inner
|
||||
this.#timeoutMs = opts.timeoutMs ?? DEFAULT_BUNKER_TIMEOUT_MS
|
||||
}
|
||||
|
||||
signEvent(template: EventTemplate): Promise<VerifiedEvent> {
|
||||
return this.#call('sign_event', () => this.#inner.signEvent(template))
|
||||
}
|
||||
|
||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
||||
return this.#call('nip44_encrypt', () => this.#inner.nip44Encrypt(peerPubkey, plaintext))
|
||||
}
|
||||
|
||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
||||
return this.#call('nip44_decrypt', () => this.#inner.nip44Decrypt(peerPubkey, ciphertext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bunker RPC with a timeout and normalize failures. nostr-tools'
|
||||
* nip46 rejects with the bunker's `error` string (a rejection) — mapped to
|
||||
* `BunkerRejectedError`; a non-response surfaces as `BunkerTimeoutError`.
|
||||
*/
|
||||
async #call<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new BunkerTimeoutError(`bunker ${label}: no response in ${this.#timeoutMs}ms`)),
|
||||
this.#timeoutMs
|
||||
)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([fn(), timeout])
|
||||
} catch (err) {
|
||||
if (err instanceof BunkerTimeoutError) throw err
|
||||
throw new BunkerRejectedError(`bunker ${label}: ${(err as Error).message ?? String(err)}`)
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A freshly-generated NIP-46 transport keypair (the ATM's `client_nsec`). */
|
||||
export interface ClientTransportKey {
|
||||
/** 64-char hex secret key — persist this to state.db. */
|
||||
secretHex: string
|
||||
/** 64-char hex public key — what the bunker binds to the spire identity. */
|
||||
publicHex: string
|
||||
}
|
||||
|
||||
/** Generate the ATM's own NIP-46 transport keypair. */
|
||||
export function generateClientTransportKey(): ClientTransportKey {
|
||||
const sk = generateSecretKey()
|
||||
return { secretHex: bytesToHex(sk), publicHex: getPublicKey(sk) }
|
||||
}
|
||||
|
||||
/** Persisted bunker binding — everything needed to resume without re-pairing. */
|
||||
export interface BunkerBinding {
|
||||
/** Hex transport secret key (`client_nsec`). */
|
||||
clientSecretHex: string
|
||||
/** The spire's signing pubkey (hex). */
|
||||
spirePubkey: string
|
||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||||
bunkerUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* First pairing: build a transport-keyed bunker signer, redeem the one-shot
|
||||
* connect secret, and bind `client_pubkey → spire_key`. The returned signer
|
||||
* is live; the caller persists `clientSecretHex` so a restart can resume.
|
||||
*
|
||||
* `bunkerUrl` is the seed's `bunker_url`; `spirePubkey` is the seed's
|
||||
* `spire_pubkey`.
|
||||
*/
|
||||
export async function connectNewSeed(
|
||||
args: { spirePubkey: string; bunkerUrl: string; clientSecretHex: string },
|
||||
opts: BunkerSignerOptions = {}
|
||||
): Promise<BunkerSigner> {
|
||||
const pointer = await parseBunkerInput(args.bunkerUrl)
|
||||
if (!pointer) {
|
||||
throw new Error(`connectNewSeed: unparseable bunker_url`)
|
||||
}
|
||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(args.clientSecretHex), pointer)
|
||||
await inner.connect() // redeems the one-shot secret; eager-binds on the bunker
|
||||
return new BunkerSigner(args.spirePubkey, inner, opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart: reuse the persisted transport key. The bunker binding is
|
||||
* server-persistent, so we do NOT call `connect()` (the secret is spent);
|
||||
* `fromBunker` opens the relay subscription and `sign_event` works against
|
||||
* the existing binding.
|
||||
*/
|
||||
export async function resumeFromBinding(
|
||||
binding: BunkerBinding,
|
||||
opts: BunkerSignerOptions = {}
|
||||
): Promise<BunkerSigner> {
|
||||
const pointer = await parseBunkerInput(binding.bunkerUrl)
|
||||
if (!pointer) {
|
||||
throw new Error(`resumeFromBinding: unparseable bunker_url`)
|
||||
}
|
||||
// The connect secret is already spent; drop it so nothing re-redeems.
|
||||
pointer.secret = null
|
||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(binding.clientSecretHex), pointer)
|
||||
return new BunkerSigner(binding.spirePubkey, inner, opts)
|
||||
}
|
||||
|
|
@ -61,6 +61,19 @@ export {
|
|||
export { LocalSigner } from './signer.js'
|
||||
export type { Signer } from './signer.js'
|
||||
|
||||
// NIP-46 bunker signer + pairing seed (aiolabs/bitspire#52)
|
||||
export {
|
||||
BunkerSigner,
|
||||
BunkerRejectedError,
|
||||
BunkerTimeoutError,
|
||||
generateClientTransportKey,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
} from './bunker-signer.js'
|
||||
export type { BunkerBinding, BunkerSignerOptions, ClientTransportKey } from './bunker-signer.js'
|
||||
export { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from './seed.js'
|
||||
export type { SpireSeed } from './seed.js'
|
||||
|
||||
// Event creation
|
||||
export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js'
|
||||
|
||||
|
|
|
|||
105
packages/nostr-client/src/seed.ts
Normal file
105
packages/nostr-client/src/seed.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Spire pairing seed-URL parser.
|
||||
*
|
||||
* 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):
|
||||
*
|
||||
* spire-seed:v1:<base64url(json, no padding)>
|
||||
* json = {
|
||||
* "v": 1,
|
||||
* "spire_npub": "npub1…", // informational, ignored here
|
||||
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity
|
||||
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>",
|
||||
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078)
|
||||
* }
|
||||
*
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex } from 'nostr-tools/utils'
|
||||
|
||||
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. */
|
||||
spirePubkey: string
|
||||
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */
|
||||
bunkerUrl: string
|
||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
const HEX64 = /^[0-9a-f]{64}$/
|
||||
|
||||
/** Decode an unpadded base64url string in both browser and Node. */
|
||||
function base64urlDecode(input: string): string {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
||||
if (typeof atob !== 'undefined') {
|
||||
return atob(padded)
|
||||
}
|
||||
return Buffer.from(padded, 'base64').toString('binary')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* half-understood bunker.
|
||||
*/
|
||||
export function parseSpireSeed(seedUrl: string): SpireSeed {
|
||||
if (typeof seedUrl !== 'string' || !seedUrl.startsWith(SPIRE_SEED_SCHEME)) {
|
||||
throw new Error(`parseSpireSeed: not a ${SPIRE_SEED_SCHEME} URL`)
|
||||
}
|
||||
|
||||
const payload = seedUrl.slice(SPIRE_SEED_SCHEME.length)
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(base64urlDecode(payload))
|
||||
} catch (err) {
|
||||
throw new Error(`parseSpireSeed: undecodable payload (${(err as Error).message})`)
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('parseSpireSeed: payload is not an object')
|
||||
}
|
||||
const obj = raw as Record<string, unknown>
|
||||
|
||||
if (obj.v !== 1) {
|
||||
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 bunkerUrl = obj.bunker_url
|
||||
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) {
|
||||
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL')
|
||||
}
|
||||
|
||||
const relays = obj.relays
|
||||
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
||||
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
||||
}
|
||||
|
||||
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable fingerprint of a seed URL, used to detect a re-pair (operator/relay
|
||||
* change). A different seed ⇒ a different fingerprint ⇒ the ATM re-binds and
|
||||
* resets its bootstrap gate (aiolabs/bitspire#56).
|
||||
*/
|
||||
export function seedFingerprint(seedUrl: string): string {
|
||||
return bytesToHex(sha256(new TextEncoder().encode(seedUrl)))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue