Files
Redflag/aggregator-web/src/lib/client-error-logger.ts
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
Complete RedFlag codebase with two major security audit implementations.

== A-1: Ed25519 Key Rotation Support ==

Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management

Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing

== A-2: Replay Attack Fixes (F-1 through F-7) ==

F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH     - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH     - Migration 026: expires_at column with partial index
F-6 HIGH     - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH     - Agent-side executedIDs dedup map with cleanup
F-4 HIGH     - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt

Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.

All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:47 -04:00

167 lines
4.4 KiB
TypeScript

import { api, ApiError } from './api';
export interface ClientErrorLog {
subsystem: string;
error_type: 'javascript_error' | 'api_error' | 'ui_error' | 'validation_error';
message: string;
stack_trace?: string;
metadata?: Record<string, any>;
url: string;
timestamp: string;
}
/**
* ClientErrorLogger provides reliable frontend error logging with retry logic
* Implements ETHOS #3: Assume Failure; Build for Resilience
*/
export class ClientErrorLogger {
private maxRetries = 3;
private baseDelayMs = 1000;
private localStorageKey = 'redflag-error-queue';
private offlineBuffer: ClientErrorLog[] = [];
private isOnline = navigator.onLine;
constructor() {
// Listen for online/offline events
window.addEventListener('online', () => this.flushOfflineBuffer());
window.addEventListener('offline', () => { this.isOnline = false; });
}
/**
* Log an error with automatic retry and offline queuing
*/
async logError(errorData: Omit<ClientErrorLog, 'url' | 'timestamp'>): Promise<void> {
const fullError: ClientErrorLog = {
...errorData,
url: window.location.href,
timestamp: new Date().toISOString(),
};
// Try to send immediately
try {
await this.sendWithRetry(fullError);
return;
} catch (error) {
// If failed after retries, queue for later
this.queueForRetry(fullError);
}
}
/**
* Send error to backend with exponential backoff retry
*/
private async sendWithRetry(error: ClientErrorLog): Promise<void> {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
await api.post('/logs/client-error', error, {
headers: { 'X-Error-Logger-Request': 'true' },
});
// Success, remove from queue if it was there
this.removeFromQueue(error);
return;
} catch (error) {
if (attempt === this.maxRetries) {
throw error; // Rethrow after final attempt
}
// Exponential backoff
await this.sleep(this.baseDelayMs * Math.pow(2, attempt - 1));
}
}
}
/**
* Queue error for retry when network is available
*/
private queueForRetry(error: ClientErrorLog): void {
try {
const queue = this.getQueue();
queue.push({
...error,
retryCount: (error as any).retryCount || 0,
queuedAt: new Date().toISOString(),
});
// Save to localStorage for persistence
localStorage.setItem(this.localStorageKey, JSON.stringify(queue));
// Also keep in memory buffer
this.offlineBuffer.push(error);
} catch (storageError) {
// localStorage might be full or unavailable
console.warn('Failed to queue error for retry:', storageError);
}
}
/**
* Flush offline buffer when coming back online
*/
private async flushOfflineBuffer(): Promise<void> {
if (!this.isOnline) return;
const queue = this.getQueue();
if (queue.length === 0) return;
const failed: typeof queue = [];
for (const queuedError of queue) {
try {
await this.sendWithRetry(queuedError);
} catch (error) {
failed.push(queuedError);
}
}
// Update queue with remaining failed items
if (failed.length < queue.length) {
localStorage.setItem(this.localStorageKey, JSON.stringify(failed));
}
}
/**
* Get current error queue from localStorage
*/
private getQueue(): any[] {
try {
const stored = localStorage.getItem(this.localStorageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Remove successfully sent error from queue
*/
private removeFromQueue(sentError: ClientErrorLog): void {
try {
const queue = this.getQueue();
const filtered = queue.filter(queued =>
queued.timestamp !== sentError.timestamp ||
queued.message !== sentError.message
);
if (filtered.length < queue.length) {
localStorage.setItem(this.localStorageKey, JSON.stringify(filtered));
}
} catch {
// Best effort cleanup
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Singleton instance
export const clientErrorLogger = new ClientErrorLogger();
// Auto-retry failed logs on app load
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
clientErrorLogger.flushOfflineBuffer().catch(() => {});
});
}