#!/usr/bin/env bash # creds-rotate.sh — guided or non-interactive rotation for a single net-kingdom credential. # # Usage: # SECRET= bash sso-mfa/bootstrap/creds-rotate.sh [secrets-dir] # make creds-rotate SECRET= # # # Agent / non-interactive mode (NK-WP-0005): # SECRET= bash sso-mfa/bootstrap/creds-rotate.sh --non-interactive [secrets-dir] # # The script: # 1. Validates the secret name # 2. Prints rotation impact and required coordination steps # 3. Generates a new value (same entropy as original) # 4. Guides through the atomic update sequence (interactive) or runs it # automatically (--non-interactive) # 5. After completion, updates creds-state.yaml set +x set -euo pipefail umask 077 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" STATE_FILE="$SCRIPT_DIR/creds-state.yaml" K8S_DIR="$REPO_ROOT/sso-mfa/k8s" NON_INTERACTIVE=false POSITIONAL_ARGS=() for arg in "$@"; do if [[ "$arg" == "--non-interactive" ]]; then NON_INTERACTIVE=true elif [[ "$arg" == --* ]]; then echo "ERROR: unknown rotation option." >&2 exit 1 else POSITIONAL_ARGS+=("$arg") fi done if (( ${#POSITIONAL_ARGS[@]} > 1 )); then echo "ERROR: expected one private secrets directory." >&2 exit 1 fi SECRETS_DIR="$(realpath -sm -- "${POSITIONAL_ARGS[0]:-$SCRIPT_DIR/secrets}")" SECRET="${SECRET:-}" rnd_hex() { openssl rand -hex "$1"; } rnd_b64() { openssl rand -base64 "$1" | tr -d '\n/+=' | head -c "$2"; } # Suppress all child diagnostics: service tools may echo credentials on failure. quiet() { if ! "$@" >/dev/null 2>&1; then echo "ERROR: rotation step failed; inspect service state through approved metadata-only tooling." >&2 echo "The private local file may contain the candidate value; rotation is not marked complete." >&2 return 1 fi } prepare_value() { local component="$1" format="$2" ENV_FILE="$SECRETS_DIR/$component/secrets.env" python3 "$SCRIPT_DIR/rotation-env.py" check "$SECRETS_DIR" "$component" "$SECRET" if [[ "$format" == hex ]]; then NEW_VAL="$(rnd_hex 32 2>/dev/null)" else NEW_VAL="$(rnd_b64 32 40 2>/dev/null)"; fi echo "Candidate generated; values are never printed." } store_value() { printf '%s' "$NEW_VAL" | python3 "$SCRIPT_DIR/rotation-env.py" update "$SECRETS_DIR" "$1" "$SECRET" echo "Candidate stored in the selected private service secrets.env file." } confirm() { local prompt="${1:-Continue?}" if [[ "$NON_INTERACTIVE" == true ]]; then echo " [non-interactive] auto-confirming: $prompt" return 0 fi echo "" read -rp "$prompt [y/N] " ans [[ "${ans,,}" == "y" ]] } header() { echo "" echo "════════════════════════════════════════════════════════" echo " Rotating: $SECRET$([ "$NON_INTERACTIVE" = true ] && echo " (non-interactive)" || true)" echo "════════════════════════════════════════════════════════" } update_last_rotated() { local key="$1" if [[ -f "$STATE_FILE" ]]; then # Update or append last_rotated_ in creds-state.yaml local ts ts="$(date -Iseconds)" if grep -qE "^last_rotated_${key}:" "$STATE_FILE"; then sed -i "s|^last_rotated_${key}: .*|last_rotated_${key}: \"${ts}\"|" "$STATE_FILE" else echo "last_rotated_${key}: \"${ts}\"" >> "$STATE_FILE" fi echo " [state] last_rotated_${key} → ${ts}" fi } post_rotation_reminder() { echo "" echo "Complete native authentication/consumer verification and custody handoff before removing local plaintext." if [[ "$NON_INTERACTIVE" == false ]]; then echo "Post-rotation checklist:" echo " - Transfer the candidate from the private local file into approved custody" echo " - Run: make creds-bundle (refresh offsite backup)" echo " - Run: make creds-verify (confirm cluster state)" else echo "Post-rotation (agent mode):" echo " - Script steps succeeded; this timestamp is not native credential validation" echo " Run: make creds-bundle (refresh offsite backup)" echo " Run: make creds-verify (confirm cluster state)" fi } # ── Dispatch ────────────────────────────────────────────────────────────────── case "$SECRET" in PI_SECRET_KEY) header echo "" echo "Impact: Flask/privacyIDEA app secret — rotates all active PI sessions." echo " All privacyIDEA users will be logged out." echo "" prepare_value privacyidea hex confirm "Apply rotation?" || exit 0 echo "" store_value privacyidea echo " 1. Updating K8s Secret privacyidea-config (namespace: mfa)..." (cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET") echo " 2. Restarting privacyIDEA pod..." quiet kubectl rollout restart deployment privacyidea -n mfa quiet kubectl rollout status deployment privacyidea -n mfa update_last_rotated "PI_SECRET_KEY" post_rotation_reminder ;; PI_PEPPER) header echo "" echo " ⚠ WARNING: PI_PEPPER CANNOT BE ROTATED without re-hashing all privacyIDEA" echo " user passwords. This is a major destructive operation." echo "" echo " Treat PI_PEPPER as permanent. If it is compromised:" echo " 1. Rotate all user credentials in privacyIDEA" echo " 2. Re-enroll all TOTP tokens" echo " 3. Contact affected users" echo "" echo " This script will NOT automate PI_PEPPER rotation." exit 1 ;; PI_DB_PASSWORD) header echo "" echo "Impact: privacyIDEA database password — coordinated updates are required in:" echo " 1. PostgreSQL (CNPG) — ALTER USER privacyidea WITH PASSWORD '...';" echo " 2. CNPG application Secret and privacyidea-config (PI_SQLALCHEMY_DATABASE_URI)" echo " These are separate steps; preserve the candidate and reconcile any partial failure." echo "" prepare_value privacyidea b64 confirm "Apply rotation?" || exit 0 echo "" echo " 1. Locating PostgreSQL before changing the private file..." if ! PG_POD=$(command kubectl get pod -n databases -l postgresql=net-kingdom-pg -o name 2>/dev/null); then echo "ERROR: PostgreSQL discovery failed; rotation stopped." >&2 exit 1 fi # Ambiguous discovery is not permission to change an arbitrary database pod. if [[ ! "$PG_POD" =~ ^pod/[a-z0-9][a-z0-9.-]*$ ]]; then echo "ERROR: expected exactly one PostgreSQL pod; rotation stopped before mutation." >&2 exit 1 fi store_value privacyidea printf "ALTER USER privacyidea WITH PASSWORD '%s';\n" "$NEW_VAL" | \ quiet kubectl exec -i -n databases "$PG_POD" -- psql -U postgres -v ON_ERROR_STOP=1 echo " PostgreSQL password update command succeeded." echo "" echo " 2. Updating PostgreSQL application credential Secret and privacyidea-config..." (cd "$K8S_DIR/postgresql" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET") (cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET") echo "" echo " 3. Restarting privacyIDEA pod..." quiet kubectl rollout restart deployment privacyidea -n mfa quiet kubectl rollout status deployment privacyidea -n mfa update_last_rotated "PI_DB_PASSWORD" post_rotation_reminder ;; LLDAP_JWT_SECRET) header echo "" echo "Impact: LLDAP JWT signing key — all LLDAP sessions will be invalidated." echo "" prepare_value lldap hex confirm "Apply rotation?" || exit 0 store_value lldap echo " Updating K8s Secret lldap-secrets (namespace: sso)..." (cd "$K8S_DIR/lldap" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET") echo " Restarting LLDAP pod..." quiet kubectl rollout restart deployment lldap -n sso quiet kubectl rollout status deployment lldap -n sso update_last_rotated "LLDAP_JWT_SECRET" post_rotation_reminder ;; LLDAP_LDAP_USER_PASS) echo "ERROR: coordinated LDAP/Authelia/KeyCape rotation requires an owner-reviewed procedure with rollback and native acceptance (NK-WP-0049-T02)." >&2 echo "The legacy manual-server/full-config-replacement path is retired before any credential changes." >&2 exit 1 ;; AUTHELIA_SESSION_SECRET) header echo "" echo "Impact: Authelia session cookie encryption key." echo " All active Authelia sessions will be invalidated (users re-prompted)." echo "" prepare_value authelia hex confirm "Apply rotation?" || exit 0 store_value authelia (cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET") quiet kubectl rollout restart deployment authelia -n sso quiet kubectl rollout status deployment authelia -n sso update_last_rotated "AUTHELIA_SESSION_SECRET" post_rotation_reminder ;; AUTHELIA_KEYCAPE_CLIENT_SECRET) echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2 echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2 exit 1 ;; KEYCAPE_RSA_KEY) echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2 echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2 exit 1 ;; BREAKGLASS_PASSWORD) header echo "" echo "Impact: break-glass emergency access; verify the current recovery consumer after rotation." echo "" prepare_value breakglass b64 confirm "Apply rotation?" || exit 0 store_value breakglass echo " Updating break-glass K8s Secret (namespace: sso)..." BG_SECRET="break-glass" if ! command kubectl create secret generic "$BG_SECRET" \ --namespace=sso \ --from-env-file="$ENV_FILE" \ --dry-run=client -o yaml 2>/dev/null | \ command kubectl apply --server-side --field-manager=netkingdom-rotation -f - >/dev/null 2>&1; then echo "ERROR: break-glass Secret update failed; local candidate retained, rotation not marked complete." >&2 exit 1 fi echo "" if [[ "$NON_INTERACTIVE" == false ]]; then echo " Update your password manager: net-kingdom/Break-glass/break-glass" fi update_last_rotated "BREAKGLASS_PASSWORD" post_rotation_reminder ;; *) echo "ERROR: unknown secret selector." echo "" echo "Known secrets:" echo " PI_SECRET_KEY Flask/PI app secret (session rotation)" echo " PI_PEPPER Password hashing pepper (PERMANENT — read help)" echo " PI_DB_PASSWORD privacyIDEA database password" echo " LLDAP_JWT_SECRET LLDAP JWT signing key" echo " LLDAP_LDAP_USER_PASS ld-admin, LLDAP configured super-admin (refused here)" echo " AUTHELIA_SESSION_SECRET Authelia session cookie key" echo " AUTHELIA_KEYCAPE_CLIENT_SECRET Authelia↔KeyCape OIDC client secret" echo " KEYCAPE_RSA_KEY KeyCape JWT signing key (causes auth outage)" echo " BREAKGLASS_PASSWORD Break-glass emergency password" echo "" echo "Usage: make creds-rotate SECRET=" exit 1 ;; esac