Skip to main content

tamperlog

CI Python License

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. JSONLStorage base64-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. JSONLStorage appends with a single write per 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)

Source distribution for tamperlog 0.1.0
File Size Uploaded
tamperlog-0.1.0.tar.gz 17.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tamperlog 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page