2026-08-16 01:18:30 +02:00
|
|
|
# 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
|
2026-09-06 20:34:22 +02:00
|
|
|
3. that attestation is **fresh** — its `observed_at` is within the
|
|
|
|
|
freshness window below
|
2026-08-16 01:18:30 +02:00
|
|
|
|
|
|
|
|
It still does not mean WORM, object lock, or ITC-CAP `data.archive`.
|
|
|
|
|
It does not raise provision maturity to D5.
|
|
|
|
|
|
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.
|
|
|
|
|
|
AUDIT-WP-0009 T02/T10 — schedule attestation, and make the §5 check total
T02. deploy/attest-cronjob.yaml: daily at 03:17 UTC against the 168h window,
its own ServiceAccount, and a Role reaching exactly one named ConfigMap —
get/update/patch, no create, no list. audit_core/attest_publish.py does the
publish in stdlib; the image carries no kubectl, and adding one to an audit
receiver's image to write a single file is the worse trade.
Three refusals, all deliberate:
The producer is not the receiver. A receiver that could rewrite its own
attestation could forge it. audit-core-egress is now scoped to
component: receiver and a separate audit-core-attest-egress carries the 6443
rule, so the receiver never gains API-server reach. Asserted by test.
It refuses to publish over a broken chain. A fresh head written over a break
replaces an honest chain_break with a fresh-looking attestation. Stale
degrades the claim visibly; false does not.
Mounted as a directory, not subPath. Found while writing the manifest: a
subPath ConfigMap mount is resolved once at pod start and never updates, so
the daily attestation would land in the ConfigMap and never reach the running
receiver — tamper_evidence would age out to false while the job reported
success every night, silent in both directions.
The offsite copy stays an operator step. audit-core holds no Nextcloud
credential and should not acquire one to publish a hash, so docs/integrity.md
states the bound plainly: until that copy exists the delivered control defends
against a database owner, not a cluster owner, and no stronger claim may be
made from it.
T10. layer.yaml lists four infrastructure contacts — platform-pg, state-hub,
kube-apiserver, the container registry — each with its role and whether another
layer reads it. tooling_contacts stays [], which is true under §5 as written;
the companion's totality request is met by the uncatalogued list rather than by
inventing a Tooling row. tests/test_layer_conformance.py derives the egress
destinations from the manifests and the registry from the pinned digests, so a
new contact appearing in deploy/ without a row fails the test rather than
waiting for a reviewer to notice.
Applying the manifests remains an operator action; nothing here was applied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb7Q6ZmXppNDkTWytfYqfv
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2069992@bnt-lap001
Assistant-Session: 167dd7f8-2a25-4be1-aa46-3b6f1a5f94c6
2026-09-10 16:39:20 +02:00
|
|
|
## 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` with reason `no_attestation`. That is the correct
|
|
|
|
|
day-one state, not a regression.
|
2026-09-06 20:34:22 +02:00
|
|
|
|
2026-08-16 01:18:30 +02:00
|
|
|
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.
|