138 lines
4 KiB
Go
138 lines
4 KiB
Go
|
|
// 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},
|
||
|
|
})
|
||
|
|
}
|