approval-service: Phase-1 content-binding approval service (DEV-608)

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 <noreply@paperclip.ing>
This commit is contained in:
Paperclip 2026-10-25 19:42:06 +00:00
parent b99801f0df
commit 3a01cc651f
24 changed files with 2957 additions and 0 deletions

View file

@ -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;

View file

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

View file

@ -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:<hex>",
"verification_digits": "734219",
"webauthn_challenge": "<b64url>",
"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": "<b64url>",
"client_data_json": "<b64url>",
"authenticator_data": "<b64url>",
"signature": "<b64url>"
}
}
```
Response:
```json
{
"approval_id": "ap_…",
"attestation_ref": "sha256:<hex>",
"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`.

View file

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

View file

@ -0,0 +1,3 @@
module basicstack.de/proofedmesh/services/approval-service
go 1.23

View file

@ -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 != ""
}

View file

@ -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:<hex> 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
}

View file

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

View file

@ -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:<hex>" 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)
}

View file

@ -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")
}
}

View file

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

View file

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

View file

@ -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,
}
}

View file

@ -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 "<prefix>_" + 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
}

View file

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

View file

@ -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]) + "…"
}

View file

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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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]) + "…"
}

View file

@ -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) || <attested cred data>?
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
}

View file

@ -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),
}
}

View file

@ -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"`
}