security: add replay protection, timestamp validation, and input checks

Addresses security audit findings for the operator command channel:

1. Replay protection: track processed management event IDs in a Set,
   reject duplicates. Caps at 1000 entries to prevent unbounded growth.

2. Timestamp validation: reject events created before machine startup
   (prevents processing stale events on relay reconnect) and events
   older than 60 seconds (limits replay window).

3. Input validation: validate bill denomination/count in
   handleManagementCommand (defense in depth — IPC path also validates
   but direct HAL path did not). Caps count at 100 per denomination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-23 00:53:19 -04:00
commit 01e71b0954
2 changed files with 51 additions and 0 deletions

View file

@ -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 { try {
const result = await dispenseFn(request.bills) const result = await dispenseFn(request.bills)
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

View file

@ -113,6 +113,10 @@ export class CLINKClient {
private managementHandler?: ManagementHandler private managementHandler?: ManagementHandler
private subscriptionId?: string private subscriptionId?: string
/** Processed management event IDs (replay protection) */
private processedManageEvents = new Set<string>()
/** Startup timestamp — reject events created before this */
private readonly startedAt = Math.floor(Date.now() / 1000)
constructor(options: CLINKClientOptions) { constructor(options: CLINKClientOptions) {
this.nostrClient = options.nostrClient this.nostrClient = options.nostrClient
@ -448,6 +452,28 @@ export class CLINKClient {
return 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) // Validate clink_version tag (per CLINK spec)
const versionTag = event.tags.find((t) => t[0] === 'clink_version') const versionTag = event.tags.find((t) => t[0] === 'clink_version')
if (!versionTag || versionTag[1] !== '1') { if (!versionTag || versionTag[1] !== '1') {
@ -457,6 +483,14 @@ export class CLINKClient {
if (!this.managementHandler) return 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<ManagementRequest>(this.identity, event.pubkey, event.content) const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
const response = await this.managementHandler(request, event.pubkey) const response = await this.managementHandler(request, event.pubkey)