Building tamper-evident audit logs for NERC CIP

A settlement run wrote 40,000 ledger lines overnight, and this morning one charge is $180 higher than the meter data supports — the question an auditor will ask is not “what is the right number” but “can you prove nobody edited the log after the run finished.” An append-only file cannot answer that on its own; a hash-chained log can. This guide implements a linked SHA-256 audit log for settlement events, seals each batch with a Merkle root, and verifies the chain end to end. It is the cryptographic core behind NERC CIP Compliance Logging, which frames the standards and the event catalogue this log has to satisfy.

The chain works by binding every record to the one before it. Writing record (e_i) computes a hash over the previous record’s hash concatenated with the canonical bytes of (e_i):

$$h_i = \text{SHA256}(h_{i-1} \parallel c_i), \qquad c_i = \text{canonical_json}(e_i)$$

Because (h_i) depends on (h_{i-1}), editing any historical event changes its hash and every hash after it, so a single re-walk of the chain detects tampering at the exact record where it occurred. The diagram below traces that construction and the two verification paths — per-record chain replay and per-batch Merkle root — this page implements.

Hash-chained append-only audit log construction and verification A settlement event is serialized to canonical JSON, hashed together with the previous record's hash to form a linked SHA-256 chain, and appended to the store. A batch of records is periodically sealed with a Merkle root. Verification re-walks the chain to detect any altered record and recomputes the Merkle root to confirm a sealed batch is intact. Settlement eventcanonical JSON Link hashH(h_prev || c_i) Append recordseq · h_prev · h_curr Seal batchMerkle root Verify chainreplay + root check tamper breaks every later hash

Prerequisites

  • Python packages: none beyond the standard library — hashlib for SHA-256, json for canonical serialization, sqlite3 for an append-only store, and decimal for money fields. The implementation intentionally avoids third-party dependencies so it can run inside a locked-down, CIP-scoped environment where package installation is change-controlled.
  • Data dependencies: a stream of settlement security events (the CipSecurityEvent shape from the parent guide, or any dict with a deterministic serialization). Each event must carry a timezone-aware UTC timestamp and a stable set of fields, because the hash is computed over the exact serialized bytes.
  • Permissions / access: write access to the append-only store and a protected location for the genesis hash and the sealed Merkle roots. Under CIP-011 the store’s access is controlled; the verifier needs only read access, so chain verification can run from a lower-privilege compliance account than the writer.

Implementation

The design has three parts: an append-only writer that computes and stores the link hash for each record, a Merkle-root sealer that binds a batch of records into a single digest, and a verifier that re-walks the chain and recomputes the root. All money fields are carried as Decimal strings so a logged charge reconciles exactly to the ledger, and serialization is canonical (sort_keys, fixed separators) so the same event always produces the same bytes on any host.

import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from decimal import Decimal
from typing import Iterable, Optional

# Genesis hash: a fixed, well-known anchor for an empty chain. Store it in a
# protected location so a verifier can confirm the chain starts where expected.
GENESIS_HASH = "0" * 64


def canonical_bytes(event: dict) -> bytes:
    """Deterministic serialization of one event — the exact bytes that get hashed.

    Decimal is rendered as an exact string so a settlement charge is byte-stable
    across hosts; sort_keys + fixed separators remove all formatting ambiguity.
    """
    def _default(value):
        if isinstance(value, Decimal):
            return str(value)
        if isinstance(value, datetime):
            # Force UTC; a naive timestamp is unprovable and must not be logged.
            if value.tzinfo is None:
                raise ValueError(f"naive timestamp not allowed: {value!r}")
            return value.astimezone(timezone.utc).isoformat()
        raise TypeError(f"unserializable type in audit event: {type(value)!r}")

    return json.dumps(
        event, sort_keys=True, separators=(",", ":"), default=_default
    ).encode("utf-8")


def link_hash(prev_hash: str, event_bytes: bytes) -> str:
    """h_i = SHA256(h_prev || canonical(e_i)). Binds each record to its predecessor."""
    digest = hashlib.sha256()
    digest.update(prev_hash.encode("utf-8"))   # chain link to the prior record
    digest.update(event_bytes)                 # this record's canonical content
    return digest.hexdigest()


class TamperEvidentAuditLog:
    """Append-only, hash-chained audit log for settlement security events."""

    def __init__(self, db_path: str):
        self._conn = sqlite3.connect(db_path)
        self._conn.execute(
            """
            CREATE TABLE IF NOT EXISTS audit_chain (
                seq        INTEGER PRIMARY KEY AUTOINCREMENT,
                event_json TEXT    NOT NULL,
                prev_hash  TEXT    NOT NULL,
                curr_hash  TEXT    NOT NULL,
                written_at TEXT    NOT NULL
            )
            """
        )
        self._conn.commit()

    def _tail_hash(self) -> str:
        """Current chain head — the last record's hash, or the genesis anchor."""
        row = self._conn.execute(
            "SELECT curr_hash FROM audit_chain ORDER BY seq DESC LIMIT 1"
        ).fetchone()
        return row[0] if row else GENESIS_HASH

    def append(self, event: dict) -> str:
        """Append one event, returning its chain hash. This is the only writer.

        No UPDATE or DELETE path exists on audit_chain, so the store is append-only;
        the hash chain makes any out-of-band edit detectable on the next verify.
        """
        event_bytes = canonical_bytes(event)
        prev_hash = self._tail_hash()
        curr_hash = link_hash(prev_hash, event_bytes)
        self._conn.execute(
            "INSERT INTO audit_chain (event_json, prev_hash, curr_hash, written_at) "
            "VALUES (?, ?, ?, ?)",
            (event_bytes.decode("utf-8"), prev_hash, curr_hash,
             datetime.now(timezone.utc).isoformat()),
        )
        self._conn.commit()
        return curr_hash

    def records(self) -> Iterable[sqlite3.Row]:
        """Iterate the chain in write order for verification or review."""
        self._conn.row_factory = sqlite3.Row
        yield from self._conn.execute(
            "SELECT seq, event_json, prev_hash, curr_hash FROM audit_chain ORDER BY seq"
        )

Sealing a batch with a Merkle root

The linked chain proves ordering and detects any single edit, but a Merkle root gives a compact commitment to a whole batch — one 64-character digest that a review job can publish (to a ticket, a separate system, or a signed daily attestation) and later recompute to prove the batch is intact without re-shipping every record.

def merkle_root(leaf_hashes: list[str]) -> str:
    """Compute a Merkle root over the per-record chain hashes of a sealed batch.

    Odd nodes are promoted (duplicated) at each level — the standard convention.
    """
    if not leaf_hashes:
        return GENESIS_HASH
    level = list(leaf_hashes)
    while len(level) > 1:
        if len(level) % 2 == 1:
            level.append(level[-1])          # promote the lone tail node
        nxt = []
        for i in range(0, len(level), 2):
            pair = (level[i] + level[i + 1]).encode("utf-8")
            nxt.append(hashlib.sha256(pair).hexdigest())
        level = nxt
    return level[0]

Verifying the chain

Verification re-walks the chain from the genesis anchor, recomputing each link hash from the stored event bytes and the running previous hash. A mismatch pinpoints the first altered — or deleted — record. The same routine recomputes the Merkle root of a sealed batch and compares it to the published value.

def verify_chain(log: TamperEvidentAuditLog,
                 expected_root: Optional[str] = None) -> dict:
    """Re-walk the chain; return the outcome and the seq of the first break, if any.

    Runs from a read-only account — the verifier never writes, satisfying the
    separation between the CIP logging writer and the compliance reviewer.
    """
    running_hash = GENESIS_HASH
    leaf_hashes: list[str] = []
    for row in log.records():
        recomputed = link_hash(running_hash, row["event_json"].encode("utf-8"))
        if row["prev_hash"] != running_hash:
            return {"ok": False, "reason": "prev_hash mismatch", "seq": row["seq"]}
        if recomputed != row["curr_hash"]:
            # This record's content or its link was altered after it was written.
            return {"ok": False, "reason": "curr_hash mismatch", "seq": row["seq"]}
        running_hash = row["curr_hash"]
        leaf_hashes.append(row["curr_hash"])

    if expected_root is not None:
        computed_root = merkle_root(leaf_hashes)
        if computed_root != expected_root:
            return {"ok": False, "reason": "merkle_root mismatch",
                    "computed_root": computed_root}

    return {"ok": True, "records": len(leaf_hashes), "head": running_hash}

Verification

Confirm the log behaves correctly before it goes anywhere near an audit. Three checks matter:

  1. Clean chain passes. Append a handful of events, then run verify_chain. It must return {"ok": True, ...} with records equal to the number appended and a 64-character head hash.
  2. Tampering is caught at the right record. Simulate an out-of-band edit — update one event_json directly with SQL — then re-verify. The result must be {"ok": False} with seq equal to the edited record, proving the break is localized rather than merely detected.
  3. Merkle root reconciles. Seal a batch, store its root, then recompute with verify_chain(log, expected_root=root). A matching root confirms the sealed batch is byte-for-byte intact.
def demo_verification(db_path: str = ":memory:") -> None:
    log = TamperEvidentAuditLog(db_path)
    for i in range(3):
        log.append({
            "event_time": datetime.now(timezone.utc),
            "actor": "svc-settlement-batch",
            "action": "LEDGER_WRITE",
            "cip_requirement": "CIP-007-R4.1",
            "settlement_run_id": f"run-2026-07-17-{i:03d}",
            "charge_amount": Decimal("1024.55"),   # exact money, not a float
        })

    clean = verify_chain(log)
    assert clean["ok"] and clean["records"] == 3, clean

    # Tamper: overwrite one record's content out-of-band, bypassing append().
    log._conn.execute(
        "UPDATE audit_chain SET event_json = ? WHERE seq = 2",
        (json.dumps({"charge_amount": "9999.99"}),),
    )
    log._conn.commit()

    broken = verify_chain(log)
    assert not broken["ok"] and broken["seq"] == 2, broken
    print("chain verification pinpointed tampering at seq", broken["seq"])

Running demo_verification() prints the sequence of the tampered record and asserts the clean chain verified first, so the whole flow is exercised in one call.

Compliance Note

This log is the technical control behind NERC CIP-007 R4: R4.1 requires that security events be logged, R4.3 requires those events be retained for at least 90 calendar days, and R4.4 requires a review of logged events at least every 15 calendar days. The hash chain does not by itself satisfy R4 — you still have to capture the enumerated event types and staff the review — but it is what lets you prove the retained events were not altered during the retention window, which turns a pile of log lines into admissible audit evidence. Pair it with CIP-010 change monitoring so any modification to the logging code is itself a tracked baseline change, and with CIP-011 redaction so BES Cyber System Information never enters the chain in the clear. Store the genesis hash and each sealed Merkle root in a separately access-controlled location, because a tamper-evident chain whose anchor an attacker can also rewrite proves nothing. The full standards mapping, retention math, and access-log review cadence live in NERC CIP Compliance Logging, and the surrounding filing obligations in Regulatory Filing Preparation.

Frequently Asked Questions

Why hash-chain the log instead of just making the table append-only?

An append-only table prevents in-place edits through the application, but it cannot prove that nobody bypassed the application — someone with database or file access could still rewrite a past row. Chaining each record to SHA256(h_prev || c_i) makes any such edit self-evident: changing a historical event changes its hash and every hash after it, and a single re-walk from the genesis anchor pinpoints the first broken record. The chain is what converts “we did not edit it” into “here is cryptographic proof it was not edited.”

Do I need a Merkle root if I already have a linked chain?

Not strictly — the linked chain alone detects any edit. The Merkle root adds a compact, publishable commitment: one 64-character digest that summarizes an entire batch, so a daily review can attest to the batch’s state in an external system and later recompute the root to prove intactness without re-shipping every record. It is most useful when the log is large or when the attestation has to live somewhere the raw records cannot go for CIP-011 reasons.

How does keeping money as Decimal affect the hash?

The hash is computed over the exact serialized bytes, so a money field must serialize identically every time or the chain will not reconcile across hosts. Rendering a Decimal as its exact string ("1024.55") is byte-stable, whereas a binary float can serialize as 1024.5499999999... and break both the settlement reconciliation and the hash verification. Carrying charges as Decimal end to end keeps the logged figure exact and the chain reproducible.