approval-service: dev auth/credentials routes + predicate chain-control fields (DEV-613)

Two changes needed to drive the Phase-1 end-to-end smoke test without a
browser and Pocket-ID:

- Add `-dev-auth-sub` / `APPROVAL_DEV_AUTH_SUB` so a dev / CI run can
  stamp every request with a fixed principal sub instead of refusing
  all callers until Pocket-ID is wired. Mutually exclusive with any
  real auth middleware; the deployment path still fails closed.
- Add `-dev-routes` / `APPROVAL_DEV_ROUTES` which mounts
  `POST /dev/webauthn/credentials` on the Phase-1 in-memory WebAuthn
  store. The smoke test generates an Ed25519 keypair and posts the
  public half here before confirming the approval. Both flags log a
  WARN at startup so a misconfigured prod stands out.

Separately, promote four chain-control fields —
`allowed_transformations`, `allowed_sinks`, `expires_at`,
`workflow_id` — from the signed rendered content into the approval
predicate. The verification-service already requires these on the
approval predicate to enforce the sink / transform allowlists and the
approval TTL; they live inside H_content so promoting them does not
weaken the WebAuthn-bound signature.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 20:58:03 +00:00
parent 47a7373ef2
commit 801c50cfb0
3 changed files with 191 additions and 7 deletions

View file

@ -34,6 +34,8 @@ func main() {
WebauthnRPID string WebauthnRPID string
WebauthnOrigins string WebauthnOrigins string
AttestationIss string AttestationIss string
DevAuthSub string
DevRoutes bool
}{} }{}
flag.StringVar(&cfg.Listen, "listen", envOr("APPROVAL_LISTEN", ":8080"), "HTTP listen address") 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.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.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.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.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() flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
@ -85,14 +89,21 @@ func main() {
AttestationIssuer: cfg.AttestationIss, AttestationIssuer: cfg.AttestationIss,
} }
server := &httpapi.Server{ var authResolver httpapi.AuthResolver
// Phase 1 wires an "unauthenticated" dev resolver — the Pocket-ID if cfg.DevAuthSub != "" {
// cookie middleware (shared with admin-api) attaches in deployment. sub := cfg.DevAuthSub
// This default is deliberately noisy so misconfigured deployments logger.Warn("approval-service: dev auth resolver in use — DEV/CI ONLY", "sub", sub)
// fail closed rather than silently accepting anonymous callers. authResolver = httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
Auth: 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") return auth.Principal{}, errors.New("approval-service: no auth middleware mounted; wire Pocket-ID before serving traffic")
}), })
}
server := &httpapi.Server{
Auth: authResolver,
Sessions: mgr, Sessions: mgr,
Orchestrator: orch, Orchestrator: orch,
Logger: logger, Logger: logger,
@ -100,6 +111,10 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
server.Mount(mux) 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", logger.Info("approval-service starting",
"listen", cfg.Listen, "listen", cfg.Listen,
@ -131,6 +146,20 @@ func envDur(key string, fallback time.Duration) time.Duration {
return d 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 { func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep) parts := strings.Split(s, sep)
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))

View file

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

View file

@ -6,6 +6,7 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "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 // buildStatement renders the in-toto v1 Statement for an approval. The
// subject.digest IS the H_content of the preview; verifiers rehash the // subject.digest IS the H_content of the preview; verifiers rehash the
// stored canonical content to prove the signature covers it. // 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 { 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. // Strip the "sha256:" prefix for the digest map, per in-toto convention.
digest := pv.ContentHash 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), "performed_at": performedAt.UTC().Format(time.RFC3339),
"verification_digits": pv.VerificationDigits, "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{ return signer.Statement{
"_type": "https://in-toto.io/Statement/v1", "_type": "https://in-toto.io/Statement/v1",
"subject": []any{ "subject": []any{