Metadata-Version: 2.4
Name: tamperlog
Version: 0.1.0
Summary: An append-only log whose history cannot be rewritten unnoticed, using RFC 6962 Merkle proofs.
Project-URL: Homepage, https://github.com/ArockiaRajamanickam/tamperlog
Project-URL: Issues, https://github.com/ArockiaRajamanickam/tamperlog/issues
Author-email: Arockia Rajamanickam <arockia.lyx@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Arockia Rajamanickam
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: append-only,audit-log,integrity,merkle,merkle-tree,rfc6962,tamper-evident,transparency-log
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Logging
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# tamperlog

[![CI](https://github.com/ArockiaRajamanickam/tamperlog/actions/workflows/ci.yml/badge.svg)](https://github.com/ArockiaRajamanickam/tamperlog/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/tamperlog/)
[![License](https://img.shields.io/badge/license-MIT-green)](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](https://datatracker.ietf.org/doc/html/rfc6962) 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:

```python
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

```bash
pip install tamperlog
```

## Use it

```python
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:

```python
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.

```python
proof = log.prove_inclusion(0)
payload = log.entry(0)
```

The verifier needs only the checkpoint, that entry and the proof:

```python
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

```bash
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`](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

```bash
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy
```

## License

MIT.
