#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: key-rotation.sh --authority-url URL --api-key TOKEN --key-id ID --key-path PATH [options] Required flags: -u, --authority-url Base Authority URL (e.g. https://authority.example.com) -k, --api-key Bootstrap API key (x-stellaops-bootstrap-key header) -i, --key-id Identifier (kid) for the new signing key -p, --key-path Path (relative to Authority content root or absolute) where the PEM key lives Optional flags: -s, --source Key source loader identifier (default: file) -a, --algorithm Signing algorithm (default: ES256) --provider Preferred crypto provider name -m, --meta key=value Additional metadata entries for the rotation record (repeatable) --dry-run Print the JSON payload instead of invoking the API -h, --help Show this help Environment fallbacks: AUTHORITY_URL, AUTHORITY_BOOTSTRAP_KEY, AUTHORITY_KEY_SOURCE, AUTHORITY_KEY_PROVIDER Example: AUTHORITY_BOOTSTRAP_KEY=$(cat key.txt) \\ ./key-rotation.sh -u https://authority.local \\ -i authority-signing-2025 \\ -p ../certificates/authority-signing-2025.pem \\ -m rotatedBy=pipeline -m ticket=OPS-1234 USAGE } require_python() { if command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3 elif command -v python >/dev/null 2>&1; then PYTHON_BIN=python else echo "error: python3 (or python) is required for JSON encoding" >&2 exit 1 fi } json_quote() { "$PYTHON_BIN" - "$1" <<'PY' import json, sys print(json.dumps(sys.argv[1])) PY } AUTHORITY_URL="${AUTHORITY_URL:-}" API_KEY="${AUTHORITY_BOOTSTRAP_KEY:-}" KEY_ID="" KEY_PATH="" SOURCE="${AUTHORITY_KEY_SOURCE:-file}" ALGORITHM="ES256" PROVIDER="${AUTHORITY_KEY_PROVIDER:-}" DRY_RUN=false declare -a METADATA=() while [[ $# -gt 0 ]]; do case "$1" in -u|--authority-url) AUTHORITY_URL="$2" shift 2 ;; -k|--api-key) API_KEY="$2" shift 2 ;; -i|--key-id) KEY_ID="$2" shift 2 ;; -p|--key-path) KEY_PATH="$2" shift 2 ;; -s|--source) SOURCE="$2" shift 2 ;; -a|--algorithm) ALGORITHM="$2" shift 2 ;; --provider) PROVIDER="$2" shift 2 ;; -m|--meta) METADATA+=("$2") shift 2 ;; --dry-run) DRY_RUN=true shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage exit 1 ;; esac done if [[ -z "$AUTHORITY_URL" || -z "$API_KEY" || -z "$KEY_ID" || -z "$KEY_PATH" ]]; then echo "error: missing required arguments" >&2 usage exit 1 fi case "$AUTHORITY_URL" in http://*|https://*) ;; *) echo "error: --authority-url must include scheme (http/https)" >&2 exit 1 ;; esac require_python payload="{" payload+="\"keyId\":$(json_quote "$KEY_ID")," payload+="\"location\":$(json_quote "$KEY_PATH")," payload+="\"source\":$(json_quote "$SOURCE")," payload+="\"algorithm\":$(json_quote "$ALGORITHM")," if [[ -n "$PROVIDER" ]]; then payload+="\"provider\":$(json_quote "$PROVIDER")," fi if [[ ${#METADATA[@]} -gt 0 ]]; then payload+="\"metadata\":{" for entry in "${METADATA[@]}"; do if [[ "$entry" != *=* ]]; then echo "warning: ignoring metadata entry '$entry' (expected key=value)" >&2 continue fi key="${entry%%=*}" value="${entry#*=}" payload+="$(json_quote "$key"):$(json_quote "$value")," done if [[ "${payload: -1}" == "," ]]; then payload="${payload::-1}" fi payload+="}," fi if [[ "${payload: -1}" == "," ]]; then payload="${payload::-1}" fi payload+="}" if [[ "$DRY_RUN" == true ]]; then echo "# Dry run payload:" echo "$payload" exit 0 fi tmp_response="$(mktemp)" cleanup() { rm -f "$tmp_response"; } trap cleanup EXIT http_code=$(curl -sS -o "$tmp_response" -w "%{http_code}" \ -X POST "${AUTHORITY_URL%/}/internal/signing/rotate" \ -H "Content-Type: application/json" \ -H "x-stellaops-bootstrap-key: $API_KEY" \ --data "$payload") if [[ "$http_code" != "200" && "$http_code" != "201" ]]; then echo "error: rotation API returned HTTP $http_code" >&2 cat "$tmp_response" >&2 || true exit 1 fi echo "Rotation request accepted (HTTP $http_code). Response:" cat "$tmp_response" echo echo "Fetching JWKS to confirm active key..." curl -sS "${AUTHORITY_URL%/}/jwks" || true echo echo "Done. Remember to update authority.yaml with the new key metadata to keep restarts consistent."