Files
ci/bin/kms
T

112 lines
5.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# kms — read one secret from Hanzo KMS. One implementation, every caller.
#
# kms <NAME> [env] # prints the value, or exits non-zero
#
# STDOUT CARRIES THE VALUE AND NOTHING ELSE, byte for byte: callers capture it
# with $(...). Every message and workflow command goes to stderr.
#
# THE CREDENTIAL, in order: HANZO_API_TOKEN when a step before this one already
# holds the IAM bearer; else, on GitHub with `id-token: write`, the run's own
# Actions ID token, exchanged at IAM for the CI application its repository
# speaks for (RFC 7523) — nothing stored; else KMS_CLIENT_ID and
# KMS_CLIENT_SECRET, which only Hanzo Git runs still carry. KMS_ENDPOINT
# defaults to https://kms.hanzo.ai; both it and api.hanzo.ai are Ingresses onto
# the same cloud service, so the host is a preference and not a fallback.
#
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL, which is the same reason imgver is:
# three callers each carried their own copy — this repo's site action, this
# repo's build.yml, and hanzoai/gui's npm-token action. Written three times it
# was wrong in three different ways at once. Two of them piped the body straight
# into jq, so a gateway timeout died as "Invalid numeric literal at line 1,
# column 6" and one reported "NPM_TOKEN absent from KMS" about a sealed secret;
# the third retried only a lost connection, not a 5xx.
#
# THE READ IS FLAT. KMS resolves a name at the credential's own scope root, so
# `HANZO_DEPLOY_TOKEN` finds the value sealed for this org, while a
# path-qualified `deploy/HANZO_DEPLOY_TOKEN` finds nothing and reads exactly like
# an absent secret.
#
# ASK AGAIN ONLY WHERE ASKING AGAIN CAN HELP: nothing arrived, 429, or any 5xx —
# the network saying "not now". 401, 403 and 404 are KMS itself answering and are
# taken on the first try.
#
# THE SCHEDULE IS SHORT ON PURPOSE — 17 seconds over four tries. It exists to
# survive a dropped response, a 502 or a pod coming back, which are seconds. It
# does NOT exist to ride out a wedged store: that lasted fifteen minutes on
# 2026-08-24 and no schedule a build step can afford would have covered it. A
# minute of waiting before the same failure only makes the build slower and the
# test that pins this behaviour slower still.
#
# EVERY FAILURE NAMES WHICH OF THE THREE IT WAS — an outage, a refusal, or an
# unsealed secret — because the whole cost of the old copies was that they all
# looked alike in a log.
set -uo pipefail
name=${1:-}
env=${2:-prod}
endpoint=${KMS_ENDPOINT:-https://kms.hanzo.ai}
[ -n "$name" ] || { echo "::error::kms: usage: kms <NAME> [env]" >&2; exit 2; }
run=${ACTIONS_ID_TOKEN_REQUEST_URL:-}
[ -n "${HANZO_API_TOKEN:-}" ] || [ -n "$run" ] || { [ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ]; } || {
echo "::error::kms: no credential — on GitHub grant the job \`permissions: id-token: write\`; on git.hanzo.ai KMS_CLIENT_ID and KMS_CLIENT_SECRET are unset" >&2
exit 1
}
body=$(mktemp); trap 'rm -f "$body"' EXIT
# ask <what> <url> [curl args...] -> body on stdout when 2xx.
#
# `|| code=000` on the ASSIGNMENT, never `|| echo 000` inside the substitution:
# curl's -w already prints 000 when the request fails, so echoing a second one
# yields "000000", which matches no case below and turns an outage into a
# refusal that never retries.
ask() {
local what=$1 url=$2; shift 2
local code
for wait in 0 2 5 10; do
[ "$wait" = 0 ] || sleep "$wait"
code=$(curl -sS -m 30 -o "$body" -w '%{http_code}' "$url" "$@") || code=000
case "$code" in
2*) cat "$body"; return 0 ;;
000|429|5*) continue ;;
*) echo "::error::kms: $endpoint refused the $what: HTTP $code — $(head -c 300 "$body")" >&2; return 1 ;;
esac
done
echo "::error::kms: $endpoint did not answer the $what — HTTP $code over four tries: $(head -c 300 "$body"). An outage, not a missing secret." >&2
return 1
}
token=${HANZO_API_TOKEN:-}
if [ -z "$token" ] && [ -n "$run" ]; then
id=$(ask "ID token" "$run&audience=https://hanzo.id" -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" \
| jq -r '.value // empty' 2>/dev/null) || id=
if [ -n "$id" ]; then
# STDERR, like every other line this script writes that is not the value.
# The runner reads workflow commands from both streams, and stdout is the
# value the caller captures: a mask written there becomes the first line of
# the "secret", the caller's own add-mask then covers only that line, and the
# real secret is printed as the second.
echo "::add-mask::$id" >&2
token=$(ask exchange "$endpoint/v1/iam/oauth/token" \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion=$id" | jq -r '.access_token // empty' 2>/dev/null) || token=
fi
if [ -z "$token" ] && [ -z "${KMS_CLIENT_ID:-}" ]; then
echo "::error::kms: IAM gave this run no token at $endpoint/v1/iam/oauth/token" >&2; exit 1
fi
fi
if [ -z "$token" ]; then
token=$(ask login "$endpoint/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" \
| jq -r '.accessToken // empty' 2>/dev/null) || exit 1
[ -n "$token" ] || { echo "::error::kms: login returned no accessToken at $endpoint/v1/kms/auth/login" >&2; exit 1; }
fi
value=$(ask "secret $name" "$endpoint/v1/kms/secrets/$name?env=$env" \
-H "Authorization: Bearer $token" | jq -r '.value // empty' 2>/dev/null) || exit 1
[ -n "$value" ] || { echo "::error::kms: no value for $name at $endpoint/v1/kms/secrets/$name?env=$env — seal it there" >&2; exit 1; }
printf '%s' "$value"