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.dumps output; without sort_keys=True the 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 as e + 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_hash and output_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.

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.