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
|
|
@ -113,6 +113,10 @@ export class CLINKClient {
|
|||
private managementHandler?: ManagementHandler
|
||||
|
||||
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) {
|
||||
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<ManagementRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.managementHandler(request, event.pubkey)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue