audit-core/docs/integrity.md
tegwick 146f4e1dd3
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
fix: repair chained imports and track blocked evidence dependencies
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1046e-465c-7d43-8c4c-d10f0a1feadd
2026-10-04 11:36:06 +02:00

150 lines
6.9 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.
PostgreSQL verification follows `chain_prev` from genesis, recomputes every
link, and retains orphaned or forked rows as breaks. The next append selects
the unique unreferenced head under the same advisory lock. This separates
chain insertion order from historical `accepted_at`: importing older SQLite
events extends the existing chain without rewriting timestamps or links.
SQLite's development chain still walks `ORDER BY accepted_at, event_id`.
Verification 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
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.
## How the attestation is produced and where it lives
`AUDIT-WP-0009-T02`, `deploy/attest-cronjob.yaml`.
| | |
| --- | --- |
| Cadence | daily, `17 3 * * *` UTC, against the 168h window |
| Producer | CronJob `audit-core-attest-chain`, its own ServiceAccount |
| Published to | ConfigMap `audit-core-chain-head`, mounted read-only by the receiver at `/etc/audit-core/attestation/` |
| Offsite copy | operator step, logical-offsite path (`rapp-postgres` / Nextcloud + age) |
Three properties of that arrangement are load-bearing, not incidental.
**The producer is not the receiver.** A receiver that could rewrite its own
attestation could forge it. So the job runs as a separate workload whose Role
reaches exactly one named ConfigMap, and NetworkPolicy gives the receiver no
API-server egress at all. Asserted in `tests/test_layer_conformance.py`.
**It refuses to publish over a broken chain.** If `verify_chain` reports a
break the job fails and leaves the previous attestation standing. Publishing a
fresh head over a break would replace an honest `chain_break` with a
fresh-looking attestation — a stale attestation degrades the claim visibly, a
false one does not.
**The ConfigMap is mounted as a directory, not with `subPath`.** A `subPath`
ConfigMap mount is resolved once at pod start and never updates, so the daily
attestation would reach the ConfigMap and never the running receiver:
`tamper_evidence` would age out to `false` while the job reported success every
night. That failure is silent in both directions, which is why the mount shape
is part of this contract rather than a deployment detail.
**What this reaches, and what it does not.** The in-cluster copy puts the
attestation outside `platform-pg`, so a database owner who rewrites a suffix
cannot also rewrite the head. It does **not** withstand a cluster owner, who
can edit the ConfigMap. The offsite copy is the one that survives losing the
cluster, and audit-core deliberately does not write it — this repo holds no
Nextcloud credential and should not acquire one to publish a hash. Until an
operator establishes that copy, the delivered control is "defends against a
database owner", and no stronger claim may be made from it.
Before the first run the ConfigMap is empty and `/readyz` reports
`tamper_evidence: false`. That is the correct day-one state, not a
regression. `/readyz` itself does not walk the chain: it publishes the
last evaluated claim, or `false` if none, so a 2-second kubelet probe
cannot take the pod out of the Service. The walk lives on
`/v1/integrity` and `retention_policy`.
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.