Generating Tamper-Evident Compliance Reports in Python

Hash-chain and HMAC-sign compliance records so any post-hoc edit is detectable on recompute.

This page solves one specific production failure: a compliance report is exported as plain JSON, a single field is quietly edited months later, and nothing in the file lets an auditor tell the altered version from the original.

A defensible report has to carry its own proof of integrity. That means each record is hashed into a chain, the chain’s root is signed with a secret key, and the signature travels with the report inside a verification manifest an auditor can recompute independently. Nothing here needs a third-party crypto library — Python’s hashlib and hmac over canonical, sorted JSON are enough to make any post-hoc edit detectable. This generator is the sealing stage of the Audit Report Generation contract within Compliance Reporting & Automation, and it reuses the keyed-chain primitive documented in Signing Compliance Chains with HMAC.

Hash chain: three leaf records fold into a root, and an HMAC key signs the root Three leaf records are each hashed to a leaf digest, the digests fold left to right into a chain of hashes seeded by a genesis value, the final chain value is the root, and an HMAC keyed by a secret signs the root. The signature plus every digest lands in a verification manifest the auditor recomputes. leaf records route_summary stops, miles hos_totals driving_seconds rcra_manifest tracking_no hash chain · hᵢ = H(hᵢ₋₁ ‖ H(record)) chain[0] H(genesis ‖ leaf₀) chain[1] H(chain[0] ‖ leaf₁) chain[2] H(chain[1] ‖ leaf₂) ROOT chain[-1] SIGNATURE HMAC(key, root) key verification manifest · every leaf and chain digest plus the signature, recomputed by the auditor

Environment & Data Prerequisites

The generator is pure standard library — hashlib, hmac, json, os, and dataclasses on Python 3.10+. pytest is the only pin, and only for verification:

python -m pip install pytest==8.2.0

The signing key must come from a secret store, never a literal. The examples read it from the environment:

export COMPLIANCE_HMAC_KEY="$(openssl rand -hex 32)"

Each record fed to the report is a flat mapping. The generator constrains the shape so hashing is deterministic:

Field Type Rule
record_id str unique within the report; becomes the manifest label
record_type str route_summary, hos_totals, or rcra_manifest
body fields str / int / bool no floats — money as integer cents, distance as integer metres
nested keys str-keyed serialized with sorted keys at every depth

One rule dominates the design: everything that gets hashed must serialize byte-for-byte identically on every machine, forever. That rules out floats (whose repr varies) and unordered dict iteration (whose key order is insertion-dependent). The canonical serializer below enforces both.

Step-by-Step Implementation

Step 1 — Canonical serialization with a float guard

Determinism is the whole game. Keys are sorted at every level, separators are fixed, and floats are rejected outright so a 0.1 + 0.2 artifact can never poison a digest.

import hashlib
import hmac
import json
import os
from dataclasses import dataclass
from typing import Any


def _reject_floats(obj: Any, path: str = "$") -> None:
    """Fail loudly if any hashed value is a float — floats are not reproducible."""
    if isinstance(obj, float):
        raise TypeError(f"float at {path} is not hashable-safe; use int cents/metres or str")
    if isinstance(obj, dict):
        for k, v in obj.items():
            _reject_floats(v, f"{path}.{k}")
    elif isinstance(obj, (list, tuple)):
        for i, v in enumerate(obj):
            _reject_floats(v, f"{path}[{i}]")


def canonical_bytes(obj: Any) -> bytes:
    """Deterministic UTF-8 JSON: sorted keys, tight separators, no floats."""
    _reject_floats(obj)
    return json.dumps(obj, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")

Step 2 — Leaf digests and the hash chain

Each record hashes to a leaf. The leaves fold into a chain seeded by a fixed genesis label, so the root digest depends on every record and their order. The recurrence is

hi=H ⁣(hi1H(ri)),h1=H(genesis)h_i = H\!\left(h_{i-1} \,\Vert\, H(r_i)\right), \qquad h_{-1} = H(\text{genesis})

where HH is SHA-256, rir_i is the ii-th canonical record, and \Vert is byte concatenation.

GENESIS = b"routeoptimization.org/compliance/v1"


def leaf_hash(record: dict) -> str:
    return hashlib.sha256(canonical_bytes(record)).hexdigest()


def build_chain(records: list[dict]) -> tuple[str, list[dict]]:
    """Return (root_hex, manifest_entries) folding records into a hash chain."""
    prev = hashlib.sha256(GENESIS).hexdigest()
    entries: list[dict] = []
    for rec in records:
        leaf = leaf_hash(rec)
        prev = hashlib.sha256((prev + leaf).encode("utf-8")).hexdigest()
        entries.append({
            "record_id": rec["record_id"],
            "record_type": rec["record_type"],
            "leaf_hash": leaf,
            "chain_hash": prev,
        })
    return prev, entries  # prev is the root

Step 3 — Sign the root with HMAC

The root is a public checksum; it proves nothing about authorship. Signing it with HMAC-SHA256 under a secret key does — only a holder of the key could have produced the signature, and it uses hmac.compare_digest on verify to resist timing attacks.

def load_key() -> bytes:
    key = os.environ.get("COMPLIANCE_HMAC_KEY")
    if not key:
        raise RuntimeError("COMPLIANCE_HMAC_KEY is not set; refusing to sign unkeyed")
    return key.encode("utf-8")


def sign_root(root_hex: str, key: bytes) -> str:
    return hmac.new(key, root_hex.encode("utf-8"), hashlib.sha256).hexdigest()


def verify_signature(root_hex: str, signature: str, key: bytes) -> bool:
    expected = sign_root(root_hex, key)
    return hmac.compare_digest(expected, signature)

Step 4 — Assemble the report and verification manifest

The report bundles the raw records; the manifest bundles the digests, root, and signature. Publishing them together lets any auditor rebuild the chain and confirm the signature without ever seeing the key’s holder.

@dataclass(frozen=True)
class SealedReport:
    report: dict
    manifest: dict


def generate_report(report_id: str, records: list[dict]) -> SealedReport:
    key = load_key()
    root, entries = build_chain(records)
    signature = sign_root(root, key)
    manifest = {
        "report_id": report_id,
        "algorithm": "sha256-chain+hmac-sha256",
        "genesis": GENESIS.decode("utf-8"),
        "entries": entries,
        "root_hash": root,
        "signature": signature,
    }
    return SealedReport(report={"report_id": report_id, "records": records},
                        manifest=manifest)


if __name__ == "__main__":
    records = [
        {"record_id": "RT-2026-06-02-11", "record_type": "route_summary",
         "stops": 148, "distance_m": 92430, "vehicle": "T-118"},
        {"record_id": "HOS-D4471-2026-06-02", "record_type": "hos_totals",
         "driver_id": "D-4471", "driving_seconds": 16200, "on_duty_seconds": 27600},
        {"record_id": "MAN-8842-0001", "record_type": "rcra_manifest",
         "tracking_no": "012345678ELC", "waste_code": "D001", "quantity_kg": 430},
    ]
    sealed = generate_report("CR-2026-06-02", records)
    print(json.dumps(sealed.manifest, indent=2))

Compliance Output

The generator emits two artifacts. The verification manifest is the auditor-facing seal:

{
  "report_id": "CR-2026-06-02",
  "algorithm": "sha256-chain+hmac-sha256",
  "genesis": "routeoptimization.org/compliance/v1",
  "entries": [
    {"record_id": "RT-2026-06-02-11", "record_type": "route_summary",
     "leaf_hash": "b1e5...c7", "chain_hash": "9a44...02"},
    {"record_id": "HOS-D4471-2026-06-02", "record_type": "hos_totals",
     "leaf_hash": "7f30...ad", "chain_hash": "40c9...e1"},
    {"record_id": "MAN-8842-0001", "record_type": "rcra_manifest",
     "leaf_hash": "cc18...9b", "chain_hash": "d7a0...5f"}
  ],
  "root_hash": "d7a0...5f",
  "signature": "e2b9...41"
}

Each field carries a specific audit and retention meaning:

  • entries[].leaf_hash — the SHA-256 of one canonical record. An auditor rehashes the corresponding record in the report; a mismatch localizes the tamper to a single record rather than condemning the whole file.
  • entries[].chain_hash — the running root after folding that record. Because each depends on the one before, editing record 0 changes every chain hash after it, so the chain also proves ordering and omission, not just per-record integrity.
  • root_hash — the final chain value, the single digest that fingerprints the entire report.
  • signature — HMAC-SHA256 of the root under the secret key, the element that makes the seal unforgeable without the key.
  • report_id — ties the manifest to the retained report. The rcra_manifest record it seals is a hazardous-waste manifest subject to the three-year generator retention rule of 40 CFR 262.40(a), and the hos_totals record is a record-of-duty-status supporting document the carrier must retain for six months under 49 CFR 395.8(k)(1); the signed report is the durable, verifiable form that satisfies both retention windows against later dispute.

Store the report and manifest together in write-once (WORM) storage for the longest applicable window — three years here, driven by the RCRA manifest. The signature does not extend the retention period; it makes whatever is retained provably original.

Verification

The tests recompute the root from the published records, confirm the signature, and prove a single-field edit is caught.

import copy


def _key(monkey=None):
    os.environ["COMPLIANCE_HMAC_KEY"] = "0f" * 32  # test key only
    return load_key()


def test_root_recomputes_and_signature_verifies():
    _key()
    records = [
        {"record_id": "A", "record_type": "route_summary", "distance_m": 1000},
        {"record_id": "B", "record_type": "hos_totals", "driving_seconds": 3600},
    ]
    sealed = generate_report("CR-TEST", records)
    root, _ = build_chain(sealed.report["records"])
    assert root == sealed.manifest["root_hash"]
    assert verify_signature(root, sealed.manifest["signature"], load_key())


def test_tampered_record_breaks_the_root():
    _key()
    records = [
        {"record_id": "A", "record_type": "route_summary", "distance_m": 1000},
        {"record_id": "B", "record_type": "hos_totals", "driving_seconds": 3600},
    ]
    sealed = generate_report("CR-TEST", records)
    tampered = copy.deepcopy(sealed.report["records"])
    tampered[1]["driving_seconds"] = 1  # falsify the log
    root, _ = build_chain(tampered)
    assert root != sealed.manifest["root_hash"]
    assert not verify_signature(root, sealed.manifest["signature"], load_key())


def test_float_field_is_rejected():
    _key()
    import pytest
    with pytest.raises(TypeError):
        leaf_hash({"record_id": "A", "record_type": "route_summary", "distance_m": 1000.5})

Common Errors

Root hash differs between the generator host and the auditor’s laptop. Root cause: one side serialized the records with default json.dumps, whose key order follows insertion, so the same data produced different bytes and a different digest. Fix: hash only canonical_bytes output — sort_keys=True with fixed separators — everywhere, so key order is never a variable.

TypeError: float at $.quantity_kg is not hashable-safe. Root cause: a record carried a floating-point quantity, and float reprs are not reproducible across platforms or Python builds, so the leaf hash would be non-deterministic. Fix: represent every hashed magnitude as an integer in a base unit — metres, cents, grams — or as a string, exactly as the sample records use distance_m and quantity_kg integers.

RuntimeError: COMPLIANCE_HMAC_KEY is not set; refusing to sign unkeyed. Root cause: the process had no signing key in its environment, and an earlier draft “helpfully” fell back to signing with an empty key — which any attacker can reproduce, making the signature worthless. Fix: load the key from a secret store and fail closed when it is absent, as load_key does; never default to an empty or hard-coded key.

FAQ

Why a hash chain instead of hashing the whole report once?

A single hash of the whole file tells you that something changed but not what. The chain hashes each record into a running root, so when a leaf hash matches but a later chain hash does not, the auditor knows exactly which record was altered and that its position or the records after it were affected. It localizes tampering and proves ordering, both of which a monolithic digest cannot.

Does the signature need the records, or just the root?

Just the root. The root already commits to every record through the chain, so signing the root transitively signs the whole report. That is why the manifest can be published and verified independently: an auditor rebuilds the chain from the records to reproduce the root, then checks the one signature against it — a far smaller keyed operation than signing each record separately.

Up: Audit Report Generation