| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- #!/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 <env> <root-dir> [<output-dir>]
- #
- # 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 <env> <root-dir> [<output-dir>]" >&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" <<EOF
- {
- "env": "$ENV_NAME",
- "rotated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
- "root": {
- "cert_pem": "$ROOT_CRT",
- "fingerprint_sha256": "$(openssl x509 -in "$ROOT_CRT" -noout -fingerprint -sha256 | cut -d'=' -f2)",
- "path": "$(realpath "$ROOT_CRT")"
- },
- "intermediate": {
- "key_pem": "$INT_KEY",
- "cert_pem": "$INT_CRT",
- "chain_pem": "$INT_CHAIN",
- "fingerprint_sha256": "$NEW_FP",
- "not_after": "$NEW_NOT_AFTER",
- "path": "$(realpath "$INT_CRT")"
- },
- "next_steps": [
- "1. Re-import the intermediate into K8s (sealed-secrets / external-secrets rotate).",
- "2. Restart cert-manager controller: kubectl rollout restart deploy/cert-manager -n cert-manager",
- "3. Re-issue serving certs: kubectl delete certificates --all -n broad-announce && kubectl apply -f deploy/cert-manager/serving-certs.yaml",
- "4. Run scripts/cert-manager/ca-verify.sh to confirm all certs valid."
- ]
- }
- EOF
- chmod 644 "$OUTPUT_DIR/ca-bundle.json"
- echo
- echo "=== done ==="
- echo "NEW INTERMEDIATE FINGERPRINT: $NEW_FP"
- echo "expires: $NEW_NOT_AFTER"
- echo
- echo "next: re-import to K8s and re-issue serving certs (see ca-bundle.json)"
|