From 3a01cc651fed1cf0ce9dc35fe6ea489f7bb43f374e6508528a8f5691a2e496cf Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sun, 25 Oct 2026 19:42:06 +0000 Subject: [PATCH] approval-service: Phase-1 content-binding approval service (DEV-608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New services/approval-service with the Session → Preview → Confirm pipeline required by DEV-606/DEV-608: * POST /api/v1/sessions starts a Pocket-ID-bound approval session. * POST /api/v1/approvals/preview renders content server-side, computes H_content = sha256(canonical JSON), stores the (nonce, salt, canonical, challenge) tuple and returns the human verification digits plus the WebAuthn challenge = nonce || H_content. * POST /api/v1/approvals/confirm verifies the WebAuthn assertion against the stored challenge, DSSE-signs the Approval statement via the signing-service (key_ref=trust-api/approval-signer-2026-q4), submits to the transparency-log, and returns {approval_id, attestation_ref, log_index}. TLog failures leave the row at attestation-pending for the reconciler. * db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables approval_sessions, approval_previews, approvals (schema matches the Phase 1 store contract). * internal/content: canonical JSON + H_content + 6-digit verification code (byte-compatible with signing-service/internal/canonical). * internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn. * internal/signer, internal/tlog: HTTP clients mapping 5xx / connection errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the HTTP layer can return 502 for signing failures and persist attestation-pending on TLog failure. * internal/service.Orchestrator ties the pipeline together and persists the Approval BEFORE the TLog submit (never return a log_index for an un-persisted approval). Tests cover: happy path, tampered H_content → 400, expired challenge → 403, signer 5xx + conn-refused → 502, TLog failure → row stays attestation-pending, duplicate confirm → 409, session expired → 403. go vet clean, go test ./... passes. Co-Authored-By: Paperclip --- .../0004_approval_sessions.down.sql | 11 + .../migrations/0004_approval_sessions.up.sql | 102 ++++ services/approval-service/README.md | 144 ++++++ .../cmd/approval-service/main.go | 149 ++++++ services/approval-service/go.mod | 3 + .../approval-service/internal/auth/context.go | 62 +++ .../internal/canonical/canonical.go | 105 ++++ .../internal/canonical/canonical_test.go | 40 ++ .../internal/content/binding.go | 117 +++++ .../internal/content/binding_test.go | 89 ++++ .../internal/httpapi/server.go | 245 +++++++++ .../internal/httpapi/server_test.go | 463 ++++++++++++++++++ .../internal/service/orchestrator.go | 236 +++++++++ .../internal/sessions/sessions.go | 97 ++++ .../internal/sessions/sessions_test.go | 59 +++ .../internal/signer/signer.go | 134 +++++ .../internal/signer/signer_test.go | 88 ++++ .../approval-service/internal/store/memory.go | 136 +++++ .../internal/store/postgres.go | 41 ++ .../approval-service/internal/store/store.go | 90 ++++ .../internal/tlog/submitter.go | 122 +++++ .../internal/webauthn/webauthn.go | 225 +++++++++ .../internal/webauthn/webauthn_test.go | 134 +++++ .../approval-service/pkg/httpapi/types.go | 65 +++ 24 files changed, 2957 insertions(+) create mode 100644 db/trust/migrations/0004_approval_sessions.down.sql create mode 100644 db/trust/migrations/0004_approval_sessions.up.sql create mode 100644 services/approval-service/README.md create mode 100644 services/approval-service/cmd/approval-service/main.go create mode 100644 services/approval-service/go.mod create mode 100644 services/approval-service/internal/auth/context.go create mode 100644 services/approval-service/internal/canonical/canonical.go create mode 100644 services/approval-service/internal/canonical/canonical_test.go create mode 100644 services/approval-service/internal/content/binding.go create mode 100644 services/approval-service/internal/content/binding_test.go create mode 100644 services/approval-service/internal/httpapi/server.go create mode 100644 services/approval-service/internal/httpapi/server_test.go create mode 100644 services/approval-service/internal/service/orchestrator.go create mode 100644 services/approval-service/internal/sessions/sessions.go create mode 100644 services/approval-service/internal/sessions/sessions_test.go create mode 100644 services/approval-service/internal/signer/signer.go create mode 100644 services/approval-service/internal/signer/signer_test.go create mode 100644 services/approval-service/internal/store/memory.go create mode 100644 services/approval-service/internal/store/postgres.go create mode 100644 services/approval-service/internal/store/store.go create mode 100644 services/approval-service/internal/tlog/submitter.go create mode 100644 services/approval-service/internal/webauthn/webauthn.go create mode 100644 services/approval-service/internal/webauthn/webauthn_test.go create mode 100644 services/approval-service/pkg/httpapi/types.go diff --git a/db/trust/migrations/0004_approval_sessions.down.sql b/db/trust/migrations/0004_approval_sessions.down.sql new file mode 100644 index 0000000..6708ce0 --- /dev/null +++ b/db/trust/migrations/0004_approval_sessions.down.sql @@ -0,0 +1,11 @@ +-- Rollback for 0004_approval_sessions. +-- DESTRUKTIV: entfernt sämtliche Approval-Historie (Sessions, Previews, +-- bestätigte Approvals). Die in Trillian abgelegten DSSE-Envelopes bleiben +-- erhalten, aber die Vorlagen und Session-Hintergründe sind weg; Audits, +-- die auf H_content vs. gespeicherte canonical content zurückgreifen, +-- verlieren damit ihren lokalen Referenzpunkt. Vor Anwendung IMMER einen +-- Dump der trust-DB ziehen. + +DROP TABLE IF EXISTS approvals; +DROP TABLE IF EXISTS approval_previews; +DROP TABLE IF EXISTS approval_sessions; diff --git a/db/trust/migrations/0004_approval_sessions.up.sql b/db/trust/migrations/0004_approval_sessions.up.sql new file mode 100644 index 0000000..6426270 --- /dev/null +++ b/db/trust/migrations/0004_approval_sessions.up.sql @@ -0,0 +1,102 @@ +-- Migration: 0004_approval_sessions +-- Content-gebundene Approvals gemäß docs/architecture/v1.1.md §4.3 und +-- Phase-1-Spezifikation [DEV-608](/DEV/issues/DEV-608). +-- +-- Drei Tabellen, strikt in der Reihenfolge der Session → Preview → Confirm +-- Pipeline des approval-service: +-- +-- 1. `approval_sessions` — eine durch die Pocket-ID-Session gestützte +-- Approval-Session; sie bindet (session_id, user_sub) an ein Ablauf- +-- datum und wird vom Approval-UI pro Browser-Login einmal erzeugt. +-- 2. `approval_previews` — ein serverseitig gerendertes Freigabe-Objekt; +-- Content-Hash (`H_content`), Salt und Challenge-Nonce werden gleich +-- bei der Vorschau festgenagelt, damit das Confirm-Step eine *andere* +-- Payload nicht nachträglich unterschieben kann. +-- 3. `approvals` — die bestätigte, DSSE-signierte Approval. `tlog_status` +-- spiegelt den TLog-Einwurf; `attestation-pending` bedeutet die Row +-- ist persistiert, der TLog-Submit aber noch nicht quittiert und wird +-- vom attest-reconciler bzw. einem späteren Worker nachgezogen. +-- +-- Design-Entscheidungen: +-- * Alle `*_id`-Spalten sind TEXT (opaque IDs; Service-generiert, nicht +-- UUID gezwungen) — bleibt kompatibel mit dem bestehenden Stil der +-- Trust-DB. +-- * `content_canonical` wird in Preview mitgespeichert, damit Confirm +-- den Hash deterministisch nachrechnen und das signierte Statement +-- ohne erneutes Rendern aufbauen kann. +-- * `tlog_status` ist ein Enum via CHECK statt pg-native ENUM, um die +-- Migration ohne CREATE TYPE drop-safe zu halten. + +CREATE TABLE IF NOT EXISTS approval_sessions ( + session_id TEXT PRIMARY KEY + CHECK (length(session_id) BETWEEN 16 AND 128), + user_sub TEXT NOT NULL + CHECK (length(user_sub) BETWEEN 1 AND 256), + active_until TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_approval_sessions_user + ON approval_sessions (user_sub); + +CREATE INDEX idx_approval_sessions_active_until + ON approval_sessions (active_until); + +CREATE TABLE IF NOT EXISTS approval_previews ( + preview_id TEXT PRIMARY KEY + CHECK (length(preview_id) BETWEEN 16 AND 128), + session_id TEXT NOT NULL + REFERENCES approval_sessions(session_id) + ON DELETE CASCADE, + content_hash TEXT NOT NULL + CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'), + content_canonical BYTEA NOT NULL, + salt BYTEA NOT NULL + CHECK (length(salt) BETWEEN 16 AND 64), + challenge_nonce BYTEA NOT NULL + CHECK (length(challenge_nonce) BETWEEN 16 AND 64), + verification_digits TEXT NOT NULL + CHECK (verification_digits ~ '^[0-9]{4,12}$'), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + consumed_at TIMESTAMPTZ +); + +CREATE INDEX idx_approval_previews_session + ON approval_previews (session_id); + +CREATE INDEX idx_approval_previews_expires + ON approval_previews (expires_at) + WHERE consumed_at IS NULL; + +CREATE TABLE IF NOT EXISTS approvals ( + approval_id TEXT PRIMARY KEY + CHECK (length(approval_id) BETWEEN 16 AND 128), + preview_id TEXT NOT NULL + REFERENCES approval_previews(preview_id), + session_id TEXT NOT NULL + REFERENCES approval_sessions(session_id), + user_sub TEXT NOT NULL, + content_hash TEXT NOT NULL + CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'), + envelope_json JSONB NOT NULL, + attestation_ref TEXT NOT NULL + CHECK (attestation_ref ~ '^sha256:[0-9a-f]{64}$'), + tlog_status TEXT NOT NULL DEFAULT 'attestation-pending' + CHECK (tlog_status IN ('attestation-pending', + 'logged', + 'failed')), + log_index BIGINT, + tlog_error TEXT, + confirmed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + logged_at TIMESTAMPTZ, + CONSTRAINT approvals_log_consistency + CHECK ((tlog_status = 'logged') = (log_index IS NOT NULL)) +); + +CREATE INDEX idx_approvals_tlog_pending + ON approvals (confirmed_at) + WHERE tlog_status = 'attestation-pending'; + +CREATE INDEX idx_approvals_user + ON approvals (user_sub, confirmed_at); diff --git a/services/approval-service/README.md b/services/approval-service/README.md new file mode 100644 index 0000000..1c25e33 --- /dev/null +++ b/services/approval-service/README.md @@ -0,0 +1,144 @@ +# 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`. diff --git a/services/approval-service/cmd/approval-service/main.go b/services/approval-service/cmd/approval-service/main.go new file mode 100644 index 0000000..e139963 --- /dev/null +++ b/services/approval-service/cmd/approval-service/main.go @@ -0,0 +1,149 @@ +// Command approval-service is the Phase-1 ProofedMesh content-binding +// approval HTTP server. See services/approval-service/README.md for the +// endpoint contract and configuration. +package main + +import ( + "errors" + "flag" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/auth" + "basicstack.de/proofedmesh/services/approval-service/internal/httpapi" + "basicstack.de/proofedmesh/services/approval-service/internal/service" + "basicstack.de/proofedmesh/services/approval-service/internal/sessions" + "basicstack.de/proofedmesh/services/approval-service/internal/signer" + "basicstack.de/proofedmesh/services/approval-service/internal/store" + "basicstack.de/proofedmesh/services/approval-service/internal/tlog" + "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" +) + +func main() { + cfg := struct { + Listen string + SigningURL string + SigningKeyRef string + SigningToken string + TLogURL string + SessionTTL time.Duration + ChallengeTTL time.Duration + WebauthnRPID string + WebauthnOrigins string + AttestationIss string + }{} + flag.StringVar(&cfg.Listen, "listen", envOr("APPROVAL_LISTEN", ":8080"), "HTTP listen address") + flag.StringVar(&cfg.SigningURL, "signing-url", os.Getenv("APPROVAL_SIGNING_URL"), "signing-service base URL") + flag.StringVar(&cfg.SigningKeyRef, "signing-key-ref", envOr("APPROVAL_SIGNING_KEY_REF", "trust-api/approval-signer-2026-q4"), "DSSE signing key reference") + flag.StringVar(&cfg.SigningToken, "signing-token", os.Getenv("APPROVAL_SIGNING_TOKEN"), "Bearer token for signing-service (Phase 1 static)") + flag.StringVar(&cfg.TLogURL, "tlog-url", os.Getenv("APPROVAL_TLOG_URL"), "transparency-log base URL") + flag.DurationVar(&cfg.SessionTTL, "session-ttl", envDur("APPROVAL_SESSION_TTL", 15*time.Minute), "approval session lifetime") + flag.DurationVar(&cfg.ChallengeTTL, "challenge-ttl", envDur("APPROVAL_CHALLENGE_TTL", 2*time.Minute), "challenge lifetime between preview and confirm") + flag.StringVar(&cfg.WebauthnRPID, "webauthn-rp-id", envOr("APPROVAL_WEBAUTHN_RP_ID", "trust.basicstack.de"), "WebAuthn relying party ID") + flag.StringVar(&cfg.WebauthnOrigins, "webauthn-origins", envOr("APPROVAL_WEBAUTHN_RP_ORIGIN", "https://trust.basicstack.de"), "comma-separated allowed WebAuthn origins") + flag.StringVar(&cfg.AttestationIss, "attestation-issuer", envOr("APPROVAL_POCKET_ID_ISSUER", "https://auth.basicstack.de"), "issuer embedded in attestations") + flag.Parse() + + logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + + if cfg.SigningURL == "" { + fatal(logger, errors.New("APPROVAL_SIGNING_URL (or -signing-url) is required")) + } + if cfg.TLogURL == "" { + fatal(logger, errors.New("APPROVAL_TLOG_URL (or -tlog-url) is required")) + } + + st := store.NewMemory() + mgr := sessions.New(st) + mgr.SessionTTL = cfg.SessionTTL + mgr.ChallengeTTL = cfg.ChallengeTTL + + signClient := signer.Client{ + BaseURL: cfg.SigningURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + TokenSource: signer.StaticToken(cfg.SigningToken), + } + tlogClient := tlog.Client{ + BaseURL: cfg.TLogURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } + + orch := &service.Orchestrator{ + Store: st, + Sessions: mgr, + Webauthn: webauthn.NewStore(), + WebauthnCfg: webauthn.VerifyConfig{ + RPID: cfg.WebauthnRPID, + AllowedOrigins: splitAndTrim(cfg.WebauthnOrigins, ","), + }, + Signer: signClient, + Submitter: tlogClient, + SigningKeyRef: cfg.SigningKeyRef, + AttestationIssuer: cfg.AttestationIss, + } + + server := &httpapi.Server{ + // Phase 1 wires an "unauthenticated" dev resolver — the Pocket-ID + // cookie middleware (shared with admin-api) attaches in deployment. + // This default is deliberately noisy so misconfigured deployments + // fail closed rather than silently accepting anonymous callers. + Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) { + return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic") + }), + Sessions: mgr, + Orchestrator: orch, + Logger: logger, + } + + mux := http.NewServeMux() + server.Mount(mux) + + logger.Info("approval-service starting", + "listen", cfg.Listen, + "signing_url", cfg.SigningURL, + "tlog_url", cfg.TLogURL, + "webauthn_rp_id", cfg.WebauthnRPID, + ) + if err := http.ListenAndServe(cfg.Listen, mux); err != nil { + fatal(logger, err) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envDur(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + return d +} + +func splitAndTrim(s, sep string) []string { + parts := strings.Split(s, sep) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func fatal(logger *slog.Logger, err error) { + logger.Error("approval-service: fatal", "err", err) + os.Exit(1) +} diff --git a/services/approval-service/go.mod b/services/approval-service/go.mod new file mode 100644 index 0000000..e5d9a17 --- /dev/null +++ b/services/approval-service/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/approval-service + +go 1.23 diff --git a/services/approval-service/internal/auth/context.go b/services/approval-service/internal/auth/context.go new file mode 100644 index 0000000..902c7f6 --- /dev/null +++ b/services/approval-service/internal/auth/context.go @@ -0,0 +1,62 @@ +// Package auth carries the authenticated principal across the HTTP stack +// so handlers do not re-decode the Pocket-ID session cookie on each call. +// The Pocket-ID verification lives in services/admin-api/internal/auth; the +// approval-service accepts a Principal already stamped by the gateway or +// its own future middleware wrap (Phase 2). +package auth + +import ( + "context" + "errors" +) + +type ctxKey int + +const ( + principalKey ctxKey = iota + sessionKey +) + +// Principal is the subject identifier (Pocket-ID `sub`) of the browser user +// driving the approval. Attestations will carry it as `performed_by.sub`. +type Principal struct { + Sub string + Groups []string +} + +// ErrNoPrincipal is returned by MustPrincipal when the request context did +// not carry an authenticated principal. +var ErrNoPrincipal = errors.New("auth: request is not authenticated") + +// WithPrincipal returns a copy of ctx carrying p. +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalKey, p) +} + +// FromContext returns the Principal stamped by WithPrincipal. +func FromContext(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey).(Principal) + return p, ok +} + +// MustFromContext is a convenience that returns ErrNoPrincipal when no +// principal is present. +func MustFromContext(ctx context.Context) (Principal, error) { + p, ok := FromContext(ctx) + if !ok || p.Sub == "" { + return Principal{}, ErrNoPrincipal + } + return p, nil +} + +// WithSession binds the Pocket-ID `sid` claim to ctx. Approval-sessions +// scope themselves to the browser login via this value. +func WithSession(ctx context.Context, sid string) context.Context { + return context.WithValue(ctx, sessionKey, sid) +} + +// SessionFromContext returns the browser sid carried in ctx. +func SessionFromContext(ctx context.Context) (string, bool) { + s, ok := ctx.Value(sessionKey).(string) + return s, ok && s != "" +} diff --git a/services/approval-service/internal/canonical/canonical.go b/services/approval-service/internal/canonical/canonical.go new file mode 100644 index 0000000..6c37091 --- /dev/null +++ b/services/approval-service/internal/canonical/canonical.go @@ -0,0 +1,105 @@ +// Package canonical implements the stable JSON serialisation the +// approval-service uses to compute H_content. It MUST stay byte-for-byte +// compatible with services/signing-service/internal/canonical/canonical.go +// and services/admin-api/internal/attest/hash.go, otherwise the signing +// service's content_hash of the Statement would not match the H_content we +// bound into the WebAuthn challenge. +// +// Not a full RFC 8785 implementation: all inputs we hash are strings, +// nulls, bools, numbers (as float64 after a json.Unmarshal round-trip), and +// nested arrays/maps of those. encoding/json emits stable output for the +// leaves; we only force deterministic object key ordering. +package canonical + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" +) + +// SHA256Hex returns the hex-encoded SHA-256 of b. +func SHA256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// Marshal returns the canonical JSON encoding of v (sorted object keys). +func Marshal(v any) ([]byte, error) { + first, err := json.Marshal(v) + if err != nil { + return nil, err + } + var any_ any + if err := json.Unmarshal(first, &any_); err != nil { + return nil, err + } + var buf bytes.Buffer + if err := writeSorted(&buf, any_); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Hash returns the sha256: content hash of v's canonical JSON. +func Hash(v any) (string, []byte, error) { + raw, err := Marshal(v) + if err != nil { + return "", nil, err + } + return "sha256:" + SHA256Hex(raw), raw, nil +} + +func writeSorted(buf *bytes.Buffer, v any) error { + switch x := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if x { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case float64: + b, _ := json.Marshal(x) + buf.Write(b) + case string: + b, _ := json.Marshal(x) + buf.Write(b) + case []any: + buf.WriteByte('[') + for i, el := range x { + if i > 0 { + buf.WriteByte(',') + } + if err := writeSorted(buf, el); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + kb, _ := json.Marshal(k) + buf.Write(kb) + buf.WriteByte(':') + if err := writeSorted(buf, x[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("canonical.writeSorted: unsupported type %T", v) + } + return nil +} diff --git a/services/approval-service/internal/canonical/canonical_test.go b/services/approval-service/internal/canonical/canonical_test.go new file mode 100644 index 0000000..d9e86bc --- /dev/null +++ b/services/approval-service/internal/canonical/canonical_test.go @@ -0,0 +1,40 @@ +package canonical + +import "testing" + +func TestMarshalSortsKeys(t *testing.T) { + got, err := Marshal(map[string]any{"b": 1, "a": 2}) + if err != nil { + t.Fatal(err) + } + // Both keys must be present and sorted by name + want := `{"a":2,"b":1}` + if string(got) != want { + t.Errorf("got %q want %q", string(got), want) + } +} + +func TestMarshalStableAcrossRuns(t *testing.T) { + input := map[string]any{ + "outer": map[string]any{"z": 1, "a": 2, "m": 3}, + "arr": []any{"3", "1", "2"}, + } + a, _ := Marshal(input) + b, _ := Marshal(input) + if string(a) != string(b) { + t.Errorf("not stable:\n%s\nvs\n%s", a, b) + } +} + +func TestHashReturnsPrefix(t *testing.T) { + hash, raw, err := Hash(map[string]any{"k": "v"}) + if err != nil { + t.Fatal(err) + } + if hash[:7] != "sha256:" { + t.Errorf("hash missing sha256: prefix: %q", hash) + } + if string(raw) != `{"k":"v"}` { + t.Errorf("canonical form: %q", raw) + } +} diff --git a/services/approval-service/internal/content/binding.go b/services/approval-service/internal/content/binding.go new file mode 100644 index 0000000..f1e2245 --- /dev/null +++ b/services/approval-service/internal/content/binding.go @@ -0,0 +1,117 @@ +// Package content implements the content-binding primitives of the +// approval-service: the H_content hash of a renderable approval payload, +// the WebAuthn challenge derivation (nonce || H_content), and the human- +// readable verification digits an operator cross-checks against the UI. +// +// The *content* passed in from the HTTP handler is server-rendered JSON — +// not the raw request payload. "Server-rendered" means we have already +// resolved template variables, lookups, and defaults, so H_content is a +// hash of exactly what the approving human saw. See +// docs/architecture/v1.1.md §4.3. +package content + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + + "basicstack.de/proofedmesh/services/approval-service/internal/canonical" +) + +// Phase1Rendered is the server-rendered approval payload. For Phase 1 the +// approval-service does not own a template language — the caller hands us +// the fully resolved object (title, bullets, action, etc.) and we hash it +// as-is. Phase 2 will introduce typed template kinds. +type Phase1Rendered = map[string]any + +// Binding is everything the preview endpoint persists so the confirm +// endpoint can verify the WebAuthn assertion unambiguously. +type Binding struct { + // ContentCanonical is the canonical JSON bytes of the rendered content. + ContentCanonical []byte + // ContentHash is "sha256:" over ContentCanonical. + ContentHash string + // Salt is 16 random bytes mixed into VerificationDigits so an attacker + // who guesses a content payload cannot pre-compute its digits. + Salt []byte + // Nonce is 32 random bytes forming the first half of the WebAuthn + // challenge; the second half is the raw sha256 of ContentCanonical. + Nonce []byte + // Challenge is Nonce || sha256(ContentCanonical). Base64url-encoded on + // the wire inside the WebAuthn ClientDataJSON `challenge` field. + Challenge []byte + // VerificationDigits is a short 6-digit number derived from the + // H_content + Salt, shown to the human for cross-checking. + VerificationDigits string +} + +// Bind computes the full content-binding for rendered. The caller MUST +// pass server-rendered content (NOT raw user input). +func Bind(rendered Phase1Rendered) (Binding, error) { + if rendered == nil { + return Binding{}, errors.New("content: rendered payload is nil") + } + raw, err := canonical.Marshal(rendered) + if err != nil { + return Binding{}, fmt.Errorf("canonicalise rendered: %w", err) + } + sum := sha256.Sum256(raw) + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return Binding{}, fmt.Errorf("salt: %w", err) + } + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return Binding{}, fmt.Errorf("nonce: %w", err) + } + challenge := make([]byte, 0, len(nonce)+len(sum)) + challenge = append(challenge, nonce...) + challenge = append(challenge, sum[:]...) + return Binding{ + ContentCanonical: raw, + ContentHash: "sha256:" + hex.EncodeToString(sum[:]), + Salt: salt, + Nonce: nonce, + Challenge: challenge, + VerificationDigits: digits(sum[:], salt), + }, nil +} + +// RehashCanonical takes previously-stored canonical bytes and recomputes +// the hash. Used on the confirm path to prove the preview's canonical +// bytes still produce the expected ContentHash — defense-in-depth against +// store tampering. +func RehashCanonical(canonicalJSON []byte) string { + sum := sha256.Sum256(canonicalJSON) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// ChallengeFor returns the canonical WebAuthn challenge bytes given a +// stored (nonce, canonicalJSON) pair. We re-derive the challenge rather +// than storing it so a corrupted challenge column cannot smuggle an +// attacker-chosen value into the signature path. +func ChallengeFor(nonce, canonicalJSON []byte) []byte { + sum := sha256.Sum256(canonicalJSON) + out := make([]byte, 0, len(nonce)+len(sum)) + out = append(out, nonce...) + out = append(out, sum[:]...) + return out +} + +// digits folds H_content and salt into a 6-digit decimal code. The salt +// prevents an attacker who knows a candidate rendered payload from pre- +// computing the digits an operator will see. +func digits(hContent, salt []byte) string { + h := sha256.New() + h.Write([]byte("verification-digits/v1\x00")) + h.Write(hContent) + h.Write([]byte{0x00}) + h.Write(salt) + sum := h.Sum(nil) + // take the first 8 bytes as a big-endian uint64, mod 10^6 for 6 digits. + n := binary.BigEndian.Uint64(sum[:8]) % 1_000_000 + return fmt.Sprintf("%06d", n) +} diff --git a/services/approval-service/internal/content/binding_test.go b/services/approval-service/internal/content/binding_test.go new file mode 100644 index 0000000..69dd068 --- /dev/null +++ b/services/approval-service/internal/content/binding_test.go @@ -0,0 +1,89 @@ +package content + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestBindStableHash(t *testing.T) { + payload := Phase1Rendered{ + "title": "Approve policy change v3", + "action": "sign", + "detail": map[string]any{"policy_id": "pol-42"}, + } + a, err := Bind(payload) + if err != nil { + t.Fatalf("Bind: %v", err) + } + b, err := Bind(payload) + if err != nil { + t.Fatalf("Bind again: %v", err) + } + if a.ContentHash != b.ContentHash { + t.Errorf("content_hash not stable: %q vs %q", a.ContentHash, b.ContentHash) + } + if !bytes.Equal(a.ContentCanonical, b.ContentCanonical) { + t.Errorf("canonical bytes not stable") + } + if bytes.Equal(a.Salt, b.Salt) { + t.Errorf("salt should be random (equal across two calls)") + } + if bytes.Equal(a.Nonce, b.Nonce) { + t.Errorf("nonce should be random (equal across two calls)") + } +} + +func TestBindRejectsNil(t *testing.T) { + if _, err := Bind(nil); err == nil { + t.Fatalf("Bind(nil) must error") + } +} + +func TestVerificationDigitsFormat(t *testing.T) { + b, err := Bind(Phase1Rendered{"k": "v"}) + if err != nil { + t.Fatal(err) + } + if l := len(b.VerificationDigits); l != 6 { + t.Errorf("want 6 digits, got %d (%q)", l, b.VerificationDigits) + } + for _, r := range b.VerificationDigits { + if r < '0' || r > '9' { + t.Errorf("non-digit %q in verification digits %q", r, b.VerificationDigits) + } + } +} + +func TestChallengeFor_Deterministic(t *testing.T) { + b, err := Bind(Phase1Rendered{"k": "v"}) + if err != nil { + t.Fatal(err) + } + got := ChallengeFor(b.Nonce, b.ContentCanonical) + if !bytes.Equal(got, b.Challenge) { + t.Errorf("ChallengeFor must reproduce the challenge") + } + // Flip a byte in canonical content — challenge must change. + alt := append([]byte(nil), b.ContentCanonical...) + alt[0] ^= 0xff + if bytes.Equal(ChallengeFor(b.Nonce, alt), b.Challenge) { + t.Errorf("challenge did not change when canonical content was tampered") + } +} + +func TestRehashCanonicalMatchesBind(t *testing.T) { + b, err := Bind(Phase1Rendered{"foo": "bar"}) + if err != nil { + t.Fatal(err) + } + if got := RehashCanonical(b.ContentCanonical); got != b.ContentHash { + t.Errorf("RehashCanonical=%q want %q", got, b.ContentHash) + } + // Sanity: computed independently + sum := sha256.Sum256(b.ContentCanonical) + if "sha256:"+hex.EncodeToString(sum[:]) != b.ContentHash { + t.Errorf("ContentHash does not match computed sha256") + } +} diff --git a/services/approval-service/internal/httpapi/server.go b/services/approval-service/internal/httpapi/server.go new file mode 100644 index 0000000..25d8ae4 --- /dev/null +++ b/services/approval-service/internal/httpapi/server.go @@ -0,0 +1,245 @@ +// Package httpapi mounts the approval-service HTTP endpoints. +package httpapi + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/auth" + "basicstack.de/proofedmesh/services/approval-service/internal/content" + "basicstack.de/proofedmesh/services/approval-service/internal/service" + "basicstack.de/proofedmesh/services/approval-service/internal/sessions" + "basicstack.de/proofedmesh/services/approval-service/internal/signer" + "basicstack.de/proofedmesh/services/approval-service/internal/store" + "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" + pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi" +) + +// AuthResolver resolves the authenticated principal from the request. +// Prod wires it to the Pocket-ID cookie middleware; tests inject a +// fixed principal via a shim. +type AuthResolver interface { + Resolve(r *http.Request) (auth.Principal, error) +} + +// AuthFunc adapts a plain function to AuthResolver. +type AuthFunc func(r *http.Request) (auth.Principal, error) + +// Resolve calls f. +func (f AuthFunc) Resolve(r *http.Request) (auth.Principal, error) { return f(r) } + +// Server is the HTTP handler for the approval-service. +type Server struct { + Auth AuthResolver + Sessions *sessions.Manager + Orchestrator *service.Orchestrator + Logger *slog.Logger + Now func() time.Time +} + +// Mount registers the three API routes plus /healthz on mux. +func (s *Server) Mount(mux *http.ServeMux) { + mux.HandleFunc("POST /api/v1/sessions", s.handleCreateSession) + mux.HandleFunc("POST /api/v1/approvals/preview", s.handlePreview) + mux.HandleFunc("POST /api/v1/approvals/confirm", s.handleConfirm) + mux.HandleFunc("GET /healthz", s.handleHealth) +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { + p, err := s.resolve(r) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + // POST /sessions has no body in Phase 1; the Pocket-ID principal + // alone identifies the user. + sess, err := s.Sessions.StartSession(r.Context(), p.Sub) + if err != nil { + s.logError("start session failed", err) + writeErr(w, http.StatusInternalServerError, "session_create_failed", err.Error()) + return + } + writeJSON(w, http.StatusCreated, pkgapi.CreateSessionResponse{ + SessionID: sess.ID, + UserSub: sess.UserSub, + ActiveUntil: sess.ActiveUntil, + }) +} + +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + p, err := s.resolve(r) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + var req pkgapi.PreviewRequest + if err := decodeJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.SessionID == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "session_id is required") + return + } + if len(req.Content) == 0 { + writeErr(w, http.StatusBadRequest, "bad_request", "content is required") + return + } + + sess, err := s.Sessions.GetActiveSession(r.Context(), req.SessionID, p.Sub) + switch { + case errors.Is(err, store.ErrNotFound): + writeErr(w, http.StatusNotFound, "session_not_found", "session_id unknown") + return + case errors.Is(err, sessions.ErrSessionExpired): + writeErr(w, http.StatusForbidden, "session_expired", "approval session expired") + return + case err != nil: + s.logError("session lookup failed", err) + writeErr(w, http.StatusInternalServerError, "session_lookup_failed", err.Error()) + return + } + + bind, err := content.Bind(req.Content) + if err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", "content: "+err.Error()) + return + } + previewID, err := s.Orchestrator.NewID("pv") + if err != nil { + writeErr(w, http.StatusInternalServerError, "id_gen_failed", err.Error()) + return + } + now := s.now() + pv := store.Preview{ + ID: previewID, + SessionID: sess.ID, + UserSub: p.Sub, + ContentHash: bind.ContentHash, + ContentCanonical: bind.ContentCanonical, + Salt: bind.Salt, + Nonce: bind.Nonce, + VerificationDigits: bind.VerificationDigits, + ExpiresAt: now.Add(s.Sessions.ChallengeTTL), + CreatedAt: now, + } + if err := s.Orchestrator.Store.SavePreview(r.Context(), pv); err != nil { + s.logError("save preview failed", err) + writeErr(w, http.StatusInternalServerError, "save_preview_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, pkgapi.PreviewResponse{ + PreviewID: pv.ID, + ContentHash: pv.ContentHash, + VerificationDigits: pv.VerificationDigits, + WebauthnChallenge: base64.RawURLEncoding.EncodeToString(bind.Challenge), + ExpiresAt: pv.ExpiresAt, + }) +} + +func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { + p, err := s.resolve(r) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + var req pkgapi.ConfirmRequest + if err := decodeJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if req.PreviewID == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "preview_id is required") + return + } + assertion := webauthn.Assertion(req.Assertion) + res, err := s.Orchestrator.Confirm(r.Context(), req.PreviewID, assertion, p.Sub) + if err != nil { + s.mapConfirmError(w, err) + return + } + writeJSON(w, http.StatusOK, pkgapi.ConfirmResponse{ + ApprovalID: res.ApprovalID, + AttestationRef: res.AttestationRef, + LogIndex: res.LogIndex, + TLogStatus: string(res.TLogStatus), + }) +} + +func (s *Server) mapConfirmError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrNotFound): + writeErr(w, http.StatusNotFound, "preview_not_found", "preview_id unknown") + case errors.Is(err, store.ErrAlreadyConsumed): + writeErr(w, http.StatusConflict, "preview_consumed", "preview already consumed") + case errors.Is(err, service.ErrChallengeExpired): + writeErr(w, http.StatusForbidden, "challenge_expired", "approval challenge expired") + case errors.Is(err, service.ErrContentHashMismatch): + writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error()) + case errors.Is(err, webauthn.ErrChallengeMismatch): + writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error()) + case errors.Is(err, webauthn.ErrCredentialUnknown): + writeErr(w, http.StatusUnauthorized, "credential_unknown", "credential_id not registered") + case errors.Is(err, webauthn.ErrAssertionInvalid): + writeErr(w, http.StatusUnauthorized, "assertion_invalid", "webauthn assertion verification failed") + case errors.Is(err, signer.ErrSignerUnavailable): + writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error()) + default: + s.logError("confirm failed", err) + writeErr(w, http.StatusInternalServerError, "confirm_failed", err.Error()) + } +} + +func (s *Server) resolve(r *http.Request) (auth.Principal, error) { + if s.Auth == nil { + return auth.Principal{}, errors.New("no auth resolver configured") + } + return s.Auth.Resolve(r) +} + +func (s *Server) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *Server) logError(msg string, err error) { + if s.Logger != nil { + s.Logger.Error("approval-service: "+msg, "err", err) + } +} + +func decodeJSON(r *http.Request, v any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return err + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, pkgapi.Error{Error: pkgapi.ErrorBody{Code: code, Message: msg}}) +} + +// Context sanity: ensure we never return a canceled error as a 500 just +// because the client disconnected mid-flight. +var _ context.Context = context.Background() diff --git a/services/approval-service/internal/httpapi/server_test.go b/services/approval-service/internal/httpapi/server_test.go new file mode 100644 index 0000000..3730429 --- /dev/null +++ b/services/approval-service/internal/httpapi/server_test.go @@ -0,0 +1,463 @@ +package httpapi_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/auth" + "basicstack.de/proofedmesh/services/approval-service/internal/httpapi" + "basicstack.de/proofedmesh/services/approval-service/internal/service" + "basicstack.de/proofedmesh/services/approval-service/internal/sessions" + "basicstack.de/proofedmesh/services/approval-service/internal/signer" + "basicstack.de/proofedmesh/services/approval-service/internal/store" + "basicstack.de/proofedmesh/services/approval-service/internal/tlog" + "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" + pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi" +) + +// fixture bundles everything a test needs to drive the HTTP server. +type fixture struct { + t *testing.T + server *httptest.Server + userSub string + credID string + credPriv ed25519.PrivateKey + rpID string + origin string + fakeSigner *fakeSigner + fakeTLog *fakeTLog + store *store.Memory +} + +type fakeSigner struct { + mu sync.Mutex + err error + wantKeyRef string + seenStatement signer.Statement +} + +func (f *fakeSigner) SignDSSE(_ context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return signer.Envelope{}, "", f.err + } + if f.wantKeyRef != "" && keyRef != f.wantKeyRef { + return signer.Envelope{}, "", errors.New("unexpected keyRef") + } + f.seenStatement = stmt + // Produce a deterministic envelope so tests can assert on it. + payload := base64.RawURLEncoding.EncodeToString([]byte("payload-bytes")) + env := signer.Envelope{ + PayloadType: "application/vnd.in-toto+json", + Payload: payload, + Signatures: []signer.EnvelopeSignature{{KeyID: keyRef, Sig: "AA"}}, + } + envBytes, _ := json.Marshal(env) + sum := sha256.Sum256(envBytes) + return env, "sha256:" + hex(sum[:]), nil +} + +type fakeTLog struct { + mu sync.Mutex + err error + submits int + lastHash string +} + +func (f *fakeTLog) Submit(_ context.Context, _ signer.Envelope, contentHash string) (tlog.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.submits++ + f.lastHash = contentHash + if f.err != nil { + return tlog.Receipt{}, f.err + } + return tlog.Receipt{ + LogIndex: 42, + ContentHash: contentHash, + LoggedAt: time.Now().UTC(), + }, nil +} + +// newFixture wires a Server backed by an in-memory store + fakes. A single +// WebAuthn credential is pre-registered for userSub so tests can confirm +// previews without going through a real enrollment ceremony. +func newFixture(t *testing.T, opts ...func(*service.Orchestrator)) *fixture { + t.Helper() + st := store.NewMemory() + mgr := sessions.New(st) + mgr.SessionTTL = 15 * time.Minute + mgr.ChallengeTTL = 2 * time.Minute + + userSub := "user-alice" + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + credID := base64.RawURLEncoding.EncodeToString(idBytes) + webStore := webauthn.NewStore() + if err := webStore.AddCredential(webauthn.Credential{ + ID: credID, + UserSub: userSub, + PublicKey: pub, + }); err != nil { + t.Fatalf("register credential: %v", err) + } + + rpID := "trust.basicstack.de" + origin := "https://trust.basicstack.de" + + sig := &fakeSigner{wantKeyRef: "trust-api/approval-signer-2026-q4"} + submitter := &fakeTLog{} + + orch := &service.Orchestrator{ + Store: st, + Sessions: mgr, + Webauthn: webStore, + WebauthnCfg: webauthn.VerifyConfig{ + RPID: rpID, + AllowedOrigins: []string{origin}, + }, + Signer: sig, + Submitter: submitter, + SigningKeyRef: "trust-api/approval-signer-2026-q4", + AttestationIssuer: "https://auth.basicstack.de", + } + for _, o := range opts { + o(orch) + } + + srv := &httpapi.Server{ + Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) { + return auth.Principal{Sub: userSub, Groups: []string{"proofedmesh-admin"}}, nil + }), + Sessions: mgr, + Orchestrator: orch, + } + mux := http.NewServeMux() + srv.Mount(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + return &fixture{ + t: t, + server: ts, + userSub: userSub, + credID: credID, + credPriv: priv, + rpID: rpID, + origin: origin, + fakeSigner: sig, + fakeTLog: submitter, + store: st, + } +} + +func (f *fixture) startSession() pkgapi.CreateSessionResponse { + f.t.Helper() + var out pkgapi.CreateSessionResponse + f.do("POST", "/api/v1/sessions", nil, http.StatusCreated, &out) + return out +} + +func (f *fixture) preview(sessionID string, content map[string]any, want int) pkgapi.PreviewResponse { + f.t.Helper() + var out pkgapi.PreviewResponse + f.do("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{ + SessionID: sessionID, + Content: content, + }, want, &out) + return out +} + +func (f *fixture) confirm(req pkgapi.ConfirmRequest, want int) (*pkgapi.ConfirmResponse, *pkgapi.Error) { + f.t.Helper() + resp := f.doRaw("POST", "/api/v1/approvals/confirm", req) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + f.t.Fatalf("confirm: status %d want %d: %s", resp.StatusCode, want, string(body)) + } + if want >= 200 && want < 300 { + var out pkgapi.ConfirmResponse + if err := json.Unmarshal(body, &out); err != nil { + f.t.Fatalf("decode confirm: %v (%s)", err, string(body)) + } + return &out, nil + } + var ebody pkgapi.Error + if err := json.Unmarshal(body, &ebody); err != nil { + f.t.Fatalf("decode confirm error: %v (%s)", err, string(body)) + } + return nil, &ebody +} + +func (f *fixture) do(method, path string, body any, want int, out any) { + f.t.Helper() + resp := f.doRaw(method, path, body) + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + f.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, string(raw)) + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + f.t.Fatalf("decode %s: %v (%s)", path, err, string(raw)) + } + } +} + +func (f *fixture) doRaw(method, path string, body any) *http.Response { + f.t.Helper() + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal body: %v", err) + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, f.server.URL+path, r) + if err != nil { + f.t.Fatalf("new request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := f.server.Client().Do(req) + if err != nil { + f.t.Fatalf("do: %v", err) + } + return resp +} + +func (f *fixture) signAssertion(challenge []byte) pkgapi.Assertion { + f.t.Helper() + cd := map[string]string{ + "type": "webauthn.get", + "challenge": base64.RawURLEncoding.EncodeToString(challenge), + "origin": f.origin, + } + cdBytes, _ := json.Marshal(cd) + authData := webauthn.MakeAuthenticatorData(f.rpID) + cdHash := sha256.Sum256(cdBytes) + signed := append(append([]byte(nil), authData...), cdHash[:]...) + sig := ed25519.Sign(f.credPriv, signed) + return pkgapi.Assertion{ + CredentialID: f.credID, + ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes), + AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData), + Signature: base64.RawURLEncoding.EncodeToString(sig), + } +} + +func hex(b []byte) string { + const hexchars = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, c := range b { + out[i*2] = hexchars[c>>4] + out[i*2+1] = hexchars[c&0x0f] + } + return string(out) +} + +// --- actual tests --------------------------------------------------------- + +func TestHappyPath(t *testing.T) { + f := newFixture(t) + sess := f.startSession() + if sess.SessionID == "" { + t.Fatalf("no session_id returned") + } + if sess.UserSub != f.userSub { + t.Errorf("session.user_sub = %q want %q", sess.UserSub, f.userSub) + } + + pv := f.preview(sess.SessionID, map[string]any{ + "title": "Approve rate change", + "action": "sign", + }, http.StatusOK) + if !strings.HasPrefix(pv.ContentHash, "sha256:") { + t.Errorf("preview content_hash: %q", pv.ContentHash) + } + if pv.VerificationDigits == "" { + t.Errorf("empty verification digits") + } + + challenge, err := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + if err != nil { + t.Fatalf("decode challenge: %v", err) + } + assertion := f.signAssertion(challenge) + ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) + if ok.TLogStatus != "logged" { + t.Errorf("tlog status: %q want logged", ok.TLogStatus) + } + if ok.LogIndex == nil || *ok.LogIndex != 42 { + t.Errorf("log_index = %v want 42", ok.LogIndex) + } + if ok.AttestationRef == "" || !strings.HasPrefix(ok.AttestationRef, "sha256:") { + t.Errorf("attestation_ref: %q", ok.AttestationRef) + } + + // Assert the signer saw a Statement whose subject.digest.sha256 is + // the H_content of the preview (no "sha256:" prefix per in-toto). + f.fakeSigner.mu.Lock() + stmt := f.fakeSigner.seenStatement + f.fakeSigner.mu.Unlock() + subj, _ := stmt["subject"].([]any) + if len(subj) != 1 { + t.Fatalf("statement subject: %+v", subj) + } + first, _ := subj[0].(map[string]any) + digest, _ := first["digest"].(map[string]any) + gotDigest, _ := digest["sha256"].(string) + wantDigest := strings.TrimPrefix(pv.ContentHash, "sha256:") + if gotDigest != wantDigest { + t.Errorf("signed subject digest=%q want %q", gotDigest, wantDigest) + } +} + +func TestConfirm_TamperedHContent(t *testing.T) { + f := newFixture(t) + sess := f.startSession() + pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) + + challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + // Flip the last byte of the challenge (equivalent to signing with a + // different H_content), the signature will be over wrong data. + tampered := append([]byte(nil), challenge...) + tampered[len(tampered)-1] ^= 0xff + assertion := f.signAssertion(tampered) + _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadRequest) + if errBody.Error.Code != "content_hash_mismatch" { + t.Errorf("error code = %q want content_hash_mismatch", errBody.Error.Code) + } +} + +func TestConfirm_ExpiredChallenge(t *testing.T) { + // Force a short challenge TTL so we can race the clock. + f := newFixture(t) + sess := f.startSession() + pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) + + // Walk the preview's ExpiresAt backward directly in the store to + // simulate an expired challenge without a sleep. + prev, err := f.store.GetPreview(context.Background(), pv.PreviewID) + if err != nil { + t.Fatalf("GetPreview: %v", err) + } + prev.ExpiresAt = time.Now().Add(-1 * time.Second) + if err := f.store.SavePreview(context.Background(), prev); err != nil { + t.Fatalf("SavePreview: %v", err) + } + + challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + assertion := f.signAssertion(challenge) + _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusForbidden) + if errBody.Error.Code != "challenge_expired" { + t.Errorf("error code = %q want challenge_expired", errBody.Error.Code) + } +} + +func TestConfirm_SignerUnavailable(t *testing.T) { + f := newFixture(t, func(o *service.Orchestrator) { + o.Signer = &fakeSigner{err: signer.ErrSignerUnavailable} + }) + sess := f.startSession() + pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) + + challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + assertion := f.signAssertion(challenge) + _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadGateway) + if errBody.Error.Code != "signer_unavailable" { + t.Errorf("error code = %q want signer_unavailable", errBody.Error.Code) + } +} + +func TestConfirm_TLogFailureStaysPending(t *testing.T) { + f := newFixture(t, func(o *service.Orchestrator) { + o.Submitter = &fakeTLog{err: tlog.ErrTLogUnavailable} + }) + sess := f.startSession() + pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) + + challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + assertion := f.signAssertion(challenge) + ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) + if ok.TLogStatus != "attestation-pending" { + t.Errorf("tlog status = %q want attestation-pending", ok.TLogStatus) + } + if ok.LogIndex != nil { + t.Errorf("log_index = %v want nil", ok.LogIndex) + } + // Approval must still have been persisted. + appr, ok2 := f.store.GetApproval(ok.ApprovalID) + if !ok2 { + t.Fatalf("approval not persisted after tlog failure") + } + if appr.TLogStatus != store.TLogPending { + t.Errorf("stored tlog status = %q want attestation-pending", appr.TLogStatus) + } + if appr.TLogError == "" { + t.Errorf("expected tlog_error to be recorded") + } +} + +func TestPreview_SessionExpired(t *testing.T) { + f := newFixture(t) + sess := f.startSession() + // Simulate session expiry. + raw, err := f.store.GetSession(context.Background(), sess.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + raw.ActiveUntil = time.Now().Add(-1 * time.Second) + if err := f.store.CreateSession(context.Background(), raw); err != nil { + t.Fatalf("CreateSession: %v", err) + } + resp := f.doRaw("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{ + SessionID: sess.SessionID, + Content: map[string]any{"k": "v"}, + }) + defer resp.Body.Close() + raw2, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status %d want 403: %s", resp.StatusCode, raw2) + } + var ebody pkgapi.Error + _ = json.Unmarshal(raw2, &ebody) + if ebody.Error.Code != "session_expired" { + t.Errorf("error code = %q want session_expired", ebody.Error.Code) + } +} + +func TestConfirm_PreviewConsumedTwice(t *testing.T) { + f := newFixture(t) + sess := f.startSession() + pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK) + challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge) + assertion := f.signAssertion(challenge) + _, _ = f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK) + // Second confirm must be refused. + _, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusConflict) + if errBody.Error.Code != "preview_consumed" { + t.Errorf("error code = %q want preview_consumed", errBody.Error.Code) + } +} diff --git a/services/approval-service/internal/service/orchestrator.go b/services/approval-service/internal/service/orchestrator.go new file mode 100644 index 0000000..d84b90c --- /dev/null +++ b/services/approval-service/internal/service/orchestrator.go @@ -0,0 +1,236 @@ +// Package service wires the approval pipeline components (sessions, +// content binding, webauthn, DSSE signing, Transparency Log) into one +// Confirm orchestrator. Keeping it HTTP-free lets unit tests drive the +// whole flow without an httptest server. +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/content" + "basicstack.de/proofedmesh/services/approval-service/internal/sessions" + "basicstack.de/proofedmesh/services/approval-service/internal/signer" + "basicstack.de/proofedmesh/services/approval-service/internal/store" + "basicstack.de/proofedmesh/services/approval-service/internal/tlog" + "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" +) + +// Signer is the signing-service client the orchestrator depends on. +type Signer interface { + SignDSSE(ctx context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) +} + +// Submitter is the transparency-log client the orchestrator depends on. +type Submitter interface { + Submit(ctx context.Context, env signer.Envelope, contentHash string) (tlog.Receipt, error) +} + +// Orchestrator holds everything a Confirm needs. Fields are populated +// from main.go; the HTTP handler just calls Confirm. +type Orchestrator struct { + Store store.Store + Sessions *sessions.Manager + Webauthn *webauthn.Store + WebauthnCfg webauthn.VerifyConfig + Signer Signer + Submitter Submitter + SigningKeyRef string + // AttestationIssuer is embedded as predicate.performed_by.iss so + // verifiers can match the approval against our Pocket-ID issuer. + AttestationIssuer string + Now func() time.Time + GenID sessions.IDGenerator +} + +// ErrContentHashMismatch is surfaced by Confirm when the preview's +// stored canonical content no longer hashes to its stored content_hash +// (store corruption) OR when the webauthn challenge the browser signed +// does not match the challenge we expected for this preview. In both +// cases the HTTP layer returns 400 per DEV-608. +var ErrContentHashMismatch = errors.New("service: content hash mismatch") + +// ErrChallengeExpired is surfaced by Confirm when the preview's +// expires_at is in the past. The HTTP layer returns 403. +var ErrChallengeExpired = errors.New("service: challenge expired") + +// ConfirmResult is what Confirm returns to the HTTP handler. +type ConfirmResult struct { + ApprovalID string + AttestationRef string + LogIndex *int64 + TLogStatus store.TLogStatus +} + +// Confirm runs the full end-of-pipeline sequence: +// +// 1. Load the preview (must exist, must not be consumed, must still be +// within expires_at). +// 2. Re-derive the WebAuthn challenge from the stored (nonce, +// canonical) and verify the assertion against it. +// 3. Build the DSSE Statement with H_content as the subject digest. +// 4. Call the signing-service. +// 5. Persist the Approval row (status = attestation-pending). +// 6. Submit to the Transparency Log. On success, flip status to +// "logged" and attach log_index. On TLog failure, leave the row at +// attestation-pending so the reconciler can retry; the HTTP response +// still returns ApprovalID + attestation_ref so the UI can carry on. +// +// The order (persist → submit) is deliberate: we never return a +// log_index to the caller for an approval that was not durably stored, +// and we never lose a signed envelope because we failed to log it. +func (o *Orchestrator) Confirm(ctx context.Context, previewID string, assertion webauthn.Assertion, userSub string) (ConfirmResult, error) { + if previewID == "" { + return ConfirmResult{}, errors.New("service: previewID is empty") + } + if userSub == "" { + return ConfirmResult{}, errors.New("service: userSub is empty") + } + now := o.now() + + pv, err := o.Store.GetPreview(ctx, previewID) + if err != nil { + return ConfirmResult{}, err + } + if pv.UserSub != userSub { + return ConfirmResult{}, store.ErrNotFound + } + if pv.ConsumedAt != nil { + return ConfirmResult{}, store.ErrAlreadyConsumed + } + if !now.Before(pv.ExpiresAt) { + return ConfirmResult{}, ErrChallengeExpired + } + + // Defense-in-depth: recompute H_content from the stored canonical + // content and make sure it still equals the stored content_hash. + if content.RehashCanonical(pv.ContentCanonical) != pv.ContentHash { + return ConfirmResult{}, fmt.Errorf("%w: stored canonical content does not match stored content_hash", ErrContentHashMismatch) + } + + expectedChallenge := content.ChallengeFor(pv.Nonce, pv.ContentCanonical) + cred, err := webauthn.Verify(o.WebauthnCfg, o.Webauthn, assertion, expectedChallenge) + if err != nil { + if errors.Is(err, webauthn.ErrChallengeMismatch) { + return ConfirmResult{}, fmt.Errorf("%w: %v", ErrContentHashMismatch, err) + } + return ConfirmResult{}, err + } + if cred.UserSub != userSub { + return ConfirmResult{}, webauthn.ErrAssertionInvalid + } + + // Atomically mark the preview consumed so a parallel confirm cannot + // reuse the same signed envelope. If this fails ErrAlreadyConsumed + // we lose the race and must not continue. + if _, err := o.Store.ConsumePreview(ctx, pv.ID, now); err != nil { + return ConfirmResult{}, err + } + + stmt := buildStatement(pv, userSub, o.AttestationIssuer, now) + env, envHash, err := o.Signer.SignDSSE(ctx, o.SigningKeyRef, stmt) + if err != nil { + return ConfirmResult{}, err + } + + approvalID, err := o.genID("ap") + if err != nil { + return ConfirmResult{}, err + } + + appr := store.Approval{ + ID: approvalID, + PreviewID: pv.ID, + SessionID: pv.SessionID, + UserSub: userSub, + ContentHash: pv.ContentHash, + Envelope: env, + AttestationRef: envHash, + TLogStatus: store.TLogPending, + ConfirmedAt: now, + } + if err := o.Store.SaveApproval(ctx, appr); err != nil { + return ConfirmResult{}, err + } + + receipt, submitErr := o.Submitter.Submit(ctx, env, envHash) + if submitErr != nil { + // Approval row stays at attestation-pending; record the error + // for diagnostics but return success-with-pending to the client. + _ = o.Store.MarkApprovalTLogError(ctx, approvalID, submitErr.Error()) + return ConfirmResult{ + ApprovalID: approvalID, + AttestationRef: envHash, + TLogStatus: store.TLogPending, + }, nil + } + loggedAt := receipt.LoggedAt + if loggedAt.IsZero() { + loggedAt = now + } + if err := o.Store.MarkApprovalLogged(ctx, approvalID, receipt.LogIndex, loggedAt); err != nil { + return ConfirmResult{}, err + } + idx := receipt.LogIndex + return ConfirmResult{ + ApprovalID: approvalID, + AttestationRef: envHash, + LogIndex: &idx, + TLogStatus: store.TLogLogged, + }, nil +} + +func (o *Orchestrator) now() time.Time { + if o.Now != nil { + return o.Now() + } + return time.Now() +} + +// NewID returns an opaque ID with the given prefix. Exposed so the HTTP +// layer can mint preview_ids with the same injectable generator. +func (o *Orchestrator) NewID(prefix string) (string, error) { + if o.GenID != nil { + return o.GenID(prefix) + } + return sessions.DefaultIDGenerator(prefix) +} + +func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(prefix) } + +// buildStatement renders the in-toto v1 Statement for an approval. The +// subject.digest IS the H_content of the preview; verifiers rehash the +// stored canonical content to prove the signature covers it. +func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.Time) signer.Statement { + // Strip the "sha256:" prefix for the digest map, per in-toto convention. + digest := pv.ContentHash + if len(digest) > 7 && digest[:7] == "sha256:" { + digest = digest[7:] + } + predicate := map[string]any{ + "approval_schema": "basicstack.de/attestations/approval/v1", + "preview_id": pv.ID, + "session_id": pv.SessionID, + "content_hash": pv.ContentHash, + "performed_by": map[string]any{ + "iss": issuer, + "sub": userSub, + "session_id": pv.SessionID, + }, + "performed_at": performedAt.UTC().Format(time.RFC3339), + "verification_digits": pv.VerificationDigits, + } + return signer.Statement{ + "_type": "https://in-toto.io/Statement/v1", + "subject": []any{ + map[string]any{ + "name": "approval/" + pv.ID, + "digest": map[string]any{"sha256": digest}, + }, + }, + "predicateType": "https://basicstack.de/attestations/approval/v1", + "predicate": predicate, + } +} diff --git a/services/approval-service/internal/sessions/sessions.go b/services/approval-service/internal/sessions/sessions.go new file mode 100644 index 0000000..d5366f7 --- /dev/null +++ b/services/approval-service/internal/sessions/sessions.go @@ -0,0 +1,97 @@ +// Package sessions implements the Session → Preview → Confirm pipeline on +// top of a Store. Keeping it one layer above the HTTP handlers lets the +// unit tests exercise business logic without an httptest server. +package sessions + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/store" +) + +// IDGenerator returns opaque collision-resistant IDs. The default +// implementation uses crypto/rand; tests inject a deterministic one. +type IDGenerator func(prefix string) (string, error) + +// DefaultIDGenerator produces "_" + 24 random base64url chars. +func DefaultIDGenerator(prefix string) (string, error) { + b := make([]byte, 18) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("id: %w", err) + } + return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil +} + +// Manager orchestrates sessions on top of a Store. Its only state is the +// clock and ID generator; all persistence happens through Store. +type Manager struct { + Store store.Store + Now func() time.Time + GenID IDGenerator + SessionTTL time.Duration + ChallengeTTL time.Duration +} + +// New constructs a Manager with safe defaults for Now, GenID, SessionTTL +// (15m) and ChallengeTTL (2m) when the caller leaves them zero. +func New(s store.Store) *Manager { + return &Manager{ + Store: s, + Now: time.Now, + GenID: DefaultIDGenerator, + SessionTTL: 15 * time.Minute, + ChallengeTTL: 2 * time.Minute, + } +} + +// StartSession creates a fresh approval_session for userSub. +func (m *Manager) StartSession(ctx context.Context, userSub string) (store.Session, error) { + if userSub == "" { + return store.Session{}, errors.New("sessions: userSub is empty") + } + id, err := m.GenID("sess") + if err != nil { + return store.Session{}, err + } + now := m.Now() + s := store.Session{ + ID: id, + UserSub: userSub, + ActiveUntil: now.Add(m.SessionTTL), + CreatedAt: now, + } + if err := m.Store.CreateSession(ctx, s); err != nil { + return store.Session{}, err + } + return s, nil +} + +// ErrSessionExpired is returned by GetActiveSession when the session +// row exists but active_until is in the past. +var ErrSessionExpired = errors.New("sessions: session expired") + +// GetActiveSession returns the session when it is still within +// active_until. Enforces session_id ↔ user_sub binding (the caller passes +// the authenticated userSub so a leaked session_id cannot be used from +// another account). +func (m *Manager) GetActiveSession(ctx context.Context, sessionID, userSub string) (store.Session, error) { + s, err := m.Store.GetSession(ctx, sessionID) + if err != nil { + return store.Session{}, err + } + if s.UserSub != userSub { + // Explicit NotFound so handlers return 404 instead of leaking the + // mismatch — a probing attacker cannot distinguish "wrong user" + // from "unknown session". + return store.Session{}, store.ErrNotFound + } + if !m.Now().Before(s.ActiveUntil) { + return store.Session{}, ErrSessionExpired + } + return s, nil +} diff --git a/services/approval-service/internal/sessions/sessions_test.go b/services/approval-service/internal/sessions/sessions_test.go new file mode 100644 index 0000000..8630596 --- /dev/null +++ b/services/approval-service/internal/sessions/sessions_test.go @@ -0,0 +1,59 @@ +package sessions + +import ( + "context" + "errors" + "testing" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/store" +) + +func TestStartSession_UsesTTL(t *testing.T) { + st := store.NewMemory() + m := New(st) + m.SessionTTL = 5 * time.Minute + m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) } + s, err := m.StartSession(context.Background(), "alice") + if err != nil { + t.Fatalf("StartSession: %v", err) + } + want := time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC) + if !s.ActiveUntil.Equal(want) { + t.Errorf("ActiveUntil = %v want %v", s.ActiveUntil, want) + } + if s.UserSub != "alice" { + t.Errorf("UserSub = %q", s.UserSub) + } +} + +func TestGetActiveSession_WrongUser(t *testing.T) { + st := store.NewMemory() + m := New(st) + m.Now = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) } + s, err := m.StartSession(context.Background(), "alice") + if err != nil { + t.Fatalf("StartSession: %v", err) + } + _, err = m.GetActiveSession(context.Background(), s.ID, "bob") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + +func TestGetActiveSession_Expired(t *testing.T) { + st := store.NewMemory() + m := New(st) + m.SessionTTL = time.Minute + t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + m.Now = func() time.Time { return t0 } + s, err := m.StartSession(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + m.Now = func() time.Time { return t0.Add(2 * time.Minute) } + _, err = m.GetActiveSession(context.Background(), s.ID, "alice") + if !errors.Is(err, ErrSessionExpired) { + t.Fatalf("want ErrSessionExpired, got %v", err) + } +} diff --git a/services/approval-service/internal/signer/signer.go b/services/approval-service/internal/signer/signer.go new file mode 100644 index 0000000..eb00e67 --- /dev/null +++ b/services/approval-service/internal/signer/signer.go @@ -0,0 +1,134 @@ +// Package signer calls the signing-service's POST /api/v1/sign/dsse +// endpoint per ADR-0005. The wire types are duplicated here (not imported +// from the signing-service's internal package) so the approval-service +// stays an independent module. +package signer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Statement is the in-toto v1 Statement handed to the signing-service. +// Loosely typed via `map[string]any` so arbitrary predicate payloads can +// flow through without an approval-service-local schema definition. +type Statement map[string]any + +// EnvelopeSignature mirrors the DSSE spec. +type EnvelopeSignature struct { + KeyID string `json:"keyid"` + Sig string `json:"sig"` +} + +// Envelope is the DSSE envelope returned by the signing-service. +type Envelope struct { + PayloadType string `json:"payloadType"` + Payload string `json:"payload"` + Signatures []EnvelopeSignature `json:"signatures"` +} + +// TokenSource supplies the Bearer token the signing-service expects. The +// production wiring will plug in a Pocket-ID client-credentials source; +// tests use StaticToken. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// StaticToken is a trivial constant TokenSource. +type StaticToken string + +// Token returns s. +func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil } + +// ErrSignerUnavailable is returned when the signing-service refuses the +// connection, times out, or answers 5xx. The HTTP layer maps this to 502 +// as DEV-608 requires. +var ErrSignerUnavailable = errors.New("signer: signing-service unavailable") + +// Client is the DSSE sign client. +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenSource TokenSource +} + +type signRequest struct { + KeyRef string `json:"key_ref"` + Statement Statement `json:"statement"` +} + +type signResponse struct { + Envelope Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` + SignedAt time.Time `json:"signed_at"` +} + +// SignDSSE calls the signing-service and returns the DSSE envelope plus +// its content hash. A connection-level or 5xx response from the signing- +// service is reported as ErrSignerUnavailable. +func (c Client) SignDSSE(ctx context.Context, keyRef string, stmt Statement) (Envelope, string, error) { + if c.BaseURL == "" { + return Envelope{}, "", errors.New("signer.Client: BaseURL is empty") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt}) + if err != nil { + return Envelope{}, "", fmt.Errorf("marshal sign request: %w", err) + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse") + if err != nil { + return Envelope{}, "", fmt.Errorf("join signing-service url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return Envelope{}, "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.TokenSource != nil { + tok, err := c.TokenSource.Token(ctx) + if err != nil { + return Envelope{}, "", fmt.Errorf("token: %w", err) + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + resp, err := hc.Do(req) + if err != nil { + return Envelope{}, "", fmt.Errorf("%w: %v", ErrSignerUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 500 { + return Envelope{}, "", fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256)) + } + if resp.StatusCode != http.StatusOK { + return Envelope{}, "", fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } + var out signResponse + if err := json.Unmarshal(raw, &out); err != nil { + return Envelope{}, "", fmt.Errorf("decode sign response: %w", err) + } + if out.ContentHash == "" { + return Envelope{}, "", errors.New("signer: response missing content_hash") + } + return out.Envelope, out.ContentHash, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/approval-service/internal/signer/signer_test.go b/services/approval-service/internal/signer/signer_test.go new file mode 100644 index 0000000..e9e5bbc --- /dev/null +++ b/services/approval-service/internal/signer/signer_test.go @@ -0,0 +1,88 @@ +package signer + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSignDSSE_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/sign/dsse" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("auth header = %q", got) + } + var body signRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.KeyRef != "key-x" { + t.Errorf("keyRef = %q", body.KeyRef) + } + resp := signResponse{ + Envelope: Envelope{PayloadType: "application/vnd.in-toto+json", Payload: "aGk"}, + ContentHash: "sha256:deadbeef", + SignedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := Client{BaseURL: srv.URL, TokenSource: StaticToken("tok")} + env, hash, err := c.SignDSSE(context.Background(), "key-x", Statement{"_type": "x"}) + if err != nil { + t.Fatalf("SignDSSE: %v", err) + } + if hash != "sha256:deadbeef" { + t.Errorf("hash = %q", hash) + } + if env.PayloadType != "application/vnd.in-toto+json" { + t.Errorf("payloadType = %q", env.PayloadType) + } +} + +func TestSignDSSE_5xxMapsToUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"}) + if !errors.Is(err, ErrSignerUnavailable) { + t.Fatalf("want ErrSignerUnavailable, got %v", err) + } +} + +func TestSignDSSE_ConnRefusedMapsToUnavailable(t *testing.T) { + // Nonexistent address — the dial will fail immediately. + c := Client{ + BaseURL: "http://127.0.0.1:1", // reserved, almost always refused + HTTPClient: &http.Client{Timeout: 500 * time.Millisecond}, + } + _, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"}) + if !errors.Is(err, ErrSignerUnavailable) { + t.Fatalf("want ErrSignerUnavailable, got %v", err) + } +} + +func TestSignDSSE_4xxDoesNotLookUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, _, err := c.SignDSSE(context.Background(), "k", Statement{"_type": "x"}) + if err == nil { + t.Fatalf("expected error") + } + if errors.Is(err, ErrSignerUnavailable) { + t.Fatalf("4xx must not be reported as ErrSignerUnavailable: %v", err) + } +} diff --git a/services/approval-service/internal/store/memory.go b/services/approval-service/internal/store/memory.go new file mode 100644 index 0000000..913e7b2 --- /dev/null +++ b/services/approval-service/internal/store/memory.go @@ -0,0 +1,136 @@ +package store + +import ( + "context" + "sync" + "time" +) + +// Memory is the Phase-1 in-process Store. Thread-safe via a single RWMutex; +// each call is cheap enough that per-table locks would be over-engineering. +type Memory struct { + mu sync.RWMutex + sessions map[string]Session + previews map[string]Preview + approvals map[string]Approval +} + +// NewMemory returns an empty Memory store. +func NewMemory() *Memory { + return &Memory{ + sessions: map[string]Session{}, + previews: map[string]Preview{}, + approvals: map[string]Approval{}, + } +} + +func (m *Memory) CreateSession(_ context.Context, s Session) error { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[s.ID] = s + return nil +} + +func (m *Memory) GetSession(_ context.Context, id string) (Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok { + return Session{}, ErrNotFound + } + return s, nil +} + +func (m *Memory) SavePreview(_ context.Context, p Preview) error { + m.mu.Lock() + defer m.mu.Unlock() + m.previews[p.ID] = p + return nil +} + +func (m *Memory) GetPreview(_ context.Context, id string) (Preview, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.previews[id] + if !ok { + return Preview{}, ErrNotFound + } + // Return a copy so callers cannot mutate our byte slices. + return clonePreview(p), nil +} + +// ConsumePreview marks the preview as consumed if it is still valid and +// unconsumed. The returned Preview is the pre-mutation copy. +func (m *Memory) ConsumePreview(_ context.Context, id string, now time.Time) (Preview, error) { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.previews[id] + if !ok { + return Preview{}, ErrNotFound + } + if p.ConsumedAt != nil { + return Preview{}, ErrAlreadyConsumed + } + snapshot := clonePreview(p) + ts := now + p.ConsumedAt = &ts + m.previews[id] = p + return snapshot, nil +} + +func (m *Memory) SaveApproval(_ context.Context, a Approval) error { + m.mu.Lock() + defer m.mu.Unlock() + m.approvals[a.ID] = a + return nil +} + +func (m *Memory) MarkApprovalLogged(_ context.Context, id string, logIndex int64, loggedAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.approvals[id] + if !ok { + return ErrNotFound + } + a.TLogStatus = TLogLogged + a.LogIndex = &logIndex + a.LoggedAt = &loggedAt + a.TLogError = "" + m.approvals[id] = a + return nil +} + +func (m *Memory) MarkApprovalTLogError(_ context.Context, id string, errorMsg string) error { + m.mu.Lock() + defer m.mu.Unlock() + a, ok := m.approvals[id] + if !ok { + return ErrNotFound + } + // Keep status at attestation-pending — a reconciler will retry. We + // only record the last error message for diagnostics. + a.TLogError = errorMsg + m.approvals[id] = a + return nil +} + +// GetApproval is a test-side accessor; not part of the Store interface +// because the HTTP handlers never need it. +func (m *Memory) GetApproval(id string) (Approval, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + a, ok := m.approvals[id] + return a, ok +} + +func clonePreview(p Preview) Preview { + out := p + out.ContentCanonical = append([]byte(nil), p.ContentCanonical...) + out.Salt = append([]byte(nil), p.Salt...) + out.Nonce = append([]byte(nil), p.Nonce...) + if p.ConsumedAt != nil { + t := *p.ConsumedAt + out.ConsumedAt = &t + } + return out +} diff --git a/services/approval-service/internal/store/postgres.go b/services/approval-service/internal/store/postgres.go new file mode 100644 index 0000000..647fe7d --- /dev/null +++ b/services/approval-service/internal/store/postgres.go @@ -0,0 +1,41 @@ +package store + +import ( + "context" + "errors" + "time" +) + +// Postgres is the production Store. **Skeleton** mirroring the pattern used +// by services/admin-api/internal/store/postgres.go: every method returns +// errPGUnimplemented until the pgxpool wiring lands. The schema is +// authoritative in db/trust/migrations/0004_approval_sessions.*.sql. +type Postgres struct { + // pool *pgxpool.Pool // kept out of go.mod in the skeleton +} + +var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for Phase 1") + +// NewPostgres returns a zero-value Postgres. Real wiring is Phase 2. +func NewPostgres( /* conn string, logger */ ) (*Postgres, error) { + return &Postgres{}, nil +} + +func (p *Postgres) CreateSession(_ context.Context, _ Session) error { return errPGUnimplemented } +func (p *Postgres) GetSession(_ context.Context, _ string) (Session, error) { + return Session{}, errPGUnimplemented +} +func (p *Postgres) SavePreview(_ context.Context, _ Preview) error { return errPGUnimplemented } +func (p *Postgres) GetPreview(_ context.Context, _ string) (Preview, error) { + return Preview{}, errPGUnimplemented +} +func (p *Postgres) ConsumePreview(_ context.Context, _ string, _ time.Time) (Preview, error) { + return Preview{}, errPGUnimplemented +} +func (p *Postgres) SaveApproval(_ context.Context, _ Approval) error { return errPGUnimplemented } +func (p *Postgres) MarkApprovalLogged(_ context.Context, _ string, _ int64, _ time.Time) error { + return errPGUnimplemented +} +func (p *Postgres) MarkApprovalTLogError(_ context.Context, _ string, _ string) error { + return errPGUnimplemented +} diff --git a/services/approval-service/internal/store/store.go b/services/approval-service/internal/store/store.go new file mode 100644 index 0000000..0c149cf --- /dev/null +++ b/services/approval-service/internal/store/store.go @@ -0,0 +1,90 @@ +// Package store defines the persistence contract for approval_sessions, +// approval_previews, and approvals. Phase 1 ships an in-memory impl; the +// Postgres schema is in db/trust/migrations/0004_approval_sessions.*.sql +// and wired via a skeleton Postgres type (see postgres.go) so the HTTP +// layer stays unchanged when the real pgxpool wiring lands. +package store + +import ( + "context" + "errors" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/signer" +) + +// ErrNotFound is returned by lookup methods when the row does not exist +// or has expired. +var ErrNotFound = errors.New("store: not found") + +// ErrAlreadyConsumed is returned by ConsumePreview when the preview has +// already been consumed — the confirm endpoint MUST reject a replay. +var ErrAlreadyConsumed = errors.New("store: preview already consumed") + +// Session is a per-browser-login approval session. active_until bounds +// how long previews may be created against it. +type Session struct { + ID string + UserSub string + ActiveUntil time.Time + CreatedAt time.Time +} + +// Preview is a server-rendered approval payload that has not yet been +// confirmed. ContentCanonical lets the confirm endpoint recompute +// H_content deterministically from the stored row. +type Preview struct { + ID string + SessionID string + UserSub string + ContentHash string + ContentCanonical []byte + Salt []byte + Nonce []byte + VerificationDigits string + ExpiresAt time.Time + CreatedAt time.Time + ConsumedAt *time.Time +} + +// TLogStatus is the three-valued state of the Transparency-Log submit. +type TLogStatus string + +const ( + TLogPending TLogStatus = "attestation-pending" + TLogLogged TLogStatus = "logged" + TLogFailed TLogStatus = "failed" +) + +// Approval is the confirmed, DSSE-signed approval record. If TLogStatus +// is TLogPending/TLogFailed, log_index is nil and the attest-reconciler +// (or a future worker) will replay the Transparency-Log submit against +// the server's idempotent /api/v1/log/entries contract. +type Approval struct { + ID string + PreviewID string + SessionID string + UserSub string + ContentHash string + Envelope signer.Envelope + AttestationRef string + TLogStatus TLogStatus + LogIndex *int64 + TLogError string + ConfirmedAt time.Time + LoggedAt *time.Time +} + +// Store is the full persistence surface the HTTP service depends on. +type Store interface { + CreateSession(ctx context.Context, s Session) error + GetSession(ctx context.Context, id string) (Session, error) + + SavePreview(ctx context.Context, p Preview) error + GetPreview(ctx context.Context, id string) (Preview, error) + ConsumePreview(ctx context.Context, id string, now time.Time) (Preview, error) + + SaveApproval(ctx context.Context, a Approval) error + MarkApprovalLogged(ctx context.Context, id string, logIndex int64, loggedAt time.Time) error + MarkApprovalTLogError(ctx context.Context, id string, errorMsg string) error +} diff --git a/services/approval-service/internal/tlog/submitter.go b/services/approval-service/internal/tlog/submitter.go new file mode 100644 index 0000000..c43d297 --- /dev/null +++ b/services/approval-service/internal/tlog/submitter.go @@ -0,0 +1,122 @@ +// Package tlog calls the transparency-log's POST /api/v1/log/entries +// endpoint. Idempotent on the server side: resubmitting the same +// (envelope, content_hash) returns the same log_index + inclusion proof. +// See DEV-600 and services/transparency-log for the server-side contract. +package tlog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "basicstack.de/proofedmesh/services/approval-service/internal/signer" +) + +// InclusionProof mirrors the server-side ADR-0005 inclusion proof. +type InclusionProof struct { + TreeSize int64 `json:"tree_size"` + TreeRootHash string `json:"tree_root_hash"` + SignedTreeHeadRef string `json:"signed_tree_head_ref"` + Hashes []string `json:"hashes"` +} + +// Receipt is what the transparency-log hands back on a successful submit. +type Receipt struct { + LogIndex int64 `json:"log_index"` + ContentHash string `json:"content_hash"` + LoggedAt time.Time `json:"logged_at"` + InclusionProof InclusionProof `json:"inclusion_proof"` +} + +// ErrTLogUnavailable classifies connection errors and 5xx responses from +// the transparency-log. Callers (the HTTP /confirm handler) use this to +// decide between returning a logged approval and an attestation-pending +// one — the Approval row is persisted either way. +var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable") + +// Client is the tlog submit client. +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenSource signer.TokenSource +} + +type submitRequest struct { + Envelope signer.Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` +} + +// Submit sends env + its contentHash to the transparency-log. Returns the +// server receipt on 200/201 (idempotent repeat / fresh insert) and +// ErrTLogUnavailable on any transport-level or 5xx failure. All other +// non-2xx responses are reported with the raw status. +func (c Client) Submit(ctx context.Context, env signer.Envelope, contentHash string) (Receipt, error) { + if c.BaseURL == "" { + return Receipt{}, errors.New("tlog.Client: BaseURL is empty") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash}) + if err != nil { + return Receipt{}, fmt.Errorf("marshal submit: %w", err) + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries") + if err != nil { + return Receipt{}, fmt.Errorf("join tlog url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return Receipt{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.TokenSource != nil { + tok, err := c.TokenSource.Token(ctx) + if err != nil { + return Receipt{}, fmt.Errorf("token: %w", err) + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + resp, err := hc.Do(req) + if err != nil { + return Receipt{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusCreated: + // ok + case resp.StatusCode >= 500: + return Receipt{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256)) + default: + return Receipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } + var out Receipt + if err := json.Unmarshal(raw, &out); err != nil { + return Receipt{}, fmt.Errorf("decode receipt: %w", err) + } + if out.ContentHash != contentHash { + return out, fmt.Errorf("tlog returned content_hash %q != sent %q", out.ContentHash, contentHash) + } + if out.LogIndex < 0 { + return out, fmt.Errorf("tlog returned negative log_index %d", out.LogIndex) + } + return out, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/approval-service/internal/webauthn/webauthn.go b/services/approval-service/internal/webauthn/webauthn.go new file mode 100644 index 0000000..8e5ea3f --- /dev/null +++ b/services/approval-service/internal/webauthn/webauthn.go @@ -0,0 +1,225 @@ +// Package webauthn is the approval-service's Phase-1 WebAuthn verifier. +// +// It is NOT the full go-webauthn/webauthn library — Phase 1 only needs to +// verify assertions against mock credentials registered in-memory for the +// test agent. The data types on the wire (`client_data_json`, +// `authenticator_data`, `signature`, `credential_id`) stay WebAuthn +// compatible so a Phase-2 swap to github.com/go-webauthn/webauthn is a +// pure dependency change, not an API break for the browser. +// +// Threat model this implementation addresses: +// +// - The challenge the browser signs is bound to H_content via +// content.ChallengeFor (nonce || sha256(canonical content)). An +// attacker rewriting the preview payload after the human saw it would +// have to find a hash collision, so we check the stored challenge +// bytes-for-bytes. +// - The authenticator type field (`type` in clientDataJSON) MUST be +// "webauthn.get", so a reused registration ceremony signature is +// rejected. +// - The RP ID hash in authenticator_data MUST match our configured RP; +// otherwise an assertion scoped to a different origin would be accepted. +// +// What this implementation deliberately does NOT do: +// +// - It does not enforce user verification flags (UV bit). Phase 2 will. +// - It does not implement the WebAuthn attestation path (registration); +// credentials are inserted directly via Store.AddCredential. +// - It only knows about Ed25519 public keys — all our mock credentials +// use Ed25519. ES256/RS256 land with the library swap in Phase 2. +package webauthn + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" +) + +// Assertion mirrors the WebAuthn PublicKeyCredential assertion response +// (base64url-encoded fields; raw bytes are decoded by Verify). +type Assertion struct { + CredentialID string `json:"credential_id"` + ClientDataJSON string `json:"client_data_json"` + AuthenticatorData string `json:"authenticator_data"` + Signature string `json:"signature"` +} + +// Credential is a registered WebAuthn credential bound to a user sub. +type Credential struct { + ID string // base64url, matches assertion.credential_id + UserSub string // Pocket-ID subject + PublicKey ed25519.PublicKey // Phase 1: only Ed25519. +} + +// Store is the in-memory credential registry. +type Store struct { + mu sync.RWMutex + creds map[string]Credential // key = CredentialID +} + +// NewStore constructs an empty in-memory credential store. +func NewStore() *Store { + return &Store{creds: map[string]Credential{}} +} + +// AddCredential registers c. Used by test agents and the Phase-2 passkey +// enrollment flow. +func (s *Store) AddCredential(c Credential) error { + if c.ID == "" { + return errors.New("webauthn: credential ID is empty") + } + if c.UserSub == "" { + return errors.New("webauthn: credential has no user sub") + } + if len(c.PublicKey) != ed25519.PublicKeySize { + return errors.New("webauthn: credential public key must be Ed25519 (32 bytes)") + } + s.mu.Lock() + defer s.mu.Unlock() + s.creds[c.ID] = c + return nil +} + +// Get returns the credential registered under id. +func (s *Store) Get(id string) (Credential, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + c, ok := s.creds[id] + return c, ok +} + +// VerifyConfig configures Verify's RP and origin checks. +type VerifyConfig struct { + // RPID is the hostname the authenticator scoped the credential to. + // authenticator_data starts with sha256(RPID). + RPID string + // AllowedOrigins is the exact set of accepted browser origins (e.g. + // "https://trust.basicstack.de"). The assertion's clientDataJSON + // `origin` MUST be one of these. + AllowedOrigins []string +} + +// ErrChallengeMismatch means the challenge in clientDataJSON did not match +// what the preview endpoint stored — i.e. H_content was tampered with. +var ErrChallengeMismatch = errors.New("webauthn: challenge in clientDataJSON does not match stored challenge") + +// ErrCredentialUnknown means the asserted credential is not registered. +var ErrCredentialUnknown = errors.New("webauthn: credential not registered") + +// ErrAssertionInvalid is returned for any lower-level verification failure +// (bad origin, wrong type, bad signature). The caller maps this to HTTP +// 401 to avoid leaking which specific check failed to a probing attacker. +var ErrAssertionInvalid = errors.New("webauthn: assertion invalid") + +// Verify checks a WebAuthn assertion against (a) the expected challenge +// bytes and (b) a registered credential. On success it returns the +// matched credential. +func Verify(cfg VerifyConfig, store *Store, a Assertion, expectedChallenge []byte) (Credential, error) { + if store == nil { + return Credential{}, errors.New("webauthn: store is nil") + } + if cfg.RPID == "" { + return Credential{}, errors.New("webauthn: RPID is empty") + } + if len(cfg.AllowedOrigins) == 0 { + return Credential{}, errors.New("webauthn: no allowed origins configured") + } + + cred, ok := store.Get(a.CredentialID) + if !ok { + return Credential{}, ErrCredentialUnknown + } + + cdJSON, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.ClientDataJSON)) + if err != nil { + return cred, fmt.Errorf("%w: client_data_json b64: %v", ErrAssertionInvalid, err) + } + authData, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.AuthenticatorData)) + if err != nil { + return cred, fmt.Errorf("%w: authenticator_data b64: %v", ErrAssertionInvalid, err) + } + sig, err := base64.RawURLEncoding.DecodeString(stripB64Pad(a.Signature)) + if err != nil { + return cred, fmt.Errorf("%w: signature b64: %v", ErrAssertionInvalid, err) + } + + var cd clientData + if err := json.Unmarshal(cdJSON, &cd); err != nil { + return cred, fmt.Errorf("%w: client_data decode: %v", ErrAssertionInvalid, err) + } + if cd.Type != "webauthn.get" { + return cred, fmt.Errorf("%w: type %q", ErrAssertionInvalid, cd.Type) + } + if !originAllowed(cfg.AllowedOrigins, cd.Origin) { + return cred, fmt.Errorf("%w: origin %q not allowed", ErrAssertionInvalid, cd.Origin) + } + + gotChal, err := base64.RawURLEncoding.DecodeString(stripB64Pad(cd.Challenge)) + if err != nil { + return cred, fmt.Errorf("%w: challenge b64: %v", ErrAssertionInvalid, err) + } + if subtle.ConstantTimeCompare(gotChal, expectedChallenge) != 1 { + return cred, ErrChallengeMismatch + } + + // authenticator_data layout per the WebAuthn spec: + // rpIdHash (32) || flags (1) || signCount (4) || ? + if len(authData) < 37 { + return cred, fmt.Errorf("%w: authenticator_data too short", ErrAssertionInvalid) + } + rpHash := sha256.Sum256([]byte(cfg.RPID)) + if subtle.ConstantTimeCompare(authData[:32], rpHash[:]) != 1 { + return cred, fmt.Errorf("%w: rpIdHash mismatch", ErrAssertionInvalid) + } + + // Signed-over bytes per the spec: authenticator_data || sha256(clientDataJSON). + cdHash := sha256.Sum256(cdJSON) + signed := make([]byte, 0, len(authData)+len(cdHash)) + signed = append(signed, authData...) + signed = append(signed, cdHash[:]...) + + if !ed25519.Verify(cred.PublicKey, signed, sig) { + return cred, fmt.Errorf("%w: ed25519 signature verification failed", ErrAssertionInvalid) + } + return cred, nil +} + +// clientData is the subset of CollectedClientData we care about. +type clientData struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` +} + +func originAllowed(allowed []string, got string) bool { + for _, o := range allowed { + if strings.EqualFold(o, got) { + return true + } + } + return false +} + +// stripB64Pad lets us accept both padded (`...==`) and unpadded base64url +// since different browsers emit different forms. +func stripB64Pad(s string) string { + return strings.TrimRight(s, "=") +} + +// MakeAuthenticatorData is a test helper: builds a minimal +// authenticator_data (rpIdHash || flags=0x01 || signCount=0) for the +// given RP ID. Production enrollment never comes through this path. +func MakeAuthenticatorData(rpID string) []byte { + sum := sha256.Sum256([]byte(rpID)) + buf := make([]byte, 32+1+4) + copy(buf, sum[:]) + buf[32] = 0x01 // UP (user present); UV left zero for the Phase-1 shim. + // sign count stays zero + return buf +} diff --git a/services/approval-service/internal/webauthn/webauthn_test.go b/services/approval-service/internal/webauthn/webauthn_test.go new file mode 100644 index 0000000..061b0e1 --- /dev/null +++ b/services/approval-service/internal/webauthn/webauthn_test.go @@ -0,0 +1,134 @@ +package webauthn + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "testing" +) + +func TestVerify_HappyPath(t *testing.T) { + pub, priv, cred, store := setupCredential(t, "alice") + _ = pub + challenge := []byte("challenge-bytes-just-for-the-test") + a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge) + + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + got, err := Verify(cfg, store, a, challenge) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.ID != cred.ID { + t.Errorf("credential id mismatch") + } +} + +func TestVerify_WrongChallenge(t *testing.T) { + _, priv, cred, store := setupCredential(t, "alice") + challenge := []byte("original-challenge-bytes-xxxxxxxx") + a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge) + + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + _, err := Verify(cfg, store, a, []byte("different-challenge-bytes-yyyyyy")) + if !errors.Is(err, ErrChallengeMismatch) { + t.Fatalf("want ErrChallengeMismatch, got %v", err) + } +} + +func TestVerify_UnknownCredential(t *testing.T) { + store := NewStore() + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + _, err := Verify(cfg, store, Assertion{CredentialID: "nope"}, []byte("c")) + if !errors.Is(err, ErrCredentialUnknown) { + t.Fatalf("want ErrCredentialUnknown, got %v", err) + } +} + +func TestVerify_WrongOrigin(t *testing.T) { + _, priv, cred, store := setupCredential(t, "alice") + challenge := []byte("challenge-xxx-xxx-xxx-xxx") + a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://evil.example", challenge) + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + _, err := Verify(cfg, store, a, challenge) + if err == nil || !errors.Is(err, ErrAssertionInvalid) { + t.Fatalf("want ErrAssertionInvalid, got %v", err) + } +} + +func TestVerify_WrongType(t *testing.T) { + _, priv, cred, store := setupCredential(t, "alice") + challenge := []byte("challenge-xxx-xxx-xxx-xxx") + a := makeAssertionWithType(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge, "webauthn.create") + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + _, err := Verify(cfg, store, a, challenge) + if !errors.Is(err, ErrAssertionInvalid) { + t.Fatalf("want ErrAssertionInvalid, got %v", err) + } +} + +func TestVerify_BadSignature(t *testing.T) { + _, priv, cred, store := setupCredential(t, "alice") + challenge := []byte("challenge-xxx-xxx-xxx-xxx") + a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge) + // Tamper with the signature. + sig, _ := base64.RawURLEncoding.DecodeString(a.Signature) + sig[0] ^= 0xff + a.Signature = base64.RawURLEncoding.EncodeToString(sig) + + cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}} + _, err := Verify(cfg, store, a, challenge) + if !errors.Is(err, ErrAssertionInvalid) { + t.Fatalf("want ErrAssertionInvalid, got %v", err) + } +} + +// --- helpers -------------------------------------------------------------- + +func setupCredential(t *testing.T, userSub string) (ed25519.PublicKey, ed25519.PrivateKey, Credential, *Store) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen key: %v", err) + } + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + cred := Credential{ + ID: base64.RawURLEncoding.EncodeToString(idBytes), + UserSub: userSub, + PublicKey: pub, + } + store := NewStore() + if err := store.AddCredential(cred); err != nil { + t.Fatalf("AddCredential: %v", err) + } + return pub, priv, cred, store +} + +// makeAssertion signs a mock WebAuthn assertion with the given private +// key over the standard authenticator_data || sha256(clientDataJSON). +func makeAssertion(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte) Assertion { + return makeAssertionWithType(t, priv, credID, rpID, origin, challenge, "webauthn.get") +} + +func makeAssertionWithType(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte, typ string) Assertion { + t.Helper() + cd := map[string]string{ + "type": typ, + "challenge": base64.RawURLEncoding.EncodeToString(challenge), + "origin": origin, + } + cdBytes, _ := json.Marshal(cd) + authData := MakeAuthenticatorData(rpID) + cdHash := sha256.Sum256(cdBytes) + signed := append(append([]byte(nil), authData...), cdHash[:]...) + sig := ed25519.Sign(priv, signed) + return Assertion{ + CredentialID: credID, + ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes), + AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData), + Signature: base64.RawURLEncoding.EncodeToString(sig), + } +} diff --git a/services/approval-service/pkg/httpapi/types.go b/services/approval-service/pkg/httpapi/types.go new file mode 100644 index 0000000..951e6e0 --- /dev/null +++ b/services/approval-service/pkg/httpapi/types.go @@ -0,0 +1,65 @@ +// Package httpapi exposes the stable wire types of the approval-service. +// Clients (admin UIs, CLI integrations, Phase-2 webauthn library adapters) +// import this package; the internal/httpapi handlers reuse these same +// structs so the on-wire shape matches the exported contract. +package httpapi + +import "time" + +// CreateSessionResponse is returned by POST /api/v1/sessions. +type CreateSessionResponse struct { + SessionID string `json:"session_id"` + UserSub string `json:"user_sub"` + ActiveUntil time.Time `json:"active_until"` +} + +// PreviewRequest is the body of POST /api/v1/approvals/preview. +type PreviewRequest struct { + SessionID string `json:"session_id"` + Content map[string]any `json:"content"` +} + +// PreviewResponse is returned by POST /api/v1/approvals/preview. +type PreviewResponse struct { + PreviewID string `json:"preview_id"` + ContentHash string `json:"content_hash"` + VerificationDigits string `json:"verification_digits"` + WebauthnChallenge string `json:"webauthn_challenge"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Assertion mirrors the WebAuthn PublicKeyCredentialAssertionResponse. +// All fields are base64url-encoded. +type Assertion struct { + CredentialID string `json:"credential_id"` + ClientDataJSON string `json:"client_data_json"` + AuthenticatorData string `json:"authenticator_data"` + Signature string `json:"signature"` +} + +// ConfirmRequest is the body of POST /api/v1/approvals/confirm. +type ConfirmRequest struct { + PreviewID string `json:"preview_id"` + Assertion Assertion `json:"assertion"` +} + +// ConfirmResponse is returned by POST /api/v1/approvals/confirm. LogIndex +// is nil (and TLogStatus is "attestation-pending") when the TLog submit +// failed; the Approval row is persisted either way. +type ConfirmResponse struct { + ApprovalID string `json:"approval_id"` + AttestationRef string `json:"attestation_ref"` + LogIndex *int64 `json:"log_index"` + TLogStatus string `json:"tlog_status"` +} + +// Error is the stable error envelope. +type Error struct { + Error ErrorBody `json:"error"` +} + +// ErrorBody is the inner typed error. +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +}