Chain Verification¶
Provena maintains a SHA-256 hash chain across all audit records, creating a tamper-evident log. If a record's hashed fields are modified, or a record is deleted or reordered, the chain breaks and verification fails. This gives you tamper evidence for the recorded content digest, source, timestamp, and chain position. Because Provena does not store raw context, chain verification cannot independently recompute a content digest without the original input.
Chain verification does not cover every column on a record. The hash
payload is only previous_hash, content_hash, source, and timestamp
(see below). Governance columns outside that payload -- provenance_status,
freshness_status, source_name, metadata_json, config_hash,
content_type, truncated, and content_hash_version -- can be modified
without breaking the chain. Chain verification proves a record's identity
and ordering; it does not, on its own, prove every governance annotation
attached to it is unchanged.
How the Hash Chain Works¶
Each record's chain hash is computed from four inputs:
- The previous record's chain hash (linking it to its predecessor)
- The content hash (SHA-256 of the raw context bytes)
- The source type string
- The timestamp in ISO format
New entries hash the full original content bytes, even when the input exceeds
max_content_bytes; truncated records whether the threshold was exceeded.
Provena stores the digest rather than the raw input, so verify_chain() checks
the recorded digest's place in the chain but cannot independently recompute it
without the original content. Existing records keep their historical hashes.
content_hash_version identifies the rule: version 1 hashes the historical
truncated bytes, and version 2 hashes the full input. Version 2 is introduced
by the first release containing this change; it is currently listed as
Unreleased, and the latest published version is v1.3.1. Find legacy
oversize entries where content_hash_version = 1 and truncated is true
(truncated = 1 in SQLite; truncated IS TRUE in PostgreSQL).
The computation follows a Merkle-style pattern:
The very first record uses a deterministic genesis hash as its predecessor:
This anchors the chain to a known starting point.
Chain integrity on SQLite assumes one writer: one ContextTrail (one
process) per database file. Concurrent writers on one .db file can fork
the chain at write time. verify_chain() does not prevent the fork: it
walks records in linear ID order and reports a break after the conflicting
appends are already stored. Keep one ContextTrail per SQLite file, or use
PostgreSQL when several workers append to the same trail.
Verifying Chain Integrity¶
Call trail.verify_chain() to recompute every chain hash from the genesis
forward and compare against stored values:
from provena import ContextTrail
trail = ContextTrail(backend="memory")
# Log some context
trail.log(content="First entry", source="retriever")
trail.log(content="Second entry", source="tool:api")
trail.log(content="Third entry", source="agent:planner")
# Verify the entire chain
verdict = trail.verify_chain()
print(verdict.intact) # True
print(verdict.total_records) # 3
print(verdict.broken_at) # None
print(verdict.details) # "Chain intact"
The ChainVerdict dataclass returned by verify_chain() contains:
| Field | Type | Description |
|---|---|---|
intact |
bool |
True if every link in the chain is valid |
total_records |
int |
Number of records that were checked |
broken_at |
int | None |
Record ID where the chain first broke, or None |
details |
str |
Human-readable summary of the verification |
HMAC Signing¶
For stronger integrity guarantees, enable HMAC-SHA256 signing. When a signing key is provided, each chain hash is computed using HMAC instead of plain SHA-256. This means an attacker cannot forge valid chain hashes without the key.
Using the signing_key parameter¶
trail = ContextTrail(
backend="memory",
signing_key="my-secret-governance-key",
)
trail.log(content="Signed entry", source="retriever")
verdict = trail.verify_chain()
print(verdict.intact) # True
print(trail.is_signed) # True
Using the PROVENA_SIGNING_KEY environment variable¶
import os
os.environ["PROVENA_SIGNING_KEY"] = "my-secret-governance-key"
trail = ContextTrail(backend="memory")
print(trail.is_signed) # True
The environment variable is checked when no signing_key parameter is passed.
The parameter takes precedence over the environment variable.
Key management
Store the signing key in a secrets manager or environment variable. Do not hard-code it in application source. Rotate keys by creating a new trail with the new key -- existing trails remain verifiable with their original key.
Tamper Detection Example¶
Here is what happens when stored data is modified outside of Provena:
from provena import ContextTrail
trail = ContextTrail(backend="memory")
trail.log(content="Original entry one", source="retriever")
trail.log(content="Original entry two", source="retriever")
trail.log(content="Original entry three", source="retriever")
# Verify before tampering
verdict = trail.verify_chain()
assert verdict.intact is True
# Simulate tampering: modify the content_hash of the second record
records = trail._backend._records # Access internal storage for demo
records[1]["content_hash"] = "tampered_hash_value"
# Verify after tampering
verdict = trail.verify_chain()
print(verdict.intact) # False
print(verdict.broken_at) # 2
print(verdict.details) # "Chain broken at record 2"
The chain breaks at the tampered record because the recomputed hash no longer matches the stored value. Every record after the tampered one would also fail if checked individually, since each depends on its predecessor.
CLI Verification¶
Verify a trail database from the command line:
# Verify with default database path
provena verify
# Verify a specific database
provena --db audit.db verify
# Verify with a signing key
provena --db audit.db --signing-key "my-secret-key" verify
# Using environment variables
export PROVENA_DB=audit.db
export PROVENA_SIGNING_KEY="my-secret-key"
provena verify
Output on success:
Output on failure:
The CLI exits with code 0 on success and code 1 on failure, making it suitable for CI/CD pipelines and automated compliance checks.
Performance¶
Chain verification is designed to be fast:
- Hashing: SHA-256 computation adds sub-millisecond overhead per record
- HMAC signing: HMAC-SHA256 adds negligible additional cost over plain SHA-256
- Verification: Full chain verification reads all records sequentially; for a trail with 10,000 records, expect verification to complete in under one second
- Logging: Each
trail.log()call performs one hash computation and one database write -- the chain does not require re-reading previous records because the previous hash is cached in memory
Verification in CI/CD
Add provena --db $TRAIL_DB verify as a step in your deployment pipeline.
This ensures that the governance trail has not been corrupted before
promoting a build. The non-zero exit code on failure integrates naturally
with CI systems.