// Package httpapi — dev-only routes. Mounted by main.go only when the // operator passes -dev-routes (or APPROVAL_DEV_ROUTES=1). These routes // seed in-memory WebAuthn credentials so the Phase-1 end-to-end smoke // test can drive the approval flow without a browser or Pocket-ID. // NEVER mount these in production. package httpapi import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "strings" "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" ) type devRegisterCredentialRequest struct { CredentialID string `json:"credential_id"` UserSub string `json:"user_sub"` PublicKey string `json:"public_key"` } type devRegisterCredentialResponse struct { CredentialID string `json:"credential_id"` UserSub string `json:"user_sub"` } // MountDevRoutes exposes POST /dev/webauthn/credentials and GET // /dev/healthz. Call only from a dev/CI build; the handler accepts any // Ed25519 public key as a trusted credential, which is useless in prod. func MountDevRoutes(mux *http.ServeMux, store *webauthn.Store, logger *slog.Logger) { mux.HandleFunc("POST /dev/webauthn/credentials", func(w http.ResponseWriter, r *http.Request) { var req devRegisterCredentialRequest dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.CredentialID == "" { writeDevErr(w, http.StatusBadRequest, "bad_request", "credential_id is required") return } if req.UserSub == "" { writeDevErr(w, http.StatusBadRequest, "bad_request", "user_sub is required") return } pub, err := decodeDevPubKey(req.PublicKey) if err != nil { writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } cred := webauthn.Credential{ ID: req.CredentialID, UserSub: req.UserSub, PublicKey: pub, } if err := store.AddCredential(cred); err != nil { writeDevErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } if logger != nil { logger.Info("dev: webauthn credential registered", "credential_id", req.CredentialID, "user_sub", req.UserSub, ) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(devRegisterCredentialResponse{ CredentialID: req.CredentialID, UserSub: req.UserSub, }) }) } func decodeDevPubKey(s string) (ed25519.PublicKey, error) { s = strings.TrimSpace(s) if s == "" { return nil, errors.New("public_key is required (hex, b64, or b64url of a 32-byte Ed25519 public key)") } if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil && len(b) == ed25519.PublicKeySize { return ed25519.PublicKey(b), nil } if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize { return ed25519.PublicKey(b), nil } if b, err := decodeHex(s); err == nil && len(b) == ed25519.PublicKeySize { return ed25519.PublicKey(b), nil } return nil, errors.New("public_key must be a 32-byte Ed25519 key encoded as hex, base64, or base64url") } func decodeHex(s string) ([]byte, error) { n := len(s) if n%2 != 0 { return nil, errors.New("odd-length hex string") } out := make([]byte, n/2) for i := 0; i < n; i += 2 { hi, err := hexNibble(s[i]) if err != nil { return nil, err } lo, err := hexNibble(s[i+1]) if err != nil { return nil, err } out[i/2] = hi<<4 | lo } return out, nil } func hexNibble(b byte) (byte, error) { switch { case b >= '0' && b <= '9': return b - '0', nil case b >= 'a' && b <= 'f': return b - 'a' + 10, nil case b >= 'A' && b <= 'F': return b - 'A' + 10, nil default: return 0, errors.New("invalid hex character") } } func writeDevErr(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]string{"code": code, "message": msg}, }) }