ADR-024: Audit hash payload covers forensic fields
Table of contents
Status
Accepted
Date
2026-05-21
Context
The HMAC audit hash chain introduced in ADR-023: Audit hash chain HMAC consideration (epoch 1) binds only identity fields into each entry's hash:
uidsecret_identifieractionactor_uidcrdateprevious_hash
A subsequent security review (the "multi-axis review" — H-4) showed this leaves the forensic fields unauthenticated. A database-privileged attacker can rewrite any of:
success(flip a denied access from0 → 1to make a rejection look like an approval),error_message(rewrite the audit narrative — "Decryption failed: wrong master key" becomes ""),reason(alter the human-readable justification),ip_address/user_agent(misattribute the action to a different client),hash_before/hash_after(change the secret-state checksum without breaking the chain),context(rewrite the JSON-encoded contextual payload),
...without breaking the chain. Each row's HMAC is still valid because the HMAC only covers identity fields — the forensic fields the audit log exists to preserve are not actually tamper-evident.
Decision
Introduce epoch 2: extend the HMAC payload to cover identity AND forensic fields. The new payload is a canonical JSON object containing all of:
uid,secret_identifier,action,actor_uid,crdate,previous_hash(from epoch 1)success,error_message,reason,ip_address,user_agent,hash_before,hash_after,context(NEW)
Encoded with `JSON_` so the byte sequence
that feeds into the HMAC is canonical (no escape drift across PHP
versions, no crash on invalid UTF-8 in attacker-controlled
user_agent / error_message fields).
Existing epoch-0 (SHA-256) and epoch-1 (HMAC v1) entries continue to
verify under their stored hmac_key_epoch column. DEFAULT_AUDIT_HMAC_EPOCH
moves to 2 so fresh installs write epoch-2 from day one; existing
installs migrate via the existing AuditHmacMigrationWizard /
vault:audit-migrate-hmac CLI.
Migration tooling generalised: the wizard's gate condition changes from "any epoch-0 row exists" to "any row below the configured target epoch", so a 1 → 2 upgrade also surfaces in the Install Tool.
Consequences
Positive
successflip fromfalse → truenow breaks the chain.- Rewriting
error_message/reason/ IP / UA / context now breaks the chain. - Attribution forensic value is preserved across the entire log surface, not just the identity columns.
- Forward compatibility: the epoch column lets us add epoch 3 later without breaking any existing entries.
Negative
- Slightly larger HMAC payload ( 10× the byte count of v1) — negligible on modern hardware; HMAC-SHA256 is constant-time per byte.
- Existing installs need a one-time migration run before any verification of pre-upgrade rows reports the new payload.
Verified
- Unit tests cover identity-field-only verification of epoch 0/1 rows,
forensic-field-only verification of epoch 2 rows, and a mixed-epoch
chain that walks both algorithms across a single
verifyHashChain()call. - Regression guards:
verifyHashChainEpoch2DetectsForensicTamperingflips asuccessvalue in storage and asserts the verifier returnsisValid() === false.
References
- Pull request: #138
- Previous: ADR-023: Audit hash chain HMAC consideration
- Related: ADR-006: Audit logging