diff --git a/services/approval-service/cmd/approval-service/main.go b/services/approval-service/cmd/approval-service/main.go index e139963..50b211a 100644 --- a/services/approval-service/cmd/approval-service/main.go +++ b/services/approval-service/cmd/approval-service/main.go @@ -34,6 +34,8 @@ func main() { WebauthnRPID string WebauthnOrigins string AttestationIss string + DevAuthSub string + DevRoutes bool }{} 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") @@ -45,6 +47,8 @@ func main() { 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.StringVar(&cfg.DevAuthSub, "dev-auth-sub", os.Getenv("APPROVAL_DEV_AUTH_SUB"), "DEV ONLY: when set, stamp every request with this Pocket-ID `sub` instead of requiring real auth") + flag.BoolVar(&cfg.DevRoutes, "dev-routes", envBool("APPROVAL_DEV_ROUTES", false), "DEV ONLY: expose POST /dev/webauthn/credentials to register in-memory credentials for the smoke test") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) @@ -85,14 +89,21 @@ func main() { 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) { + var authResolver httpapi.AuthResolver + if cfg.DevAuthSub != "" { + sub := cfg.DevAuthSub + logger.Warn("approval-service: dev auth resolver in use — DEV/CI ONLY", "sub", sub) + authResolver = httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) { + return auth.Principal{Sub: sub}, nil + }) + } else { + authResolver = 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") - }), + }) + } + + server := &httpapi.Server{ + Auth: authResolver, Sessions: mgr, Orchestrator: orch, Logger: logger, @@ -100,6 +111,10 @@ func main() { mux := http.NewServeMux() server.Mount(mux) + if cfg.DevRoutes { + logger.Warn("approval-service: dev routes enabled — DEV/CI ONLY") + httpapi.MountDevRoutes(mux, orch.Webauthn, logger) + } logger.Info("approval-service starting", "listen", cfg.Listen, @@ -131,6 +146,20 @@ func envDur(key string, fallback time.Duration) time.Duration { return d } +func envBool(key string, fallback bool) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return fallback +} + func splitAndTrim(s, sep string) []string { parts := strings.Split(s, sep) out := make([]string, 0, len(parts)) diff --git a/services/approval-service/internal/httpapi/dev.go b/services/approval-service/internal/httpapi/dev.go new file mode 100644 index 0000000..83d0ccd --- /dev/null +++ b/services/approval-service/internal/httpapi/dev.go @@ -0,0 +1,137 @@ +// 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}, + }) +} diff --git a/services/approval-service/internal/service/orchestrator.go b/services/approval-service/internal/service/orchestrator.go index d84b90c..17272c5 100644 --- a/services/approval-service/internal/service/orchestrator.go +++ b/services/approval-service/internal/service/orchestrator.go @@ -6,6 +6,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -203,6 +204,13 @@ func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(pre // 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. +// +// A small set of chain-control fields on the rendered content — +// allowed_transformations, allowed_sinks, expires_at, workflow_id — are +// promoted onto the predicate so the verification-service can enforce +// them without parsing the renderable content payload itself. All of +// these are already part of the approving human's view (committed to by +// H_content), so promoting them does not weaken the signature guarantee. 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 @@ -222,6 +230,16 @@ func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.T "performed_at": performedAt.UTC().Format(time.RFC3339), "verification_digits": pv.VerificationDigits, } + + var rendered map[string]any + if err := json.Unmarshal(pv.ContentCanonical, &rendered); err == nil { + for _, k := range []string{"allowed_transformations", "allowed_sinks", "expires_at", "workflow_id"} { + if v, ok := rendered[k]; ok { + predicate[k] = v + } + } + } + return signer.Statement{ "_type": "https://in-toto.io/Statement/v1", "subject": []any{