kms: dev secrets via the one OIDC mechanism (IAM issues, KMS owns)

A developer's `hanzo login` already mints an IAM OIDC token; `kms pull --env
devnet` exchanges it for a short-lived KMS token and writes a local .env. Clean
orthogonal separation — IAM is a pure issuer (/v1/iam), KMS owns secrets/envs/
authz (/v1/kms); they compose through the token, neither imports the other. No
broker, no /api/, no compound words (oidc, not oidc-auth).

- src/lib/kms.ts: kmsLogin + kmsSecrets + pullSecrets over /v1/kms/oidc/login
  and /v1/kms/secrets; tolerant of {K:V} or [{secretKey,secretValue}] shapes.
- src/commands/kms.ts + src/kms-cli.ts: `hanzo kms` and standalone `kms` bin —
  one implementation, two entry points. pull/list, env devnet|testnet|mainnet|
  production (default devnet), dotenv out.
- CI shares the SAME mechanism: .github/actions/kms-secrets loads secrets via a
  GitHub OIDC token through /v1/kms/*; reusable .github/workflows/npm-release.yml
  publishes any package with NPM_TOKEN from KMS (self-hosted runners, no
  long-lived token in GitHub); release.yml ships helper + forks on tag.
- DRY: dev and CI use one /v1/kms exchange; one login, one token store.
This commit is contained in:
Hanzo Dev
2026-06-30 14:16:11 -07:00
parent a46e4a51f7
commit 8712aef343
10 changed files with 389 additions and 2 deletions
+78
View File
@@ -0,0 +1,78 @@
name: Load KMS secrets
description: >
Load environment secrets from KMS into the job, via the one OIDC mechanism
shared with local dev (the `kms` CLI). GitHub mints an OIDC token; KMS
exchanges it at /v1/kms/oidc/login and returns the env's secrets from
/v1/kms/secrets. No long-lived KMS credential in GitHub.
inputs:
env:
description: "Environment slug: devnet | testnet | mainnet | production."
required: false
default: production
path:
description: Secret path.
required: false
default: /
base-url:
description: Gateway base that serves /v1/kms (KMS owns this namespace).
required: false
default: https://api.hanzo.ai
audience:
description: OIDC audience the GitHub token is minted for.
required: false
default: kms.hanzo.ai
export:
description: "Where to put secrets: 'env' (job environment) or 'file'."
required: false
default: env
file:
description: Output dotenv path when export=file.
required: false
default: .env
runs:
using: composite
steps:
- name: Load secrets from KMS
shell: bash
env:
KMS_ENV: ${{ inputs.env }}
KMS_PATH: ${{ inputs.path }}
KMS_BASE: ${{ inputs.base-url }}
KMS_AUDIENCE: ${{ inputs.audience }}
KMS_EXPORT: ${{ inputs.export }}
KMS_FILE: ${{ inputs.file }}
run: |
set -euo pipefail
# 1. GitHub OIDC token (requires permissions: id-token: write).
oidc=$(curl -sf "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${KMS_AUDIENCE}" \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value')
[ -n "$oidc" ] && [ "$oidc" != "null" ] || { echo "::error::no GitHub OIDC token (need id-token: write)"; exit 1; }
# 2. Exchange it for a KMS token — KMS owns /v1/kms.
kms_token=$(curl -sf -X POST "${KMS_BASE}/v1/kms/oidc/login" \
-H "Authorization: Bearer ${oidc}" -H "Accept: application/json" \
| jq -r '.token // .accessToken')
[ -n "$kms_token" ] && [ "$kms_token" != "null" ] || { echo "::error::KMS login failed"; exit 1; }
# 3. Read the env's secrets.
resp=$(curl -sf "${KMS_BASE}/v1/kms/secrets?env=${KMS_ENV}&path=${KMS_PATH}" \
-H "Authorization: Bearer ${kms_token}" -H "Accept: application/json")
# Flatten {KEY:val} or [{secretKey,secretValue}] → KEY=val lines.
flatten='if (.secrets|type)=="array" then (.secrets[]|"\(.secretKey // .key)=\(.secretValue // .value)") else (.secrets|to_entries[]|"\(.key)=\(.value)") end'
if [ "${KMS_EXPORT}" = "file" ]; then
echo "$resp" | jq -r "$flatten" > "${KMS_FILE}"
echo "Wrote $(wc -l < "${KMS_FILE}") secrets to ${KMS_FILE} (${KMS_ENV})"
else
while IFS= read -r line; do
[ -z "$line" ] && continue
key=${line%%=*}; val=${line#*=}
echo "::add-mask::${val}"
echo "${key}=${val}" >> "$GITHUB_ENV"
done < <(echo "$resp" | jq -r "$flatten")
echo "Loaded $(echo "$resp" | jq -r "$flatten" | grep -c '=') secrets into job env (${KMS_ENV})"
fi
+68
View File
@@ -0,0 +1,68 @@
# Reusable: publish one npm package, with NPM_TOKEN loaded from KMS via OIDC.
# The one way an org repo publishes to npm — no long-lived token in GitHub, the
# same KMS mechanism the `kms` CLI uses for local dev. Runs on our self-hosted
# pool, never GitHub builders.
#
# Caller:
# jobs:
# release:
# uses: hanzoai/helper/.github/workflows/npm-release.yml@main
# permissions: { id-token: write, contents: read }
# with: { package-dir: ., kms-env: production }
on:
workflow_call:
inputs:
package-dir:
description: Directory of the package to publish.
type: string
default: .
node-version:
type: string
default: "22"
kms-env:
description: KMS environment holding NPM_TOKEN.
type: string
default: production
build:
description: Run typecheck + lint + build before publishing.
type: boolean
default: true
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: hanzo-build-linux-amd64
defaults:
run:
working-directory: ${{ inputs.package-dir }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
- if: ${{ inputs.build }}
run: pnpm typecheck && pnpm lint && pnpm build
# NPM_TOKEN → job env, via KMS over the shared OIDC mechanism.
- uses: hanzoai/helper/.github/actions/kms-secrets@main
with:
env: ${{ inputs.kms-env }}
path: /npm
- name: Publish
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }}
+28
View File
@@ -0,0 +1,28 @@
# Release @hanzo/helper and its white-label forks on a version tag.
# One way: each package is published by the reusable npm-release workflow, which
# pulls NPM_TOKEN from KMS. Tag the repo (vX.Y.Z) and everything ships.
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
helper:
uses: ./.github/workflows/npm-release.yml
with:
package-dir: .
forks:
needs: helper
strategy:
matrix:
dir: [forks/lux, forks/zoo, forks/zen]
uses: ./.github/workflows/npm-release.yml
with:
package-dir: ${{ matrix.dir }}
build: false
+18
View File
@@ -43,9 +43,27 @@ codex # Codex → Hanzo Cloud
| `hanzo auth revoke` | Revoke your API key |
| `hanzo auth logout` | Clear local credentials |
| `hanzo models --tiers` | Show the smart tiers (effort words → cloud aliases) |
| `hanzo kms pull` | Pull env secrets from KMS for local dev (also `kms`) |
| `hanzo install [parts…]` | Set up the ecosystem (dev, mcp, node, apps, …) |
| `hanzo doctor` | Diagnose connectivity and tool configuration |
### Secrets for local dev (`kms`)
One login, one mechanism. Your `hanzo login` mints an IAM OIDC token; `kms`
exchanges it for a short-lived KMS token and reads an environment's secrets.
KMS owns secrets, envs and authz (`/v1/kms/*`); IAM only issues the token
(`/v1/iam/*`) — they compose through the token, nothing braided.
```bash
kms pull --env devnet # write .env for local dev (also: hanzo kms pull)
kms list --env devnet # secret names only, no values
kms pull --env testnet --out - # print to stdout
```
Environments: `devnet` (default), `testnet`, `mainnet`, `production`. CI uses
the *same* `/v1/kms/*` exchange with a GitHub OIDC token — see
`.github/actions/kms-secrets` and the reusable `.github/workflows/npm-release.yml`.
### Smart tiers
The catalog is read live from `api.hanzo.ai/v1/models` — nothing is hardcoded.
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"main": "./dist/index.js",
"bin": {
"hanzo": "./dist/cli.js"
"hanzo": "./dist/cli.js",
"kms": "./dist/kms-cli.js"
},
"exports": {
".": {
+2
View File
@@ -10,6 +10,7 @@ import { authCmd } from './commands/auth';
import { installCmd } from './commands/install';
import { statusCmd, runStatus } from './commands/status';
import { modelsCmd } from './commands/models';
import { kmsCmd } from './commands/kms';
import { doctorCmd } from './commands/doctor';
import { getConfig } from './lib/config';
import { brand } from './lib/brand';
@@ -29,6 +30,7 @@ program.addCommand(authCmd);
program.addCommand(installCmd);
program.addCommand(statusCmd);
program.addCommand(modelsCmd);
program.addCommand(kmsCmd);
program.addCommand(doctorCmd);
// Bare `hanzo` (or `npx @hanzo/helper`): if you're not signed in, run the login
+85
View File
@@ -0,0 +1,85 @@
/**
* `hanzo kms` (also installed as the standalone `kms` command) — pull
* environment secrets from KMS for local dev, using your existing IAM login.
*
* kms pull [--env devnet] [--path /] [--out .env] write a dotenv file
* kms list [--env devnet] [--path /] names only (no values)
*
* Auth is your `hanzo login` session — one login, one token store. KMS decides
* what you may read per env; this is just the client.
*/
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'node:fs';
import { getConfig } from '../lib/config';
import { pullSecrets, KMS_ENVS, KMS_PROD_ENVS, KmsError } from '../lib/kms';
const DEFAULT_ENV = 'devnet';
export const kmsCmd = new Command('kms')
.description('Pull environment secrets from KMS for local dev (uses your hanzo login)');
kmsCmd
.command('pull')
.description('Fetch secrets for an environment and write a dotenv file')
.option('--env <env>', `Environment: ${KMS_ENVS.join(', ')}`, DEFAULT_ENV)
.option('--path <path>', 'Secret path', '/')
.option('--out <file>', 'Output dotenv file (use "-" for stdout)', '.env')
.action(async (opts: { env: string; path: string; out: string }) => {
const secrets = await fetchOrExit(opts.env, opts.path);
const body = toDotenv(secrets);
if (opts.out === '-') {
process.stdout.write(body);
return;
}
if (KMS_PROD_ENVS.has(opts.env)) {
console.log(chalk.yellow(` ! ${opts.env} secrets — handle with care; do not commit ${opts.out}.`));
}
fs.writeFileSync(opts.out, body, { mode: 0o600 });
console.log(chalk.green(` ✓ Wrote ${Object.keys(secrets).length} secrets to ${opts.out} (${opts.env})`));
});
kmsCmd
.command('list')
.description('List secret names for an environment (no values)')
.option('--env <env>', `Environment: ${KMS_ENVS.join(', ')}`, DEFAULT_ENV)
.option('--path <path>', 'Secret path', '/')
.action(async (opts: { env: string; path: string }) => {
const secrets = await fetchOrExit(opts.env, opts.path);
for (const k of Object.keys(secrets).sort()) console.log(` ${k}`);
console.log(chalk.dim(`\n ${Object.keys(secrets).length} secrets in ${opts.env}`));
});
async function fetchOrExit(env: string, path: string): Promise<Record<string, string>> {
const { accessToken } = await getConfig();
if (!accessToken) {
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
process.exit(1);
}
const spinner = ora(`Reading ${env} secrets…`).start();
try {
const secrets = await pullSecrets(accessToken, env, path);
spinner.stop();
return secrets;
} catch (err) {
spinner.fail(chalk.red(err instanceof KmsError || err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
/** Render a key→value map as a dotenv file, quoting only when needed. */
function toDotenv(secrets: Record<string, string>): string {
return (
Object.keys(secrets)
.sort()
.map((k) => `${k}=${quote(secrets[k]!)}`)
.join('\n') + '\n'
);
}
function quote(v: string): string {
return /[\s"'#=]/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v;
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* Standalone `kms` command — the same `hanzo kms` subcommand, surfaced as its
* own binary so `kms pull --env devnet` works directly. One implementation
* (commands/kms.ts), two entry points.
*/
import { kmsCmd } from './commands/kms';
import { version } from './lib/version';
kmsCmd.name('kms').version(version);
await kmsCmd.parseAsync(process.argv);
+95
View File
@@ -0,0 +1,95 @@
/**
* KMS client — pull environment secrets for local dev.
*
* One mechanism, shared with CI: an OIDC token (the dev's IAM login here; a
* GitHub OIDC token in Actions) is exchanged for a short-lived KMS token, which
* reads secrets for one environment. KMS owns secrets, envs, and authz; IAM
* only issues the token. They compose through the token — neither imports the
* other.
*
* Concerns stay in their own path namespace: KMS lives under `/v1/kms/*`, IAM
* under `/v1/iam/*`. The helper never crosses them.
*/
import { endpoints } from './endpoints';
/** KMS surface — KMS only, under its own `/v1/kms` namespace. */
const KMS_PATHS = {
oidcLogin: '/v1/kms/oidc/login',
secrets: '/v1/kms/secrets',
} as const;
/** Environments secrets are scoped by — the network/deploy axis. */
export const KMS_ENVS = ['devnet', 'testnet', 'mainnet', 'production'] as const;
export type KmsEnv = (typeof KMS_ENVS)[number];
/** Environments that may hold production material — gated server-side; named so
* the CLI can warn before writing them to a local file. */
export const KMS_PROD_ENVS: ReadonlySet<string> = new Set(['mainnet', 'production']);
/**
* Exchange a trusted OIDC token (the dev's IAM access token) for a short-lived
* KMS token. KMS validates it against the issuer's JWKS and applies its own
* env/path authorization.
*/
export async function kmsLogin(oidcToken: string): Promise<string> {
const res = await fetch(`${endpoints.api}${KMS_PATHS.oidcLogin}`, {
method: 'POST',
headers: { Authorization: `Bearer ${oidcToken}`, Accept: 'application/json' },
});
if (res.status === 401) {
throw new KmsError('KMS rejected your login. Run `hanzo login` to refresh, then retry.');
}
if (!res.ok) throw new KmsError(`KMS login failed (${res.status} ${res.statusText})`);
const body = (await res.json().catch(() => null)) as { token?: string; accessToken?: string } | null;
const token = body?.token ?? body?.accessToken;
if (!token) throw new KmsError('KMS did not return a token');
return token;
}
/** Fetch every secret for one environment as a flat key→value map. */
export async function kmsSecrets(kmsToken: string, env: string, path = '/'): Promise<Record<string, string>> {
const url = new URL(`${endpoints.api}${KMS_PATHS.secrets}`);
url.searchParams.set('env', env);
if (path && path !== '/') url.searchParams.set('path', path);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${kmsToken}`, Accept: 'application/json' },
});
if (!res.ok) throw new KmsError(`KMS secrets fetch failed (${res.status} ${res.statusText})`);
const body = (await res.json().catch(() => null)) as { secrets?: unknown } | null;
return normalizeSecrets(body?.secrets);
}
/** Login + fetch in one step — the common path. */
export async function pullSecrets(
oidcToken: string,
env: string,
path = '/'
): Promise<Record<string, string>> {
return kmsSecrets(await kmsLogin(oidcToken), env, path);
}
export class KmsError extends Error {}
/**
* Accept either shape KMS may return — a flat `{KEY: value}` map or an array of
* `{secretKey, secretValue}` (Infisical-style) — and flatten to `{KEY: value}`.
*/
function normalizeSecrets(secrets: unknown): Record<string, string> {
if (!secrets) return {};
if (Array.isArray(secrets)) {
const out: Record<string, string> = {};
for (const s of secrets as Array<Record<string, unknown>>) {
const k = (s.secretKey ?? s.key ?? s.name) as string | undefined;
const v = (s.secretValue ?? s.value) as string | undefined;
if (k != null && v != null) out[k] = String(v);
}
return out;
}
if (typeof secrets === 'object') {
return Object.fromEntries(
Object.entries(secrets as Record<string, unknown>).map(([k, v]) => [k, String(v)])
);
}
return {};
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/cli.ts'],
entry: ['src/index.ts', 'src/cli.ts', 'src/kms-cli.ts'],
format: ['esm'],
target: 'node22',
clean: true,