diff --git a/package-lock.json b/package-lock.json index c41b23d..9600bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6078,7 +6078,6 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3691dc5..645ff71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -295,6 +295,41 @@ model AdminAuditLog { @@map("admin_audit_logs") } +enum AuditBlockType { + EVENT_BATCH + TXN_BATCH + ADMIN_BATCH + ANCHOR +} + +model AuditBlock { + id String @id @default(uuid()) + height Int @unique + prevHash String + hash String @unique + blockType AuditBlockType + payloadCount Int @default(0) + payloadHash String + createdAt DateTime @default(now()) + + @@index([blockType]) + @@index([createdAt]) + @@map("audit_blocks") +} + +model AuditPayloadHash { + id String @id @default(uuid()) + tableName String + kind String + payloadHash String + createdAt DateTime @default(now()) + + @@index([tableName]) + @@index([kind]) + @@index([payloadHash]) + @@map("audit_payload_hashes") +} + model Position { id String @id @default(uuid()) userId String diff --git a/src/audit/chain.ts b/src/audit/chain.ts new file mode 100644 index 0000000..1058c31 --- /dev/null +++ b/src/audit/chain.ts @@ -0,0 +1,273 @@ +import crypto from 'node:crypto' +import { Prisma } from '@prisma/client' + +export type AuditBlockType = + 'EVENT_BATCH' | 'TXN_BATCH' | 'ADMIN_BATCH' | 'ANCHOR' + +export interface AuditBlockLike { + height: number + prevHash: string + hash: string + blockType: AuditBlockType + payloadHash: string + payloadCount?: number + createdAt: string | Date +} + +export interface AuditHashInput { + height: number + prevHash: string + payloadHash: string + blockType: AuditBlockType + timestamp: string +} + +export const GENESIS_AUDIT_HASH = + 'sha256:' + + crypto.createHash('sha256').update('neuro-audit-genesis-v1').digest('hex') + +export function canonicalizeAuditPayload(value: unknown): string { + const stable = (input: unknown): unknown => { + if (input === null || input === undefined) return input + + if (typeof input === 'string') return input + if (typeof input === 'boolean') return input + if (typeof input === 'number') + return Number.isFinite(input) ? String(input) : String(input) + if (typeof input === 'bigint') return input.toString() + if (typeof input === 'symbol') return input.toString() + + if (typeof input === 'object') { + if (input instanceof Date) { + return input.toISOString() + } + if (Buffer.isBuffer(input)) { + return input.toString('base64') + } + if (input instanceof Prisma.Decimal || Prisma.Decimal.isDecimal(input)) { + return input.toString() + } + if (Array.isArray(input)) { + return input.map((item) => stable(item)) + } + + const obj = input as Record + return Object.keys(obj) + .sort() + .reduce>((acc, key) => { + acc[key] = stable(obj[key]) + return acc + }, {}) + } + + return String(input) + } + + return JSON.stringify(stable(value)) +} + +export function aggregatePayloadHash(payloads: unknown[]): string { + const normalized = payloads + .map((entry) => canonicalizeAuditPayload(entry)) + .sort() + .join('|') + + return ( + 'sha256:' + crypto.createHash('sha256').update(normalized).digest('hex') + ) +} + +export function computeAuditHash({ + height, + prevHash, + payloadHash, + blockType, + timestamp, +}: AuditHashInput): string { + const material = `${height}|${prevHash}|${payloadHash}|${blockType}|${timestamp}` + return 'sha256:' + crypto.createHash('sha256').update(material).digest('hex') +} + +export function verifyAuditChain(blocks: AuditBlockLike[]): { + valid: boolean + height: number + blocksChecked: number + firstInvalidBlock?: AuditBlockLike & { reason: string } +} { + const ordered = [...blocks].sort((a, b) => a.height - b.height) + + if (ordered.length === 0) { + return { + valid: false, + height: 0, + blocksChecked: 0, + firstInvalidBlock: { + height: 0, + prevHash: '', + hash: '', + blockType: 'ANCHOR', + payloadHash: '', + createdAt: new Date().toISOString(), + reason: 'Genesis missing', + }, + } + } + + let previous: AuditBlockLike | null = null + + for (let index = 0; index < ordered.length; index++) { + const block = ordered[index] + const expectedTimestamp = new Date(block.createdAt).toISOString() + + if (index === 0) { + if (block.height !== 0) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: 'Genesis block must be height 0', + }, + } + } + + if (block.prevHash !== GENESIS_AUDIT_HASH) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: 'Genesis prevHash does not match the documented constant', + }, + } + } + + if (block.hash !== GENESIS_AUDIT_HASH) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: 'Genesis hash does not match the documented constant', + }, + } + } + } else { + if (block.height !== (previous?.height ?? 0) + 1) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: `Height drift: expected ${(previous?.height ?? 0) + 1} but received ${block.height}`, + }, + } + } + + if (block.prevHash !== previous!.hash) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: `Prev hash mismatch: expected ${previous!.hash} but received ${block.prevHash}`, + }, + } + } + + const expectedHash = computeAuditHash({ + height: block.height, + prevHash: block.prevHash, + payloadHash: block.payloadHash, + blockType: block.blockType, + timestamp: expectedTimestamp, + }) + + if (block.hash !== expectedHash) { + return { + valid: false, + height: block.height, + blocksChecked: index + 1, + firstInvalidBlock: { + ...block, + reason: `Hash mismatch: expected ${expectedHash} but received ${block.hash}`, + }, + } + } + } + + previous = block + } + + return { + valid: true, + height: previous?.height ?? 0, + blocksChecked: ordered.length, + } +} + +export function appendAuditBlock(params: { + height: number + prevHash: string + payloadHash: string + blockType: AuditBlockType + createdAt: Date + payloads?: unknown[] +}): { + id?: string + height: number + prevHash: string + hash: string + blockType: AuditBlockType + payloadCount: number + payloadHash: string + createdAt: Date +} { + const timestamp = params.createdAt.toISOString() + const hash = computeAuditHash({ + height: params.height, + prevHash: params.prevHash, + payloadHash: params.payloadHash, + blockType: params.blockType, + timestamp, + }) + + return { + height: params.height, + prevHash: params.prevHash, + hash, + blockType: params.blockType, + payloadCount: params.payloads?.length ?? 0, + payloadHash: params.payloadHash, + createdAt: params.createdAt, + } +} + +export function serializeAuditRow(payload: unknown): string { + return canonicalizeAuditPayload(payload) +} + +export function isGenesisAnchored(blocks: AuditBlockLike[]): boolean { + if (blocks.length === 0) return false + const genesis = [...blocks].sort((a, b) => a.height - b.height)[0] + return genesis.height === 0 && genesis.prevHash === GENESIS_AUDIT_HASH +} + +export function auditPayloadHashFor(value: unknown): string { + return ( + 'sha256:' + + crypto + .createHash('sha256') + .update(canonicalizeAuditPayload(value)) + .digest('hex') + ) +} + +export function canonicalizeAuditPayloadForTest(value: unknown): string { + return canonicalizeAuditPayload(value) +} diff --git a/src/jobs/dataRetention.ts b/src/jobs/dataRetention.ts index fd1be1f..adddc88 100644 --- a/src/jobs/dataRetention.ts +++ b/src/jobs/dataRetention.ts @@ -7,6 +7,11 @@ import { import { config } from '../config/env' import { recordBackgroundJob, recordRetentionDeletes } from '../utils/metrics' import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { + appendAuditBlock, + aggregatePayloadHash, + GENESIS_AUDIT_HASH, +} from '../audit/chain' function cutoffDate(retentionDays: number): Date { const d = new Date() @@ -14,6 +19,42 @@ function cutoffDate(retentionDays: number): Date { return d } +async function anchorBeforeDelete( + modelName: string, + rows: Array> +): Promise { + if (rows.length === 0) return null + + const payloadHash = aggregatePayloadHash(rows) + const latest = await db.auditBlock.findFirst({ + orderBy: { height: 'desc' }, + select: { hash: true, height: true }, + }) + + const block = appendAuditBlock({ + height: (latest?.height ?? 0) + 1, + prevHash: latest?.hash ?? GENESIS_AUDIT_HASH, + payloadHash, + blockType: 'ANCHOR', + createdAt: new Date(), + payloads: rows.map((row) => ({ modelName, row })), + }) + + await db.auditBlock.create({ + data: { + height: block.height, + prevHash: block.prevHash, + hash: block.hash, + blockType: block.blockType, + payloadCount: block.payloadCount, + payloadHash: block.payloadHash, + createdAt: block.createdAt, + }, + }) + + return block.hash +} + /** * Delete expired auth_nonces (expiresAt < now). */ @@ -66,6 +107,20 @@ export async function cleanupProcessedEvents(): Promise { try { const cutoff = cutoffDate(config.retention.processedEventsDays) + const rows = await db.processedEvent.findMany({ + where: { processedAt: { lt: cutoff } }, + select: { + id: true, + contractId: true, + txHash: true, + eventType: true, + ledger: true, + processedAt: true, + }, + }) + if (rows.length > 0) { + await anchorBeforeDelete('processed_event', rows) + } const result = await db.processedEvent.deleteMany({ where: { processedAt: { lt: cutoff } }, }) @@ -160,6 +215,20 @@ export async function cleanupAgentLogs(): Promise { try { const cutoff = cutoffDate(config.retention.agentLogsDays) + const rows = await db.agentLog.findMany({ + where: { createdAt: { lt: cutoff } }, + select: { + id: true, + action: true, + status: true, + createdAt: true, + userId: true, + positionId: true, + }, + }) + if (rows.length > 0) { + await anchorBeforeDelete('agent_log', rows) + } const result = await db.agentLog.deleteMany({ where: { createdAt: { lt: cutoff } }, }) diff --git a/src/routes/admin.ts b/src/routes/admin.ts index ff6026b..b1dae3e 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -16,6 +16,8 @@ import { logger } from '../utils/logger' import { requireAdminAuth, requireAdminScope } from '../middleware/adminAuth' import { getAllProviderHealth, adminSetProviderCircuit } from '../fiat/registry' import db from '../db' +import { alertingService } from '../services/alerting' +import { verifyAuditChain } from '../audit/chain' const router = Router() const prisma = db as any @@ -74,6 +76,117 @@ function auditLog( // ── Auth applied once here — rate limiting is applied in app.ts ─────────── router.use(requireAdminAuth) +router.get( + '/audit/verify', + requireAdminScope('super'), + async (req: Request, res: Response) => { + try { + const blocks = await prisma.auditBlock.findMany({ + orderBy: { height: 'asc' }, + }) + + const proof = verifyAuditChain( + blocks.map((block: any) => ({ + ...block, + createdAt: block.createdAt.toISOString(), + })) + ) + + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Transfer-Encoding', 'chunked') + res.status(200) + res.write( + JSON.stringify({ + valid: proof.valid, + height: proof.height, + blocksChecked: proof.blocksChecked, + firstInvalidBlock: proof.firstInvalidBlock ?? null, + }) + ) + res.end() + + if (!proof.valid) { + await alertingService.emit( + { + title: 'Audit chain drift detected', + description: `Audit verification failed at height ${proof.height}.`, + severity: 'critical', + component: 'audit-chain', + metadata: { + firstInvalidBlock: proof.firstInvalidBlock ?? null, + blocksChecked: proof.blocksChecked, + }, + }, + 'audit:chain-integrity' + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('[Admin] Audit verification failed', { error: message }) + await alertingService.emit( + { + title: 'Audit verification failed', + description: `Could not verify the audit chain: ${message}`, + severity: 'critical', + component: 'audit-chain', + metadata: { error: message }, + }, + 'audit:chain-integrity' + ) + res + .status(500) + .json({ success: false, error: 'Audit verification failed' }) + } + } +) + +router.get( + '/audit/prove', + requireAdminScope('super'), + async (req: Request, res: Response) => { + try { + const from = Number(req.query.from ?? 0) + const to = Number(req.query.to ?? Number.MAX_SAFE_INTEGER) + + const blocks = await prisma.auditBlock.findMany({ + where: { + height: { + gte: Number.isFinite(from) ? from : 0, + lte: Number.isFinite(to) ? to : Number.MAX_SAFE_INTEGER, + }, + }, + orderBy: { height: 'asc' }, + select: { + height: true, + prevHash: true, + hash: true, + blockType: true, + payloadHash: true, + payloadCount: true, + createdAt: true, + }, + }) + + res.status(200).json({ + success: true, + data: { + from: Number.isFinite(from) ? from : 0, + to: Number.isFinite(to) ? to : Number.MAX_SAFE_INTEGER, + blocks: blocks.map((block: any) => ({ + ...block, + createdAt: block.createdAt.toISOString(), + })), + }, + timestamp: new Date().toISOString(), + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('[Admin] Audit proof failed', { error: message }) + res.status(500).json({ success: false, error: 'Audit proof failed' }) + } + } +) + /** * GET /api/admin/stellar/metrics * Returns current event processing metrics. diff --git a/tests/unit/audit/audit-chain.test.ts b/tests/unit/audit/audit-chain.test.ts new file mode 100644 index 0000000..ddf5f87 --- /dev/null +++ b/tests/unit/audit/audit-chain.test.ts @@ -0,0 +1,139 @@ +import { Prisma } from '@prisma/client' +import { + GENESIS_AUDIT_HASH, + appendAuditBlock, + canonicalizeAuditPayload, + computeAuditHash, + type AuditBlockType, + verifyAuditChain, +} from '../../../src/audit/chain' + +describe('audit chain', () => { + it('serializes decimal-like values without float coercion', () => { + const value = { + amount: new Prisma.Decimal('123.4500'), + fee: new Prisma.Decimal('0.0100'), + nested: { + total: new Prisma.Decimal('9.0000'), + }, + arr: [new Prisma.Decimal('1.2300'), 'ok'], + } + + const serialized = canonicalizeAuditPayload(value) + + expect(serialized).toContain('"amount":"123.45"') + expect(serialized).toContain('"fee":"0.01"') + expect(serialized).toContain('"total":"9"') + expect(serialized).toContain('"arr":["1.23","ok"]') + expect(serialized).not.toContain('"amount":123.45') + expect(serialized).not.toContain('"fee":0.01') + }) + + it('builds a chained hash from the genesis anchor', () => { + const hashA = computeAuditHash({ + height: 1, + prevHash: GENESIS_AUDIT_HASH, + payloadHash: 'abc123', + blockType: 'EVENT_BATCH', + timestamp: '2026-01-01T00:00:00.000Z', + }) + + const hashB = computeAuditHash({ + height: 2, + prevHash: hashA, + payloadHash: 'def456', + blockType: 'TXN_BATCH', + timestamp: '2026-01-01T00:00:01.000Z', + }) + + expect(hashA).not.toBe(GENESIS_AUDIT_HASH) + expect(hashB).not.toBe(hashA) + expect(hashA.startsWith('sha256:')).toBe(true) + expect(hashB.startsWith('sha256:')).toBe(true) + }) + + it('accepts a valid chain and rejects tampering', () => { + const blocks = [ + { + height: 0, + prevHash: GENESIS_AUDIT_HASH, + hash: GENESIS_AUDIT_HASH, + blockType: 'ANCHOR' as AuditBlockType, + payloadHash: 'genesis', + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + height: 1, + prevHash: GENESIS_AUDIT_HASH, + hash: computeAuditHash({ + height: 1, + prevHash: GENESIS_AUDIT_HASH, + payloadHash: 'payload-one', + blockType: 'EVENT_BATCH', + timestamp: '2026-01-01T00:00:01.000Z', + }), + blockType: 'EVENT_BATCH' as AuditBlockType, + payloadHash: 'payload-one', + createdAt: '2026-01-01T00:00:01.000Z', + }, + ] + + expect(verifyAuditChain(blocks)).toEqual({ + valid: true, + height: 1, + blocksChecked: 2, + }) + + const tampered = [...blocks] + tampered[1] = { + ...tampered[1], + blockType: 'EVENT_BATCH' as AuditBlockType, + payloadHash: 'payload-two', + } + tampered[1].hash = 'sha256:wrong-hash' + + expect(verifyAuditChain(tampered)).toMatchObject({ + valid: false, + height: 1, + blocksChecked: 2, + firstInvalidBlock: expect.objectContaining({ height: 1 }), + }) + }) + + it('appends a new block using the latest head and verifies the chain rules', async () => { + const previous = { + id: 'genesis', + height: 0, + prevHash: GENESIS_AUDIT_HASH, + hash: GENESIS_AUDIT_HASH, + blockType: 'ANCHOR' as AuditBlockType, + payloadHash: 'genesis', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + } + + const head = await appendAuditBlock({ + prevHash: previous.hash, + payloadHash: 'payload-one', + blockType: 'EVENT_BATCH', + createdAt: new Date('2026-01-01T00:00:01.000Z'), + height: previous.height + 1, + payloads: [{ kind: 'processed_event', correlationId: 'abc' }], + }) + + expect(head.height).toBe(1) + expect(head.prevHash).toBe(GENESIS_AUDIT_HASH) + expect(head.hash.startsWith('sha256:')).toBe(true) + const proof = verifyAuditChain([ + previous, + { + height: head.height, + prevHash: head.prevHash, + hash: head.hash, + blockType: head.blockType, + payloadHash: head.payloadHash, + createdAt: head.createdAt.toISOString(), + }, + ]) + expect(proof.valid).toBe(true) + }) +})