ProofedMesh/services/approval-service/internal/httpapi/dev.go

138 lines
4 KiB
Go
Raw Permalink Normal View History

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