docs: Phase-1 wrap-up — README status, ADR-0006/0007/0008 (DEV-616)
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
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>
This commit is contained in:
parent
20ba29d3ac
commit
ddbd304659
5 changed files with 641 additions and 2 deletions
44
README.md
44
README.md
|
|
@ -12,7 +12,11 @@ Identität, Berechtigung, Verarbeitung und Nachrichtentransport werden strikt ge
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Dieses Repository bildet die **Basis für die Architekturentwicklung**. Die Komponentenentwicklung hat noch nicht begonnen.
|
**Phase 1 abgeschlossen — End-to-End-Skelett läuft, Phase-1-Smoke grün.**
|
||||||
|
Die vollständige Vertrauenskette (User → Approval → Transform → Dummy-Sink → Verification)
|
||||||
|
lässt sich reproduzierbar durchlaufen; `make phase1-smoke` deckt sie in CI ab
|
||||||
|
([`.forgejo/workflows/phase1-smoke.yml`](.forgejo/workflows/phase1-smoke.yml)).
|
||||||
|
Die Prod-Härtung (OpenBao Transit, OPA, echte Senken) ist Phase-2-Scope.
|
||||||
|
|
||||||
- **Aktive Architektur:** [`docs/architecture/v1.1.md`](docs/architecture/v1.1.md) — V1.1 inkl. Senken-Generalisierung (SAP ist eine von vielen Senken), Pocket ID als alleiniger IdP, PII-Trennung, Trillian für Transparency Log, Content Binding mit WebAuthn, Replay-Schutz, Retention 1 Jahr
|
- **Aktive Architektur:** [`docs/architecture/v1.1.md`](docs/architecture/v1.1.md) — V1.1 inkl. Senken-Generalisierung (SAP ist eine von vielen Senken), Pocket ID als alleiniger IdP, PII-Trennung, Trillian für Transparency Log, Content Binding mit WebAuthn, Replay-Schutz, Retention 1 Jahr
|
||||||
- **Historisch:** [`docs/architecture/v1.0.md`](docs/architecture/v1.0.md) — ursprünglicher Entwurf, archiviert
|
- **Historisch:** [`docs/architecture/v1.0.md`](docs/architecture/v1.0.md) — ursprünglicher Entwurf, archiviert
|
||||||
|
|
@ -56,9 +60,15 @@ docs/
|
||||||
0002-session-scope-token-format.md
|
0002-session-scope-token-format.md
|
||||||
0003-pii-retention-policy.md
|
0003-pii-retention-policy.md
|
||||||
0004-db-migrations.md
|
0004-db-migrations.md
|
||||||
|
0005-signing-service-and-transparency-log-api.md
|
||||||
|
0006-approval-service-webauthn-content-binding.md
|
||||||
|
0007-signing-service-dual-identity-check.md
|
||||||
|
0008-dummy-sink-and-verification-chain.md
|
||||||
db/
|
db/
|
||||||
README.md
|
README.md
|
||||||
trust/migrations/0001_per_diem_rates.{up,down}.sql
|
trust/migrations/0001_per_diem_rates.{up,down}.sql
|
||||||
|
trust/migrations/0004_approval_sessions.{up,down}.sql
|
||||||
|
trust/migrations/0005_verification_decisions.{up,down}.sql
|
||||||
schemas/
|
schemas/
|
||||||
README.md
|
README.md
|
||||||
expense/canonical/{v1.0.0,v1.1.0,v1.2.0}.json
|
expense/canonical/{v1.0.0,v1.1.0,v1.2.0}.json
|
||||||
|
|
@ -75,14 +85,22 @@ services/
|
||||||
per-diem-calculator/ # deterministic calculator (Go)
|
per-diem-calculator/ # deterministic calculator (Go)
|
||||||
admin-api/ # admin surface for per-diem rate management (Go)
|
admin-api/ # admin surface for per-diem rate management (Go)
|
||||||
attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go)
|
attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go)
|
||||||
signing-service/ # DSSE signing service (Go, ADR-0005) + Dockerfile
|
signing-service/ # DSSE signing service (Go, ADR-0005 + ADR-0007) + Dockerfile
|
||||||
transparency-log/ # Trillian-backed transparency log (Go, ADR-0005)
|
transparency-log/ # Trillian-backed transparency log (Go, ADR-0005)
|
||||||
|
approval-service/ # WebAuthn-gated content-binding approvals (Go, ADR-0006)
|
||||||
|
testagent-transform/ # Phase-1 Transform-Agent-CLI (Go)
|
||||||
|
dummy-sink/ # Phase-1 Mock-Senke (Go, ADR-0008)
|
||||||
|
verification-service/ # Phase-1 Chain-Verifier + Replay-Guard (Go, ADR-0008)
|
||||||
deploy/
|
deploy/
|
||||||
trillian/ # Trillian MySQL + log server/signer (namespace agent-trust)
|
trillian/ # Trillian MySQL + log server/signer (namespace agent-trust)
|
||||||
signing-service/ # signing-service Deployment/Service/NetworkPolicy + Secret convention
|
signing-service/ # signing-service Deployment/Service/NetworkPolicy + Secret convention
|
||||||
|
approval-service/ # approval-service Deployment/Service/NetworkPolicy + Pocket-ID OIDC-Secret
|
||||||
|
verification-service/ # verification-service Deployment/Service/NetworkPolicy
|
||||||
|
dummy-sink/ # dummy-sink Deployment/Service/NetworkPolicy + PVC (Longhorn RWO)
|
||||||
.forgejo/workflows/
|
.forgejo/workflows/
|
||||||
validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test)
|
validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test)
|
||||||
build-images.yml # Container-Images für ProofedMesh-Services → harbor.basicstack.de
|
build-images.yml # Container-Images für ProofedMesh-Services → harbor.basicstack.de
|
||||||
|
phase1-smoke.yml # End-to-End Phase-1-Smoke (make phase1-smoke)
|
||||||
```
|
```
|
||||||
|
|
||||||
Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung.
|
Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung.
|
||||||
|
|
@ -109,6 +127,28 @@ opa test policies/ -v
|
||||||
(cd services/admin-api && go vet ./... && go test ./...)
|
(cd services/admin-api && go vet ./... && go test ./...)
|
||||||
(cd services/per-diem-calculator && go vet ./... && go test ./...)
|
(cd services/per-diem-calculator && go vet ./... && go test ./...)
|
||||||
(cd services/attest-reconciler && go vet ./... && go test ./...)
|
(cd services/attest-reconciler && go vet ./... && go test ./...)
|
||||||
|
(cd services/signing-service && go vet ./... && go test ./...)
|
||||||
|
(cd services/transparency-log && go vet ./... && go test ./...)
|
||||||
|
(cd services/approval-service && go vet ./... && go test ./...)
|
||||||
|
(cd services/testagent-transform && go vet ./... && go test ./...)
|
||||||
|
(cd services/dummy-sink && go vet ./... && go test ./...)
|
||||||
|
(cd services/verification-service && go vet ./... && go test ./...)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI (`.forgejo/workflows/validate.yml`) läuft dieselben Prüfungen auf jedem Push und PR gegen `main`.
|
CI (`.forgejo/workflows/validate.yml`) läuft dieselben Prüfungen auf jedem Push und PR gegen `main`.
|
||||||
|
|
||||||
|
## Phase-1-Smoke
|
||||||
|
|
||||||
|
Der End-to-End-Smoketest baut jeden Phase-1-Service als statisches Binary,
|
||||||
|
startet sie auf Loopback-Ports und treibt die vollständige Kette
|
||||||
|
User → Approval → Transform → Dummy-Sink → Verification mit `curl` + `jq`
|
||||||
|
durch. Keine Container-Runtime nötig, keine Kubernetes-Instanz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make phase1-smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Test prüft `valid=true` auf `/verify/chain`, dass der Dummy-Sink die
|
||||||
|
Payload unter `<WORK>/data/<decision_id>.json` abgelegt hat, und dass der
|
||||||
|
Transparency-Log drei DSSE-Attestationen enthält (Approval + Transform +
|
||||||
|
Handover). CI-Workflow: [`.forgejo/workflows/phase1-smoke.yml`](.forgejo/workflows/phase1-smoke.yml).
|
||||||
|
|
|
||||||
172
docs/adr/0006-approval-service-webauthn-content-binding.md
Normal file
172
docs/adr/0006-approval-service-webauthn-content-binding.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# ADR-0006: Approval-Service — WebAuthn als Content-Binding-Mechanismus
|
||||||
|
|
||||||
|
- **Status:** accepted
|
||||||
|
- **Datum:** 2026-10-25
|
||||||
|
- **Entscheider:** CTO
|
||||||
|
- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.3 (Content Binding), `services/approval-service/`, `deploy/approval-service/`
|
||||||
|
|
||||||
|
## Kontext und Problemstellung
|
||||||
|
|
||||||
|
Die unveränderliche Benutzerfreigabe ist das Fundament der gesamten
|
||||||
|
Vertrauenskette: alle späteren Attestationen (Transform, Handover) binden
|
||||||
|
sich über `materials[].attestation_ref` an genau diese Approval zurück.
|
||||||
|
Damit eine Freigabe ihren Zweck erfüllt, muss sie zwei Dinge nachweisen:
|
||||||
|
|
||||||
|
1. **Content Binding** — die Signatur bezieht sich auf exakt den
|
||||||
|
serverseitig gerenderten Geschäftsinhalt, nicht auf einen beliebigen
|
||||||
|
Hash. Ohne diese Bindung kann ein kompromittierter Browser dem
|
||||||
|
Benutzer `A` zeigen und `B` signieren lassen.
|
||||||
|
2. **Phishing-Resistenz** — ein Angreifer darf eine abgefangene Signatur
|
||||||
|
nicht gegen eine andere Origin oder einen anderen Relying Party
|
||||||
|
wiederverwenden können.
|
||||||
|
|
||||||
|
Beide Eigenschaften müssen von einem Hardware-gebundenen Second Factor
|
||||||
|
getragen werden, nicht nur vom Pocket-ID-Login — ein Pocket-ID-Cookie
|
||||||
|
alleine stellt nur den Benutzer fest, nicht den konkret autorisierten
|
||||||
|
Inhalt.
|
||||||
|
|
||||||
|
## Entscheidungsoptionen
|
||||||
|
|
||||||
|
- **Option A: WebAuthn (FIDO2) mit Challenge `nonce || H_content`.**
|
||||||
|
Der Server rendert den Inhalt, berechnet `H_content =
|
||||||
|
sha256(canonical_json)`, erzeugt einen `nonce` und verlangt vom
|
||||||
|
Browser eine WebAuthn-Assertion über `nonce || H_content`. Die
|
||||||
|
Authenticator-Hardware signiert `authenticatorData ||
|
||||||
|
sha256(clientDataJSON)`, wobei `clientDataJSON.challenge` die
|
||||||
|
base64url-codierte Verkettung enthält.
|
||||||
|
- **Option B: U2F-Challenge (nur Challenge-Nonce, kein Inhalts-Hash).**
|
||||||
|
Klassisches zweites-Faktor-Modell: Server erzeugt eine zufällige
|
||||||
|
Challenge, Hardware-Token signiert sie. Keine Bindung an den
|
||||||
|
tatsächlichen Freigabe-Inhalt — der Client muss "ehrlich" sein,
|
||||||
|
Content Binding geschieht konzeptionell per UI-Review.
|
||||||
|
- **Option C: Server-seitige Pocket-ID-OIDC-Reauth mit Prompt=login.**
|
||||||
|
Freigabe durch erneuten Pocket-ID-Login. Weder Hardware-Faktor noch
|
||||||
|
Inhalts-Bindung — nur eine Session-Zusicherung.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Wir wählen **Option A: WebAuthn mit Content-gebundener Challenge
|
||||||
|
`nonce || H_content`**.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/approvals/preview { session_id, content }
|
||||||
|
→ { preview_id, content_hash: "sha256:…",
|
||||||
|
verification_digits: "734219",
|
||||||
|
webauthn_challenge: base64url(nonce || H_content_bytes),
|
||||||
|
expires_at }
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
navigator.credentials.get({
|
||||||
|
publicKey: { challenge: fromBase64Url(webauthn_challenge),
|
||||||
|
allowCredentials, userVerification: "required", rpId }
|
||||||
|
})
|
||||||
|
|
||||||
|
POST /api/v1/approvals/confirm { preview_id, assertion }
|
||||||
|
Server:
|
||||||
|
1. load (preview_id → H_content, nonce, expires_at)
|
||||||
|
2. parse clientDataJSON; assert .type == "webauthn.get",
|
||||||
|
.origin == APPROVAL_WEBAUTHN_RP_ORIGIN,
|
||||||
|
base64url(.challenge) == webauthn_challenge
|
||||||
|
3. verify signature over authenticatorData || sha256(clientDataJSON)
|
||||||
|
with the credential's public key (Ed25519/ECDSA/RSA)
|
||||||
|
4. H_content_recomputed = sha256(canonical(content))
|
||||||
|
→ MUST equal H_content from preview; else 400
|
||||||
|
5. DSSE-sign the approval statement (predicateType
|
||||||
|
basicstack.de/attestations/approval/v1); tlog-submit
|
||||||
|
```
|
||||||
|
|
||||||
|
Die 6-stellige `verification_digits` wird aus `H_content` abgeleitet und
|
||||||
|
im UI vor dem Prompt dargestellt. Der Mensch gleicht sie beim Signieren
|
||||||
|
mit der auf dem Authenticator angezeigten Transaktionsbestätigung ab
|
||||||
|
(dort, wo der Authenticator diese Funktion unterstützt — ansonsten dient
|
||||||
|
die Ziffer als visuelle Prüfsumme gegen `H_content` im Prompt-UI).
|
||||||
|
|
||||||
|
### Begründung
|
||||||
|
|
||||||
|
- **Content Binding ist Hardware-garantiert.** Weil `H_content` Teil der
|
||||||
|
Challenge ist und die Challenge Teil der signierten
|
||||||
|
`clientDataJSON`-Struktur, ist jede gültige Assertion zwangsläufig an
|
||||||
|
genau diesen Inhalt gebunden. Ein kompromittierter Browser kann die
|
||||||
|
Challenge nicht ohne Mitwirkung des Authenticators ändern.
|
||||||
|
- **Phishing-Resistenz kostet uns nichts extra.** WebAuthn liefert
|
||||||
|
`origin`-Bindung und RP-ID-Bindung ab Werk. Wir prüfen sie serverseitig
|
||||||
|
gegen `APPROVAL_WEBAUTHN_RP_ID` / `APPROVAL_WEBAUTHN_RP_ORIGIN`.
|
||||||
|
- **Keine Protokoll-Eigenerfindung.** `clientDataJSON` ist ein offener
|
||||||
|
Standard; die Phase-1-Shim im `approval-service/internal/signer/` ist
|
||||||
|
wire-kompatibel mit `github.com/go-webauthn/webauthn`, auf das in
|
||||||
|
Phase 2 ohne API-Bruch umgestellt werden kann.
|
||||||
|
- **Trennung UI-Prüfung vs. kryptografische Prüfung.** Die
|
||||||
|
`verification_digits` sind ein UX-Backstop für den Fall, dass der
|
||||||
|
Authenticator keine Transaktions-Display-Fähigkeit hat. Die
|
||||||
|
Vertrauenskette hängt nicht an der Augenprüfung — sie hängt am
|
||||||
|
Content-Hash in der Challenge.
|
||||||
|
|
||||||
|
### Alternativen bewusst nicht gewählt
|
||||||
|
|
||||||
|
- **Option B (U2F-Challenge ohne Inhaltsbindung)** schließen wir aus,
|
||||||
|
weil sie das Kern-Angriffsmodell (kompromittierter Browser rendert
|
||||||
|
`A`, lässt `B` signieren) nicht abdeckt. Die Approval-Attestation
|
||||||
|
bezöge sich dann auf einen bloßen Challenge-Nonce — die Aussage
|
||||||
|
"`User X hat exakt diesen Inhalt freigegeben`" wäre nicht mehr
|
||||||
|
beweisbar. U2F bleibt als Fallback-Idee dokumentiert für den Fall,
|
||||||
|
dass WebAuthn in einer Browser-/Geräteumgebung nicht verfügbar ist
|
||||||
|
(Legacy-Clients). Dann müsste die Approval-Attestation explizit
|
||||||
|
`binding_level: "challenge-only"` tragen und Downstream-Policies
|
||||||
|
dürften diese Freigaben für sensitive Senken ablehnen. Diese
|
||||||
|
Weiche wird erst gebaut, wenn ein konkreter Legacy-Fall auftaucht.
|
||||||
|
- **Option C (Pocket-ID-Reauth)** verfehlt beide Anforderungen: kein
|
||||||
|
Hardware-Faktor, keine Inhaltsbindung. Nur sinnvoll als zusätzlicher
|
||||||
|
Session-Freshness-Check, nicht als Approval-Mechanismus.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Die Approval-Attestation trägt einen kryptografisch beweisbaren Bezug
|
||||||
|
zum tatsächlich freigegebenen Inhalt (`H_content` steht sowohl im
|
||||||
|
Predicate als auch in der signierten `clientDataJSON`).
|
||||||
|
- Downstream-Verifier (verification-service, ADR-0008) müssen keine
|
||||||
|
Inhalts-Redaktion rekonstruieren — sie prüfen Hash-Gleichheit gegen
|
||||||
|
das Approval-Predicate.
|
||||||
|
- Keine zusätzliche Infrastruktur: WebAuthn-Credentials werden pro
|
||||||
|
Pocket-ID-Benutzer am Approval-Service registriert.
|
||||||
|
|
||||||
|
### Negative / Trade-offs
|
||||||
|
|
||||||
|
- Jeder Benutzer braucht einen WebAuthn-fähigen Authenticator
|
||||||
|
(Plattform-Authenticator, YubiKey o.ä.). Phase 1 setzt in-memory
|
||||||
|
Credential-Registration voraus (Shim); Rollout-Prozess für
|
||||||
|
echte Hardware-Keys ist Phase-2-Thema.
|
||||||
|
- `canonical_json` muss byte-identisch zwischen Preview und Confirm sein
|
||||||
|
— jede Serialisierungs-Änderung bricht alle offenen Previews bis zum
|
||||||
|
`APPROVAL_CHALLENGE_TTL`. Die Kanonisierung ist deshalb in einer
|
||||||
|
einzelnen Funktion in `services/approval-service/internal/signer/`
|
||||||
|
gekapselt und RFC-8785-nah (JCS).
|
||||||
|
- WebAuthn-Fehlerpfade sind UX-sensitiv (`NotAllowedError`,
|
||||||
|
`AbortError`). Das UI muss den Benutzer zur erneuten Preview führen,
|
||||||
|
statt stumm zu scheitern.
|
||||||
|
|
||||||
|
### Folgearbeiten / Open Points
|
||||||
|
|
||||||
|
- **Echtes WebAuthn-Backend** (`github.com/go-webauthn/webauthn`) in
|
||||||
|
Phase 2 inklusive persistiertem Credential-Store (PostgreSQL,
|
||||||
|
Migration folgt in `db/trust/migrations/`).
|
||||||
|
- **Browser-UI** zur Preview + Prompt-Darstellung inkl. Anzeige der
|
||||||
|
`verification_digits`. Separates Follow-up-Issue.
|
||||||
|
- **Attested Credential-Enrollment:** die Erstregistrierung eines
|
||||||
|
Credentials selbst sollte attestiert werden (`attestationObject`
|
||||||
|
prüfen, Herstellermodell loggen). Phase-2-Scope.
|
||||||
|
- **Fallback-Policy** für Clients ohne WebAuthn — siehe oben
|
||||||
|
(`binding_level`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- `services/approval-service/README.md` — API-Vertrag + Konfigurations-ENVs
|
||||||
|
- `services/approval-service/internal/signer/signer.go` — WebAuthn-Shim-Implementierung
|
||||||
|
- `deploy/approval-service/README.md` — Kubernetes-Manifeste + Secret-Konvention
|
||||||
|
- `docs/architecture/v1.1.md` §4.3 (Content Binding)
|
||||||
|
- [ADR-0005](0005-signing-service-and-transparency-log-api.md) — DSSE-/TLog-API, Konsument hier
|
||||||
|
- WebAuthn Level 2: https://www.w3.org/TR/webauthn-2/
|
||||||
|
- RFC 8785 (JSON Canonicalisation Scheme)
|
||||||
189
docs/adr/0007-signing-service-dual-identity-check.md
Normal file
189
docs/adr/0007-signing-service-dual-identity-check.md
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# ADR-0007: Signing-Service — zweistufige Identitätsprüfung (Pocket-ID JWKS + K8s TokenReview)
|
||||||
|
|
||||||
|
- **Status:** accepted
|
||||||
|
- **Datum:** 2026-10-25
|
||||||
|
- **Entscheider:** CTO
|
||||||
|
- **Betroffene Dokumente:** `docs/architecture/v1.1.md` §4.2 (DSSE), §9.1 (Workload-Identität), `services/signing-service/internal/httpapi/sign.go`, `services/signing-service/internal/token/`, `services/signing-service/internal/registry/`
|
||||||
|
|
||||||
|
## Kontext und Problemstellung
|
||||||
|
|
||||||
|
[ADR-0005](0005-signing-service-and-transparency-log-api.md) legt die
|
||||||
|
`POST /api/v1/sign/dsse`-API fest; die dort gezeigte Authentisierung
|
||||||
|
("Pocket-ID-Access-Token, client_credentials") war aber die *minimale*
|
||||||
|
Variante. Für den Produktionsbetrieb reicht sie nicht: ein geleaktes
|
||||||
|
Pocket-ID-Client-Credential würde reichen, um aus einem beliebigen Pod
|
||||||
|
(oder einer beliebigen Workstation) einen Transform- oder
|
||||||
|
Approval-Signaturen zu erzeugen, die im Transparency-Log als "signiert
|
||||||
|
von Agent X" landen.
|
||||||
|
|
||||||
|
Zwei Identitäten müssen am Punkt der Signatur gleichzeitig stimmen:
|
||||||
|
|
||||||
|
1. **Wer ruft die API semantisch auf?** — eine OIDC-Client-Identität,
|
||||||
|
die wir dem Agenten/Service im Agent-Registry zugeordnet haben.
|
||||||
|
2. **Welche Workload spricht wirklich mit uns?** — eine
|
||||||
|
Kubernetes-Pod-Identität (Namespace + ServiceAccount), die am
|
||||||
|
TLS-/HTTP-Rand nicht gefälscht werden kann, ohne das K8s-API-Server
|
||||||
|
oder den Node zu kompromittieren.
|
||||||
|
|
||||||
|
Wenn beide Identitäten unabhängig voneinander auf dieselbe
|
||||||
|
Agent-Registry-Row zeigen, ist die Aussage "Agent `agent-X` signiert"
|
||||||
|
substantiell schwerer zu fälschen als mit nur einer der beiden.
|
||||||
|
|
||||||
|
## Entscheidungsoptionen
|
||||||
|
|
||||||
|
- **Option A: Pocket-ID JWKS allein (ADR-0005-Minimum).**
|
||||||
|
Nur `Authorization: Bearer <Pocket-ID-Access-Token>` validieren, aus
|
||||||
|
dem Token `client_id`/`sub` ziehen, Policy entscheidet. Workload-ID
|
||||||
|
wird nicht geprüft.
|
||||||
|
- **Option B: K8s TokenReview allein (projected ServiceAccount token).**
|
||||||
|
Nur `X-Workload-Token: <projected-SA-token>` gegen den
|
||||||
|
Kube-API-Server (`authentication.k8s.io/v1 TokenReview`) prüfen,
|
||||||
|
(ns, sa) → Agent-Registry-Lookup, Policy entscheidet. Pocket-ID wird
|
||||||
|
nicht geprüft.
|
||||||
|
- **Option C: Beide Identitäten prüfen und auf Konvergenz in der
|
||||||
|
Agent-Registry bestehen.**
|
||||||
|
Beide Header verlangen, Pocket-ID-`sub` → Registry-Row A, (ns, sa) →
|
||||||
|
Registry-Row B. Nur signieren, wenn A und B existieren und auf
|
||||||
|
dieselbe Agent-Row zeigen; sonst *Registry-Mismatch* → 403.
|
||||||
|
- **Option D: mTLS mit SPIFFE/SPIRE als zweite Identität.**
|
||||||
|
Statt K8s-TokenReview ein SPIFFE-Mesh einziehen, SVIDs als Client-Zert
|
||||||
|
im HTTP-Request.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Wir wählen **Option C**.
|
||||||
|
|
||||||
|
Konkret:
|
||||||
|
|
||||||
|
- Der Handler nimmt `Authorization: Bearer <JWT>` **und**
|
||||||
|
`X-Workload-Token: <projected-SA-token>` entgegen.
|
||||||
|
- Pocket-ID-Validator (`services/signing-service/internal/token/`)
|
||||||
|
prüft Signatur gegen `{POCKET_ID_ISSUER}/.well-known/jwks.json`,
|
||||||
|
Audience, Expiry → `sub = client_id`.
|
||||||
|
- `TokenReviewer` (`internal/token/k8s_tokenreview.go`) ruft
|
||||||
|
`POST /apis/authentication.k8s.io/v1/tokenreviews` am K8s-API-Server
|
||||||
|
auf, prüft `status.authenticated == true`,
|
||||||
|
`status.user.username` entspricht `system:serviceaccount:<ns>:<sa>` →
|
||||||
|
liefert `(Namespace, ServiceAccount)` zurück.
|
||||||
|
- `Registry.ByPocketID(sub)` **und** `Registry.ByK8sIdentity(ns, sa)`
|
||||||
|
schlagen die jeweils zugehörige Agent-Row nach. Beide Lookups müssen
|
||||||
|
erfolgreich sein **und** dieselbe `agent_id` liefern.
|
||||||
|
- Die strukturierte Policy
|
||||||
|
(`internal/policy/policy.go#StructuredDecider`) bekommt beide
|
||||||
|
Lookups als `Input.Registry.ByPocketID` / `Input.Registry.ByK8s` plus
|
||||||
|
`KeyRef` und entscheidet die Signatur-Erlaubnis.
|
||||||
|
- Beim Entwickeln/CI darf der K8s-Pfad abgeschaltet werden
|
||||||
|
(`TokenReviewer == nil`); dann greift der Legacy-Decider mit nur
|
||||||
|
Pocket-ID-Subject. **Produktion fährt zwingend mit beiden**: die
|
||||||
|
Prüfung `useStructuredPath()` schaltet den Doppel-Check erst dann
|
||||||
|
frei, wenn alle drei Komponenten (TokenReviewer, Registry,
|
||||||
|
StructuredPolicy) verdrahtet sind — Prod-Deployments setzen sie, Dev
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
Fehlermodell:
|
||||||
|
|
||||||
|
- Pocket-ID JWT fehlt/ungültig/abgelaufen → `401 unauthorized`.
|
||||||
|
- `X-Workload-Token` fehlt → `401 unauthorized`
|
||||||
|
(`"missing X-Workload-Token header"`).
|
||||||
|
- TokenReview antwortet `authenticated: false` oder
|
||||||
|
`ErrWorkloadAuthFailed` → `401 unauthorized`.
|
||||||
|
- **Registry-Mismatch** (eine der beiden Lookups scheitert, oder beide
|
||||||
|
liefern unterschiedliche `agent_id`): Policy `deny` mit Begründung
|
||||||
|
`registry_identity_mismatch` → `403 signing_denied`,
|
||||||
|
`error.message` enthält die OPA-/Decider-Deny-Reason. Dieser Fehler
|
||||||
|
ist audit-relevant und wird strukturiert geloggt.
|
||||||
|
- `KeyRef` existiert nicht → `400 unknown_key`.
|
||||||
|
|
||||||
|
### Begründung
|
||||||
|
|
||||||
|
- **Zwei unabhängige Vertrauensanker.** Pocket-ID lebt in einem anderen
|
||||||
|
Deployment, mit anderen Schlüsseln (RSA/ECDSA JWKS). K8s TokenReview
|
||||||
|
verifiziert gegen den API-Server-Signaturpfad. Ein Angreifer muss
|
||||||
|
beide gleichzeitig kompromittieren, nicht nur einen.
|
||||||
|
- **Container-interne Skripte können kein JWT fälschen.** Projected
|
||||||
|
ServiceAccount-Tokens haben kurze Lebenszeit und einen fixen
|
||||||
|
`audience: trust-api`; sie werden vom Kubelet rotierend ausgestellt.
|
||||||
|
Ein Pod kann nicht an einen fremden Namespace heranmanövrieren, ohne
|
||||||
|
dass TokenReview das sofort bemerkt.
|
||||||
|
- **Registry-Mismatch als expliziter Fehlerzustand.** Das ist der
|
||||||
|
Hauptgrund für die Doppelprüfung: wenn der Pocket-ID-Client zu
|
||||||
|
Agent `A` gehört, der Pod aber unter einem SA läuft, der im Registry
|
||||||
|
Agent `B` hinterlegt ist, lehnen wir ab. Verschleppte Leaks (ein
|
||||||
|
gestohlenes Client-Secret in einem anderen Pod) scheitern an diesem
|
||||||
|
Mismatch, nicht erst an einer Policy-Decke weiter oben.
|
||||||
|
- **Kompatibel mit ADR-0005.** Der API-Vertrag bleibt unverändert
|
||||||
|
(`POST /api/v1/sign/dsse`); wir fügen genau einen zusätzlichen Header
|
||||||
|
hinzu und präzisieren das Policy-Input.
|
||||||
|
|
||||||
|
### Alternativen bewusst nicht gewählt
|
||||||
|
|
||||||
|
- **Option A (nur Pocket-ID)** reicht nur, solange wir dem Client-Secret
|
||||||
|
komplett vertrauen. Für Prod zu wenig: ein geleaktes Secret ist nicht
|
||||||
|
rotierbar schnell genug, um die Blast-Radius-Analyse zu bestehen.
|
||||||
|
- **Option B (nur TokenReview)** wäre für in-cluster Konsumenten
|
||||||
|
attraktiv, verliert aber die Pocket-ID-Audit-Spur ("welcher
|
||||||
|
OIDC-Client hat semantisch gehandelt"). Außerdem erschwert sie den
|
||||||
|
Betrieb externer Konsumenten (admin-api aus einem Dev-Workspace), die
|
||||||
|
keinen projected SA-Token haben.
|
||||||
|
- **Option D (SPIFFE/SPIRE)** ist semantisch ähnlich zu C, aber
|
||||||
|
operativ teurer: zusätzliches Mesh, zusätzlicher Lifecycle,
|
||||||
|
komplizierteres Dev-Setup. Wir behalten SPIFFE als Option für Phase
|
||||||
|
3+, falls Multi-Cluster-Signing anstehen sollte. Für den aktuellen
|
||||||
|
Scope (ein Cluster, bekannte Namespaces) ist die TokenReview-Variante
|
||||||
|
billiger und ausreichend.
|
||||||
|
|
||||||
|
## Konsequenzen
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Zwei Identitäten → zwei getrennte Kompromittierungs-Pfade zum
|
||||||
|
Signieren nötig.
|
||||||
|
- Konkreter, benannter Fehlermodus (`registry_identity_mismatch`) für
|
||||||
|
Audits; keine stille Degradierung.
|
||||||
|
- Klarer Policy-Input: `Input.Registry.ByPocketID` /
|
||||||
|
`Input.Registry.ByK8s` sind getrennte Felder, die OPA-Regeln können
|
||||||
|
explizit auf "beide auf dieselbe `agent_id`" prüfen oder
|
||||||
|
Agenten-spezifische Signaturerlaubnisse ableiten.
|
||||||
|
- Dev-Pfad bleibt leicht (`-bearers=dev-token=agent-admin-api`): der
|
||||||
|
Doppel-Check wird nur eingeschaltet, wenn TokenReviewer + Registry +
|
||||||
|
StructuredPolicy alle gesetzt sind.
|
||||||
|
|
||||||
|
### Negative / Trade-offs
|
||||||
|
|
||||||
|
- Konsumenten (admin-api, approval-service, testagent-transform,
|
||||||
|
dummy-sink) müssen den projected SA-Token aus
|
||||||
|
`/var/run/secrets/tokens/trust-api` beschaffen und als
|
||||||
|
`X-Workload-Token`-Header senden. Boilerplate liegt zentral in
|
||||||
|
`services/*/internal/auth/sa_token.go`.
|
||||||
|
- Agent-Registry muss sowohl `pocket_id_client` als auch
|
||||||
|
`k8s_namespace`/`k8s_service_account` pro Agent-Row vorhalten. Das
|
||||||
|
Schema steht in Phase 2 noch nicht final (eigene Migration folgt),
|
||||||
|
Phase 1 kommt mit einem statischen In-Memory-Resolver aus.
|
||||||
|
- Zusätzlicher Netz-Hop pro Signatur zum K8s-API-Server für den
|
||||||
|
TokenReview. Latenz-Budget: ~5 ms, cacheable (Projected SA-Tokens
|
||||||
|
rotieren langsam genug).
|
||||||
|
|
||||||
|
### Folgearbeiten / Open Points
|
||||||
|
|
||||||
|
- **Prod-Agent-Registry:** PostgreSQL-Backend für
|
||||||
|
`registry.Resolver` + Migration. Phase 2.
|
||||||
|
- **Signing-Policy in OPA** (`policies/signing/*.rego`): regelt
|
||||||
|
`agent_id → erlaubte key_refs → erlaubte predicateType-Namen`.
|
||||||
|
Phase 2.
|
||||||
|
- **TokenReview-Caching** (kurzes TTL, Hash(workloadToken) → k8sID),
|
||||||
|
um bei Lastspitzen den API-Server-Hop zu entlasten. Nur bei
|
||||||
|
tatsächlicher Last.
|
||||||
|
- **OpenBao-Transit-Backend** für `Signer`
|
||||||
|
([ADR-0005](0005-signing-service-and-transparency-log-api.md)) —
|
||||||
|
bleibt orthogonal zu dieser ADR, aber lebt im selben Service.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- `services/signing-service/README.md` — API, Dev-Pfad, Deployment
|
||||||
|
- `services/signing-service/internal/httpapi/sign.go` — Handler mit `useStructuredPath()`
|
||||||
|
- `services/signing-service/internal/token/k8s_tokenreview.go` — TokenReview-Client
|
||||||
|
- `services/signing-service/internal/registry/` — Resolver-Interface
|
||||||
|
- `services/testagent-transform/README.md` — Beispiel-Konsument mit beiden Identitäten
|
||||||
|
- `docs/architecture/v1.1.md` §4.2, §9.1
|
||||||
|
- Kubernetes TokenReview API: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#token-review-api
|
||||||
|
- [ADR-0005](0005-signing-service-and-transparency-log-api.md) — API-Form, erweitert durch diese ADR
|
||||||
235
docs/adr/0008-dummy-sink-and-verification-chain.md
Normal file
235
docs/adr/0008-dummy-sink-and-verification-chain.md
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# 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
|
||||||
|
|
@ -18,5 +18,8 @@ Dieses Verzeichnis dokumentiert durchlebte Architekturentscheidungen in [MADR-Fo
|
||||||
| 0003 | [PII-Retention-Richtlinie](0003-pii-retention-policy.md) | accepted |
|
| 0003 | [PII-Retention-Richtlinie](0003-pii-retention-policy.md) | accepted |
|
||||||
| 0004 | [Datenbank-Migrationsframework (golang-migrate)](0004-db-migrations.md) | accepted |
|
| 0004 | [Datenbank-Migrationsframework (golang-migrate)](0004-db-migrations.md) | accepted |
|
||||||
| 0005 | [Signing-Service- und Transparency-Log-API-Vertrag](0005-signing-service-and-transparency-log-api.md) | accepted |
|
| 0005 | [Signing-Service- und Transparency-Log-API-Vertrag](0005-signing-service-and-transparency-log-api.md) | accepted |
|
||||||
|
| 0006 | [Approval-Service — WebAuthn als Content-Binding-Mechanismus](0006-approval-service-webauthn-content-binding.md) | accepted |
|
||||||
|
| 0007 | [Signing-Service — zweistufige Identitätsprüfung (Pocket-ID JWKS + K8s TokenReview)](0007-signing-service-dual-identity-check.md) | accepted |
|
||||||
|
| 0008 | [Dummy-Sink und zweistufige Verification-Chain](0008-dummy-sink-and-verification-chain.md) | accepted |
|
||||||
|
|
||||||
Vorlage: [`0000-template.md`](0000-template.md).
|
Vorlage: [`0000-template.md`](0000-template.md).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue