Files
kms/embed.go
T
zeekayandHanzo Dev 4bcdcbfbf6 Go 1.27.1, and the code it can say more simply
Both modules declare go 1.27.1 and the build images follow. go fix applied
the modernizers: promoted-field keys in struct literals, SplitSeq,
strings.Cut, new(expr), slices.Contains and any. The ptr helper had no
callers left once new(expr) could say it. omitzero is not applied; it
changes what the JSON says, so it is a per-field decision.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 18:32:14 -07:00

690 lines
23 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), and identity-header hygiene.
//
// Secrets are stored in ZapDB at cfg.DataDir with optional at-rest
// encryption via KMS_ENCRYPTION_KEY_B64, and the in-process Replicator
// streams encrypted backups to S3. They are reached over the ZAP
// transport, whose authorizer is a signed consensus-authority snapshot
// (consensus.go) and which is disabled unless that snapshot is present.
//
// There is no HTTP secret plane here. Secret reads and writes over HTTP
// are served by cloud (apps/kms) on /v1/kms/secrets, which takes the
// org from the caller's token and folds it into the storage key, so one
// tenant's path cannot name another's record. This package therefore
// makes no authorization decision at all: IAM owns identity and
// permissions, and verifyJWT answers only "who signed this token".
//
// The HTTP surface is health and the IAM client-credentials login proxy.
// The console is a static site (frontend/) that calls cloud. embed.go
// owns the route assembly plus the transport wiring, and exposes
// Embed(ctx, cfg) (*Embedded, error).
package kms
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
badger "github.com/luxfi/zapdb"
"github.com/hanzoai/kms/custody"
"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
// 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
// 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
zapNode *zap.Node // nil when ZAP server disabled
// Node identity custody, built on first use. One store per Embedded, because
// the store's lock is what serializes the read-modify-write paths; a second
// store over the same database would let two rotations interleave.
custodyOnce sync.Once
custodyStore *custody.Store
custodyErr error
}
// 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. 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)
secStore := store.NewSecretStore(db)
mux := http.NewServeMux()
registerHealth(mux)
registerAuth(mux, cfg.IAMEndpoint)
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.db != nil {
_ = e.db.Close()
e.db = nil
}
log.Info("kms.Embed: stopped")
return nil
}
// ListenAddr is where the KMS HTTP surface binds: $KMS_LISTEN, else :8443.
//
// Exported because Embed runs with SkipListen under a host that owns the
// listener, so the address is resolved in one place and read by both.
func ListenAddr() string { return envOr("KMS_LISTEN", ":8443") }
// applyEmbedDefaults resolves process-shape EmbedConfig fields
// (DataDir, HTTPAddr, NodeID, ZAPPort) 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 = ListenAddr()
}
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.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"})
})
}
// --- Authentication ---
// jwtClaims is the minimal set of verified JWT claims we extract.
// Verification (signature, alg, iss, aud, exp) happens in verifyJWT
// (auth.go).
//
// These are identity claims only. KMS derives no permission from them:
// IAM owns identity and permissions, and this package answers "who
// signed this token" — never "what may they reach". Authorization for
// the secret plane lives in cloud (apps/kms), which folds the caller's
// org into the storage key.
type jwtClaims struct {
Iss string `json:"iss"`
Owner string `json:"owner"`
Sub string `json:"sub"`
}
// 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
}
// --- 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 ...any) {
log.Error(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Warningf(format string, args ...any) {
log.Warn(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Infof(format string, args ...any) {
log.Info(fmt.Sprintf(format, args...))
}
func (zapdbLogger) Debugf(format string, args ...any) {
log.Debug(fmt.Sprintf(format, args...))
}