#!/usr/bin/env bash # ca-rotate-intermediate.sh — Rotate the broad-announce intermediate CA # against the existing root. Run annually (~30 days before the # intermediate's not_after). The root key never moves. # # Usage: # scripts/cert-manager/ca-rotate-intermediate.sh [] # # Examples: # scripts/cert-manager/ca-rotate-intermediate.sh dev /secure/pki/dev # scripts/cert-manager/ca-rotate-intermediate.sh prod /secure/pki/prod /tmp/pki-new # # Args: # env dev, staging, prod (matches the dir name from ca-init.sh) # root-dir Directory containing root-ca.key + root-ca.crt (offline) # output-dir Where to write the new intermediate. Defaults to root-dir # (overwrites in place). If you want a side-by-side, pass # a different dir and swap manually. # # After this script: # - You have a new intermediate-ca.{key,crt,chain.pem} in output-dir. # - You need to: # 1. Update the K8s Secret holding the intermediate (sealed-secrets / # external-secrets rotate). # 2. Trigger cert-manager to re-issue all serving certs that # reference this CA (kubectl annotate certificate -n broad-announce # --all cert-manager.io/issue-temporary-certificate=true, or # delete+recreate the Certificate CRs). # 3. Verify the rotation with scripts/cert-manager/ca-verify.sh. # # Safety: this script does NOT touch the root key. If the root itself # needs rotation (every 10 years, or compromise), use ca-rotate-root.sh # (separate runbook — see docs/runbooks/mtls-incident.md). # # Requires: openssl 3.x, jq, the existing root-ca.key + root-ca.crt. set -euo pipefail ENV_NAME="${1:-}" ROOT_DIR="${2:-}" OUTPUT_DIR="${3:-$ROOT_DIR}" if [[ -z "$ENV_NAME" || -z "$ROOT_DIR" ]]; then echo "usage: $0 []" >&2 exit 1 fi for bin in openssl jq; do if ! command -v "$bin" >/dev/null 2>&1; then echo "FATAL: $bin not found in PATH" >&2 exit 1 fi done ROOT_KEY="$ROOT_DIR/root-ca.key" ROOT_CRT="$ROOT_DIR/root-ca.crt" if [[ ! -f "$ROOT_KEY" || ! -f "$ROOT_CRT" ]]; then echo "FATAL: root-ca.key or root-ca.crt missing in $ROOT_DIR" >&2 exit 1 fi mkdir -p "$OUTPUT_DIR" chmod 700 "$OUTPUT_DIR" # Passphrase handling — same as ca-init.sh if [[ -n "${BA_CA_PASSPHRASE:-}" ]]; then ROOT_PASS_ARGS=(-passin "env:BA_CA_PASSPHRASE") else ROOT_PASS_ARGS=() fi # Check current intermediate expiry — refuse to rotate if not within 30d of expiry CURRENT_INT_CRT="$ROOT_DIR/intermediate-ca.crt" if [[ -f "$CURRENT_INT_CRT" ]]; then NOT_AFTER_EPOCH=$(openssl x509 -in "$CURRENT_INT_CRT" -noout -enddate | cut -d'=' -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null || openssl x509 -in "$CURRENT_INT_CRT" -noout -enddate | cut -d'=' -f2 | xargs -I{} date -j -f "%b %d %H:%M:%S %Y %Z" "{}" +%s) NOW_EPOCH=$(date +%s) DAYS_LEFT=$(( (NOT_AFTER_EPOCH - NOW_EPOCH) / 86400 )) echo "current intermediate expires in $DAYS_LEFT days" if [[ $DAYS_LEFT -gt 60 && "${ROTATE_FORCE:-}" != "1" ]]; then echo "FATAL: refusing to rotate more than 60 days before expiry" >&2 echo " current: $DAYS_LEFT days left" >&2 echo " re-run closer to expiry, or set ROTATE_FORCE=1 (DANGEROUS)" >&2 exit 1 fi fi INT_KEY="$OUTPUT_DIR/intermediate-ca.key" INT_CRT="$OUTPUT_DIR/intermediate-ca.crt" INT_CSR="$OUTPUT_DIR/intermediate-ca.csr" INT_CHAIN="$OUTPUT_DIR/intermediate-ca-chain.pem" INT_BACKUP="$OUTPUT_DIR/intermediate-ca.previous.$(date -u +%Y%m%d).pem" # Back up the current intermediate (if any) before overwriting if [[ -f "$INT_CRT" && -f "$INT_KEY" && "$OUTPUT_DIR" == "$ROOT_DIR" ]]; then echo "backing up current intermediate to $INT_BACKUP" cp "$INT_CRT" "$INT_BACKUP" chmod 644 "$INT_BACKUP" fi echo "=== broad-announce intermediate CA rotation ===" echo "env: $ENV_NAME" echo "root dir: $ROOT_DIR" echo "output dir: $OUTPUT_DIR" echo echo "[1/4] Generating new intermediate CA key (ECDSA P-256)..." openssl ecparam -name prime256v1 -genkey -noout -out "$INT_KEY" chmod 600 "$INT_KEY" echo "[2/4] Creating CSR..." openssl req -new -sha256 -key "$INT_KEY" \ -subj "/CN=broad-announce Intermediate CA ($ENV_NAME)/O=broad-announce/OU=PKI" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign,digitalSignature" \ -addext "extendedKeyUsage=serverAuth,clientAuth" \ -addext "subjectKeyIdentifier=hash" \ -out "$INT_CSR" echo "[3/4] Signing new intermediate with root (1y)..." openssl x509 -req -in "$INT_CSR" -CA "$ROOT_CRT" -CAkey "$ROOT_KEY" "${ROOT_PASS_ARGS[@]}" \ -CAcreateserial -sha384 -days 365 \ -extfile <(cat <<'EOF' basicConstraints=critical,CA:TRUE,pathlen:0 keyUsage=critical,keyCertSign,cRLSign,digitalSignature extendedKeyUsage=serverAuth,clientAuth subjectKeyIdentifier=hash authorityKeyIdentifier=keyid:always EOF ) \ -out "$INT_CRT" chmod 644 "$INT_CRT" cat "$INT_CRT" "$ROOT_CRT" > "$INT_CHAIN" chmod 644 "$INT_CHAIN" echo "[4/4] Verifying new chain..." openssl verify -CAfile "$ROOT_CRT" "$INT_CRT" NEW_FP=$(openssl x509 -in "$INT_CRT" -noout -fingerprint -sha256 | cut -d'=' -f2) NEW_NOT_AFTER=$(openssl x509 -in "$INT_CRT" -noout -enddate | cut -d'=' -f2) cat > "$OUTPUT_DIR/ca-bundle.json" <