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.
73 lines
3 KiB
Markdown
73 lines
3 KiB
Markdown
# 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.
|