kms: use the canonical live KMS API, drop invented endpoints

KMS already serves the one-way secret surface (hanzoai/kms embed.go): the dev's
IAM login token goes straight to /v1/kms/orgs/{org}/secrets — KMS verifies it
against hanzo.id JWKS (authorize + canActOnOrg). No /v1/kms/oidc/login or
/v1/kms/get-secrets exchange (those were invented; they 404). One token, one way.

- lib/kms.ts: listSecrets + getSecret + pullSecrets over the real endpoints.
  Respects KMS's security boundary — list returns KEYS ONLY; values are read
  one-by-one (bounded concurrency), no bulk-value dump.
- commands/kms.ts: org comes from the login session (--org override); IAM token
  from config.accessToken. pull/list unchanged in UX.
- actions/kms-secrets: same canonical list-then-get; token input is an
  IAM-issued token KMS trusts (GitHub OIDC only if KMS trusts that issuer).
- npm-release.yml: pass org=hanzo + KMS_IAM_TOKEN.

Verified: GET /v1/kms/orgs/hanzo/secrets?env=devnet is LIVE (401, gated) on
api.hanzo.ai — endpoint exists, needs a real IAM token to list.
This commit is contained in:
2026-06-30 17:31:16 -07:00
parent 5abfd085e1
commit 29b6cd166f
4 changed files with 140 additions and 140 deletions
+35 -42
View File
@@ -1,27 +1,28 @@
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/get-secrets. No long-lived KMS credential in GitHub.
Load an org/env's secrets from KMS into the job, using the canonical live KMS
API (/v1/kms/orgs/{org}/secrets). The token is an IAM-issued OIDC token KMS
verifies against hanzo.id's JWKS — the same trust the `kms` CLI uses for local
dev. KMS lists keys, then reads each value (no bulk-value endpoint by design).
inputs:
org:
description: Organization slug (KMS scopes secrets by org).
required: true
env:
description: "Environment slug: devnet | testnet | mainnet | production."
required: false
default: production
path:
description: Secret path.
required: false
default: /
token:
description: >
IAM-issued OIDC/access token KMS trusts (hanzo.id issuer). Required —
the GitHub Actions OIDC token is only accepted if KMS is configured to
trust the GitHub issuer; otherwise pass an IAM machine-identity token.
required: true
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
@@ -37,42 +38,34 @@ runs:
- name: Load secrets from KMS
shell: bash
env:
KMS_ORG: ${{ inputs.org }}
KMS_ENV: ${{ inputs.env }}
KMS_PATH: ${{ inputs.path }}
KMS_TOKEN: ${{ inputs.token }}
KMS_BASE: ${{ inputs.base-url }}
KMS_AUDIENCE: ${{ inputs.audience }}
KMS_EXPORT: ${{ inputs.export }}
KMS_FILE: ${{ inputs.file }}
run: |
set -euo pipefail
secrets_url="${KMS_BASE}/v1/kms/orgs/${KMS_ORG}/secrets"
auth=(-H "Authorization: Bearer ${KMS_TOKEN}" -H "Accept: application/json")
# 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; }
# 1. List the env's secret keys (metadata only — no values).
names=$(curl -sf "${secrets_url}?env=${KMS_ENV}" "${auth[@]}" \
| jq -r '.secrets[] | [.path, .name] | @tsv')
# 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/get-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#*=}
[ "${KMS_EXPORT}" = "file" ] && : > "${KMS_FILE}"
count=0
# 2. Read each value; export or write it.
while IFS=$'\t' read -r path name; do
[ -z "$name" ] && continue
rest="$name"; [ -n "$path" ] && rest="${path}/${name}"
val=$(curl -sf "${secrets_url}/${rest}?env=${KMS_ENV}" "${auth[@]}" | jq -r '.secret.value')
if [ "${KMS_EXPORT}" = "file" ]; then
echo "${name}=${val}" >> "${KMS_FILE}"
else
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
echo "${name}=${val}" >> "$GITHUB_ENV"
fi
count=$((count+1))
done <<< "$names"
echo "Loaded ${count} secrets (${KMS_ORG}/${KMS_ENV})"
+4 -2
View File
@@ -56,11 +56,13 @@ jobs:
- if: ${{ inputs.build }}
run: pnpm typecheck && pnpm lint && pnpm build
# NPM_TOKEN → job env, via KMS over the shared OIDC mechanism.
# NPM_TOKEN → job env, from KMS via the canonical /v1/kms API. The token
# is an IAM machine-identity token KMS trusts (KMS_IAM_TOKEN secret).
- uses: hanzoai/helper/.github/actions/kms-secrets@main
with:
org: hanzo
env: ${{ inputs.kms-env }}
path: /npm
token: ${{ secrets.KMS_IAM_TOKEN }}
- name: Publish
run: npm publish --access public
+33 -20
View File
@@ -2,11 +2,12 @@
* `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)
* kms pull [--env devnet] [--org <org>] [--out .env] write a dotenv file
* kms list [--env devnet] [--org <org>] 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.
* Auth is your `hanzo login` session — one login, one token store. Your org
* comes from that login (override with --org). KMS decides what you may read
* per env; this is just the client.
*/
import { Command } from 'commander';
@@ -14,7 +15,7 @@ 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';
import { listSecrets, pullSecrets, KMS_ENVS, KMS_PROD_ENVS, KmsError } from '../lib/kms';
const DEFAULT_ENV = 'devnet';
@@ -25,10 +26,11 @@ 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('--org <org>', 'Organization (defaults to your signed-in org)')
.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);
.action(async (opts: { env: string; org?: string; out: string }) => {
const { token, org } = await session(opts.org);
const secrets = await run(`Reading ${opts.env} secrets…`, () => pullSecrets(token, org, opts.env));
const body = toDotenv(secrets);
if (opts.out === '-') {
@@ -39,31 +41,42 @@ kmsCmd
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})`));
console.log(chalk.green(` ✓ Wrote ${Object.keys(secrets).length} secrets to ${opts.out} (${org}/${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}`));
.option('--org <org>', 'Organization (defaults to your signed-in org)')
.action(async (opts: { env: string; org?: string }) => {
const { token, org } = await session(opts.org);
const metas = await run(`Listing ${opts.env} secrets…`, () => listSecrets(token, org, opts.env));
for (const m of metas.map((s) => s.name).sort()) console.log(` ${m}`);
console.log(chalk.dim(`\n ${metas.length} secrets in ${org}/${opts.env}`));
});
async function fetchOrExit(env: string, path: string): Promise<Record<string, string>> {
const { accessToken } = await getConfig();
if (!accessToken) {
/** Resolve the IAM token + org from the login session (org overridable). */
async function session(orgOverride?: string): Promise<{ token: string; org: string }> {
const cfg = await getConfig();
if (!cfg.accessToken) {
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
process.exit(1);
}
const spinner = ora(`Reading ${env} secrets…`).start();
const org = orgOverride ?? cfg.user?.org;
if (!org) {
console.error(chalk.red('No organization found. Pass --org <org> or run `hanzo login` again.'));
process.exit(1);
}
return { token: cfg.accessToken, org };
}
async function run<T>(label: string, fn: () => Promise<T>): Promise<T> {
const spinner = ora(label).start();
try {
const secrets = await pullSecrets(accessToken, env, path);
const result = await fn();
spinner.stop();
return secrets;
return result;
} catch (err) {
spinner.fail(chalk.red(err instanceof KmsError || err instanceof Error ? err.message : String(err)));
process.exit(1);
+68 -76
View File
@@ -1,96 +1,88 @@
/**
* 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 at /v1/kms/oidc/login for a
* short-lived KMS token, which reads an environment's secrets from
* /v1/kms/get-secrets. KMS owns secrets, envs, and authz; IAM only issues the
* token. They compose through the token — neither imports the other.
* Uses the canonical, already-live KMS API (KMS owns /v1/kms/*): the dev's IAM
* login token is presented directly — KMS verifies it against hanzo.id's JWKS
* (authorize + canActOnOrg), no separate exchange. One token, one way.
*
* Concerns stay in their own path namespace: KMS lives under `/v1/kms/*`, IAM
* under `/v1/iam/*`. The helper never crosses them.
* list GET /v1/kms/orgs/{org}/secrets?env=X → key metadata (no values)
* get GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env=X → one value
*
* By KMS's design the bulk list returns KEYS ONLY; values are read one at a
* time. `pullSecrets` composes the two: list the env, then read each value.
* IAM lives under /v1/iam/*, KMS under /v1/kms/* — the helper never crosses them.
*/
import { endpoints } from './endpoints';
/** KMS surface — KMS only, under its own `/v1/kms` namespace. Verb-noun names
* match the IAM house style (`/v1/iam/get-account`, `/v1/iam/get-users`). */
const KMS_PATHS = {
oidcLogin: '/v1/kms/oidc/login',
getSecrets: '/v1/kms/get-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. */
/** Environments that may hold production material — named so the CLI can warn
* before writing them to a local file (KMS still gates access server-side). */
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.getSecrets}`);
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 {}
/** One metadata row from the list endpoint — keys only, never a value. */
interface SecretMeta {
path: string;
name: string;
env: string;
}
const base = (org: string) => `${endpoints.api}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`;
const authFail = (res: Response, what: string): KmsError =>
res.status === 401 || res.status === 403
? new KmsError(`KMS denied ${what} (${res.status}). Run \`hanzo login\` to refresh, or check your org access.`)
: new KmsError(`KMS ${what} failed (${res.status} ${res.statusText})`);
/** List an env's secret keys (no values) for the org. */
export async function listSecrets(iamToken: string, org: string, env: string): Promise<SecretMeta[]> {
const url = new URL(base(org));
url.searchParams.set('env', env);
const res = await fetch(url, { headers: auth(iamToken) });
if (!res.ok) throw authFail(res, 'secret listing');
const body = (await res.json().catch(() => null)) as { secrets?: SecretMeta[] } | null;
return body?.secrets ?? [];
}
/** Read one secret's value. `path` may be empty; `name` is required. */
export async function getSecret(
iamToken: string,
org: string,
meta: SecretMeta,
env: string
): Promise<string> {
const rest = meta.path ? `${meta.path}/${meta.name}` : meta.name;
const url = new URL(`${base(org)}/${rest}`);
url.searchParams.set('env', env);
const res = await fetch(url, { headers: auth(iamToken) });
if (!res.ok) throw authFail(res, `read ${meta.name}`);
const body = (await res.json().catch(() => null)) as { secret?: { value?: string } } | null;
return body?.secret?.value ?? '';
}
/**
* Accept either shape KMS may return — a flat `{KEY: value}` map or an array of
* `{secretKey, secretValue}` (Infisical-style) — and flatten to `{KEY: value}`.
* Pull every secret for one environment as a flat name→value map. Lists the
* env's keys, then reads each value (KMS has no bulk-value endpoint by design).
* Reads run with bounded concurrency so a large env stays quick without
* hammering the service.
*/
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;
export async function pullSecrets(iamToken: string, org: string, env: string): Promise<Record<string, string>> {
const metas = await listSecrets(iamToken, org, env);
const out: Record<string, string> = {};
const CONCURRENCY = 8;
for (let i = 0; i < metas.length; i += CONCURRENCY) {
const batch = metas.slice(i, i + CONCURRENCY);
const values = await Promise.all(batch.map((m) => getSecret(iamToken, org, m, env)));
batch.forEach((m, j) => (out[m.name] = values[j]!));
}
if (typeof secrets === 'object') {
return Object.fromEntries(
Object.entries(secrets as Record<string, unknown>).map(([k, v]) => [k, String(v)])
);
}
return {};
return out;
}
function auth(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}`, Accept: 'application/json' };
}