Files
python-sdk/pkg/hanzo-kms
zeekayandHanzo Dev c80140810a kms: migrate hanzo-kms off Infisical paths onto the luxfi/kms surface
kms.hanzo.ai runs luxfi/kms, which has never served an /api/* route. This
SDK was sending Infisical's: /api/v3/secrets/raw for every secret operation,
/api/v3/auth/login for user auth, /api/v1/auth/kubernetes-auth/login for k8s.
Only one call in the whole package — /v1/kms/auth/login — was ever real.

It stayed invisible because older luxfi/kms builds embedded a console SPA
behind a root catch-all that answered every unmatched path with 200
text/html. A wrong URL came back as a JSON decode error, which reads like a
parsing bug in the client rather than "this endpoint does not exist." That
catch-all is gone (cmd/kms/main.go ends in notFoundJSON), so those paths now
return honest JSON 404s and the SDK fails outright.

The route table is not the only thing that was wrong. The data model was
Infisical's too — project_id, workspace, secret version, secret comment,
shared-vs-personal type — and luxfi/kms has none of those concepts. It keys
one value by (org, path, name, env) in ZapDB at kms/secrets/{path}/{env}/{name}
and a write upserts it in place. Half-migrating would have left response
models that cannot validate what the server sends, so the vocabulary moves
with the paths:

  list_secrets(path, env) -> names   GET  /v1/kms/orgs/{org}/secrets
  get_secret(path, name, env)        GET  /v1/kms/orgs/{org}/secrets/{path}/{name}
  put_secret(path, name, value, env) POST /v1/kms/orgs/{org}/secrets   (create AND replace)
  delete_secret(path, name, env)     DELETE .../secrets/{path}/{name}
  health()                           GET  /v1/kms/healthz

Two server behaviors now have one home, hanzo_kms/routes.py, shared by the
sync and async clients so they stay mirror images:

  - The server splits the trailing path at its LAST slash into (path, name),
    so each segment is escaped individually. Escaping the joined string
    encodes the separators away and the server reads one long name. A name
    containing "/" is rejected outright: it would be written under one key
    and read back under another, so the write looks like it succeeded and
    the read never finds it.
  - There is no versioned read. get_secret(version=N) raises
    VersionUnsupportedError rather than quietly returning the current value.

org is new and required — it scopes both the URL and the JWT owner claim.
Constructor field, HANZO_KMS_ORG, defaults to "hanzo".

Auth collapses to what the server actually offers: client credentials
exchanged at /v1/kms/auth/login, or a pre-issued IAM bearer token. The AWS,
Azure, GCP, Kubernetes and SRP methods were Infisical's and none were served.
That also fixes a divergence where the async client silently ignored
HANZO_KMS_TOKEN — env parsing is now one function.

Callers migrated with it: hanzo-cli's `hanzo kms`, the hanzo-tools-kms MCP
tool, and hanzo-tools-auth's session client, all of which were building the
removed auth models and calling the removed create/update pair. Each now
constructs KMSClient() and lets it read the environment.

Tests: pkg/hanzo-kms/tests pins the wire shape — no request URL may contain
"/api/", per-segment escaping survives the server's last-slash split, sync
and async emit byte-identical request lines, and a decoder rejects the
200-HTML/JSON-404 shapes instead of reading them as empty results. Wired into
hanzo-packages-ci without `|| true`, so a regression turns CI red instead of
waiting for a decode error in production.

hanzo-kms 1.1.0 -> 1.1.1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 08:29:59 -07:00
..
2026-03-23 10:26:07 -07:00

Hanzo KMS - Python SDK

Official Python SDK for Hanzo KMS — secret management for your applications.

The server is luxfi/kms; this SDK speaks its canonical /v1/kms surface and nothing else.

Installation

pip install hanzo-kms

Or with uv:

uv add hanzo-kms

Quick Start

A secret is identified by (org, path, name, env). The org scopes both the URL and the JWT; path, name and env identify the value.

from hanzo_kms import ClientSettings, KMSClient

client = KMSClient(ClientSettings(
    org="lux",
    client_id="your-client-id",
    client_secret="your-client-secret",
))

# List the secret names at a path
names = client.list_secrets("providers/lux", env="prod")

# Read one value
mnemonic = client.get_secret("providers/lux", "deploy-mnemonic", env="prod")

# Create or replace — one upsert; KMS holds one value per (path, name, env)
client.put_secret("providers/lux", "deploy-mnemonic", mnemonic, env="prod")

# Delete
client.delete_secret("providers/lux", "deploy-mnemonic", env="prod")

# Load a whole path into os.environ, keyed by secret name
client.inject_env("providers/lux", env="prod")

env defaults to "default".

Async

AsyncKMSClient is the mirror image of KMSClient — same methods, same arguments.

from hanzo_kms import AsyncKMSClient

async with AsyncKMSClient() as client:
    names = await client.list_secrets("providers/lux", env="prod")

Environment Variables

export HANZO_KMS_URL="https://kms.hanzo.ai"   # default
export HANZO_KMS_ORG="hanzo"                  # default
export HANZO_KMS_CLIENT_ID="your-client-id"
export HANZO_KMS_CLIENT_SECRET="your-client-secret"
# ...or a pre-issued IAM bearer token instead of client credentials:
export HANZO_KMS_TOKEN="eyJ..."

Then:

from hanzo_kms import KMSClient

client = KMSClient()  # configures itself from the environment

Authentication

Two ways, because the server has one login route:

Setting Behavior
client_id + client_secret Exchanged at POST /v1/kms/auth/login for a bearer token
access_token A pre-issued IAM bearer token, used as-is

The AWS / Azure / GCP / Kubernetes / SRP methods this SDK used to advertise were Infisical's. luxfi/kms has never served them.

CLI

hanzo-kms list --path providers/lux --env prod
hanzo-kms get providers/lux deploy-mnemonic --env prod
hanzo-kms set providers/lux deploy-mnemonic "word word ..." --env prod
hanzo-kms delete providers/lux deploy-mnemonic --env prod
hanzo-kms export --path providers/lux --env prod --format json

API Reference

KMSClient

Method Description
list_secrets(path="", env="default") Secret names at a path
get_secret(path, name, env="default") The secret's value
put_secret(path, name, value, env="default") Create or replace
delete_secret(path, name, env="default") Delete
inject_env(path="", env="default", overwrite=False) Load a path into os.environ
health() {"service": "kms", "status": "ok"}

Server surface

POST   /v1/kms/auth/login                            {clientId, clientSecret} -> {accessToken, expiresIn}
GET    /v1/kms/orgs/{org}/secrets?path=&env=         -> {"names": [...]}
GET    /v1/kms/orgs/{org}/secrets/{path}/{name}?env= -> {"secret": {"value": "..."}}
POST   /v1/kms/orgs/{org}/secrets                    {path, name, env, value}
DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env=
GET    /healthz | /v1/kms/healthz                    -> {"service": "kms", "status": "ok"}

Two things follow from it:

  • The server splits the trailing path at its LAST slash into (path, name). A name may not contain / — this SDK rejects one rather than let a write land under a key the matching read can never find.
  • There is no versioned read. KMS holds exactly one value per (path, name, env), so get_secret(..., version=N) raises VersionUnsupportedError instead of quietly handing back the current value.

Compatibility

License

MIT License - see LICENSE for details.