# approval-service Phase 1 der ProofedMesh content-gebundenen Approvals ([DEV-608](/DEV/issues/DEV-608), siehe `docs/architecture/v1.1.md §4.3`). Der Dienst nimmt menschliche Freigaben an, bindet sie kryptographisch an einen deterministisch gerenderten JSON-Inhalt (`H_content = sha256(canonical_json)`), verifiziert eine WebAuthn- Assertion gegen die Challenge `nonce || H_content` und erzeugt daraus eine DSSE-signierte Approval-Attestation, die ins Transparency-Log eingetragen wird. ## Endpoints Alle Routen sind JSON-API, Pocket-ID-Session-Cookie-geschützt. | Methode | Pfad | Zweck | | ------- | --------------------------- | -------------------------------------------------------------------------------------------------- | | `POST` | `/api/v1/sessions` | Startet eine Approval-Session. Antwort: `{session_id, active_until}`. | | `POST` | `/api/v1/approvals/preview` | Rendert Freigabe-Inhalt serverseitig, berechnet `H_content`, legt Challenge und Prüfziffer ab. | | `POST` | `/api/v1/approvals/confirm` | Nimmt die WebAuthn-Assertion entgegen, signiert und loggt die Approval. | | `GET` | `/healthz` | Liveness. | ### `POST /api/v1/sessions` Erzeugt eine Approval-Session an die aktuelle Pocket-ID-`sub`. `active_until` ist in Phase 1 fest auf `SessionTTL` (Default 15 min). ### `POST /api/v1/approvals/preview` Request: ```json { "session_id": "sess_…", "content": { "...application-defined..." } } ``` Response: ```json { "preview_id": "pv_…", "content_hash": "sha256:", "verification_digits": "734219", "webauthn_challenge": "", "expires_at": "2026-01-01T12:00:00Z" } ``` `verification_digits` ist eine 6-stellige Prüfziffer aus `H_content`, die der Mensch im UI vor der Freigabe mit der Signaturaufforderung abgleichen MUSS. `webauthn_challenge` ist bereits `nonce || H_content` (b64url), damit der Browser direkt eine WebAuthn-Assertion anfordern kann. ### `POST /api/v1/approvals/confirm` Request: ```json { "preview_id": "pv_…", "assertion": { "credential_id": "", "client_data_json": "", "authenticator_data": "", "signature": "" } } ``` Response: ```json { "approval_id": "ap_…", "attestation_ref": "sha256:", "log_index": 42, "tlog_status": "logged" } ``` Wenn der TLog-Submit scheitert, bleibt die Row `tlog_status = "attestation-pending"`; die Antwort meldet dann `log_index: null, tlog_status: "attestation-pending"`. Ein späterer Reconciler zieht den Submit nach (ADR-0005, idempotent per `content_hash`). ## Fehler | Zustand | Code | Body `error.code` | | -------------------------------------------- | ---- | ------------------------- | | Session abgelaufen / unbekannt | 403 | `session_expired` | | `H_content`-Mismatch (Assertion vs. Preview) | 400 | `content_hash_mismatch` | | Challenge abgelaufen | 403 | `challenge_expired` | | WebAuthn-Assertion ungültig | 401 | `assertion_invalid` | | Signing-Service nicht erreichbar | 502 | `signer_unavailable` | ## Phase 1 Scope - Store ist in-memory (`internal/store/memory.go`); Postgres-Impl ist in `internal/store/postgres.go` als Skelett angelegt (Pattern aus `admin-api/internal/store/postgres.go`). Die SQL-Migration liegt bereits in `db/trust/migrations/0004_approval_sessions.*.sql`. - WebAuthn-Verifikation ist eine stdlib-only Shim im Stil von `admin-api/internal/auth/pocket_id.go`: In-Memory-`Credential`-Store, Ed25519-Assertion über `authenticator_data || sha256(client_data_json)`, Challenge-Vergleich konstantzeitig. Die Datenformate sind WebAuthn-kompatibel (b64url-encoded `client_data_json` mit `challenge`, `type`, `origin`), damit das Phase-2-Umstieg auf `github.com/go-webauthn/webauthn` ohne API-Bruch möglich bleibt. - Keine HTML/UI; nur JSON. Browser-Integration landet in einem Folge-Task. ## Konfiguration Alle über Flags oder Env (`APPROVAL_*`): | Env / Flag | Default | Zweck | | ------------------------------- | --------------------------- | --------------------------------------------------------- | | `APPROVAL_LISTEN` | `:8080` | HTTP-Listen-Adresse. | | `APPROVAL_SIGNING_URL` | *(required)* | Base URL des signing-service (`/api/v1/sign/dsse`). | | `APPROVAL_SIGNING_KEY_REF` | `trust-api/approval-signer-2026-q4` | DSSE-Signaturschlüssel. | | `APPROVAL_SIGNING_TOKEN` | *(optional)* | Bearer-Token für signing-service (Phase 1 Static). | | `APPROVAL_TLOG_URL` | *(required)* | Base URL des transparency-log. | | `APPROVAL_SESSION_TTL` | `15m` | Lebensdauer einer Approval-Session. | | `APPROVAL_CHALLENGE_TTL` | `2m` | Max. Zeit zwischen Preview und Confirm. | | `APPROVAL_WEBAUTHN_RP_ID` | `trust.basicstack.de` | WebAuthn relying party ID. | | `APPROVAL_WEBAUTHN_RP_ORIGIN` | `https://trust.basicstack.de` | Erlaubter Origin in `client_data_json`. | | `APPROVAL_POCKET_ID_ISSUER` | *(required in prod)* | Pocket-ID OIDC-Issuer. | | `APPROVAL_POCKET_ID_CLIENT` | *(required in prod)* | Client-ID des approval-service. | ## Tests ``` cd services/approval-service go test ./... ``` Deckt ab: - Happy Path (Session → Preview → Confirm → DSSE verifiziert und ins TLog eingetragen). - Tampered `H_content` in der Assertion → 400. - Abgelaufene Challenge → 403. - Signing-Service antwortet nicht (connection refused) → 502. - TLog-Submit scheitert → Approval bleibt `attestation-pending`.