diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index befd9ed..513e3ca 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -67,6 +67,23 @@ async function handleManagementCommand( } } + // Validate bill entries (defense in depth — IPC path also validates) + for (const bill of request.bills) { + if (typeof bill.denomination !== 'number' || typeof bill.count !== 'number') { + return { res: 'GFY', code: GFYCode.InvalidRequest, error: 'Invalid bill entry' } + } + if (!Number.isInteger(bill.count) || bill.count <= 0 || bill.count > 100) { + return { res: 'GFY', code: GFYCode.InvalidRequest, error: `Invalid count: ${bill.count}` } + } + if (!Number.isInteger(bill.denomination) || bill.denomination <= 0) { + return { + res: 'GFY', + code: GFYCode.InvalidRequest, + error: `Invalid denomination: ${bill.denomination}`, + } + } + } + try { const result = await dispenseFn(request.bills) const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` diff --git a/packages/clink/src/client.ts b/packages/clink/src/client.ts index 97b3a8b..afd6912 100644 --- a/packages/clink/src/client.ts +++ b/packages/clink/src/client.ts @@ -113,6 +113,10 @@ export class CLINKClient { private managementHandler?: ManagementHandler private subscriptionId?: string + /** Processed management event IDs (replay protection) */ + private processedManageEvents = new Set() + /** Startup timestamp — reject events created before this */ + private readonly startedAt = Math.floor(Date.now() / 1000) constructor(options: CLINKClientOptions) { this.nostrClient = options.nostrClient @@ -448,6 +452,28 @@ export class CLINKClient { return } + // Replay protection: reject already-processed events + if (this.processedManageEvents.has(event.id)) { + console.warn('Ignoring replayed management event:', event.id) + return + } + + // Timestamp validation: reject events created before startup or too old (>60s) + const now = Math.floor(Date.now() / 1000) + if (event.created_at < this.startedAt) { + console.warn( + 'Ignoring pre-startup management event:', + event.id, + 'created_at:', + event.created_at + ) + return + } + if (now - event.created_at > 60) { + console.warn('Ignoring stale management event (>60s old):', event.id) + return + } + // Validate clink_version tag (per CLINK spec) const versionTag = event.tags.find((t) => t[0] === 'clink_version') if (!versionTag || versionTag[1] !== '1') { @@ -457,6 +483,14 @@ export class CLINKClient { if (!this.managementHandler) return + // Mark as processed BEFORE execution (prevent concurrent replays) + this.processedManageEvents.add(event.id) + // Cap set size to prevent unbounded growth + if (this.processedManageEvents.size > 1000) { + const first = this.processedManageEvents.values().next().value + if (first) this.processedManageEvents.delete(first) + } + const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) const response = await this.managementHandler(request, event.pubkey)