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