Lab: prove nobody read it
Build a hash-chained audit log, try to answer an auditor's question, then edit and delete entries and watch the chain object.
The audit lesson said that showing no matching event proves nothing on its own, and that negative assurance needs completeness evidence attached. This lab builds the mechanism that supplies it, in about thirty lines.
Each step appends to the same script, so every run reprints the sections before it. The expected output shown under each step is the new part; scroll to the bottom of your terminal to find it.
- 1
Build a log where each entry commits to the one before it. That single field is what turns a list into evidence.
bashmkdir -p /tmp/auditlab && cd /tmp/auditlab cat > log.py <<'EOF' import hashlib, json, copy def entry(seq, actor, action, target, prev_hash): e = {"seq": seq, "actor": actor, "action": action, "target": target, "prev": prev_hash} e["hash"] = hashlib.sha256( json.dumps(e, sort_keys=True).encode()).hexdigest()[:16] return e def build(events): log, prev = [], "genesis" for i, (actor, action, target) in enumerate(events, 1): e = entry(i, actor, action, target, prev) log.append(e); prev = e["hash"] return log def verify(log): problems, prev = [], "genesis" for i, e in enumerate(log): if e["seq"] != i + 1: problems.append(f"gap: expected seq {i+1}, found {e['seq']}") if e["prev"] != prev: problems.append(f"seq {e['seq']}: prev hash does not match") body = {k: e[k] for k in ("seq","actor","action","target","prev")} if hashlib.sha256( json.dumps(body, sort_keys=True).encode()).hexdigest()[:16] != e["hash"]: problems.append(f"seq {e['seq']}: modified after it was written") prev = e["hash"] return problems log = build([ ("alice", "read", "app/config"), ("deploy", "read", "app/config"), ("mallory", "read", "prod/db/root"), ("alice", "write", "app/config"), ]) for e in log: print(f" {e['seq']} {e['actor']:8} {e['action']:6} {e['target']:14} {e['hash']}") print("verify:", verify(log) or "intact") EOF python3 log.py - 2
A normal, intact log.
text1 alice read app/config 1f60b80d41853f19 2 deploy read app/config 9f6775b6316dd39e 3 mallory read prod/db/root 7db2cf9f81110058 4 alice write app/config 6c8ab733497f6d9c verify: intact - 3
Now be the person who wants entry three to say something else.
bashcat >> log.py <<'EOF' print("\n--- edit the entry ---") edited = copy.deepcopy(log); edited[2]["target"] = "app/config" print("verify:", verify(edited)) print("\n--- delete it instead ---") deleted = [e for e in copy.deepcopy(log) if e["seq"] != 3] print("verify:", verify(deleted)) EOF python3 log.py - 4
Both attempts are detected, and for different reasons worth distinguishing.
text--- edit the entry --- verify: ['seq 3: modified after it was written'] --- delete it instead --- verify: ['gap: expected seq 3, found 4', 'seq 4: prev hash does not match'] - 5
Now answer the auditor properly. The claim is two statements, not one.
bashcat >> log.py <<'EOF' print("\n--- the auditor's question ---") reads = [e for e in log if e["action"] == "read" and e["target"] == "prod/db/root"] print(f" matching events: {len(reads)}") print(f" log complete: {verify(log) or 'chain intact, no gaps'}") EOF python3 log.py - 6
Clean up.
bashcd /tmp && rm -rf auditlab
The two tampering attempts failed differently, and knowing which is which matters during an investigation.
- Editing an entry breaks that entry's own hash, because the hash covers its contents. You learn exactly which record was altered.
- Deleting an entry breaks two things: the sequence has a hole, and the following entry's prev no longer matches. Deletion is louder than modification, which is the opposite of what most people expect.
- Neither is prevented. A hash chain is tamper-evident, not tamper-proof, and the distinction is the whole design: you cannot stop someone with write access from changing a file, you can make it impossible to do so quietly.
This is why the answer to "prove nobody read it" is two statements joined together: no event matches, and the log is complete and unmodified for the period. The first alone is worthless, and it is the one people give.
A chain verified only by the party who could tamper with it is weaker than it looks. Real deployments anchor it somewhere the operator does not control: a periodic hash published externally, a write-once store, or a third party countersigning.
Every property this lab demonstrates is one STRIDE calls repudiation. An actor denies having done something and you cannot prove otherwise, which is a security failure with no confidentiality breach, no downtime, and nothing an intrusion detector would ever flag.
Check your understanding
3 questionsWhy does deleting an entry produce two errors rather than one?
What does a hash chain actually provide?
What is the complete answer to 'prove nobody read this secret in Q3'?