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