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
253 lines
7.9 KiB
Python
253 lines
7.9 KiB
Python
"""Hash-chain integrity for operational custody (AUDIT-WP-0007).
|
|
|
|
See ``docs/integrity.md`` for the proof bound. This module is the shared
|
|
arithmetic; backends persist and walk rows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
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
|
|
|
|
|
|
def chain_link(previous: str, payload_hash: str, event_id: str) -> str:
|
|
"""Next ``chain_hash`` = SHA-256(previous || payload_hash || event_id)."""
|
|
material = f"{previous}|{payload_hash}|{event_id}".encode("utf-8")
|
|
return hashlib.sha256(material).hexdigest()
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChainRow:
|
|
event_id: str
|
|
payload_hash: str
|
|
chain_hash: str
|
|
chain_prev: str
|
|
accepted_at: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ChainReport:
|
|
intact: bool
|
|
events: int
|
|
head: str
|
|
head_event_id: str | None
|
|
head_accepted_at: str | None
|
|
first_break: str | None
|
|
attestation_match: bool | None = None
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"intact": self.intact,
|
|
"events": self.events,
|
|
"head": self.head,
|
|
"head_event_id": self.head_event_id,
|
|
"head_accepted_at": self.head_accepted_at,
|
|
"first_break": self.first_break,
|
|
"attestation_match": self.attestation_match,
|
|
}
|
|
|
|
|
|
def verify_rows(
|
|
rows: Iterable[ChainRow],
|
|
*,
|
|
attestation: Mapping[str, Any] | None = None,
|
|
) -> ChainReport:
|
|
"""Walk accept order and recompute every link.
|
|
|
|
``first_break`` is an event id, or ``attestation_mismatch`` when a cited
|
|
head is not present in the live chain.
|
|
"""
|
|
ordered = list(rows)
|
|
prev = GENESIS
|
|
first_break: str | None = None
|
|
for row in ordered:
|
|
expected = chain_link(prev, row.payload_hash, row.event_id)
|
|
if row.chain_prev != prev or row.chain_hash != expected:
|
|
first_break = row.event_id
|
|
break
|
|
prev = row.chain_hash
|
|
|
|
intact = first_break is None
|
|
last = ordered[-1] if ordered else None
|
|
head = last.chain_hash if last else GENESIS
|
|
report = ChainReport(
|
|
intact=intact,
|
|
events=len(ordered),
|
|
head=head,
|
|
head_event_id=last.event_id if last else None,
|
|
head_accepted_at=last.accepted_at if last else None,
|
|
first_break=first_break,
|
|
)
|
|
if attestation is None:
|
|
return report
|
|
return _apply_attestation(report, ordered, attestation)
|
|
|
|
|
|
def _apply_attestation(
|
|
report: ChainReport,
|
|
rows: list[ChainRow],
|
|
attestation: Mapping[str, Any],
|
|
) -> ChainReport:
|
|
cited = str(attestation.get("chain_hash") or "")
|
|
live_hashes = {row.chain_hash for row in rows}
|
|
empty_ok = not rows and cited in {"", GENESIS}
|
|
matched = cited in live_hashes or empty_ok
|
|
first_break = report.first_break
|
|
intact = report.intact
|
|
if not matched:
|
|
intact = False
|
|
if first_break is None:
|
|
first_break = "attestation_mismatch"
|
|
return ChainReport(
|
|
intact=intact,
|
|
events=report.events,
|
|
head=report.head,
|
|
head_event_id=report.head_event_id,
|
|
head_accepted_at=report.head_accepted_at,
|
|
first_break=first_break,
|
|
attestation_match=matched,
|
|
)
|
|
|
|
|
|
@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,
|
|
"genesis": GENESIS,
|
|
"chain_hash": report.head,
|
|
"event_id": report.head_event_id,
|
|
"accepted_at": report.head_accepted_at,
|
|
"event_count": report.events,
|
|
"observed_at": observed_at or utc_now(),
|
|
}
|
|
|
|
|
|
def load_attestation(path: str | Path) -> dict[str, Any]:
|
|
payload = json.loads(Path(path).read_text())
|
|
if not isinstance(payload, dict) or not payload.get("chain_hash"):
|
|
raise ValueError("attestation must be an object with chain_hash")
|
|
return payload
|
|
|
|
|
|
def write_attestation(path: str | Path, report: ChainReport) -> dict[str, Any]:
|
|
body = attestation_from_report(report)
|
|
destination = Path(path)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n")
|
|
return body
|