Files
kms/cmd/kms/main.go
T
Hanzo AI da574aa1db lux/kms: orphan history at f2e793bf8 (was: kms: wire luxfi/mpc as REK backend)
Squashes all pre-rewrite history into a single root commit. Previous tree:
- 14506 commits including upstream Infisical fork history
- Multiple merge chains carrying pre-purge content

Force-rewrite per disk-space reclamation discipline. GHCR images
(1.9.9, 1.9.11) remain immutable; their git lineage is no longer
materialized.
2026-06-07 14:28:09 -07:00

972 lines
37 KiB
Go

// Command kms starts the KMS server backed by ZapDB + MPC.
//
// Configuration precedence: flags > env vars > defaults.
//
// Boot is fail-open: if MPC_VAULT_ID is set but the MPC daemon is
// unreachable, KMS logs a warning and continues in secrets-only mode.
// /healthz reports `status=degraded` and any /v1/kms/keys/* request
// returns 503 with body `{"error":"mpc unreachable","mode":"secrets-only"}`.
// Each request re-probes MPC, so the same pod recovers transparently
// once MPC comes back up — no restart required.
//
// Env vars:
// MPC_ADDR - ZAP address (host:port); empty = mDNS discovery
// MPC_VAULT_ID - MPC vault ID for validator keys (required for MPC)
// KMS_NODE_ID - ZAP node ID (default "kms-0")
// ZAP_PORT - ZAP secrets-server listen port (default 9999, 0 = disable)
// MPC_REK_ENDPOINT - MPC ZAP CSV for threshold-rooted Root Encryption Key
// fetch. When set, kmsd refuses to start unless the
// MPC cluster returns a 32-byte REK. Takes precedence
// over KMS_MASTER_KEY_B64.
// MPC_REK_KEY_ID - MPC-side identifier of the wrapped REK record
// (default "kms/rek/v1"). Bump alongside reshare.
// MPC_REK_TIMEOUT - REK bootstrap timeout (Go duration, default "10s").
// KMS_MASTER_KEY_B64 - LEGACY 32-byte master key (base64). Used only when
// MPC_REK_ENDPOINT is unset. Slated for removal once
// every KMS deployment ships with MPC-rooted REK.
// KMS_DATA_DIR - ZapDB data directory (default "/data/kms")
// KMS_LISTEN - HTTP listen address (default ":8080")
// IAM_ENDPOINT - Hanzo IAM endpoint for auth (default "https://hanzo.id")
// KMS_CONSENSUS_VALIDATORS - newline-separated NodeIDs of the validator
// authority (consensus read set). Required for
// ZAP. Format: one cb58 NodeID per line.
// KMS_CONSENSUS_OPERATORS - newline-separated NodeIDs of the operator
// authority (consensus write set). Required
// for ZAP. Same format.
// KMS_CONSENSUS_FILE - optional file path supplying the same two
// sets (JSON: {"validators":[...],"operators":[...]}).
// When set, env vars above are ignored.
// KMS_CONSENSUS_TTL - per-authority refresh TTL (Go duration,
// default 30s). The kms-operator re-applies
// the snapshot at this cadence.
// KMS_NONCE_LEDGER_TTL - anti-replay nonce TTL (Go duration,
// default MaxClockSkew+1m=6m). Captured
// envelopes are rejected as replays while
// their (NodeID,Nonce) tuple is in the
// ledger; after TTL they fail clock-skew
// anyway. Tune up only if MaxClockSkew is
// tuned up.
// KMS_NONCE_LEDGER_GC - background GC sweep cadence (Go duration,
// default TTL/4 = 90s). Smaller values
// reduce peak memory; larger values reduce
// CPU burn. The ledger correctness does
// NOT depend on this — it's a memory/CPU
// trade only.
//
// S3 replication (ZapDB Replicator):
// REPLICATE_S3_ENDPOINT - S3 endpoint (empty = replication disabled)
// REPLICATE_S3_BUCKET - S3 bucket (default "lux-kms-backups")
// REPLICATE_S3_REGION - S3 region (default "us-central1")
// REPLICATE_S3_ACCESS_KEY
// REPLICATE_S3_SECRET_KEY
// REPLICATE_AGE_RECIPIENT - age public key for backup encryption
// REPLICATE_AGE_IDENTITY - age private key for restore
// REPLICATE_PATH - S3 key prefix (default "kms/{KMS_NODE_ID}")
package main
import (
"context"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"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/store/mpcrek"
"github.com/luxfi/kms/pkg/zapserver"
luxlog "github.com/luxfi/log"
"github.com/luxfi/zap"
)
// One binary, one image. The KMS admin UI ships inside this binary.
// The `web/` directory at this package root is populated by `make build`
// (it copies `frontend/dist/` from the repo root before `go build`); the
// embed picks up the static Vite output and serves it at `/` with a SPA
// fallback so React Router routes resolve. API routes (`/v1/*`,
// `/healthz`, `/health`) take precedence and are registered before the
// catch-all. If `web/` is empty (e.g. fresh clone, no UI build yet), the
// fallback returns 404 cleanly — the API still works.
//
//go:embed all:web
var frontendDist embed.FS
func main() {
mpcAddr := envOr("MPC_ADDR", "")
vaultID := envOr("MPC_VAULT_ID", "")
nodeID := envOr("KMS_NODE_ID", "kms-0")
iamEndpoint := envOr("IAM_ENDPOINT", "https://hanzo.id")
dataDir := envOr("KMS_DATA_DIR", "/data/kms")
listen := envOr("KMS_LISTEN", ":8080")
// Open ZapDB.
if err := os.MkdirAll(dataDir, 0o700); err != nil {
log.Fatalf("kms: create data dir %s: %v", dataDir, err)
}
dbOpts := badger.DefaultOptions(dataDir).
WithLogger(zapdbLogger{}).
WithEncryptionKey(masterKeyFromEnv()).
WithIndexCacheSize(64 << 20) // 64MB index cache
db, err := badger.Open(dbOpts)
if err != nil {
log.Fatalf("kms: open zapdb at %s: %v", dataDir, err)
}
defer db.Close()
log.Printf("kms: zapdb opened at %s", dataDir)
// Start ZapDB Replicator if S3 is configured.
replicator := startReplicator(db, nodeID)
if replicator != nil {
defer replicator.Stop()
}
mux := http.NewServeMux()
// MPC availability flag. Flips to true after a successful ZAP probe at
// boot. Read by /healthz to surface degraded mode and by the keys
// routes to short-circuit with 503 when MPC is unreachable. The
// pointer-to-bool is set once in main and read concurrently — atomic
// load isn't necessary because the value never changes after boot.
var mpcAvailable bool
// Health probes — wired in every shape callers might try:
// /healthz / /health — root, for direct/standalone probes
// /v1/kms/healthz / /v1/kms/health — gateway-routed, no prefix strip
// All return the same shape so probes are interchangeable.
healthOK := healthHandler(vaultID, &mpcAvailable)
mux.HandleFunc("GET /healthz", healthOK)
mux.HandleFunc("GET /health", healthOK)
mux.HandleFunc("GET /v1/kms/healthz", healthOK)
mux.HandleFunc("GET /v1/kms/health", healthOK)
// SPA bootstrap config. The Infisical-derived React frontend in
// frontend/src/hooks/api/admin/queries.ts:fetchServerConfig fetches
// `/v1/admin/config` at first paint and refuses to render when the
// payload is missing — the user sees `["server-config"] data is
// undefined`. We don't run the full Infisical admin surface, so
// this returns a minimal-but-complete shape: signups via IAM, no
// invite-only gating, no SMTP, instance is initialized. Fields the
// SPA reads: initialized, allowSignUp, allowedSignUpDomain, etc.
// defaultAuthOrgSlug drives the OIDC "Login with <Brand>" button in
// the SPA: when set, the SPA bypasses the org-picker and goes straight
// to /v1/sso/oidc/login?orgSlug=<this>. Read from env so the same image
// white-labels per-deployment via KMS_DEFAULT_ORG_SLUG.
defaultOrgSlug := envOr("KMS_DEFAULT_ORG_SLUG", "")
mux.HandleFunc("GET /v1/admin/config", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"config": map[string]any{
"initialized": true,
"allowSignUp": true,
"allowedSignUpDomain": nil,
"trustSamlEmails": false,
"trustLdapEmails": false,
"trustOidcEmails": true,
"defaultAuthOrgId": "",
"defaultAuthOrgSlug": defaultOrgSlug,
"isSecretScanningDisabled": false,
"isMigrationModeOn": false,
"enabledLoginMethods": []string{"oidc"},
"slackClientId": "",
"isSmtpConfigured": false,
"isSecretApprovalDisabled": false,
"identityRevocationEnabled": true,
},
})
})
// Machine identity auth via IAM.
mux.HandleFunc("POST /v1/kms/auth/login", func(w http.ResponseWriter, r *http.Request) {
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},
}
// Canonical OAuth2 path on hanzoai/iam — no `/api/` prefix (killed in
// v2.381.0). `/login/oauth/access_token` is mounted at root per the
// OAuth2 spec; `/oauth/access_token` is also wired as an alias.
resp, err := http.PostForm(iamEndpoint+"/login/oauth/access_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"})
})
// Secret store — ZapDB-backed, encrypted at rest.
secStore := store.NewSecretStore(db)
// JWT-backed authorization for the secrets surface. Every request to
// /v1/kms/orgs/{org}/secrets/* must carry an IAM-signed bearer token
// whose `owner` claim equals {org} (or whose roles include
// kms-admin). Public endpoints (health, /v1/kms/auth/login, OIDC
// SSO, the SPA) are NOT wrapped — they remain reachable without a
// token. See auth.go.
//
// JWKS is fetched from KMS_IAM_URL (in-cluster) and the JWT `iss`
// claim is validated against KMS_EXPECTED_ISSUER (public hostname).
// When the deployment uses a single URL for both, fall through to
// IAM_ENDPOINT.
jwksFrom := envOr("KMS_IAM_URL", iamEndpoint)
expectedIss := envOr("KMS_EXPECTED_ISSUER", iamEndpoint)
auth := newOrgJWTAuth(jwksFrom, expectedIss)
// GET /v1/kms/orgs/{org}/secrets/{path...}/{name}
// Matches the ATS kmsclient.Get() URL pattern.
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
idx := strings.LastIndex(rest, "/")
if idx < 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "path and name required"})
return
}
path, name := rest[:idx], rest[idx+1:]
env := r.URL.Query().Get("env")
if env == "" {
env = "default"
}
sec, err := secStore.Get(path, name, env)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"secret": map[string]any{"value": string(sec.Ciphertext)},
})
}))
// POST /v1/kms/orgs/{org}/secrets — create a secret.
mux.HandleFunc("POST /v1/kms/orgs/{org}/secrets", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
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"})
return
}
if req.Env == "" {
req.Env = "default"
}
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()})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
}))
// DELETE /v1/kms/orgs/{org}/secrets/{rest...}/{name}
mux.HandleFunc("DELETE /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
idx := strings.LastIndex(rest, "/")
if idx < 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "path and name required"})
return
}
path, name := rest[:idx], rest[idx+1:]
env := r.URL.Query().Get("env")
if env == "" {
env = "default"
}
if err := secStore.Delete(path, name, env); err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}))
// Legacy: env-backed secret fetch.
mux.HandleFunc("GET /v1/kms/secrets/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"message": "secret name required"})
return
}
val := os.Getenv(name)
if val == "" {
writeJSON(w, http.StatusNotFound, map[string]any{"message": "not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"secret": map[string]any{"secretKey": name, "secretValue": val},
})
})
// MPC key management (only when MPC_VAULT_ID is set).
//
// Boot is fail-open: every error path here logs a warning and degrades
// to secrets-only mode instead of exiting. The previous behaviour
// (log.Fatalf on any ZAP init or status failure) meant a transient MPC
// outage took down KMS too — the secrets surface is independent and
// must keep serving. Routes that require MPC return 503 via the
// mpcAvailable flag wired into healthOK / registerKMSRoutes.
if vaultID != "" {
// Trust at the network boundary (NetworkPolicy + ZAP wire).
zapClient, err := mpc.NewZapClient(nodeID, mpcAddr)
switch {
case err != nil:
log.Printf("kms: WARNING: mpc zap client init failed: %v — degrading to secrets-only mode", err)
// Even though no client could be built, the key routes must
// still respond with a clear 503 — without this, callers see
// 405/404 and can't distinguish "MPC down" from "wrong path"
// or "wrong method". registerStubKMSRoutes installs handlers
// that always 503 with the same body shape as registerKMSRoutes.
registerStubKMSRoutes(mux, err)
default:
keyStore, ksErr := store.New(db)
if ksErr != nil {
log.Printf("kms: WARNING: mpc key store init failed: %v — degrading to secrets-only mode", ksErr)
zapClient.Close()
registerStubKMSRoutes(mux, ksErr)
break
}
checkCtx, checkCancel := context.WithTimeout(context.Background(), 5*time.Second)
status, statusErr := zapClient.Status(checkCtx)
checkCancel()
if statusErr != nil {
log.Printf("kms: WARNING: mpc unreachable via ZAP: %v — degrading to secrets-only mode", statusErr)
// Keep the client + key routes wired even though the probe
// failed: MPC may come up later in the same pod lifetime
// (e.g. NetworkPolicy applied after KMS started). Routes
// re-probe on every call; if MPC is up by then they
// succeed transparently.
} else {
log.Printf("kms: mpc ready=%v peers=%d/%d mode=%s",
status.Ready, status.ConnectedPeers, status.ExpectedPeers, status.Mode)
mpcAvailable = true
}
mgr := keys.NewManager(zapClient, keyStore, vaultID)
registerKMSRoutes(mux, mgr, zapClient, &mpcAvailable)
}
}
if vaultID == "" {
log.Printf("kms: MPC_VAULT_ID not set — running in secrets-only mode (no threshold signing)")
}
// ZAP secrets server — exposes the SecretStore over luxfi/zap on its own
// port so in-cluster callers can fetch with zero REST round-trip.
//
// The master key (Root Encryption Key) protecting every per-secret DEK is
// resolved by loadREK below. Preferred source is a luxfi/mpc threshold
// cluster (MPC_REK_ENDPOINT); fallback is KMS_MASTER_KEY_B64 for the
// migration window. If MPC_REK_ENDPOINT is set, kmsd FAILS CLOSED on any
// fetch error — there is no env-var fallback in MPC mode, since the
// whole point of MPC-rooting the REK is that it must not be derivable
// from anything on the pod.
zapPortStr := envOr("ZAP_PORT", "9999")
zapPort, _ := strconv.Atoi(zapPortStr)
masterKey := loadREK()
defer mpcrek.Zero(masterKey)
if masterKey != nil && zapPort > 0 {
n := zap.NewNode(zap.NodeConfig{
NodeID: nodeID + "-secrets",
ServiceType: "_kms._tcp",
Port: zapPort,
})
if err := n.Start(); err != nil {
log.Printf("kms: ZAP secrets-server failed to start on :%d: %v", zapPort, err)
} else {
authorizer, err := buildConsensusAuthorizer()
if err != nil {
// Fail-closed: a misconfigured authorizer is a wire-
// reachable security hole. Refuse to boot.
log.Fatalf("kms: consensus authorizer init failed: %v", err)
}
nonceLedger, err := buildNonceLedger()
if err != nil {
// Same fail-closed posture: a misconfigured ledger
// silently re-opens the replay window. Refuse to
// boot rather than ship a downgrade.
log.Fatalf("kms: nonce ledger init failed: %v", err)
}
zs := zapserver.New(zapserver.Config{
Store: secStore,
MasterKey: masterKey,
Authorizer: authorizer,
NonceLedger: nonceLedger,
Logger: luxlog.New("component", "kms-zapserver"),
})
zs.Register(n)
log.Printf("kms: ZAP secrets-server listening on :%d (service=_kms._tcp)", zapPort)
}
} else {
log.Printf("kms: ZAP secrets-server disabled (set MPC_REK_ENDPOINT or KMS_MASTER_KEY_B64 and ZAP_PORT to enable)")
}
// IAM OIDC SSO — /v1/sso/oidc/{login,callback}, /v1/sso/whoami, /v1/sso/logout.
// Registered before the SPA catch-all; if OIDC isn't configured the
// handlers return 503 so misconfiguration is observable.
registerOIDCRoutes(mux)
// Mount the embedded admin UI under the root catch-all. Registered last
// so explicit handlers (`/healthz`, `/health`, `/v1/*`) win the route
// match. SPA fallback: any GET that doesn't match a real file falls
// back to index.html so React Router can resolve the path client-side.
registerWebUI(mux)
// Start HTTP server.
srv := &http.Server{
Addr: listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("kms: HTTP listening on %s", listen)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("kms: http: %v", err)
}
}()
// Graceful shutdown.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("kms: shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
// registerStubKMSRoutes installs 503-only handlers for every /v1/kms/keys/*
// route. Used when the boot-time MPC client could not be constructed at
// all (e.g. dial timed out, ZAP node init failed). Without this, GET
// /v1/kms/keys/{id} fell through to the SPA catch-all and returned the
// admin UI HTML; POST returned 405 from the muxer's "no method match"
// path. Both are useless to callers — they need a single, parseable
// signal that MPC is down so retry / circuit-break logic kicks in.
//
// The body shape matches registerKMSRoutes' requireMPC 503 exactly so
// callers don't need to branch on "stub vs gated" — they see one
// `{"error":"mpc unreachable","mode":"secrets-only","detail":"..."}`
// response across both code paths.
func registerStubKMSRoutes(mux *http.ServeMux, bootErr error) {
stub := func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "mpc unreachable",
"detail": bootErr.Error(),
"mode": "secrets-only",
})
}
mux.HandleFunc("POST /v1/kms/keys/generate", stub)
mux.HandleFunc("GET /v1/kms/keys", stub)
mux.HandleFunc("GET /v1/kms/keys/{id}", stub)
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", stub)
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", stub)
mux.HandleFunc("GET /v1/kms/status", stub)
}
// healthHandler returns the /healthz handler.
//
// Health is intentionally HTTP 200 even when MPC is unreachable: the
// secrets surface (ZAP secrets-server, /v1/kms/orgs/.../secrets, IAM
// SSO, /v1/kms/auth/login) is fully functional in secrets-only mode.
// Routes that require MPC (/v1/kms/keys/*) self-report 503 instead.
// K8s readiness gating off /healthz would otherwise pull a working
// secrets-only KMS out of rotation purely because MPC is in upgrade.
//
// When MPC is enabled in spec but unreachable, the body switches to
// `{"status":"degraded","mpc":"unreachable","detail":"..."}` so probes
// that scrape the body still observe the degraded mode without
// flapping the pod out of service.
func healthHandler(vaultID string, mpcAvailable *bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body := map[string]string{"status": "ok", "service": "kms"}
if vaultID != "" && (mpcAvailable == nil || !*mpcAvailable) {
body["status"] = "degraded"
body["mpc"] = "unreachable"
body["detail"] = "secrets-only mode; signing routes return 503"
}
writeJSON(w, http.StatusOK, body)
}
}
func registerKMSRoutes(mux *http.ServeMux, mgr *keys.Manager, mpcBackend keys.MPCBackend, mpcAvailable *bool) {
// KMS key routes are unprotected at the HTTP layer — auth is enforced
// by the Gateway (JWT validation + X-IAM-Roles header injection).
// In-cluster callers (ATS, BD, TA) go through Gateway; direct access
// is blocked by K8s NetworkPolicy (only Gateway can reach port 8080).
//
// requireMPC short-circuits with 503 + a re-probe attempt when the
// boot-time MPC handshake failed. The re-probe lets KMS recover
// transparently if MPC came up after KMS did (common during rollout
// or NetworkPolicy reconcile races); a single 5s status call is
// cheap relative to keygen/sign latency.
requireMPC := func(w http.ResponseWriter, r *http.Request) bool {
if mpcAvailable != nil && *mpcAvailable {
return true
}
probeCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, err := mpcBackend.Status(probeCtx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "mpc unreachable",
"detail": err.Error(),
"mode": "secrets-only",
})
return false
}
if mpcAvailable != nil {
*mpcAvailable = true
}
return true
}
mux.HandleFunc("POST /v1/kms/keys/generate", func(w http.ResponseWriter, r *http.Request) {
if !requireMPC(w, r) {
return
}
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 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "threshold must be >= 2"})
return
}
if req.Parties < req.Threshold {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "parties must be >= threshold"})
return
}
if req.Threshold == req.Parties {
log.Printf("kms: WARNING: keygen threshold==parties (%d) for validator=%s — no fault tolerance",
req.Threshold, req.ValidatorID)
}
ks, err := mgr.GenerateValidatorKeys(r.Context(), req)
if err != nil {
log.Printf("kms: audit: keygen FAILED validator_id=%s error=%v", req.ValidatorID, err)
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
}
log.Printf("kms: audit: keygen OK validator_id=%s bls_wallet=%s corona_wallet=%s threshold=%d parties=%d",
ks.ValidatorID, ks.BLSWalletID, ks.CoronaWalletID, ks.Threshold, ks.Parties)
writeJSON(w, http.StatusCreated, ks)
})
mux.HandleFunc("GET /v1/kms/keys", 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}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ks, err := mgr.Get(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "validator key set not found"})
return
}
writeJSON(w, http.StatusOK, ks)
})
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", func(w http.ResponseWriter, r *http.Request) {
if !requireMPC(w, r) {
return
}
id := r.PathValue("id")
var req keys.SignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if len(req.Message) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
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 {
log.Printf("kms: audit: sign FAILED validator_id=%s key_type=%s error=%v", id, req.KeyType, err)
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
}
log.Printf("kms: audit: sign OK validator_id=%s key_type=%s", id, req.KeyType)
writeJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", func(w http.ResponseWriter, r *http.Request) {
if !requireMPC(w, r) {
return
}
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 body"})
return
}
if req.NewThreshold == 0 && len(req.NewParticipants) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new_threshold or new_participants required"})
return
}
ks, err := mgr.Rotate(r.Context(), id, req)
if err != nil {
log.Printf("kms: audit: rotate FAILED validator_id=%s error=%v", id, err)
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
}
log.Printf("kms: audit: rotate OK validator_id=%s new_threshold=%d new_parties=%d",
id, ks.Threshold, ks.Parties)
writeJSON(w, http.StatusOK, ks)
})
mux.HandleFunc("GET /v1/kms/status", func(w http.ResponseWriter, r *http.Request) {
status, err := mpcBackend.Status(r.Context())
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{
"kms": "ok",
"mpc": "unreachable",
"details": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"kms": "ok",
"mpc": status,
})
})
}
// startReplicator initializes ZapDB S3 replication if configured.
//
// REPLICATE_S3_ENDPOINT is normalized to bare host:port — s3.New
// rejects fully-qualified URLs ("Endpoint url cannot have fully
// qualified paths") and silently disables replication when an operator
// passes a scheme- or path-bearing value. We parse defensively and
// drop both the scheme and any path component, logging a warning so
// the misconfiguration is visible.
func startReplicator(db *badger.DB, nodeID string) *badger.Replicator {
rawEndpoint := os.Getenv("REPLICATE_S3_ENDPOINT")
if rawEndpoint == "" {
log.Printf("kms: S3 replication disabled (set REPLICATE_S3_ENDPOINT to enable)")
return nil
}
endpoint, useSSL := normalizeS3Endpoint(rawEndpoint)
// Backwards-compatible env reads: AWS SDK names take precedence,
// REPLICATE_S3_* legacy names are honoured, and the historical
// REPLICATE_S3_ACCESS_KEY / _SECRET_KEY shorthand still wins as a
// last resort. Operators should pick one — we accept all three so
// a stale chart cannot silently zero-out credentials.
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"),
)
// Backup interval. Default 60s — every-second backups generated
// continuous allocation pressure (~3 KiB streamed per cycle plus
// the encrypter's working buffers, ~150 MiB sustained heap on
// quiet KMS pods). 60s catches up to mainnet write rate (~10
// secret rotations/day) with plenty of headroom; operators can
// tune via REPLICATE_INTERVAL=<go-duration>.
intv := time.Minute
if v := os.Getenv("REPLICATE_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
intv = d
}
}
cfg := badger.ReplicatorConfig{
Endpoint: endpoint,
Bucket: envOr("REPLICATE_S3_BUCKET", "lux-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: intv,
}
// Age encryption for backups.
recipientStr := os.Getenv("REPLICATE_AGE_RECIPIENT")
if recipientStr != "" {
// Age recipient parsing happens inside the Replicator via the age package.
// The ReplicatorConfig accepts the raw recipient/identity interfaces.
log.Printf("kms: S3 replication with age encryption enabled")
}
replicator, err := badger.NewReplicator(db, cfg)
if err != nil {
log.Printf("kms: WARNING: S3 replicator init failed: %v — replication disabled", err)
return nil
}
go replicator.Start(context.Background())
log.Printf("kms: S3 replication started → %s/%s/%s", endpoint, cfg.Bucket, cfg.Path)
return replicator
}
// loadREK returns the 32-byte Root Encryption Key used by the ZAP secrets
// server to seal every per-secret DEK.
//
// Preference order:
// 1. MPC_REK_ENDPOINT set → fetch via mpcrek.Bootstrap. FAIL-CLOSED on
// any error: log.Fatalf rather than silently falling back to the
// env-var path. This is the point of MPC-rooting the REK — if the
// pod can shrug off MPC unavailability and still come up with a
// key, we have re-introduced the static-secret weakness we set out
// to remove.
// 2. KMS_MASTER_KEY_B64 set → decode and return. Logged WARN so the
// deployment knows it's still on the legacy path.
// 3. Neither set → nil (ZAP secrets-server stays disabled).
//
// The returned slice is the live REK for the process lifetime. The
// caller MUST keep it alive until shutdown and arrange to zero it via
// mpcrek.Zero on the way out (see the defer in main).
func loadREK() []byte {
if endpoint := envOr("MPC_REK_ENDPOINT", ""); endpoint != "" {
keyID := envOr("MPC_REK_KEY_ID", "kms/rek/v1")
timeoutStr := envOr("MPC_REK_TIMEOUT", "10s")
timeout, perr := time.ParseDuration(timeoutStr)
if perr != nil {
log.Fatalf("kms: MPC_REK_TIMEOUT %q invalid: %v", timeoutStr, perr)
}
nodeID := envOr("KMS_NODE_ID", "kms-0") + "-rek-bootstrap"
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
log.Printf("kms: bootstrapping REK from MPC endpoint=%q keyID=%q", endpoint, keyID)
rek, err := mpcrek.Bootstrap(ctx, mpcrek.Config{
Endpoint: endpoint,
KeyID: keyID,
NodeID: nodeID,
Timeout: timeout,
})
if err != nil {
// Fail-closed. The deployment asked for MPC-rooted REK and
// MPC said no — refuse to start with a static key, that's
// the split-brain we are escaping.
log.Fatalf("kms: MPC REK bootstrap failed: %v", err)
}
log.Printf("kms: REK bootstrapped from MPC (32 bytes)")
return rek
}
b64 := envOr("KMS_MASTER_KEY_B64", "")
if b64 == "" {
return nil
}
mk, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(mk) != 32 {
log.Printf("kms: KMS_MASTER_KEY_B64 invalid (need 32 bytes base64); ZAP secrets-server disabled")
return nil
}
log.Printf("kms: WARNING: using legacy KMS_MASTER_KEY_B64 env-var REK; set MPC_REK_ENDPOINT to migrate to MPC-rooted REK")
return mk
}
// masterKeyFromEnv returns a 32-byte encryption key for ZapDB at-rest encryption,
// or nil to disable (dev only).
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.Printf("kms: 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
}
func envBool(key string, fallback bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch v {
case "":
return fallback
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
// firstNonEmpty returns the first argument with non-zero length, or "" if
// all are empty. Used to fall back across the AWS / REPLICATE_S3 env-name
// variants without picking a default that could silently mask a typo.
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// normalizeS3Endpoint strips scheme + path from a REPLICATE_S3_ENDPOINT
// value so s3.New receives the bare host:port it expects. Returns
// (host[:port], useSSL) where useSSL is true unless the operator
// explicitly specified an http:// scheme. A non-empty path is logged as
// a warning so the misconfiguration is visible — the SDK rejects path-
// bearing endpoints with "Endpoint url cannot have fully qualified
// paths." and the historical behaviour was to silently disable
// replication.
func normalizeS3Endpoint(raw string) (host string, useSSL bool) {
useSSL = !strings.HasPrefix(raw, "http://")
// strings.HasPrefix above already covers "http://"; treat the bare
// "https://" case explicitly so the parse path stays simple.
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
log.Printf("kms: REPLICATE_S3_ENDPOINT %q failed to parse — using as-is, replication may fail", raw)
return raw, useSSL
}
if u.Path != "" && u.Path != "/" {
log.Printf("kms: REPLICATE_S3_ENDPOINT %q has a path component (%q) — stripping; put the bucket in REPLICATE_S3_BUCKET", raw, u.Path)
}
if u.RawQuery != "" {
log.Printf("kms: REPLICATE_S3_ENDPOINT %q has a query string — stripping", raw)
}
return u.Host, useSSL
}
// Already bare host:port. Trim a trailing "/" if any operator added one.
return strings.TrimRight(raw, "/"), useSSL
}
// zapdbLogger adapts slog to ZapDB's Logger interface.
type zapdbLogger struct{}
func (zapdbLogger) Errorf(format string, args ...interface{}) {
slog.Error(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Warningf(format string, args ...interface{}) {
slog.Warn(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Infof(format string, args ...interface{}) {
slog.Info(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Debugf(format string, args ...interface{}) {
slog.Debug(fmt.Sprintf(format, args...))
}
// registerWebUI mounts the embedded Vite SPA at `/` with a SPA fallback.
// API routes are registered earlier on the same mux and win the route
// match (Go 1.22 ServeMux specificity rules); this only catches the
// remainder. If `web/` is empty (not built yet), every fallthrough returns
// 404 — the API still works, just no UI.
func registerWebUI(mux *http.ServeMux) {
sub, err := fs.Sub(frontendDist, "web")
if err != nil {
log.Printf("kms: web UI disabled — embed sub: %v", err)
return
}
// If index.html is missing, treat the UI as not-built and return early.
// (`make build` populates `web/` from frontend/dist before `go build`.)
if _, err := fs.Stat(sub, "index.html"); err != nil {
log.Printf("kms: web UI not built — `make build` populates cmd/kms/web from frontend/dist")
return
}
fileSrv := http.FileServer(http.FS(sub))
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
// Trim leading "/" once for fs.Stat.
clean := strings.TrimPrefix(r.URL.Path, "/")
if clean == "" {
clean = "index.html"
}
if _, err := fs.Stat(sub, clean); err != nil {
// Not a real asset — serve index.html so React Router takes over.
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
fileSrv.ServeHTTP(w, r2)
return
}
fileSrv.ServeHTTP(w, r)
})
log.Printf("kms: web UI mounted at /")
}