feat(creds): implement NK-WP-0004 Credential Management Foundation

- .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 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:39:35 +00:00
parent a96d72193c
commit c10d7d2f8a
10 changed files with 885 additions and 1 deletions

110
.githooks/pre-commit Executable file
View File

@@ -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 <file>"
echo "To edit an encrypted file: sops <file>"
echo "To bypass (never in prod): git commit --no-verify"
exit 1
fi
exit 0

4
.gitignore vendored
View File

@@ -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

17
.sops.yaml Normal file
View File

@@ -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/<file>
# To encrypt a new file: sops --encrypt --in-place secrets/<file>
# To decrypt to stdout (inspect): sops -d secrets/<file>
# To add a recipient: update .sops.yaml + sops --rotate --in-place secrets/<file>
creation_rules:
- path_regex: secrets/.*$
key_groups:
- age:
- age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4

144
Makefile Normal file
View File

@@ -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 <file>"; \
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=<name>
@[[ -n "$(SECRET)" ]] \
|| (echo "Usage: make creds-rotate SECRET=<name>"; \
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

1
keys/age.pub Normal file
View File

@@ -0,0 +1 @@
age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4

119
sso-mfa/bootstrap/creds-apply.sh Executable file
View File

@@ -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."

296
sso-mfa/bootstrap/creds-rotate.sh Executable file
View File

@@ -0,0 +1,296 @@
#!/usr/bin/env bash
# creds-rotate.sh — guided rotation for a single net-kingdom credential.
#
# Usage:
# SECRET=<name> bash sso-mfa/bootstrap/creds-rotate.sh [secrets-dir]
# make creds-rotate SECRET=<name>
#
# 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 <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
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=<name>"
exit 1
;;
esac

View File

@@ -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

View File

@@ -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."

104
sso-mfa/bootstrap/creds-verify.sh Executable file
View File

@@ -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 ]]