Verifying Tamper-Evident Audit Manifests
This guide builds a verifier that walks an existing redaction manifest entry by entry, recomputes each SHA-256 digest, compares back-links, and reports the exact index where the chain first breaks — the difference between “something is wrong” and “entry 4,812 was altered.”
Prerequisites & Version Matrix Permalink to this section
Verification uses the same canonicalization the writer used; any divergence in serialization would produce false failures, so the two halves must agree byte for byte.
| Component | Version | Notes |
|---|---|---|
| Python | 3.11+ | Matches the builder in the companion guide |
hashlib |
stdlib | Recomputes each sha256 digest |
json |
stdlib | Canonical mode only — must mirror the writer exactly |
argparse |
stdlib | Drives the deterministic PASS/FAIL command |
The construction side lives in Building SHA-256 Hash Chains for Redaction Logs; this page assumes a finished log and the schema defined in Cryptographic Audit Manifests.
Step 1 — Reproduce the Canonical Preimage Permalink to this section
The verifier must reconstruct each entry’s preimage identically to the writer: strip the stored digest, canonicalize the remaining body, and prepend the recorded prev. Any drift here manifests as a phantom break, so the encoding is shared code, not a reimplementation.
import hashlib
import json
import unicodedata
GENESIS_PREV = "0" * 64
def canonicalize(record: dict) -> bytes:
def norm(value):
if isinstance(value, str):
return unicodedata.normalize("NFC", value)
if isinstance(value, dict):
return {k: norm(v) for k, v in sorted(value.items())}
return value
body = {k: norm(v) for k, v in record.items()}
return json.dumps(
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
def recompute(entry: dict) -> str:
body = {k: v for k, v in entry.items() if k != "digest"}
return hashlib.sha256(entry["prev"].encode("utf-8") + canonicalize(body)).hexdigest()
Step 2 — Walk the Chain and Locate the First Break Permalink to this section
The walk tracks an expected_prev that starts at the genesis sentinel and advances to each verified digest. Two checks run per entry: the recorded back-link must equal the running expectation, and the stored digest must match a fresh recomputation. The first entry that fails either check is the break point, returned as an index so the caller knows precisely where custody was compromised.
def find_break(manifest: list):
"""Return the index of the first broken entry, or None if the chain is intact."""
expected_prev = GENESIS_PREV
for index, entry in enumerate(manifest):
# A rewritten or reordered entry desynchronizes the back-link first.
if entry.get("prev") != expected_prev:
return index
# A forged field survives the back-link check but fails recomputation.
if recompute(entry) != entry.get("digest"):
return index
expected_prev = entry["digest"]
return None
Step 3 — Emit a Deterministic PASS/FAIL Verdict Permalink to this section
Wrap the walk in a command-line entry point that loads a newline-delimited manifest and prints an unambiguous verdict. A clean chain prints PASS; a compromised one prints FAIL with the exact index, and the process exit code lets a pipeline gate on it.
import argparse
import sys
def load_manifest(path: str) -> list:
with open(path, encoding="utf-8") as fh:
return [json.loads(line) for line in fh if line.strip()]
def main() -> int:
parser = argparse.ArgumentParser(description="Verify a redaction audit manifest")
parser.add_argument("path")
args = parser.parse_args()
manifest = load_manifest(args.path)
broken = find_break(manifest)
if broken is None:
print(f"PASS: {len(manifest)} entries, chain intact")
return 0
print(f"FAIL: chain broken at index {broken}")
return 1
if __name__ == "__main__":
sys.exit(main())
Compliance Checkpoint Permalink to this section
GDPR Article 5(2) makes the controller responsible for, and able to demonstrate, compliance with the processing principles — the accountability principle. A verifier that independently recomputes every digest converts that duty from a claim into a repeatable proof: a supervisory authority, or the controller’s own auditor, can run the walk against the sealed manifest and confirm without trusting the application that no redaction record was altered after the fact. The returned break index makes any failure specific enough to investigate rather than a blanket “logs may be unreliable.”
Gotchas Permalink to this section
- Serializer drift. If the verifier’s canonicalization diverges from the writer’s by even one flag, every entry “fails.” Import the shared
canonicalize; do not paraphrase it. - Whole-file rehashing is not verification. Hashing the manifest file as one blob detects change but cannot say where; only the per-entry walk yields the actionable first-break index.
- Truncation looks intact. Lopping off the tail entries leaves a shorter but internally consistent chain. Verify the expected entry count or anchor the tail digest out-of-band, or silent truncation passes.
- Never print raw spans on failure. The verdict names an index and a digest, never document content — a diagnostic must not become the leak the manifest exists to prevent.
Verification & Testing Permalink to this section
Round-trip the verifier against a known-good chain and a deliberately corrupted copy. The intact log returns None; forging any field at position two returns exactly 2.
def test_find_break_pinpoints_tampering():
intact = load_manifest("manifest.jsonl")
assert find_break(intact) is None # clean chain
tampered = [dict(e) for e in intact]
tampered[2]["output_hash"] = "hacked" # rewrite one output digest
assert find_break(tampered) == 2 # first break located exactly
Invoked as python verify_manifest.py manifest.jsonl, a sealed log prints PASS: N entries, chain intact and exits 0; a tampered log prints FAIL: chain broken at index 2 and exits 1, giving CI a deterministic gate.
Related Permalink to this section
Pair this walk with its construction counterpart, Building SHA-256 Hash Chains for Redaction Logs, and consult Cryptographic Audit Manifests for the entry schema and failure-routing policy. A failed verdict routes the document through Automated Fallback Routing for High-Risk Files rather than re-releasing it. Part of the Immutable Audit Trails & Chain of Custody field guide.