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

- 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:
CTO (Paperclip) 2026-10-25 21:23:30 +00:00
parent 20ba29d3ac
commit ddbd304659
5 changed files with 641 additions and 2 deletions

View file

@ -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).

View 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)

View 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

View 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

View file

@ -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).