Building SHA-256 Hash Chains for Redaction Logs
This guide walks through constructing a tamper-evident SHA-256 hash chain for redaction events in Python: seeding a genesis entry, canonically encoding each record, linking every new event to its predecessor, and persisting the growing log to durable storage without ever breaking reproducibility.
Prerequisites & Version Matrix Permalink to this section
The chain writer leans entirely on the standard library, so there are no third-party pins to reconcile against a redaction pipeline’s existing dependency tree.
| Component | Version | Notes |
|---|---|---|
| Python | 3.11+ | Structural pattern matching and hashlib improvements assumed |
hashlib |
stdlib | sha256 is FIPS-validated on CPython builds linked against system OpenSSL |
json |
stdlib | Used only in canonical mode (sort_keys, tight separators) |
unicodedata |
stdlib | NFC normalization before hashing |
This page is the construction half of Cryptographic Audit Manifests; the schema and field definitions live there, and the independent walk that validates the finished log lives in Verifying Tamper-Evident Audit Manifests.
Step 1 — Canonicalize the Record Permalink to this section
Before anything is hashed, each record is reduced to one unambiguous byte string. Keys are sorted, separators are tightened to remove incidental whitespace, and every string is normalized to Unicode NFC so that visually identical text produces identical bytes.
import hashlib
import json
import unicodedata
GENESIS_PREV = "0" * 64 # sentinel back-link for the first entry
def canonicalize(record: dict) -> bytes:
def norm(value):
if isinstance(value, str):
return unicodedata.normalize("NFC", value) # collapse composed/decomposed forms
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")
Step 2 — Seal an Entry Against Its Predecessor Permalink to this section
Sealing concatenates the previous digest with the canonical body and hashes the pair. Because prev is folded into the preimage, altering any earlier entry changes every digest that follows it.
def seal(record: dict, prev: str) -> dict:
entry = dict(record, prev=prev)
preimage = prev.encode("utf-8") + canonicalize(entry)
entry["digest"] = hashlib.sha256(preimage).hexdigest()
return entry
Step 3 — Grow the Chain from a Genesis Entry Permalink to this section
The genesis entry is sealed against the zero sentinel; every later event is sealed against the digest of the current tail. The loop is deliberately linear — there is no branching, because an audit log admits exactly one successor per entry.
def build_chain(records: list) -> list:
chain, prev = [], GENESIS_PREV
for record in records:
entry = seal(record, prev)
chain.append(entry)
prev = entry["digest"] # the tail becomes the next back-link
return chain
events = [
{"event": "ingest", "source_hash": "aa11", "ts": "2026-07-15T09:00:00Z"},
{"event": "redact", "source_hash": "aa11", "output_hash": "bb22", "ts": "2026-07-15T09:00:01Z"},
{"event": "seal", "output_hash": "bb22", "ts": "2026-07-15T09:00:02Z"},
]
chain = build_chain(events)
Step 4 — Persist Without Breaking Reproducibility Permalink to this section
Append the sealed entries as newline-delimited JSON so the file is streamable and each line is independently parseable. Flush and fsync before acknowledging the event upstream, so a crash can never leave a redaction acknowledged but unrecorded.
import os
def persist(entry: dict, path: str = "manifest.jsonl") -> None:
line = json.dumps(entry, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
with open(path, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
fh.flush()
os.fsync(fh.fileno()) # durable before the event is acknowledged
Compliance Checkpoint Permalink to this section
FRCP Rule 37(e) exposes a party to sanctions only when electronically stored information that should have been preserved is lost and cannot be restored or replaced. A durably flushed, hash-linked log is the concrete “reasonable steps to preserve” the rule asks for: each redaction event is committed before acknowledgment, and the chain can be replayed in full, so no action is silently dropped from the record a court or regulator later inspects.
Gotchas Permalink to this section
- Non-deterministic dict order. Never hash the default
json.dumpsoutput; withoutsort_keys=Truethe byte string depends on insertion order and two builders will disagree. Canonicalize first, always. - Unicode normalization. A name entered as a precomposed
éand one entered ase+ combining accent are visually identical but hash differently. NFC-normalize every string field before hashing, or accented parties silently break reproducibility. - Float serialization.
json.dumps(0.1)is platform-sensitive and lossy. Keep numeric fields as integers or fixed-precision strings; never let a raw float enter the preimage. - Logging raw content. The record holds
source_hashandoutput_hash, never document text. If a debug line ever prints the raw span, the audit log itself becomes a PII leak.
Verification & Testing Permalink to this section
A minimal pytest confirms the core invariant: forging any earlier field makes the stored digest disagree with a fresh recomputation, proving the chain is tamper-evident.
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()
def test_tamper_breaks_chain():
forged = [dict(e) for e in chain]
forged[1]["output_hash"] = "deadbeef" # rewrite a redaction result
assert recompute(forged[1]) != forged[1]["digest"]
Running pytest -q yields a single deterministic pass, and flipping any character in a sealed field flips the assertion to a failure.
Related Permalink to this section
The schema and performance envelope for these entries are specified in Cryptographic Audit Manifests, and the companion Verifying Tamper-Evident Audit Manifests walks a finished log to locate the first break. The threshold metadata stamped into each event originates in Confidence Threshold Configuration. Part of the Immutable Audit Trails & Chain of Custody field guide.