Evidence integrity and signing

When an auditor pulls your RBAC drift history six months after a run, the only question that matters is whether the record set in front of them is the record set your pipeline actually produced — or something edited afterward to look clean. This section covers the three cryptographic layers that make that question answerable: content hashing so any single record is self-verifying, hash-chaining so the sequence of records cannot be re-ordered or silently truncated, and cryptographic signatures so provenance is bound to a key an auditor can check without trusting your storage.

The failure scenario if you skip this is quiet but fatal to a compliance narrative. A drift log stored as ordinary rows in a table — or as JSON files in a bucket — is trivially mutable by anyone with write access, including the very operator whose access the log is supposed to police. “The database says no unauthorized grants occurred” is worth nothing if the database row could have been updated after the fact. Integrity engineering converts your evidence from an assertion you make into a record an auditor can independently verify, which is the difference between a passed control and a finding.

Content hash, chain link, and signature — the three integrity layers and their verification gate A run's canonical manifest bytes are hashed with SHA-256 to a content hash. That hash is embedded into an append-only chain entry that also carries the prior entry's hash, so re-ordering or truncation breaks the link. The chain head is signed with an Ed25519 detached signature. An auditor recomputes every hash, checks every link, and verifies the signature against the trusted public key, producing either a pass or fail verdict. Run N manifest bytes canonical JSON · sorted keys · stable separators Layer 1 · content hash h_N = SHA-256(bytes) — any edit changes h_N Layer 2 · chain link entry embeds prev hash h_(N-1) — order is fixed Layer 3 · signature Ed25519 detached sig over the chain head Auditor verify recompute hashes · check links · verify sig PASS record set intact FAIL altered or truncated
Figure 1 — Three independent layers: hashing makes one record self-verifying, chaining fixes the record order, and signing binds provenance to a key. The auditor's verify pass is the only thing that has to be trusted.

Prerequisites and scope

This section assumes you already emit a stable evidence record per drift run — the normalized diff, the applied weights, and the routing decision — from a stage upstream of storage. If you do not yet produce those records deterministically, start with the evidence shapes in audit report artifact formats first, because integrity guarantees are only as good as the canonicalization underneath them: two byte-equal serializations of the same logical record must hash identically, every time.

Concretely you need Python 3.11+, the standard-library hashlib (no dependency) for hashing and chaining, and the cryptography library version 42 or newer for Ed25519 (cryptography.hazmat.primitives.asymmetric.ed25519). Record models are pydantic v2. Storage is deliberately out of scope for the crypto layer — the guarantees hold whether the log lands in PostgreSQL, S3 with object lock, or an append-only file — but the write path must be genuinely append-only, which is where this section meets audit trail retention and query.

Core implementation walkthrough

The three layers compose bottom-up: canonicalize, then hash, then chain, then sign. Each step below is independently runnable.

Step 1 — Canonicalize, then hash each record

A content hash is only meaningful if the bytes fed to it are canonical. Serialize with sorted keys and fixed separators so logically identical records always produce identical bytes, then take the SHA-256 hex digest.

import hashlib
import json
from pydantic import BaseModel

class DriftRecord(BaseModel):
    run_id: str
    environment: str
    principal: str
    privilege: str
    object_scope: str
    decision: str          # "alert" | "ledger" | "log"

def canonical_bytes(model: BaseModel) -> bytes:
    """Deterministic bytes: sorted keys, no incidental whitespace."""
    payload = model.model_dump(mode="json")
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")

def content_hash(model: BaseModel) -> str:
    return hashlib.sha256(canonical_bytes(model)).hexdigest()

Verify that canonicalization is order-insensitive — the same logical record hashes identically regardless of field construction order:

a = DriftRecord(run_id="r1", environment="prod", principal="svc",
                privilege="SELECT", object_scope="cust.cards", decision="alert")
b = DriftRecord(decision="alert", object_scope="cust.cards", privilege="SELECT",
                principal="svc", environment="prod", run_id="r1")
assert content_hash(a) == content_hash(b)   # canonical form is identical

Step 2 — Chain the records so order is tamper-evident

A per-record hash proves a single record is intact but says nothing about whether records were re-ordered or deleted. Bind each entry to its predecessor by embedding the prior entry’s hash; the first entry links to a fixed genesis hash. Now any deletion, insertion, or re-order breaks the chain at a detectable position. The full build-and-verify walkthrough lives in hash-chaining drift audit logs for tamper evidence; the essential shape is:

GENESIS = "0" * 64

def link(seq: int, prev_hash: str, record: DriftRecord) -> dict:
    body = {"seq": seq, "prev_hash": prev_hash, "record": record.model_dump(mode="json")}
    entry_hash = hashlib.sha256(
        json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    return {**body, "entry_hash": entry_hash}

chain: list[dict] = []
prev = GENESIS
for i, rec in enumerate([a, b]):
    entry = link(i, prev, rec)
    chain.append(entry)
    prev = entry["entry_hash"]

For large runs, chaining every record individually is fine, but if you need to prove membership of one record in a batch without releasing the whole batch, hash the records into a Merkle tree and chain only the root — same link discipline, cheaper inclusion proofs.

Step 3 — Sign the chain head with Ed25519

Hashing and chaining protect integrity but not provenance: anyone who can rewrite the whole log can also recompute every hash and produce a self-consistent forgery. A signature closes that gap by binding the chain head to a private key the forger does not hold. Sign the final entry hash; publish a detached signature and the public key. The end-to-end key handling and ingest verification are covered in signing compliance manifests with Ed25519.

from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey, Ed25519PublicKey,
)
from cryptography.exceptions import InvalidSignature

signing_key = Ed25519PrivateKey.generate()          # persist the private key securely
head = chain[-1]["entry_hash"].encode("utf-8")
signature = signing_key.sign(head)                  # 64-byte detached signature

# Distribute the public key; an auditor verifies without trusting your storage.
pub: Ed25519PublicKey = signing_key.public_key()
try:
    pub.verify(signature, head)                     # returns None on success
    verdict = "PASS"
except InvalidSignature:
    verdict = "FAIL"
assert verdict == "PASS"

The same signing discipline is what routes an approved exception past the drift alarm without a hole in the audit trail — see cryptographic manifest signing for access exceptions.

Idempotency and safety contract

Integrity operations must be pure functions of their inputs. Re-hashing an unchanged record yields the same digest, so re-running the hash or chain-build pass over an existing log is a no-op that either reproduces identical entry hashes or surfaces a mismatch — never a silent rewrite. Treat verification as strictly read-only: it recomputes and compares, and it must never “repair” a broken chain, because a repair is indistinguishable from a cover-up. If verification fails, the correct action is to freeze the log, alert, and investigate, not to re-sign.

Two hard rules keep the contract sound. First, the private signing key never touches the same trust boundary as the log storage; if an attacker who can edit records can also sign, the signature proves nothing. Second, canonicalization is frozen — changing serialization rules (key order, number formatting, timezone encoding) after records are signed silently invalidates every prior signature, so version the canonicalization scheme and record its version inside each entry.

Compliance alignment

The three layers map cleanly onto the audit-integrity language that assessors already use. Content hashing and chaining satisfy the “protect audit information from unauthorized modification” intent of NIST SP 800-53 AU-9 and the audit-log integrity expectations behind SOC 2 CC7.x; the Ed25519 signature adds the non-repudiation and provenance that PCI-DSS Requirement 10.5 asks for when it requires audit trails be protected so they “cannot be altered.” The evidence artifact this section produces is compact and portable: for each run, the canonical record bytes, its chain entry (seq, prev_hash, entry_hash), and a detached signature over the chain head, plus the published public key. An auditor needs nothing from you but those files and the key to reach an independent PASS or FAIL — which is exactly the property that turns a monitored control into an attestable one under the criteria mapped in continuous control monitoring.

Troubleshooting matrix

Failure scenario Root cause Remediation
Same logical record hashes differently on re-run Non-canonical serialization — key order, float formatting, or whitespace varies Serialize through one canonical_bytes helper with sort_keys=True and fixed separators; never hash a framework’s default repr
Signature verifies locally but fails at the auditor Signed the human-readable digest string in one place and the raw bytes in another, or line-ending drift on the signed file Sign and verify the exact same byte object; pin the encoding and never round-trip the signed head through a text editor
Chain verification fails at entry k after a legitimate correction Someone edited a historical record in place instead of appending a correcting entry Never mutate a signed entry; append a new record that supersedes it and let the chain stay intact
InvalidSignature for every entry after a library upgrade Canonicalization scheme changed, invalidating all prior signatures Version the canonicalization rules inside each entry; verify each entry with the scheme version it was written under
Signature valid but provenance disputed Private key was reachable from the same role that writes the log Move signing behind a separate trust boundary (KMS/HSM or an isolated signer service) so log-writers cannot sign

Up: Compliance Evidence and Audit Reporting