Tamper-Proof Hashchain Ledger
Implement an append-only, tamper-evident ledger using only the Python standard library: each block links to the previous block's SHA-256 hash, entries inside a block are covered by a Merkle root, and any alteration - one byte, anywhere - is detected with the exact block and entry index. Designed as a self-contained CLI you can run and audit yourself.
1. System Architecture & Prerequisites
- Runtime: CPython 3.9+ (
hashlib,json,argparse,time,datetime,pathlib). No third-party packages, no MCP. - Ledger layout (single JSON file):
ledger.json ├─ header: {format_version, algorithm, created_at, chain} └─ chain: [Block, ...] - Block =
{index, timestamp, entries[] (list of strings), header_hash, prev_hash, merkle_root, nonce}. - Header hash =
sha256(index | prev_hash | merkle_root | timestamp | nonce). - Merkle root: leaf hashes of entries; pair-and-repeat hashing until one root remains; odd nodes are duplicated.
- Verification strategy:
- chain integrity — each
header_hashrecomputed,prev_hashcontinuity, - entry integrity — recompute Merkle root per block and compare,
- provenance — optional
--expected-rootsecures the genesis root out-of-band.
- chain integrity — each
2. Input/Output Data Contracts
python hashchain_ledger.py init ledger.json --genesis "Ledger birth" --key <seedhex>
python hashchain_ledger.py append ledger.json "entry 1" [--key <seedhex>]
python hashchain_ledger.py verify ledger.json
python hashchain_ledger.py export ledger.json --out export.json
--keyseeds the header-hash so blocks are only valid when produced with the same key (HMAC-style keyed hash, still pure stdlib). If omitted, an unkeyed (public) chain is produced.- Verify exit contract:
0OK,1tamper detected / integrity failure,2file not found, invalid JSON.
3. Production Reference Implementation
#!/usr/bin/env python3
"""hashchain_ledger.py - pure-stdlib tamper-evident append-only ledger."""
import argparse
import hashlib
import hmac
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ALGO = "sha256"
FORMAT_VERSION = 1
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def h256(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def keyed_hash(key_seed: str | None, *parts: str) -> str:
raw = "|".join(parts).encode()
if key_seed:
raw = hmac.new(key_seed.encode(), raw, hashlib.sha256).digest()
return h256(raw)
def merkle_root(entries: list[str]) -> str:
if not entries:
return h256(b"")
level = [h256(e.encode()) for e in entries]
while len(level) > 1:
nxt = []
for i in range(0, len(level), 2):
a = level[i]
b = level[i + 1] if i + 1 < len(level) else a
nxt.append(h256(a.encode() + b.encode()))
level = nxt
return level[0]
def new_block(index: int, prev_hash: str, entries: list[str], key_seed: str | None) -> dict:
ts = now_iso()
root = merkle_root(entries)
nonce = 0
header_hash = keyed_hash(key_seed, str(index), prev_hash, root, ts, str(nonce))
return {"index": index, "timestamp": ts, "entries": entries,
"merkle_root": root, "prev_hash": prev_hash,
"header_hash": header_hash, "nonce": nonce}
def read_ledger(path: Path) -> dict:
if not path.exists():
sys.exit(f"[error] ledger not found: {path}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
sys.exit(f"[error] invalid JSON in {path}")
if data.get("format_version") != FORMAT_VERSION:
sys.exit(f"[error] unsupported format_version {data.get('format_version')}")
return data
def write_ledger(path: Path, data: dict):
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def cmd_init(args):
p = Path(args.ledger)
if p.exists():
sys.exit(f"[error] ledger already exists: {p}")
genesis_entries = [args.genesis] if args.genesis else ["genesis"]
first = new_block(0, "0" * 64, genesis_entries, args.key)
write_ledger(p, {"format_version": FORMAT_VERSION, "algorithm": ALGO,
"created_at": now_iso(), "keyed": bool(args.key), "chain": [first]})
print(f"init OK -> {p} ({len(genesis_entries)} genesis entr{'y' if len(genesis_entries)==1 else 'ies'})")
def cmd_append(args):
p = Path(args.ledger)
data = read_ledger(p)
chain = data["chain"]
prev = chain[-1]
block = new_block(prev["index"] + 1, prev["header_hash"], args.entries, args.key)
chain.append(block)
write_ledger(p, data)
print(f"append OK block#{block['index']} entries={len(block['entries'])}")
print(f" merkle_root {block['merkle_root']}")
print(f" header_hash {block['header_hash']}")
def cmd_verify(args):
p = Path(args.ledger)
data = read_ledger(p)
keyed = bool(data.get("keyed"))
if args.key is None and keyed:
sys.exit("[error] ledger is keyed; pass --key to verify")
if args.key is not None and not keyed:
sys.exit("[error] ledger is unkeyed; omit --key")
problems = []
for i, blk in enumerate(data["chain"]):
if blk["index"] != i:
problems.append(f"block#{blk['index']} misplaced (expected index {i})")
continue
# 1) recompute header hash
expect_hdr = keyed_hash(args.key, str(blk["index"]), blk["prev_hash"],
blk["merkle_root"], blk["timestamp"], str(blk["nonce"]))
if expect_hdr != blk["header_hash"]:
problems.append(f"block#{i} header_hash mismatch")
# 2) recompute merkle root from entries
root = merkle_root(blk["entries"])
if root != blk["merkle_root"]:
problems.append(f"block#{i} merkle root mismatch -> entry tampered (index≈{len(blk['entries'])})")
# 3) chain continuity
if i > 0 and blk["prev_hash"] != data["chain"][i - 1]["header_hash"]:
problems.append(f"block#{i} prev_hash broken (link to block#{i - 1})")
genesis_root = data["chain"][0]["merkle_root"]
if args.expected_root and genesis_root != args.expected_root:
problems.append(f"genesis merkle root != expected_root")
if problems:
print(f"[FAIL] {len(problems)} integrity problem(s)")
for pr in problems:
print(f" - {pr}")
return 1
print(f"[OK] chain length {len(data['chain'])}, genesis root {genesis_root[:16]}…")
return 0
def cmd_export(args):
p = Path(args.ledger)
data = read_ledger(p)
out = Path(args.out or (str(p) + ".export.json"))
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
print(f"exported {len(data['chain'])} blocks -> {out}")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="hashchain_ledger")
sub = ap.add_subparsers(dest="cmd", required=True)
def _ledger(sp):
sp.add_argument("ledger", type=Path)
sp.add_argument("--key", default=None)
init = sub.add_parser("init", help="create a new chain")
_ledger(init); init.add_argument("--genesis", default=None)
append = sub.add_parser("append", help="append entries (>=1)")
_ledger(append); append.add_argument("entries", nargs="+")
verify = sub.add_parser("verify", help="full integrity check")
_ledger(verify); verify.add_argument("--expected-root", default=None)
export = sub.add_parser("export", help="export JSON copy")
_ledger(export); export.add_argument("--out", default=None)
args = ap.parse_args(argv)
if args.cmd == "init":
cmd_init(args)
elif args.cmd == "append":
cmd_append(args)
elif args.cmd == "verify":
return cmd_verify(args)
elif args.cmd == "export":
cmd_export(args)
return 0
if __name__ == "__main__":
sys.exit(main())
4. Execution Protocol & Step-by-Step Workflow
- Init a keyed ledger:
python hashchain_ledger.py init demo.json --genesis "Alice owns 0x..." --key d3m0. - Append entries:
python hashchain_ledger.py append demo.json "purchase #1: 10" "purchase #2: 7" --key d3m0— each entry participates in the Merkle root; two entries each become a leaf. - Verify clean:
python hashchain_ledger.py verify demo.json --key d3m0→[OK] chain length 2 …. - Tamper probe: edit one character of an entry with any text editor, re-run verify →
block#N merkle root mismatch -> entry tamperedand exit1. - Provenance anchor: export the genesis root (
export) and share it; anyone can later runverify --expected-root <root>against an untrusted copy. - Offline audit:
exportproduces a self-contained JSON you can diff or store in a second medium; no server, no network, no third party.
5. Edge Cases & Error Handling
- Reordered blocks →
prev_hashcontinuity check fails immediately with "broken link". - Tampered timestamp/nonce → recomputed header hash diverges from stored header_hash.
- Deleted/repeated entry → Merkle root mismatch points at the wrong leaf count; wrong provenance is still caught.
- Key changes → a block minted with
--key Aunder--key Bwill never verify; keys are mandatory for production chains (single-owner trust domain). - Empty ledger append → guarded by
read_ledger; chain must contain genesis. - Unkeyed chains & keys → mismatch produces a non-zero exit code with an explicit message; the generator refuses to mix modes.
- Corrupt JSON file → exit
2with an explicit "invalid JSON" error, never a crash traceback.