A hash-chained ledger makes tampering evident: because every entry commits to the cryptographic hash of the one before it, changing any historical record silently is impossible without breaking every hash that follows. This page implements that mechanism in Python — the append path, the verification walk that pinpoints the first altered entry, and the signed checkpoints that let an outside auditor confirm integrity without trusting your database. It is the tamper-evidence layer beneath the Parts-Traceability Ledger Design for Aviation Components, and it is what turns “we don’t edit records” from a policy into a provable property.
Prerequisites & inputs
You need canonical, already-validated entries — the chain protects integrity, it does not judge correctness. Each entry is a maintenance fact about a component: an evacuation-slide overhaul on VH-OQA at ATA 25-62, carrying a part number like EV-5590-02, a UTC timestamp, and the certifying-staff identity. You also need one design decision made up front: what exactly gets hashed. The hash must cover the entry’s full payload plus the previous hash and the sequence index, serialized deterministically, so that two systems computing the hash of the same entry always agree byte-for-byte. Non-deterministic serialization (unsorted JSON keys, floating timestamps) is the most common reason a chain that should verify does not.
Finally, decide where trust is anchored. A chain alone proves internal consistency; it does not stop someone who can rewrite the entire chain from doing so cleanly. Anchoring — publishing periodic signed checkpoints to an append-only or external store — closes that gap, and it pairs naturally with the ability to reconstruct history described in Replaying Pipeline Transformations for Audit.
Tamper-evident ledger entries
Step-by-step implementation
1. Fix a deterministic digest. Serialize each entry’s payload with sorted keys and compact separators, prepend the sequence index and the previous digest, and take SHA-256. This is the single primitive the whole scheme rests on; keep it in one function and never let two call sites serialize differently.
2. Append by chaining. To append, read the current head digest, compute the new entry’s digest over it, store the entry, and advance the head. The genesis entry chains onto a fixed all-zero digest so the very first record is anchored too.
3. Verify by re-deriving. Verification recomputes every digest from the genesis forward. The instant a recomputed digest fails to match the stored one, you have found the first tampered entry — its index localizes the breach.
4. Checkpoint for external trust. Periodically sign the current head digest and the entry count, and store that checkpoint somewhere you do not control. An auditor who trusts only the checkpoint can still verify the whole prefix it covers.
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.hash_chain_ledger")
GENESIS_DIGEST = "0" * 64
def compute_digest(index: int, prev_digest: str, payload: dict) -> str:
"""Deterministic SHA-256 over (index, prev_digest, canonical payload)."""
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
hasher = hashlib.sha256()
hasher.update(f"{index}|{prev_digest}|".encode("utf-8"))
hasher.update(canonical.encode("utf-8"))
return hasher.hexdigest()
@dataclass(frozen=True)
class ChainEntry:
index: int
payload: dict
prev_digest: str
digest: str
@dataclass(frozen=True)
class Checkpoint:
entry_count: int
head_digest: str
signed_at: datetime
@dataclass
class HashChainLedger:
"""Append-only, tamper-evident ledger of maintenance events."""
_entries: list[ChainEntry] = field(default_factory=list)
@property
def head_digest(self) -> str:
return self._entries[-1].digest if self._entries else GENESIS_DIGEST
def append(self, payload: dict) -> ChainEntry:
index = len(self._entries)
prev = self.head_digest
entry = ChainEntry(
index=index,
payload=payload,
prev_digest=prev,
digest=compute_digest(index, prev, payload),
)
self._entries.append(entry)
logger.info("appended index=%d digest=%s", index, entry.digest[:12])
return entry
def verify(self) -> int | None:
"""Return the index of the first tampered entry, or None if intact.
Under 14 CFR 43.9(a)(4) a maintenance record must carry "the signature,
the certificate number, and kind of certificate held by the person
approving the work" -- a non-repudiation requirement. A hash chain makes
any retroactive change to that signed content detectable.
"""
expected_prev = GENESIS_DIGEST
for entry in self._entries:
recomputed = compute_digest(entry.index, expected_prev, entry.payload)
if entry.prev_digest != expected_prev or recomputed != entry.digest:
logger.error("tamper detected at index=%d", entry.index)
return entry.index
expected_prev = entry.digest
return None
def checkpoint(self) -> Checkpoint:
return Checkpoint(
entry_count=len(self._entries),
head_digest=self.head_digest,
signed_at=datetime.now(timezone.utc),
)
Worked example
Append three maintenance events for the evacuation slide on VH-OQA: a removal, a shop overhaul, and a reinstall, each a small payload dict with part_number EV-5590-02, ata_chapter 25-62, a UTC timestamp, and a certifying-staff id. Immediately after loading, verification is clean:
appended index=0 digest=9f2c1a7b4d3e
appended index=1 digest=1c88ef50a92d
appended index=2 digest=b7043fe6c118
ledger.verify() -> None # intact
Now simulate a bad actor who edits the overhaul entry in the store — say, backdating its timestamp to hide a late return-to-service — by mutating _entries[1].payload directly. Re-running verify() recomputes index 1’s digest from the (unchanged) genesis-side prefix, finds it no longer matches the stored 1c88ef50a92d, and reports:
tamper detected at index=1
ledger.verify() -> 1
The breach is localized to the exact entry, and every entry after it is implicated because their prev_digest values descended from the now-broken link. A checkpoint taken before the edit — entry_count=3, head_digest=b7043fe6c118 — is the independent witness: comparing the live head against the checkpoint’s head immediately reveals divergence even if the attacker also rewrote index 2’s digest to look internally consistent. This is precisely the property that gives an LLP record its weight; the cycle totals reconstructed in Modeling Life-Limited Parts Back to Birth Records are only trustworthy if the events they sum cannot be quietly altered after the fact.
Edge cases & compliance gotchas
Non-deterministic serialization. If one process serializes a payload with unsorted keys and another with sorted keys, their digests differ and verification fails on untouched data. Pin the canonical form — sorted keys, fixed separators, timestamps as ISO 8601 UTC strings, not native datetimes — and unit-test that two independent serializations of the same entry produce identical bytes.
Legitimate corrections. Regulators expect corrections, not erasures. A hash chain forbids editing entry n; the compliant path is to append a new entry that references and supersedes it, exactly as a paper logbook uses a struck line and a fresh signature. Model a correction_of field in the payload so the supersession is itself part of the tamper-evident record.
Whole-chain rewrite. A chain proves internal order but not that the whole thing wasn’t rebuilt from scratch. Without external anchoring, an attacker with write access can regenerate a fully consistent alternate history. Publish signed checkpoints to a store outside the ledger’s own trust boundary and verify against them; the chain plus the checkpoint together are what satisfy the electronic-recordkeeping expectations in AC 120-78B.
Floating-point and Unicode in payloads. A cycle count stored as 4200.0 in one run and 4200 in another hashes differently; a technician name with a combining diacritic can normalize two ways. Coerce numeric fields to a fixed representation and Unicode-normalize text before hashing, or benign round-trips will masquerade as tampering. Get the digest deterministic and the anchoring external, and the ledger stops merely asserting that its history is untouched — it lets anyone prove it, which is the standard a maintenance record has to meet the day it is questioned.