← Python for Treasury Work

Lesson 5 · Intermediate · 25 min

Make a tamper-evident log with HMAC

Seal each log entry with a keyed hash chained to the one before it, so any later edit or deletion shows.

✓ Last checked: November 2026 · code run, output real, facts checked

hmachashlibjsonsecurity

In this lesson

  • What a hash and an HMAC are, in plain terms
  • Chain records so that removing one breaks the next
  • Compare secrets safely with hmac.compare_digest
  • Keep keys out of the code

A log of who did what, and when, is only useful if no one can quietly change it afterwards. Python's standard library has what you need to make a log that shows tampering: hashlib and hmac.

A hash turns any text into a fixed-length fingerprint; change one character and the fingerprint changes completely. An HMAC is a hash mixed with a secret key, so only someone who holds the key can produce a valid fingerprint. Here each entry's fingerprint, its seal, also includes the seal of the entry before it. That chain is what makes a deleted entry visible too.

The code

audit_log.pyDownload
"""A tamper-evident log: each entry is sealed with an HMAC that also covers the entry before it."""

import hashlib
import hmac
import json
import os

# In real use the key comes from outside the code, e.g. an environment variable.
SECRET_KEY = os.environ.get("AUDIT_KEY", "practice-key-only").encode()


def seal(entry: dict, previous_seal: str) -> str:
    message = previous_seal + json.dumps(entry, sort_keys=True)
    return hmac.new(SECRET_KEY, message.encode(), hashlib.sha256).hexdigest()


def add(log: list, entry: dict) -> None:
    previous = log[-1]["seal"] if log else "START"
    log.append({"entry": entry, "seal": seal(entry, previous)})


def verify(log: list) -> list:
    """Return the positions of entries whose seal no longer matches."""
    problems, previous = [], "START"
    for i, item in enumerate(log, start=1):
        if not hmac.compare_digest(item["seal"], seal(item["entry"], previous)):
            problems.append(i)
        previous = item["seal"]
    return problems


log = []
add(log, {"time": "2026-11-02 08:15", "user": "collector_a", "action": "issue OR", "or": 7650101, "amount": "4500.00"})
add(log, {"time": "2026-11-02 08:40", "user": "collector_a", "action": "issue OR", "or": 7650102, "amount": "1250.75"})
add(log, {"time": "2026-11-02 09:05", "user": "collector_a", "action": "cancel OR", "or": 7650103, "amount": "0.00"})
add(log, {"time": "2026-11-02 16:30", "user": "treasurer", "action": "deposit", "amount": "5750.75"})

for i, item in enumerate(log, start=1):
    e = item["entry"]
    print(f"{i}. {e['time']}  {e['action']:<10}{e['amount']:>9}  seal {item['seal'][:12]}...")

print("\nCheck 1:", verify(log) or "all seals match")

# Someone quietly edits the amount on the second receipt
log[1]["entry"]["amount"] = "250.75"
print("Check 2:", verify(log) or "all seals match")

# Put the amount back, then remove the cancelled receipt from the log instead
log[1]["entry"]["amount"] = "1250.75"
del log[2]
print("Check 3:", verify(log) or "all seals match")

Output

Output of audit_log.py
1. 2026-11-02 08:15  issue OR    4500.00  seal be777d3a17dd...
2. 2026-11-02 08:40  issue OR    1250.75  seal a65c817ec515...
3. 2026-11-02 09:05  cancel OR      0.00  seal 82a6eeeca1bf...
4. 2026-11-02 16:30  deposit     5750.75  seal d9e9122fef5d...

Check 1: all seals match
Check 2: [2]
Check 3: [3]

How it works

json.dumps(entry, sort_keys=True) turns the entry into text in a fixed order, so the same entry always produces the same seal.

previous_seal + ... each seal covers the one before it. Remove entry 3 and entry 4 no longer fits, which is why Check 3 points at position 3. The very last entry has nothing after it, so removing it would not show; that is one more reason to keep copies of the log elsewhere.

hmac.compare_digest compares two seals in a way that does not leak timing clues to an attacker. Use it instead of == whenever secrets are compared.

os.environ.get("AUDIT_KEY", ...) reads the key from an environment variable. The practice key in the code is only for this lesson; a real key never goes into source code or a shared folder.

Note: Tamper-evident is not tamper-proof. The log cannot stop someone from editing it, but it shows that it happened. Keep the key with someone other than the people who write the entries, and keep copies of the log elsewhere.

Try it yourself

Practice

Save the log to a JSON file with json.dump, load it back with json.load, and run verify() on the loaded copy. Then edit the file by hand and run it again.

Show answer
answer_json.pyDownload
"""Answer: save the sealed log to a JSON file, load it back, and verify it."""

import hashlib
import hmac
import json
import os

SECRET_KEY = os.environ.get("AUDIT_KEY", "practice-key-only").encode()


def seal(entry, previous_seal):
    message = previous_seal + json.dumps(entry, sort_keys=True)
    return hmac.new(SECRET_KEY, message.encode(), hashlib.sha256).hexdigest()


def add(log, entry):
    previous = log[-1]["seal"] if log else "START"
    log.append({"entry": entry, "seal": seal(entry, previous)})


def verify(log):
    problems, previous = [], "START"
    for i, item in enumerate(log, start=1):
        if not hmac.compare_digest(item["seal"], seal(item["entry"], previous)):
            problems.append(i)
        previous = item["seal"]
    return problems


log = []
add(log, {"time": "2026-11-02 08:15", "action": "issue OR", "or": 7650101, "amount": "4500.00"})
add(log, {"time": "2026-11-02 08:40", "action": "issue OR", "or": 7650102, "amount": "1250.75"})

with open("audit_log.json", "w", encoding="utf-8") as f:
    json.dump(log, f, indent=2)

with open("audit_log.json", encoding="utf-8") as f:
    loaded = json.load(f)
print("Loaded copy:", verify(loaded) or "all seals match")

# Edit the file the way a person would in a text editor: change 1250.75 to 250.75
with open("audit_log.json", encoding="utf-8") as f:
    text = f.read()
with open("audit_log.json", "w", encoding="utf-8") as f:
    f.write(text.replace('"1250.75"', '"250.75"'))

with open("audit_log.json", encoding="utf-8") as f:
    edited = json.load(f)
print("After editing the file:", verify(edited) or "all seals match")
Output of answer_json.py
Loaded copy: all seals match
After editing the file: [2]