diff --git a/packages/nostr-client/src/__tests__/bunker-signer.test.ts b/packages/nostr-client/src/__tests__/bunker-signer.test.ts new file mode 100644 index 0000000..c72dc4e --- /dev/null +++ b/packages/nostr-client/src/__tests__/bunker-signer.test.ts @@ -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 { + 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(() => {})) }) + 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/) + }) +}) diff --git a/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts new file mode 100644 index 0000000..0deef80 --- /dev/null +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -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}$/) + }) +}) diff --git a/packages/nostr-client/src/bunker-signer.ts b/packages/nostr-client/src/bunker-signer.ts new file mode 100644 index 0000000..ed80f02 --- /dev/null +++ b/packages/nostr-client/src/bunker-signer.ts @@ -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 + signEvent(event: EventTemplate): Promise + nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise + nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise +} + +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 { + return this.#call('sign_event', () => this.#inner.signEvent(template)) + } + + nip44Encrypt(peerPubkey: string, plaintext: string): Promise { + return this.#call('nip44_encrypt', () => this.#inner.nip44Encrypt(peerPubkey, plaintext)) + } + + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise { + 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(label: string, fn: () => Promise): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, 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 { + 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 { + 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) +} diff --git a/packages/nostr-client/src/index.ts b/packages/nostr-client/src/index.ts index eb16f5f..bb821b4 100644 --- a/packages/nostr-client/src/index.ts +++ b/packages/nostr-client/src/index.ts @@ -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' diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts new file mode 100644 index 0000000..e2ab902 --- /dev/null +++ b/packages/nostr-client/src/seed.ts @@ -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: + * json = { + * "v": 1, + * "spire_npub": "npub1…", // informational, ignored here + * "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity + * "bunker_url": "bunker://?relay=&secret=", + * "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://?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 + + 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))) +}