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