Phase C: resolve signer from spire seed / bunker binding at bootstrap (#52) #60
1 changed files with 41 additions and 50 deletions
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>
commit
40239aa075
|
|
@ -10,34 +10,30 @@
|
|||
* Uses NIP-44v2 encryption for all messages.
|
||||
*/
|
||||
|
||||
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
|
||||
import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client'
|
||||
import type { Event, EventTemplate } from 'nostr-tools'
|
||||
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
||||
|
||||
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
||||
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(
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
content: unknown
|
||||
): string {
|
||||
return encryptContentV2(identity, recipientPubkey, content)
|
||||
function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise<string> {
|
||||
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
||||
return signer.nip44Encrypt(recipientPubkey, plaintext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse JSON content using NIP-44 v2
|
||||
* Decrypt and parse JSON content using NIP-44 v2.
|
||||
*/
|
||||
function decryptCLINKJSON<T = unknown>(
|
||||
identity: MachineIdentity,
|
||||
async function decryptCLINKJSON<T = unknown>(
|
||||
signer: Signer,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): T {
|
||||
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
|
||||
): Promise<T> {
|
||||
const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext)
|
||||
return JSON.parse(plaintext) as T
|
||||
}
|
||||
import {
|
||||
|
|
@ -67,8 +63,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js'
|
|||
export interface CLINKClientOptions {
|
||||
/** Nostr client for communication */
|
||||
nostrClient: NostrClient
|
||||
/** Machine identity */
|
||||
identity: MachineIdentity
|
||||
/** Signer for the spire identity (local nsec or remote bunker) */
|
||||
signer: Signer
|
||||
/** Operator pubkey(s) for management commands */
|
||||
operatorPubkey: string | string[]
|
||||
/** Relays to use for offers */
|
||||
|
|
@ -102,7 +98,7 @@ export type ManagementHandler = (
|
|||
*/
|
||||
export class CLINKClient {
|
||||
private nostrClient: NostrClient
|
||||
private identity: MachineIdentity
|
||||
private signer: Signer
|
||||
private operatorPubkeys: string[]
|
||||
private relays: string[]
|
||||
private generateInvoice?: GenerateInvoice
|
||||
|
|
@ -120,7 +116,7 @@ export class CLINKClient {
|
|||
|
||||
constructor(options: CLINKClientOptions) {
|
||||
this.nostrClient = options.nostrClient
|
||||
this.identity = options.identity
|
||||
this.signer = options.signer
|
||||
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
||||
? options.operatorPubkey
|
||||
: [options.operatorPubkey]
|
||||
|
|
@ -144,7 +140,7 @@ export class CLINKClient {
|
|||
currency?: string
|
||||
}): string {
|
||||
const offer: CLINKOffer = {
|
||||
pubkey: this.identity.publicKey,
|
||||
pubkey: this.signer.pubkey,
|
||||
relays: this.relays,
|
||||
priceType: options.priceType,
|
||||
offerId: options.offerId,
|
||||
|
|
@ -193,7 +189,7 @@ export class CLINKClient {
|
|||
[
|
||||
{
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
content,
|
||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -269,9 +265,9 @@ export class CLINKClient {
|
|||
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,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -303,9 +299,9 @@ export class CLINKClient {
|
|||
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,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -324,9 +320,9 @@ export class CLINKClient {
|
|||
targetPubkey: string,
|
||||
request: ManagementRequest
|
||||
): 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,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -394,15 +390,15 @@ export class CLINKClient {
|
|||
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)
|
||||
if (!response) return
|
||||
|
||||
// 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,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -425,14 +421,14 @@ export class CLINKClient {
|
|||
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)
|
||||
|
||||
// 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,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -491,15 +487,15 @@ export class CLINKClient {
|
|||
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)
|
||||
if (!response) return
|
||||
|
||||
// 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,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -524,7 +520,7 @@ export class CLINKClient {
|
|||
{
|
||||
kinds: [kind],
|
||||
authors: [fromPubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
'#p': [this.signer.pubkey],
|
||||
'#e': [requestEventId],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
|
|
@ -540,12 +536,7 @@ export class CLINKClient {
|
|||
|
||||
clearTimeout(timeout)
|
||||
this.nostrClient.unsubscribe(subId)
|
||||
try {
|
||||
const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
|
||||
resolve(response)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
decryptCLINKJSON<T>(this.signer, fromPubkey, event.content).then(resolve).catch(reject)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -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 {
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
return finalizeEvent(event, this.identity.privateKey)
|
||||
private createSignedEvent(template: EventTemplate): Promise<Event> {
|
||||
return this.signer.signEvent(template)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue