AUDIT-WP-0009-T01 — derive tamper_evidence from live attestation state
The Postgres backend returned tamper_evidence=True as a constant while docs/integrity.md permits the claim only when a live external chain-head attestation exists. The one attestation on record is 2026-08-16 and no job renews it, so audit-core was telling every sender it had a property whose precondition was unverified — the §9.6 defect it twice corrected in gate-house's doctrine, turned inward. evaluate_tamper_evidence() derives the flag from the chain report and the mounted attestation, distinguishing seven states. Absence, staleness, mismatch, an undated or unreadable attestation, a chain break, and an unwalkable chain all degrade the claim rather than leave it standing. Unreadable is treated as absent on purpose: a malformed file must not hold up a claim a missing file would drop. The freshness window is 168h against an intended daily cadence — seven cadences, so a handful of missed runs degrade the claim rather than a single one flapping it. Window and cadence are one contract in docs/integrity.md. Production /readyz will now report tamper_evidence: false until AUDIT-WP-0009-T02 schedules attestation. The claim was already false; it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185wifnLzCxjEY2MT1XbK7L Assistant: claude-code Assistant-Model: opus Assistant-Process: 713962@bnt-lap001 Assistant-Session: 2718d99d-d3ff-478f-83a2-3a30f01a02fc
This commit is contained in:
parent
95dcb78e17
commit
2f7f475e85
7 changed files with 408 additions and 12 deletions
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -35,10 +36,14 @@ from audit_core.interface import (
|
|||
from audit_core.credentials import CredentialDirectory
|
||||
from audit_core.integrity import (
|
||||
CHAIN_LOCK_KEY,
|
||||
DEFAULT_ATTESTATION_MAX_AGE_HOURS,
|
||||
GENESIS,
|
||||
ChainRow,
|
||||
TamperEvidenceState,
|
||||
attestation_from_report,
|
||||
chain_link,
|
||||
evaluate_tamper_evidence,
|
||||
load_attestation,
|
||||
verify_rows,
|
||||
)
|
||||
from audit_core.redaction import Finding
|
||||
|
|
@ -177,6 +182,9 @@ class PostgresAuditBackend:
|
|||
statement_timeout_ms: int = 30_000,
|
||||
migrate: bool = True,
|
||||
credential_dir: str | None = None,
|
||||
attestation_path: str | None = None,
|
||||
attestation_max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
|
||||
attestation_cache_seconds: float = 60.0,
|
||||
) -> None:
|
||||
# A mounted credential directory takes precedence: it is the only
|
||||
# source that can change while the process runs, which is what dynamic
|
||||
|
|
@ -202,6 +210,19 @@ class PostgresAuditBackend:
|
|||
self.recoverable_days = recoverable_days
|
||||
self.recoverable_source = recoverable_source
|
||||
self.recoverable_basis = recoverable_basis
|
||||
# The chain-head attestation lives outside this database by design
|
||||
# (docs/integrity.md): a copy restored with the table proves nothing.
|
||||
# Its path is mounted, not configured here, so a rotated attestation
|
||||
# takes effect without a restart.
|
||||
self.attestation_path = (
|
||||
attestation_path
|
||||
if attestation_path is not None
|
||||
else os.environ.get("AUDIT_CORE_ATTESTATION_PATH") or None
|
||||
)
|
||||
self.attestation_max_age_hours = float(attestation_max_age_hours)
|
||||
self._attestation_cache_seconds = float(attestation_cache_seconds)
|
||||
self._tamper_state: TamperEvidenceState | None = None
|
||||
self._tamper_state_at = 0.0
|
||||
base_kwargs = {
|
||||
"autocommit": True,
|
||||
# A stalled write must surface as unavailable rather than hold a
|
||||
|
|
@ -288,10 +309,12 @@ class PostgresAuditBackend:
|
|||
``immutable`` is True because migration 0002 installs a trigger that
|
||||
rejects UPDATE and DELETE, so no consumer credential can alter a stored
|
||||
record. It is not a claim against the database owner or a superuser,
|
||||
who can drop the trigger. ``tamper_evidence`` is True because a
|
||||
hash chain plus verify detects a rewritten payload, and a chain-head
|
||||
attestation outside this database detects a suffix rewrite that
|
||||
stays inside Postgres. It is not WORM or ``data.archive``.
|
||||
who can drop the trigger. ``tamper_evidence`` is *derived*, not
|
||||
declared: a hash chain plus verify detects a rewritten payload only
|
||||
while a live chain-head attestation outside this database also
|
||||
detects a suffix rewrite that stays inside Postgres. Absent, stale,
|
||||
or mismatched attestation degrades the claim to False — see
|
||||
:meth:`tamper_evidence_state`. It is never WORM or ``data.archive``.
|
||||
|
||||
``custody_class`` is ``operational``, not ``archive``. This store is
|
||||
durable append-only Postgres recovered through the platform
|
||||
|
|
@ -303,7 +326,7 @@ class PostgresAuditBackend:
|
|||
custody_class="operational",
|
||||
retention_days=self.retention_days,
|
||||
immutable=True,
|
||||
tamper_evidence=True,
|
||||
tamper_evidence=self.tamper_evidence_state().claimed,
|
||||
durable=True,
|
||||
recoverable_days=self.recoverable_days,
|
||||
recoverable_source=self.recoverable_source,
|
||||
|
|
@ -492,6 +515,44 @@ class PostgresAuditBackend:
|
|||
def attest_chain(self) -> dict:
|
||||
return attestation_from_report(self.verify_chain())
|
||||
|
||||
def load_current_attestation(self) -> dict | None:
|
||||
"""The mounted chain-head attestation, or None if absent/unreadable.
|
||||
|
||||
Unreadable is treated as absent on purpose: a malformed file must not
|
||||
be able to keep a claim standing that a missing file would drop.
|
||||
"""
|
||||
if not self.attestation_path:
|
||||
return None
|
||||
try:
|
||||
return load_attestation(self.attestation_path)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def tamper_evidence_state(self) -> TamperEvidenceState:
|
||||
"""Evaluate the tamper-evidence preconditions against live state.
|
||||
|
||||
Cached briefly because ``/readyz`` reads it on every probe and the
|
||||
evaluation walks the chain. The cache only ever delays a *change* of
|
||||
state; it cannot manufacture one.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cached = self._tamper_state
|
||||
if cached is not None and now - self._tamper_state_at < self._attestation_cache_seconds:
|
||||
return cached
|
||||
attestation = self.load_current_attestation()
|
||||
try:
|
||||
report = self.verify_chain(attestation)
|
||||
except Exception: # backend unavailable, and so is the claim
|
||||
report = None
|
||||
state = evaluate_tamper_evidence(
|
||||
report,
|
||||
attestation,
|
||||
max_age_hours=self.attestation_max_age_hours,
|
||||
)
|
||||
self._tamper_state = state
|
||||
self._tamper_state_at = now
|
||||
return state
|
||||
|
||||
def health(self) -> None:
|
||||
self._query("SELECT 1", ())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue