ProofedMesh/docs/adr/0008-dummy-sink-and-verification-chain.md
CTO (Paperclip) ddbd304659
Some checks failed
build-images / approval-service image (push) Waiting to run
build-images / dummy-sink image (push) Waiting to run
phase1-smoke / Phase 1 end-to-end smoke (push) Waiting to run
validate / JSON Schema (ajv) (push) Waiting to run
validate / Rego (opa fmt + opa test) (push) Waiting to run
validate / Go (vet + test) (push) Waiting to run
validate / DB (migrate up/down dry-run) (push) Waiting to run
build-images / verification-service image (push) Has been cancelled
build-images / signing-service image (push) Has been cancelled
docs: Phase-1 wrap-up — README status, ADR-0006/0007/0008 (DEV-616)
- README: Phase 1 abgeschlossen, Service-Liste + Deploy-Pfade + phase1-smoke-Anleitung.
- ADR-0006: Approval-Service — WebAuthn als Content-Binding mit `nonce || H_content`,
  Alternative U2F-Challenge dokumentiert und als "challenge-only"-Fallback markiert.
- ADR-0007: Signing-Service — zweistufige Identität (Pocket-ID JWKS + K8s TokenReview),
  Fehlermodus `registry_identity_mismatch` explizit.
- ADR-0008: Dummy-Sink + zweistufige Verification-Chain, zentraler Replay-Guard
  in `verification_decisions`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 21:23:30 +00:00

235 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0008: Dummy-Sink und zweistufige Verification-Chain
- **Status:** accepted
- **Datum:** 2026-10-25
- **Entscheider:** CTO
- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.4 (Verification), §4.5 (Replay-Schutz), §7 (Senken-Vertrag), `services/dummy-sink/`, `services/verification-service/`, `db/trust/migrations/0005_verification_decisions.*.sql`
## Kontext und Problemstellung
Phase 1 muss die Vertrauenskette *zweistufig* schließen können (Approval
→ Transform → Sink), ohne auf eine echte Ziel-Senke (ERP, DB-App) zu
warten. Gleichzeitig muss der Vertrag so sitzen, dass die Phase-2-Senken
(SAP-ABAP, DB-App, HTTP-Webhook) **nicht umgeschrieben werden
müssen** — die generische Senke muss Phase-1 bereits erfüllen können.
Zwei Fragen sind hier zu entscheiden:
1. **Was ist die Mindest-Pflicht eines Senken-Adapters?** Der
Senken-Vertrag aus `docs/architecture/v1.1.md §7` ist konzeptionell
klar, aber in Code musste das für Phase 1 exakt gezogen werden.
2. **Wo lebt die Verifikation der Kette, und wo lebt der
Replay-Schutz?** Die naive Lösung wäre, jede Senke die DSSE-Prüfung
+ Replay-Tabelle selbst machen zu lassen — das macht die
Kette so stark wie die schwächste Senke und verteilt die
Verifikationslogik.
## Entscheidungsoptionen
- **Option A: Verifikation + Replay-Schutz in jeder Senke.**
Jeder Adapter prüft DSSE, lädt Inclusion-Proofs vom TLog, hält
eigenen `decision_id`-Replay-Store.
- **Option B: Zentraler verification-service, Senken rufen ihn vor
jeder Zustellung auf; Replay-Schutz lebt im verification-service.**
- **Option C: Zentraler verification-service, Senken halten
zusätzlich eigenen `decision_id`-Store.**
## Entscheidung
Wir wählen **Option B**.
### Senken-Vertrag (Phase 1, mindestens erforderlich)
Ein Senken-Adapter MUSS:
1. **`POST /api/v1/sink/<id>/deliver`** mit folgendem Body akzeptieren:
```json
{
"decision_id": "<string>",
"payload_hash": "sha256:<64-hex>",
"payload": { ... },
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>"
}
```
2. **Payload-Hash gegen Payload prüfen.** `payload_hash` muss zu
`sha256(raw payload bytes)` *oder* `sha256(canonical(payload))`
passen — der Adapter rechnet beide Formen nach, weil
Agenten-Implementierungen je nach Umfeld das eine oder andere
schicken. Mismatch → `400`.
3. **`POST {VERIFY_URL}/api/v1/verify/chain`** aufrufen mit
`{decision_id, approval_ref, transform_ref, payload_hash,
sink_target: "sink:<id>"}` und das Verdict verarbeiten:
- `valid: true` → Zustellung ausführen.
- `valid: false, replay: true` → `409 Conflict`.
- `valid: false` → `403 Forbidden` mit `error.reasons: [...]`.
4. **Handover-Attestation signieren.** Nach erfolgreicher Zustellung
DSSE-signiert eine Statement mit
`predicateType = https://basicstack.de/attestations/handover/v1`
publizieren und ins TLog eintragen, die
`decision_id`, `payload_hash`, `approval_ref`, `transform_ref` und
einen senken-spezifischen `sink_record_ref` (z.B.
`file://…`, `sap:/<buchungsnr>`, `db:/<schema>/<id>`) materialisiert.
`materials[]` referenziert `approval_ref` und `transform_ref`
zurück — so bleibt die Vertrauenskette im TLog traversierbar.
5. **Keinen lokalen Replay-Store führen.** Der Adapter sieht
`decision_id` nur als Pass-Through-Feld; die Entscheidung "schon
gesehen oder neu?" trifft ausschließlich der verification-service.
Was ein Senken-Adapter NICHT muss:
- DSSE-Signaturen selbst verifizieren.
- Inclusion-Proofs vom TLog einholen.
- Policy-Regeln (`allowed_transformations`, `allowed_sinks`)
interpretieren.
- `approval_ref` / `transform_ref` auflösen oder dereferenzieren.
Diese Zentralisierung ist der Kern des Vertrages: eine neue Senke in
Phase 2 (z.B. SAP) braucht nichts Kryptografisches zu lernen — sie
implementiert nur den `deliver`-Handler, delegiert an
verification-service, signiert ihre Handover und schreibt ins TLog.
### Zweistufige Verification
`POST /api/v1/verify/chain` auf dem verification-service führt in dieser
Reihenfolge aus (erster Fehler gewinnt, Begründung in `reasons[]`):
1. **DSSE-Signaturen prüfen** — Approval gegen
`trust-api/approval-signer-2026-q4`, Transform gegen
`trust-api/agent-testagent-transform-2026-q4` (konfigurierbar via
`APPROVAL_SIGNER_KEY_ID` / `TRANSFORM_SIGNER_KEY_ID`,
Public-Keys aus `VERIFICATION_SIGNER_KEYS`).
2. **Transparency-Log-Inclusion-Proofs** gegen den TLog-`tree_root_hash`
schließen (RFC 6962 §2.1.1) — beide Envelopes müssen tatsächlich
im TLog stehen, nicht nur signiert sein.
3. **Content-Binding-Hash-Gleichheit**: `approval.subject.digest.sha256`
== `approval.predicate.content_hash`, `transform.subject.digest.sha256`
== `request.payload_hash`.
4. **`allowed_transformations`** auf dem Approval-Predicate deckt die
`operation` des Transform-Predicates ab.
5. **`allowed_sinks`** auf dem Approval-Predicate enthält
`request.sink_target`.
6. **Approval-Expiry**: `approval.predicate.expires_at` (RFC 3339) liegt
in der Zukunft; sonst `approval_expired`.
7. **Replay-Guard**: `decision_id` wird in der
`verification_decisions`-Tabelle *atomar reserviert*. Ein zweites
`verify/chain` mit derselben `decision_id` schlägt die Reservierung
fehl und liefert `replay: true, valid: false,
reasons: ["replay_decision_id"]`.
Bemerkenswert: Replay-Schutz wird **erst nach allen anderen Prüfungen**
geschrieben. Eine Chain, die wegen `approval_expired` scheitert, darf
später — nach Erneuerung — doch noch mit *neuer* `decision_id` laufen;
die alte `decision_id` wird in diesem Fall nicht reserviert und wäre
ebenfalls wiederverwendbar (soll aber laut Operations-Policy nicht
wiederverwendet werden, da sie das Audit erschwert).
Error-Semantik (konsistent mit `services/verification-service/README.md`):
- Transport zum TLog kaputt → HTTP `502`.
- Body malformed → `400`.
- Sonst immer `200` + `valid`/`replay`/`reasons`-JSON. Die Senke
entscheidet daraus `201`/`403`/`409`.
### Replay-Schutz-Semantik — bewusst zentral
- **Pro-Decision-ID-Reservierung**: `decision_id` ist der einzige
Dedup-Schlüssel. Senken dürfen nicht selbst dedupieren
(`sink_record_ref` kennt der Verifier nicht, umgekehrt kennt die
Senke `approval_ref`/`transform_ref` nicht in Reservierungs-Form).
- **`cancelled` ist kein Terminalzustand des Replay-Guards.** Eine
einmal reservierte `decision_id` bleibt reserviert; auch wenn der
Flow danach abbricht, kann die Senke die gleiche Entscheidung nicht
nochmal ausliefern. Rückabwickeln passiert auf
Geschäftsebene mit einer neuen `decision_id`.
- **Transaktionsgrenze**: Die Reservierung ist atomar relativ zum
Verifier (Postgres `INSERT … ON CONFLICT DO NOTHING RETURNING`,
Phase 2 Prod; Phase 1 sync-Map, Mutex-geschützt). Die Senke führt
erst zu, nachdem der Verifier `valid: true` zurückgegeben hat —
wenn die Senken-Zustellung danach noch failt, bleibt die
`decision_id` dennoch reserviert. Das ist beabsichtigt: eine
halbgescheiterte Zustellung darf nicht lautlos zweimal passieren;
Operator entscheidet, ob eine neue `decision_id` ausgestellt wird.
## Begründung
- **Ein Verifier, viele Senken.** Alle Senken-Adapter stimmen über
denselben Verifier ab; neue Signaturregeln oder Content-Binding-
Fehlermodi müssen nicht in n Codebasen nachgezogen werden.
- **Senken-Code bleibt dumm.** Für Phase-2-SAP-Adapter (ABAP) ist das
entscheidend: dort hätten wir mit "DSSE in jeder Senke" gar nicht
erst anfangen können.
- **Replay-Schutz kann nicht gegabelt werden.** Zwei
Replay-Stores (zentraler + lokaler, Option C) öffnen genau die
Lücke, die Replay-Schutz vermeiden soll: Sync-Drift zwischen beiden,
Race zwischen beiden, Doppelzustellung während Store-Resync.
- **Reasons-Codes sind stabil.** Dashboards und Operator-Playbooks
haben feste Strings (`approval_expired`, `sink_not_in_allowed_sinks`
usw.) zum Grepen (`services/verification-service/README.md`).
### Alternativen bewusst nicht gewählt
- **Option A (Verifikation in jeder Senke)**: hätte die Senken-Barriere
für Phase 2 unakzeptabel hochgezogen und die Verifikationslogik
mehrfach ausgeliefert.
- **Option C (Verifier + lokaler Senken-Store)**: siehe oben,
Dedup-Kollision und Race-Fenster. Wenn ein Compliance-Audit später
einen senkenlokalen "we actually delivered this" Log fordert, lässt
er sich aus der `handover`-Attestation im TLog ableiten; der ist
signiert und zeitstempel-ordnend.
## Konsequenzen
### Positive
- Phase-2-Senken (SAP, DB-App, Webhook, S3) implementieren denselben
flachen Vertrag; die Vertrauenskette-Codepfade sind bereits getestet.
- Audit-Spur ist einheitlich im TLog: Approval, Transform, Handover
(pro Zustellung eine) stehen hintereinander mit stabilen Hashes.
- `verification_decisions`-Tabelle (ADR-0004-Migrations) ist der
einzige Punkt, an dem "Replay-Status" zustandsbehaftet lebt. Backup/
Restore / Dis-aster-Recovery hat genau ein Objekt zu beachten.
### Negative / Trade-offs
- Jeder Delivery kostet einen Verifier-Roundtrip, bevor die Senke
schreibt. Latenz-Budget ca. 50–150 ms (DSSE-Verify + TLog-GET +
Replay-INSERT). Für Agenten-Flows akzeptabel.
- Senken müssen Fehlerfälle `403` / `409` aus Verifier-Verdicten
verlässlich propagieren; sonst sieht der Agent eine fehlgeschlagene
Zustellung nicht korrekt.
- Verifier-Ausfall blockiert alle Senken. Das ist ein akzeptabler
single-point-of-failure für Phase 1 — Prod-Härtung in Phase 2
(HA, Postgres-Backend, read-only standby).
### Folgearbeiten / Open Points
- **Postgres-Backend** für `verification_decisions` (Phase 2). Phase 1
nutzt In-Memory + Mutex, Schema ist bereits authoritativ unter
`db/trust/migrations/0005_verification_decisions.{up,down}.sql`.
- **Verifier-Policy nach OPA** verschieben (statt hartcodierter
Chain-Regeln in `internal/chain/chain.go`). Phase 2.
- **Agent-Registry-Lookup** für Transform-Signer-Keys (statt
statischem `VERIFICATION_SIGNER_KEYS`). Phase 2.
- **N-stufige Chains** (mehrere Transform-Agenten hintereinander). Die
Chain-Logik ist auf zweistufig hartcodiert; der nächste Transform
würde `materials[]` auf vorhergehende Transforms setzen. Phase 2.
- **Senken-spezifische `sink_record_ref`-Formate** sauber in einer
Mini-Spezifikation festhalten (DB-Senke, SAP-Senke, Webhook-Senke).
## Links
- `services/dummy-sink/README.md` — Phase-1-Senken-Adapter
- `services/verification-service/README.md` — Chain-Regeln, Reason-Codes
- `services/verification-service/internal/chain/chain.go` — Chain-Validator
- `db/trust/migrations/0005_verification_decisions.{up,down}.sql` — Replay-Tabelle
- `docs/architecture/v1.1.md` §4.4, §4.5, §7
- RFC 6962 §2.1.1 (Merkle Audit Path)
- [ADR-0005](0005-signing-service-and-transparency-log-api.md) — DSSE/TLog-Konsument
- [ADR-0006](0006-approval-service-webauthn-content-binding.md) — Erzeuger der Approval-Attestation
- [ADR-0007](0007-signing-service-dual-identity-check.md) — Erzeuger der Transform-Signatur