audit-core/docs/integrity.md
tegwick de9e3abe5f 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

6.3 KiB

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_hashSHA-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
  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 with reason no_attestation. That is the correct day-one state, not a regression.

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

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.