bitspire/packages/nostr-client/src/bunker-signer.ts
Padreug 09ed5e95de docs(nostr-client): TTL expiry is now a post-bind deauth cause
nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired
token (`expiresAt`) now stops signing post-bind, not just at connect —
reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL
now surfaces as the same BunkerRejectedError as a revoke, so the Phase D
re-pair handling covers both. Docstring corrected to say so.

refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:19:58 +02:00

176 lines
6.7 KiB
TypeScript

/**
* 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. Post-bind causes
* (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the
* binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed,
* or the requested 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)
}