Who Changed It/Docs/Core concepts

The hash chain: making the log stand up as evidence

How tamper detection works, what an authorised gap is, and how to verify a handed-over export without WordPress.

The problem this solves

An audit log is only worth as much as your confidence that it has not been edited. Anyone with database access can normally delete the row that incriminates them, and nothing about the remaining table would look wrong. From version 0.6.0 the log defends against exactly that.

How the chain works

Every record carries three hashes:

Because each record_hash folds in the one before it, editing or removing any row breaks every link after it. The verifier walks the chain and reports precisely what broke: rows altered, rows missing, links broken.

Authorised gaps

Deleting rows is sometimes legitimate — retention purges, manual purges, GDPR erasure. If those simply removed rows, the chain would break and every legitimate cleanup would look like an attack.

Instead, an authorised removal records a gap: the range removed, how many records it covered, the record_hash of the last row in the removed span, and the reason. The surviving row's prev_hash can then still be matched across the hole, so the chain verifies. Anything that removes rows without recording a gap — a hand-written DELETE, a database restore, a row edited in phpMyAdmin — is what "flagged" means.

The trust model, stated honestly

The chain detects tampering by anyone who cannot compute the HMAC. Where the key lives therefore decides what the chain is worth:

// wp-config.php - set this before you accumulate records you care about.
define( 'WHOCHITA_CHAIN_KEY', 'a-long-random-string-kept-out-of-the-database' );

Whether the key is held outside the database is recorded in every export's manifest, as key_held_outside_database, so whoever receives the file can judge the strength of what they are holding.

Changing the key after records exist invalidates the existing chain — the old hashes were computed with the old key. Set it early, keep it backed up with your other secrets, and treat losing it as losing the ability to prove the history.

Checking the chain

Verification status is shown on the log screen and on the settings page. It reports how many records were checked, and counts of altered rows, missing rows, broken links and authorised gaps. A healthy log reports zero of the first three, and however many gaps your retention policy has legitimately created.

Signed evidence export

Exports are not just a dump of rows. Every export carries a manifest describing what was asked for and what came back, and the whole thing is covered by one HMAC-SHA256 signature. The manifest records:

Because records_digest is inside the signed manifest, neither the records nor the stated bounds can be altered without invalidating the signature. That is what makes a filtered export meaningful to a third party: they can see it was a filtered view, and see exactly which filter.

Verifying a handed-over file

Verification needs no WordPress and no plugin — only the file and the site's chain key:

  1. Compute records_digest: SHA-256 over record_hash followed by a newline, for each record, in ascending chain-index order.
  2. Check it matches the digest inside the manifest.
  3. Derive the export subkey: HMAC-SHA256 of the string whochita/export, keyed with the site chain key.
  4. Re-encode the manifest with the signature key removed, compactly, keys in the order given, slashes and unicode unescaped.
  5. Compute HMAC-SHA256 of that JSON with the subkey, and compare it to the signature.

The algorithm block is repeated inside every manifest, so the file explains how to check itself years later without reference to this page.

What this does and does not prove

Worth being precise, because "tamper-proof" gets used loosely. A verified chain proves that the records are internally consistent and that nothing was removed or altered without leaving a mark, given that whoever produced it did not hold the key. It does not prove the events themselves were recorded correctly, it cannot see activity that never passed through WordPress, and it is not a substitute for backups. It raises the cost of quietly rewriting history from trivial to high, which is the honest claim.