Some checks failed
Custody gains LLDAP_SVC_{AUTHELIA,KEYCAPE,PRIVACYIDEA,PROVISIONER}_PASS and
LLDAP_LDAP_USER_PASS becomes ld-admin's. Authelia, KeyCape, the privacyIDEA
resolver and identity-provisioner (new Secret identity-provisioner-ldap) switch
to svc-* accounts; LLDAP's configured super-admin is ld-admin. The LLDAP image
pin waits for the running digest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
292 lines
12 KiB
Bash
Executable file
292 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# creds-rotate.sh — guided or non-interactive rotation for a single net-kingdom credential.
|
|
#
|
|
# Usage:
|
|
# SECRET=<name> bash sso-mfa/bootstrap/creds-rotate.sh [secrets-dir]
|
|
# make creds-rotate SECRET=<name>
|
|
#
|
|
# # Agent / non-interactive mode (NK-WP-0005):
|
|
# SECRET=<name> 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_<key> 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=<name>"
|
|
exit 1
|
|
;;
|
|
|
|
esac
|