// 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 =***. 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: =***. 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-)"}) 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) } }