tamperlog
An append-only log whose history cannot be rewritten without the change being detectable. Entries go into a Merkle tree built exactly as RFC 6962 specifies, so anyone holding a small checkpoint from earlier can later prove the log still contains what it did before: unmodified, in the same order, with nothing removed.
No dependencies, pure Python, fully typed.
Why not just chain hashes together
Most "tamper-evident log" code stores hash(previous_hash + entry) on each row and calls it done. That catches an edited entry, but it misses the attack that actually matters: truncation. If whoever runs the log drops the last few entries, the remaining chain is still perfectly self-consistent. Re-verifying it finds nothing wrong, because the evidence you would have needed was in the part that got deleted.
The fix is not a better hash. It is having committed to the log's state at a point in time, somewhere its operator cannot quietly change. That commitment is a checkpoint: a size and a root hash, about 40 bytes. Publish it, hand it to an auditor, put it in a different system, anchor it to a blockchain, email it to yourself. Then:
log.verify_against(old_checkpoint)
either passes, or tells you the earlier entries are not intact any more. That check is what this library is for.
Install
pip install tamperlog
Use it
from tamperlog import AppendOnlyLog, JSONLStorage
log = AppendOnlyLog(JSONLStorage("audit.jsonl"))
log.append_json({"action": "wage_paid", "worker": "selvam", "amount": 500})
log.append_json({"action": "wage_paid", "worker": "kavitha", "amount": 480})
checkpoint = log.checkpoint()
print(checkpoint) # 2:9f86d081884c7d65...
Keep that checkpoint string somewhere the log's operator does not control. Later, however much the log has grown:
log = AppendOnlyLog(JSONLStorage("audit.jsonl"))
log.verify_against(checkpoint) # raises ProofError if history was rewritten
append_json encodes with sorted keys and no incidental whitespace, so the same record always hashes to the same leaf. If you have already encoded your data, use append(payload: bytes) and the bytes are stored verbatim.
Proving one entry without handing over the log
An inclusion proof lets someone confirm a single entry was in the log, without seeing any of the others. It is log₂(n) hashes.
proof = log.prove_inclusion(0)
payload = log.entry(0)
The verifier needs only the checkpoint, that entry and the proof:
from tamperlog import verify_inclusion_against
verify_inclusion_against(checkpoint, 0, payload, proof)
This is how you show a worker their own record is in the log, and that it is the same record an auditor's checkpoint covers, without exposing everyone else's wages.
From the command line
tamperlog checkpoint audit.jsonl
# 40:6f4b9a2c...
tamperlog verify audit.jsonl --checkpoint 12:a1b2c3...
# OK: 40 entries, still consistent with the checkpoint at 12 entries.
It exits non-zero when verification fails, so it drops into a cron job or a CI step. Running verify without --checkpoint only confirms each stored payload still matches its own hash, and says so — that alone cannot detect truncation.
What it detects
Given a checkpoint recorded when the log had m entries, verify_against detects all of these:
| An earlier entry edited | ✅ |
| Entries reordered | ✅ |
| An entry inserted into the middle | ✅ |
| Entries removed from the end | ✅ |
| Rolled back, then refilled to the same length | ✅ |
| A stored payload altered behind the log's back | ✅ via verify_all_entries() |
Each of those is a test in tests/test_log.py.
What it does not do
Being straight about the boundaries, because a log like this is easy to over-trust:
- It does not stop tampering, it makes tampering evident. Anyone who can write to your storage can rewrite the log. The point is that they cannot do so and still satisfy an old checkpoint.
- A checkpoint you never published proves nothing. If the only copy lives next to the log, whoever edits the log edits the checkpoint too. Its value comes entirely from being somewhere out of reach.
- Entries are not signed. The log shows that a record has not changed since it was added. It says nothing about who wrote it. Sign your payloads before appending if you need that.
- Entries are not encrypted. Payloads are stored as given.
JSONLStoragebase64-encodes them for transport safety, which is not confidentiality. - It is not a distributed ledger. There is no consensus, no replication, no gossip. One log, one writer.
- Storage is not concurrency-safe.
JSONLStorageappends with a singlewriteper entry, but two processes writing the same file are not coordinated. Serialise writes yourself.
How it works
SHA-256, with the domain separation RFC 6962 requires: leaves are hashed as SHA-256(0x00 || payload) and interior nodes as SHA-256(0x01 || left || right). Without those distinct prefixes an interior node could be presented as a leaf, which would let two different logs share a root hash.
The tree is built as the RFC defines it: for n > 1, split at the largest power of two below n, hash the two subtree roots together. An empty log's root is SHA-256("").
Proofs are generated from the RFC's recursive definitions and verified by an independent replay that walks the same recursion using only the proof. The test suite checks the two against a directly computed root for every combination of sizes up to 65 entries, so a mistake in either one shows up rather than cancelling out.
Development
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy
License
MIT.
Metadata
Release files for tamperlog 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tamperlog-0.1.0.tar.gz | 17.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tamperlog-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 33.7 kB
Release files / tamperlog-0.1.0.tar.gz
| Download URL | tamperlog-0.1.0.tar.gz |
|---|---|
| Size | 17.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a7335fe3a0177fd2da58b62ef34cac01ea685dc577d9e342387b55baaa654df3
|
|
BLAKE2b-256 checksum How to use checksums |
99960d85b91c1014bc047aad0ff9f801cefd1260e5df6adcd3c40c6c127e6b8d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.
Transparency logRelease files / tamperlog-0.1.0-py3-none-any.whl
| Download URL | tamperlog-0.1.0-py3-none-any.whl |
|---|---|
| Size | 16.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8673bb75b04a8731eb8dc369cf149ea84830682ab7e0790128b1d173b1e01aea
|
|
BLAKE2b-256 checksum How to use checksums |
473d489258901d61d6101e3daa62458424636d2883233c4739571a7d9cd71aba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.
Transparency log