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
|
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))
|
||||||
|
|
|
||||||
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 (
|
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{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue