diff --git a/.env.example b/.env.example index fd7862cb..7b22dd1a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ KYC_PREVIOUS_KEY_VERSIONS= # Optional JSON keyring for rotation. Values shown are placeholders; never commit real keys. KYC_ENCRYPTION_KEYS_JSON= +# Tamper-evident audit checkpoints +AUDIT_CHECKPOINT_PRIVATE_KEY=replace_with_local_audit_checkpoint_hmac_key +AUDIT_CHECKPOINT_PREVIOUS_KEYS= +CRITICAL_AUDIT_ACTIONS= + # Privy NEXT_PUBLIC_PRIVY_APP_ID=replace_with_privy_app_id PRIVY_APP_ID=replace_with_privy_app_id_for_server_validation diff --git a/docs/tamper-evident-audit.md b/docs/tamper-evident-audit.md new file mode 100644 index 00000000..19cb241d --- /dev/null +++ b/docs/tamper-evident-audit.md @@ -0,0 +1,63 @@ +# Tamper-Evident Audit Logs + +ChainMove records sensitive administrative and financial actions in an append-only hash chain. The default partition strategy is monthly (`YYYY-MM`) so verification and exports can be scoped without loading the full lifetime log. Each non-legacy event includes actor context, action, target, result, request ID, sanitized metadata, timestamp, sequence, previous hash, canonical data, and event hash. + +## Critical Events + +The compatibility writer in `lib/security/audit-log.ts` mirrors existing audit writes into the tamper-evident log. Actions containing `kyc`, `wallet`, `repayment`, `payout`, `loan`, `asset`, `investment`, `admin`, `user.role`, `notification.broadcast`, or `email.send` are treated as critical by default. Configure `CRITICAL_AUDIT_ACTIONS` as a comma-separated allowlist of critical action fragments when deployments need a stricter or narrower policy. + +Critical audit failures throw `CRITICAL_AUDIT_FAILURE` and must block the business action. Failed business actions should be logged with `status: "failure"` whenever the attempt is security-relevant. + +## Privacy Rules + +Audit metadata is sanitized before hashing. Do not include passwords, tokens, private keys, session IDs, raw KYC documents, raw gateway payloads, unnecessary PII, or full bank/card data. Use stable identifiers, object IDs, short reason codes, amounts, currencies, status transitions, and redacted summaries instead. + +Filtered exports may use `--redact-pii`. Redacted exports include `sourceEventHash` for traceability and recompute an export-local hash chain so offline verification proves the exported content has not changed. + +## Checkpoints + +`createCheckpoint(partition)` signs the ordered event-hash root for events not yet checkpointed. Contributor mode uses the local HMAC signer configured by `AUDIT_CHECKPOINT_PRIVATE_KEY`; production should replace the signer adapter with KMS, HSM, or external transparency storage. + +Set `AUDIT_CHECKPOINT_PREVIOUS_KEYS` to a comma-separated list of retired HMAC keys during rotation. Verification accepts the active key plus previous keys. Keep retired keys until every checkpoint signed by them is outside retention and backup restore windows. + +Recommended rotation: + +1. Add the current key to `AUDIT_CHECKPOINT_PREVIOUS_KEYS`. +2. Deploy a new `AUDIT_CHECKPOINT_PRIVATE_KEY`. +3. Create a checkpoint and verify it with `npm run audit:verify -- --partition=YYYY-MM --checkpoints`. +4. Retire old keys only after retention and legal-hold windows expire. + +## Migration + +Run `npm run audit:migrate` once per environment to copy existing mutable audit logs into a `legacy-YYYY-MM` partition. Legacy events are clearly marked with `isLegacy: true`; they remain accessible but do not fabricate pre-migration proof. Do not delete the old collection unless legal, retention, and backup requirements explicitly permit it. + +## Export And Verification + +Export a partition: + +```bash +npm run audit:export -- --partition=2026-07 --output=audit-export.json --checkpoints +``` + +Verify the live database: + +```bash +npm run audit:verify -- --partition=2026-07 --checkpoints +``` + +Verify an export offline from the project checkout: + +```bash +npm install +npm run audit:verify -- --file=audit-export.json +``` + +Verification reports the first broken chain link, sequence gaps, malformed events, hash mismatches, and checkpoint signature/root failures. + +## Retention And Access + +Retain tamper-evident audit logs and checkpoints for at least seven years for financial and KYC evidence unless a stricter legal hold applies. Limit read access to security, compliance, and authorized administrators. Direct database update/delete permissions for audit collections must not be granted to application operators; emergency database access must be ticketed and followed by full partition verification. + +## Incident Procedure + +If verification fails, stop automated deletion or archival for the affected partition, preserve database and export snapshots, record the first failing sequence and checkpoint, compare against externalized checkpoint roots where available, and open a security incident. Rebuild trust from the last valid checkpoint; do not repair audit history in place. diff --git a/lib/security/audit-checkpoint.ts b/lib/security/audit-checkpoint.ts new file mode 100644 index 00000000..b42c57be --- /dev/null +++ b/lib/security/audit-checkpoint.ts @@ -0,0 +1,229 @@ +import crypto from "crypto" +import dbConnect from "@/lib/dbConnect" +import AuditCheckpoint from "@/models/AuditCheckpoint" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" +import { computeRootHash } from "./audit-hash" + +// Simple local signer for contributor mode +// In production, this should be replaced with a proper HSM or KMS integration +class LocalCheckpointSigner { + private privateKey: string + private previousKeys: string[] + + constructor() { + // In production, this should be loaded from secure environment variable or key management service + this.privateKey = process.env.AUDIT_CHECKPOINT_PRIVATE_KEY || this.generateKeyPair() + this.previousKeys = (process.env.AUDIT_CHECKPOINT_PREVIOUS_KEYS || "") + .split(",") + .map((key) => key.trim()) + .filter(Boolean) + } + + private generateKeyPair(): string { + // Generate a simple signing key for development + return crypto.randomBytes(32).toString("hex") + } + + sign(data: string): string { + const hmac = crypto.createHmac("sha256", this.privateKey) + hmac.update(data) + return hmac.digest("hex") + } + + verify(data: string, signature: string): boolean { + return [this.privateKey, ...this.previousKeys].some((key) => { + const hmac = crypto.createHmac("sha256", key) + hmac.update(data) + const expectedSignature = hmac.digest("hex") + const actual = Buffer.from(signature, "hex") + const expected = Buffer.from(expectedSignature, "hex") + return actual.length === expected.length && crypto.timingSafeEqual(actual, expected) + }) + } + + getKeyIdentifier(): string { + // Return a hash of the public portion or key ID + return crypto.createHash("sha256").update(this.privateKey).digest("hex").slice(0, 16) + } +} + +const localSigner = new LocalCheckpointSigner() + +export interface CheckpointConfig { + partition: string + eventsPerCheckpoint?: number + autoCheckpoint?: boolean +} + +/** + * Create a checkpoint for a partition + */ +export async function createCheckpoint(partition: string): Promise<{ + success: boolean + checkpoint?: { + checkpointNumber: number + eventCount: number + rootHash: string + } + error?: string +}> { + try { + await dbConnect() + + // Get the last checkpoint for this partition + const lastCheckpoint = await AuditCheckpoint.findOne({ partition }).sort({ checkpointNumber: -1 }) + const nextCheckpointNumber = lastCheckpoint ? lastCheckpoint.checkpointNumber + 1 : 1 + const startSequence = lastCheckpoint ? lastCheckpoint.endSequence + 1 : 0 + + // Get all events since the last checkpoint + const events = await TamperEvidentAuditLog.find({ + partition, + sequence: { $gte: startSequence }, + isLegacy: false, + }).sort({ sequence: 1 }) + + if (events.length === 0) { + return { + success: false, + error: "No new events to checkpoint", + } + } + + const endSequence = events[events.length - 1].sequence + const startEventHash = events[0].eventHash + const endEventHash = events[events.length - 1].eventHash + const eventHashes = events.map((e) => e.eventHash) + const rootHash = computeRootHash(eventHashes) + + // Sign the checkpoint + const signatureData = JSON.stringify({ + partition, + checkpointNumber: nextCheckpointNumber, + startSequence, + endSequence, + rootHash, + }) + const signature = localSigner.sign(signatureData) + const signedBy = localSigner.getKeyIdentifier() + + // Create the checkpoint + const checkpoint = await AuditCheckpoint.create({ + partition, + checkpointNumber: nextCheckpointNumber, + startSequence, + endSequence, + startEventHash, + endEventHash, + rootHash, + signature, + signedBy, + signedAt: new Date(), + eventCount: events.length, + }) + + return { + success: true, + checkpoint: { + checkpointNumber: checkpoint.checkpointNumber, + eventCount: checkpoint.eventCount, + rootHash: checkpoint.rootHash, + }, + } + } catch (error) { + console.error("CHECKPOINT_CREATE_ERROR", error) + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +/** + * Verify a checkpoint signature + */ +export async function verifyCheckpointSignature(checkpointId: string): Promise<{ + valid: boolean + error?: string +}> { + try { + await dbConnect() + + const checkpoint = await AuditCheckpoint.findById(checkpointId) + if (!checkpoint) { + return { valid: false, error: "Checkpoint not found" } + } + + const signatureData = JSON.stringify({ + partition: checkpoint.partition, + checkpointNumber: checkpoint.checkpointNumber, + startSequence: checkpoint.startSequence, + endSequence: checkpoint.endSequence, + rootHash: checkpoint.rootHash, + }) + + const valid = localSigner.verify(signatureData, checkpoint.signature) + + return { valid } + } catch (error) { + console.error("CHECKPOINT_VERIFY_ERROR", error) + return { + valid: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +/** + * Get checkpoint statistics for a partition + */ +export async function getCheckpointStats(partition: string): Promise<{ + totalCheckpoints: number + lastCheckpoint?: { + number: number + eventCount: number + signedAt: Date + } + totalEventsCheckpointed: number +}> { + await dbConnect() + + const checkpoints = await AuditCheckpoint.find({ partition }).sort({ checkpointNumber: -1 }) + + const totalCheckpoints = checkpoints.length + const lastCheckpoint = checkpoints[0] + const totalEventsCheckpointed = checkpoints.reduce((sum, cp) => sum + cp.eventCount, 0) + + return { + totalCheckpoints, + lastCheckpoint: lastCheckpoint + ? { + number: lastCheckpoint.checkpointNumber, + eventCount: lastCheckpoint.eventCount, + signedAt: lastCheckpoint.signedAt, + } + : undefined, + totalEventsCheckpointed, + } +} + +/** + * Auto-checkpoint if threshold is met + */ +export async function autoCheckpointIfNeeded(partition: string, threshold: number = 1000): Promise { + await dbConnect() + + const lastCheckpoint = await AuditCheckpoint.findOne({ partition }).sort({ checkpointNumber: -1 }) + const startSequence = lastCheckpoint ? lastCheckpoint.endSequence + 1 : 0 + + const eventCount = await TamperEvidentAuditLog.countDocuments({ + partition, + sequence: { $gte: startSequence }, + isLegacy: false, + }) + + if (eventCount >= threshold) { + await createCheckpoint(partition) + } +} + +export { localSigner } diff --git a/lib/security/audit-export.ts b/lib/security/audit-export.ts new file mode 100644 index 00000000..8ba49326 --- /dev/null +++ b/lib/security/audit-export.ts @@ -0,0 +1,352 @@ +import dbConnect from "@/lib/dbConnect" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" +import AuditCheckpoint from "@/models/AuditCheckpoint" +import { verifyAuditChain } from "./audit-verification" +import { buildCanonicalAuditEventData, canonicalizeEventData, computeEventHash, redactPII } from "./audit-hash" + +export interface ExportOptions { + partition: string + startDate?: Date + endDate?: Date + startSequence?: number + endSequence?: number + actions?: string[] + actorId?: string + targetType?: string + includeLegacy?: boolean + redactPII?: boolean + includeCheckpoints?: boolean + format?: "json" | "csv" +} + +export interface ExportResult { + events: any[] + checkpoints?: any[] + manifest: ExportManifest + verificationInstructions: string +} + +export interface ExportManifest { + exportedAt: Date + partition: string + totalEvents: number + startSequence: number + endSequence: number + startEventHash: string + endEventHash: string + filters: { + startDate?: Date + endDate?: Date + actions?: string[] + actorId?: string + targetType?: string + } + integrity: { + verified: boolean + verificationErrors: number + verificationWarnings: number + } + checkpoints?: { + included: boolean + count: number + } + piiRedacted: boolean +} + +/** + * Export audit events with integrity manifest + */ +export async function exportAuditEvents(options: ExportOptions): Promise { + await dbConnect() + + // Build query + const query: any = { + partition: options.partition, + } + + if (!options.includeLegacy) { + query.isLegacy = false + } + + if (options.startDate || options.endDate) { + query.timestamp = {} + if (options.startDate) query.timestamp.$gte = options.startDate + if (options.endDate) query.timestamp.$lte = options.endDate + } + + if (options.startSequence !== undefined || options.endSequence !== undefined) { + query.sequence = {} + if (options.startSequence !== undefined) query.sequence.$gte = options.startSequence + if (options.endSequence !== undefined) query.sequence.$lte = options.endSequence + } + + if (options.actions && options.actions.length > 0) { + query.action = { $in: options.actions } + } + + if (options.actorId) { + query.actorId = options.actorId + } + + if (options.targetType) { + query.targetType = options.targetType + } + + // Fetch events + let events: any[] = await TamperEvidentAuditLog.find(query).sort({ sequence: 1 }).lean() + + if (options.redactPII) { + let previousHash = events[0]?.previousHash + events = events.map((event) => { + const redactedEvent = { + ...event, + actorIdentifier: event.actorIdentifier ? "[REDACTED]" : event.actorIdentifier, + metadata: redactPII(event.metadata), + sourceEventHash: event.eventHash, + previousHash, + } + const canonicalData = canonicalizeEventData(buildCanonicalAuditEventData(redactedEvent)) + const eventHash = computeEventHash(redactedEvent.previousHash + canonicalData) + previousHash = eventHash + + return { + ...redactedEvent, + canonicalData, + eventHash, + } + }) + } + + // Fetch checkpoints if requested + let checkpoints: any[] = [] + if (options.includeCheckpoints) { + const checkpointQuery: any = { + partition: options.partition, + } + + if (options.startSequence !== undefined || options.endSequence !== undefined) { + checkpointQuery.$or = [ + { startSequence: { $gte: options.startSequence, $lte: options.endSequence } }, + { endSequence: { $gte: options.startSequence, $lte: options.endSequence } }, + ] + } + + checkpoints = await AuditCheckpoint.find(checkpointQuery).sort({ checkpointNumber: 1 }).lean() + } + + // Verify integrity + const verificationResult = await verifyAuditChain(options.partition, { + startSequence: events.length > 0 ? events[0].sequence : undefined, + endSequence: events.length > 0 ? events[events.length - 1].sequence : undefined, + verifyCheckpoints: options.includeCheckpoints, + }) + + // Build manifest + const manifest: ExportManifest = { + exportedAt: new Date(), + partition: options.partition, + totalEvents: events.length, + startSequence: events.length > 0 ? events[0].sequence : 0, + endSequence: events.length > 0 ? events[events.length - 1].sequence : 0, + startEventHash: events.length > 0 ? events[0].eventHash : "", + endEventHash: events.length > 0 ? events[events.length - 1].eventHash : "", + filters: { + startDate: options.startDate, + endDate: options.endDate, + actions: options.actions, + actorId: options.actorId, + targetType: options.targetType, + }, + integrity: { + verified: verificationResult.valid, + verificationErrors: verificationResult.errors.length, + verificationWarnings: verificationResult.warnings.length, + }, + checkpoints: options.includeCheckpoints + ? { + included: true, + count: checkpoints.length, + } + : undefined, + piiRedacted: options.redactPII || false, + } + + // Generate verification instructions + const verificationInstructions = generateVerificationInstructions(manifest) + + return { + events, + checkpoints: options.includeCheckpoints ? checkpoints : undefined, + manifest, + verificationInstructions, + } +} + +/** + * Generate verification instructions for offline verification + */ +function generateVerificationInstructions(manifest: ExportManifest): string { + return ` +AUDIT LOG EXPORT VERIFICATION INSTRUCTIONS +========================================== + +Export Information: +- Partition: ${manifest.partition} +- Exported At: ${manifest.exportedAt.toISOString()} +- Total Events: ${manifest.totalEvents} +- Sequence Range: ${manifest.startSequence} to ${manifest.endSequence} +- PII Redacted: ${manifest.piiRedacted ? "Yes" : "No"} + +Integrity Status: +- Verified: ${manifest.integrity.verified ? "PASSED" : "FAILED"} +- Verification Errors: ${manifest.integrity.verificationErrors} +- Verification Warnings: ${manifest.integrity.verificationWarnings} + +Hash Chain Verification: +1. First Event Hash: ${manifest.startEventHash} +2. Last Event Hash: ${manifest.endEventHash} + +To verify this export offline: + +1. Install project dependencies: + npm install + +2. Run verification: + npm run audit:verify -- --file=export.json + +3. Manual verification steps: + a) Check sequence continuity (no gaps in sequence numbers) + b) Verify each event hash matches its canonical data + c) Verify previous hash chain links + d) Verify checkpoint signatures (if included) + +Filters Applied: +${manifest.filters.startDate ? `- Start Date: ${manifest.filters.startDate.toISOString()}` : ""} +${manifest.filters.endDate ? `- End Date: ${manifest.filters.endDate.toISOString()}` : ""} +${manifest.filters.actions ? `- Actions: ${manifest.filters.actions.join(", ")}` : ""} +${manifest.filters.actorId ? `- Actor ID: ${manifest.filters.actorId}` : ""} +${manifest.filters.targetType ? `- Target Type: ${manifest.filters.targetType}` : ""} + +Checkpoints: +${manifest.checkpoints ? `- Included: Yes (${manifest.checkpoints.count} checkpoints)` : "- Included: No"} + +For questions or issues, contact: security@chainmove.com +`.trim() +} + +/** + * Export to CSV format + */ +export function exportToCSV(events: any[]): string { + const headers = [ + "Sequence", + "Event ID", + "Timestamp", + "Actor ID", + "Actor Role", + "Action", + "Target Type", + "Target ID", + "Status", + "Request ID", + "IP Address", + "Previous Hash", + "Event Hash", + "Is Legacy", + ] + + const rows = events.map((event) => [ + event.sequence, + event.eventId, + event.timestamp.toISOString(), + event.actorId || "", + event.actorRole || "", + event.action, + event.targetType, + event.targetId || "", + event.status, + event.requestId || "", + event.ipAddress || "", + event.previousHash, + event.eventHash, + event.isLegacy ? "true" : "false", + ]) + + const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n") + + return csvContent +} + +/** + * Generate integrity report for a partition + */ +export async function generateIntegrityReport(partition: string): Promise<{ + report: string + summary: { + totalEvents: number + legacyEvents: number + verifiedEvents: number + errors: number + warnings: number + checkpoints: number + } +}> { + await dbConnect() + + const totalEvents = await TamperEvidentAuditLog.countDocuments({ partition }) + const legacyEvents = await TamperEvidentAuditLog.countDocuments({ partition, isLegacy: true }) + const checkpoints = await AuditCheckpoint.countDocuments({ partition }) + + const verificationResult = await verifyAuditChain(partition, { verifyCheckpoints: true }) + + const report = ` +AUDIT LOG INTEGRITY REPORT +========================= + +Partition: ${partition} +Generated: ${new Date().toISOString()} + +Summary: +-------- +Total Events: ${totalEvents} +Legacy Events: ${legacyEvents} +Verifiable Events: ${totalEvents - legacyEvents} +Events Verified: ${verificationResult.summary.eventsVerified} +Checkpoints: ${checkpoints} + +Integrity Status: ${verificationResult.valid ? "PASSED" : "FAILED"} +Errors: ${verificationResult.errors.length} +Warnings: ${verificationResult.warnings.length} + +${verificationResult.errors.length > 0 ? ` +Errors: +------- +${verificationResult.errors.map((e) => `- [${e.type}] ${e.message}`).join("\n")} +` : ""} + +${verificationResult.warnings.length > 0 ? ` +Warnings: +--------- +${verificationResult.warnings.map((w) => `- [${w.type}] ${w.message}`).join("\n")} +` : ""} + +Recommendations: +--------------- +${verificationResult.errors.length > 0 ? "- Investigate and resolve integrity errors immediately" : ""} +${verificationResult.warnings.length > 0 ? "- Review and address warnings" : ""} +${checkpoints === 0 && totalEvents > 1000 ? "- Create checkpoints for this partition" : ""} +${legacyEvents > 0 ? `- ${legacyEvents} legacy events cannot be verified` : ""} +`.trim() + + return { + report, + summary: { + totalEvents, + legacyEvents, + verifiedEvents: verificationResult.summary.eventsVerified, + errors: verificationResult.errors.length, + warnings: verificationResult.warnings.length, + checkpoints, + }, + } +} diff --git a/lib/security/audit-hash.test.ts b/lib/security/audit-hash.test.ts new file mode 100644 index 00000000..f15f1d4e --- /dev/null +++ b/lib/security/audit-hash.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest" +import { + buildCanonicalAuditEventData, + canonicalizeEventData, + computeEventHash, + getGenesisHash, +} from "./audit-hash" +import { verifyAuditExportPayload } from "./audit-verification" + +function buildEvent(overrides: Record = {}) { + const base = { + sequence: 0, + eventId: "evt-1", + actorId: "actor-1", + actorRole: "admin", + action: "kyc.approve", + targetType: "user", + targetId: "user-1", + status: "success", + requestId: "req-1", + metadata: { after: "approved", before: "pending" }, + timestamp: "2026-07-23T12:00:00.000Z", + partition: "2026-07", + previousHash: getGenesisHash("2026-07"), + isLegacy: false, + ...overrides, + } + const event = { + ...base, + previousHash: String(base.previousHash), + } + const canonicalData = canonicalizeEventData(buildCanonicalAuditEventData(event)) + const eventHash = computeEventHash(event.previousHash + canonicalData) + + return { + ...event, + canonicalData, + eventHash, + } +} + +describe("audit hash canonicalization", () => { + it("is stable for equivalent object key ordering", () => { + const first = canonicalizeEventData({ + z: 1, + a: { b: 2, a: 1 }, + list: [{ y: true, x: false }], + }) + const second = canonicalizeEventData({ + list: [{ x: false, y: true }], + a: { a: 1, b: 2 }, + z: 1, + }) + + expect(first).toBe(second) + }) + + it("verifies an intact offline export", () => { + const first = buildEvent() + const second = buildEvent({ + sequence: 1, + eventId: "evt-2", + previousHash: first.eventHash, + action: "wallet.credit", + }) + + const result = verifyAuditExportPayload({ + manifest: { + partition: "2026-07", + startSequence: 0, + endSequence: 1, + startEventHash: first.eventHash, + endEventHash: second.eventHash, + }, + events: [first, second], + }) + + expect(result.valid).toBe(true) + }) + + it("detects modified exported events", () => { + const event = buildEvent() + const result = verifyAuditExportPayload({ + manifest: { + partition: "2026-07", + startSequence: 0, + endSequence: 0, + startEventHash: event.eventHash, + endEventHash: event.eventHash, + }, + events: [{ ...event, action: "kyc.reject" }], + }) + + expect(result.valid).toBe(false) + expect(result.errors.some((error) => error.type === "INVALID_HASH")).toBe(true) + }) + + it("detects deleted or reordered exported events", () => { + const first = buildEvent() + const second = buildEvent({ + sequence: 1, + eventId: "evt-2", + previousHash: first.eventHash, + action: "wallet.credit", + }) + + const result = verifyAuditExportPayload({ + manifest: { + partition: "2026-07", + startSequence: 0, + endSequence: 1, + startEventHash: first.eventHash, + endEventHash: second.eventHash, + }, + events: [second, first], + }) + + expect(result.valid).toBe(false) + expect(result.errors.some((error) => error.type === "BROKEN_CHAIN" || error.type === "MISSING_SEQUENCE")).toBe(true) + }) +}) diff --git a/lib/security/audit-hash.ts b/lib/security/audit-hash.ts new file mode 100644 index 00000000..c7b95cd7 --- /dev/null +++ b/lib/security/audit-hash.ts @@ -0,0 +1,214 @@ +import crypto from "crypto" + +/** + * Canonicalize audit event data for consistent hashing + * This ensures that the same event data always produces the same hash + */ +export function canonicalizeEventData(eventData: Record): string { + const sortedData = sortObjectKeys(eventData) + return JSON.stringify(sortedData) +} + +/** + * Recursively sort object keys for canonical representation + */ +function sortObjectKeys(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(sortObjectKeys) + } + + if (typeof obj === "object") { + const sorted: Record = {} + const keys = Object.keys(obj).sort() + + for (const key of keys) { + sorted[key] = sortObjectKeys((obj as Record)[key]) + } + + return sorted + } + + return obj +} + +/** + * Compute SHA-256 hash of the canonical event data + */ +export function computeEventHash(canonicalData: string): string { + return crypto.createHash("sha256").update(canonicalData).digest("hex") +} + +/** + * Compute hash of a chain link (previous hash + current event data) + */ +export function computeChainHash(previousHash: string, eventData: Record): string { + const canonicalData = canonicalizeEventData(eventData) + const combined = previousHash + canonicalData + return crypto.createHash("sha256").update(combined).digest("hex") +} + +export interface CanonicalAuditEventData extends Record { + sequence: number + eventId: string + actorId?: string + actorRole?: string + actorIdentifier?: string + action: string + targetType: string + targetId?: string + status: string + requestId?: string + metadata?: Record + ipAddress?: string + userAgent?: string + timestamp: string + partition: string + previousHash: string + isLegacy?: boolean +} + +export function buildCanonicalAuditEventData(event: { + sequence: number + eventId: string + actorId?: string | null + actorRole?: string | null + actorIdentifier?: string | null + action: string + targetType: string + targetId?: string | null + status: string + requestId?: string | null + metadata?: Record | null + ipAddress?: string | null + userAgent?: string | null + timestamp: Date | string + partition: string + previousHash: string + isLegacy?: boolean +}): CanonicalAuditEventData { + const timestamp = + event.timestamp instanceof Date ? event.timestamp.toISOString() : new Date(event.timestamp).toISOString() + + return { + sequence: event.sequence, + eventId: event.eventId, + actorId: event.actorId || undefined, + actorRole: event.actorRole || undefined, + actorIdentifier: event.actorIdentifier || undefined, + action: event.action, + targetType: event.targetType, + targetId: event.targetId || undefined, + status: event.status, + requestId: event.requestId || undefined, + metadata: event.metadata || undefined, + ipAddress: event.ipAddress || undefined, + userAgent: event.userAgent || undefined, + timestamp, + partition: event.partition, + previousHash: event.previousHash, + isLegacy: event.isLegacy || undefined, + } +} + +/** + * Compute root hash for a range of events (Merkle-like approach) + */ +export function computeRootHash(eventHashes: string[]): string { + if (eventHashes.length === 0) { + return crypto.createHash("sha256").update("EMPTY_ROOT").digest("hex") + } + + if (eventHashes.length === 1) { + return eventHashes[0] + } + + // Combine all hashes in order + const combined = eventHashes.join("") + return crypto.createHash("sha256").update(combined).digest("hex") +} + +/** + * Genesis hash for the start of a partition + */ +export function getGenesisHash(partition: string): string { + return crypto.createHash("sha256").update(`GENESIS:${partition}`).digest("hex") +} + +/** + * Sanitize metadata to remove sensitive information + */ +export function sanitizeAuditMetadata(metadata: Record | undefined): Record | undefined { + if (!metadata) return undefined + + const sanitized = { ...metadata } + + // Remove sensitive fields + const sensitiveFields = [ + "password", + "token", + "secret", + "apiKey", + "privateKey", + "accessToken", + "refreshToken", + "sessionId", + "kycDocument", + "rawKycData", + "ssn", + "taxId", + "creditCard", + "bankAccount", + ] + + for (const field of sensitiveFields) { + if (field in sanitized) { + delete sanitized[field] + } + } + + // Recursively sanitize nested objects + for (const [key, value] of Object.entries(sanitized)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + sanitized[key] = sanitizeAuditMetadata(value as Record) + } + } + + return sanitized +} + +/** + * Redact PII from metadata while keeping structure + */ +export function redactPII(metadata: Record | undefined): Record | undefined { + if (!metadata) return undefined + + const redacted = { ...metadata } + + // Fields that may contain PII but we want to keep the structure + const piiFields = ["email", "phone", "address", "name", "firstName", "lastName"] + + for (const field of piiFields) { + if (field in redacted && typeof redacted[field] === "string") { + const value = redacted[field] as string + // Keep first 2 and last 2 characters, redact middle + if (value.length > 4) { + redacted[field] = `${value.slice(0, 2)}***${value.slice(-2)}` + } else { + redacted[field] = "***" + } + } + } + + // Recursively redact nested objects + for (const [key, value] of Object.entries(redacted)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + redacted[key] = redactPII(value as Record) + } + } + + return redacted +} diff --git a/lib/security/audit-log.ts b/lib/security/audit-log.ts index 8a7e3e7c..d41d94d5 100644 --- a/lib/security/audit-log.ts +++ b/lib/security/audit-log.ts @@ -1,5 +1,6 @@ import dbConnect from "@/lib/dbConnect" import AuditLog from "@/models/AuditLog" +import { logTamperEvidentAuditEvent } from "./tamper-evident-audit" type AuditActor = { _id?: { toString(): string } @@ -12,6 +13,30 @@ function sanitizeMetadata(metadata: Record | undefined) { return JSON.parse(JSON.stringify(metadata)) as Record } +const DEFAULT_CRITICAL_ACTION_PATTERNS = [ + "kyc", + "wallet", + "repayment", + "payout", + "loan", + "asset", + "investment", + "admin", + "user.role", + "notification.broadcast", + "email.send", +] + +function isCriticalAuditAction(action: string) { + const configured = process.env.CRITICAL_AUDIT_ACTIONS?.split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + const patterns = configured && configured.length > 0 ? configured : DEFAULT_CRITICAL_ACTION_PATTERNS + const normalizedAction = action.toLowerCase() + + return patterns.some((pattern) => normalizedAction.includes(pattern)) +} + export async function logAuditEvent({ actor, action, @@ -19,7 +44,10 @@ export async function logAuditEvent({ targetId, status = "success", ipAddress, + requestId, + userAgent, metadata, + criticalAction, }: { actor?: AuditActor action: string @@ -27,8 +55,31 @@ export async function logAuditEvent({ targetId?: string | null status?: "success" | "failure" ipAddress?: string | null + requestId?: string | null + userAgent?: string | null metadata?: Record + criticalAction?: boolean }) { + const sanitizedMetadata = sanitizeMetadata(metadata) + const mustWrite = criticalAction ?? isCriticalAuditAction(action) + + const tamperEvidentResult = await logTamperEvidentAuditEvent({ + actor, + action, + targetType, + targetId, + status, + requestId, + ipAddress, + userAgent, + metadata: sanitizedMetadata, + criticalAction: mustWrite, + }) + + if (!tamperEvidentResult.success && mustWrite) { + throw new Error(`CRITICAL_AUDIT_FAILURE: ${tamperEvidentResult.error || "Unknown error"}`) + } + try { await dbConnect() await AuditLog.create({ @@ -42,9 +93,12 @@ export async function logAuditEvent({ targetId: targetId || undefined, status, ipAddress: ipAddress || undefined, - metadata: sanitizeMetadata(metadata), + metadata: sanitizedMetadata, }) } catch (error) { console.error("AUDIT_LOG_WRITE_ERROR", error) + if (mustWrite && !tamperEvidentResult.success) { + throw error + } } } diff --git a/lib/security/audit-migration.ts b/lib/security/audit-migration.ts new file mode 100644 index 00000000..055346cb --- /dev/null +++ b/lib/security/audit-migration.ts @@ -0,0 +1,198 @@ +import dbConnect from "@/lib/dbConnect" +import AuditLog from "@/models/AuditLog" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" +import { getCurrentPartition } from "./tamper-evident-audit" +import { getGenesisHash, canonicalizeEventData, computeEventHash } from "./audit-hash" + +export interface MigrationResult { + success: boolean + migratedCount: number + skippedCount: number + errors: string[] + legacyPartition: string +} + +/** + * Migrate existing audit logs to tamper-evident system as legacy events + */ +export async function migrateLegacyAuditLogs(): Promise { + const errors: string[] = [] + let migratedCount = 0 + let skippedCount = 0 + + try { + await dbConnect() + + const legacyPartition = "legacy-" + getCurrentPartition() + + // Get all existing audit logs + const legacyLogs = await AuditLog.find({}).sort({ createdAt: 1 }).lean() + + if (legacyLogs.length === 0) { + return { + success: true, + migratedCount: 0, + skippedCount: 0, + errors: [], + legacyPartition, + } + } + + let sequence = 0 + let previousHash = getGenesisHash(legacyPartition) + + for (const legacyLog of legacyLogs) { + try { + // Check if already migrated + const existing = await TamperEvidentAuditLog.findOne({ + partition: legacyPartition, + sequence, + isLegacy: true, + }) + + if (existing) { + skippedCount++ + sequence++ + previousHash = existing.eventHash + continue + } + + // Build canonical event data for legacy event + const eventData = { + sequence, + eventId: legacyLog._id.toString(), + actorId: legacyLog.actorId?.toString(), + actorRole: legacyLog.actorRole, + action: legacyLog.action, + targetType: legacyLog.targetType, + targetId: legacyLog.targetId, + status: legacyLog.status, + metadata: legacyLog.metadata, + ipAddress: legacyLog.ipAddress, + timestamp: legacyLog.createdAt.toISOString(), + partition: legacyPartition, + previousHash, + isLegacy: true, + } + + const canonicalData = canonicalizeEventData(eventData) + const eventHash = computeEventHash(previousHash + canonicalData) + + // Create tamper-evident audit log entry + await TamperEvidentAuditLog.create({ + sequence, + eventId: legacyLog._id.toString(), + actorId: legacyLog.actorId?.toString(), + actorRole: legacyLog.actorRole, + action: legacyLog.action, + targetType: legacyLog.targetType, + targetId: legacyLog.targetId, + status: legacyLog.status, + metadata: legacyLog.metadata, + ipAddress: legacyLog.ipAddress, + timestamp: legacyLog.createdAt, + previousHash, + eventHash, + canonicalData, + partition: legacyPartition, + isLegacy: true, + }) + + previousHash = eventHash + sequence++ + migratedCount++ + } catch (error) { + errors.push( + `Failed to migrate event ${legacyLog._id}: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + } + + return { + success: errors.length === 0, + migratedCount, + skippedCount, + errors, + legacyPartition, + } + } catch (error) { + errors.push(`Migration failed: ${error instanceof Error ? error.message : "Unknown error"}`) + return { + success: false, + migratedCount, + skippedCount, + errors, + legacyPartition: "legacy-unknown", + } + } +} + +/** + * Get migration status + */ +export async function getMigrationStatus(): Promise<{ + legacyLogsCount: number + migratedLogsCount: number + migrationComplete: boolean + legacyPartition: string +}> { + await dbConnect() + + const legacyLogsCount = await AuditLog.countDocuments({}) + const legacyPartition = "legacy-" + getCurrentPartition() + const migratedLogsCount = await TamperEvidentAuditLog.countDocuments({ + partition: legacyPartition, + isLegacy: true, + }) + + return { + legacyLogsCount, + migratedLogsCount, + migrationComplete: legacyLogsCount === migratedLogsCount, + legacyPartition, + } +} + +/** + * Clean up old audit logs after successful migration + * WARNING: This should only be run after verifying migration success + */ +export async function cleanupOldAuditLogs(confirmationToken: string): Promise<{ + success: boolean + deletedCount: number + error?: string +}> { + if (confirmationToken !== "CONFIRM_DELETE_OLD_AUDIT_LOGS") { + return { + success: false, + deletedCount: 0, + error: "Invalid confirmation token", + } + } + + try { + await dbConnect() + + const migrationStatus = await getMigrationStatus() + if (!migrationStatus.migrationComplete) { + return { + success: false, + deletedCount: 0, + error: "Migration not complete. Cannot delete old logs.", + } + } + + const result = await AuditLog.deleteMany({}) + + return { + success: true, + deletedCount: result.deletedCount || 0, + } + } catch (error) { + return { + success: false, + deletedCount: 0, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} diff --git a/lib/security/audit-verification.ts b/lib/security/audit-verification.ts new file mode 100644 index 00000000..bb8760fe --- /dev/null +++ b/lib/security/audit-verification.ts @@ -0,0 +1,574 @@ +import dbConnect from "@/lib/dbConnect" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" +import AuditCheckpoint from "@/models/AuditCheckpoint" +import { buildCanonicalAuditEventData, canonicalizeEventData, computeEventHash, computeRootHash, getGenesisHash } from "./audit-hash" +import { localSigner } from "./audit-checkpoint" + +export interface VerificationResult { + valid: boolean + errors: VerificationError[] + warnings: VerificationWarning[] + summary: { + totalEvents: number + eventsVerified: number + firstSequence: number + lastSequence: number + partition: string + checkpointsVerified?: number + } +} + +export interface VerificationError { + type: + | "BROKEN_CHAIN" + | "MISSING_SEQUENCE" + | "INVALID_HASH" + | "MALFORMED_EVENT" + | "INVALID_CHECKPOINT" + | "CHECKPOINT_SIGNATURE_INVALID" + sequence?: number + eventId?: string + message: string + details?: any +} + +export interface VerificationWarning { + type: "LEGACY_EVENTS" | "MISSING_CHECKPOINT" | "OLD_CHECKPOINT" + message: string + details?: any +} + +export interface AuditExportPayload { + manifest?: { + partition?: string + startSequence?: number + endSequence?: number + startEventHash?: string + endEventHash?: string + } + events?: any[] + checkpoints?: any[] +} + +/** + * Verify the integrity of the audit chain for a partition + */ +export async function verifyAuditChain(partition: string, options?: { + startSequence?: number + endSequence?: number + verifyCheckpoints?: boolean +}): Promise { + const errors: VerificationError[] = [] + const warnings: VerificationWarning[] = [] + + try { + await dbConnect() + + // Get all events in the partition + const query: any = { partition } + if (options?.startSequence !== undefined || options?.endSequence !== undefined) { + query.sequence = {} + if (options.startSequence !== undefined) { + query.sequence.$gte = options.startSequence + } + if (options.endSequence !== undefined) { + query.sequence.$lte = options.endSequence + } + } + + const events = await TamperEvidentAuditLog.find(query).sort({ sequence: 1 }).lean() + + if (events.length === 0) { + return { + valid: true, + errors: [], + warnings: [], + summary: { + totalEvents: 0, + eventsVerified: 0, + firstSequence: 0, + lastSequence: 0, + partition, + }, + } + } + + // Check for legacy events + const legacyEvents = events.filter((e) => e.isLegacy) + if (legacyEvents.length > 0) { + warnings.push({ + type: "LEGACY_EVENTS", + message: `Found ${legacyEvents.length} legacy events that cannot be verified`, + details: { count: legacyEvents.length }, + }) + } + + // Verify non-legacy events + const verifiableEvents = events.filter((e) => !e.isLegacy) + let expectedPreviousHash = getGenesisHash(partition) + if (verifiableEvents[0]?.sequence > 0) { + const predecessor = await TamperEvidentAuditLog.findOne({ + partition, + sequence: verifiableEvents[0].sequence - 1, + isLegacy: false, + }).lean() + + expectedPreviousHash = predecessor?.eventHash || verifiableEvents[0].previousHash + } + let eventsVerified = 0 + + for (let i = 0; i < verifiableEvents.length; i++) { + const event = verifiableEvents[i] + const expectedSequence = i === 0 ? event.sequence : verifiableEvents[i - 1].sequence + 1 + + // Check sequence continuity + if (event.sequence !== expectedSequence && i > 0) { + errors.push({ + type: "MISSING_SEQUENCE", + sequence: expectedSequence, + message: `Missing sequence number ${expectedSequence}. Expected ${expectedSequence}, got ${event.sequence}`, + details: { expected: expectedSequence, actual: event.sequence }, + }) + } + + // Check previous hash matches + if (event.previousHash !== expectedPreviousHash) { + errors.push({ + type: "BROKEN_CHAIN", + sequence: event.sequence, + eventId: event.eventId, + message: `Broken chain at sequence ${event.sequence}. Previous hash mismatch.`, + details: { + expected: expectedPreviousHash, + actual: event.previousHash, + }, + }) + } + + // Recompute event hash + const canonicalData = canonicalizeEventData(buildCanonicalAuditEventData(event)) + if (event.canonicalData !== canonicalData) { + errors.push({ + type: "INVALID_HASH", + sequence: event.sequence, + eventId: event.eventId, + message: `Canonical data mismatch at sequence ${event.sequence}. Event fields may have been modified.`, + details: { + stored: event.canonicalData, + computed: canonicalData, + }, + }) + } + + const recomputedHash = computeEventHash(event.previousHash + canonicalData) + if (event.eventHash !== recomputedHash) { + errors.push({ + type: "INVALID_HASH", + sequence: event.sequence, + eventId: event.eventId, + message: `Invalid hash at sequence ${event.sequence}. Event may have been modified.`, + details: { + stored: event.eventHash, + computed: recomputedHash, + }, + }) + } + + // Validate event structure + if (!event.eventId || !event.action || !event.targetType || !event.timestamp) { + errors.push({ + type: "MALFORMED_EVENT", + sequence: event.sequence, + eventId: event.eventId, + message: `Malformed event at sequence ${event.sequence}. Missing required fields.`, + details: { + hasEventId: !!event.eventId, + hasAction: !!event.action, + hasTargetType: !!event.targetType, + hasTimestamp: !!event.timestamp, + }, + }) + } + + expectedPreviousHash = event.eventHash + eventsVerified++ + } + + // Verify checkpoints if requested + let checkpointsVerified = 0 + if (options?.verifyCheckpoints) { + const checkpointResult = await verifyCheckpoints(partition) + errors.push(...checkpointResult.errors) + warnings.push(...checkpointResult.warnings) + checkpointsVerified = checkpointResult.checkpointsVerified + } + + return { + valid: errors.length === 0, + errors, + warnings, + summary: { + totalEvents: events.length, + eventsVerified, + firstSequence: events[0].sequence, + lastSequence: events[events.length - 1].sequence, + partition, + checkpointsVerified: options?.verifyCheckpoints ? checkpointsVerified : undefined, + }, + } + } catch (error) { + console.error("VERIFICATION_ERROR", error) + errors.push({ + type: "MALFORMED_EVENT", + message: `Verification failed: ${error instanceof Error ? error.message : "Unknown error"}`, + }) + + return { + valid: false, + errors, + warnings, + summary: { + totalEvents: 0, + eventsVerified: 0, + firstSequence: 0, + lastSequence: 0, + partition, + }, + } + } +} + +/** + * Verify checkpoint integrity + */ +async function verifyCheckpoints(partition: string): Promise<{ + errors: VerificationError[] + warnings: VerificationWarning[] + checkpointsVerified: number +}> { + const errors: VerificationError[] = [] + const warnings: VerificationWarning[] = [] + let checkpointsVerified = 0 + + const checkpoints = await AuditCheckpoint.find({ partition }).sort({ checkpointNumber: 1 }) + + for (const checkpoint of checkpoints) { + // Verify signature + const signatureData = JSON.stringify({ + partition: checkpoint.partition, + checkpointNumber: checkpoint.checkpointNumber, + startSequence: checkpoint.startSequence, + endSequence: checkpoint.endSequence, + rootHash: checkpoint.rootHash, + }) + + const signatureValid = localSigner.verify(signatureData, checkpoint.signature) + if (!signatureValid) { + errors.push({ + type: "CHECKPOINT_SIGNATURE_INVALID", + message: `Invalid checkpoint signature for checkpoint ${checkpoint.checkpointNumber}`, + details: { checkpointNumber: checkpoint.checkpointNumber }, + }) + continue + } + + // Verify root hash + const events = await TamperEvidentAuditLog.find({ + partition, + sequence: { $gte: checkpoint.startSequence, $lte: checkpoint.endSequence }, + isLegacy: false, + }).sort({ sequence: 1 }) + + const eventHashes = events.map((e) => e.eventHash) + const computedRootHash = computeRootHash(eventHashes) + + if (checkpoint.rootHash !== computedRootHash) { + errors.push({ + type: "INVALID_CHECKPOINT", + message: `Invalid root hash for checkpoint ${checkpoint.checkpointNumber}`, + details: { + checkpointNumber: checkpoint.checkpointNumber, + stored: checkpoint.rootHash, + computed: computedRootHash, + }, + }) + continue + } + + // Check if checkpoint is old + const daysSinceCheckpoint = (Date.now() - checkpoint.signedAt.getTime()) / (1000 * 60 * 60 * 24) + if (daysSinceCheckpoint > 30) { + warnings.push({ + type: "OLD_CHECKPOINT", + message: `Checkpoint ${checkpoint.checkpointNumber} is ${Math.floor(daysSinceCheckpoint)} days old`, + details: { + checkpointNumber: checkpoint.checkpointNumber, + daysSinceCheckpoint: Math.floor(daysSinceCheckpoint), + }, + }) + } + + checkpointsVerified++ + } + + // Check if checkpoints exist + if (checkpoints.length === 0) { + const eventCount = await TamperEvidentAuditLog.countDocuments({ partition, isLegacy: false }) + if (eventCount > 1000) { + warnings.push({ + type: "MISSING_CHECKPOINT", + message: `Partition ${partition} has ${eventCount} events but no checkpoints`, + details: { eventCount }, + }) + } + } + + return { errors, warnings, checkpointsVerified } +} + +/** + * Verify a single event + */ +export async function verifySingleEvent(eventId: string): Promise<{ + valid: boolean + errors: VerificationError[] +}> { + await dbConnect() + + const errors: VerificationError[] = [] + const event = await TamperEvidentAuditLog.findOne({ eventId }).lean() + + if (!event) { + errors.push({ + type: "MALFORMED_EVENT", + eventId, + message: "Event not found", + }) + return { valid: false, errors } + } + + if (event.isLegacy) { + return { valid: true, errors: [] } // Legacy events cannot be verified + } + + // Recompute hash + const canonicalData = canonicalizeEventData(buildCanonicalAuditEventData(event)) + const recomputedHash = computeEventHash(event.previousHash + canonicalData) + if (event.canonicalData !== canonicalData) { + errors.push({ + type: "INVALID_HASH", + eventId: event.eventId, + sequence: event.sequence, + message: "Canonical data mismatch - event fields may have been tampered with", + details: { + stored: event.canonicalData, + computed: canonicalData, + }, + }) + } + + if (event.eventHash !== recomputedHash) { + errors.push({ + type: "INVALID_HASH", + eventId: event.eventId, + sequence: event.sequence, + message: "Event hash mismatch - event may have been tampered with", + details: { + stored: event.eventHash, + computed: recomputedHash, + }, + }) + } + + return { + valid: errors.length === 0, + errors, + } +} + +export function verifyAuditExportPayload(payload: AuditExportPayload): VerificationResult { + const errors: VerificationError[] = [] + const warnings: VerificationWarning[] = [] + const events = Array.isArray(payload.events) ? payload.events : [] + const partition = payload.manifest?.partition || events[0]?.partition || "unknown" + + if (!Array.isArray(payload.events)) { + errors.push({ + type: "MALFORMED_EVENT", + message: "Export payload is missing an events array", + }) + } + + let expectedPreviousHash = events[0]?.previousHash || getGenesisHash(partition) + let eventsVerified = 0 + + for (let index = 0; index < events.length; index++) { + const event = events[index] + const expectedSequence = index === 0 ? event.sequence : events[index - 1].sequence + 1 + + if (event.sequence !== expectedSequence && index > 0) { + errors.push({ + type: "MISSING_SEQUENCE", + sequence: expectedSequence, + eventId: event.eventId, + message: `Missing sequence number ${expectedSequence}. Expected ${expectedSequence}, got ${event.sequence}`, + }) + } + + if (!event.eventId || !event.action || !event.targetType || !event.timestamp || !event.previousHash || !event.eventHash) { + errors.push({ + type: "MALFORMED_EVENT", + sequence: event.sequence, + eventId: event.eventId, + message: `Malformed event at sequence ${event.sequence}. Missing required fields.`, + }) + continue + } + + if (event.previousHash !== expectedPreviousHash) { + errors.push({ + type: "BROKEN_CHAIN", + sequence: event.sequence, + eventId: event.eventId, + message: `Broken chain at sequence ${event.sequence}. Previous hash mismatch.`, + details: { expected: expectedPreviousHash, actual: event.previousHash }, + }) + } + + const canonicalData = canonicalizeEventData(buildCanonicalAuditEventData(event)) + if (event.canonicalData !== canonicalData) { + errors.push({ + type: "INVALID_HASH", + sequence: event.sequence, + eventId: event.eventId, + message: `Canonical data mismatch at sequence ${event.sequence}.`, + }) + } + + const recomputedHash = computeEventHash(event.previousHash + canonicalData) + if (event.eventHash !== recomputedHash) { + errors.push({ + type: "INVALID_HASH", + sequence: event.sequence, + eventId: event.eventId, + message: `Invalid hash at sequence ${event.sequence}. Event may have been modified.`, + }) + } + + if (event.isLegacy) { + warnings.push({ + type: "LEGACY_EVENTS", + message: `Legacy event ${event.eventId} is included but cannot prove pre-migration history.`, + }) + } + + expectedPreviousHash = event.eventHash + eventsVerified++ + } + + if (payload.manifest?.startSequence !== undefined && events[0]?.sequence !== payload.manifest.startSequence) { + errors.push({ + type: "MISSING_SEQUENCE", + sequence: payload.manifest.startSequence, + message: "Manifest start sequence does not match first exported event", + }) + } + + if (payload.manifest?.endSequence !== undefined && events.at(-1)?.sequence !== payload.manifest.endSequence) { + errors.push({ + type: "MISSING_SEQUENCE", + sequence: payload.manifest.endSequence, + message: "Manifest end sequence does not match last exported event", + }) + } + + if (payload.manifest?.startEventHash && events[0]?.eventHash !== payload.manifest.startEventHash) { + errors.push({ + type: "INVALID_HASH", + sequence: events[0]?.sequence, + message: "Manifest start event hash does not match first exported event", + }) + } + + if (payload.manifest?.endEventHash && events.at(-1)?.eventHash !== payload.manifest.endEventHash) { + errors.push({ + type: "INVALID_HASH", + sequence: events.at(-1)?.sequence, + message: "Manifest end event hash does not match last exported event", + }) + } + + return { + valid: errors.length === 0, + errors, + warnings, + summary: { + totalEvents: events.length, + eventsVerified, + firstSequence: events[0]?.sequence || 0, + lastSequence: events.at(-1)?.sequence || 0, + partition, + checkpointsVerified: Array.isArray(payload.checkpoints) ? payload.checkpoints.length : undefined, + }, + } +} + +/** + * Detect deleted or reordered events + */ +export async function detectAnomalies(partition: string): Promise<{ + missingSequences: number[] + duplicateSequences: number[] + outOfOrderEvents: Array<{ sequence: number; timestamp: Date }> +}> { + await dbConnect() + + const events = await TamperEvidentAuditLog.find({ partition, isLegacy: false }) + .sort({ sequence: 1 }) + .lean() + + const missingSequences: number[] = [] + const duplicateSequences: number[] = [] + const outOfOrderEvents: Array<{ sequence: number; timestamp: Date }> = [] + + const sequenceCounts: Record = {} + + // Check for duplicates + for (const event of events) { + sequenceCounts[event.sequence] = (sequenceCounts[event.sequence] || 0) + 1 + } + + for (const [seq, count] of Object.entries(sequenceCounts)) { + if (count > 1) { + duplicateSequences.push(Number(seq)) + } + } + + // Check for missing sequences + if (events.length > 0) { + const firstSeq = events[0].sequence + const lastSeq = events[events.length - 1].sequence + + for (let seq = firstSeq; seq <= lastSeq; seq++) { + if (!sequenceCounts[seq]) { + missingSequences.push(seq) + } + } + } + + // Check for out-of-order timestamps + for (let i = 1; i < events.length; i++) { + if (new Date(events[i].timestamp) < new Date(events[i - 1].timestamp)) { + outOfOrderEvents.push({ + sequence: events[i].sequence, + timestamp: events[i].timestamp, + }) + } + } + + return { + missingSequences, + duplicateSequences, + outOfOrderEvents, + } +} diff --git a/lib/security/tamper-evident-audit.ts b/lib/security/tamper-evident-audit.ts new file mode 100644 index 00000000..cc49ce49 --- /dev/null +++ b/lib/security/tamper-evident-audit.ts @@ -0,0 +1,287 @@ +import { randomUUID } from "crypto" +import dbConnect from "@/lib/dbConnect" +import AuditSequence from "@/models/AuditSequence" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" +import { + buildCanonicalAuditEventData, + canonicalizeEventData, + computeEventHash, + getGenesisHash, + sanitizeAuditMetadata, +} from "./audit-hash" +import { autoCheckpointIfNeeded } from "./audit-checkpoint" + +type AuditActor = { + _id?: { toString(): string } + role?: string + email?: string + walletAddress?: string +} | null + +export interface TamperEvidentAuditEventInput { + actor?: AuditActor + action: string + targetType: string + targetId?: string | null + status?: "success" | "failure" + requestId?: string | null + ipAddress?: string | null + userAgent?: string | null + metadata?: Record + partition?: string + criticalAction?: boolean // If true, throw error on audit failure +} + +/** + * Get the current partition identifier (monthly by default) + */ +export function getCurrentPartition(): string { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, "0") + return `${year}-${month}` +} + +/** + * Get the next sequence number for a partition + */ +async function reserveNextSequence(partition: string): Promise { + const counter = await AuditSequence.findOneAndUpdate( + { partition }, + { $inc: { nextSequence: 1 }, $setOnInsert: { partition } }, + { new: false, upsert: true, setDefaultsOnInsert: true }, + ) + + if (!counter) return 0 + return counter.nextSequence +} + +/** + * Get the previous event hash for chain linking + */ +async function getPreviousHash(partition: string, sequence: number): Promise { + if (sequence === 0) return getGenesisHash(partition) + + const predecessorSequence = sequence - 1 + for (let attempt = 0; attempt < 25; attempt++) { + const previousEvent = await TamperEvidentAuditLog.findOne({ + partition, + sequence: predecessorSequence, + }).select("eventHash") + + if (previousEvent?.eventHash) return previousEvent.eventHash + await new Promise((resolve) => setTimeout(resolve, 40)) + } + + throw new Error(`AUDIT_PREDECESSOR_MISSING: missing sequence ${predecessorSequence} in ${partition}`) +} + +/** + * Log a tamper-evident audit event + */ +export async function logTamperEvidentAuditEvent(input: TamperEvidentAuditEventInput): Promise<{ + success: boolean + eventId?: string + error?: string +}> { + try { + await dbConnect() + + const partition = input.partition || getCurrentPartition() + const sequence = await reserveNextSequence(partition) + const previousHash = await getPreviousHash(partition, sequence) + const timestamp = new Date() + const eventId = randomUUID() + + // Extract actor information + const actorId = input.actor?._id?.toString() + const actorRole = + input.actor?.role === "admin" || + input.actor?.role === "driver" || + input.actor?.role === "investor" || + input.actor?.role === "system" + ? input.actor.role + : undefined + + const actorIdentifier = input.actor?.email || input.actor?.walletAddress + + // Sanitize metadata + const sanitizedMetadata = sanitizeAuditMetadata(input.metadata) + + // Build canonical event data + const eventData = buildCanonicalAuditEventData({ + sequence, + eventId, + actorId, + actorRole, + actorIdentifier, + action: input.action, + targetType: input.targetType, + targetId: input.targetId || undefined, + status: input.status || "success", + requestId: input.requestId || undefined, + metadata: sanitizedMetadata, + ipAddress: input.ipAddress || undefined, + userAgent: input.userAgent || undefined, + timestamp: timestamp.toISOString(), + partition, + previousHash, + }) + + // Canonicalize and hash + const canonicalData = canonicalizeEventData(eventData) + const eventHash = computeEventHash(previousHash + canonicalData) + + // Create the audit log entry + const auditLog = await TamperEvidentAuditLog.create({ + sequence, + eventId, + actorId, + actorRole, + actorIdentifier, + action: input.action, + targetType: input.targetType, + targetId: input.targetId || undefined, + status: input.status || "success", + requestId: input.requestId || undefined, + metadata: sanitizedMetadata, + ipAddress: input.ipAddress || undefined, + userAgent: input.userAgent || undefined, + timestamp, + previousHash, + eventHash, + canonicalData, + partition, + isLegacy: false, + }) + + // Auto-checkpoint if needed (every 1000 events) + await autoCheckpointIfNeeded(partition, 1000) + + return { + success: true, + eventId: auditLog.eventId, + } + } catch (error) { + console.error("TAMPER_EVIDENT_AUDIT_ERROR", error) + + // If this is a critical action, throw the error + if (input.criticalAction) { + throw new Error( + `CRITICAL_AUDIT_FAILURE: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +/** + * Batch log multiple audit events (for migration or bulk operations) + */ +export async function logTamperEvidentAuditEventBatch( + events: TamperEvidentAuditEventInput[], +): Promise<{ + success: boolean + eventIds: string[] + errors: string[] +}> { + const eventIds: string[] = [] + const errors: string[] = [] + + for (const event of events) { + const result = await logTamperEvidentAuditEvent(event) + if (result.success && result.eventId) { + eventIds.push(result.eventId) + } else if (result.error) { + errors.push(result.error) + } + } + + return { + success: errors.length === 0, + eventIds, + errors, + } +} + +/** + * Get audit events for a partition + */ +export async function getAuditEvents(partition: string, options?: { + startSequence?: number + endSequence?: number + limit?: number + includeLegacy?: boolean +}): Promise { + await dbConnect() + + const query: any = { partition } + + if (options?.startSequence !== undefined || options?.endSequence !== undefined) { + query.sequence = {} + if (options.startSequence !== undefined) { + query.sequence.$gte = options.startSequence + } + if (options.endSequence !== undefined) { + query.sequence.$lte = options.endSequence + } + } + + if (!options?.includeLegacy) { + query.isLegacy = false + } + + let queryBuilder = TamperEvidentAuditLog.find(query).sort({ sequence: 1 }) + + if (options?.limit) { + queryBuilder = queryBuilder.limit(options.limit) + } + + return await queryBuilder.lean() +} + +/** + * Search audit events by criteria + */ +export async function searchAuditEvents(criteria: { + partition?: string + action?: string + actorId?: string + targetType?: string + targetId?: string + status?: "success" | "failure" + startDate?: Date + endDate?: Date + limit?: number +}): Promise { + await dbConnect() + + const query: any = {} + + if (criteria.partition) query.partition = criteria.partition + if (criteria.action) query.action = criteria.action + if (criteria.actorId) query.actorId = criteria.actorId + if (criteria.targetType) query.targetType = criteria.targetType + if (criteria.targetId) query.targetId = criteria.targetId + if (criteria.status) query.status = criteria.status + + if (criteria.startDate || criteria.endDate) { + query.timestamp = {} + if (criteria.startDate) query.timestamp.$gte = criteria.startDate + if (criteria.endDate) query.timestamp.$lte = criteria.endDate + } + + query.isLegacy = false + + let queryBuilder = TamperEvidentAuditLog.find(query).sort({ timestamp: -1 }) + + if (criteria.limit) { + queryBuilder = queryBuilder.limit(criteria.limit) + } + + return await queryBuilder.lean() +} diff --git a/models/AuditCheckpoint.ts b/models/AuditCheckpoint.ts new file mode 100644 index 00000000..4531933c --- /dev/null +++ b/models/AuditCheckpoint.ts @@ -0,0 +1,112 @@ +import mongoose, { Schema, type Document } from "mongoose" + +export interface IAuditCheckpoint extends Document { + partition: string + checkpointNumber: number + startSequence: number + endSequence: number + startEventHash: string + endEventHash: string + rootHash: string + signature: string + signedBy: string // key identifier + signedAt: Date + eventCount: number + metadata?: Record + createdAt: Date +} + +const AuditCheckpointSchema = new Schema( + { + partition: { + type: String, + required: true, + index: true, + }, + checkpointNumber: { + type: Number, + required: true, + }, + startSequence: { + type: Number, + required: true, + }, + endSequence: { + type: Number, + required: true, + }, + startEventHash: { + type: String, + required: true, + }, + endEventHash: { + type: String, + required: true, + }, + rootHash: { + type: String, + required: true, + }, + signature: { + type: String, + required: true, + }, + signedBy: { + type: String, + required: true, + }, + signedAt: { + type: Date, + required: true, + }, + eventCount: { + type: Number, + required: true, + }, + metadata: { + type: Schema.Types.Mixed, + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +) + +// Compound index for partition and checkpoint number +AuditCheckpointSchema.index({ partition: 1, checkpointNumber: 1 }, { unique: true }) +AuditCheckpointSchema.index({ signedAt: -1 }) + +// Prevent modifications +AuditCheckpointSchema.pre("save", function (next) { + if (!this.isNew) { + throw new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be modified") + } + next() +}) + +AuditCheckpointSchema.pre("findOneAndUpdate", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be modified")) +}) + +AuditCheckpointSchema.pre("updateOne", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be modified")) +}) + +AuditCheckpointSchema.pre("updateMany", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be modified")) +}) + +AuditCheckpointSchema.pre("findOneAndDelete", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be deleted")) +}) + +AuditCheckpointSchema.pre("deleteOne", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be deleted")) +}) + +AuditCheckpointSchema.pre("deleteMany", function (next) { + next(new Error("CHECKPOINT_IMMUTABLE: Checkpoints cannot be deleted")) +}) + +export default (mongoose.models.AuditCheckpoint || + mongoose.model("AuditCheckpoint", AuditCheckpointSchema)) as mongoose.Model diff --git a/models/AuditSequence.ts b/models/AuditSequence.ts new file mode 100644 index 00000000..83beb0eb --- /dev/null +++ b/models/AuditSequence.ts @@ -0,0 +1,30 @@ +import mongoose, { Schema, type Document } from "mongoose" + +export interface IAuditSequence extends Document { + partition: string + nextSequence: number + createdAt: Date + updatedAt: Date +} + +const AuditSequenceSchema = new Schema( + { + partition: { + type: String, + required: true, + unique: true, + index: true, + }, + nextSequence: { + type: Number, + required: true, + default: 0, + }, + }, + { + timestamps: true, + }, +) + +export default (mongoose.models.AuditSequence || + mongoose.model("AuditSequence", AuditSequenceSchema)) as mongoose.Model diff --git a/models/TamperEvidentAuditLog.ts b/models/TamperEvidentAuditLog.ts new file mode 100644 index 00000000..b2558c8f --- /dev/null +++ b/models/TamperEvidentAuditLog.ts @@ -0,0 +1,185 @@ +import mongoose, { Schema, type Document } from "mongoose" + +export interface ITamperEvidentAuditLog extends Document { + // Event identification + sequence: number + eventId: string + + // Actor information + actorId?: string + actorRole?: "admin" | "driver" | "investor" | "system" + actorIdentifier?: string // email or wallet for additional context + + // Action details + action: string + targetType: string + targetId?: string + + // Result and context + status: "success" | "failure" + requestId?: string // correlation ID for request tracking + + // Sanitized metadata (no secrets, tokens, or raw PII) + metadata?: Record + + // Security context + ipAddress?: string + userAgent?: string + + // Temporal information + timestamp: Date + + // Hash chain fields + previousHash: string + eventHash: string + + // Canonicalized event data (for verification) + canonicalData: string + + // Partition information + partition: string // e.g., "2026-07", "global", etc. + + // Legacy flag + isLegacy: boolean + + createdAt: Date +} + +const TamperEvidentAuditLogSchema = new Schema( + { + sequence: { + type: Number, + required: true, + index: true, + }, + eventId: { + type: String, + required: true, + unique: true, + index: true, + }, + actorId: { + type: String, + index: true, + }, + actorRole: { + type: String, + enum: ["admin", "driver", "investor", "system"], + index: true, + }, + actorIdentifier: { + type: String, + }, + action: { + type: String, + required: true, + index: true, + }, + targetType: { + type: String, + required: true, + index: true, + }, + targetId: { + type: String, + index: true, + }, + status: { + type: String, + enum: ["success", "failure"], + required: true, + default: "success", + index: true, + }, + requestId: { + type: String, + index: true, + }, + metadata: { + type: Schema.Types.Mixed, + }, + ipAddress: { + type: String, + }, + userAgent: { + type: String, + }, + timestamp: { + type: Date, + required: true, + index: true, + }, + previousHash: { + type: String, + required: true, + index: true, + }, + eventHash: { + type: String, + required: true, + unique: true, + index: true, + }, + canonicalData: { + type: String, + required: true, + }, + partition: { + type: String, + required: true, + index: true, + }, + isLegacy: { + type: Boolean, + default: false, + index: true, + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +) + +// Compound indexes for efficient queries +TamperEvidentAuditLogSchema.index({ partition: 1, sequence: 1 }, { unique: true }) +TamperEvidentAuditLogSchema.index({ partition: 1, timestamp: 1 }) +TamperEvidentAuditLogSchema.index({ action: 1, timestamp: -1 }) +TamperEvidentAuditLogSchema.index({ actorId: 1, timestamp: -1 }) + +// Prevent updates and deletes at the application level +TamperEvidentAuditLogSchema.pre("save", function (next) { + if (!this.isNew) { + throw new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be modified") + } + next() +}) + +TamperEvidentAuditLogSchema.pre("findOneAndUpdate", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be modified")) +}) + +TamperEvidentAuditLogSchema.pre("updateOne", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be modified")) +}) + +TamperEvidentAuditLogSchema.pre("updateMany", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be modified")) +}) + +TamperEvidentAuditLogSchema.pre("findOneAndDelete", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be deleted")) +}) + +TamperEvidentAuditLogSchema.pre("deleteOne", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be deleted")) +}) + +TamperEvidentAuditLogSchema.pre("deleteMany", function (next) { + next(new Error("AUDIT_LOG_IMMUTABLE: Audit logs cannot be deleted")) +}) + +export default (mongoose.models.TamperEvidentAuditLog || + mongoose.model( + "TamperEvidentAuditLog", + TamperEvidentAuditLogSchema, + )) as mongoose.Model diff --git a/package.json b/package.json index 38eb1a93..976bd561 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,12 @@ "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit", + "openapi:generate": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/check-openapi-drift.ts && tsx scripts/check-openapi-compat.ts", "start": "next start", + "audit:export": "tsx scripts/audit-export.ts", + "audit:migrate": "tsx scripts/audit-migrate.ts", + "audit:verify": "tsx scripts/audit-verify.ts", "fx:legacy-check": "tsx scripts/check-legacy-fx-transactions.ts", "demo:pool-asset": "tsx scripts/demo-pool-asset.ts" }, diff --git a/scripts/audit-export.ts b/scripts/audit-export.ts new file mode 100644 index 00000000..f4b538e6 --- /dev/null +++ b/scripts/audit-export.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env tsx +/** + * Audit Export CLI + * + * Usage: + * npm run audit:export -- --partition=2026-07 --output=export.json + * npm run audit:export -- --partition=2026-07 --format=csv --output=export.csv + * npm run audit:export -- --partition=2026-07 --redact-pii --checkpoints + */ + +import fs from "fs" +import { exportAuditEvents, exportToCSV } from "@/lib/security/audit-export" +import { getCurrentPartition } from "@/lib/security/tamper-evident-audit" + +async function main() { + const args = process.argv.slice(2) + const partitionArg = args.find((arg) => arg.startsWith("--partition="))?.split("=")[1] + const outputArg = args.find((arg) => arg.startsWith("--output="))?.split("=")[1] + const formatArg = args.find((arg) => arg.startsWith("--format="))?.split("=")[1] as "json" | "csv" | undefined + const redactPII = args.includes("--redact-pii") + const includeCheckpoints = args.includes("--checkpoints") + const actionArgs = args.filter((arg) => arg.startsWith("--action=")).map((arg) => arg.split("=")[1]) + + const partition = partitionArg || getCurrentPartition() + const format = formatArg || "json" + const output = outputArg || `audit-export-${partition}-${Date.now()}.${format}` + + console.log(`Exporting audit logs for partition: ${partition}`) + console.log(`Output file: ${output}`) + console.log(`Format: ${format}`) + console.log(`Redact PII: ${redactPII}`) + console.log(`Include Checkpoints: ${includeCheckpoints}`) + if (actionArgs.length > 0) console.log(`Filter Actions: ${actionArgs.join(", ")}`) + + const exportResult = await exportAuditEvents({ + partition, + actions: actionArgs.length > 0 ? actionArgs : undefined, + redactPII, + includeCheckpoints, + format, + }) + + if (format === "csv") { + fs.writeFileSync(output, exportToCSV(exportResult.events), "utf-8") + console.log(`OK Exported ${exportResult.events.length} events to ${output}`) + } else { + fs.writeFileSync( + output, + JSON.stringify( + { + manifest: exportResult.manifest, + events: exportResult.events, + checkpoints: exportResult.checkpoints, + verificationInstructions: exportResult.verificationInstructions, + }, + null, + 2, + ), + "utf-8", + ) + + const instructionsFile = output.replace(/\.json$/, "-VERIFY.txt") + fs.writeFileSync(instructionsFile, exportResult.verificationInstructions, "utf-8") + console.log(`OK Exported ${exportResult.events.length} events to ${output}`) + console.log(`OK Verification instructions written to ${instructionsFile}`) + } + + console.log("Export Summary:") + console.log(` Total Events: ${exportResult.manifest.totalEvents}`) + console.log(` Sequence Range: ${exportResult.manifest.startSequence} - ${exportResult.manifest.endSequence}`) + console.log(` Integrity Verified: ${exportResult.manifest.integrity.verified ? "YES" : "NO"}`) + console.log(` Verification Errors: ${exportResult.manifest.integrity.verificationErrors}`) + console.log(` Verification Warnings: ${exportResult.manifest.integrity.verificationWarnings}`) + if (exportResult.manifest.checkpoints) { + console.log(` Checkpoints Included: ${exportResult.manifest.checkpoints.count}`) + } +} + +main().catch((error) => { + console.error("Export failed:", error) + process.exit(1) +}) diff --git a/scripts/audit-migrate.ts b/scripts/audit-migrate.ts new file mode 100644 index 00000000..ac1327c7 --- /dev/null +++ b/scripts/audit-migrate.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env tsx +/** + * Audit Migration CLI + * + * Usage: + * npm run audit:migrate + * npm run audit:migrate -- --status + */ + +import { getMigrationStatus, migrateLegacyAuditLogs } from "@/lib/security/audit-migration" + +async function main() { + const args = process.argv.slice(2) + const checkStatus = args.includes("--status") + + if (checkStatus) { + const status = await getMigrationStatus() + + console.log("Migration Status:") + console.log(` Legacy Audit Logs: ${status.legacyLogsCount}`) + console.log(` Migrated Logs: ${status.migratedLogsCount}`) + console.log(` Migration Complete: ${status.migrationComplete ? "YES" : "NO"}`) + console.log(` Legacy Partition: ${status.legacyPartition}`) + process.exit(0) + } + + const result = await migrateLegacyAuditLogs() + + console.log("Migration Result:") + console.log(` Success: ${result.success ? "YES" : "NO"}`) + console.log(` Migrated: ${result.migratedCount}`) + console.log(` Skipped: ${result.skippedCount}`) + console.log(` Legacy Partition: ${result.legacyPartition}`) + + if (result.errors.length > 0) { + console.log(`Errors (${result.errors.length}):`) + for (const error of result.errors.slice(0, 10)) { + console.log(` ${error}`) + } + if (result.errors.length > 10) { + console.log(` ... and ${result.errors.length - 10} more`) + } + } + + if (!result.success) { + process.exit(1) + } + + console.log("OK Migration completed. Verify with:") + console.log(` npm run audit:verify -- --partition=${result.legacyPartition}`) +} + +main().catch((error) => { + console.error("Migration failed:", error) + process.exit(1) +}) diff --git a/scripts/audit-verify.ts b/scripts/audit-verify.ts new file mode 100644 index 00000000..38dd8885 --- /dev/null +++ b/scripts/audit-verify.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env tsx +/** + * Audit Verification CLI + * + * Usage: + * npm run audit:verify -- --partition=2026-07 + * npm run audit:verify -- --partition=2026-07 --checkpoints + * npm run audit:verify -- --all + * npm run audit:verify -- --file=audit-export.json + */ + +import fs from "fs" +import { detectAnomalies, verifyAuditChain, verifyAuditExportPayload } from "@/lib/security/audit-verification" +import { getCurrentPartition } from "@/lib/security/tamper-evident-audit" +import dbConnect from "@/lib/dbConnect" +import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog" + +type VerificationOutput = Awaited> + +async function main() { + const args = process.argv.slice(2) + const partitionArg = args.find((arg) => arg.startsWith("--partition="))?.split("=")[1] + const fileArg = args.find((arg) => arg.startsWith("--file="))?.split("=")[1] + const verifyCheckpoints = args.includes("--checkpoints") + const verifyAll = args.includes("--all") + + if (fileArg) { + const payload = JSON.parse(fs.readFileSync(fileArg, "utf-8")) + const result = verifyAuditExportPayload(payload) + printResult(result, fileArg) + process.exit(result.valid ? 0 : 1) + } + + await dbConnect() + + if (verifyAll) { + const partitions = await TamperEvidentAuditLog.distinct("partition") + for (const partition of partitions) { + await verifyPartition(partition, verifyCheckpoints) + } + } else { + await verifyPartition(partitionArg || getCurrentPartition(), verifyCheckpoints) + } +} + +async function verifyPartition(partition: string, verifyCheckpoints: boolean) { + console.log(`\n${"=".repeat(60)}`) + console.log(`Verifying partition: ${partition}`) + console.log(`${"=".repeat(60)}\n`) + + const result = await verifyAuditChain(partition, { verifyCheckpoints }) + printResult(result, partition) + + const anomalies = await detectAnomalies(partition) + + if (anomalies.missingSequences.length > 0) { + console.log(`\nERROR Missing Sequences (${anomalies.missingSequences.length}):`) + console.log(` ${anomalies.missingSequences.slice(0, 10).join(", ")}${anomalies.missingSequences.length > 10 ? "..." : ""}`) + } + + if (anomalies.duplicateSequences.length > 0) { + console.log(`\nERROR Duplicate Sequences (${anomalies.duplicateSequences.length}):`) + console.log(` ${anomalies.duplicateSequences.slice(0, 10).join(", ")}${anomalies.duplicateSequences.length > 10 ? "..." : ""}`) + } + + if (anomalies.outOfOrderEvents.length > 0) { + console.log(`\nWARN Out-of-Order Timestamps (${anomalies.outOfOrderEvents.length}):`) + for (const event of anomalies.outOfOrderEvents.slice(0, 5)) { + console.log(` Sequence ${event.sequence}: ${event.timestamp.toISOString()}`) + } + } + + if ( + result.valid && + anomalies.missingSequences.length === 0 && + anomalies.duplicateSequences.length === 0 && + anomalies.outOfOrderEvents.length === 0 + ) { + console.log("\nOK No anomalies detected. Audit chain is intact.") + } +} + +function printResult(result: VerificationOutput, scope: string) { + console.log("Verification Result:") + console.log(` Scope: ${scope}`) + console.log(` Status: ${result.valid ? "PASSED" : "FAILED"}`) + console.log(` Total Events: ${result.summary.totalEvents}`) + console.log(` Events Verified: ${result.summary.eventsVerified}`) + console.log(` Sequence Range: ${result.summary.firstSequence} - ${result.summary.lastSequence}`) + if (result.summary.checkpointsVerified !== undefined) { + console.log(` Checkpoints Verified: ${result.summary.checkpointsVerified}`) + } + console.log(` Errors: ${result.errors.length}`) + console.log(` Warnings: ${result.warnings.length}`) + + for (const error of result.errors) { + console.log(` ERROR [${error.type}] ${error.message}`) + if (error.sequence !== undefined) console.log(` Sequence: ${error.sequence}`) + if (error.eventId) console.log(` Event ID: ${error.eventId}`) + } + + for (const warning of result.warnings) { + console.log(` WARN [${warning.type}] ${warning.message}`) + } +} + +main().catch((error) => { + console.error("Verification failed:", error) + process.exit(1) +}) diff --git a/scripts/check-openapi-drift.ts b/scripts/check-openapi-drift.ts index 779f1fe5..d8331487 100644 --- a/scripts/check-openapi-drift.ts +++ b/scripts/check-openapi-drift.ts @@ -1,9 +1,14 @@ import { readFileSync } from "fs" import { execFileSync } from "child_process" +import { createRequire } from "module" +import { dirname, resolve } from "path" const target = "docs/openapi/chainmove.openapi.json" +const require = createRequire(import.meta.url) +const tsxPackageRoot = dirname(require.resolve("tsx/package.json")) +const tsxCli = resolve(tsxPackageRoot, "dist/cli.mjs") const before = readFileSync(target, "utf8") -execFileSync("npx", ["tsx", "scripts/generate-openapi.ts"], { stdio: "inherit" }) +execFileSync(process.execPath, [tsxCli, "scripts/generate-openapi.ts"], { stdio: "inherit" }) const after = readFileSync(target, "utf8") if (before !== after) {