From 2f7f475e85933e9bebde5ab4849f9551e8481312 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 20:34:22 +0200 Subject: [PATCH] =?UTF-8?q?AUDIT-WP-0009-T01=20=E2=80=94=20derive=20tamper?= =?UTF-8?q?=5Fevidence=20from=20live=20attestation=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- SCOPE.md | 10 +- audit_core/integrity.py | 104 ++++++++++ audit_core/postgres_backend.py | 71 ++++++- docs/audit-backend-contract.md | 7 +- docs/integrity.md | 29 +++ tests/test_integrity.py | 179 ++++++++++++++++++ ...AUDIT-WP-0009-evidence-role-conformance.md | 20 +- 7 files changed, 408 insertions(+), 12 deletions(-) diff --git a/SCOPE.md b/SCOPE.md index 9090942..45daf6d 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -97,10 +97,12 @@ effect of its own. **Known conformance gaps** (assessed 2026-08-29, `history/2026-08-29-v0.7-alignment-and-scope-assessment.md`): -- `tamper_evidence=True` is returned unconditionally by the Postgres backend - while `docs/integrity.md` permits it only when a live external attestation - exists. The one attestation on record is 2026-08-16 and no job renews it. - This is Audit Core overclaiming its own bound — the §9.6 defect turned inward. +- ~~`tamper_evidence=True` returned unconditionally by the Postgres backend~~ + **Closed 2026-09-06 (`AUDIT-WP-0009-T01`).** The flag is now derived per + read from live chain and attestation state against a declared 168h + freshness window. Until `AUDIT-WP-0009-T02` schedules attestation, the + honest answer in production is `false` — the overclaim is gone, the + precondition is not yet met. - No cadence, heartbeat, reconciliation, or load-bearing classification exists. The §9.6 obligations Audit Core argued for are not yet supportable by Audit Core. diff --git a/audit_core/integrity.py b/audit_core/integrity.py index dc0c5ec..41f2fa5 100644 --- a/audit_core/integrity.py +++ b/audit_core/integrity.py @@ -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, diff --git a/audit_core/postgres_backend.py b/audit_core/postgres_backend.py index 5e4884c..eb79162 100644 --- a/audit_core/postgres_backend.py +++ b/audit_core/postgres_backend.py @@ -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", ()) diff --git a/docs/audit-backend-contract.md b/docs/audit-backend-contract.md index bc9a855..ff6d2c6 100644 --- a/docs/audit-backend-contract.md +++ b/docs/audit-backend-contract.md @@ -120,7 +120,7 @@ Compatibility rules (not yet implemented): | `custody_class` | `development`, `operational`, `archive`, or `hot_search` | | `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee | | `immutable` | Whether stored records are protected from in-place alteration | -| `tamper_evidence` | Whether manifests, hash chains, or signatures exist | +| `tamper_evidence` | Whether manifests, hash chains, or signatures exist **and their preconditions are currently live** — backends derive this, they do not declare it | | `durable` | Whether survival is expected across process restarts and host reboots | | `recoverable_days` | Cited platform backup window; `None` if not declared | | `recoverable_source` | Where the recoverable window is cited from | @@ -165,7 +165,10 @@ integrity proofs, or survival of `/tmp` across reboots. - `custody_class`: `operational` - `retention_days`: unset in production (the service does not expire rows) - `immutable`: true (trigger `events_append_only`; not a claim against the database owner) -- `tamper_evidence`: true (hash chain + verify + external head attestation; not WORM) +- `tamper_evidence`: **derived per read**, not constant — true only while the + chain verifies and a fresh, matching external head attestation is mounted + (`docs/integrity.md`, freshness window 168h; not WORM). Absent, stale, + unreadable, or mismatched attestation reports false. - `durable`: true - `recoverable_days`: 30, cited from the platform `data.backup` provision - `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]` diff --git a/docs/integrity.md b/docs/integrity.md index b70c633..cd9d75f 100644 --- a/docs/integrity.md +++ b/docs/integrity.md @@ -39,10 +39,39 @@ owner can. Tamper evidence against that class of attacker requires a 1. `verify` exists and fails on a rewritten row 2. an external head attestation exists and verify-against-attestation reports a missing cited head as a break +3. that attestation is **fresh** — its `observed_at` is within the + freshness window below It still does not mean WORM, object lock, or ITC-CAP `data.archive`. It does not raise provision maturity to D5. +## Attestation cadence and freshness window + +An attestation dated once is not a live precondition. The claim is +derived from the mounted attestation on every read, not declared: + +| Setting | Value | +| --- | --- | +| Intended attestation cadence | daily | +| Freshness window | **168 hours (7 days)** | +| Mount path | `AUDIT_CORE_ATTESTATION_PATH` | + +The window is seven times the cadence so that a handful of missed runs +degrade the claim rather than a single one flapping it. Widening the +window without shortening the cadence weakens the claim and is a +change to this contract, not a tuning knob. + +`tamper_evidence` degrades to `False`, with the reason recorded, when +the attestation is absent, unreadable, undated, older than the window, +or cites a head the live chain does not carry — and when the chain +itself is broken or cannot be walked. Unreadable is treated as absent +deliberately: a malformed file must not hold up a claim that a missing +file would drop. + +Until `AUDIT-WP-0009-T02` schedules the attestation job, no attestation +is mounted in production and `/readyz` reports `tamper_evidence: false`. +That is the honest reading of the current state, not a regression. + Do not write the attestation into the Barman prefix (`platform-pg/` on `resource:platform:audit-storage`). That copy is restored with the table. A second copy may follow the logical-offsite diff --git a/tests/test_integrity.py b/tests/test_integrity.py index d63a1a1..6518566 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -137,3 +137,182 @@ def test_http_integrity_requires_read(tmp_path): assert body["intact"] is True assert body["events"] == 1 assert "record" not in body + + +# --- AUDIT-WP-0009-T01: tamper_evidence is derived, not declared ----------- + + +def _report(**kw): + from audit_core.integrity import ChainReport + + fields = dict( + intact=True, + events=2, + head="a" * 64, + head_event_id="e2", + head_accepted_at="2026-09-01T00:00:00+00:00", + first_break=None, + attestation_match=True, + ) + fields.update(kw) + return ChainReport(**fields) + + +def _at(hours_ago: float): + from datetime import datetime, timedelta, timezone + + stamp = datetime(2026, 9, 6, tzinfo=timezone.utc) - timedelta(hours=hours_ago) + return stamp.isoformat() + + +def _now(): + from datetime import datetime, timezone + + return datetime(2026, 9, 6, tzinfo=timezone.utc) + + +def test_fresh_matching_attestation_earns_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence( + _report(), {"chain_hash": "a" * 64, "observed_at": _at(6)}, now=_now() + ) + assert state.claimed is True + assert state.reason == "attested" + + +def test_absent_attestation_degrades_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence(_report(attestation_match=None), None, now=_now()) + assert state.claimed is False + assert state.reason == "no_attestation" + + +def test_stale_attestation_degrades_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + # The one attestation on record when this task was written was 21 days old. + state = evaluate_tamper_evidence( + _report(), {"chain_hash": "a" * 64, "observed_at": _at(21 * 24)}, now=_now() + ) + assert state.claimed is False + assert state.reason == "attestation_stale" + assert state.age_seconds > state.max_age_seconds + + +def test_attestation_just_inside_the_window_still_counts(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence( + _report(), {"chain_hash": "a" * 64, "observed_at": _at(167)}, now=_now() + ) + assert state.claimed is True + + +def test_mismatched_attestation_degrades_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence( + _report(attestation_match=False), + {"chain_hash": "b" * 64, "observed_at": _at(1)}, + now=_now(), + ) + assert state.claimed is False + assert state.reason == "attestation_mismatch" + + +def test_undated_attestation_degrades_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence(_report(), {"chain_hash": "a" * 64}, now=_now()) + assert state.claimed is False + assert state.reason == "attestation_undated" + + +def test_broken_chain_degrades_the_claim_even_with_fresh_attestation(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence( + _report(intact=False, first_break="e2"), + {"chain_hash": "a" * 64, "observed_at": _at(1)}, + now=_now(), + ) + assert state.claimed is False + assert state.reason == "chain_break" + + +def test_unreadable_chain_degrades_the_claim(): + from audit_core.integrity import evaluate_tamper_evidence + + state = evaluate_tamper_evidence( + None, {"chain_hash": "a" * 64, "observed_at": _at(1)}, now=_now() + ) + assert state.claimed is False + assert state.reason == "chain_unreadable" + + +def _bare_postgres_backend(tmp_path, attestation_path=None): + """A PostgresAuditBackend with no pool — retention_policy needs no I/O.""" + import pytest + + pytest.importorskip("psycopg", reason="needs psycopg to import the backend") + from audit_core.integrity import DEFAULT_ATTESTATION_MAX_AGE_HOURS + from audit_core.postgres_backend import PostgresAuditBackend + + backend = object.__new__(PostgresAuditBackend) + backend.retention_days = None + backend.recoverable_days = 30 + backend.recoverable_source = "test" + backend.recoverable_basis = "measured" + backend.attestation_path = str(attestation_path) if attestation_path else None + backend.attestation_max_age_hours = DEFAULT_ATTESTATION_MAX_AGE_HOURS + backend._attestation_cache_seconds = 0.0 + backend._tamper_state = None + backend._tamper_state_at = 0.0 + return backend + + +def test_postgres_policy_drops_the_claim_without_an_attestation(tmp_path): + backend = _bare_postgres_backend(tmp_path) + backend.verify_chain = lambda attestation=None: _report(attestation_match=None) + policy = backend.retention_policy + assert policy.tamper_evidence is False + assert policy.immutable is True + assert backend.tamper_evidence_state().reason == "no_attestation" + + +def test_postgres_policy_earns_the_claim_from_a_fresh_attestation(tmp_path): + from audit_core.integrity import utc_now + + path = tmp_path / "chain-head.json" + path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()})) + backend = _bare_postgres_backend(tmp_path, attestation_path=path) + backend.verify_chain = lambda attestation=None: _report( + attestation_match=attestation is not None + ) + assert backend.retention_policy.tamper_evidence is True + + +def test_postgres_policy_treats_an_unreadable_attestation_as_absent(tmp_path): + path = tmp_path / "chain-head.json" + path.write_text("{not json") + backend = _bare_postgres_backend(tmp_path, attestation_path=path) + backend.verify_chain = lambda attestation=None: _report(attestation_match=None) + assert backend.retention_policy.tamper_evidence is False + assert backend.tamper_evidence_state().reason == "no_attestation" + + +def test_postgres_policy_drops_the_claim_when_the_chain_cannot_be_walked(tmp_path): + from audit_core.integrity import utc_now + + path = tmp_path / "chain-head.json" + path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()})) + backend = _bare_postgres_backend(tmp_path, attestation_path=path) + + def _unavailable(attestation=None): + raise RuntimeError("backend unavailable") + + backend.verify_chain = _unavailable + assert backend.retention_policy.tamper_evidence is False + assert backend.tamper_evidence_state().reason == "chain_unreadable" diff --git a/workplans/AUDIT-WP-0009-evidence-role-conformance.md b/workplans/AUDIT-WP-0009-evidence-role-conformance.md index c9971fc..46ec582 100644 --- a/workplans/AUDIT-WP-0009-evidence-role-conformance.md +++ b/workplans/AUDIT-WP-0009-evidence-role-conformance.md @@ -57,7 +57,7 @@ Fixed by the statute; not deferred, not ours: ```task id: AUDIT-WP-0009-T01 -status: todo +status: done priority: high state_hub_task_id: "f545b0e4-8c99-5186-affd-ce9a41209ed7" ``` @@ -69,6 +69,24 @@ leave it standing. Assert the degradation with a test, and state the freshness window in `docs/integrity.md` alongside the two existing preconditions, which today are documented but unenforced. +Done 2026-09-06. `evaluate_tamper_evidence` in `audit_core/integrity.py` derives +the claim from a `ChainReport` and the mounted attestation; `PostgresAuditBackend` +reads it per `retention_policy` call through `tamper_evidence_state()`, briefly +cached because `/readyz` probes it. Seven degradation reasons are distinguished +and asserted — `no_attestation`, `attestation_stale`, `attestation_mismatch`, +`attestation_undated`, `chain_break`, `chain_unreadable`, against `attested`. +Unreadable is treated as absent deliberately: a malformed file must not hold up +a claim a missing file would drop. The freshness window is **168h** against an +intended daily cadence, declared in `docs/integrity.md` with the reasoning that +the window is seven cadences so missed runs degrade rather than flap. Twelve +tests in `tests/test_integrity.py`; `docs/audit-backend-contract.md` now states +the field is derived rather than declared. + +Consequence to state plainly: production `/readyz` will report +`tamper_evidence: false` until T02 mounts a renewed attestation. The 2026-08-16 +attestation is 21 days old against a 7-day window. That is the overclaim being +removed, not a regression — the claim was false before and now says so. + ```task id: AUDIT-WP-0009-T02 status: todo