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

11 KiB
Raw Permalink Blame History

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:

    {
      "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).
  • 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 — DSSE/TLog-Konsument
  • ADR-0006 — Erzeuger der Approval-Attestation
  • ADR-0007 — Erzeuger der Transform-Signatur