diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..cb9c834 --- /dev/null +++ b/internal/server/server.go @@ -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 =***. +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) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..bb83aa4 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,467 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.knownelement.com/ukrrs/mopac-keyproxy/internal/backend" + "git.knownelement.com/ukrrs/mopac-keyproxy/internal/config" +) + +const ( + authToken = "smoke-bearer-token-do-not-log" + fileSecret = "sk-file-supersecret-material-aaa111" + envSecret = "sk-env-supersecret-material-bbb222" + panicSecret = "sk-panic-supersecret-material-ccc333" +) + +type fixture struct { + cfg *config.Config + log *bytes.Buffer + ts *httptest.Server + backend *backend.Registry +} + +// newFixture builds a full stack: real config file, real 0600 env files, +// real registry, real HTTP listener on 127.0.0.1 (httptest), shared log +// buffer for redaction sweeps. +func newFixture(t *testing.T) *fixture { + t.Helper() + dir := t.TempDir() + write := func(name, content string, mode os.FileMode) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + return path + } + tokenFile := write("keyproxy.env", "KEYPROXY_TOKEN="+authToken+"\n", 0o600) + goodFile := write("good.env", "API_KEY="+fileSecret+"\nOTHER=x\n", 0o600) + looseFile := write("loose.env", "API_KEY="+fileSecret+"\n", 0o644) + emptyFile := write("empty.env", "API_KEY=\n", 0o600) + + t.Setenv("KEYPROXY_TEST_ENV_SECRET", envSecret) + + cfgPath := write("keyproxy.toml", fmt.Sprintf(` +listen = "127.0.0.1:0" + +[auth] +token_ref = "mpk-keyproxy-self" + +[refs."mpk-keyproxy-self"] +backend = "file" +source = %q +key = "KEYPROXY_TOKEN" + +[refs."mpk-file"] +backend = "file" +source = %q +key = "API_KEY" + +[refs."mpk-env"] +backend = "env" +source = "KEYPROXY_TEST_ENV_SECRET" + +[refs."mpk-loose"] +backend = "file" +source = %q +key = "API_KEY" + +[refs."mpk-empty"] +backend = "file" +source = %q +key = "API_KEY" + +[refs."mpk-bitwarden"] +backend = "bitwarden" +source = "sm://p/x" +key = "API_KEY" + +[refs."mpk-vault"] +backend = "vault" +source = "secret/data/x" +key = "API_KEY" +`, tokenFile, goodFile, looseFile, emptyFile), 0o600) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("fixture config: %v", err) + } + reg := backend.NewRegistry( + backend.NewFile(), + backend.NewEnv(), + backend.NewBitwarden(), + backend.NewVault(), + ) + log := &bytes.Buffer{} + srv, err := New(cfg, reg, log) + if err != nil { + t.Fatalf("fixture server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return &fixture{cfg: cfg, log: log, ts: ts, backend: reg} +} + +// post drives /v1/resolve with an optional bearer token. +func (f *fixture) post(t *testing.T, token, body string) (int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, f.ts.URL+"/v1/resolve", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := &bytes.Buffer{} + _, _ = buf.ReadFrom(resp.Body) + return resp.StatusCode, buf.String() +} + +func TestResolveTable(t *testing.T) { + f := newFixture(t) + + tests := []struct { + name string + token string + body string + wantStatus int + wantBody string // substring expected in the body + wantBody2 string // second substring expected in the body + noBody string // substring that must NOT appear in the body + wantLog string // substring expected in the log + }{ + { + name: "file ref resolves", + token: authToken, + body: `{"ref":"mpk-file"}`, + wantStatus: http.StatusOK, + wantBody: `"value":"` + fileSecret + `"`, + wantLog: "ref=mpk-file=*** backend=file status=200", + }, + { + name: "env ref resolves", + token: authToken, + body: `{"ref":"mpk-env"}`, + wantStatus: http.StatusOK, + wantBody: `"value":"` + envSecret + `"`, + wantLog: "ref=mpk-env=*** backend=env status=200", + }, + { + name: "no token 401", + body: `{"ref":"mpk-file"}`, + wantStatus: http.StatusUnauthorized, + wantBody: "unauthorized", + noBody: fileSecret, + wantLog: "auth failure", + }, + { + name: "wrong token 401", + token: "wrong-token", + body: `{"ref":"mpk-file"}`, + wantStatus: http.StatusUnauthorized, + wantBody: "unauthorized", + noBody: "wrong-token", // presented token never logged/echoed + }, + { + name: "unknown ref 404", + token: authToken, + body: `{"ref":"mpk-ghost"}`, + wantStatus: http.StatusNotFound, + wantBody: `"ref":"mpk-ghost"`, + wantLog: "ref=mpk-ghost=*** backend=- status=404 reason=unknown_ref", + }, + { + name: "invalid ref format 400 without echo", + token: authToken, + body: `{"ref":"sk-live-pasted-secret-xyz"}`, + wantStatus: http.StatusBadRequest, + wantBody: "invalid ref", + noBody: "sk-live-pasted-secret-xyz", + }, + { + name: "malformed body 400", + token: authToken, + body: `not json`, + wantStatus: http.StatusBadRequest, + wantBody: "malformed body", + }, + { + name: "empty ref 400", + token: authToken, + body: `{"ref":""}`, + wantStatus: http.StatusBadRequest, + wantBody: "malformed body", + }, + { + name: "bitwarden stub 501", + token: authToken, + body: `{"ref":"mpk-bitwarden"}`, + wantStatus: http.StatusNotImplemented, + wantBody: `"reason":"not_implemented"`, + wantBody2: `"backend":"bitwarden"`, + wantLog: "ref=mpk-bitwarden=*** backend=bitwarden status=501 reason=not_implemented", + }, + { + name: "vault stub 501", + token: authToken, + body: `{"ref":"mpk-vault"}`, + wantStatus: http.StatusNotImplemented, + wantBody: `"reason":"not_implemented"`, + wantBody2: `"backend":"vault"`, + wantLog: "ref=mpk-vault=*** backend=vault status=501 reason=not_implemented", + }, + { + name: "empty value 502", + token: authToken, + body: `{"ref":"mpk-empty"}`, + wantStatus: http.StatusBadGateway, + wantBody: `"reason":"empty_value"`, + wantLog: "ref=mpk-empty=*** backend=file status=502 reason=empty_value", + }, + { + name: "loose file mode 502", + token: authToken, + body: `{"ref":"mpk-loose"}`, + wantStatus: http.StatusBadGateway, + wantBody: `"reason":"insecure_source_mode"`, + wantLog: "ref=mpk-loose=*** backend=file status=502 reason=insecure_source_mode", + }, + { + name: "oversized body 413", + token: authToken, + body: `{"ref":"` + strings.Repeat("a", 8192) + `"}`, + wantStatus: http.StatusRequestEntityTooLarge, + wantBody: "payload too large", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, body := f.post(t, tt.token, tt.body) + if status != tt.wantStatus { + t.Fatalf("status = %d (%s), want %d", status, body, tt.wantStatus) + } + if tt.wantBody != "" && !strings.Contains(body, tt.wantBody) { + t.Fatalf("body %q missing %q", body, tt.wantBody) + } + if tt.wantBody2 != "" && !strings.Contains(body, tt.wantBody2) { + t.Fatalf("body %q missing %q", body, tt.wantBody2) + } + if tt.noBody != "" && strings.Contains(body, tt.noBody) { + t.Fatalf("BODY LEAKS %q: %q", tt.noBody, body) + } + if tt.wantLog != "" && !strings.Contains(f.log.String(), tt.wantLog) { + t.Fatalf("log %q missing %q", f.log.String(), tt.wantLog) + } + }) + } + + // Method checks. + resp, err := http.Get(f.ts.URL + "/v1/resolve") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("GET /v1/resolve = %d, want 405", resp.StatusCode) + } + resp, err = http.Get(f.ts.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /healthz = %d, want 200 (unauthenticated)", resp.StatusCode) + } + + // Whole-run redaction sweep: no material, no presented token, no + // panic values anywhere in the log buffer; every ref named in the + // log appears masked as =***. + logAll := f.log.String() + for _, secret := range []string{authToken, "wrong-token", fileSecret, envSecret, "sk-live-pasted-secret"} { + if strings.Contains(logAll, secret) { + t.Fatalf("LOG LEAKS MATERIAL %q:\n%s", secret, logAll) + } + } + for _, ref := range []string{"mpk-file", "mpk-env", "mpk-ghost", "mpk-empty", "mpk-loose"} { + if strings.Contains(logAll, ref) && !strings.Contains(logAll, ref+"=***") { + // ref may appear only in masked form; check per line + for _, line := range strings.Split(logAll, "\n") { + if strings.Contains(line, ref) && !strings.Contains(line, ref+"=***") { + t.Fatalf("LOG LINE NAMES REF UNMASKED: %s", line) + } + } + } + } +} + +// TestPanicRedaction drives a backend panic whose VALUE is the material +// and asserts the crash path stays redacted. +func TestPanicRedaction(t *testing.T) { + dir := t.TempDir() + tokenPath := filepath.Join(dir, "keyproxy.env") + if err := os.WriteFile(tokenPath, []byte("KEYPROXY_TOKEN="+authToken+"\n"), 0o600); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "keyproxy.toml") + content := fmt.Sprintf(` +[auth] +token_ref = "mpk-keyproxy-self" + +[refs."mpk-keyproxy-self"] +backend = "file" +source = %q +key = "KEYPROXY_TOKEN" + +[refs."mpk-file"] +backend = "file" +source = %q +key = "API_KEY" +`, tokenPath, filepath.Join(dir, "absent.env")) + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + + // A file-backend implementation that serves the auth token normally + // but panics with the material as the panic value on data refs: the + // worst-case crash path. + reg := backend.NewRegistry(&panicBackend{token: authToken, material: panicSecret}, backend.NewEnv(), backend.NewBitwarden(), backend.NewVault()) + log := &bytes.Buffer{} + srv, err := New(cfg, reg, log) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/resolve", strings.NewReader(`{"ref":"mpk-file"}`)) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := &bytes.Buffer{} + _, _ = buf.ReadFrom(resp.Body) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } + if strings.Contains(buf.String(), panicSecret) { + t.Fatalf("RESPONSE LEAKS MATERIAL: %q", buf.String()) + } + got := log.String() + if !strings.Contains(got, "panic recovered") || !strings.Contains(got, "ref=mpk-file=***") { + t.Fatalf("panic log line missing or unmasked:\n%s", got) + } + if strings.Contains(got, panicSecret) { + t.Fatalf("LOG LEAKS MATERIAL ON CRASH PATH:\n%s", got) + } +} + +// TestNewFailFastAuth covers startup refusing when the auth token cannot +// be resolved (fail fast, error names the ref, never the token). +func TestNewFailFastAuth(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "keyproxy.toml") + content := fmt.Sprintf(` +[auth] +token_ref = "mpk-keyproxy-self" + +[refs."mpk-keyproxy-self"] +backend = "file" +source = %q +key = "KEYPROXY_TOKEN" +`, filepath.Join(dir, "absent.env")) + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + reg := backend.NewRegistry(backend.NewFile(), backend.NewEnv(), backend.NewBitwarden(), backend.NewVault()) + _, err = New(cfg, reg, &bytes.Buffer{}) + if err == nil { + t.Fatal("New must fail when the auth token cannot be resolved") + } + if !strings.Contains(err.Error(), "mpk-keyproxy-self") || !strings.Contains(err.Error(), "unreadable_source") { + t.Fatalf("error %q must name the auth ref and reason", err) + } +} + +// panicBackend implements Backend (registered under the file name): +// with all set it panics on every resolve; otherwise it serves the auth +// token normally and panics with material in the panic value on every +// other ref. +type panicBackend struct { + token string + material string + all bool +} + +func (p *panicBackend) Name() string { return "file" } + +func (p *panicBackend) Resolve(ctx context.Context, ref backend.Ref) (string, error) { + if !p.all && ref.Key == "KEYPROXY_TOKEN" { + return p.token, nil + } + panic(p.material) +} + +// TestNewPanicSuppressed: a backend that panics even on the auth-token +// resolve makes startup fail fast with a generic, material-free error. +func TestNewPanicSuppressed(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "keyproxy.toml") + content := fmt.Sprintf(` +[auth] +token_ref = "mpk-keyproxy-self" + +[refs."mpk-keyproxy-self"] +backend = "file" +source = %q +key = "KEYPROXY_TOKEN" +`, filepath.Join(dir, "absent.env")) + if err := os.WriteFile(cfgPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + reg := backend.NewRegistry( + &panicBackend{token: "never-served", material: panicSecret, all: true}, + backend.NewEnv(), backend.NewBitwarden(), backend.NewVault(), + ) + logBuf := &bytes.Buffer{} + _, err = New(cfg, reg, logBuf) + if err == nil { + t.Fatal("New must fail when the auth-token resolve panics") + } + if strings.Contains(err.Error(), panicSecret) { + t.Fatalf("STARTUP ERROR LEAKS MATERIAL: %q", err) + } + if !strings.Contains(err.Error(), "panicked (detail suppressed)") { + t.Fatalf("error %q must say the panic detail is suppressed", err) + } +}