Skip to content

HMAC audit chain

Every audit row stores:

id, ts, actor_user_id, project_id, action, target_type, target_id, details,
row_hmac, prev_row_hmac

Where:

row_hmac = HMAC-SHA256(
Z4J_AUDIT_SECRET,
canonical_json({
id, ts, actor_user_id, project_id, action,
target_type, target_id, details,
prev_row_hmac
})
)

canonical_json is sorted-keys, no whitespace, UTF-8. prev_row_hmac is the row_hmac of the previous row in id order. Genesis row has prev_row_hmac = null.

  • Tamper-evident - change any field → row_hmac no longer verifies.
  • Insertion-evident - inserting a row in the middle breaks the next row’s prev_row_hmac link.
  • Deletion-evident - deleting a row breaks the chain at the gap.

Not prevented:

  • Append-only suffix replacement - someone with Z4J_AUDIT_SECRET and DB access can truncate trailing rows and re-sign. Mitigation: periodically export row_hmac externally (e.g. to an append-only S3 bucket or a second DB).
Terminal window
z4j-brain audit verify

Walks the full log; returns (ok, rows_verified, first_broken_id). Runs:

  • On demand (CLI / API / dashboard button).
  • Optionally on schedule: set Z4J_AUDIT_VERIFY_INTERVAL=3600 to run hourly.

Rotating Z4J_AUDIT_SECRET breaks verification for old rows. Strategies:

  1. Dual-key window - support both keys during rotation (planned post-v1.1).
  2. Snapshot & start fresh - export old rows with their HMACs (still verifiable using the old secret externally), rotate, begin a new chain.

Most operators don’t rotate the audit secret; the sensitive rotation targets are Z4J_SECRET and Z4J_SESSION_SECRET.

CSV / NDJSON export includes row_hmac + prev_row_hmac. Downstream verification requires Z4J_AUDIT_SECRET (share it with the verifier out-of-band).

HMAC-SHA256 on a small canonical payload is microseconds; the chain does not meaningfully slow writes. Verification of a million rows takes ~10 seconds on modest hardware.