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:
tegwick 2026-09-06 20:34:22 +02:00
parent 95dcb78e17
commit 2f7f475e85
7 changed files with 408 additions and 12 deletions

View file

@ -14,6 +14,10 @@ from pathlib import Path
from typing import Any, Iterable, Mapping
SCHEMA = "audit-core.chain-head.v1"
# How old a chain-head attestation may be before the tamper-evidence claim
# degrades. Declared in ``docs/integrity.md`` alongside the attestation
# cadence; the two are one contract and must move together.
DEFAULT_ATTESTATION_MAX_AGE_HOURS = 168.0
GENESIS = "0" * 64
# Documented advisory-lock key so concurrent accepts cannot fork the head.
CHAIN_LOCK_KEY = 0xA0D17007
@ -122,6 +126,106 @@ def _apply_attestation(
)
@dataclass(frozen=True)
class TamperEvidenceState:
"""Whether the ``tamper_evidence`` claim is currently earned.
``docs/integrity.md`` permits the claim only while both preconditions are
live. Absence or staleness degrades it it does not leave it standing.
"""
claimed: bool
reason: str
observed_at: str | None = None
age_seconds: float | None = None
max_age_seconds: float | None = None
def as_dict(self) -> dict[str, Any]:
return {
"tamper_evidence": self.claimed,
"reason": self.reason,
"attestation_observed_at": self.observed_at,
"attestation_age_seconds": self.age_seconds,
"attestation_max_age_seconds": self.max_age_seconds,
}
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def evaluate_tamper_evidence(
report: ChainReport | None,
attestation: Mapping[str, Any] | None,
*,
max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
now: datetime | None = None,
) -> TamperEvidenceState:
"""Derive the tamper-evidence claim from live state.
``report`` must be the chain report produced *against* ``attestation``, so
that ``attestation_match`` reflects the cited head. ``report is None``
means the chain could not be walked at all, which is not a licence to keep
claiming.
"""
max_age_seconds = float(max_age_hours) * 3600.0
if report is None:
return TamperEvidenceState(
False, "chain_unreadable", max_age_seconds=max_age_seconds
)
if not report.intact:
return TamperEvidenceState(
False, "chain_break", max_age_seconds=max_age_seconds
)
if attestation is None:
return TamperEvidenceState(
False, "no_attestation", max_age_seconds=max_age_seconds
)
observed_at = str(attestation.get("observed_at") or "") or None
if report.attestation_match is not True:
return TamperEvidenceState(
False,
"attestation_mismatch",
observed_at=observed_at,
max_age_seconds=max_age_seconds,
)
stamped = _parse_iso(observed_at)
if stamped is None:
return TamperEvidenceState(
False,
"attestation_undated",
observed_at=observed_at,
max_age_seconds=max_age_seconds,
)
reference = now or datetime.now(timezone.utc)
if reference.tzinfo is None:
reference = reference.replace(tzinfo=timezone.utc)
age_seconds = (reference - stamped).total_seconds()
if age_seconds > max_age_seconds:
return TamperEvidenceState(
False,
"attestation_stale",
observed_at=observed_at,
age_seconds=age_seconds,
max_age_seconds=max_age_seconds,
)
return TamperEvidenceState(
True,
"attested",
observed_at=observed_at,
age_seconds=age_seconds,
max_age_seconds=max_age_seconds,
)
def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]:
return {
"schema": SCHEMA,

View file

@ -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", ())