Implement AUDIT-WP-0007 hash-chain integrity.

Accept now extends a single-schema chain. Verify walks it; a rewritten
payload_hash is a break. Tamper evidence is that detector plus an
external chain-head attestation, not WORM.
This commit is contained in:
tegwick 2026-08-16 01:18:30 +02:00
parent 5faede18fc
commit 5fd04e2095
17 changed files with 696 additions and 29 deletions

View file

@ -22,6 +22,13 @@ from audit_core.interface import (
RetentionPolicy,
validate_event,
)
from audit_core.integrity import (
GENESIS,
ChainRow,
attestation_from_report,
chain_link,
verify_rows,
)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
@ -30,7 +37,9 @@ CREATE TABLE IF NOT EXISTS events (
accepted_at TEXT NOT NULL,
correlation_id TEXT,
tenant TEXT NOT NULL,
record TEXT NOT NULL
record TEXT NOT NULL,
chain_hash TEXT,
chain_prev TEXT
);
CREATE INDEX IF NOT EXISTS events_correlation_idx ON events (correlation_id);
CREATE INDEX IF NOT EXISTS events_tenant_idx ON events (tenant);
@ -87,6 +96,7 @@ class SQLiteAuditBackend:
self._local = threading.local()
with self._connect_raw() as setup:
setup.executescript(_SCHEMA)
self._ensure_chain_columns(setup)
def _connect_raw(self) -> sqlite3.Connection:
try:
@ -136,11 +146,18 @@ class SQLiteAuditBackend:
raise BackendUnavailableError(str(exc)) from exc
try:
head = db.execute(
"SELECT chain_hash FROM events "
"ORDER BY accepted_at DESC, event_id DESC LIMIT 1"
).fetchone()
previous = head[0] if head and head[0] else GENESIS
link = chain_link(previous, payload_hash, event.event_id)
inserted = db.execute(
"""
INSERT INTO events
(event_id, payload_hash, accepted_at, correlation_id, tenant, record)
VALUES (?, ?, ?, ?, ?, ?)
(event_id, payload_hash, accepted_at, correlation_id, tenant, record,
chain_hash, chain_prev)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
RETURNING event_id
""",
@ -151,6 +168,8 @@ class SQLiteAuditBackend:
str(details.get("correlation_id") or "") or None,
event.tenant,
json.dumps(event.as_record(), sort_keys=True),
link,
previous,
),
).fetchone()
existing = None
@ -305,6 +324,54 @@ class SQLiteAuditBackend:
except sqlite3.Error as exc:
raise BackendUnavailableError(str(exc)) from exc
def verify_chain(self, attestation: dict | None = None):
rows = self._query(
"SELECT event_id, payload_hash, chain_hash, chain_prev, accepted_at "
"FROM events ORDER BY accepted_at, event_id",
(),
)
return verify_rows(
[
ChainRow(
event_id=r[0],
payload_hash=r[1],
chain_hash=r[2] or "",
chain_prev=r[3] or "",
accepted_at=r[4],
)
for r in rows
],
attestation=attestation,
)
def attest_chain(self) -> dict:
return attestation_from_report(self.verify_chain())
def _ensure_chain_columns(self, db: sqlite3.Connection) -> None:
cols = {row[1] for row in db.execute("PRAGMA table_info(events)")}
if "chain_hash" not in cols:
db.execute("ALTER TABLE events ADD COLUMN chain_hash TEXT")
if "chain_prev" not in cols:
db.execute("ALTER TABLE events ADD COLUMN chain_prev TEXT")
missing = db.execute(
"SELECT event_id, payload_hash FROM events "
"WHERE chain_hash IS NULL OR chain_prev IS NULL "
"ORDER BY accepted_at, event_id"
).fetchall()
if not missing:
return
prev = GENESIS
# Recompute the whole chain so a partial backfill cannot fork.
for event_id, payload_hash in db.execute(
"SELECT event_id, payload_hash FROM events ORDER BY accepted_at, event_id"
):
link = chain_link(prev, payload_hash, event_id)
db.execute(
"UPDATE events SET chain_prev = ?, chain_hash = ? WHERE event_id = ?",
(prev, link, event_id),
)
prev = link
def health(self) -> None:
"""Raise :class:`BackendUnavailableError` if the store is unusable."""
try: