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:
parent
47a7373ef2
commit
801c50cfb0
3 changed files with 191 additions and 7 deletions
|
|
@ -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))
|
||||
|
|
|
|||
137
services/approval-service/internal/httpapi/dev.go
Normal file
137
services/approval-service/internal/httpapi/dev.go
Normal 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},
|
||||
})
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue