Files
net-kingdom/sso-mfa/bootstrap/creds-rotate.sh
Bernd Worsch 95656f2324 feat(creds): NK-WP-0005 — agent-driven credential bootstrap
Implements all 7 tasks from NK-WP-0005:

T01: creds-state.yaml → schema_version: 2, agent_mode: true
     Replaces keepass_confirmed with emergency_bundle_delivered,
     adds phase tracking fields for fully automated flow.

T02: creds-bootstrap-agent.sh — single entrypoint for autonomous
     bootstrap. 10 phases, idempotent re-runs via state file.
     Only human touchpoint: emergency bundle confirmation gate.

T03: emergency-bundle.sh — assembles and displays emergency bundle
     (age key + break-glass passwords + ops bundle location).
     Writes temp file, shreds on confirmation, clears screen.
     Supports --reprint for re-delivery.

T04: ~/.claude/commands/creds-init.md — /creds-init skill replaces
     /creds-bootstrap. Fully autonomous execution via the agent.

T05: Makefile — creds-agent-init, creds-agent-status,
     creds-emergency-reprint targets.

T06: creds-rotate.sh — --non-interactive flag for agent-driven
     rotation. Auto-confirms all gates; tracks last_rotated_<key>
     in creds-state.yaml. LLDAP web UI step prints warning in
     non-interactive mode.

T07: canon/standards/credential-management_v0.2.md — updated
     standard: KeePassXC removed from operational path, agent
     bootstrap as Phase 0, emergency bundle section, prohibited
     patterns updated.

Also: creds-status.sh handles both schema v1 (legacy) and v2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:38:52 +00:00

356 lines
14 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 -euo pipefail
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
else
POSITIONAL_ARGS+=("$arg")
fi
done
SECRETS_DIR="${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"; }
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 ""
if [[ "$NON_INTERACTIVE" == false ]]; then
echo "Post-rotation checklist:"
echo " ✓ Update your personal password manager entry for this secret"
echo " ✓ Run: make creds-bundle (refresh offsite backup)"
echo " ✓ Run: make creds-verify (confirm cluster state)"
else
echo "Post-rotation (agent mode):"
echo " ✓ creds-state.yaml updated with last_rotated timestamp"
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 ""
echo "New value:"
NEW_VAL="$(rnd_hex 32)"
echo " PI_SECRET_KEY=$NEW_VAL"
confirm "Apply rotation?" || exit 0
echo ""
ENV_FILE="$SECRETS_DIR/privacyidea/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^PI_SECRET_KEY=.*|PI_SECRET_KEY=$NEW_VAL|" "$ENV_FILE"
echo " 1. Updating K8s Secret privacyidea-config (namespace: mfa)..."
(cd "$K8S_DIR/privacyidea" && bash create-secrets.sh "$SECRETS_DIR")
echo " 2. Restarting privacyIDEA pod..."
kubectl rollout restart deployment privacyidea -n mfa
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 — must be updated atomically in:"
echo " 1. PostgreSQL (CNPG) — ALTER USER privacyidea WITH PASSWORD '...';"
echo " 2. K8s Secret privacyidea-config (PI_SQLALCHEMY_DATABASE_URI)"
echo " Privacyidea pod must be restarted after both are updated."
echo ""
echo "New value:"
NEW_VAL="$(rnd_b64 32 40)"
echo " PI_DB_PASSWORD=$NEW_VAL"
confirm "Apply rotation?" || exit 0
echo ""
ENV_FILE="$SECRETS_DIR/privacyidea/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^PI_DB_PASSWORD=.*|PI_DB_PASSWORD=$NEW_VAL|" "$ENV_FILE"
echo " 1. Updating PostgreSQL password..."
PG_POD=$(kubectl get pod -n databases -l postgresql=net-kingdom-pg -o name 2>/dev/null | head -1)
if [[ -n "$PG_POD" ]]; then
kubectl exec -n databases "$PG_POD" -- \
psql -U postgres -c "ALTER USER privacyidea WITH PASSWORD '$NEW_VAL';"
echo " ✔ PostgreSQL password updated"
else
echo " WARN: Could not find PostgreSQL pod — update manually:"
echo " kubectl exec -n databases <pg-pod> -- psql -U postgres -c \"ALTER USER privacyidea WITH PASSWORD '$NEW_VAL';\""
fi
echo ""
echo " 2. Updating K8s Secret privacyidea-config..."
(cd "$K8S_DIR/privacyidea" && bash create-secrets.sh "$SECRETS_DIR")
echo ""
echo " 3. Restarting privacyIDEA pod..."
kubectl rollout restart deployment privacyidea -n mfa
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 ""
echo "New value:"
NEW_VAL="$(rnd_hex 32)"
echo " LLDAP_JWT_SECRET=$NEW_VAL"
confirm "Apply rotation?" || exit 0
ENV_FILE="$SECRETS_DIR/lldap/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^LLDAP_JWT_SECRET=.*|LLDAP_JWT_SECRET=$NEW_VAL|" "$ENV_FILE"
echo " Updating K8s Secret lldap-secrets (namespace: sso)..."
(cd "$K8S_DIR/lldap" && bash create-secrets.sh "$SECRETS_DIR")
echo " Restarting LLDAP pod..."
kubectl rollout restart deployment lldap -n sso
kubectl rollout status deployment lldap -n sso
update_last_rotated "LLDAP_JWT_SECRET"
post_rotation_reminder
;;
LLDAP_LDAP_USER_PASS)
header
echo ""
echo "Impact: LLDAP admin + LDAP bind password — used by Authelia and KeyCape."
echo " All three must be updated atomically:"
echo " 1. LLDAP admin password (via LLDAP web UI or API)"
echo " 2. Authelia K8s Secret (ldap_password)"
echo " 3. KeyCape K8s Secret (config.yaml lldap.bindPW)"
echo " Restart all three pods after updating."
echo ""
echo "New value:"
NEW_VAL="$(rnd_b64 32 40)"
echo " LLDAP_LDAP_USER_PASS=$NEW_VAL"
confirm "Apply rotation?" || exit 0
ENV_FILE="$SECRETS_DIR/lldap/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^LLDAP_LDAP_USER_PASS=.*|LLDAP_LDAP_USER_PASS=$NEW_VAL|" "$ENV_FILE"
echo " 1. Updating LLDAP admin password via API..."
echo " WARN: Automated LLDAP password update not implemented."
if [[ "$NON_INTERACTIVE" == false ]]; then
echo " Log in to https://lldap.coulomb.social and change the admin password manually."
confirm "Confirm you have updated the LLDAP admin password?" || { echo "Aborting."; exit 1; }
else
echo " [non-interactive] Skipping LLDAP web UI step — update manually:"
echo " Log in to https://lldap.coulomb.social and set admin password to: $NEW_VAL"
fi
echo " 2. Updating Authelia secrets..."
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
echo " 3. Updating KeyCape secrets..."
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
echo " 4. Restarting Authelia and KeyCape pods..."
kubectl rollout restart deployment authelia -n sso
kubectl rollout restart deployment keycape -n sso
kubectl rollout status deployment authelia -n sso
kubectl rollout status deployment keycape -n sso
update_last_rotated "LLDAP_LDAP_USER_PASS"
post_rotation_reminder
;;
AUTHELIA_SESSION_SECRET)
header
echo ""
echo "Impact: Authelia session cookie encryption key."
echo " All active Authelia sessions will be invalidated (users re-prompted)."
echo ""
echo "New value:"
NEW_VAL="$(rnd_hex 32)"
echo " AUTHELIA_SESSION_SECRET=$NEW_VAL"
confirm "Apply rotation?" || exit 0
ENV_FILE="$SECRETS_DIR/authelia/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^AUTHELIA_SESSION_SECRET=.*|AUTHELIA_SESSION_SECRET=$NEW_VAL|" "$ENV_FILE"
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
kubectl rollout restart deployment authelia -n sso
kubectl rollout status deployment authelia -n sso
update_last_rotated "AUTHELIA_SESSION_SECRET"
post_rotation_reminder
;;
AUTHELIA_KEYCAPE_CLIENT_SECRET)
header
echo ""
echo "Impact: Authelia↔KeyCape OIDC client secret."
echo " Must be updated atomically in:"
echo " 1. Authelia K8s Secret (bcrypt hash of the new plaintext)"
echo " 2. KeyCape K8s Secret (plaintext, stored as authelia.clientSecret)"
echo " Both pods must be restarted before the new secret takes effect."
echo ""
echo "New value:"
NEW_VAL="$(rnd_b64 32 40)"
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET=$NEW_VAL"
confirm "Apply rotation?" || exit 0
ENV_FILE="$SECRETS_DIR/authelia/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^AUTHELIA_KEYCAPE_CLIENT_SECRET=.*|AUTHELIA_KEYCAPE_CLIENT_SECRET=$NEW_VAL|" "$ENV_FILE"
echo " Updating Authelia secrets (bcrypt hash)..."
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
echo " Updating KeyCape secrets (plaintext)..."
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
echo " Restarting Authelia and KeyCape..."
kubectl rollout restart deployment authelia -n sso
kubectl rollout restart deployment keycape -n sso
kubectl rollout status deployment authelia -n sso
kubectl rollout status deployment keycape -n sso
update_last_rotated "AUTHELIA_KEYCAPE_CLIENT_SECRET"
post_rotation_reminder
;;
KEYCAPE_RSA_KEY)
header
echo ""
echo " ⚠ WARNING: Rotating the KeyCape RSA signing key immediately invalidates"
echo " ALL issued JWT tokens. This causes a brief authentication outage."
echo " All downstream applications will reject existing tokens until they"
echo " refresh against the new JWKS endpoint."
echo ""
echo " Coordination required:"
echo " 1. Notify downstream application owners of the planned outage window."
echo " 2. Delete secrets/keycape/key.pem to trigger regeneration."
echo " 3. Re-run keycape/create-secrets.sh."
echo " 4. Restart KeyCape pod."
echo " 5. Confirm downstream apps recover (they should auto-refresh JWKS)."
echo ""
confirm "Proceed with RSA key rotation? (causes auth outage)" || exit 0
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
if [[ -f "$KEY_FILE" ]]; then
shred -u "$KEY_FILE"
echo " Old key shredded."
fi
echo " Regenerating RSA-2048 signing key..."
mkdir -p "$(dirname "$KEY_FILE")"
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
chmod 600 "$KEY_FILE"
echo " Updating KeyCape secrets..."
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
echo " Restarting KeyCape pod..."
kubectl rollout restart deployment keycape -n sso
kubectl rollout status deployment keycape -n sso
echo ""
if [[ "$NON_INTERACTIVE" == false ]]; then
echo " ✔ RSA key rotated. Store the new key in your password manager: net-kingdom/KeyCape/jwt-signing-key"
else
echo " ✔ RSA key rotated."
fi
update_last_rotated "KEYCAPE_RSA_KEY"
post_rotation_reminder
;;
BREAKGLASS_PASSWORD)
header
echo ""
echo "Impact: Break-glass emergency access password. Low blast radius."
echo " Rotate freely — no downstream dependencies."
echo ""
echo "New value:"
NEW_VAL="$(rnd_b64 32 40)"
echo " BREAKGLASS_PASSWORD=$NEW_VAL"
confirm "Apply rotation?" || exit 0
ENV_FILE="$SECRETS_DIR/breakglass/secrets.env"
[[ -f "$ENV_FILE" ]] && sed -i "s|^BREAKGLASS_PASSWORD=.*|BREAKGLASS_PASSWORD=$NEW_VAL|" "$ENV_FILE"
echo " Updating break-glass K8s Secret (namespace: sso)..."
BG_SECRET="break-glass"
kubectl create secret generic "$BG_SECRET" \
--namespace=sso \
--from-literal=BREAKGLASS_PASSWORD="$NEW_VAL" \
--dry-run=client -o yaml | kubectl apply -f -
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: '$SECRET'"
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 LLDAP admin + LDAP bind password (3-way coordinated)"
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