Add localhost resolve HTTP hop with bearer auth and redaction

Implement `keyproxy serve`: POST /v1/resolve (constant-time bearer-token
auth, 4 KiB body cap, mpk- ref validation before any echo) and
GET /healthz. The token is resolved once at startup from the file
backend. Panics are recovered with the panic value discarded and detail
suppressed; auth failures log the remote address only; every log line
naming a ref masks it as <ref>=***. Failures respond with fixed reason
enums (404/400/405/413/500/501/502) that cannot embed material.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-28 22:30:50 -05:00
parent 7bec2a1905
commit 82861b6d23
2 changed files with 698 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
// Package server implements `keyproxy serve`: a localhost HTTP hop that
// resolves mpk- refs to material. Consumers authenticate with a bearer
// token bootstrapped from the file backend; material crosses the wire at
// resolve time only, in memory, never persisted, never logged — every
// log line that names a ref masks any value as <ref>=***.
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"crypto/subtle"
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend"
"git.knownelement.com/ukrrs/mopac-keyproxy/internal/config"
)
// maxBodyBytes bounds resolve requests; bigger bodies are rejected
// before parsing (413).
const maxBodyBytes = 4 << 10
// Server is the `keyproxy serve` HTTP hop.
type Server struct {
cfg *config.Config
reg *backend.Registry
token string // resolved at startup from the file backend; never logged
logger *log.Logger
}
// New resolves the bearer token through the registry (fail-fast: the
// auth ref must resolve at startup) and returns the server. The token
// value never reaches the log writer; a backend panic during the
// startup resolve is converted to a generic error (a panic value may
// embed material).
func New(cfg *config.Config, reg *backend.Registry, out io.Writer) (*Server, error) {
ref := cfg.Refs[cfg.AuthTokenRef]
b, ok := reg.Get(ref.Backend)
if !ok {
return nil, fmt.Errorf("auth ref %s: backend %s not registered", cfg.AuthTokenRef, ref.Backend)
}
token, err := resolveAuthToken(b, ref)
if err != nil {
return nil, err
}
return &Server{
cfg: cfg,
reg: reg,
token: token,
logger: log.New(out, "keyproxy: ", log.LstdFlags|log.Lmsgprefix),
}, nil
}
// resolveAuthToken resolves the bearer token, converting a backend
// panic into a generic error whose text carries no material.
func resolveAuthToken(b backend.Backend, ref backend.Ref) (token string, err error) {
defer func() {
if p := recover(); p != nil {
_ = p // never stringified: never logged
err = fmt.Errorf("auth ref %s: backend %s: resolve panicked (detail suppressed)", ref.Name, ref.Backend)
}
}()
token, err = b.Resolve(context.Background(), ref)
if err != nil {
return "", fmt.Errorf("auth ref %s: %w", ref.Name, err)
}
return token, nil
}
// Handler builds the HTTP routes: POST /v1/resolve (bearer auth) and
// GET /healthz (unauthenticated liveness).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status":"ok"}`)
})
mux.HandleFunc("/v1/resolve", s.resolve)
return mux
}
// masked returns the log-safe form of a ref: <ref>=***. Refs are
// validated mpk- placeholders before they ever reach a log line, so the
// name itself is safe; the =*** form asserts the value stays masked.
func masked(ref string) string { return ref + "=***" }
func (s *Server) resolve(w http.ResponseWriter, r *http.Request) {
// Crash-path redaction: if anything below panics, log the tracked
// ref/backend (masked, format-validated) and NEVER the panic value
// (a panic value may embed material). Stack suppressed for the same
// reason.
logRef, logBackend := "-", "-"
defer func() {
if p := recover(); p != nil {
_ = p // never stringified: never logged
s.logger.Printf("panic recovered ref=%s backend=%s status=500 detail=suppressed", logRef, logBackend)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}()
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
return
}
// Auth first, before the body is read: failures log the remote only,
// never the presented token.
if !s.authorized(r) {
s.logger.Printf("auth failure remote=%s status=401", r.RemoteAddr)
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
body, ok := s.readBody(w, r)
if !ok {
return
}
var req struct {
Ref string `json:"ref"`
}
if err := json.Unmarshal(body, &req); err != nil || strings.TrimSpace(req.Ref) == "" {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed body (expected {\"ref\":\"mpk-...\"})"})
return
}
name := strings.TrimSpace(req.Ref)
if !config.ValidRef(name) {
// Not echoed: an unparsable "ref" field could hold pasted
// material. Only format-validated mpk- refs are ever echoed.
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ref (expected mpk-<name>)"})
return
}
logRef = masked(name)
ref, ok := s.cfg.Refs[name]
if !ok {
s.logger.Printf("resolve ref=%s backend=%s status=404 reason=%s", logRef, logBackend, backend.ReasonUnknownRef)
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown ref", "ref": name})
return
}
logBackend = ref.Backend
b, ok := s.reg.Get(ref.Backend)
if !ok {
s.logger.Printf("resolve ref=%s backend=%s status=500 reason=unknown_backend", logRef, logBackend)
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backend not registered", "backend": ref.Backend})
return
}
value, err := b.Resolve(r.Context(), ref)
if err != nil {
rerr := backend.AsResolveError(err, ref, ref.Backend)
status := http.StatusBadGateway
if rerr.Reason == backend.ReasonNotImplemented {
status = http.StatusNotImplemented
}
// The reason is a fixed enum: it can name the ref and the
// backend, never material.
s.logger.Printf("resolve ref=%s backend=%s status=%d reason=%s", logRef, logBackend, status, rerr.Reason)
s.writeJSON(w, status, map[string]string{
"error": "resolution failed",
"ref": name,
"backend": ref.Backend,
"reason": string(rerr.Reason),
})
return
}
s.logger.Printf("resolve ref=%s backend=%s status=200", logRef, logBackend)
s.writeJSON(w, http.StatusOK, map[string]string{"value": value})
}
// authorized checks the bearer token in constant time; the presented
// value is never logged and never compared with early exit.
func (s *Server) authorized(r *http.Request) bool {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return false
}
presented := h[len(prefix):]
return subtle.ConstantTimeCompare([]byte(presented), []byte(s.token)) == 1
}
func (s *Server) readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unreadable body"})
return nil, false
}
if len(body) > maxBodyBytes {
s.writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
return nil, false
}
return body, true
}
func (s *Server) writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// ListenAndServe runs the HTTP hop until ctx is cancelled, then shuts
// down gracefully (material is memory-only; shutdown drops it).
func (s *Server) ListenAndServe(ctx context.Context) error {
httpSrv := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() { errCh <- httpSrv.ListenAndServe() }()
s.logger.Printf("listening addr=%s refs=%d backends=%s auth=%s", s.cfg.Listen, len(s.cfg.Refs), strings.Join(s.reg.Names(), ","), masked(s.cfg.AuthTokenRef))
select {
case err := <-errCh:
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
}