Skip to main content

auditchain

CI

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 a verify/checkpoint CLI (exit code 1 on failure — CI friendly)
  • Zero runtime dependencies (the postgres extra adds asyncpg), 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 --checkpoint anchor or expected_count).
  • Not detectable from the chain alone, without an anchor: removal of the last records. Keep a checkpoint outside the log's trust boundary, or pass expected_count to verify().
  • 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

auditchain-0.2.0.tar.gz (26.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

auditchain-0.2.0-py3-none-any.whl (22.3 kB view details)

Uploaded Python 3

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

Hashes for auditchain-0.2.0.tar.gz
Algorithm Hash digest
SHA256 dc2dcd82f9a1b35315df68affeb9bcb0ca34cf55de96a0ecebf875df4836f5a3
MD5 d69a7ee95a31c84df320fc50432067c5
BLAKE2b-256 bb45c797377c55110990575eb9ef830d828fbdaeb1a708935d7ad7f98e7fd050

See more details on using hashes here.

Provenance

The following attestation bundles were made for auditchain-0.2.0.tar.gz:

Publisher: release.yml on mmdverse/auditchain

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for auditchain-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e8d32eb7d033bc7030b75fd10bd0b5d847fda4334f6e1071c53d450dd423622e
MD5 2f6d2db38c0649d049f5502fe33dbc4d
BLAKE2b-256 0d1b75b3b98b3fe0f5b95f07c2aab2559aec66ddf13baed4a0c4f6b651de8f4a

See more details on using hashes here.

Provenance

The following attestation bundles were made for auditchain-0.2.0-py3-none-any.whl:

Publisher: release.yml on mmdverse/auditchain

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.0

2 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