Compare commits

...
Author SHA1 Message Date
hanzo-dev 1fe217d518 fix(kms): require explicit env on secret writes (no silent default)
env is a first-class component of the storage key
(kms/secrets/{path}/{env}/{name}); a POST/PATCH that omitted env used to
silently default to "default", committing the write to a bucket that
project/env/path readers (kms-operator, cluster syncs) never resolve. That
split landed an IAM z-password in env=default while prod kept serving the
stale value. POST/PATCH now fail loud with 400 when env is omitted;
GET/DELETE/LIST keep a compat default for legacy readers (a read/delete
can't plant a value another reader trusts).

Root cause on the client: sdk/go/kmsclient dropped env on every HTTP op
(c.env was used only on the ZAP path), so a client configured for env=prod
still wrote to env=default over HTTP. The HTTP path now sends c.env on the
write body and as ?env= on read/delete, matching the ZAP path. Built
in-tree via a local replace so cmd/kms and cmd/kms-fetch ship the fix; the
two Dockerfiles stage sdk/go/go.mod so `go mod download` resolves the
replaced module.

Regression tests: write without env -> 400; write env=prod is readable via
the operator's project/env/path resolution (sha256 round-trip, values never
printed) and is not visible in env=default.

Claude-Session: https://claude.ai/code/session_01D4FSvT3UfhrFJNQjrctjEj
2026-07-08 07:06:25 -07:00
9 changed files with 310 additions and 7 deletions
+4
View File
@@ -18,6 +18,10 @@ ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
# kmsclient is an in-repo module built via a local replace (see go.mod).
# Stage its go.mod/go.sum so `go mod download` can read the replaced module's
# graph before the full source tree is copied.
COPY sdk/go/go.mod sdk/go/go.sum ./sdk/go/
RUN --mount=type=cache,target=/go/pkg/mod \
GOPRIVATE="github.com/luxfi/*,github.com/hanzoai/*" \
GONOSUMCHECK="github.com/luxfi/*,github.com/hanzoai/*" \
+4
View File
@@ -28,6 +28,10 @@ ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
# kmsclient is an in-repo module built via a local replace (see go.mod).
# Stage its go.mod/go.sum so `go mod download` can read the replaced module's
# graph before the full source tree is copied.
COPY sdk/go/go.mod sdk/go/go.sum ./sdk/go/
RUN --mount=type=cache,target=/go/pkg/mod \
GOPRIVATE="github.com/luxfi/*,github.com/hanzoai/*" \
GONOSUMCHECK="github.com/luxfi/*,github.com/hanzoai/*" \
+11
View File
@@ -94,6 +94,17 @@ IAM use `/v1/iam/*` — not a route this service exposes, and never
Mismatch → 409 with the current version. Replay defence is structural,
not advisory.
- DELETE wipes the version record so a recreate restarts from 1.
- **R-ENV (one-way env):** `env` is a first-class component of the storage
key (`kms/secrets/{path}/{env}/{name}`) — it can never be aliased. The
value-writing mutations **POST and PATCH require an explicit `env`** (in
the request body); omitting it is a fail-loud `400`, never a silent
`default`. A silent default is exactly what let an IAM z-password land in
`env=default` while the operator's `project/env/path` read (env=prod) kept
serving the stale value. GET/DELETE/LIST keep a backward-compatible
`default` for legacy readers (a read/delete can't plant a value another
reader trusts). The canonical `sdk/go/kmsclient` now always sends `env`
explicitly on every HTTP op, so new callers are one-way by construction;
the server default is a legacy-compat shim, not the intended path.
### Admin-only
+1 -1
View File
@@ -1 +1 @@
0.159.4
0.159.5
+37 -4
View File
@@ -512,6 +512,27 @@ func registerAuth(mux *http.ServeMux, iamEndpoint string) {
})
}
// 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.
//
@@ -521,6 +542,10 @@ func registerAuth(mux *http.ServeMux, iamEndpoint string) {
//
// 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)
@@ -542,6 +567,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
}
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)
@@ -587,8 +615,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
recordAudit(claims, r, req.Path, req.Name, req.Env, http.StatusBadRequest, 0)
return
}
if req.Env == "" {
req.Env = "default"
if !envRequired(w, req.Env) {
recordAudit(claims, r, req.Path, req.Name, "", http.StatusBadRequest, 0)
return
}
sec := &store.Secret{
Name: req.Name,
@@ -646,8 +675,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
if env == "" {
env = r.URL.Query().Get("env")
}
if env == "" {
env = "default"
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
@@ -740,6 +770,9 @@ func registerSecretRoutes(mux *http.ServeMux, secStore *store.SecretStore, db *b
}
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 {
+165
View File
@@ -0,0 +1,165 @@
// Regression coverage for R-ENV (one-way env). env is a first-class
// component of the storage key (kms/secrets/{path}/{env}/{name}); a
// value-writing mutation that omits env must fail loud (400) instead of
// silently landing in a "default" bucket that project/env/path readers (the
// kms-operator, cluster syncs) never resolve. That split is what let an IAM
// z-password land in env=default while prod kept serving the stale value.
//
// Secret values are never printed — round-trip fidelity is asserted by
// comparing SHA-256 digests.
package kms
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func postSecret(t *testing.T, srvURL, org, tok string, body map[string]string) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", srvURL+"/v1/kms/orgs/"+org+"/secrets", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
func getSecret(t *testing.T, srvURL, org, tok, rest, env string) *http.Response {
t.Helper()
u := srvURL + "/v1/kms/orgs/" + org + "/secrets/" + rest
if env != "" {
u += "?env=" + env
}
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
return resp
}
// A write that omits env must 400 — and must not land anywhere.
func TestEnvRequired_PostWithoutEnv_400(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
resp := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "value": "irrelevant",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("omitted-env write: want 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if msg, _ := m["message"].(string); !strings.Contains(msg, "env is required") {
t.Fatalf("want env-required message, got %q", msg)
}
// Prove the rejected write did not silently populate the default bucket.
g := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "default")
defer g.Body.Close()
if g.StatusCode != http.StatusNotFound {
t.Fatalf("omitted-env write must not land in env=default: GET want 404, got %d", g.StatusCode)
}
}
// A write with an explicit env is readable through the exact
// project(org)/env/path resolution the kms-operator uses
// (GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env={env}) — and is NOT
// visible in any other env bucket.
func TestEnvRequired_PostWithEnvProd_ReadableViaProjectEnvPath(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
const secret = "z-password-98f3-do-not-log"
want := sha256hex(secret)
resp := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "env": "prod", "value": secret,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("explicit-env write: want 201, got %d", resp.StatusCode)
}
// Operator resolution: org=hanzo (project), env=prod, path=/iam-passwords.
g := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "prod")
defer g.Body.Close()
if g.StatusCode != http.StatusOK {
t.Fatalf("project/env/path read: want 200, got %d", g.StatusCode)
}
var got struct {
Secret struct {
Value string `json:"value"`
} `json:"secret"`
}
if err := json.NewDecoder(g.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if h := sha256hex(got.Secret.Value); h != want {
t.Fatalf("round-trip value digest mismatch: got %s want %s", h, want)
}
// No cross-bucket bleed: env=default must not resolve the prod write.
gd := getSecret(t, srv.URL, "hanzo", tok, "iam-passwords/Z_PASSWORD", "default")
defer gd.Body.Close()
if gd.StatusCode != http.StatusNotFound {
t.Fatalf("prod write must not be visible in env=default: want 404, got %d", gd.StatusCode)
}
}
// PATCH (update) is a value-writing mutation too: omitting env in both body
// and query must 400 before any CAS check.
func TestEnvRequired_PatchWithoutEnv_400(t *testing.T) {
srv, cleanup := newTestServer(t)
defer cleanup()
tok := mintToken(t, "hanzo", "user-1")
// Seed a prod record so PATCH has a target (env check still precedes CAS).
seed := postSecret(t, srv.URL, "hanzo", tok, map[string]string{
"path": "iam-passwords", "name": "Z_PASSWORD", "env": "prod", "value": "v0",
})
seed.Body.Close()
if seed.StatusCode != http.StatusCreated {
t.Fatalf("seed: want 201, got %d", seed.StatusCode)
}
// PATCH with neither body env nor ?env → 400 (before If-Match/version).
pb, _ := json.Marshal(map[string]any{"value": "rotated", "version": 1})
req, _ := http.NewRequest("PATCH", srv.URL+"/v1/kms/orgs/hanzo/secrets/iam-passwords/Z_PASSWORD", bytes.NewReader(pb))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("patch: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("omitted-env PATCH: want 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
if msg, _ := m["message"].(string); !strings.Contains(msg, "env is required") {
t.Fatalf("want env-required message, got %q", msg)
}
}
+8
View File
@@ -131,3 +131,11 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
// The kmsclient library lives in this repo (sdk/go) and is released together
// with the server. Build the in-tree copy so cmd/kms and cmd/kms-fetch pick up
// the fix that sends env explicitly on the HTTP path (see sdk/go/kmsclient).
// The require above stays at the last published tag so dropping this replace
// falls back to a real version, never a missing one; publish sdk/go/v1.1.1 and
// bump the require when cutting the next SDK release.
replace github.com/hanzoai/kms/sdk/go => ./sdk/go
+17 -2
View File
@@ -301,6 +301,14 @@ func (c *Client) secretPath(path, name string) string {
)
}
// secretURL is secretPath plus the ?env= query the server keys the record
// on. c.env defaults to "default" (see New) and is sent explicitly so reads
// and deletes target the configured env instead of relying on the server's
// legacy default — the same one-way discipline the write path enforces.
func (c *Client) secretURL(path, name string) string {
return c.secretPath(path, name) + "?env=" + url.QueryEscape(c.env)
}
// Get fetches a single secret value by path and name.
//
// On the HTTP path: GET /v1/kms/orgs/{org}/secrets/{path}/{name}.
@@ -317,7 +325,7 @@ func (c *Client) httpGet(ctx context.Context, path, name string) (string, error)
if err != nil {
return "", fmt.Errorf("kmsclient: auth: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.secretPath(path, name), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.secretURL(path, name), nil)
if err != nil {
return "", fmt.Errorf("kmsclient: build request: %w", err)
}
@@ -460,9 +468,16 @@ func (c *Client) httpPut(ctx context.Context, path, name, value string) error {
c.endpoint,
url.PathEscape(c.org),
)
// env is REQUIRED by the server on writes (no silent "default"): send
// c.env explicitly so the write lands in the same env-keyed record that
// project/env/path readers resolve. Historically this body omitted env
// and the server defaulted it to "default" — silently diverting a client
// configured for env=prod into the wrong bucket. That is the bug this
// fixes end to end.
payload, _ := json.Marshal(map[string]string{
"path": path,
"name": name,
"env": c.env,
"value": value,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(payload)))
@@ -499,7 +514,7 @@ func (c *Client) httpDelete(ctx context.Context, path, name string) error {
if err != nil {
return fmt.Errorf("kmsclient: auth: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.secretPath(path, name), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.secretURL(path, name), nil)
if err != nil {
return fmt.Errorf("kmsclient: build request: %w", err)
}
+63
View File
@@ -223,6 +223,69 @@ func TestPut_SendsCanonicalPayload(t *testing.T) {
if gotBody["path"] != "providers/square/dev" || gotBody["name"] != "access_token" || gotBody["value"] != "sq_abc" {
t.Errorf("body = %+v, want path=providers/square/dev name=access_token value=sq_abc", gotBody)
}
// env MUST be on the write body — the server now rejects omitted env. With
// no Config.Env set it defaults to "default" and is sent explicitly.
if gotBody["env"] != "default" {
t.Errorf("body env = %q, want default (sent explicitly, never omitted)", gotBody["env"])
}
}
// A client configured for a specific env sends it on the write body, so the
// write lands in that env's record rather than the server's legacy default.
func TestPut_SendsConfiguredEnv(t *testing.T) {
iam := mockIAM(t, "tok")
defer iam.Close()
var gotBody map[string]string
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}))
defer kms.Close()
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
if err := c.Put(context.Background(), "iam-passwords", "Z_PASSWORD", "s3cr3t"); err != nil {
t.Fatalf("Put: %v", err)
}
if gotBody["env"] != "prod" {
t.Errorf("body env = %q, want prod", gotBody["env"])
}
}
// Reads and deletes carry ?env= too, so they target the configured env
// instead of relying on any server default.
func TestGetDelete_SendEnvQuery(t *testing.T) {
iam := mockIAM(t, "tok")
defer iam.Close()
var gotQuery, gotMethod string
kms := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotMethod = r.Method
if r.Method == http.MethodGet {
_ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]any{"value": "v"}})
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer kms.Close()
c, _ := New(Config{Endpoint: kms.URL, IAMEndpoint: iam.URL, ClientID: "i", ClientSecret: "s", Org: "hanzo", Env: "prod"})
if _, err := c.Get(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
t.Fatalf("Get: %v", err)
}
if gotMethod != http.MethodGet || gotQuery != "env=prod" {
t.Errorf("GET query = %q (method %s), want env=prod", gotQuery, gotMethod)
}
if err := c.Delete(context.Background(), "iam-passwords", "Z_PASSWORD"); err != nil {
t.Fatalf("Delete: %v", err)
}
if gotMethod != http.MethodDelete || gotQuery != "env=prod" {
t.Errorf("DELETE query = %q (method %s), want env=prod", gotQuery, gotMethod)
}
}
func TestList_UsesCanonicalPath(t *testing.T) {