refactor(clink): route CLINK signing + encryption through the Signer

Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event /
nip44 now go through the signer (async), so the spire identity can live in a
NIP-46 bunker. The kind-21003 management path (operator-driven manual
dispense, the one live CLINK path on dev) decrypts as the spire via the
bunker; the dormant offer/debit paths are migrated too so they're
bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec
(shocknet/CLINK#7, #8).

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-19 00:15:17 +02:00
commit 40239aa075

View file

@ -10,34 +10,30 @@
* Uses NIP-44v2 encryption for all messages. * Uses NIP-44v2 encryption for all messages.
*/ */
import type { Event, UnsignedEvent } from 'nostr-tools' import type { Event, EventTemplate } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools' import type { NostrClient, Signer } from '@bitSpire/nostr-client'
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client'
/** CLINK protocol version tag (mandatory per CLINK spec) */ /** CLINK protocol version tag (mandatory per CLINK spec) */
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1'] const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
/** /**
* Encrypt content using NIP-44 v2 (required for CLINK events) * Encrypt content using NIP-44 v2 (required for CLINK events).
* Routes through the Signer so the spire identity can live in a bunker.
*/ */
function encryptCLINK( function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise<string> {
identity: MachineIdentity, const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
recipientPubkey: string, return signer.nip44Encrypt(recipientPubkey, plaintext)
content: unknown
): string {
return encryptContentV2(identity, recipientPubkey, content)
} }
/** /**
* Decrypt and parse JSON content using NIP-44 v2 * Decrypt and parse JSON content using NIP-44 v2.
*/ */
function decryptCLINKJSON<T = unknown>( async function decryptCLINKJSON<T = unknown>(
identity: MachineIdentity, signer: Signer,
senderPubkey: string, senderPubkey: string,
ciphertext: string ciphertext: string
): T { ): Promise<T> {
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext) const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext)
return JSON.parse(plaintext) as T return JSON.parse(plaintext) as T
} }
import { import {
@ -67,8 +63,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js'
export interface CLINKClientOptions { export interface CLINKClientOptions {
/** Nostr client for communication */ /** Nostr client for communication */
nostrClient: NostrClient nostrClient: NostrClient
/** Machine identity */ /** Signer for the spire identity (local nsec or remote bunker) */
identity: MachineIdentity signer: Signer
/** Operator pubkey(s) for management commands */ /** Operator pubkey(s) for management commands */
operatorPubkey: string | string[] operatorPubkey: string | string[]
/** Relays to use for offers */ /** Relays to use for offers */
@ -102,7 +98,7 @@ export type ManagementHandler = (
*/ */
export class CLINKClient { export class CLINKClient {
private nostrClient: NostrClient private nostrClient: NostrClient
private identity: MachineIdentity private signer: Signer
private operatorPubkeys: string[] private operatorPubkeys: string[]
private relays: string[] private relays: string[]
private generateInvoice?: GenerateInvoice private generateInvoice?: GenerateInvoice
@ -120,7 +116,7 @@ export class CLINKClient {
constructor(options: CLINKClientOptions) { constructor(options: CLINKClientOptions) {
this.nostrClient = options.nostrClient this.nostrClient = options.nostrClient
this.identity = options.identity this.signer = options.signer
this.operatorPubkeys = Array.isArray(options.operatorPubkey) this.operatorPubkeys = Array.isArray(options.operatorPubkey)
? options.operatorPubkey ? options.operatorPubkey
: [options.operatorPubkey] : [options.operatorPubkey]
@ -144,7 +140,7 @@ export class CLINKClient {
currency?: string currency?: string
}): string { }): string {
const offer: CLINKOffer = { const offer: CLINKOffer = {
pubkey: this.identity.publicKey, pubkey: this.signer.pubkey,
relays: this.relays, relays: this.relays,
priceType: options.priceType, priceType: options.priceType,
offerId: options.offerId, offerId: options.offerId,
@ -193,7 +189,7 @@ export class CLINKClient {
[ [
{ {
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage], kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
'#p': [this.identity.publicKey], '#p': [this.signer.pubkey],
}, },
], ],
{ {
@ -234,9 +230,9 @@ export class CLINKClient {
expires_in_seconds: options?.expiresInSeconds, expires_in_seconds: options?.expiresInSeconds,
} }
const content = encryptCLINK(this.identity, offer.pubkey, request) const content = await encryptCLINK(this.signer, offer.pubkey, request)
const event = this.createSignedEvent({ const event = await this.createSignedEvent({
kind: CLINKEventKind.Offer, kind: CLINKEventKind.Offer,
content, content,
tags: [['p', offer.pubkey], CLINK_VERSION_TAG], tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
@ -269,9 +265,9 @@ export class CLINKClient {
description: options?.description, description: options?.description,
} }
const content = encryptCLINK(this.identity, targetPubkey, request) const content = await encryptCLINK(this.signer, targetPubkey, request)
const event = this.createSignedEvent({ const event = await this.createSignedEvent({
kind: CLINKEventKind.Debit, kind: CLINKEventKind.Debit,
content, content,
tags: [['p', targetPubkey], CLINK_VERSION_TAG], tags: [['p', targetPubkey], CLINK_VERSION_TAG],
@ -303,9 +299,9 @@ export class CLINKClient {
description: options?.description, description: options?.description,
} }
const content = encryptCLINK(this.identity, targetPubkey, request) const content = await encryptCLINK(this.signer, targetPubkey, request)
const event = this.createSignedEvent({ const event = await this.createSignedEvent({
kind: CLINKEventKind.Debit, kind: CLINKEventKind.Debit,
content, content,
tags: [['p', targetPubkey], CLINK_VERSION_TAG], tags: [['p', targetPubkey], CLINK_VERSION_TAG],
@ -324,9 +320,9 @@ export class CLINKClient {
targetPubkey: string, targetPubkey: string,
request: ManagementRequest request: ManagementRequest
): Promise<ManagementResponse> { ): Promise<ManagementResponse> {
const content = encryptCLINK(this.identity, targetPubkey, request) const content = await encryptCLINK(this.signer, targetPubkey, request)
const event = this.createSignedEvent({ const event = await this.createSignedEvent({
kind: CLINKEventKind.Manage, kind: CLINKEventKind.Manage,
content, content,
tags: [['p', targetPubkey], CLINK_VERSION_TAG], tags: [['p', targetPubkey], CLINK_VERSION_TAG],
@ -394,15 +390,15 @@ export class CLINKClient {
return return
} }
const request = decryptCLINKJSON<OfferRequest>(this.identity, event.pubkey, event.content) const request = await decryptCLINKJSON<OfferRequest>(this.signer, event.pubkey, event.content)
const response = await this.offerHandler(request, event.pubkey) const response = await this.offerHandler(request, event.pubkey)
if (!response) return if (!response) return
// Send encrypted response with clink_version tag // Send encrypted response with clink_version tag
const content = encryptCLINK(this.identity, event.pubkey, response) const content = await encryptCLINK(this.signer, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = await this.createSignedEvent({
kind: CLINKEventKind.Offer, kind: CLINKEventKind.Offer,
content, content,
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
@ -425,14 +421,14 @@ export class CLINKClient {
return return
} }
const request = decryptCLINKJSON<DebitRequest>(this.identity, event.pubkey, event.content) const request = await decryptCLINKJSON<DebitRequest>(this.signer, event.pubkey, event.content)
const response = await this.debitHandler(request, event.pubkey) const response = await this.debitHandler(request, event.pubkey)
// Send encrypted response with clink_version tag // Send encrypted response with clink_version tag
const content = encryptCLINK(this.identity, event.pubkey, response) const content = await encryptCLINK(this.signer, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = await this.createSignedEvent({
kind: CLINKEventKind.Debit, kind: CLINKEventKind.Debit,
content, content,
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
@ -491,15 +487,15 @@ export class CLINKClient {
if (first) this.processedManageEvents.delete(first) if (first) this.processedManageEvents.delete(first)
} }
const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content) const request = await decryptCLINKJSON<ManagementRequest>(this.signer, event.pubkey, event.content)
const response = await this.managementHandler(request, event.pubkey) const response = await this.managementHandler(request, event.pubkey)
if (!response) return if (!response) return
// Send encrypted response with clink_version tag // Send encrypted response with clink_version tag
const content = encryptCLINK(this.identity, event.pubkey, response) const content = await encryptCLINK(this.signer, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = await this.createSignedEvent({
kind: CLINKEventKind.Manage, kind: CLINKEventKind.Manage,
content, content,
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
@ -524,7 +520,7 @@ export class CLINKClient {
{ {
kinds: [kind], kinds: [kind],
authors: [fromPubkey], authors: [fromPubkey],
'#p': [this.identity.publicKey], '#p': [this.signer.pubkey],
'#e': [requestEventId], '#e': [requestEventId],
since: Math.floor(Date.now() / 1000) - 5, since: Math.floor(Date.now() / 1000) - 5,
}, },
@ -540,12 +536,7 @@ export class CLINKClient {
clearTimeout(timeout) clearTimeout(timeout)
this.nostrClient.unsubscribe(subId) this.nostrClient.unsubscribe(subId)
try { decryptCLINKJSON<T>(this.signer, fromPubkey, event.content).then(resolve).catch(reject)
const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
resolve(response)
} catch (e) {
reject(e)
}
}, },
} }
) )
@ -553,11 +544,11 @@ export class CLINKClient {
} }
/** /**
* Create a signed event * Create a signed event via the signer (sets pubkey/id/sig). Async because
* a BunkerSigner is a relay round-trip.
*/ */
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event { private createSignedEvent(template: EventTemplate): Promise<Event> {
// finalizeEvent derives pubkey from the secret key return this.signer.signEvent(template)
return finalizeEvent(event, this.identity.privateKey)
} }
} }