From c7b82df2673d62fdcd692a24a27a2e52edcdf627 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 29 May 2026 03:07:17 +0200 Subject: [PATCH] Add KeyCape privacyIDEA token repair flow --- sso-mfa/k8s/keycape/create-pi-token.sh | 6 +- sso-mfa/k8s/keycape/refresh-pi-token-live.sh | 226 ++++++++++++++++++ .../k8s/privacyidea/check-user-mfa-state.sh | 89 +++++++ sso-mfa/k8s/verify-t06.sh | 6 +- .../security_bootstrap_console.py | 24 ++ 5 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 sso-mfa/k8s/keycape/refresh-pi-token-live.sh create mode 100644 sso-mfa/k8s/privacyidea/check-user-mfa-state.sh diff --git a/sso-mfa/k8s/keycape/create-pi-token.sh b/sso-mfa/k8s/keycape/create-pi-token.sh index 503c049..2b00b03 100644 --- a/sso-mfa/k8s/keycape/create-pi-token.sh +++ b/sso-mfa/k8s/keycape/create-pi-token.sh @@ -13,8 +13,9 @@ # ./create-secrets.sh # kubectl rollout restart deployment/keycape -n sso # -# The privacyIDEA admin token does NOT expire by default (it is a permanent -# service account token). Store it in KeePassXC as: +# The privacyIDEA admin token may expire depending on the live privacyIDEA +# policy/configuration. Refresh it if KeyCape reports "mfa check error". +# Store the current token in KeePassXC as: # net-kingdom/KeyCape/pi-admin-token # # Requires: kubectl, curl, jq @@ -108,7 +109,6 @@ echo " Done." echo "" echo "Token written to: $TOKEN_FILE" -echo "Token preview : ${TOKEN:0:32}…" echo "" echo "IMPORTANT: Store this token in KeePassXC → net-kingdom/KeyCape/pi-admin-token" echo " as a password entry. It cannot be recovered without re-authenticating." diff --git a/sso-mfa/k8s/keycape/refresh-pi-token-live.sh b/sso-mfa/k8s/keycape/refresh-pi-token-live.sh new file mode 100644 index 0000000..5edf52f --- /dev/null +++ b/sso-mfa/k8s/keycape/refresh-pi-token-live.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# refresh-pi-token-live.sh — refresh KeyCape's privacyIDEA admin token. +# +# This is an attended repair for "mfa check error" when the privacyIDEA admin +# JWT embedded in keycape-config has expired. It prompts for the pi-admin +# password, fetches a fresh token inside the privacyIDEA pod, updates the +# keycape-pi-token Secret, patches keycape-config without printing secrets, and +# restarts KeyCape. +# +# Usage: +# bash refresh-pi-token-live.sh [username] +# +# Optional environment: +# KUBECTL=/path/to/kubectl +# KEYCAPE_PI_REALM=coulomb|netkingdom + +set -euo pipefail + +USERNAME="${1:-platform-root}" +KUBECTL="${KUBECTL:-kubectl}" +SSO_NAMESPACE="${SSO_NAMESPACE:-sso}" +MFA_NAMESPACE="${MFA_NAMESPACE:-mfa}" +KEYCAPE_DEPLOYMENT="${KEYCAPE_DEPLOYMENT:-keycape}" +KEYCAPE_SECRET="${KEYCAPE_SECRET:-keycape-config}" +KEYCAPE_TOKEN_SECRET="${KEYCAPE_TOKEN_SECRET:-keycape-pi-token}" + +read -r -s -p "privacyIDEA pi-admin password: " PI_ADMIN_PASSWORD +printf "\n" >&2 +if [[ -z "$PI_ADMIN_PASSWORD" ]]; then + echo "[FAIL] Empty pi-admin password." >&2 + exit 1 +fi + +PI_POD="$( + "$KUBECTL" get pod -n "$MFA_NAMESPACE" \ + -l app.kubernetes.io/name=privacyidea \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true +)" +if [[ -z "$PI_POD" ]]; then + echo "[FAIL] No running privacyIDEA pod in namespace $MFA_NAMESPACE." >&2 + exit 1 +fi + +echo "Fetching fresh privacyIDEA admin token inside pod $PI_POD ..." +PI_TOKEN="$( + "$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \ + env PI_ADMIN_PASSWORD="$PI_ADMIN_PASSWORD" \ + python3 -c ' +import json +import os +import sys +import urllib.parse +import urllib.request + +payload = urllib.parse.urlencode({ + "username": "pi-admin", + "password": os.environ["PI_ADMIN_PASSWORD"], +}).encode() +req = urllib.request.Request("http://localhost:8080/auth", data=payload) +try: + with urllib.request.urlopen(req, timeout=10) as response: + body = json.load(response) + print(body["result"]["value"]["token"]) +except Exception as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) +' 2>/dev/null +)" +unset PI_ADMIN_PASSWORD + +if [[ -z "$PI_TOKEN" ]]; then + echo "[FAIL] Could not fetch a privacyIDEA admin token." >&2 + exit 1 +fi + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +api_get() { + local path="$1" + "$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \ + env PI_TOKEN="$PI_TOKEN" PI_PATH="$path" \ + python3 -c ' +import os +import sys +import urllib.request + +path = os.environ["PI_PATH"] +token = os.environ["PI_TOKEN"] +req = urllib.request.Request( + "http://localhost:8080" + path, + headers={"Authorization": token}, +) +try: + with urllib.request.urlopen(req, timeout=10) as response: + sys.stdout.write(response.read().decode()) +except Exception as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) +' +} + +echo "Inspecting non-secret MFA state for $USERNAME ..." +realm_summary="$( + for realm in coulomb netkingdom; do + users_json="$(api_get "/user/?realm=$realm&username=$USERNAME" || true)" + tokens_json="$(api_get "/token/?realm=$realm&user=$USERNAME" || true)" + REALM="$realm" USERS_JSON="$users_json" TOKENS_JSON="$tokens_json" python3 -c ' +import json +import os + +realm = os.environ["REALM"] +users = [] +tokens = [] +try: + users = json.loads(os.environ["USERS_JSON"]).get("result", {}).get("value", {}).get("users", []) +except Exception: + pass +try: + value = json.loads(os.environ["TOKENS_JSON"]).get("result", {}).get("value", {}) + tokens = value.get("tokens", []) if isinstance(value, dict) else [] +except Exception: + pass +active = sum(1 for token in tokens if token.get("active", True)) +print(f"{realm} users={len(users)} tokens={len(tokens)} active={active}") +' + done +)" +printf '%s\n' "$realm_summary" + +current_config="$( + "$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.config\.yaml}' | base64 -d +)" +current_realm="$( + CONFIG_YAML="$current_config" python3 -c ' +import os +import re + +match = re.search(r"(?m)^ realm:\s*[\"'\"']?([^\"'\"'\n]+)", os.environ["CONFIG_YAML"]) +print(match.group(1).strip() if match else "") +' +)" + +selected_realm="${KEYCAPE_PI_REALM:-}" +if [[ -z "$selected_realm" ]]; then + selected_realm="$( + REALM_SUMMARY="$realm_summary" CURRENT_REALM="$current_realm" python3 -c ' +import os + +lines = os.environ["REALM_SUMMARY"].splitlines() +counts = {} +for line in lines: + parts = dict(item.split("=", 1) for item in line.split()[1:]) + counts[line.split()[0]] = { + "users": int(parts.get("users", "0")), + "tokens": int(parts.get("tokens", "0")), + "active": int(parts.get("active", "0")), + } +for realm in ("coulomb", "netkingdom"): + if counts.get(realm, {}).get("active", 0) > 0: + print(realm) + raise SystemExit +current = os.environ.get("CURRENT_REALM", "") +if current: + print(current) +else: + print("coulomb") +' + )" +fi + +if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then + echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2 + exit 1 +fi + +echo "Selected privacyIDEA realm for KeyCape: $selected_realm" + +"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem" +chmod 600 "$tmpdir/key.pem" + +CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" \ + python3 -c ' +import json +import os +import re +import sys + +config = os.environ["CONFIG_YAML"] +token = json.dumps(os.environ["PI_TOKEN"]) +realm = json.dumps(os.environ["PI_REALM"]) + +config, token_count = re.subn(r"(?m)^ adminToken:.*$", " adminToken: " + token, config) +config, realm_count = re.subn(r"(?m)^ realm:.*$", " realm: " + realm, config) +if token_count != 1 or realm_count != 1: + print("Could not patch exactly one adminToken and one realm field.", file=sys.stderr) + sys.exit(1) +sys.stdout.write(config) +' > "$tmpdir/config.yaml" + +echo "Applying refreshed KeyCape config Secret ..." +"$KUBECTL" create secret generic "$KEYCAPE_TOKEN_SECRET" \ + --namespace="$SSO_NAMESPACE" \ + --from-literal=token="$PI_TOKEN" \ + --dry-run=client -o yaml | "$KUBECTL" apply -f - + +"$KUBECTL" create secret generic "$KEYCAPE_SECRET" \ + --namespace="$SSO_NAMESPACE" \ + --from-file=config.yaml="$tmpdir/config.yaml" \ + --from-file=key.pem="$tmpdir/key.pem" \ + --dry-run=client -o yaml | "$KUBECTL" apply -f - + +unset PI_TOKEN + +echo "Restarting KeyCape ..." +"$KUBECTL" rollout restart "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE" +"$KUBECTL" rollout status "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE" --timeout=90s + +echo "" +echo "[OK] KeyCape privacyIDEA token refreshed. Retry the OIDC login with a fresh URL." diff --git a/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh b/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh new file mode 100644 index 0000000..649a4cb --- /dev/null +++ b/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# check-user-mfa-state.sh — non-secret privacyIDEA MFA state diagnostic. +# +# Reports realm presence and user/token counts for a user without printing +# passwords, OTP seeds, admin JWTs, token serials, or token metadata. +# +# Usage: +# bash check-user-mfa-state.sh [username] + +set -euo pipefail + +USERNAME="${1:-platform-root}" +PI_URL="${PI_URL:-https://pink.coulomb.social}" +SSO_NAMESPACE="${SSO_NAMESPACE:-sso}" +KUBECTL="${KUBECTL:-kubectl}" + +TOKEN="$( + "$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true +)" +if [[ -z "$TOKEN" ]]; then + TOKEN="$( + "$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || true + )" +fi + +if [[ -z "$TOKEN" || "$TOKEN" == "PENDING_create-pi-token.sh" ]]; then + echo "[FAIL] keycape-pi-token is missing or still a placeholder." + exit 1 +fi + +api_get() { + local path="$1" + curl -sk -H "Authorization: $TOKEN" "$PI_URL$path" +} + +json_field() { + python3 -c "$1" +} + +echo "privacyIDEA MFA state for user: $USERNAME" +echo "Endpoint: $PI_URL" +echo "" + +REALM_RESP="$(api_get "/realm/")" +echo "Realms:" +printf '%s' "$REALM_RESP" | json_field ' +import json +import sys + +data = json.load(sys.stdin) +if not data.get("result", {}).get("status", False): + print(" [WARN] realm query returned status=false") + err = data.get("result", {}).get("error", {}) + if err: + print(" [WARN] error=" + str(err.get("message", err))) +realms = data.get("result", {}).get("value", {}) +if not realms: + print(" [WARN] no realms returned") +for name in sorted(realms): + print(" - " + name + " default=" + str(bool(realms[name].get("default")))) +' + +for realm in coulomb netkingdom; do + echo "" + echo "Realm: $realm" + USER_RESP="$(api_get "/user/?realm=$realm&username=$USERNAME")" + TOKEN_RESP="$(api_get "/token/?realm=$realm&user=$USERNAME")" + + printf '%s' "$USER_RESP" | json_field ' +import json +import sys + +data = json.load(sys.stdin) +users = data.get("result", {}).get("value", {}).get("users", []) +print(f" users={len(users)}") +' + printf '%s' "$TOKEN_RESP" | json_field ' +import json +import sys + +data = json.load(sys.stdin) +value = data.get("result", {}).get("value", {}) +tokens = value.get("tokens", []) if isinstance(value, dict) else [] +active = sum(1 for token in tokens if token.get("active", True)) +print(f" tokens={len(tokens)} active={active}") +' +done diff --git a/sso-mfa/k8s/verify-t06.sh b/sso-mfa/k8s/verify-t06.sh index 873378e..e7ccb81 100755 --- a/sso-mfa/k8s/verify-t06.sh +++ b/sso-mfa/k8s/verify-t06.sh @@ -37,9 +37,9 @@ PASS=0 FAIL=0 WARN=0 -pass() { echo " [PASS] $1"; ((PASS++)); } -fail() { echo " [FAIL] $1"; ((FAIL++)); } -warn() { echo " [WARN] $1"; ((WARN++)); } +pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); } +fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); } +warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); } section() { echo ""; echo "── $1 ──────────────────────────────────────"; } diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 832ca46..dcd3e0d 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -1398,6 +1398,22 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] f"{kubectl_bin} get ingress keycape -n sso -o jsonpath='{{.status.loadBalancer.ingress[0].ip}}{{\"\\n\"}}'\n" "NETKINGDOM_KEYCAPE_PUBLIC_ROUTE\n" ) + refresh_pi_token_state = "done" if login_verified else "redo" if auth_configured else "blocked" + refresh_pi_token_reason = ( + "Optional repair action. Run this if KeyCape shows 'mfa check error'; " + "it refreshes the expired privacyIDEA admin token without printing it." + ) + if refresh_pi_token_state == "done": + refresh_pi_token_reason = "OIDC-backed OpenBao login is verified; no MFA-token repair is currently needed." + if refresh_pi_token_state == "blocked": + refresh_pi_token_reason = "Configure OpenBao OIDC auth before repairing the MFA check path." + refresh_pi_token_command = ( + "bash <<'NETKINGDOM_KEYCAPE_PI_TOKEN_REFRESH'\n" + "set -euo pipefail\n" + f"cd {keycape_dir}\n" + f"KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\n" + "NETKINGDOM_KEYCAPE_PI_TOKEN_REFRESH\n" + ) login_command = ( "# Terminal 1: bridge the browser callback to the bao CLI running in the OpenBao pod.\n" "kubectl -n openbao port-forward pod/openbao-0 8250:8250\n\n" @@ -1435,6 +1451,14 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] public_route_command, downstream_taint if yes(data, "openbao_initialized") else {}, ), + action( + "Repair KeyCape privacyIDEA MFA token", + "Refresh KeyCape's privacyIDEA admin token when the browser flow reaches MFA and reports 'mfa check error'. The command prompts for the pi-admin password and does not print the token.", + refresh_pi_token_state, + refresh_pi_token_reason, + refresh_pi_token_command, + downstream_taint if yes(data, "openbao_initialized") else {}, + ), action( "Verify OIDC-backed OpenBao admin login", "Use the bao CLI already present in the OpenBao pod, bridge its localhost callback to your workstation, complete the KeyCape MFA browser flow, and verify the returned token before checking the confirmation box.",