ca-rotate-intermediate.sh 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. #!/usr/bin/env bash
  2. # ca-rotate-intermediate.sh — Rotate the broad-announce intermediate CA
  3. # against the existing root. Run annually (~30 days before the
  4. # intermediate's not_after). The root key never moves.
  5. #
  6. # Usage:
  7. # scripts/cert-manager/ca-rotate-intermediate.sh <env> <root-dir> [<output-dir>]
  8. #
  9. # Examples:
  10. # scripts/cert-manager/ca-rotate-intermediate.sh dev /secure/pki/dev
  11. # scripts/cert-manager/ca-rotate-intermediate.sh prod /secure/pki/prod /tmp/pki-new
  12. #
  13. # Args:
  14. # env dev, staging, prod (matches the dir name from ca-init.sh)
  15. # root-dir Directory containing root-ca.key + root-ca.crt (offline)
  16. # output-dir Where to write the new intermediate. Defaults to root-dir
  17. # (overwrites in place). If you want a side-by-side, pass
  18. # a different dir and swap manually.
  19. #
  20. # After this script:
  21. # - You have a new intermediate-ca.{key,crt,chain.pem} in output-dir.
  22. # - You need to:
  23. # 1. Update the K8s Secret holding the intermediate (sealed-secrets /
  24. # external-secrets rotate).
  25. # 2. Trigger cert-manager to re-issue all serving certs that
  26. # reference this CA (kubectl annotate certificate -n broad-announce
  27. # --all cert-manager.io/issue-temporary-certificate=true, or
  28. # delete+recreate the Certificate CRs).
  29. # 3. Verify the rotation with scripts/cert-manager/ca-verify.sh.
  30. #
  31. # Safety: this script does NOT touch the root key. If the root itself
  32. # needs rotation (every 10 years, or compromise), use ca-rotate-root.sh
  33. # (separate runbook — see docs/runbooks/mtls-incident.md).
  34. #
  35. # Requires: openssl 3.x, jq, the existing root-ca.key + root-ca.crt.
  36. set -euo pipefail
  37. ENV_NAME="${1:-}"
  38. ROOT_DIR="${2:-}"
  39. OUTPUT_DIR="${3:-$ROOT_DIR}"
  40. if [[ -z "$ENV_NAME" || -z "$ROOT_DIR" ]]; then
  41. echo "usage: $0 <env> <root-dir> [<output-dir>]" >&2
  42. exit 1
  43. fi
  44. for bin in openssl jq; do
  45. if ! command -v "$bin" >/dev/null 2>&1; then
  46. echo "FATAL: $bin not found in PATH" >&2
  47. exit 1
  48. fi
  49. done
  50. ROOT_KEY="$ROOT_DIR/root-ca.key"
  51. ROOT_CRT="$ROOT_DIR/root-ca.crt"
  52. if [[ ! -f "$ROOT_KEY" || ! -f "$ROOT_CRT" ]]; then
  53. echo "FATAL: root-ca.key or root-ca.crt missing in $ROOT_DIR" >&2
  54. exit 1
  55. fi
  56. mkdir -p "$OUTPUT_DIR"
  57. chmod 700 "$OUTPUT_DIR"
  58. # Passphrase handling — same as ca-init.sh
  59. if [[ -n "${BA_CA_PASSPHRASE:-}" ]]; then
  60. ROOT_PASS_ARGS=(-passin "env:BA_CA_PASSPHRASE")
  61. else
  62. ROOT_PASS_ARGS=()
  63. fi
  64. # Check current intermediate expiry — refuse to rotate if not within 30d of expiry
  65. CURRENT_INT_CRT="$ROOT_DIR/intermediate-ca.crt"
  66. if [[ -f "$CURRENT_INT_CRT" ]]; then
  67. 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)
  68. NOW_EPOCH=$(date +%s)
  69. DAYS_LEFT=$(( (NOT_AFTER_EPOCH - NOW_EPOCH) / 86400 ))
  70. echo "current intermediate expires in $DAYS_LEFT days"
  71. if [[ $DAYS_LEFT -gt 60 && "${ROTATE_FORCE:-}" != "1" ]]; then
  72. echo "FATAL: refusing to rotate more than 60 days before expiry" >&2
  73. echo " current: $DAYS_LEFT days left" >&2
  74. echo " re-run closer to expiry, or set ROTATE_FORCE=1 (DANGEROUS)" >&2
  75. exit 1
  76. fi
  77. fi
  78. INT_KEY="$OUTPUT_DIR/intermediate-ca.key"
  79. INT_CRT="$OUTPUT_DIR/intermediate-ca.crt"
  80. INT_CSR="$OUTPUT_DIR/intermediate-ca.csr"
  81. INT_CHAIN="$OUTPUT_DIR/intermediate-ca-chain.pem"
  82. INT_BACKUP="$OUTPUT_DIR/intermediate-ca.previous.$(date -u +%Y%m%d).pem"
  83. # Back up the current intermediate (if any) before overwriting
  84. if [[ -f "$INT_CRT" && -f "$INT_KEY" && "$OUTPUT_DIR" == "$ROOT_DIR" ]]; then
  85. echo "backing up current intermediate to $INT_BACKUP"
  86. cp "$INT_CRT" "$INT_BACKUP"
  87. chmod 644 "$INT_BACKUP"
  88. fi
  89. echo "=== broad-announce intermediate CA rotation ==="
  90. echo "env: $ENV_NAME"
  91. echo "root dir: $ROOT_DIR"
  92. echo "output dir: $OUTPUT_DIR"
  93. echo
  94. echo "[1/4] Generating new intermediate CA key (ECDSA P-256)..."
  95. openssl ecparam -name prime256v1 -genkey -noout -out "$INT_KEY"
  96. chmod 600 "$INT_KEY"
  97. echo "[2/4] Creating CSR..."
  98. openssl req -new -sha256 -key "$INT_KEY" \
  99. -subj "/CN=broad-announce Intermediate CA ($ENV_NAME)/O=broad-announce/OU=PKI" \
  100. -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  101. -addext "keyUsage=critical,keyCertSign,cRLSign,digitalSignature" \
  102. -addext "extendedKeyUsage=serverAuth,clientAuth" \
  103. -addext "subjectKeyIdentifier=hash" \
  104. -out "$INT_CSR"
  105. echo "[3/4] Signing new intermediate with root (1y)..."
  106. openssl x509 -req -in "$INT_CSR" -CA "$ROOT_CRT" -CAkey "$ROOT_KEY" "${ROOT_PASS_ARGS[@]}" \
  107. -CAcreateserial -sha384 -days 365 \
  108. -extfile <(cat <<'EOF'
  109. basicConstraints=critical,CA:TRUE,pathlen:0
  110. keyUsage=critical,keyCertSign,cRLSign,digitalSignature
  111. extendedKeyUsage=serverAuth,clientAuth
  112. subjectKeyIdentifier=hash
  113. authorityKeyIdentifier=keyid:always
  114. EOF
  115. ) \
  116. -out "$INT_CRT"
  117. chmod 644 "$INT_CRT"
  118. cat "$INT_CRT" "$ROOT_CRT" > "$INT_CHAIN"
  119. chmod 644 "$INT_CHAIN"
  120. echo "[4/4] Verifying new chain..."
  121. openssl verify -CAfile "$ROOT_CRT" "$INT_CRT"
  122. NEW_FP=$(openssl x509 -in "$INT_CRT" -noout -fingerprint -sha256 | cut -d'=' -f2)
  123. NEW_NOT_AFTER=$(openssl x509 -in "$INT_CRT" -noout -enddate | cut -d'=' -f2)
  124. cat > "$OUTPUT_DIR/ca-bundle.json" <<EOF
  125. {
  126. "env": "$ENV_NAME",
  127. "rotated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  128. "root": {
  129. "cert_pem": "$ROOT_CRT",
  130. "fingerprint_sha256": "$(openssl x509 -in "$ROOT_CRT" -noout -fingerprint -sha256 | cut -d'=' -f2)",
  131. "path": "$(realpath "$ROOT_CRT")"
  132. },
  133. "intermediate": {
  134. "key_pem": "$INT_KEY",
  135. "cert_pem": "$INT_CRT",
  136. "chain_pem": "$INT_CHAIN",
  137. "fingerprint_sha256": "$NEW_FP",
  138. "not_after": "$NEW_NOT_AFTER",
  139. "path": "$(realpath "$INT_CRT")"
  140. },
  141. "next_steps": [
  142. "1. Re-import the intermediate into K8s (sealed-secrets / external-secrets rotate).",
  143. "2. Restart cert-manager controller: kubectl rollout restart deploy/cert-manager -n cert-manager",
  144. "3. Re-issue serving certs: kubectl delete certificates --all -n broad-announce && kubectl apply -f deploy/cert-manager/serving-certs.yaml",
  145. "4. Run scripts/cert-manager/ca-verify.sh to confirm all certs valid."
  146. ]
  147. }
  148. EOF
  149. chmod 644 "$OUTPUT_DIR/ca-bundle.json"
  150. echo
  151. echo "=== done ==="
  152. echo "NEW INTERMEDIATE FINGERPRINT: $NEW_FP"
  153. echo "expires: $NEW_NOT_AFTER"
  154. echo
  155. echo "next: re-import to K8s and re-issue serving certs (see ca-bundle.json)"