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:
parent
5faede18fc
commit
5fd04e2095
17 changed files with 696 additions and 29 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue