feat(core-audit): pseudonymize helper (sha256 + AUDIT_PSEUDONYM_SALT)
Adds `pseudonymize(actorId)` in core-audit — SHA-256 of salt+":"+actorId truncated to 16 hex chars, prefixed "erased-". Salt from AUDIT_PSEUDONYM_SALT env (fallback dev label). 6 unit tests: deterministic, salt-change-differs, fallback-no-throw. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
58
packages/core-audit/src/pseudonymize.test.ts
Normal file
58
packages/core-audit/src/pseudonymize.test.ts
Normal file
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
22
packages/core-audit/src/pseudonymize.ts
Normal file
22
packages/core-audit/src/pseudonymize.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Produces a stable, irreversible token for a GDPR-erased actorId.
|
||||
*
|
||||
* Format: `erased-<first-16-hex-chars-of-sha256(salt:actorId)>`
|
||||
*
|
||||
* 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user