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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue