fix(core-audit): keyed 128-bit pseudonyms + salted DSR certificate

pseudonymize() used an unkeyed sha256 over 'salt:id' truncated to 64
bits, and the DSR deletion certificate hashed the raw subjectId with no
salt at all (audit finding A13). Both now use HMAC-SHA256 keyed by
AUDIT_PSEUDONYM_SALT, truncated to 128 bits. Rotation semantics are
documented on pseudonymize(): a key rotation changes future pseudonyms
only — stored rows keep old tokens and erasure still matches by real
actorId — and the certificate change likewise affects new certificates
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:13:38 +02:00
parent 68a142fa6b
commit d95ae74aed
4 changed files with 68 additions and 19 deletions

View File

@@ -1,7 +1,6 @@
import { getPayload as _getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
import { randomUUID, createHmac } from "node:crypto";
import type { AuditLogProtocol } from "@repo/core-shared/di";
import {
RETENTION_TOMBSTONE_FIELD,
@@ -321,9 +320,20 @@ export class PayloadDataDelete implements IDataDelete {
correlationId: string,
affected: DeletionAffected[],
): DeletionCertificate {
// Salted, keyed pseudonym (audit finding A13): the certificate used to
// hash the raw subjectId with NO salt (truncated to 64 bits), letting a
// certificate holder brute-force small id spaces offline. Now
// HMAC-SHA256 keyed by the operator secret AUDIT_PSEUDONYM_SALT (the
// same secret the audit-log pseudonymizer uses), truncated to 128 bits.
// NOTE: this changes tokens on FUTURE certificates only — certificates
// issued under the old scheme keep their historical value, and rotating
// the key likewise affects only certificates issued afterwards.
const certKey =
process.env["AUDIT_PSEUDONYM_SALT"] ??
"dev-fallback-salt-replace-in-prod";
const certSubjectId =
mode === "cascade-hard"
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
? `erased-${createHmac("sha256", certKey).update(subjectId).digest("hex").slice(0, 32)}`
: subjectId;
return {