diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e2fab06..04915fe 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -125,6 +125,12 @@ export const stellarEventStore = pgPool : undefined; if (pgPool) app.decorate("pg", pgPool); +import { AuditAnchorWorker } from "./lib/workers/auditAnchorWorker.js"; +export const auditAnchorWorker = pgPool ? new AuditAnchorWorker(pgPool) : undefined; +if (auditAnchorWorker) { + auditAnchorWorker.start(); +} + // Echo the request ID back to the client so a failed call can be traced // in the logs — see docs/request-tracing.md. app.addHook("onRequest", async (req, reply) => { @@ -412,6 +418,8 @@ app.register(reputationRoutes, { prefix: "/api/v1" }); app.register(providerRoutes, { prefix: "/api/v1" }); app.register(adminRoutes, { prefix: "/api/v1" }); app.register(sessionRoutes, { prefix: "/api/v1" }); +import { auditVaultRoutes } from "./routes/audit-vault.js"; +app.register(auditVaultRoutes, { prefix: "/api/v1" }); app.register(sessionRotationRoutes, { prefix: "/api/v1" }); app.register(ratesRoutes, { prefix: "/api/v1" }); app.register(statusRoutes, { prefix: "/api/v1" }); diff --git a/apps/api/src/db/migrations/023_add_merkle_audit_vault.sql b/apps/api/src/db/migrations/023_add_merkle_audit_vault.sql new file mode 100644 index 0000000..64bbcaa --- /dev/null +++ b/apps/api/src/db/migrations/023_add_merkle_audit_vault.sql @@ -0,0 +1,17 @@ +CREATE TABLE audit_hash_chain ( + sequence_id BIGSERIAL PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + payload_hash VARCHAR(64) NOT NULL, + prev_hash VARCHAR(64) NOT NULL, + curr_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE audit_roots ( + block_index BIGINT PRIMARY KEY, + start_sequence BIGINT NOT NULL, + end_sequence BIGINT NOT NULL, + merkle_root VARCHAR(64) NOT NULL, + tx_hash VARCHAR(128) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/api/src/lib/audit/__tests__/hash-chain.test.ts b/apps/api/src/lib/audit/__tests__/hash-chain.test.ts new file mode 100644 index 0000000..5e88cd9 --- /dev/null +++ b/apps/api/src/lib/audit/__tests__/hash-chain.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { computeMerkleRoot, computeMerkleProof, verifyMerkleProof } from "../merkle-aggregator.js"; + +describe("Merkle Aggregator", () => { + it("should compute valid root for single hash", () => { + const hashes = ["a"]; + const root = computeMerkleRoot(hashes); + expect(root).toBe("a"); + }); + + it("should compute consistent root for multiple hashes", () => { + const hashes = ["a", "b", "c", "d"]; + const root = computeMerkleRoot(hashes); + expect(root).toBeTruthy(); + expect(typeof root).toBe("string"); + }); + + it("should generate and verify merkle proof", () => { + const hashes = Array.from({ length: 16 }, (_, i) => `hash${i}`); + const root = computeMerkleRoot(hashes); + + const leafIndex = 5; + const leafHash = hashes[leafIndex]; + const proof = computeMerkleProof(hashes, leafIndex); + + expect(proof.length).toBeGreaterThan(0); + + const isValid = verifyMerkleProof(leafHash, root, proof, leafIndex); + expect(isValid).toBe(true); + }); + + it("should fail verification with wrong leaf", () => { + const hashes = Array.from({ length: 8 }, (_, i) => `hash${i}`); + const root = computeMerkleRoot(hashes); + + const leafIndex = 3; + const proof = computeMerkleProof(hashes, leafIndex); + + const isValid = verifyMerkleProof("wrongHash", root, proof, leafIndex); + expect(isValid).toBe(false); + }); +}); diff --git a/apps/api/src/lib/audit/hash-chain-engine.ts b/apps/api/src/lib/audit/hash-chain-engine.ts new file mode 100644 index 0000000..1aacb11 --- /dev/null +++ b/apps/api/src/lib/audit/hash-chain-engine.ts @@ -0,0 +1,51 @@ +import { Pool } from "pg"; +import { createHash } from "node:crypto"; +import { AuditLogEvent } from "@velo/shared"; + +export class HashChainEngine { + constructor(private pool: Pool) {} + + async appendEvent(eventType: string, payload: Record): Promise { + const payloadStr = JSON.stringify(payload); + const payloadHash = createHash("sha256").update(payloadStr).digest("hex"); + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + + // Get the previous hash. If table is empty, use 64 zeros. + // Using FOR UPDATE to serialize inserts and prevent gaps/race conditions. + const { rows } = await client.query( + "SELECT curr_hash FROM audit_hash_chain ORDER BY sequence_id DESC LIMIT 1 FOR UPDATE" + ); + const prevHash = rows.length > 0 ? rows[0].curr_hash : "0".repeat(64); + + const currHash = createHash("sha256") + .update(payloadHash + prevHash) + .digest("hex"); + + const insertRes = await client.query( + `INSERT INTO audit_hash_chain (event_type, payload_hash, prev_hash, curr_hash) + VALUES ($1, $2, $3, $4) + RETURNING sequence_id, created_at`, + [eventType, payloadHash, prevHash, currHash] + ); + + await client.query("COMMIT"); + + return { + sequenceId: insertRes.rows[0].sequence_id, + eventType, + payloadHash, + prevHash, + currHash, + createdAt: insertRes.rows[0].created_at.toISOString() + }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } +} diff --git a/apps/api/src/lib/audit/merkle-aggregator.ts b/apps/api/src/lib/audit/merkle-aggregator.ts new file mode 100644 index 0000000..27d7ebe --- /dev/null +++ b/apps/api/src/lib/audit/merkle-aggregator.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; + +export function computeMerkleRoot(hashes: string[]): string { + if (hashes.length === 0) return "0".repeat(64); + if (hashes.length === 1) return hashes[0]; + + const nextLevel: string[] = []; + for (let i = 0; i < hashes.length; i += 2) { + const left = hashes[i]; + const right = i + 1 < hashes.length ? hashes[i + 1] : left; + const combined = createHash("sha256").update(left + right).digest("hex"); + nextLevel.push(combined); + } + return computeMerkleRoot(nextLevel); +} + +export function computeMerkleProof(hashes: string[], index: number): string[] { + if (hashes.length <= 1) return []; + + const proof: string[] = []; + let currentIndex = index; + let currentLevel = hashes; + + while (currentLevel.length > 1) { + const nextLevel: string[] = []; + for (let i = 0; i < currentLevel.length; i += 2) { + const left = currentLevel[i]; + const right = i + 1 < currentLevel.length ? currentLevel[i + 1] : left; + + if (i === currentIndex || i + 1 === currentIndex) { + const sibling = i === currentIndex ? right : left; + proof.push(sibling); + } + + const combined = createHash("sha256").update(left + right).digest("hex"); + nextLevel.push(combined); + } + currentIndex = Math.floor(currentIndex / 2); + currentLevel = nextLevel; + } + + return proof; +} + +export function verifyMerkleProof(leafHash: string, root: string, proof: string[], index: number): boolean { + let currentHash = leafHash; + let currentIndex = index; + + for (const sibling of proof) { + if (currentIndex % 2 === 0) { + currentHash = createHash("sha256").update(currentHash + sibling).digest("hex"); + } else { + currentHash = createHash("sha256").update(sibling + currentHash).digest("hex"); + } + currentIndex = Math.floor(currentIndex / 2); + } + + return currentHash === root; +} diff --git a/apps/api/src/lib/audit/proof-generator.ts b/apps/api/src/lib/audit/proof-generator.ts new file mode 100644 index 0000000..9ff8797 --- /dev/null +++ b/apps/api/src/lib/audit/proof-generator.ts @@ -0,0 +1,62 @@ +import { Pool } from "pg"; +import { AuditInclusionProof } from "@velo/shared"; +import { computeMerkleRoot, computeMerkleProof } from "./merkle-aggregator.js"; + +export class ProofGenerator { + constructor(private pool: Pool) {} + + async generateProof(sequenceIdStr: string): Promise { + const sequenceId = BigInt(sequenceIdStr); + // Each block is 1000 events + const blockIndex = (sequenceId - 1n) / 1000n; + const startSequence = blockIndex * 1000n + 1n; + const endSequence = startSequence + 999n; + + const client = await this.pool.connect(); + try { + // Check if block is anchored + const rootRes = await client.query( + "SELECT merkle_root, tx_hash FROM audit_roots WHERE block_index = $1", + [blockIndex.toString()] + ); + + if (rootRes.rows.length === 0) { + throw new Error("Audit root not yet anchored for this event"); + } + + const { merkle_root: root, tx_hash: txHash } = rootRes.rows[0]; + + // Fetch the entire block + const blockRes = await client.query( + "SELECT sequence_id, curr_hash FROM audit_hash_chain WHERE sequence_id >= $1 AND sequence_id <= $2 ORDER BY sequence_id ASC", + [startSequence.toString(), endSequence.toString()] + ); + + if (blockRes.rows.length === 0) { + throw new Error("Events not found in block"); + } + + const hashes = blockRes.rows.map(r => r.curr_hash); + const computedRoot = computeMerkleRoot(hashes); + + // Find index of requested event + const leafIndex = blockRes.rows.findIndex(r => r.sequence_id === sequenceIdStr); + if (leafIndex === -1) { + throw new Error("Event not found in fetched block"); + } + + const proof = computeMerkleProof(hashes, leafIndex); + + return { + eventId: sequenceIdStr, + merkleRoot: root, + proof, + leafIndex, + stellarTxHash: txHash, + verified: computedRoot === root + }; + } finally { + client.release(); + } + } +} diff --git a/apps/api/src/lib/stellar.ts b/apps/api/src/lib/stellar.ts index e974ac3..d2fb461 100644 --- a/apps/api/src/lib/stellar.ts +++ b/apps/api/src/lib/stellar.ts @@ -1807,3 +1807,53 @@ export async function getRotationProposal( ); return proposal ?? null; } + +export async function anchorAuditRoot(contractId: string, sequence: number, rootHex: string): Promise { + const signer = loadSignerKeypair(); + const account = await server.getAccount(signer.publicKey()); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: contractId, + function: "anchor_audit_root", + args: [ + nativeToScVal(BigInt(sequence), { type: "u64" }), + nativeToScVal(Buffer.from(rootHex, "hex"), { type: "bytes" }) + ], + }) + ) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (Api.isSimulationError(sim)) { + throw new Error(`simulation failed: ${sim.error}`); + } + + const prepared = assembleTransaction(tx, sim).build(); + prepared.sign(signer); + + const sendResult = await server.sendTransaction(prepared); + if (sendResult.status === "ERROR") { + throw new Error(`submission failed: ${JSON.stringify(sendResult.errorResult)}`); + } + + let getResult = await server.getTransaction(sendResult.hash); + const start = Date.now(); + while (getResult.status === Api.GetTransactionStatus.NOT_FOUND) { + if (Date.now() - start > 30_000) { + throw new Error(`timed out waiting for tx ${sendResult.hash} to confirm`); + } + await new Promise((r) => setTimeout(r, 1500)); + getResult = await server.getTransaction(sendResult.hash); + } + + if (getResult.status !== Api.GetTransactionStatus.SUCCESS) { + throw new Error(`tx ${sendResult.hash} failed with status ${getResult.status}`); + } + + return sendResult.hash; +} diff --git a/apps/api/src/lib/workers/auditAnchorWorker.ts b/apps/api/src/lib/workers/auditAnchorWorker.ts new file mode 100644 index 0000000..f49eda6 --- /dev/null +++ b/apps/api/src/lib/workers/auditAnchorWorker.ts @@ -0,0 +1,95 @@ +import { Pool } from "pg"; +import { anchorAuditRoot } from "../stellar.js"; +import { computeMerkleRoot } from "../audit/merkle-aggregator.js"; +import { CONTRACTS } from "@velo/shared"; + +export class AuditAnchorWorker { + private isRunning = false; + private timer?: NodeJS.Timeout; + + constructor( + private pool: Pool, + private pollIntervalMs = 60000, + private network: "testnet" | "mainnet" = "testnet" + ) {} + + start() { + if (this.isRunning) return; + this.isRunning = true; + this.tick(); + } + + stop() { + this.isRunning = false; + if (this.timer) { + clearTimeout(this.timer); + } + } + + private async tick() { + if (!this.isRunning) return; + + try { + await this.processPendingBlocks(); + } catch (e) { + console.error("AuditAnchorWorker error:", e); + } + + if (this.isRunning) { + this.timer = setTimeout(() => this.tick(), this.pollIntervalMs); + } + } + + private async processPendingBlocks() { + const client = await this.pool.connect(); + try { + const rootRes = await client.query("SELECT COALESCE(MAX(block_index), -1) as max_block FROM audit_roots"); + const maxBlock = BigInt(rootRes.rows[0].max_block); + + const maxSeqRes = await client.query("SELECT COALESCE(MAX(sequence_id), 0) as max_seq FROM audit_hash_chain"); + const maxSeq = BigInt(maxSeqRes.rows[0].max_seq); + + const fullyFormedBlocks = maxSeq / 1000n; + + for (let blockIndex = maxBlock + 1n; blockIndex < fullyFormedBlocks; blockIndex++) { + const startSequence = blockIndex * 1000n + 1n; + const endSequence = startSequence + 999n; + + await client.query("BEGIN"); + + const blockRes = await client.query( + "SELECT curr_hash FROM audit_hash_chain WHERE sequence_id >= $1 AND sequence_id <= $2 ORDER BY sequence_id ASC", + [startSequence.toString(), endSequence.toString()] + ); + + if (blockRes.rows.length === 1000) { + const hashes = blockRes.rows.map(r => r.curr_hash); + const root = computeMerkleRoot(hashes); + + const contractId = CONTRACTS[this.network].zkVerifierRegistry; + if (contractId === "SET_ME_AFTER_FIRST_DEPLOY" || contractId === "") { + throw new Error("zkVerifierRegistry contract not configured"); + } + + const txHash = await anchorAuditRoot(contractId, Number(endSequence), root); + + await client.query( + `INSERT INTO audit_roots (block_index, start_sequence, end_sequence, merkle_root, tx_hash) + VALUES ($1, $2, $3, $4, $5)`, + [blockIndex.toString(), startSequence.toString(), endSequence.toString(), root, txHash] + ); + + await client.query("COMMIT"); + console.log(`Anchored audit block ${blockIndex} with root ${root}`); + } else { + await client.query("ROLLBACK"); + } + } + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } +} diff --git a/apps/api/src/routes/__tests__/audit-vault.test.ts b/apps/api/src/routes/__tests__/audit-vault.test.ts new file mode 100644 index 0000000..4ff02eb --- /dev/null +++ b/apps/api/src/routes/__tests__/audit-vault.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from "vitest"; +import fastify from "fastify"; +import { auditVaultRoutes } from "../audit-vault.js"; +import { ProofGenerator } from "../../lib/audit/proof-generator.js"; + +vi.mock("../../lib/audit/proof-generator.js"); + +describe("Audit Vault Routes", () => { + it("should return 404 if proof is not ready", async () => { + vi.mocked(ProofGenerator.prototype.generateProof).mockRejectedValueOnce( + new Error("Audit root not yet anchored for this event") + ); + + const app = fastify(); + app.decorate("pg", {} as any); // Mock pg pool + app.register(auditVaultRoutes); + + const response = await app.inject({ + method: "GET", + url: "/audit/proof/123", + }); + + expect(response.statusCode).toBe(404); + expect(JSON.parse(response.payload)).toEqual({ + error: "Audit root not yet anchored for this event" + }); + }); + + it("should return proof successfully", async () => { + vi.mocked(ProofGenerator.prototype.generateProof).mockResolvedValueOnce({ + eventId: "123", + merkleRoot: "mockRoot", + proof: ["hash1", "hash2"], + leafIndex: 5, + stellarTxHash: "mockTxHash", + verified: true, + }); + + const app = fastify(); + app.decorate("pg", {} as any); // Mock pg pool + app.register(auditVaultRoutes); + + const response = await app.inject({ + method: "GET", + url: "/audit/proof/123", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual({ + eventId: "123", + merkleRoot: "mockRoot", + proof: ["hash1", "hash2"], + leafIndex: 5, + stellarTxHash: "mockTxHash", + verified: true, + }); + }); +}); diff --git a/apps/api/src/routes/audit-vault.ts b/apps/api/src/routes/audit-vault.ts new file mode 100644 index 0000000..cf5df54 --- /dev/null +++ b/apps/api/src/routes/audit-vault.ts @@ -0,0 +1,33 @@ +import { FastifyInstance, FastifyPluginAsync } from "fastify"; +import { ProofGenerator } from "../lib/audit/proof-generator.js"; + +export const auditVaultRoutes: FastifyPluginAsync = async ( + fastify: FastifyInstance +) => { + // Only available if DB is configured + if (!fastify.hasDecorator("pg")) { + return; + } + + const pool = (fastify as any).pg; + const proofGenerator = new ProofGenerator(pool); + + fastify.get<{ + Params: { eventId: string } + }>("/audit/proof/:eventId", { + config: { + rateLimit: { max: 30, timeWindow: "1 minute" } + } + }, async (request, reply) => { + try { + const proof = await proofGenerator.generateProof(request.params.eventId); + return proof; + } catch (err: any) { + request.log.error(err, "Failed to generate audit proof"); + if (err.message.includes("Audit root not yet anchored") || err.message.includes("not found")) { + return reply.status(404).send({ error: err.message }); + } + return reply.status(500).send({ error: "Internal server error" }); + } + }); +}; diff --git a/contracts/zk-credential/src/lib.rs b/contracts/zk-credential/src/lib.rs index f07f2c2..50ee7e4 100644 --- a/contracts/zk-credential/src/lib.rs +++ b/contracts/zk-credential/src/lib.rs @@ -38,6 +38,7 @@ pub enum DataKey { TreeConfig, SubTree(u32), Node(u32, u32), + AuditRoot(u64), } #[contracterror] @@ -383,6 +384,28 @@ impl ZkCredentialContract { true } + + /// Admin method to anchor a Merkle root for the audit log vault + pub fn anchor_audit_root(env: Env, sequence: u64, root: BytesN<32>) -> Result<(), Error> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(Error::NotInitialized); + } + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::AuditRoot(sequence), &root); + env.storage().persistent().extend_ttl( + &DataKey::AuditRoot(sequence), + TTL_EXTEND, + TTL_EXTEND, + ); + + env.events() + .publish((soroban_sdk::symbol_short!("audit"), sequence), root); + Ok(()) + } } #[cfg(test)] diff --git a/mobile/frontend/src/components/AuditProofViewer.tsx b/mobile/frontend/src/components/AuditProofViewer.tsx new file mode 100644 index 0000000..40d2cd6 --- /dev/null +++ b/mobile/frontend/src/components/AuditProofViewer.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import { AuditInclusionProof } from '@velo/shared'; +import { useTranslation } from "react-i18next"; + +export const AuditProofViewer: React.FC<{ eventId: string }> = ({ eventId }) => { + const { t } = useTranslation(); + const [proof, setProof] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const loadProof = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/v1/audit/proof/${eventId}`); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || t('common.error')); + } + const data: AuditInclusionProof = await res.json(); + setProof(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+

{t('auditVault.proofTitle')}

+ + + {error &&
{error}
} + + {proof && ( +
+
{t('auditVault.status')} {proof.verified ? t('auditVault.verified') : t('auditVault.failed')}
+
{t('auditVault.eventId')} {proof.eventId}
+
{t('auditVault.merkleRoot')} {proof.merkleRoot}
+
{t('auditVault.stellarTxHash')} {proof.stellarTxHash}
+ +

{t('auditVault.merklePath')}

+
    + {proof.proof.map((p: string, idx: number) => ( +
  • {p}
  • + ))} +
+
+ )} +
+ ); +}; diff --git a/mobile/frontend/src/i18n/locales/en.json b/mobile/frontend/src/i18n/locales/en.json index bc67fb7..28c06ed 100644 --- a/mobile/frontend/src/i18n/locales/en.json +++ b/mobile/frontend/src/i18n/locales/en.json @@ -498,6 +498,23 @@ "uploadEvidenceBtn": "Upload Evidence" } }, + "auditVault": { + "title": "Compliance & Audit Portal", + "vaultActive": "Tamper-Proof Vault Active", + "instruction": "Enter an Event Sequence ID to retrieve its cryptographically verifiable Merkle inclusion proof anchored on the Stellar network.", + "placeholder": "Event Sequence ID (e.g. 1005)", + "searchLog": "Search Log", + "proofTitle": "Audit Inclusion Proof", + "loading": "Loading...", + "verifyBtn": "Verify Event on Stellar", + "status": "Status:", + "verified": "✅ Verified", + "failed": "❌ Failed", + "eventId": "Event ID:", + "merkleRoot": "Merkle Root:", + "stellarTxHash": "Stellar Tx Hash:", + "merklePath": "Merkle Proof Path" + }, "e2ee": { "tradeChat": "Trade Chat", "protocolBadge": "X3DH + Double Ratchet", diff --git a/mobile/frontend/src/i18n/locales/es.json b/mobile/frontend/src/i18n/locales/es.json index 6b16725..49ada69 100644 --- a/mobile/frontend/src/i18n/locales/es.json +++ b/mobile/frontend/src/i18n/locales/es.json @@ -498,6 +498,23 @@ "uploadEvidenceBtn": "Subir evidencia" } }, + "auditVault": { + "title": "Portal de Cumplimiento y Auditoría", + "vaultActive": "Bóveda a prueba de manipulaciones activa", + "instruction": "Ingresa un ID de Secuencia de Evento para recuperar su prueba de inclusión Merkle criptográficamente verificable anclada en la red Stellar.", + "placeholder": "ID de Secuencia de Evento (ej. 1005)", + "searchLog": "Buscar registro", + "proofTitle": "Prueba de inclusión de auditoría", + "loading": "Cargando...", + "verifyBtn": "Verificar evento en Stellar", + "status": "Estado:", + "verified": "✅ Verificado", + "failed": "❌ Falló", + "eventId": "ID de evento:", + "merkleRoot": "Raíz Merkle:", + "stellarTxHash": "Hash Tx Stellar:", + "merklePath": "Ruta de prueba Merkle" + }, "e2ee": { "tradeChat": "Chat de comercio", "protocolBadge": "X3DH + Double Ratchet", diff --git a/mobile/frontend/src/pages/AuditorPortal.tsx b/mobile/frontend/src/pages/AuditorPortal.tsx new file mode 100644 index 0000000..26debf0 --- /dev/null +++ b/mobile/frontend/src/pages/AuditorPortal.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { AuditProofViewer } from '../components/AuditProofViewer'; +import { useTranslation } from "react-i18next"; + +export default function AuditorPortal() { + const { t } = useTranslation(); + const [searchId, setSearchId] = useState(''); + const [activeEventId, setActiveEventId] = useState(null); + + return ( +
+
+

{t('auditVault.title')}

+
{t('auditVault.vaultActive')}
+
+ +
+

+ {t('auditVault.instruction')} +

+ +
+ setSearchId(e.target.value)} + className="flex-1 p-3 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 outline-none transition" + /> + +
+ + {activeEventId && ( +
+ +
+ )} +
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index fda64a1..a7627d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -228,6 +228,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -576,6 +577,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -599,6 +601,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1799,6 +1802,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -2437,8 +2441,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2600,6 +2603,7 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2611,6 +2615,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2917,7 +2922,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2928,7 +2932,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3158,6 +3161,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -3499,8 +3503,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -4355,6 +4358,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -4660,7 +4664,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4976,6 +4979,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -5194,7 +5198,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5359,6 +5362,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5371,6 +5375,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5411,8 +5416,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -6371,6 +6375,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a35f9a4..277576f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -91,6 +91,7 @@ export const CIRCUIT_BREAKER = { export * from "./types/batch-auctions.js"; export * from "./types/enterprise.js"; export * from "./types/e2ee.js"; +export * from "./types/audit.js"; /** * Timing + phase constants for the commit-reveal batch auction engine (#403). diff --git a/packages/shared/src/types/audit.ts b/packages/shared/src/types/audit.ts new file mode 100644 index 0000000..2b62c55 --- /dev/null +++ b/packages/shared/src/types/audit.ts @@ -0,0 +1,17 @@ +export interface AuditLogEvent { + sequenceId: string; + eventType: string; + payloadHash: string; + prevHash: string; + currHash: string; + createdAt: string; +} + +export interface AuditInclusionProof { + eventId: string; + merkleRoot: string; + proof: string[]; + leafIndex: number; + stellarTxHash?: string; + verified: boolean; +} diff --git a/scripts/verify-audit-chain.ts b/scripts/verify-audit-chain.ts new file mode 100644 index 0000000..28bc3ab --- /dev/null +++ b/scripts/verify-audit-chain.ts @@ -0,0 +1,52 @@ +import { Pool } from "pg"; +import { createHash } from "node:crypto"; +import "dotenv/config"; + +async function verifyChain() { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const client = await pool.connect(); + + try { + const { rows } = await client.query( + "SELECT sequence_id, payload_hash, prev_hash, curr_hash FROM audit_hash_chain ORDER BY sequence_id ASC" + ); + + if (rows.length === 0) { + console.log("Audit chain is empty. Nothing to verify."); + return; + } + + let expectedPrevHash = "0".repeat(64); + let errorFound = false; + + for (const row of rows) { + const { sequence_id, payload_hash, prev_hash, curr_hash } = row; + + if (prev_hash !== expectedPrevHash) { + console.error(`Tamper detected at sequence_id ${sequence_id}: prev_hash does not match expected.`); + errorFound = true; + break; + } + + const computedHash = createHash("sha256").update(payload_hash + prev_hash).digest("hex"); + if (computedHash !== curr_hash) { + console.error(`Tamper detected at sequence_id ${sequence_id}: curr_hash does not match computed hash.`); + errorFound = true; + break; + } + + expectedPrevHash = curr_hash; + } + + if (!errorFound) { + console.log("Audit chain is intact."); + } else { + process.exit(1); + } + } finally { + client.release(); + await pool.end(); + } +} + +verifyChain().catch(console.error); diff --git a/tests/e2e/audit_tamper_detection.test.ts b/tests/e2e/audit_tamper_detection.test.ts new file mode 100644 index 0000000..51170d9 --- /dev/null +++ b/tests/e2e/audit_tamper_detection.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { Pool } from "pg"; +import { HashChainEngine } from "../../apps/api/src/lib/audit/hash-chain-engine.js"; + +const execAsync = promisify(exec); + +describe("Audit Tamper Detection E2E", () => { + let pool: Pool; + let engine: HashChainEngine; + + beforeAll(async () => { + pool = new Pool({ connectionString: process.env.DATABASE_URL }); + engine = new HashChainEngine(pool); + + // Ensure table is clean for test + await pool.query("TRUNCATE audit_hash_chain RESTART IDENTITY CASCADE"); + }); + + afterAll(async () => { + await pool.query("TRUNCATE audit_hash_chain RESTART IDENTITY CASCADE"); + await pool.end(); + }); + + it("should generate sequential hash chains", async () => { + const ev1 = await engine.appendEvent("TEST_EVENT", { amount: 100 }); + const ev2 = await engine.appendEvent("TEST_EVENT", { amount: 200 }); + + expect(ev2.prevHash).toBe(ev1.currHash); + }); + + it("CLI tool should verify intact chain successfully", async () => { + const { stdout } = await execAsync("npx tsx scripts/verify-audit-chain.ts", { + env: { ...process.env } + }); + expect(stdout).toContain("Audit chain is intact."); + }); + + it("CLI tool should detect tampering", async () => { + // Mutate the payload hash to break the chain + await pool.query( + "UPDATE audit_hash_chain SET payload_hash = 'TAMPERED_HASH' WHERE sequence_id = 1" + ); + + let errorFound = false; + try { + await execAsync("npx tsx scripts/verify-audit-chain.ts", { + env: { ...process.env } + }); + } catch (err: any) { + errorFound = true; + expect(err.stderr).toContain("Tamper detected at sequence_id 1"); + } + + expect(errorFound).toBe(true); + }); +});