Verifying Audit Chain Integrity After Tampering

Within the broader Cryptographic Audit Chains specification — part of the wider Audit Logging & Compliance Evidence framework — this page builds the detection path: the code that reads a hash-chained ledger, proves it is intact, and, when it is not, pinpoints the first record where history diverged and classifies how. The engineers who reach for it are the ones who already write a ledger with Building a Hash-Chained Audit Ledger in Python and now need to answer the question a regulator, auditor, or incident responder actually asks: not “is the log fine?” but “if it was touched, exactly where and what happened?” A verifier that only returns a boolean is nearly useless in an investigation; a verifier that says “record 42 was edited in place” is evidence. That localization is the non-repudiation guarantee GDPR Art. 5(2) accountability leans on — the controller must be able to demonstrate that its record was not altered, and demonstrating it means being able to prove where any alteration would show.

Verification walks the chain from genesis, recomputes each link, and stops reasoning about integrity at the first mismatch — then inspects the surrounding pointers to decide whether the break is a deletion, a reordering, or an in-place edit:

Verification walk that localizes and classifies the first broken link Verification starts from the genesis anchor and recomputes each record's link from the previous digest and the record's canonical bytes. At each step it compares the recomputed digest with the stored digest. While they match, it advances and updates the running previous-hash. At the first mismatch it stops advancing and enters classification: if a record's stored previous-hash points to a digest no record produced, it reports a deletion; if the events are present but their pointers are out of order, it reports a reordering; otherwise, where a record's own bytes changed so its successor's pointer no longer matches, it reports an in-place edit. The outcome is a verification report carrying the verdict, the first broken sequence number, and the tamper class. Genesis anchor Recomputelink(prev, event) stored = calc? Classify breakat first mismatch Reordering Deletion In-place edit match → advance mismatch
Verification recomputes each link from genesis; the first stored-versus-calculated mismatch is localized, then classified as a deletion, reordering, or in-place edit.

Prerequisites

The verifier targets Python 3.11+ and shares its dependencies with the ledger it inspects — it must recompute links identically, so it uses the same hash function and the same canonical serialization:

  • pydantic (v2) — loads each persisted record with model_validate_json and models the verification report as a typed, frozen result. The code uses v2 idioms only (model_config = ConfigDict(...), model_validate).
  • hashlib (stdlib) — SHA-256, the NIST SP 800-107 approved function the ledger chained with; the verifier must not substitute a different algorithm or the recomputed links will never match.
  • json, enum (stdlib) — canonical serialization parity and the tamper-class enumeration.

The verifier assumes the ledger format produced by Building a Hash-Chained Audit Ledger in Python: a JSONL file of records, each carrying seq, prev_hash, entry_hash, and the event, anchored at a genesis digest of sixty-four zeros. Crucially, the verifier is a separate program from the writer — a regulator’s independent verifier will not be your production append path, so this code deliberately depends on nothing but the persisted bytes and the public hashing rule.

Step-by-step implementation

The verifier re-declares the exact event schema and link computation the ledger used. Any divergence — a different key order, a naive timestamp, a different hash — makes correct records look tampered, so this code mirrors the writer precisely.

from __future__ import annotations

import hashlib
import json
from datetime import datetime, timezone
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field

GENESIS: str = "0" * 64


class AuditEvent(BaseModel):
    """Verifier-side mirror of the ledger's event, byte-for-byte compatible."""

    model_config = ConfigDict(strict=True, frozen=True, extra="forbid")

    request_id: str
    stage: str
    legal_basis: str
    actor: str
    at: datetime

    def canonical_bytes(self) -> bytes:
        """Reproduce the writer's canonical serialization exactly."""
        payload = {
            "request_id": self.request_id,
            "stage": self.stage,
            "legal_basis": self.legal_basis,
            "actor": self.actor,
            "at": self.at.astimezone(timezone.utc).isoformat(),
        }
        return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()


class LedgerRecord(BaseModel):
    """A persisted chain link as read back from the JSONL ledger."""

    model_config = ConfigDict(strict=True, frozen=True, extra="forbid")

    seq: int = Field(ge=0)
    prev_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
    entry_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
    event: AuditEvent


def link(prev_hash: str, event: AuditEvent) -> str:
    """Recompute the tamper-evident digest for a record."""
    hasher = hashlib.sha256()
    hasher.update(prev_hash.encode("ascii"))
    hasher.update(event.canonical_bytes())
    return hasher.hexdigest()

Step 2 — Model the verification report

The output of verification is not a boolean — it is a structured, non-repudiable finding that can go into an incident record or a closure manifest. A frozen Pydantic model captures the verdict, the first broken sequence number (if any), the tamper class, and a human-readable detail, so the report itself is a stable artifact you can hash and retain.

class TamperClass(str, Enum):
    """How the first broken link diverged from a valid chain."""

    NONE = "none"
    IN_PLACE_EDIT = "in_place_edit"
    DELETION = "deletion"
    REORDERING = "reordering"
    STRUCTURAL = "structural"  # malformed record / bad genesis anchor


class VerificationReport(BaseModel):
    """A non-repudiable result an auditor can retain and re-verify."""

    model_config = ConfigDict(frozen=True)

    intact: bool
    records_checked: int
    first_broken_seq: int | None = None
    tamper_class: TamperClass = TamperClass.NONE
    detail: str = "chain verified from genesis to head"
    head: str = GENESIS

Step 3 — Walk the chain and localize the first break

The core loop recomputes each link from the running previous digest and compares it to the stored entry_hash, while also checking the stored prev_hash actually equals the running digest. The first record that fails either check is the localization point. Stopping at the first break is deliberate: once the chain diverges, every record after it inherits an untrustworthy predecessor, so only the first mismatch is a reliable fact.

def _classify_break(
    records: list[LedgerRecord], index: int, running_prev: str
) -> TamperClass:
    """Decide how the break at ``index`` most likely arose.

    Uses the surrounding pointers: a pointer to an unknown digest suggests a
    deletion; present-but-misordered sequence numbers suggest a reordering;
    an otherwise-consistent pointer with changed bytes suggests an edit.
    """
    broken = records[index]
    known_digests = {r.entry_hash for r in records} | {GENESIS}

    if broken.prev_hash not in known_digests:
        # Nothing present produced the digest this record chains from.
        return TamperClass.DELETION
    expected_seq = 0 if index == 0 else records[index - 1].seq + 1
    if broken.seq != expected_seq or broken.prev_hash != running_prev:
        # Pointers reference real records but in the wrong order.
        return TamperClass.REORDERING
    # Pointer chains correctly but the recomputed link disagrees: bytes changed.
    return TamperClass.IN_PLACE_EDIT


def verify_chain(records: list[LedgerRecord]) -> VerificationReport:
    """Walk from genesis, localize the first broken link, and classify it."""
    prev = GENESIS
    for index, record in enumerate(records):
        recomputed = link(prev, record.event)
        if record.prev_hash != prev or record.entry_hash != recomputed:
            return VerificationReport(
                intact=False,
                records_checked=index + 1,
                first_broken_seq=record.seq,
                tamper_class=_classify_break(records, index, prev),
                detail=f"link mismatch at seq {record.seq}",
                head=prev,
            )
        prev = record.entry_hash
    return VerificationReport(
        intact=True,
        records_checked=len(records),
        head=prev,
    )

Step 4 — Load the ledger and run verification

Loading is itself a verification step: a record that fails model_validate_json is a structural tamper — a truncated, corrupted, or hand-edited line — and is reported as such rather than crashing the walk.

def load_and_verify(path: str) -> VerificationReport:
    """Read a JSONL ledger and return a verification report."""
    records: list[LedgerRecord] = []
    with open(path, encoding="utf-8") as handle:
        for line_no, raw in enumerate(handle):
            raw = raw.strip()
            if not raw:
                continue
            try:
                records.append(LedgerRecord.model_validate_json(raw))
            except ValueError:
                return VerificationReport(
                    intact=False,
                    records_checked=line_no + 1,
                    tamper_class=TamperClass.STRUCTURAL,
                    detail=f"unparseable record at line {line_no + 1}",
                )
    return verify_chain(records)

Configuration reference

Parameter Type Default Compliance note
GENESIS str "0" * 64 Must equal the writer’s anchor; a mismatch surfaces as a break at seq 0, catching prefix truncation.
hash function callable hashlib.sha256 Must be the algorithm the ledger chained with (NIST SP 800-107 approved); a substitute makes every link mismatch.
stop-on-first-break bool True Only the first mismatch is a reliable fact; records after a break inherit an untrusted predecessor.
TamperClass enum 5 classes Distinguishes edit / deletion / reordering / structural so a report is forensically actionable, not just “invalid”.
report immutability bool frozen=True The VerificationReport is itself retained evidence; freezing prevents post-hoc edits to a finding.
canonical parity rule sorted keys, UTC The verifier must serialize identically to the writer or intact chains read as tampered.

Verification

Prove the verifier by injecting each tamper class into a known-good chain and asserting the report localizes and classifies it correctly. This test builds a three-record chain in memory, then mutates it three ways.

def _rec(seq: int, prev: str, event: AuditEvent) -> LedgerRecord:
    return LedgerRecord(
        seq=seq, prev_hash=prev, entry_hash=link(prev, event), event=event
    )


def _event(stage: str) -> AuditEvent:
    return AuditEvent(
        request_id="dsr_" + "b" * 32,
        stage=stage,
        legal_basis="art17",
        actor="svc:pipeline",
        at=datetime(2026, 7, 16, 9, 0, tzinfo=timezone.utc),
    )


def _good_chain() -> list[LedgerRecord]:
    chain: list[LedgerRecord] = []
    prev = GENESIS
    for i, stage in enumerate(("intake", "verified", "closed")):
        record = _rec(i, prev, _event(stage))
        chain.append(record)
        prev = record.entry_hash
    return chain


def test_clean_chain_is_intact() -> None:
    assert verify_chain(_good_chain()).intact is True


def test_in_place_edit_is_localized() -> None:
    chain = _good_chain()
    # Tamper record 1's event bytes without fixing downstream hashes.
    chain[1] = LedgerRecord(
        seq=1,
        prev_hash=chain[1].prev_hash,
        entry_hash=chain[1].entry_hash,  # stale hash over new bytes
        event=_event("rectification"),
    )
    report = verify_chain(chain)
    assert report.intact is False
    assert report.first_broken_seq == 1
    assert report.tamper_class is TamperClass.IN_PLACE_EDIT


def test_deletion_is_detected() -> None:
    chain = _good_chain()
    del chain[1]  # remove the middle record
    report = verify_chain(chain)
    assert report.intact is False
    assert report.tamper_class is TamperClass.DELETION

Under pytest all three assertions pass: the clean chain verifies, the edit is localized to seq 1 and classed as an in-place edit, and the deletion is detected where the survivor points at a now-absent digest.

Troubleshooting

Intact chain reported as tampered — Root cause: the verifier’s canonical serialization diverges from the writer’s — a different key order, a naive timestamp, or a different JSON separator. Fix: mirror canonical_bytes() exactly and confirm both sides normalize at to UTC; a single differing byte flips every downstream link to a mismatch.

Break reported at seq 0 on a known-good ledger — Root cause: the verifier’s GENESIS constant does not match the writer’s anchor. Fix: use the identical sixty-four-zero anchor; a mismatched genesis is indistinguishable from prefix truncation, which is exactly why the anchor must be a shared constant.

Every record after the first break is flagged — Root cause: continuing to reason about integrity past the first mismatch. Fix: stop at the first break — records after it chain from an untrusted predecessor, so only the first mismatch is a reliable fact to report to a regulator.

Reordering misclassified as an edit — Root cause: classification checking only recomputed hashes, not sequence numbers and pointer targets. Fix: consult seq continuity and whether prev_hash references a known digest, as _classify_break does, so a swap is distinguished from a byte change.

Verifier crashes on a corrupted line — Root cause: assuming every JSONL line parses. Fix: catch the ValueError from model_validate_json and return a STRUCTURAL tamper report; an unparseable line is itself evidence of tampering, not a reason to abort the audit.

Frequently Asked Questions

Why report the first broken link only, instead of every mismatch?

Once the chain breaks, every record after it chains from an untrusted predecessor, so downstream mismatches are consequences of the first break rather than independent findings. Reporting only the first divergence gives a regulator or investigator one reliable, defensible fact — this exact record is where history diverged — instead of a noisy list that overstates the damage. The first broken sequence number is the localization the non-repudiation guarantee rests on.

Can the verifier always tell a deletion from an in-place edit?

Usually, from the surrounding pointers: a deletion leaves a survivor whose stored previous-hash points at a digest no present record produced, while an in-place edit leaves the pointers consistent but changes a record’s bytes so the recomputed link disagrees. A reordering shows real pointers in the wrong sequence order. Adversarial tampering that also rewrites sequence numbers can blur these, which is why cryptographic classification is paired with write-once storage that stops the rewrite in the first place.

How does this support non-repudiation and forensic expectations?

GDPR Art. 5(2) makes the controller responsible for demonstrating compliance, which in practice means being able to show the audit record was not altered. A verifier that localizes and classifies any tampering turns the ledger into evidence a third party can independently re-check: they run the same public walk from genesis and reach the same verdict. The frozen verification report is itself a retainable artifact, so the finding cannot later be quietly revised.

Should verification run continuously or only on demand?

Both. Run it on demand whenever evidence is packaged for a regulator or an incident is investigated, and run it continuously as a scheduled sweep over the stored ledger so tampering is caught close to when it happens rather than years later. A continuous failure should raise a high-severity alert and freeze new appends to the affected chain pending review, rather than appending on top of a compromised history.