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:
parent
e03782803b
commit
01e71b0954
2 changed files with 51 additions and 0 deletions
|
|
@ -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)}`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue