KMS
Secret custody: your org's secrets sealed at rest, plus threshold signing.
Also for this capability: API · CLI · MCP · SDKs
Secret custody: your org's secrets sealed at rest, plus threshold signing.
| Base URL | https://api.hanzo.ai |
| Operations | 5 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1134 · KMS — Secret Custody — Draft · read the specification →
/v1/kms is secret custody: an org's secrets sealed at rest, read and written
over one REST surface, plus threshold signing through the MPC ring. It embeds
the luxfi/kms primitives in-process in the one cloud binary, implemented in
hanzoai/cloud at apps/kms (HIP-0106). This HIP states the custody
invariants: plaintext never touches disk, the master key exists only in the
environment, and with either one absent the capability fails closed rather than
degrading.
Motivation
Cloud hosting the secret store is a chicken-and-egg: it cannot fetch its own
master key from the KMS it hosts. And a secrets manager that is a separate
process is a second auth stack, a second database and a second thing to leak.
Embedding the store gives every subsystem an in-process client and gives the
console one REST face, both backed by the same sealed store and gated by
cloud's one auth boundary — never a parallel JWT stack
(apps/kms/kms.go:1-57).
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store: per-org files, two seals, one env key
Each org's secrets live in its own encrypted file,
{DataDir}/orgs/{org}/kms.db, opened through the canonical per-org store seam
(apps/kms/store.go:62-77) — per-org files carry no single-opener lock, which
is what lets different pods serve different tenants. Two layers of at-rest
protection root in the same key: each secret is sealed in an AES-256-GCM
envelope (a fresh per-secret DEK wrapped by the master key) BEFORE it reaches
SQLite, and the file itself is SQLCipher-encrypted under a per-db DEK
(apps/kms/kms.go:19-30).
The 32-byte master key is read ONLY from the environment
(CLOUD_KMS_MASTER_KEY_REF), injected by the operator — never from the store,
never logged, never persisted. Absent, the subsystem mounts health-only:
/v1/kms/health answers 503, every secret operation refuses with a clear
error, and the fallback store is ephemeral in-memory — never an unencrypted
on-disk one, so a later keyed boot opens clean (apps/kms/kms.go:32-42).
Signing is MPC-backed and MUST fail closed when the MPC backend is not
configured; a signature is never fabricated (apps/kms/kms.go:43-47).
§2 The address
Seven REST operations (apps/kms/mount.go:10-15): health and the SPA config
are public; listing and reading secrets are member operations; writing and
deleting require org admin; and auth/login is a broker that exchanges a
client id and secret at IAM — with no IAM token URL configured it refuses 503,
because cloud is not a token issuer (apps/kms/mount.go:52-58).
Five of the seven are typed ops (apps/kms/typed.go), and typing moved no wire:
each model spells the map its handler assembled, in alphabetical json-tag order,
because encoding/json writes a map's keys sorted — so the answer is
byte-identical to the map it replaced rather than merely equal as JSON. The door
they pass is one function of values rather than of a request, admits
(apps/kms/typed.go:65): the authority the caller holds, then the org key it
resolved, then whether this process can open a secret at all — 403, 400, 503,
fail-closed at each step and before any record is touched. The typed ops reach
it through admit (:92), which takes the org from the parked principal and
admin-ness from the request, neither of them an In field; the two raw handlers
reach the same function off the request they are already holding. A typed op and
a raw handler therefore cannot disagree about who may pass, or in what order.
The two that stay untyped are the value routes, GET and DELETE /v1/kms/secrets/+, and what keeps them out is the ADDRESS rather than anything
about secrets. A secret is named by a sub-path, so the route is fiber's greedy
+; zip's registry publishes that segment verbatim while cloud's reading of the
router names it {wildcard1}. The fold looks a typed op up by zip's spelling,
finds no live route at that key, and refuses to produce a document at all — not
a mis-named parameter, no document — which TestTheWildcardCannotBeATypedOp
proves by trying it (apps/kms/typed_wire_test.go:34-62, :223). Both declare
the body they answer with through openapi.Register, so an SDK generated off
the document still has a return type.
Every description here is written under one rule for a credential broker: it
never implies secret material turns up anywhere but the one response body that
exists to carry it. The list operation returns metadata only; the read returns a
value; neither a log line nor an error body carries either
(apps/kms/mount.go:67-80).
§3 The internal plane
Exactly one process holds the sealed store, so every other app asks it: four
typed operations — get, put, sign, delete — declared on the internal plane,
which listens only on this app's canonical socket, so there is no route from
the edge to any of them. The surface is the KMSClient interface and nothing
more, because a secret store's call surface is the one place where "while I'm
here, expose the rest" is how a tenant's material leaves its boundary
(apps/kms/secret_rpc.go:39-51).
§4 Tenancy
The org is the caller's, read from the validated principal (HIP-0026), and
never named in the URL — it used to be a path segment that had to equal the
principal's org, which made the tenant caller-selectable; the segment is gone.
The org is folded into the store path as the isolation partition, the same role
the org column plays in every table (apps/kms/mount.go:17-25). On the plane,
a ref names its tenant and the authorize check binds the two
(apps/kms/secret_rpc.go:152-164).
§5 Money, events, observability, stage
Free (cloud.Free, plugin/kms/main.go). It publishes nothing on the bus. It
emits nothing beyond the request span, and by the stated rule above its spans
and errors are value-free. Stage ga: secrets are identity-plane core.
§6 Upstream
It embeds github.com/luxfi/kms v1.12.22 (Lux Ecosystem License 1.2): the
secret store and client primitives survive in HEAD as the library face behind
both the in-process client and the REST surface. HIP-0027 describes the earlier
standalone deployment of the same lineage; this HIP specifies the embedded
capability.
Rationale
The alternative bootstrap is to seed the master key into the store itself, which is circular, or into a peer secrets service, which reintroduces the process this design deletes. Env-only injection is the smallest trusted input, and the health-only mode makes its absence loud instead of silently insecure — the design refuses the "temporary plaintext fallback" every outage invites.
Security Considerations
The attacker's prize is another tenant's credentials, and the defenses are structural: per-org files mean a tenancy bug must open the wrong file, not merely forget a predicate; the caller-selectable org segment is gone; plane operations are unreachable from the edge. The second prize is the master key, which exists in exactly one place, the process environment. The residual surface is disclosure by side channel — a value in a log line or error body — and the surface's own rule is that the one response body is the only place a value ever appears.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | kms at its own prefix | 5 operations |
| CLI | hanzo kms … | 5 of 5 |
| SDK | KmsApi in every published client | 5 methods |
| MCP | tool kms on https://api.hanzo.ai/v1/mcp | 2 operations |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/kms/config, operation get_kms_config:
hanzo kms configimport { Configuration, KmsApi } from 'hanzoai';
const api = new KmsApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getKmsConfig();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import KmsApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = KmsApi(client).get_kms_config()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.KmsAPI.GetKmsConfig(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, kms_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = kms_api::get_kms_config(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.KmsApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new KmsApi(client).getKmsConfig();curl https://api.hanzo.ai/v1/kms/config \
-H "Authorization: Bearer $HANZO_API_KEY"Tool kms, op get_kms_config — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "kms",
"arguments": {
"op": "get_kms_config",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/kms/auth/login | Exchanges a machine credential for an IAM bearer token. |
GET /v1/kms/config | Returns the runtime configuration for the KMS console. |
GET /v1/kms/health | Reports whether this broker can actually serve secrets. |
GET /v1/kms/secrets | Lists the secrets your org holds, without their values. |
POST /v1/kms/secrets | Stores or replaces one secret in your org. |
How is this guide?