auditchain
Tamper-evident, hash-chained audit logging for Python. Async-first, zero runtime dependencies.
Every record commits to the hash of the previous one. Anyone who edits, inserts,
removes or reorders records later breaks the chain — and verify tells you exactly
where. Built for compliance (SOC 2, ISO 27001, PCI) and for any system where "who
did what" must be provable after the fact.
How it works
record[0].hash = SHA-256(prev_hash(genesis) || payload[0])
record[1].hash = SHA-256(record[0].hash || payload[1])
...
With a seal_key, records are signed with HMAC-SHA256 instead, so a party that
does not hold the key cannot silently rewrite the log at all. Verification recomputes
the whole chain in O(n) and reports the first broken link.
Features
- Async-first API (
AuditLog) plus thin sync wrappers (SyncAuditLog) - Backends:
SqliteBackend,JsonlBackend,MemoryBackend,PostgresBackend - HMAC-SHA256 sealing with key rotation (per-record
key_id), or plain SHA-256 integrity without a key - Checkpoints: signed anchors that detect tail truncation and prove a chain's state at a point in time
- Batch appends (
append_many) — one write for many records verify()API and averify/checkpointCLI (exit code 1 on failure — CI friendly)- Zero runtime dependencies (the
postgresextra addsasyncpg), Python 3.10+, fully typed (py.typed)
Install
pip install auditchain # sqlite / jsonl / memory backends
pip install "auditchain[postgres]" # + PostgreSQL backend (asyncpg)
Quickstart (async)
import asyncio
from auditchain import AuditLog, SqliteBackend
async def main():
async with AuditLog(SqliteBackend("audit.sqlite")) as log:
await log.append("sara", "login", "admin", metadata={"ip": "10.0.0.1"})
await log.append("jawad", "payment.approve", "invoice:12", metadata={"amount": 1200})
report = await log.verify()
print(report) # OK: 2 record(s) verified
asyncio.run(main())
Sealing records (HMAC)
from auditchain import AuditLog, SqliteBackend
key = secrets.token_bytes(32)
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key)
Without a seal_key, tampering is still detected — but only by integrity; anyone who
can write the log can rewrite it and re-seal it. Use a key when attackers might have
write access. Keep the key outside the log (env var, secret manager, file).
Key rotation
Rotate the seal key and the chain records the rotation itself (the marker is sealed with the old key, so it documents the decision under the key that was in effect):
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key0, key_id="k0")
await log.append("sara", "login")
await log.rotate(new_key, "k1") # appends a "key.rotate" marker, switches key
await log.append("jawad", "logout")
report = await log.verify() # uses the keyring (retired keys) automatically
Pass retired keys explicitly (or to verify) when reopening outside the same object:
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=new_key, key_id="k1",
keyring={"k0": key0})
key_id is stored next to each record but is not part of the hashed payload, so
logs written by v0.1 (which has no key ids) still verify — and records from 0.1 that
were sealed keep working with the same key.
Checkpoints (anchors)
The chain alone cannot detect someone deleting the last records — the remaining chain still links cleanly. A checkpoint is a signed anchor ("at seq N, the chain hash was H") that you store outside the log's trust boundary and verify against later:
cp = await log.checkpoint() # anchor the current tail
save_checkpoint(cp, "anchors/audit.checkpoint") # e.g. other machine, object storage
# later, on a possibly-tampered copy:
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key)
report = await log.verify(checkpoint=load_checkpoint("anchors/audit.checkpoint", key))
# FAILED: chain ends before the checkpoint: tail truncation
Checkpoints with a seal key are signed (HMAC-SHA256), so a forged or edited checkpoint file is rejected. Without a key, the checkpoint is unsigned and only as trustworthy as the place you store it.
Sync API
from auditchain import JsonlBackend, SyncAuditLog
log = SyncAuditLog(JsonlBackend("audit.jsonl"))
log.append("sara", "login")
assert log.verify().ok
log.close()
SyncAuditLog runs its own event loop per call; use the async API from inside an
already-running loop.
Backends
| Backend | Used for |
|---|---|
SqliteBackend |
Real applications (durable, queryable) |
PostgresBackend |
Multi-service setups, shared/remote storage |
JsonlBackend |
Simple logs, git-friendly, streaming-friendly |
MemoryBackend |
Short-lived processes, tests |
PostgresBackend takes a DSN (asyncpg) and stores the same record shape; v0.1
SQLite databases are migrated in place on first open (the key_id column is added).
Verify from the CLI
# format is auto-detected from the extension
python -m auditchain verify audit.sqlite
auditchain verify audit.jsonl --seal-key-file seal.key --expected-count 1000
auditchain verify audit.sqlite --checkpoint anchors/audit.checkpoint
# write an anchor after each batch (e.g. in CI/cron)
auditchain checkpoint audit.sqlite --output anchors/audit.checkpoint --seal-key-file seal.key
Example output when the log was tampered with:
$ python -m auditchain verify audit.jsonl
FAILED at seq 1: hash mismatch: the record was modified
Exit code 0 on success, 1 when the chain is broken, 2 on usage/file errors —
so it drops straight into CI.
Security model — be honest about limits
- Detected: modification of any record, insertion, reordering, removal of middle
records, sequence gaps, unknown key ids, count mismatches (with
--expected-count), and tail truncation (with a--checkpointanchor orexpected_count). - Not detectable from the chain alone, without an anchor: removal of the last
records. Keep a
checkpointoutside the log's trust boundary, or passexpected_counttoverify(). - Single writer: one process appends at a time. Use a queue/lock for writers; the chain must be serialized.
- Without a
seal_key, records are integrity-protected, not authenticated — an attacker who can rewrite the log can re-seal it. - Key rotation only helps if you control the keyring. Store retired keys safely; losing a key means the records sealed with it fail verification.
Beyond the basics
Read the full argument — threat model, honest limits, and when to anchor digests — in Why your audit log needs a hash chain.
خلاصهٔ فارسی
auditchain یک کتابخانهٔ پایتونی برای لاگ حسابرسیِ ضدتغییر است. هر رکورد با هشِ
رکورد قبلی زنجیر میشود (و در صورت دادن seal_key با HMAC-SHA256 امضا میگردد)،
بنابراین هر تغییر بعدی — ویرایش، جابهجایی، حذف یا درج — زنجیره را میشکند و
verify دقیقاً نشان میدهد کجا. بدون وابستگی، async-first؛ بکاندهای
SQLite/JSONL/Postgres؛ چرخش کلید HMAC با keyring؛ لنگر امضاشده (checkpoint) برای
تشخیص بریدهشدن انتهای زنجیره؛ و CLI با کد خروج مناسب CI (کد ۱ یعنی زنجیره شکسته).
License
MIT — see LICENSE.
Made ❤️ by Mohammad — @llllxyz
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file auditchain-0.2.0.tar.gz.
File metadata
- Download URL: auditchain-0.2.0.tar.gz
- Upload date:
- Size: 26.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc2dcd82f9a1b35315df68affeb9bcb0ca34cf55de96a0ecebf875df4836f5a3
|
|
| MD5 |
d69a7ee95a31c84df320fc50432067c5
|
|
| BLAKE2b-256 |
bb45c797377c55110990575eb9ef830d828fbdaeb1a708935d7ad7f98e7fd050
|
Provenance
The following attestation bundles were made for auditchain-0.2.0.tar.gz:
Publisher:
release.yml on mmdverse/auditchain
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
auditchain-0.2.0.tar.gz -
Subject digest:
dc2dcd82f9a1b35315df68affeb9bcb0ca34cf55de96a0ecebf875df4836f5a3 - Sigstore transparency entry: 2654880541
- Sigstore integration time:
-
Permalink:
mmdverse/auditchain@f80f42d260648a59dc87fe86c9d9ad1198a451bb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/mmdverse
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f80f42d260648a59dc87fe86c9d9ad1198a451bb -
Trigger Event:
push
-
Statement type:
File details
Details for the file auditchain-0.2.0-py3-none-any.whl.
File metadata
- Download URL: auditchain-0.2.0-py3-none-any.whl
- Upload date:
- Size: 22.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e8d32eb7d033bc7030b75fd10bd0b5d847fda4334f6e1071c53d450dd423622e
|
|
| MD5 |
2f6d2db38c0649d049f5502fe33dbc4d
|
|
| BLAKE2b-256 |
0d1b75b3b98b3fe0f5b95f07c2aab2559aec66ddf13baed4a0c4f6b651de8f4a
|
Provenance
The following attestation bundles were made for auditchain-0.2.0-py3-none-any.whl:
Publisher:
release.yml on mmdverse/auditchain
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
auditchain-0.2.0-py3-none-any.whl -
Subject digest:
e8d32eb7d033bc7030b75fd10bd0b5d847fda4334f6e1071c53d450dd423622e - Sigstore transparency entry: 2654880587
- Sigstore integration time:
-
Permalink:
mmdverse/auditchain@f80f42d260648a59dc87fe86c9d9ad1198a451bb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/mmdverse
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f80f42d260648a59dc87fe86c9d9ad1198a451bb -
Trigger Event:
push
-
Statement type: