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:
parent
5faede18fc
commit
5fd04e2095
17 changed files with 696 additions and 29 deletions
|
|
@ -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
73
docs/integrity.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue