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.
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
where is SHA-256, is the -th canonical record, and 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. Thercra_manifestrecord it seals is a hazardous-waste manifest subject to the three-year generator retention rule of 40 CFR 262.40(a), and thehos_totalsrecord 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.
Related
- Audit Report Generation — the reporting contract this sealing stage completes.
- Signing Compliance Chains with HMAC — the keyed-chain primitive this generator applies to whole reports.
- Compliance Reporting & Automation — the framework that produces the routing, HOS, and manifest records sealed here.
- Core Architecture & Compliance Mapping — where the record schemas and retention rules these reports depend on are defined.