mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add tamper-resistant audit mode with HMAC integrity
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
* calling runSystemJob); anything else is a bug.
|
* calling runSystemJob); anything else is a bug.
|
||||||
*/
|
*/
|
||||||
import type { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { getMaxAgeMs } from "../lib/cleanup.js";
|
import { getMaxAgeMs } from "../lib/cleanup.js";
|
||||||
@@ -158,8 +158,19 @@ async function retentionSweep(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (env.AUDIT_RETENTION_DAYS > 0) {
|
if (env.AUDIT_RETENTION_DAYS > 0) {
|
||||||
await db.execute(
|
const tamperResult = await db
|
||||||
sql`DELETE FROM audit_log WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'`,
|
.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'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { env } from "../config.js";
|
||||||
import { db, schema } from "../db/index.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;
|
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 targetId = (details.targetUserId as string) ?? (details.keyId as string) ?? null;
|
||||||
const targetType = deriveTargetType(event);
|
const targetType = deriveTargetType(event);
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
try {
|
try {
|
||||||
await db.insert(schema.auditLog).values({
|
await db.insert(schema.auditLog).values({
|
||||||
id: randomUUID(),
|
id,
|
||||||
actorId,
|
actorId,
|
||||||
actorUsername,
|
actorUsername,
|
||||||
action: event,
|
action: event,
|
||||||
@@ -69,6 +73,38 @@ export async function auditLog(
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn({ event }, "Failed to write audit log to DB");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { canonicalize, computeHmac, verifyHmac } from "../../../apps/api/src/lib/audit-integrity.js";
|
||||||
|
|
||||||
|
describe("audit integrity", () => {
|
||||||
|
const testKey = Buffer.from("a".repeat(64), "hex");
|
||||||
|
|
||||||
|
it("canonicalizes with sorted keys", () => {
|
||||||
|
const input = { z: 1, a: 2, m: { b: 3, a: 4 } };
|
||||||
|
const result = canonicalize(input);
|
||||||
|
expect(result).toBe('{"a":2,"m":{"a":4,"b":3},"z":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canonicalizes with null values included", () => {
|
||||||
|
const input = { a: null, b: "test" };
|
||||||
|
expect(canonicalize(input)).toBe('{"a":null,"b":"test"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canonicalizes arrays", () => {
|
||||||
|
const input = { items: [3, 1, 2] };
|
||||||
|
expect(canonicalize(input)).toBe('{"items":[3,1,2]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes deterministic HMAC", () => {
|
||||||
|
const data = { event: "LOGIN_SUCCESS", actorId: "u1" };
|
||||||
|
const hmac1 = computeHmac(data, testKey);
|
||||||
|
const hmac2 = computeHmac(data, testKey);
|
||||||
|
expect(hmac1).toBe(hmac2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes and verifies HMAC", () => {
|
||||||
|
const data = { event: "LOGIN_SUCCESS", actorId: "u1" };
|
||||||
|
const hmac = computeHmac(data, testKey);
|
||||||
|
expect(verifyHmac(data, hmac, testKey)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects tampering", () => {
|
||||||
|
const data = { event: "LOGIN_SUCCESS", actorId: "u1" };
|
||||||
|
const hmac = computeHmac(data, testKey);
|
||||||
|
const tampered = { ...data, actorId: "u2" };
|
||||||
|
expect(verifyHmac(tampered, hmac, testKey)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("key ordering does not affect HMAC", () => {
|
||||||
|
const data1 = { b: 2, a: 1 };
|
||||||
|
const data2 = { a: 1, b: 2 };
|
||||||
|
expect(computeHmac(data1, testKey)).toBe(computeHmac(data2, testKey));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user