import { encodeBase64 } from "jsr:@std/encoding/base64"; import { encodeHex } from "jsr:@std/encoding/hex"; import { sqlWrapper } from "./db.ts"; import { buildMerkleTree, leafHash } from "./audit_merkle.ts"; import { fetchSpiffeIdentity } from "./spire_ffi.ts"; import { valkey } from "./valkey.ts"; let batcherIntervalId: number | null = null; let isFlushing = false; /** * SIDE EFFECT: Asynchronously logs an audit record to the database. * Does not block the main execution thread. Errors are logged but swallowed * to prevent failing the core request due to a logging issue. */ export let auditLog = function auditLog( userId: string | null, action: string, resource: string | null, details: Record | null, ipAddress: string, ): void { // Fire and forget (async () => { try { const entryObj = { userId, action, resource, details, ipAddress, timestamp: Date.now(), // Rough tie breaker if needed, but not strictly RFC6962 entry structure }; const entryStr = JSON.stringify(entryObj); const entryBytes = new TextEncoder().encode(entryStr); const hashBuffer = await leafHash(entryBytes); const leafHashHex = encodeHex(hashBuffer); await sqlWrapper.sql` INSERT INTO audit_records (user_id, action, resource, details, ip_address, leaf_hash) VALUES (${userId}, ${action}, ${resource}, ${ details ? JSON.stringify(details) : null }, ${ipAddress}, ${leafHashHex}) `; } catch (error: any) { console.error("[Audit Logger] Failed to insert audit record:", error); } })(); }; export async function flush(): Promise { if (isFlushing) return; isFlushing = true; try { // 1. Get the latest STH tree_size const sthResult = await sqlWrapper.sql` SELECT tree_size FROM audit_sths ORDER BY tree_size DESC LIMIT 1 `; const lastTreeSize = sthResult.length > 0 ? parseInt(sthResult[0].tree_size, 10) : 0; // 2. Fetch new leaf hashes // We order by created_at and id to append to the tree deterministically. const allRecordsResult = await sqlWrapper.sql` SELECT leaf_hash FROM audit_records ORDER BY created_at ASC, id ASC `; const currentTreeSize = allRecordsResult.length; if (currentTreeSize > lastTreeSize) { // 3. Compute Merkle Tree Root const leafHashes = allRecordsResult.map((row: any) => { // Convert hex string back to Uint8Array const hex = row.leaf_hash; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); } return bytes; }); const rootHashBytes = await buildMerkleTree(leafHashes); const rootHashHex = encodeHex(rootHashBytes); // 4. Sign the STH let svidData; try { svidData = await fetchSpiffeIdentity(); } catch (e) { // Fallback for hermetic tests when mocked FFI might throw svidData = { x509_svid_key: new Uint8Array() }; } let signatureBase64 = ""; if (svidData.x509_svid_key && svidData.x509_svid_key.length > 0) { try { // Convert array buffer to standard ArrayBuffer if needed const keyBuffer = svidData.x509_svid_key.buffer as ArrayBuffer; const privateKey = await crypto.subtle.importKey( "pkcs8", keyBuffer, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"], ).catch(() => crypto.subtle.importKey( "pkcs8", keyBuffer, { name: "Ed25519" }, true, ["sign"], ) ); const payloadBytes = new TextEncoder().encode( JSON.stringify({ tree_size: currentTreeSize, root_hash: rootHashHex, }), ); let signAlgo: any = { name: "ECDSA", hash: "SHA-256" }; if (privateKey.algorithm.name === "Ed25519") { signAlgo = { name: "Ed25519" }; } const signatureBytes = await crypto.subtle.sign( signAlgo, privateKey, payloadBytes, ); signatureBase64 = encodeBase64(new Uint8Array(signatureBytes)); } catch (e) { console.warn( "[Audit Batcher] Failed to import key or sign, using empty signature.", e, ); } } // 5. Insert into audit_sths await sqlWrapper.sql` INSERT INTO audit_sths (tree_size, root_hash, signature) VALUES (${currentTreeSize}, ${rootHashHex}, ${signatureBase64}) `; // 6. Broadcast via Valkey const payload = { tree_size: currentTreeSize, root_hash: rootHashHex, signature: signatureBase64, created_at: new Date().toISOString(), }; const payloadStr = JSON.stringify(payload); try { if (typeof valkey.set === "function") { await valkey.set("auth:audit:latest_sth", payloadStr); } if (typeof valkey.publish === "function") { await valkey.publish("auth:audit:sth", payloadStr); } } catch (e) { console.warn("[Audit Batcher] Valkey broadcast failed", e); } } } catch (error) { console.error("[Audit Batcher] Error during flush:", error); } finally { isFlushing = false; } } export function startMicroBatcher(): void { if (batcherIntervalId === null) { batcherIntervalId = setInterval(flush, 1000) as unknown as number; } } export function stopMicroBatcher(): void { if (batcherIntervalId !== null) { clearInterval(batcherIntervalId); batcherIntervalId = null; } } export const auditWrapper = { get auditLog() { return auditLog; }, set auditLog(val: any) { auditLog = val; }, };