Files
Hanzo Dev c5d633bf3a refactor(embed): drop dead Embedded.HTTPAddr accessor
Provably-dead exported accessor: no in-repo callers (only cmd/kmsd and
mount.go consume the root package) and no external importer of
github.com/hanzoai/kms across the tree uses it. Its backing httpAddr
field was write-only and its comment was wrong (stored the raw config
addr, never a resolved listener address). Removes field, assignment,
and accessor. Build+vet green.
2026-07-18 11:02:07 -07:00

1456 lines
51 KiB
Go

// Package kms is the in-process Hanzo KMS server. It is a thin wrapper
// over github.com/luxfi/kms with Hanzo-specific defaults (port 8443,
// /data/hanzo-kms), explicit IAM/JWKS/issuer/audience config (no
// defaults — refuses to boot if missing), JWT verification (RFC 7519,
// no HS*, no alg=none), audit log with composite actor_id, version CAS
// for replay-resistant updates, and identity-header hygiene.
//
// All threshold signing is delegated to the MPC daemon over ZAP
// (cfg.MPCAddr, cfg.MPCVaultID). All secret storage uses ZapDB at
// cfg.DataDir with optional at-rest encryption via KMS_ENCRYPTION_KEY_B64
// and the in-process Replicator streaming encrypted backups to S3.
//
// This file owns the route assembly + transport wiring. embed.go exposes
// the canonical Embed(ctx, cfg) (*Embedded, error) entry point that the
// fused hanzo binary will import alongside iam, base, and tasks.
package kms
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
badger "github.com/luxfi/zapdb"
"github.com/luxfi/kms/pkg/keys"
"github.com/luxfi/kms/pkg/mpc"
"github.com/luxfi/kms/pkg/store"
"github.com/luxfi/kms/pkg/zapserver"
"github.com/luxfi/log"
"github.com/luxfi/zap"
)
// Version is overridden at build time via -ldflags "-X
// github.com/hanzoai/kms.Version=...".
var Version = "dev"
// maxBodyBytes caps every POST body to prevent OOM via slowloris-style upload.
const maxBodyBytes = 1 << 20 // 1 MiB
// globalAuditor receives one entry per KMS request. Created in Embed,
// consumed by registerSecretRoutes. nil-safe (see auditor.record).
//
// This is a process-global because the auth-failure paths in
// authorize() also need to call recordAudit, and threading the auditor
// into every handler would spread the package surface needlessly. The
// fused hanzo binary calls Embed exactly once, so the global is fine.
var globalAuditor *auditor
// EmbedConfig configures the in-process Hanzo KMS server.
//
// Process-shape fields (DataDir, HTTPAddr, ZAPPort) resolve to the
// canonical defaults of the kmsd container image. Identity-coupled
// fields (IAMEndpoint, JWKSURL, ExpectedIssuer, ExpectedAudience) have
// no defaults — supply them via env or config or Embed refuses to
// boot.
type EmbedConfig struct {
// DataDir is the ZapDB persistence root.
// Empty → "/data/hanzo-kms" (production default).
DataDir string
// HTTPAddr is the bind address for the HTTP server.
// Empty → ":8443". Use ":0" for an ephemeral port (tests).
HTTPAddr string
// IAMEndpoint is the Hanzo IAM origin used for the
// /v1/kms/auth/login client_credentials proxy. Required.
IAMEndpoint string
// JWKSURL is the JWKS endpoint for verifying inbound JWTs.
// Required when JWTKeySource is empty. Tests can point this at an
// httptest.Server.
JWKSURL string
// JWTKeySource is an alias kept for parity with iam.EmbedConfig
// and the fused-binary call site. When non-empty, overrides
// JWKSURL. Empty → JWKSURL or default.
JWTKeySource string
// ZAPPort is the ZAP secrets-server listen port.
// 0 → 9999. Negative disables.
ZAPPort int
// NodeID identifies this KMS instance for replication paths and
// ZAP service discovery. Empty → "hanzo-kms-0".
NodeID string
// Env names the deployment environment. All envs (including dev)
// must supply IAM_ISSUER, IAM_AUDIENCE, IAM_KEYS_URL or boot fails.
// Empty → "dev".
Env string
// ExpectedIssuer is the iss claim a JWT must carry. Empty →
// $IAM_ISSUER.
ExpectedIssuer string
// ExpectedAudience is the aud claim a JWT must carry (comma list
// allowed). Empty → $IAM_AUDIENCE.
ExpectedAudience string
// MPCAddr is the MPC daemon network address. Empty → mDNS only.
MPCAddr string
// MPCVaultID enables the threshold-signing key routes when set.
MPCVaultID string
// AuditDB is the SQLite path for the audit ledger. Empty →
// $KMS_AUDIT_DB or "/tmp/kms-aux.db".
AuditDB string
// SkipListen, when true, runs the bootstrap and registers all
// routes/ZAP handlers, but does NOT bind the HTTP listener.
// The parent serves the returned HTTPHandler() over its own
// listener. Tests use this flag.
SkipListen bool
}
// Embedded is the handle to a running Hanzo KMS server.
type Embedded struct {
cfg EmbedConfig
mux *http.ServeMux
handler http.Handler // mux wrapped in stripIdentityHeaders + methodAllowlist
httpServer *http.Server // nil when SkipListen=true
db *badger.DB
replicator *badger.Replicator
auditCancel context.CancelFunc
zapNode *zap.Node // nil when ZAP server disabled
}
// Embed boots the Hanzo KMS server in-process and returns a handle.
//
// Embed is safe to call exactly once per process: it registers
// process-global JWT verification config and a single audit-writer
// goroutine. The fused hanzo binary calls Embed once at startup and
// keeps the handle for the lifetime of the process.
//
// The provided ctx is used for cancellation; if ctx is cancelled before
// Stop is called, Embed shuts the server down automatically.
func Embed(ctx context.Context, cfg EmbedConfig) (*Embedded, error) {
cfg = applyEmbedDefaults(cfg)
// Identity-coupled config — IAMEndpoint, ExpectedIssuer,
// ExpectedAudience, JWKSURL — has no defaults. Refuses to boot if
// missing, so a misconfig fails on the laptop instead of silently
// rejecting tokens at staging (the hanzo.id default outage of
// 2026-05-07).
if err := validateProdConfigAtBoot(cfg); err != nil {
return nil, fmt.Errorf("kms.Embed: %w", err)
}
// JWT verification contract. Boot refuses missing envs in prod.
auth := authCfgValues{
issuer: cfg.ExpectedIssuer,
audience: cfg.ExpectedAudience,
jwksURL: cfg.JWTKeySource,
}
if auth.jwksURL == "" {
auth.jwksURL = cfg.JWKSURL
}
if err := validateAuthConfigAtBoot(cfg.Env, auth.issuer, auth.audience, auth.jwksURL); err != nil {
return nil, fmt.Errorf("kms.Embed: auth config: %w", err)
}
applyAuthConfig(auth)
log.Info("kms.Embed: auth configured",
"iss", auth.issuer,
"aud", auth.audience,
"jwks", auth.jwksURL,
"env", cfg.Env)
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("kms.Embed: create data dir %s: %w", cfg.DataDir, err)
}
dbOpts := badger.DefaultOptions(cfg.DataDir).
WithLogger(zapdbLogger{}).
WithEncryptionKey(masterKeyFromEnv()).
WithIndexCacheSize(64 << 20)
db, err := badger.Open(dbOpts)
if err != nil {
return nil, fmt.Errorf("kms.Embed: open zapdb at %s: %w", cfg.DataDir, err)
}
log.Info("kms.Embed: zapdb opened", "dir", cfg.DataDir, "version", Version)
em := &Embedded{cfg: cfg, db: db}
em.replicator = startReplicator(db, cfg.NodeID)
auditCtx, auditCancel := context.WithCancel(context.Background())
em.auditCancel = auditCancel
globalAuditor = newAuditor(auditCtx, cfg.AuditDB)
secStore := store.NewSecretStore(db)
mux := http.NewServeMux()
registerHealth(mux)
registerAuth(mux, cfg.IAMEndpoint)
registerSecretRoutes(mux, secStore, db)
if cfg.MPCVaultID != "" {
registerKeyRoutes(mux, db, cfg)
} else {
log.Info("kms.Embed: MPC_VAULT_ID empty — secrets-only mode (no threshold signing)")
}
// Frontend SPA. The Dockerfile builds frontend/ → /app/frontend and
// sets KMS_FRONTEND_DIR. When the env var resolves to a directory
// containing index.html, register a catch-all handler at "/" that
// serves static assets directly and falls back to index.html for
// SPA client-side routes (/login, /dashboard, etc.). Anything that
// hits an API path (/healthz, /v1/kms/**) takes the more-specific
// route registered above; the SPA only fires on the catch-all.
registerFrontend(mux)
em.mux = mux
em.handler = methodAllowlist(stripIdentityHeaders(mux))
if zapNode := startZAPSecretServer(secStore, cfg); zapNode != nil {
em.zapNode = zapNode
}
if !cfg.SkipListen {
em.httpServer = &http.Server{
Addr: cfg.HTTPAddr,
Handler: em.handler,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Info("kms.Embed: HTTP listening", "addr", cfg.HTTPAddr)
if err := em.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Crit("kms.Embed: http", "err", err)
}
}()
}
go func() {
<-ctx.Done()
_ = em.Stop(context.Background())
}()
return em, nil
}
// HTTPHandler returns the wrapped HTTP handler (mux + header hygiene +
// method allowlist). Mount this behind a parent listener (e.g. a fused
// hanzo binary or hanzoai/gateway).
func (e *Embedded) HTTPHandler() http.Handler {
if e == nil {
return http.NotFoundHandler()
}
return e.handler
}
// ZAPPort returns the ZAP secrets-server port (0 when disabled).
func (e *Embedded) ZAPPort() int {
if e == nil || e.zapNode == nil {
return 0
}
return e.cfg.ZAPPort
}
// Stop gracefully shuts down the KMS server. Idempotent.
func (e *Embedded) Stop(ctx context.Context) error {
if e == nil {
return nil
}
if e.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_ = e.httpServer.Shutdown(shutdownCtx)
e.httpServer = nil
}
if e.zapNode != nil {
e.zapNode.Stop()
e.zapNode = nil
}
if e.replicator != nil {
e.replicator.Stop()
e.replicator = nil
}
if e.auditCancel != nil {
e.auditCancel()
e.auditCancel = nil
}
if e.db != nil {
_ = e.db.Close()
e.db = nil
}
log.Info("kms.Embed: stopped")
return nil
}
// applyEmbedDefaults resolves process-shape EmbedConfig fields
// (DataDir, HTTPAddr, NodeID, ZAPPort, AuditDB, MPC*) to the canonical
// kmsd container defaults. Env vars override empty fields.
//
// Identity-coupled fields — IAMEndpoint, JWKSURL, ExpectedIssuer,
// ExpectedAudience — are read from env only and never defaulted.
// validateProdConfigAtBoot rejects boot if any are empty, in every
// environment. The historical IAMEndpoint=https://hanzo.id default
// silently routed every white-label tenant service-account login to a
// foreign IAM and rejected every token, masking real outages — no env
// (dev, devnet, prod) gets a silent fallback anymore.
func applyEmbedDefaults(cfg EmbedConfig) EmbedConfig {
if cfg.DataDir == "" {
cfg.DataDir = envOr("KMS_DATA_DIR", "/data/hanzo-kms")
}
if cfg.HTTPAddr == "" {
cfg.HTTPAddr = envOr("KMS_LISTEN", ":8443")
}
if cfg.NodeID == "" {
cfg.NodeID = envOr("KMS_NODE_ID", "hanzo-kms-0")
}
if cfg.Env == "" {
cfg.Env = envOr("KMS_ENV", "dev")
}
// Identity-coupled config is read from env only — never defaulted.
// One canonical name per concept across every Hanzo service:
// IAM_URL — the IAM origin
// IAM_ISSUER — iss claim in JWTs (usually = IAM_URL)
// IAM_AUDIENCE — aud claim this service accepts
// IAM_KEYS_URL — JWKS URL (usually = IAM_URL/.well-known/jwks)
// validateProdConfigAtBoot rejects boot if any are empty.
if cfg.IAMEndpoint == "" {
cfg.IAMEndpoint = strings.TrimSpace(os.Getenv("IAM_URL"))
}
if cfg.ExpectedIssuer == "" {
cfg.ExpectedIssuer = strings.TrimSpace(os.Getenv("IAM_ISSUER"))
}
if cfg.ExpectedAudience == "" {
cfg.ExpectedAudience = strings.TrimSpace(os.Getenv("IAM_AUDIENCE"))
}
if cfg.JWKSURL == "" && cfg.JWTKeySource == "" {
cfg.JWKSURL = strings.TrimSpace(os.Getenv("IAM_KEYS_URL"))
}
if cfg.MPCAddr == "" {
cfg.MPCAddr = envOr("MPC_ADDR", "")
}
if cfg.MPCVaultID == "" {
cfg.MPCVaultID = envOr("MPC_VAULT_ID", "")
}
if cfg.AuditDB == "" {
cfg.AuditDB = envOr("KMS_AUDIT_DB", "/tmp/kms-aux.db")
}
if cfg.ZAPPort == 0 {
// Honour env override; fall back to canonical 9999.
if v := envOr("KMS_ZAP_PORT", strings.TrimPrefix(envOr("KMS_ZAP", ":9999"), ":")); v != "" {
fmt.Sscanf(v, "%d", &cfg.ZAPPort)
}
if cfg.ZAPPort == 0 {
cfg.ZAPPort = 9999
}
}
return cfg
}
// validateProdConfigAtBoot rejects boot if identity-coupled config is
// missing. The hard-coded https://hanzo.id default silently routed
// every white-label tenant service-account login to the wrong IAM and
// rejected every token, masking real outages. There is no dev escape
// hatch — every Hanzo user (laptop, devnet, prod) gets the same
// contract: supply IAM_URL, IAM_ISSUER, IAM_AUDIENCE, IAM_KEYS_URL or
// refuse to boot.
func validateProdConfigAtBoot(cfg EmbedConfig) error {
if strings.TrimSpace(cfg.IAMEndpoint) == "" {
return fmt.Errorf("IAM_URL is required (no default) — point at the IAM you want this KMS to trust")
}
if strings.TrimSpace(cfg.ExpectedIssuer) == "" {
return fmt.Errorf("IAM_ISSUER is required (no default) — set to the issuer URL the IAM stamps in its JWTs")
}
if strings.TrimSpace(cfg.ExpectedAudience) == "" {
return fmt.Errorf("IAM_AUDIENCE is required (no default) — set to this KMS's audience claim")
}
jwks := strings.TrimSpace(cfg.JWKSURL)
if jwks == "" {
jwks = strings.TrimSpace(cfg.JWTKeySource)
}
if jwks == "" {
return fmt.Errorf("IAM_KEYS_URL is required (no default) — set to where IAM publishes its signing keys")
}
return nil
}
// --- Header hygiene ---
// stripIdentityHeaders removes every inbound identity header before mux
// dispatch. The only headers honoured downstream are the canonical three —
// X-User-Id, X-Org-Id, X-Roles — injected by the Hanzo Gateway after JWKS
// verification. Every legacy variant (X-Hanzo-*, X-IAM-*, X-User-Role
// singular, X-Tenant-Id, X-Is-Admin, …) is dropped outright so a spoofed
// header cannot survive to a handler even if the cluster boundary is
// bypassed.
func stripIdentityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, h := range []string{
// Canonical 3 — dropped, re-injected by gateway from verified JWT.
"X-User-Id", "X-Org-Id", "X-Roles",
// Informational.
"X-User-Email",
// Legacy gateway pre-validation hints — killed.
"X-Gateway-Validated", "X-Gateway-User-Id", "X-Gateway-Org-Id", "X-Gateway-User-Email",
// Legacy hanzo-prefixed variants — killed.
"X-Hanzo-User-Id", "X-Hanzo-User-Email",
"X-Hanzo-User-Role", "X-Hanzo-User-Roles", "X-Hanzo-User-IsAdmin",
"X-Hanzo-Org", "X-Hanzo-Org-Id",
// Legacy IAM-prefixed variants — killed.
"X-IAM-User-Id", "X-IAM-Org", "X-IAM-Org-Id", "X-IAM-Roles",
// Legacy singular / alias role headers — killed.
"X-User-Role", "X-User-Roles",
// Tenant aliases — killed.
"X-Tenant-Id", "X-Tenant-ID", "X-Org",
// Is-admin boolean — killed.
"X-Is-Admin",
} {
r.Header.Del(h)
}
next.ServeHTTP(w, r)
})
}
// methodAllowlist rejects TRACE/CONNECT/OPTIONS at the edge.
// Everything else is dispatched normally and handled per-route.
func methodAllowlist(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodTrace, http.MethodConnect, http.MethodOptions:
w.Header().Set("Allow", "GET, POST, PATCH, DELETE")
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"message": "method not allowed"})
return
}
next.ServeHTTP(w, r)
})
}
// --- Routes ---
func registerHealth(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "kms",
"version": Version,
})
})
// Canonical lux health path also gets a binding for parity with the
// luxfi/kms surface; the standalone kmsd binary served only /healthz
// historically, but the Hanzo gateway probes /v1/kms/health.
mux.HandleFunc("GET /v1/kms/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "kms",
"version": Version,
})
})
}
func registerAuth(mux *http.ServeMux, iamEndpoint string) {
// Bounded HTTP client — prevents slow IAM responses from holding goroutines.
iamClient := &http.Client{Timeout: 10 * time.Second}
mux.HandleFunc("POST /v1/kms/auth/login", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ClientID == "" || req.ClientSecret == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"statusCode": 400, "message": "clientId and clientSecret required"})
return
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {req.ClientID},
"client_secret": {req.ClientSecret},
}
resp, err := iamClient.PostForm(iamEndpoint+"/v1/iam/oauth/token", form)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"statusCode": 502, "message": "identity provider unreachable"})
return
}
defer resp.Body.Close()
var tok map[string]any
json.NewDecoder(resp.Body).Decode(&tok)
at, _ := tok["access_token"].(string)
if at == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"statusCode": 401, "message": "invalid credentials"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"accessToken": at, "expiresIn": 86400, "tokenType": "Bearer"})
})
}
// envRequired enforces that a value-writing secret mutation names its
// environment explicitly. env is a first-class component of the storage
// key (kms/secrets/{path}/{env}/{name}), so it can never be aliased: a
// silent "default" commits the write to one bucket while a project/env/path
// reader (the kms-operator, cluster syncs) resolves a different record. That
// split is exactly what let an IAM z-password land in env=default while prod
// kept serving the stale value. Writes fail loud; reads keep a
// backward-compatible default (a read cannot plant a value another reader
// later trusts, and legacy readers that omit env must keep working). Returns
// false — after writing the 400 — when env is empty; the caller records the
// audit row and returns.
func envRequired(w http.ResponseWriter, env string) bool {
if strings.TrimSpace(env) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{
"message": `env is required — set "env" in the request body; there is no default. A silent default would split this write from the project/env/path record readers resolve.`,
})
return false
}
return true
}
// registerSecretRoutes mounts the canonical lux/kms HTTP secret CRUD at
// /v1/kms/orgs/{org}/secrets/... — one path, one way.
//
// R-3 (replay protection): POST (create/upsert) always bumps the version.
// PATCH (update) requires If-Match or body.version matching current; a
// replayed PATCH after rotation returns 409.
//
// R-12 (audit trail): every request emits one audit row with composite
// actor_id "iss:sub". See audit.go for details.
//
// R-ENV (one-way env): value-writing mutations (POST, PATCH) require an
// explicit env via envRequired — no silent "default". GET/DELETE/LIST keep a
// compat default (see envRequired) for legacy readers.
func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *badger.DB) {
get := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
rest := r.PathValue("rest")
path, name, ok := splitSecretPath(w, rest)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
return
}
env := r.URL.Query().Get("env")
if env == "" {
// Read keeps a compat default (see envRequired): a read cannot
// plant a value another reader trusts, and legacy readers that
// omit env must keep working. Only writes fail loud.
env = "default"
}
sec, err := secStore.Get(path, name, env)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
recordAudit(claims, r, path, name, env, http.StatusNotFound, 0)
return
}
curVer, _ := readVersion(db, path, name, env)
writeJSON(w, http.StatusOK, map[string]any{
"secret": map[string]any{"value": string(sec.Ciphertext)},
"version": curVer,
})
recordAudit(claims, r, path, name, env, http.StatusOK, 0)
}
put := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct {
Path string `json:"path"`
Name string `json:"name"`
Env string `json:"env"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "name and value required"})
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusBadRequest, 0)
return
}
if !safePath(req.Path) || !safePath(req.Name) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid path or name"})
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusBadRequest, 0)
return
}
if !envRequired(w, req.Env) {
recordAudit(claims, r, req.Path, req.Name, "", http.StatusBadRequest, 0)
return
}
sec := &store.Secret{
Name: req.Name,
Path: req.Path,
Env: req.Env,
Ciphertext: []byte(req.Value),
}
if err := secStore.Put(sec); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"message": err.Error()})
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusInternalServerError, 0)
return
}
// POST is upsert — do NOT enforce CAS. Bump version by passing -1.
newVer, verErr := bumpVersion(db, req.Path, req.Name, req.Env, -1)
if verErr != nil {
log.Warn("kms: version bump failed after put", "err", verErr)
}
if mErr := writeMtime(db, req.Path, req.Name, req.Env); mErr != nil {
log.Warn("kms: mtime write failed after put", "err", mErr)
}
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "version": newVer})
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusCreated, newVer)
}
patch := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
rest := r.PathValue("rest")
path, name, ok := splitSecretPath(w, rest)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct {
Value string `json:"value"`
Version *int64 `json:"version"` // pointer: distinguish 0 from "missing"
Env string `json:"env"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "value required"})
recordAudit(claims, r, path, name, req.Env, http.StatusBadRequest, 0)
return
}
env := req.Env
if env == "" {
env = r.URL.Query().Get("env")
}
if !envRequired(w, env) {
recordAudit(claims, r, path, name, "", http.StatusBadRequest, 0)
return
}
// Version CAS: require EITHER If-Match header OR body.version. If
// both are present they must agree. Missing both → 428 Precondition
// Required: PATCH is explicitly CAS; an unauthenticated rotation
// is exactly the replay vector.
var expected int64 = -1
if h := strings.TrimSpace(r.Header.Get("If-Match")); h != "" {
var v int64
if _, err := fmt.Sscanf(strings.Trim(h, `"`), "%d", &v); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "If-Match must be integer version"})
recordAudit(claims, r, path, name, env, http.StatusBadRequest, 0)
return
}
expected = v
}
if req.Version != nil {
if expected >= 0 && expected != *req.Version {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "If-Match and body.version disagree"})
recordAudit(claims, r, path, name, env, http.StatusBadRequest, 0)
return
}
expected = *req.Version
}
if expected < 0 {
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
"message": "PATCH requires If-Match header or body.version",
})
recordAudit(claims, r, path, name, env, http.StatusPreconditionRequired, 0)
return
}
// Ensure the secret exists before attempting CAS — otherwise an
// attacker with a stale "version 1" envelope could CREATE a secret
// via PATCH. PATCH is update-only by contract.
if _, err := secStore.Get(path, name, env); err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
recordAudit(claims, r, path, name, env, http.StatusNotFound, 0)
return
}
newVer, verErr := bumpVersion(db, path, name, env, expected)
if errors.Is(verErr, ErrVersionMismatch) {
cur, _ := readVersion(db, path, name, env)
writeJSON(w, http.StatusConflict, map[string]any{
"message": "version mismatch — replayed or stale update",
"currentVersion": cur,
"expectedVersion": expected,
})
recordAudit(claims, r, path, name, env, http.StatusConflict, cur)
return
}
if verErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"message": verErr.Error()})
recordAudit(claims, r, path, name, env, http.StatusInternalServerError, 0)
return
}
sec := &store.Secret{
Name: name,
Path: path,
Env: env,
Ciphertext: []byte(req.Value),
}
if err := secStore.Put(sec); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"message": err.Error()})
recordAudit(claims, r, path, name, env, http.StatusInternalServerError, 0)
return
}
if mErr := writeMtime(db, path, name, env); mErr != nil {
log.Warn("kms: mtime write failed after patch", "err", mErr)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": newVer})
recordAudit(claims, r, path, name, env, http.StatusOK, newVer)
}
del := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
rest := r.PathValue("rest")
path, name, ok := splitSecretPath(w, rest)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
return
}
env := r.URL.Query().Get("env")
if env == "" {
// Compat default (see envRequired): DELETE without env removes the
// default-env record only — it 404s if that env holds nothing, so
// it cannot silently touch another env's value.
env = "default"
}
if err := secStore.Delete(path, name, env); err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
recordAudit(claims, r, path, name, env, http.StatusNotFound, 0)
return
}
// Clear version + mtime records so a re-create starts clean.
_ = deleteVersion(db, path, name, env)
_ = deleteMtime(db, path, name, env)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
recordAudit(claims, r, path, name, env, http.StatusOK, 0)
}
// list (R-LIST): metadata-only browse of an org's secret KEYS. By
// construction it cannot return a value — see listSecretMetadata
// (keys-only scan, no value field on the row type). Authorization is the
// IDENTICAL authorize()+canActOnOrg() gate the other secret ops use: a
// caller who cannot read the org's secrets cannot list them.
list := func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
orgURL := r.PathValue("org")
if !claims.canActOnOrg(orgURL) {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "org claim does not match URL"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
if !safePath(orgURL) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid org"})
recordAudit(claims, r, "", "", "", http.StatusBadRequest, 0)
return
}
// Structural org confinement: the scan is rooted at the org's canonical
// brand/<org> namespace and cannot escape it. This is STRICTER than
// get-one (it can never enumerate another tenant's secret names) while
// using the SAME authorize()+canActOnOrg() gate — never weaker.
// orgRoot carries no trailing slash; listSecretMetadata appends the
// boundary slash so brand/<org> never matches brand/<org>foo.
orgRoot := "brand/" + orgURL
listPath := orgRoot
if qp := r.URL.Query().Get("prefix"); qp != "" {
qp = strings.TrimSuffix(qp, "/") // accept the directory-style brand/<org>/ form
if qp == "" || !safePath(qp) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid prefix"})
recordAudit(claims, r, qp, "", "", http.StatusBadRequest, 0)
return
}
// Containment: the prefix must be the org root or strictly within it.
if qp != orgRoot && !strings.HasPrefix(qp, orgRoot+"/") {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "prefix must be within brand/" + orgURL + "/"})
recordAudit(claims, r, qp, "", "", http.StatusForbidden, 0)
return
}
listPath = qp
}
envFilter := r.URL.Query().Get("env")
if envFilter != "" && (!safePath(envFilter) || strings.Contains(envFilter, "/")) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid env"})
recordAudit(claims, r, listPath, "", envFilter, http.StatusBadRequest, 0)
return
}
rows, truncated := listSecretMetadata(db, listPath, envFilter)
resp := map[string]any{"secrets": rows, "count": len(rows)}
if truncated {
resp["truncated"] = true
}
writeJSON(w, http.StatusOK, resp)
recordAudit(claims, r, listPath, "", envFilter, http.StatusOK, 0)
}
// Canonical (lux native).
//
// Routing precedence (Go 1.22+ net/http ServeMux): the bare pattern
// ".../secrets" and the wildcard ".../secrets/{rest...}" do NOT conflict —
// no request path matches both (the wildcard requires a literal "/secrets/"
// prefix; the bare matches exactly "/secrets"). Registering the explicit
// bare GET also suppresses any subtree redirect to ".../secrets/". Net
// effect: GET on the bare path hits list; GET on ".../secrets/<path>/<name>"
// still hits get-one and returns its value. Confirmed by
// TestList_RoutingPrecedence_NoShadowGetOne.
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets", list)
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", get)
mux.HandleFunc("POST /v1/kms/orgs/{org}/secrets", put)
mux.HandleFunc("PATCH /v1/kms/orgs/{org}/secrets/{rest...}", patch)
mux.HandleFunc("DELETE /v1/kms/orgs/{org}/secrets/{rest...}", del)
// Env-backed legacy fetch — admin-only. Reads any process env var, so it
// MUST NOT be available to a tenant-scoped JWT. Only callers carrying a
// role of "superadmin" or "kms-admin" may use it; everyone else gets 403.
mux.HandleFunc("GET /v1/kms/secrets/{name}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
recordAudit(claims, r, "", "", "", http.StatusUnauthorized, 0)
return
}
if !claims.isAdmin() {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "admin role required"})
recordAudit(claims, r, "", "", "", http.StatusForbidden, 0)
return
}
name := r.PathValue("name")
if !safeEnvName(name) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid name"})
recordAudit(claims, r, "", name, "", http.StatusBadRequest, 0)
return
}
val := os.Getenv(name)
if val == "" {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
recordAudit(claims, r, "", name, "", http.StatusNotFound, 0)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"secret": map[string]any{"secretKey": name, "secretValue": val},
})
recordAudit(claims, r, "", name, "", http.StatusOK, 0)
})
// Audit stats endpoint — admin only.
mux.HandleFunc("GET /v1/kms/audit/stats", func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
return
}
if !claims.isAdmin() {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "admin role required"})
return
}
written, dropped := globalAuditor.stats()
writeJSON(w, http.StatusOK, map[string]any{"written": written, "dropped": dropped})
})
}
// recordAudit is a small helper that emits one audit row per handler
// call. Pulls method/path from the request, derives actor_id from the
// JWT claims via composeActorID, and queues the entry to the background
// writer. Safe to call with empty claims (unauthenticated requests).
func recordAudit(claims jwtClaims, r *http.Request, secretPath, secretName, env string, status int, newVersion int64) {
if globalAuditor == nil {
return
}
globalAuditor.record(auditEntry{
TS: time.Now().UTC(),
ActorID: composeActorID(claims.Iss, claims.Sub),
Issuer: claims.Iss,
Subject: claims.Sub,
ActorRole: firstRole(claims.Roles),
Owner: claims.Owner,
Method: r.Method,
Path: r.URL.Path,
SecretPath: secretPath,
SecretName: secretName,
Env: env,
Result: status,
Version: newVersion,
})
}
func registerKeyRoutes(mux *http.ServeMux, db *badger.DB, cfg EmbedConfig) {
// MPC connectivity is best-effort: if the MPC daemon is unreachable at
// boot (common on devnet when MPC restarts or ZAP service is not yet
// exposed), log a warning and run in secrets-only mode rather than
// crash-looping the pod. Readiness probe must keep passing so secrets
// routes (the majority of traffic) stay online. Threshold-signing key
// routes will return 503 via zapClient.Status() checks downstream.
zapClient, err := mpc.NewZapClient(cfg.NodeID, cfg.MPCAddr)
if err != nil {
log.Warn("kms: mpc zap client init failed — secrets-only mode, key routes disabled", "err", err)
return
}
keyStore, err := store.New(db)
if err != nil {
log.Crit("kms: key store", "err", err)
}
checkCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if status, err := zapClient.Status(checkCtx); err != nil {
log.Warn("kms: mpc unreachable", "err", err)
} else {
log.Info("kms: mpc status",
"ready", status.Ready,
"peers", status.ConnectedPeers,
"expected", status.ExpectedPeers,
"mode", status.Mode)
}
cancel()
mgr := keys.NewManager(zapClient, keyStore, cfg.MPCVaultID)
// Admin gate for every key route: authorize() (full JWT verify) →
// isAdmin() (explicit role claim). No route is reachable without
// both checks passing. Red F5: registerKeyRoutes had NO auth at all
// prior to this patch.
adminOnly := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
return
}
if !claims.isAdmin() {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "admin role required"})
return
}
next(w, r)
}
}
mux.HandleFunc("POST /v1/kms/keys/generate", adminOnly(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req keys.GenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.ValidatorID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "validator_id is required"})
return
}
if req.Threshold < 2 || req.Parties < req.Threshold {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid threshold/parties"})
return
}
ks, err := mgr.GenerateValidatorKeys(r.Context(), req)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, ks)
}))
mux.HandleFunc("GET /v1/kms/keys", adminOnly(func(w http.ResponseWriter, r *http.Request) {
list := mgr.List()
if list == nil {
list = []*keys.ValidatorKeySet{}
}
writeJSON(w, http.StatusOK, list)
}))
mux.HandleFunc("GET /v1/kms/keys/{id}", adminOnly(func(w http.ResponseWriter, r *http.Request) {
ks, err := mgr.Get(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, ks)
}))
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", adminOnly(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
id := r.PathValue("id")
var req keys.SignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Message) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
var resp *keys.SignResponse
var err error
switch req.KeyType {
case "bls":
resp, err = mgr.SignWithBLS(r.Context(), id, req.Message)
case "corona":
resp, err = mgr.SignWithCorona(r.Context(), id, req.Message)
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key_type must be bls or corona"})
return
}
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}))
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", adminOnly(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
id := r.PathValue("id")
var req keys.RotateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
ks, err := mgr.Rotate(r.Context(), id, req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, ks)
}))
mux.HandleFunc("GET /v1/kms/status", adminOnly(func(w http.ResponseWriter, r *http.Request) {
st, err := zapClient.Status(r.Context())
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"kms": "ok", "mpc": "unreachable", "details": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"kms": "ok", "mpc": st})
}))
}
// --- Authorization ---
// jwtClaims is the minimal set of verified JWT claims we extract for
// authorization. Verification (signature, alg, iss, aud, exp) happens
// in verifyJWT (auth.go).
type jwtClaims struct {
Iss string `json:"iss"`
Owner string `json:"owner"`
Sub string `json:"sub"`
Roles []string `json:"roles"`
}
// canActOnOrg returns true if the bearer can act on the URL's org segment.
// Exactly two paths grant access:
//
// 1. The bearer's verified `owner` claim equals the URL org segment.
// 2. The bearer's verified `roles` claim contains "superadmin",
// "kms-admin", or "admin".
//
// There is no owner=="admin" shortcut. IAM emits owner="admin" for
// every service account in its superuser-app namespace — treating that
// string as a cross-tenant grant makes every IAM service account a root
// key over every org. Red demonstrated this live on 2026-04-21.
func (c jwtClaims) canActOnOrg(org string) bool {
if c.isAdmin() {
return true
}
return c.Owner != "" && org != "" && c.Owner == org
}
// isAdmin checks for an explicit superadmin role. The owner claim is a
// scoping field, not a privilege flag — keep the two concepts separate.
func (c jwtClaims) isAdmin() bool {
for _, r := range c.Roles {
switch strings.ToLower(strings.TrimSpace(r)) {
case "superadmin", "kms-admin", "admin":
return true
}
}
return false
}
// authorize extracts the bearer token and performs full RFC 7519
// verification: signature (via JWKS), alg allowlist (asymmetric only —
// no HS*, no none), iss, aud, exp. On any failure emits 401 with a
// generic body and logs a structured audit line with the failure class.
//
// The function NEVER falls back to unsigned parsing. It NEVER accepts
// alg=none. It NEVER honours an owner=="admin" shortcut. Upstream gateway
// verification is belt-and-braces — we verify independently in case the
// gateway is bypassed.
func authorize(w http.ResponseWriter, r *http.Request) (jwtClaims, bool) {
claims, err := verifyJWT(r.Header.Get("Authorization"))
if err != nil {
authLog.Info("kms_auth_reject",
"reason", authFailReason(err),
"peer", peerIP(r),
"method", r.Method,
"path", r.URL.Path,
)
writeJSON(w, http.StatusUnauthorized, map[string]any{"message": "unauthorized"})
return jwtClaims{}, false
}
return claims, true
}
// splitSecretPath separates "rest" into (path, name) and rejects any
// traversal/control-byte attempts. Returns ok=false after writing 400.
func splitSecretPath(w http.ResponseWriter, rest string) (string, string, bool) {
if !safePath(rest) {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "invalid path"})
return "", "", false
}
idx := strings.LastIndex(rest, "/")
if idx < 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "path and name required"})
return "", "", false
}
return rest[:idx], rest[idx+1:], true
}
// safePath rejects path-traversal, double-slash collapsing, and control bytes.
// Allowed: ASCII alnum, '_', '-', '.', '/'. Disallowed segments: "" and "..".
func safePath(p string) bool {
if p == "" {
return true // optional fields
}
if strings.Contains(p, "//") || strings.Contains(p, "\x00") {
return false
}
for _, seg := range strings.Split(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
for _, ch := range seg {
switch {
case ch >= 'a' && ch <= 'z':
case ch >= 'A' && ch <= 'Z':
case ch >= '0' && ch <= '9':
case ch == '_' || ch == '-' || ch == '.':
default:
return false
}
}
}
return true
}
// safeEnvName matches POSIX env-var name rules: [A-Za-z_][A-Za-z0-9_]*.
func safeEnvName(n string) bool {
if n == "" {
return false
}
for i, ch := range n {
switch {
case ch >= 'A' && ch <= 'Z':
case ch >= 'a' && ch <= 'z':
case ch == '_':
case i > 0 && ch >= '0' && ch <= '9':
default:
return false
}
}
return true
}
// --- ZAP secrets server ---
// startZAPSecretServer brings up the binary ZAP transport for in-cluster
// secret CRUD. Disabled when:
// - KMS_MASTER_KEY_B64 unset or invalid (no transport encryption key)
// - KMS_ZAP_PORT/KMS_ZAP resolves to <=0
// - no consensus authority snapshot configured (fail-soft: HTTP path
// still up; ZAP comes online when kms-operator drops the snapshot)
//
// A configured-but-malformed snapshot is fatal (fail-closed on bad input).
// Returns the running node so Stop can shut it down.
func startZAPSecretServer(secStore *store.SecretStore, cfg EmbedConfig) *zap.Node {
masterKeyB64 := envOr("KMS_MASTER_KEY_B64", "")
if masterKeyB64 == "" || cfg.ZAPPort <= 0 {
log.Info("kms.Embed: ZAP secrets-server disabled (set KMS_MASTER_KEY_B64 + KMS_ZAP_PORT/KMS_ZAP)")
return nil
}
masterKey, err := base64.StdEncoding.DecodeString(masterKeyB64)
if err != nil || len(masterKey) != 32 {
log.Info("kms.Embed: KMS_MASTER_KEY_B64 invalid (need 32 raw bytes base64); ZAP server disabled")
return nil
}
authorizer, err := buildConsensusAuthorizer()
if err != nil {
log.Crit("kms.Embed: consensus authorizer config invalid (refusing to fail open)", "err", err)
return nil
}
if authorizer == nil {
log.Info("kms.Embed: no consensus authority snapshot configured; ZAP secrets-server disabled (HTTP path still up)",
"file_env", envConsensusFile, "validators_env", envConsensusValidators, "operators_env", envConsensusOperators)
return nil
}
n := zap.NewNode(zap.NodeConfig{
NodeID: cfg.NodeID + "-secrets",
ServiceType: "_kms._tcp",
Port: cfg.ZAPPort,
})
if err := n.Start(); err != nil {
log.Error("kms.Embed: ZAP secrets-server start failed", "port", cfg.ZAPPort, "err", err)
return nil
}
zs := zapserver.New(zapserver.Config{
Store: secStore,
MasterKey: masterKey,
Logger: log.Root(),
Authorizer: authorizer,
})
zs.Register(n)
log.Info("kms.Embed: ZAP secrets-server listening", "port", cfg.ZAPPort, "service", "_kms._tcp")
return n
}
// --- Replicator ---
func startReplicator(db *badger.DB, nodeID string) *badger.Replicator {
rawEndpoint := os.Getenv("REPLICATE_S3_ENDPOINT")
if rawEndpoint == "" {
log.Info("kms.Embed: S3 replication disabled (set REPLICATE_S3_ENDPOINT to enable)")
return nil
}
endpoint, useSSL := normalizeS3Endpoint(rawEndpoint)
access := firstNonEmpty(
os.Getenv("REPLICATE_S3_ACCESS_KEY_ID"),
os.Getenv("AWS_ACCESS_KEY_ID"),
os.Getenv("REPLICATE_S3_ACCESS_KEY"),
)
secret := firstNonEmpty(
os.Getenv("REPLICATE_S3_SECRET_ACCESS_KEY"),
os.Getenv("AWS_SECRET_ACCESS_KEY"),
os.Getenv("REPLICATE_S3_SECRET_KEY"),
)
cfg := badger.ReplicatorConfig{
Endpoint: endpoint,
Bucket: envOr("REPLICATE_S3_BUCKET", "hanzo-kms-backups"),
Region: envOr("REPLICATE_S3_REGION", "us-central1"),
AccessKey: access,
SecretKey: secret,
UseSSL: useSSL,
Path: envOr("REPLICATE_S3_PATH", envOr("REPLICATE_PATH", fmt.Sprintf("kms/%s", nodeID))),
Interval: time.Second,
}
if os.Getenv("REPLICATE_AGE_RECIPIENT") != "" {
log.Info("kms.Embed: S3 replication with age encryption enabled")
}
r, err := badger.NewReplicator(db, cfg)
if err != nil {
log.Warn("kms.Embed: S3 replicator init failed — replication disabled", "err", err)
return nil
}
go r.Start(context.Background())
log.Info("kms.Embed: S3 replication started",
"endpoint", endpoint,
"bucket", cfg.Bucket,
"path", cfg.Path)
return r
}
// --- Helpers ---
func masterKeyFromEnv() []byte {
b64 := os.Getenv("KMS_ENCRYPTION_KEY_B64")
if b64 == "" {
return nil
}
key, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(key) != 32 {
log.Info("kms.Embed: KMS_ENCRYPTION_KEY_B64 invalid (need 32 bytes base64); at-rest encryption disabled")
return nil
}
return key
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// firstNonEmpty returns the first argument with non-zero length, or "".
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// normalizeS3Endpoint strips scheme + path from REPLICATE_S3_ENDPOINT so
// minio.New receives the bare host:port it expects. Returns (host[:port],
// useSSL). Logs a warning if the operator passed a path-bearing URL —
// the historical failure mode was silent disablement of replication.
func normalizeS3Endpoint(raw string) (host string, useSSL bool) {
useSSL = !strings.HasPrefix(raw, "http://")
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
log.Warn("kms.Embed: REPLICATE_S3_ENDPOINT failed to parse — using as-is, replication may fail", "endpoint", raw)
return raw, useSSL
}
if u.Path != "" && u.Path != "/" {
log.Warn("kms.Embed: REPLICATE_S3_ENDPOINT has a path component — stripping; put the bucket in REPLICATE_S3_BUCKET", "endpoint", raw, "path", u.Path)
}
if u.RawQuery != "" {
log.Warn("kms.Embed: REPLICATE_S3_ENDPOINT has a query string — stripping", "endpoint", raw)
}
return u.Host, useSSL
}
return strings.TrimRight(raw, "/"), useSSL
}
// --- ZapDB logger adapter ---
//
// ZapDB expects an Errorf/Warningf/Infof/Debugf surface (badger's
// Logger interface). luxfi/log is the Hanzo-wide logging library;
// we adapt its variadic API to ZapDB's format-string API.
type zapdbLogger struct{}
func (zapdbLogger) Errorf(format string, args ...interface{}) {
log.Error(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Warningf(format string, args ...interface{}) {
log.Warn(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Infof(format string, args ...interface{}) {
log.Info(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Debugf(format string, args ...interface{}) {
log.Debug(fmt.Sprintf(format, args...))
}
// registerFrontend adds a catch-all `/` handler that serves the React
// SPA from KMS_FRONTEND_DIR (set by the Dockerfile to /app/frontend).
//
// The mux is already populated with explicit API routes (/healthz,
// /v1/kms/**, /v1/mpc/**) — those take precedence because Go's
// ServeMux longest-match-wins rule fires the more-specific pattern
// before this one.
//
// Static assets resolve directly from disk. Anything else (e.g.
// /login, /dashboard) returns index.html so the React Router can
// handle client-side routing without a server round-trip.
//
// If KMS_FRONTEND_DIR is unset OR doesn't contain index.html, the
// handler returns a tiny JSON status object — preserves the
// "no UI here, but the service is alive" signal we used to get
// from the bare 404 without surprising operators.
func registerFrontend(mux *http.ServeMux) {
dir := strings.TrimSpace(os.Getenv("KMS_FRONTEND_DIR"))
indexPath := ""
if dir != "" {
candidate := dir + "/index.html"
if _, err := os.Stat(candidate); err == nil {
indexPath = candidate
}
}
if indexPath == "" {
// Fallback: tiny JSON service banner for when the SPA isn't bundled.
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"service": "kms",
"version": Version,
"status": "ok",
"docs": "/v1/kms",
})
})
return
}
fileServer := http.FileServer(http.Dir(dir))
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
// API paths starting with /v1/ or /healthz never reach here —
// ServeMux fires the more-specific handler first. Defense in
// depth: bail out if the path looks like an API route.
//
// /api/ is explicitly 404'd: this service has ZERO /api/ surface
// (the ONE canonical prefix is /v1/kms). Without this guard the SPA
// fallback below would answer GET /api/v1/... with index.html (200),
// masquerading as a live legacy /api/ backend. There is no /api/;
// make that unambiguous at the wire.
if strings.HasPrefix(r.URL.Path, "/v1/") ||
strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" ||
r.URL.Path == "/healthz" || r.URL.Path == "/health" {
http.NotFound(w, r)
return
}
// SPA fallback: serve index.html for any path that isn't a
// concrete file on disk. This is the standard React Router
// pattern (matches Vite's default dev-server behaviour).
clean := strings.TrimPrefix(r.URL.Path, "/")
if clean == "" {
http.ServeFile(w, r, indexPath)
return
}
if _, err := os.Stat(dir + "/" + clean); err != nil {
http.ServeFile(w, r, indexPath)
return
}
fileServer.ServeHTTP(w, r)
})
}