Implement AUDIT-WP-0007 hash-chain integrity.

Accept now extends a single-schema chain. Verify walks it; a rewritten
payload_hash is a break. Tamper evidence is that detector plus an
external chain-head attestation, not WORM.
This commit is contained in:
tegwick 2026-08-16 01:18:30 +02:00
parent 5faede18fc
commit 5fd04e2095
17 changed files with 696 additions and 29 deletions

View file

@ -165,7 +165,7 @@ 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`: false (a superuser can drop the trigger; no hash-chain)
- `tamper_evidence`: true (hash chain + verify + external head attestation; not WORM)
- `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]`
@ -240,6 +240,7 @@ Archive remains the evidence record; hot search may use shorter `retention_days`
- `INTENT.md` — product purpose and principles
- `spec/ProductRequirementsDefinition.md` — full v1 envelope and API requirements
- `docs/integrity.md` — hash chain, proof bound, verify/attest
- `registry/capabilities/capability.audit.event-retain.md` — capability registry entry
## Secret-shaped fields

73
docs/integrity.md Normal file
View file

@ -0,0 +1,73 @@
# 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
It still does not mean WORM, object lock, or ITC-CAP `data.archive`.
It does not raise provision maturity to D5.
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.

View file

@ -62,7 +62,7 @@ validation_expectations:
disposition: unmet requirement recorded on the ITC-CAP case; do not build the sink in AUDIT-WP-0006
- id: tamper-evidence-false
owner: audit-core
disposition: integrity_verification hook is unknown; trigger is not a proof
disposition: closed by AUDIT-WP-0007; proof bound is in docs/integrity.md
- id: no-hash-chain
owner: audit-core
disposition: INTENT residual, not this workplan
@ -92,7 +92,7 @@ consumer_needs:
feedback: []
known_deviations:
- no data.archive sink
- tamper_evidence=False
- tamper_evidence is a hash chain plus external head, not WORM
- no hash-chain
- single sender user-engine
- no rapp.yaml (not a rapp-* repo)

View file

@ -29,8 +29,9 @@ warden route show database-dynamic-credentials --json
| Check | Meaning |
| --- | --- |
| `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. |
| `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` (cited platform backup window). Readiness uses this; the pod leaves the Service rather than accept events it cannot store. |
| `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` and `tamper_evidence`. Readiness uses this; the pod leaves the Service rather than accept events it cannot store. |
| `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. |
| `GET /v1/integrity` | Hash-chain walk: `{intact, events, head, first_break}`. No payloads. Requires `may_read`. A break is a custody defect, not a sender retry. |
A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup
failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational`), not a silent
@ -40,7 +41,20 @@ accepted as an alias for one mixed rollout.
After a node reboot, `/readyz` failing for tens of seconds is expected:
CoreDNS and `platform-pg` come up after the receiver. Liveness stays on
`/healthz` so that window does not restart the pod. Walked 2026-08-16
(~40s unreadiness, then Ready).
(~40s unreadiness, then Ready). That window is not a chain break.
## Integrity
Each accept extends a single hash chain for the schema (not per tenant).
`python -m audit_core verify-chain` exits 0 only if the walk is intact.
`python -m audit_core attest-chain --output docs/evidence/chain-head-<ts>.json`
writes the live head **outside** `platform-pg`. Do not put that file in
the Barman prefix; a second copy may follow the RESOURCE-WP-0002-T06
logical-offsite path. `verify-chain --against <file>` fails if the cited
head is not in the live chain.
A break is a custody defect. Do not "fix" it by accepting the same event
again. Reboot unreadiness is unrelated.
## Lookup