diff --git a/packages/core-audit/src/pseudonymize.test.ts b/packages/core-audit/src/pseudonymize.test.ts new file mode 100644 index 0000000..66d737c --- /dev/null +++ b/packages/core-audit/src/pseudonymize.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { pseudonymize } from "./pseudonymize"; + +describe("pseudonymize", () => { + const originalSalt = process.env["AUDIT_PSEUDONYM_SALT"]; + + beforeEach(() => { + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-1"; + }); + + afterEach(() => { + if (originalSalt === undefined) { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + } else { + process.env["AUDIT_PSEUDONYM_SALT"] = originalSalt; + } + }); + + it("returns a string prefixed with 'erased-'", () => { + const result = pseudonymize("user_42"); + expect(result).toMatch(/^erased-/); + }); + + it("produces exactly 16 hex chars after the prefix", () => { + const result = pseudonymize("user_42"); + const hex = result.slice("erased-".length); + expect(hex).toHaveLength(16); + expect(hex).toMatch(/^[0-9a-f]+$/); + }); + + it("is deterministic — same salt + actorId always yields the same token", () => { + const a = pseudonymize("user_42"); + const b = pseudonymize("user_42"); + expect(a).toBe(b); + }); + + it("differs when actorId differs (same salt)", () => { + const a = pseudonymize("user_42"); + const b = pseudonymize("user_99"); + expect(a).not.toBe(b); + }); + + it("differs when the salt changes", () => { + const withSalt1 = pseudonymize("user_42"); + + process.env["AUDIT_PSEUDONYM_SALT"] = "test-salt-2"; + const withSalt2 = pseudonymize("user_42"); + + expect(withSalt1).not.toBe(withSalt2); + }); + + it("uses the fallback salt when env var is absent", () => { + delete process.env["AUDIT_PSEUDONYM_SALT"]; + // Should not throw; just use the fallback. + const result = pseudonymize("user_1"); + expect(result).toMatch(/^erased-[0-9a-f]{16}$/); + }); +}); diff --git a/packages/core-audit/src/pseudonymize.ts b/packages/core-audit/src/pseudonymize.ts new file mode 100644 index 0000000..4439cb1 --- /dev/null +++ b/packages/core-audit/src/pseudonymize.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; + +/** + * Produces a stable, irreversible token for a GDPR-erased actorId. + * + * Format: `erased-` + * + * The salt is read from `AUDIT_PSEUDONYM_SALT` env at call time so that + * production binding can pre-validate the var at boot (see `bindAudit`) + * while tests can override it per-test via `process.env`. + * + * Fallback salt is intentionally weak and labelled so that any token + * produced with it is recognisable as a dev/test artefact. + */ +export function pseudonymize(actorId: string): string { + const salt = + process.env["AUDIT_PSEUDONYM_SALT"] ?? "dev-fallback-salt-replace-in-prod"; + const hash = createHash("sha256") + .update(`${salt}:${actorId}`) + .digest("hex"); + return `erased-${hash.slice(0, 16)}`; +}