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:
Padreug 2026-06-18 23:24:22 +02:00
commit 9c9009af31
5 changed files with 457 additions and 0 deletions

View 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/)
})
})

View 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}$/)
})
})

View 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)
}

View file

@ -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'

View 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)))
}