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}$/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue