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
- 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>
235 lines
11 KiB
Markdown
235 lines
11 KiB
Markdown
# 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
|