audit-core/docs/integrity.md

103 lines
4.2 KiB
Markdown
Raw Normal View History

# Integrity contract (AUDIT-WP-0007)
Operational custody already stores `payload_hash` and rejects UPDATE/DELETE
for the runtime role. That is not tamper evidence: a database owner can
drop the trigger and rewrite rows. This contract says what the hash chain
proves, and what it does not.
## Chain
Each accepted event stores:
- `payload_hash` — SHA-256 of the canonical event record (already stored)
- `chain_prev` — the previous event's `chain_hash`, or genesis
- `chain_hash``SHA-256(chain_prev | payload_hash | event_id)`
Genesis is 64 ASCII zeros (`audit_core.integrity.GENESIS`). The delimiter
is `|`. The chain is **one stream per schema**, not per tenant. A
per-tenant chain would hide a cross-tenant rewrite.
The first accept of an empty store uses genesis as `chain_prev`. A
duplicate accept must not mint a second link. A conflict must not
advance the head. Concurrent first-accepts of different events take an
advisory lock (`CHAIN_LOCK_KEY`) so they cannot fork.
Verify walks `ORDER BY accepted_at, event_id`, recomputes every link,
and reports the first event id whose stored hashes do not match. A
break is a **custody defect**, not a sender error. Retrying the event
will not repair it.
## Proof bound
A chain *inside* the same database detects a rewritten `payload_hash`
**if the attacker does not also recompute the suffix**. A database
owner can. Tamper evidence against that class of attacker requires a
**chain-head attestation** stored outside `platform-pg`.
`tamper_evidence=True` is allowed only when:
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
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
2026-09-06 20:34:22 +02:00
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.
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
2026-09-06 20:34:22 +02:00
## 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
path already used by RESOURCE-WP-0002-T06 (`rapp-postgres` /
Nextcloud + age); cite it, do not invent a new bucket.
## Tests this contract names
| Test | What it asserts |
| --- | --- |
| `test_first_accept_sets_genesis` | First `chain_prev` is genesis |
| `test_second_accept_links` | Second `chain_prev` is the first `chain_hash` |
| `test_duplicate_does_not_fork` | Replay does not add a link |
| `test_verify_clean_on_fresh_store` | Empty and two-event stores verify |
| `test_rewritten_payload_fails_verify` | Superuser rewrite of `payload_hash` is a break |
| `test_attestation_mismatch` | Cited head absent from the live chain is a break |
## Operator surface
```bash
python -m audit_core verify-chain
python -m audit_core verify-chain --against docs/evidence/chain-head-<ts>.json
python -m audit_core attest-chain --output docs/evidence/chain-head-<ts>.json
```
`GET /v1/integrity` (`may_read`) returns
`{intact, events, head, head_event_id, first_break, attestation_match}`
and never event payloads.