From c10d7d2f8a4e245f5cc697014ad69aad765ed19e Mon Sep 17 00:00:00 2001 From: Bernd Worsch Date: Fri, 20 Mar 2026 23:39:35 +0000 Subject: [PATCH] feat(creds): implement NK-WP-0004 Credential Management Foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .sops.yaml + keys/age.pub: SOPS age encryption for all secrets/ paths - .gitignore: broad secrets/ catch-all (any depth) - .githooks/pre-commit: blocks unencrypted secrets/, *.env outside bootstrap/, and known plaintext patterns (PI_SECRET_KEY=, LLDAP_JWT_SECRET=, etc.) - Makefile: full credential lifecycle (creds-init/generate/bundle/apply/verify/ status/rotate) + SOPS helpers (sops-setup/edit/encrypt/decrypt/rotate/check-secrets) + hooks/hooks-test - creds-apply.sh: runs create-secrets.sh in dependency order (postgresql → lldap → authelia → privacyidea), skips keycape with printed instructions, updates state - creds-verify.sh: checks all K8s secrets exist, updates creds-state.yaml - creds-status.sh: human-readable state table from creds-state.yaml - creds-rotate.sh: guided rotation for all 9 secret types with impact descriptions and atomic multi-component update sequences - creds-state.yaml: committable state file tracking generation, bundle, KeePassXC confirmation, per-component apply status, enckey and pi-admin bootstrap flags NK-WP-0003-T01 unblocked. /creds-bootstrap skill registered separately. Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-commit | 110 +++++++++++ .gitignore | 4 +- .sops.yaml | 17 ++ Makefile | 144 ++++++++++++++ keys/age.pub | 1 + sso-mfa/bootstrap/creds-apply.sh | 119 ++++++++++++ sso-mfa/bootstrap/creds-rotate.sh | 296 +++++++++++++++++++++++++++++ sso-mfa/bootstrap/creds-state.yaml | 26 +++ sso-mfa/bootstrap/creds-status.sh | 65 +++++++ sso-mfa/bootstrap/creds-verify.sh | 104 ++++++++++ 10 files changed, 885 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100644 .sops.yaml create mode 100644 Makefile create mode 100644 keys/age.pub create mode 100755 sso-mfa/bootstrap/creds-apply.sh create mode 100755 sso-mfa/bootstrap/creds-rotate.sh create mode 100644 sso-mfa/bootstrap/creds-state.yaml create mode 100755 sso-mfa/bootstrap/creds-status.sh create mode 100755 sso-mfa/bootstrap/creds-verify.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..121a581 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# net-kingdom pre-commit hook — blocks plaintext secrets from entering git. +# +# Enabled by: make hooks (sets core.hooksPath = .githooks) +# Tested by: make hooks-test +# +# BLOCKS: +# 1. Any file under secrets/ without a SOPS marker +# 2. Any *.env file outside sso-mfa/bootstrap/ (env files contain raw secrets) +# 3. Any staged file containing known plaintext secret patterns +# +# WARNS: +# - Bundle archives (*-bundle*.tar.age) — these belong offsite, not in git + +set -euo pipefail + +fail=0 + +# ── Helper ──────────────────────────────────────────────────────────────────── +staged_files() { + git diff --cached --name-only --diff-filter=ACMR +} + +file_content() { + git show ":$1" 2>/dev/null || true +} + +# ── Check 1: secrets/ files must be SOPS-encrypted ─────────────────────────── +secrets_files=$(staged_files | grep -E '(^|/)secrets/' || true) + +if [[ -n "$secrets_files" ]]; then + while IFS= read -r f; do + [[ -z "$f" ]] && continue + content="$(file_content "$f")" + [[ -z "$content" ]] && continue + # SOPS-encrypted files contain a top-level 'sops:' or '"sops":' key + if echo "$content" | grep -qE '^[[:space:]]*sops:[[:space:]]*$|"sops"[[:space:]]*:'; then + continue + fi + # Also allow binary age-encrypted or gpg-encrypted blobs + case "$f" in + *.age|*.gpg) continue ;; + esac + echo " [secrets/] not SOPS-encrypted: $f" + fail=1 + done <<< "$secrets_files" +fi + +# ── Check 2: *.env files outside sso-mfa/bootstrap/ ───────────────────────── +env_files=$(staged_files | grep -E '\.env$' | grep -v '^sso-mfa/bootstrap/' || true) + +if [[ -n "$env_files" ]]; then + while IFS= read -r f; do + [[ -z "$f" ]] && continue + echo " [*.env outside bootstrap/] $f" + fail=1 + done <<< "$env_files" +fi + +# ── Check 3: known plaintext secret patterns in any staged file ─────────────── +# These patterns appear only in generated secrets files — never in code. +FORBIDDEN_PATTERNS=( + 'PI_SECRET_KEY=' + 'PI_PEPPER=' + 'LLDAP_JWT_SECRET=' + 'AUTHELIA_JWT_SECRET=' + 'AUTHELIA_SESSION_SECRET=' + 'AUTHELIA_STORAGE_ENCRYPTION_KEY=' + 'AUTHELIA_OIDC_HMAC_SECRET=' + 'AUTHELIA_KEYCAPE_CLIENT_SECRET=' + 'BREAKGLASS_PASSWORD=' + 'PG_ROOT_PASSWORD=' +) + +while IFS= read -r f; do + [[ -z "$f" ]] && continue + content="$(file_content "$f")" + [[ -z "$content" ]] && continue + for pat in "${FORBIDDEN_PATTERNS[@]}"; do + if echo "$content" | grep -qF "$pat"; then + echo " [secret pattern '$pat'] $f" + fail=1 + fi + done +done <<< "$(staged_files)" + +# ── Warning: bundle archives in git ────────────────────────────────────────── +bundle_files=$(staged_files | grep -E '\-bundle.*\.tar\.age$' || true) + +if [[ -n "$bundle_files" ]]; then + echo "" + echo "WARN: ops bundle archives detected — these belong offsite, not in git:" + while IFS= read -r f; do + echo " $f" + done <<< "$bundle_files" + echo "" +fi + +# ── Result ──────────────────────────────────────────────────────────────────── +if [[ "$fail" -ne 0 ]]; then + echo "" + echo "Commit blocked: plaintext secret(s) detected (see above)." + echo "" + echo "To encrypt a file with SOPS: sops --encrypt --in-place " + echo "To edit an encrypted file: sops " + echo "To bypass (never in prod): git commit --no-verify" + exit 1 +fi + +exit 0 diff --git a/.gitignore b/.gitignore index 669ac0d..991ee26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # ── Secrets (never commit) ───────────────────────────────────────────────────── -sso-mfa/bootstrap/secrets/ +# Broad catch-all: any directory named secrets/ at any path depth. +# The pre-commit hook provides a second line of defence. +secrets/ *.age !sso-mfa/bootstrap/secrets.enc/**/*.age *.kdbx diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..17b644f --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,17 @@ +# SOPS encryption rules for net-kingdom +# Any file under a secrets/ directory (at any depth) is encrypted with the operator age key. +# Same age keypair as railiance-infra — one key per operator across all repos. +# +# Key fingerprint: age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 +# Public key stored in: keys/age.pub +# +# To edit an encrypted file: sops secrets/ +# To encrypt a new file: sops --encrypt --in-place secrets/ +# To decrypt to stdout (inspect): sops -d secrets/ +# To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/ + +creation_rules: + - path_regex: secrets/.*$ + key_groups: + - age: + - age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fc2bce --- /dev/null +++ b/Makefile @@ -0,0 +1,144 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +# Operator age public key — used as bundle encryption recipient +OPERATOR_AGE_PUBKEY := $(shell cat keys/age.pub 2>/dev/null | tr -d '[:space:]') + +# ── Help ────────────────────────────────────────────────────────────────────── +help: ## Show this help + @echo "net-kingdom — available targets" + @echo "" + @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \ + | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " %-28s %s\n", $$1, $$2}' + +# ── Git hooks ───────────────────────────────────────────────────────────────── +hooks: ## Configure git to use repo-local hooks (.githooks) + @mkdir -p .githooks + git config core.hooksPath .githooks + @test -f .githooks/pre-commit \ + || (echo "ERROR: .githooks/pre-commit not found"; exit 1) + chmod +x .githooks/pre-commit + @echo "✔ git hooks enabled (.githooks/pre-commit is active)" + +hooks-test: ## Test that the pre-commit hook blocks plaintext secrets + @mkdir -p sso-mfa/bootstrap/secrets/_hooktest + @echo 'PI_SECRET_KEY=deadbeef' > sso-mfa/bootstrap/secrets/_hooktest/test.env + @git add sso-mfa/bootstrap/secrets/_hooktest/test.env 2>/dev/null || true + @if git commit -m "TEST: hook must block this" 2>/dev/null; then \ + echo "FAIL: hook did NOT block plaintext commit"; \ + git reset --soft HEAD~1 2>/dev/null || true; \ + else \ + echo "✔ Hook blocked plaintext as expected"; \ + fi + @git restore --staged sso-mfa/bootstrap/secrets/_hooktest/test.env 2>/dev/null || true + @rm -rf sso-mfa/bootstrap/secrets/_hooktest + +# ── SOPS / age ──────────────────────────────────────────────────────────────── +sops-setup: ## Copy age key to SOPS default path (~/.config/sops/age/keys.txt) + @mkdir -p ~/.config/sops/age + @if [[ -f ~/.config/age/key.txt ]]; then \ + cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \ + chmod 600 ~/.config/sops/age/keys.txt; \ + echo "✔ SOPS key ready at ~/.config/sops/age/keys.txt"; \ + elif [[ -f ~/.config/sops/age/keys.txt ]]; then \ + echo "✔ SOPS key already present at ~/.config/sops/age/keys.txt"; \ + else \ + echo "ERROR: age key not found at ~/.config/age/key.txt"; \ + echo " Generate one with: age-keygen -o ~/.config/age/key.txt"; \ + exit 1; \ + fi + +sops-edit: ## Edit an encrypted file with SOPS: make sops-edit FILE=secrets/foo.yaml + @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-edit FILE=secrets/path/to/file.yaml"; exit 1) + sops $(FILE) + +sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yaml + @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-encrypt FILE=secrets/path/to/file.yaml"; exit 1) + sops --encrypt --in-place $(FILE) + @echo "✔ Encrypted $(FILE)" + +sops-decrypt: ## Decrypt a file to stdout (never writes plaintext to disk): make sops-decrypt FILE=secrets/foo.yaml + @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-decrypt FILE=secrets/path/to/file.yaml"; exit 1) + sops -d $(FILE) + +sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rotate FILE=secrets/foo.yaml + @[[ -n "$(FILE)" ]] || (echo "Usage: make sops-rotate FILE=secrets/path/to/file.yaml"; exit 1) + sops --rotate --in-place $(FILE) + @echo "✔ Recipients rotated for $(FILE)" + +check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted + @echo "Checking for unencrypted files under secrets/..." + @bad=0; \ + while IFS= read -r f; do \ + [[ -z "$$f" ]] && continue; \ + if grep -qE '^[[:space:]]*sops:[[:space:]]*$$|"sops"[[:space:]]*:' "$$f" 2>/dev/null; then \ + continue; \ + fi; \ + case "$$f" in *.age|*.gpg) continue ;; esac; \ + echo " UNENCRYPTED: $$f"; bad=1; \ + done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$'); \ + if [[ "$$bad" -ne 0 ]]; then \ + echo ""; \ + echo "ERROR: Unencrypted secret(s) detected. Encrypt with: sops --encrypt --in-place "; \ + exit 1; \ + fi; \ + echo "✔ All secrets/ files appear SOPS-encrypted" + +# ── Credential lifecycle ────────────────────────────────────────────────────── +creds-init: sops-setup hooks ## One-time setup: verify prerequisites, configure SOPS and git hooks + @echo "=== creds-init: checking prerequisites ===" + @command -v age >/dev/null 2>&1 || (echo "ERROR: age not installed (apt install age)"; exit 1) + @command -v sops >/dev/null 2>&1 || (echo "ERROR: sops not installed (see https://github.com/getsops/sops/releases)"; exit 1) + @command -v kubectl >/dev/null 2>&1 \ + && echo "✔ kubectl found" \ + || echo "WARN: kubectl not found (required for creds-apply/verify)" + @test -f ~/.config/sops/age/keys.txt \ + || (echo "ERROR: age key not found — run 'make sops-setup' after placing your key at ~/.config/age/key.txt"; exit 1) + @echo "" + @echo "✔ creds-init complete. Next: make creds-generate" + +creds-generate: ## Generate all service secrets and print KeePassXC entry guide + @command -v openssl >/dev/null 2>&1 || (echo "ERROR: openssl not found"; exit 1) + cd sso-mfa/bootstrap && bash gen-secrets.sh + @sed -i "s|^generated_at: .*|generated_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml + @echo "" + @echo "State: generated_at updated in sso-mfa/bootstrap/creds-state.yaml" + @echo "Next : enter values into KeePassXC, then set keepass_confirmed: true" + @echo " Run 'make creds-bundle' to create an encrypted offsite backup." + +creds-bundle: ## Age-encrypt the ops bundle for offsite storage + @[[ -n "$(OPERATOR_AGE_PUBKEY)" ]] \ + || (echo "ERROR: keys/age.pub not found — cannot determine encryption recipient"; exit 1) + @[[ -d sso-mfa/bootstrap/secrets ]] \ + || (echo "ERROR: sso-mfa/bootstrap/secrets/ not found — run 'make creds-generate' first"; exit 1) + cd sso-mfa/bootstrap && bash pack-bundle.sh secrets "$(OPERATOR_AGE_PUBKEY)" + @sed -i "s|^bundle_at: .*|bundle_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml + @echo "" + @echo "State: bundle_at updated in sso-mfa/bootstrap/creds-state.yaml" + @echo "Store the .tar.age bundle offsite (external drive, encrypted cloud, second location)." + +creds-apply: ## Apply all create-secrets.sh scripts to the cluster in dependency order + bash sso-mfa/bootstrap/creds-apply.sh + +creds-verify: ## Check all expected K8s secrets exist and update creds-state.yaml + bash sso-mfa/bootstrap/creds-verify.sh + +creds-status: ## Print human-readable credential state from creds-state.yaml + bash sso-mfa/bootstrap/creds-status.sh + +creds-rotate: ## Guided rotation for one secret: make creds-rotate SECRET= + @[[ -n "$(SECRET)" ]] \ + || (echo "Usage: make creds-rotate SECRET="; \ + echo ""; \ + echo "Known secrets:"; \ + echo " PI_SECRET_KEY PI_PEPPER PI_DB_PASSWORD LLDAP_JWT_SECRET"; \ + echo " LLDAP_LDAP_USER_PASS AUTHELIA_SESSION_SECRET"; \ + echo " AUTHELIA_KEYCAPE_CLIENT_SECRET KEYCAPE_RSA_KEY"; \ + echo " BREAKGLASS_PASSWORD"; \ + exit 1) + SECRET=$(SECRET) bash sso-mfa/bootstrap/creds-rotate.sh + +.PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \ + check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \ + creds-status creds-rotate diff --git a/keys/age.pub b/keys/age.pub new file mode 100644 index 0000000..8bf9c2e --- /dev/null +++ b/keys/age.pub @@ -0,0 +1 @@ +age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 diff --git a/sso-mfa/bootstrap/creds-apply.sh b/sso-mfa/bootstrap/creds-apply.sh new file mode 100755 index 0000000..ba8a395 --- /dev/null +++ b/sso-mfa/bootstrap/creds-apply.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# creds-apply.sh — run all create-secrets.sh scripts in dependency order. +# +# Usage: +# bash sso-mfa/bootstrap/creds-apply.sh [secrets-dir] +# make creds-apply +# +# Dependency order: +# 1. postgresql (no dependencies) +# 2. lldap (needs: secrets/lldap/secrets.env) +# 3. authelia (needs: lldap/secrets.env → LLDAP_LDAP_USER_PASS) +# 4. privacyidea (needs: secrets/privacyidea/secrets.env) +# 5. keycape SKIPPED — requires PI_ADMIN_TOKEN from post-T04 bootstrap. +# Run sso-mfa/k8s/keycape/create-pi-token.sh, then +# sso-mfa/k8s/keycape/create-secrets.sh manually. +# +# After each successful component, creds-state.yaml is updated. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SECRETS_DIR="${1:-$SCRIPT_DIR/secrets}" +STATE_FILE="$SCRIPT_DIR/creds-state.yaml" +K8S_DIR="$REPO_ROOT/sso-mfa/k8s" + +# ── Preflight checks ────────────────────────────────────────────────────────── +if [[ -z "${KUBECONFIG:-}" && ! -f "$HOME/.kube/config" ]]; then + echo "ERROR: KUBECONFIG is not set and ~/.kube/config does not exist." >&2 + echo " Set KUBECONFIG or ensure cluster access before running creds-apply." >&2 + exit 1 +fi + +if ! kubectl cluster-info &>/dev/null; then + echo "ERROR: Cannot reach the Kubernetes cluster." >&2 + echo " Ensure the cluster is running and KUBECONFIG points to the right context." >&2 + exit 1 +fi + +if [[ ! -d "$SECRETS_DIR" ]]; then + echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2 + echo " Run 'make creds-generate' first." >&2 + exit 1 +fi + +echo "=== creds-apply — net-kingdom SSO/MFA secrets ===" +echo "Secrets dir : $SECRETS_DIR" +echo "Cluster : $(kubectl config current-context 2>/dev/null || echo '(unknown)')" +echo "" + +# ── Helper: run a component's create-secrets.sh ─────────────────────────────── +run_component() { + local component="$1" + local script_dir="$K8S_DIR/$component" + local script="$script_dir/create-secrets.sh" + local state_key="$2" + + echo "──────────────────────────────────────────────" + echo "Applying: $component" + + if [[ ! -f "$script" ]]; then + echo "ERROR: $script not found" >&2 + exit 1 + fi + + (cd "$script_dir" && bash create-secrets.sh "$SECRETS_DIR") + + # Update state file + if [[ -f "$STATE_FILE" ]]; then + sed -i "s|^ $state_key: .*| $state_key: true|" "$STATE_FILE" + echo " [state] secrets_applied.$state_key → true" + fi + echo "" +} + +# ── Step 1: PostgreSQL ──────────────────────────────────────────────────────── +run_component "postgresql" "postgres" + +# ── Step 2: LLDAP ──────────────────────────────────────────────────────────── +if [[ ! -f "$SECRETS_DIR/lldap/secrets.env" ]]; then + echo "ERROR: $SECRETS_DIR/lldap/secrets.env not found — run creds-generate first." >&2 + exit 1 +fi +run_component "lldap" "lldap" + +# ── Step 3: Authelia (needs LLDAP bind password from lldap/secrets.env) ─────── +run_component "authelia" "authelia" + +# ── Step 4: privacyIDEA ─────────────────────────────────────────────────────── +if [[ ! -f "$SECRETS_DIR/privacyidea/secrets.env" ]]; then + echo "ERROR: $SECRETS_DIR/privacyidea/secrets.env not found." >&2 + exit 1 +fi +run_component "privacyidea" "privacyidea" + +# ── Step 5: KeyCape — SKIPPED (requires PI_ADMIN_TOKEN) ────────────────────── +echo "──────────────────────────────────────────────" +echo "SKIPPED: keycape (requires PI_ADMIN_TOKEN from post-T04 bootstrap)" +echo "" +echo " After privacyIDEA is Running and bootstrapped:" +echo " 1. TIME-SENSITIVE: run enckey-bootstrap.sh while pod is live:" +echo " bash $K8S_DIR/privacyidea/enckey-bootstrap.sh" +echo "" +echo " 2. Create the pi-admin user:" +echo " bash $K8S_DIR/privacyidea/bootstrap-admin.sh" +echo "" +echo " 3. Generate the PI admin token for KeyCape:" +echo " bash $K8S_DIR/keycape/create-pi-token.sh" +echo "" +echo " 4. Apply KeyCape secrets:" +echo " bash $K8S_DIR/keycape/create-secrets.sh $SECRETS_DIR" +echo "" +echo " 5. Update state:" +echo " make creds-verify" +echo "" + +echo "=== creds-apply complete ===" +echo "Run 'make creds-status' to review state." +echo "Run 'make creds-verify' to confirm K8s secrets exist." diff --git a/sso-mfa/bootstrap/creds-rotate.sh b/sso-mfa/bootstrap/creds-rotate.sh new file mode 100755 index 0000000..9a9f805 --- /dev/null +++ b/sso-mfa/bootstrap/creds-rotate.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# creds-rotate.sh — guided rotation for a single net-kingdom credential. +# +# Usage: +# SECRET= bash sso-mfa/bootstrap/creds-rotate.sh [secrets-dir] +# make creds-rotate SECRET= +# +# 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 +# 5. After confirmation, updates creds-state.yaml and reminds to re-bundle + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SECRETS_DIR="${1:-$SCRIPT_DIR/secrets}" +STATE_FILE="$SCRIPT_DIR/creds-state.yaml" +K8S_DIR="$REPO_ROOT/sso-mfa/k8s" + +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?}" + echo "" + read -rp "$prompt [y/N] " ans + [[ "${ans,,}" == "y" ]] +} + +header() { + echo "" + echo "════════════════════════════════════════════════════════" + echo " Rotating: $SECRET" + echo "════════════════════════════════════════════════════════" +} + +post_rotation_reminder() { + echo "" + echo "Post-rotation checklist:" + echo " ✓ Update KeePassXC entry for this secret" + echo " ✓ Run: make creds-bundle (refresh offsite backup)" + echo " ✓ Run: make creds-verify (confirm cluster state)" +} + +# ── 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 + 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 -- 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 + 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 + 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." + 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; } + 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 + 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 + 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 + 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 "" + echo " ✔ RSA key rotated. Store the new key in KeePassXC: net-kingdom/KeyCape/jwt-signing-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 "" + echo " Update KeePassXC entry: net-kingdom/Break-glass/break-glass" + 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=" + exit 1 + ;; + +esac diff --git a/sso-mfa/bootstrap/creds-state.yaml b/sso-mfa/bootstrap/creds-state.yaml new file mode 100644 index 0000000..74e06e8 --- /dev/null +++ b/sso-mfa/bootstrap/creds-state.yaml @@ -0,0 +1,26 @@ +# Credential state — net-kingdom SSO/MFA stack +# This file is SAFE TO COMMIT. It contains no secrets. +# Updated automatically by make creds-* targets and sso-mfa/bootstrap/creds-verify.sh. +# +# keepass_confirmed is the only field that requires manual operator intervention. +# Set it to true after you have entered all generated secrets into KeePassXC. + +generated_at: null +bundle_at: null +keepass_confirmed: false + +secrets_applied: + postgres: false + lldap: false + authelia: false + privacyidea: false + # keycape requires PI_ADMIN_TOKEN from post-privacyIDEA T04 bootstrap. + # Run: sso-mfa/k8s/keycape/create-pi-token.sh, then re-run keycape/create-secrets.sh. + keycape: false + +# enckey_bootstrapped: set by sso-mfa/k8s/privacyidea/enckey-bootstrap.sh +# This step is TIME-SENSITIVE — it must run while the privacyIDEA pod is live. +enckey_bootstrapped: false + +# pi_admin_created: set after sso-mfa/k8s/privacyidea/bootstrap-admin.sh completes +pi_admin_created: false diff --git a/sso-mfa/bootstrap/creds-status.sh b/sso-mfa/bootstrap/creds-status.sh new file mode 100755 index 0000000..1f03fbb --- /dev/null +++ b/sso-mfa/bootstrap/creds-status.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# creds-status.sh — print a human-readable credential state table. +# +# Usage: +# bash sso-mfa/bootstrap/creds-status.sh +# make creds-status + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_FILE="${1:-$SCRIPT_DIR/creds-state.yaml}" + +if [[ ! -f "$STATE_FILE" ]]; then + echo "ERROR: creds-state.yaml not found: $STATE_FILE" >&2 + echo " This file is created at repo init — check your working directory." >&2 + exit 1 +fi + +# Simple key extractors (no yaml lib dependency) +top_val() { grep -E "^$1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; } +nested_val() { grep -E "^ $1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; } + +status_icon() { + case "$1" in + true) echo "✔" ;; + false) echo "✗" ;; + null) echo "—" ;; + *) echo "?" ;; + esac +} + +echo "=== net-kingdom Credential State ===" +echo "" + +generated_at="$(top_val generated_at)" +bundle_at="$(top_val bundle_at)" +keepass_confirmed="$(top_val keepass_confirmed)" + +printf " %-30s %s\n" "Generated at:" "${generated_at:-—}" +printf " %-30s %s\n" "Bundle at:" "${bundle_at:-—}" +printf " %-30s %s %s\n" "KeePassXC confirmed:" \ + "$(status_icon "$keepass_confirmed")" \ + "$([ "$keepass_confirmed" = "false" ] && echo "(set keepass_confirmed: true manually)" || true)" +echo "" + +echo " Secrets applied:" +for component in postgres lldap authelia privacyidea keycape; do + val="$(nested_val "$component")" + note="" + [[ "$component" == "keycape" && "$val" == "false" ]] && \ + note=" (requires PI_ADMIN_TOKEN — post-T04)" + printf " %-28s %s%s\n" "$component" "$(status_icon "$val")" "$note" +done +echo "" + +enckey="$(top_val enckey_bootstrapped)" +pi_admin="$(top_val pi_admin_created)" + +printf " %-30s %s%s\n" "enckey bootstrapped:" \ + "$(status_icon "$enckey")" \ + "$([ "$enckey" = "false" ] && echo " ← TIME-SENSITIVE once pod is live" || true)" +printf " %-30s %s\n" "pi-admin created:" "$(status_icon "$pi_admin")" + +echo "" +echo "Run 'make creds-verify' to refresh state from the live cluster." diff --git a/sso-mfa/bootstrap/creds-verify.sh b/sso-mfa/bootstrap/creds-verify.sh new file mode 100755 index 0000000..cbf51b3 --- /dev/null +++ b/sso-mfa/bootstrap/creds-verify.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# creds-verify.sh — check all expected K8s secrets exist and update creds-state.yaml. +# +# Usage: +# bash sso-mfa/bootstrap/creds-verify.sh +# make creds-verify +# +# Checks the following K8s secrets: +# databases/net-kingdom-pg-privacyidea-app → secrets_applied.postgres +# sso/lldap-secrets → secrets_applied.lldap +# sso/authelia-secrets → secrets_applied.authelia +# mfa/privacyidea-config → secrets_applied.privacyidea +# sso/keycape-config → secrets_applied.keycape +# mfa/privacyidea-enckey → enckey_bootstrapped +# sso/keycape-pi-token → pi_admin_created + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_FILE="$SCRIPT_DIR/creds-state.yaml" + +if ! kubectl cluster-info &>/dev/null; then + echo "ERROR: Cannot reach the Kubernetes cluster. Verify KUBECONFIG." >&2 + exit 1 +fi + +# ── Check helper ────────────────────────────────────────────────────────────── +secret_exists() { + local ns="$1" name="$2" + kubectl get secret "$name" --namespace="$ns" --ignore-not-found -o name 2>/dev/null | grep -q . +} + +update_state_top() { + local key="$1" value="$2" + if [[ -f "$STATE_FILE" ]]; then + sed -i "s|^$key: .*|$key: $value|" "$STATE_FILE" + fi +} + +update_state_nested() { + local key="$1" value="$2" + if [[ -f "$STATE_FILE" ]]; then + sed -i "s|^ $key: .*| $key: $value|" "$STATE_FILE" + fi +} + +# ── Results table ───────────────────────────────────────────────────────────── +pass=0 +fail=0 + +check() { + local label="$1" ns="$2" secret="$3" state_fn="$4" state_key="$5" + if secret_exists "$ns" "$secret"; then + printf " %-40s ✔ exists\n" "$label" + "$state_fn" "$state_key" "true" + ((pass++)) || true + else + printf " %-40s ✗ missing (ns: %s, secret: %s)\n" "$label" "$ns" "$secret" + "$state_fn" "$state_key" "false" + ((fail++)) || true + fi +} + +echo "=== creds-verify — net-kingdom SSO/MFA secrets ===" +echo "" + +check "postgres (net-kingdom-pg-privacyidea-app)" \ + databases net-kingdom-pg-privacyidea-app \ + update_state_nested postgres + +check "lldap (lldap-secrets)" \ + sso lldap-secrets \ + update_state_nested lldap + +check "authelia (authelia-secrets)" \ + sso authelia-secrets \ + update_state_nested authelia + +check "privacyidea (privacyidea-config)" \ + mfa privacyidea-config \ + update_state_nested privacyidea + +check "keycape (keycape-config)" \ + sso keycape-config \ + update_state_nested keycape + +echo "" + +check "enckey (privacyidea-enckey)" \ + mfa privacyidea-enckey \ + update_state_top enckey_bootstrapped + +check "pi-admin token (keycape-pi-token)" \ + sso keycape-pi-token \ + update_state_top pi_admin_created + +echo "" +echo "Results: $pass present, $fail missing" + +if [[ -f "$STATE_FILE" ]]; then + echo "State file updated: $STATE_FILE" +fi + +[[ "$fail" -eq 0 ]]