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>
176 lines
6.7 KiB
TypeScript
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)
|
|
}
|