feat(enterprise): add tamper-resistant audit mode with HMAC integrity

This commit is contained in:
SnapOtter
2026-06-13 16:48:09 +08:00
parent 913dd6bbe1
commit 895e29e93f
4 changed files with 126 additions and 5 deletions
+15 -4
View File
@@ -10,7 +10,7 @@
* calling runSystemJob); anything else is a bug.
*/
import type { Job } from "bullmq";
import { inArray, sql } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { getMaxAgeMs } from "../lib/cleanup.js";
@@ -158,8 +158,19 @@ async function retentionSweep(): Promise<void> {
);
}
if (env.AUDIT_RETENTION_DAYS > 0) {
await db.execute(
sql`DELETE FROM audit_log WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'`,
);
const tamperResult = await db
.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, "tamperResistantAudit"))
.limit(1);
const isTamperResistant = tamperResult.length > 0 && tamperResult[0].value === "true";
// Only delete audit logs if tamper-resistant mode is OFF
if (!isTamperResistant) {
await db.execute(
sql`DELETE FROM audit_log WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'`,
);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { createHmac } from "node:crypto";
export function canonicalize(obj: unknown): string {
if (obj === null || obj === undefined) return "null";
if (typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return `[${obj.map(canonicalize).join(",")}]`;
const sorted = Object.keys(obj as Record<string, unknown>)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalize((obj as Record<string, unknown>)[k])}`)
.join(",");
return `{${sorted}}`;
}
export function computeHmac(data: Record<string, unknown>, key: Buffer): string {
return createHmac("sha256", key).update(canonicalize(data)).digest("hex");
}
export function verifyHmac(
data: Record<string, unknown>,
hmac: string,
key: Buffer,
): boolean {
const computed = computeHmac(data, key);
return computed === hmac;
}
+37 -1
View File
@@ -1,7 +1,10 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { computeHmac } from "./audit-integrity.js";
import { deriveAuditHmacKey } from "./encryption.js";
const MAX_AUDIT_INPUT_LENGTH = 200;
@@ -56,9 +59,10 @@ export async function auditLog(
const targetId = (details.targetUserId as string) ?? (details.keyId as string) ?? null;
const targetType = deriveTargetType(event);
const id = randomUUID();
try {
await db.insert(schema.auditLog).values({
id: randomUUID(),
id,
actorId,
actorUsername,
action: event,
@@ -69,6 +73,38 @@ export async function auditLog(
});
} catch {
logger.warn({ event }, "Failed to write audit log to DB");
return;
}
// Compute HMAC for tamper-resistant mode
if (env.DATA_ENCRYPTION_KEY) {
try {
const tamperResult = await db
.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, "tamperResistantAudit"))
.limit(1);
if (tamperResult.length > 0 && tamperResult[0].value === "true") {
const hmacKey = await deriveAuditHmacKey(env.DATA_ENCRYPTION_KEY);
const rowData = {
actorId,
actorUsername,
action: event,
targetType,
targetId,
details,
ipAddress: ip,
};
const integrity = computeHmac(rowData, hmacKey);
await db
.update(schema.auditLog)
.set({ integrity })
.where(eq(schema.auditLog.id, id));
}
} catch {
logger.warn({ event }, "Failed to compute audit HMAC");
}
}
}