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:
parent
2b8e951de5
commit
40239aa075
1 changed files with 41 additions and 50 deletions
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue