The operator reconciled `secrets.lux.network` on Hanzo clusters. It is a Lux-lineage component — it depends on luxfi/kms and luxfi/keys — but the group is what every manifest in the estate names, and a Hanzo cluster naming a Lux domain is the white-label rule broken in the most-copied place there is. Group becomes kms.hanzo.ai, version stays v1 and there is no alpha: a second served version is the thing a migration exists to remove, not something to carry. The CRD bases are renamed to match, because the filename carries the group and a stale name is a manifest that applies to nothing. Two tests hold it. One pins group and version. The other walks the tree and fails on any file still naming the old group — a rename that misses one manifest ships a CRD the operator has no RBAC for, and that surfaces as "no permission" long after the commit that caused it. This is the operator half only. Nothing in a cluster moves until the objects are migrated, and that sequence is not a sed: 147 live CRs, two of which are creationPolicy Owner, so deleting those two CRs deletes the Secrets they manage.
12 KiB
Hanzo Kms Operator
Overview
Go module: github.com/hanzoai/kms-operator.
Reconciles KMSSecret and KMSPushSecret CRDs (kms.hanzo.ai/v1)
against the canonical luxfi/kms surface (~/work/hanzo/kms cmd/kmsd at
kms.hanzo.svc.cluster.local).
Tech Stack
- Language: Go 1.26.3
- Controller framework: sigs.k8s.io/controller-runtime v0.23.x
- KMS dep:
github.com/luxfi/[email protected](transitively pullspkg/zapclientfor ZAP transport)
Build & Run
GOWORK=off go build ./...
GOWORK=off go test ./...
(GOWORK=off because this module is not a member of the parent
~/work/hanzo/go.work use-list — it ships as a leaf module.)
Transport (v0.3.0+) — HTTP vs ZAP
The reconciler talks to KMS over either of:
- HTTP (default):
http(s)://…host.clientId/clientSecretfrom the CR'suniversalAuth.credentialsRef→ bearer JWT via IAM. Used for every CR that doesn't opt into ZAP. - ZAP (opt-in per CR via
spec.hostAPI):zap://host:port— direct address (typicallyzap://kms.hanzo.svc.cluster.local:9999).zap+mdns://_kms._tcp— mDNS discovery (single peer wins).
The factory util.BuildTransport(host, ca, ua) picks the impl by
scheme. Both reconcilers (KMSSecret, KMSPushSecret) call it at
getResourceVariables time.
ZAP auth — important divergence
ZAP does NOT consume IAM bearer tokens. The kmsd ZAP server
(luxfi/kms/pkg/zapserver/auth.go) authorises by peer NodeID + ACL:
# KMS_ZAP_ACL file format (per luxfi/kms):
nodeId,pathPrefix,role
kms-operator-canonical,,admin
To enable ZAP in production:
- Set
KMS_OPERATOR_ZAP_NODE_ID=<unique-id>on the operator Deployment. - Add
<unique-id>,<path-prefix>,admin(orread) to the KMS ACL file referenced byKMS_ZAP_ACLon kmsd. - Set
spec.hostAPI: zap://kms.hanzo.svc.cluster.local:9999on the target CR.
clientId/clientSecret from the CR's universalAuth.credentialsRef
are accepted but ignored on the ZAP path — the call still works
because Login() returns a synthetic zap:<nodeID> token kept in the
existing token cache.
Structure
kms-operator/
Dockerfile # alpine builder (incl. git for VCS resolution)
Makefile
PROJECT
README.md
api/v1/ # KMSSecret + KMSPushSecret CRD types
bin/
config/
controllers/
kmssecret/ # read-side reconciler
kmspushsecret/ # write-side reconciler
frontend/ # static (unused on the wire)
go.mod
go.sum
hack/
kubectl-install/
main.go
packages/
api/ # global host / CA / UA config
constants/
controllerhelpers/
controllerutil/
crypto/
generator/ # password/token generator output for push
kmsapi/ # HTTP transport + Transport interface (v0.3.0+)
transport.go # ← interface seam
client.go # HTTP impl
kmszap/ # ZAP transport (v0.3.0+) — wraps luxfi/kms/pkg/zapclient
client.go
model/
template/ # go-template for managed Secret/CM
util/
auth.go # HandleUniversalAuth (token cache, NUL filter)
models.go # ResourceVariables (KMSClient: Transport)
secrets.go # GetPlainTextSecretsViaMachineIdentity
transport.go # BuildTransport factory (v0.3.0+)
Key Files
packages/kmsapi/transport.go—Transportinterface (Login,LoginCached,GetSecret,CreateSecret,UpdateSecret,DeleteSecret,InvalidateToken) +IsZAPHost.packages/kmsapi/client.go— HTTP implementation (default).packages/kmszap/client.go— ZAP implementation (opt-in).packages/util/transport.go—BuildTransport(host, ca, ua)factory: picks HTTP vs ZAP by scheme.controllers/{kmssecret,kmspushsecret}_helper.go— callutil.BuildTransportatgetResourceVariables; pass the Transport to the reconcile pipeline.
Consensus-authority reconciler (v0.3.1+)
internal/bootstrap/ owns the kms-consensus-authority Secret that
the kmsd loads via KMS_CONSENSUS_FILE. The package is a
controller-runtime Runnable registered alongside the CR reconcilers
and gated by leader election.
Responsibilities:
-
Bootstrap — on Start, ensure
kms-operator-mnemonicSecret exists in the target namespace (defaulthanzo). Generate a fresh 24-word BIP-39 phrase if absent (or load fromKMS_OPERATOR_MNEMONICenv). Derive the operator's NodeID via the byte-for-byte port ofluxfi/keys.NewServiceIdentity(service_identity.go). Write the initialkms-consensus-authoritySecret with that NodeID as the sole operator + the current luxd validator set. -
Per-service identity — each
KMSSecretCR reconcile callsEnsureServiceIdentity(ref, servicePath). If the mnemonic Secret is missing the operator generates + persists one, then derives the NodeID and stamps the canonical path back onto the Secret as an annotation (kms-operator.lux.network/service-path) so the periodic reconcile can re-derive without consulting the CR. -
TTL reconcile — every
KMS_CONSENSUS_TTL(default 30s): a. Re-derive the operator NodeID (cached after first call). b. List every Secret inKMS_MNEMONIC_NAMESPACEcarrying labelapp.kubernetes.io/component=service-mnemonicand derive each one's NodeID under its stamped service path. c. Pullplatform.getCurrentValidatorsfromLUXD_RPC_URL. d. Compose a canonical snapshot{"validators": [...], "operators": [...]}andUpdatethe authority Secret only when the canonical bytes differ.
Fail-closed: any luxd unreachable / HTTP error leaves the authority Secret untouched. The kmsd continues honouring the prior snapshot until the next successful tick.
Mnemonic discipline: the BIP-39 phrase NEVER appears in CR status, in
operator logs, or in any non-Secret resource. Only the source category
(secret / env / generate) and the derived NodeID surface.
Determinism contract: service_identity_test.go pins NodeIDs derived
locally against vectors produced by luxfi/keys.NewServiceIdentity at
v1.0.10. Treat any failure as an emergency — drift would silently
revoke every consumer's KMS access.
Env vars
| Var | Default | Meaning |
|---|---|---|
KMS_AUTHORITY_NAMESPACE |
hanzo |
Namespace of the authority Secret + per-service mnemonic Secrets. |
KMS_AUTHORITY_SECRET |
kms-consensus-authority |
Authority Secret name. |
KMS_MNEMONIC_NAMESPACE |
$KMS_AUTHORITY_NAMESPACE |
Namespace scanned for app.kubernetes.io/component=service-mnemonic Secrets. |
KMS_OPERATOR_MNEMONIC_SECRET |
kms-operator-mnemonic |
Operator's own mnemonic Secret name. |
KMS_OPERATOR_MNEMONIC |
unset | Optional bootstrap env carrying a pre-existing operator mnemonic. Honoured only on first-boot when the Secret is absent. |
KMS_OPERATOR_SERVICE_PATH |
hanzo/kms-operator |
Canonical service path for the operator's own derivation. |
LUXD_RPC_URL |
http://luxd-headless.lux-mainnet.svc.cluster.local:9650 |
luxd Platform Chain RPC base. The hanzo cluster routes this at https://api.lux.network in production. |
KMS_CONSENSUS_TTL |
30s |
Cadence of the periodic luxd-validator-set refresh. |
CRD additions (v0.3.1+)
KMSSecretSpec and KMSPushSecretSpec gain two optional fields:
spec.servicePath— canonical service path for the consumer's NodeID derivation. Empty →hanzo/<crname>.spec.mnemonicSecretRef—KubeSecretReferenceto an existing mnemonic Secret. Empty →<crname>-mnemonicinKMS_MNEMONIC_NAMESPACE.
Backward compatible: omitting both fields falls back to the auto-derived defaults so existing CRs continue working without edit.
Releases
v0.1.0(2026-03) initial port to canonical /v1/kms/* surface.v0.2.0(2026-04) hardening: control-byte filter, fail-closed on empty fetch, token cache, same-ns credentialsRef.v0.3.0(2026-05) ZAP transport alongside HTTP.v0.3.1(2026-05) kms-consensus-authority reconciler. Closes the operator-side bootstrap left open by the consensus-native-identity agent.v0.4.0(2026-07) per-service WRITE path-scopes in the authority snapshot (task #53). Also fixes two pre-existing build blockers: Dockerfile nowCOPY internal/, and flags the stale-vendor drift (see below).
Path-scope activation (task #53) — state + open model gap
The kms-consensus-authority snapshot now carries an optional least-privilege
scopes overlay (AuthorityScopes{validators,operators}, NodeID→prefix).
The kmsd consumer (luxfi/kms cmd/kms/consensus.go, from
feat/v1-sdk-secrets-plane) parses it into NewScopedAuthorityProvider and
confines each bound NodeID to its granted subtree; absent scopes → flat +
a boot WARN. End-to-end scoped-deny is proven through verifyAndAuthorize
(luxfi/kms pkg/zapserver/path_scope_e2e_test.go).
What the operator emits now: scopes.operators = each service NodeID
confined to its OWN subtree (deriveServiceScope = the canonical service
path), the kms-operator itself unconfined. scopes.validators = flat
(the luxd L1 consensus set has no per-node secret subtree).
Open model gap — DO NOT guess the read boundary:
- The enveloped
/v1/sdkZAP plane is OFF in prod: the live kmsd (ghcr.io/luxfi/kms:1.11.8, universeinfra/k8s/kms/deployment.yaml) mounts noKMS_CONSENSUS_FILEand nokms-consensus-authoritySecret. All secret access is the legacy HTTP/v1/kms/orgs/{org}/secretspath (IAM JWT, org-scoped byowner). So path-scope is defense-in-depth readiness, not a live blast-radius reduction yet. - zapserver requires validator (read) membership for ALL ops; services
are placed in
operators(write) only → a service can neither read nor write via the plane today, so the operator write-scope is inert until the plane is activated AND services are added to the read authority. Confining a compromised SERVICE key's READS (the task's actual goal) needs services to become scoped members of the READ (validator) authority — an authority-placement decision (recommended: operators ⊆ validators, each scoped to its own subtree; luxd L1 either unconfined readers or dropped). Left for CTO confirmation.
Activation checklist (all gated): (a) merge the /v1/sdk plane PR +
this operator PR; (b) re-sync stale vendor (go mod vendor on a networked
runner — x/{sys,term,text,tools} pinned newer in go.mod than vendored);
(c) confirm the read-authority placement + emit scopes.validators;
(d) mount KMS_CONSENSUS_FILE=/etc/kms/consensus-authority.json (the
kms-consensus-authority Secret) into the kmsd Deployment;
(e) semver tag → operator CR.
Binary secret convention: *.b64 (v0.4.2)
KMS values are JSON strings and the transport fails closed on control
bytes, so raw binary material (a 32-byte BLS signer key) cannot ride the
wire verbatim. Convention, enforced in ONE place
(packages/util/secrets.go fetch layer, pinned by
TestB64KeysDecodeOnProjection): a KMS secret named X.b64 holds the
base64 of the raw bytes; on projection the operator strips the suffix and
decodes, so the managed k8s Secret carries key X with the exact bytes
its consumers expect (luxd startup scripts stay untouched). Invalid
base64 fails the whole reconcile (no partial/garbage projection). First
consumer: luxd-staking sync CRs in luxfi/universe
k8s/lux-k8s/kms-secrets.yaml (org lux, envs mainnet/testnet/devnet,
path /staking, keys staker-N.crt, staker-N.key,
signer-N.key.b64). Server-side path note: values must be written with
path: "staking" (NO leading slash) — the store keys (path, name)
literally and the operator's NormaliseScopePath strips slashes, so a
value written under /staking is only reachable as %2Fstaking and the
operator will 404 on it.