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:
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user