From 7d4bb1653edb45abeb4e66990cd877d635424cd0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 19:46:53 +0200 Subject: [PATCH] fix(security): reconcile KeyCape bootstrap with scoped factor custody Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- sso-mfa/WORKPLAN.md | 2 +- sso-mfa/bootstrap/creds-apply.sh | 14 +- sso-mfa/bootstrap/creds-bootstrap-agent.sh | 8 +- sso-mfa/bootstrap/creds-rotate.sh | 65 +------ sso-mfa/bootstrap/creds-status.sh | 2 +- sso-mfa/bootstrap/gen-secrets.sh | 6 +- sso-mfa/k8s/backup/DR-RUNBOOK.md | 6 +- sso-mfa/k8s/keycape/README.md | 96 ++++----- sso-mfa/k8s/keycape/bootstrap-config.json | 123 ++++++++++++ sso-mfa/k8s/keycape/create-pi-token.sh | 120 +----------- sso-mfa/k8s/keycape/create-secrets.py | 137 +++++++++++++ sso-mfa/k8s/keycape/create-secrets.sh | 178 +---------------- sso-mfa/k8s/keycape/refresh-pi-token-live.sh | 182 +----------------- .../k8s/keycape/test_portal_client_rollout.py | 4 +- tests/test_keycape_bootstrap.py | 142 ++++++++++++++ tests/test_rotation_output.py | 18 +- ...-bootstrap-secret-free-recovery-handoff.md | 38 +++- 17 files changed, 528 insertions(+), 613 deletions(-) create mode 100644 sso-mfa/k8s/keycape/bootstrap-config.json create mode 100644 sso-mfa/k8s/keycape/create-secrets.py create mode 100644 tests/test_keycape_bootstrap.py diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md index 7bbd142..ecf1baf 100644 --- a/sso-mfa/WORKPLAN.md +++ b/sso-mfa/WORKPLAN.md @@ -101,7 +101,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co ### In Progress (this session) - [ ] Run `bootstrap-realm.sh` on live cluster (requires T04 applied) -- [ ] Run `keycape/create-pi-token.sh` then `keycape/create-secrets.sh` (inject real PI token) +- [ ] Establish RPF-WP-0040 scoped factor delivery, then first-bootstrap `keycape/create-secrets.sh` only if config is absent (old admin-token helpers retired). - [ ] Restart KeyCape with updated keycape-config - [ ] Enroll a TOTP token for pi-admin via pink-account.coulomb.social - [ ] Test end-to-end login via kc.coulomb.social diff --git a/sso-mfa/bootstrap/creds-apply.sh b/sso-mfa/bootstrap/creds-apply.sh index ba8a395..7e81c82 100755 --- a/sso-mfa/bootstrap/creds-apply.sh +++ b/sso-mfa/bootstrap/creds-apply.sh @@ -10,9 +10,8 @@ # 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. +# 5. keycape SKIPPED — requires owner-established RPF-WP-0040 factor delivery. +# create-secrets.sh is first-creation only, never live replacement. # # After each successful component, creds-state.yaml is updated. @@ -93,9 +92,9 @@ if [[ ! -f "$SECRETS_DIR/privacyidea/secrets.env" ]]; then fi run_component "privacyidea" "privacyidea" -# ── Step 5: KeyCape — SKIPPED (requires PI_ADMIN_TOKEN) ────────────────────── +# ── Step 5: KeyCape — SKIPPED (requires scoped factor delivery) ────────────────────── echo "──────────────────────────────────────────────" -echo "SKIPPED: keycape (requires PI_ADMIN_TOKEN from post-T04 bootstrap)" +echo "SKIPPED: keycape (requires RPF-WP-0040 scoped factor delivery)" echo "" echo " After privacyIDEA is Running and bootstrapped:" echo " 1. TIME-SENSITIVE: run enckey-bootstrap.sh while pod is live:" @@ -104,10 +103,9 @@ 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 " 3. Establish scoped factor custody and ESO delivery through railiance-platform RPF-WP-0040." echo "" -echo " 4. Apply KeyCape secrets:" +echo " 4. Create KeyCape bootstrap secrets only if keycape-config is absent:" echo " bash $K8S_DIR/keycape/create-secrets.sh $SECRETS_DIR" echo "" echo " 5. Update state:" diff --git a/sso-mfa/bootstrap/creds-bootstrap-agent.sh b/sso-mfa/bootstrap/creds-bootstrap-agent.sh index 9ef31c7..5d10c9d 100755 --- a/sso-mfa/bootstrap/creds-bootstrap-agent.sh +++ b/sso-mfa/bootstrap/creds-bootstrap-agent.sh @@ -287,22 +287,18 @@ fi # ── Phase 6: Apply keycape secrets ──────────────────────────────────────────── -step "6 — Apply KeyCape secrets (requires pi-admin)" +step "6 — Create KeyCape bootstrap secrets (requires scoped factor delivery)" if [[ "$(state_get_nested keycape)" == "true" ]]; then ok "keycape secrets already applied — skipping" else if [[ "$DRY_RUN" == false ]]; then - log "fetching PI admin token..." - (cd "$K8S_DIR/keycape" && bash create-pi-token.sh "$SECRETS_DIR") - ok "PI admin token fetched" - log "applying keycape secrets..." (cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR") state_set_nested "keycape" "true" ok "keycape secrets applied" else - echo " [dry-run] would run create-pi-token.sh + keycape/create-secrets.sh" + echo " [dry-run] would verify RPF-WP-0040 delivery and create first-bootstrap keycape-config" fi fi diff --git a/sso-mfa/bootstrap/creds-rotate.sh b/sso-mfa/bootstrap/creds-rotate.sh index 7ae56ce..4e79f14 100755 --- a/sso-mfa/bootstrap/creds-rotate.sh +++ b/sso-mfa/bootstrap/creds-rotate.sh @@ -262,68 +262,15 @@ AUTHELIA_SESSION_SECRET) ;; 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 "" - prepare_value authelia b64 - confirm "Apply rotation?" || exit 0 - store_value authelia - echo " Updating Authelia secrets (bcrypt hash)..." - (cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR") - echo " Updating KeyCape secrets (plaintext)..." - (cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR") - echo " Restarting Authelia and KeyCape..." - quiet kubectl rollout restart deployment authelia -n sso - quiet kubectl rollout restart deployment keycape -n sso - quiet kubectl rollout status deployment authelia -n sso - quiet kubectl rollout status deployment keycape -n sso - update_last_rotated "AUTHELIA_KEYCAPE_CLIENT_SECRET" - post_rotation_reminder + echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2 + echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2 + exit 1 ;; KEYCAPE_RSA_KEY) - 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" && quiet bash create-secrets.sh "$SECRETS_DIR") - echo " Restarting KeyCape pod..." - quiet kubectl rollout restart deployment keycape -n sso - quiet 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 + echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2 + echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2 + exit 1 ;; BREAKGLASS_PASSWORD) diff --git a/sso-mfa/bootstrap/creds-status.sh b/sso-mfa/bootstrap/creds-status.sh index a50200a..adea5ad 100755 --- a/sso-mfa/bootstrap/creds-status.sh +++ b/sso-mfa/bootstrap/creds-status.sh @@ -106,7 +106,7 @@ else val="$(nested_val "$component")" note="" [[ "$component" == "keycape" && "$val" == "false" ]] && \ - note=" (requires PI_ADMIN_TOKEN — post-T04)" + note=" (requires RPF-WP-0040 scoped factor delivery)" printf " %-28s %s%s\n" "$component" "$(status_icon "$val")" "$note" done echo "" diff --git a/sso-mfa/bootstrap/gen-secrets.sh b/sso-mfa/bootstrap/gen-secrets.sh index 63adee7..f6246bb 100755 --- a/sso-mfa/bootstrap/gen-secrets.sh +++ b/sso-mfa/bootstrap/gen-secrets.sh @@ -129,16 +129,16 @@ EOF # ── KeyCape ──────────────────────────────────────────────────────────────────── # KeyCape has no generated secrets here: # key.pem — RSA signing key, generated by keycape/create-secrets.sh on first run -# pi_admin_token — generated by keycape/create-pi-token.sh AFTER privacyIDEA is bootstrapped +# Factor JWT — delivered through RPF-WP-0040 ESO, never stored in this directory. # We create the directory and a placeholder file so operators know to check it. cat > "$OUT_DIR/keycape/secrets.env" <&2 - exit 1 -fi - -read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } -PI_ADMIN_PASSWORD=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD) - -if [[ -z "$PI_ADMIN_PASSWORD" ]]; then - echo "ERROR: PI_ADMIN_PASSWORD is empty in $PI_ENV" >&2 - exit 1 -fi - -# Fetch token by exec-ing into the privacyIDEA pod (localhost call, bypasses -# NetworkPolicy which restricts external ingress to the service). -PI_POD=$(kubectl get pod -n mfa \ - -l app.kubernetes.io/name=privacyidea \ - --field-selector=status.phase=Running \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") - -if [[ -z "$PI_POD" ]]; then - # Fall back to public hostname if pod is not directly accessible - PI_POD="" -fi - -echo "Fetching privacyIDEA admin token..." - -if [[ -n "$PI_POD" ]]; then - echo " Method: kubectl exec into $PI_POD (avoids NetworkPolicy restriction)" - # Pass the password via env var. Retry up to 3 times — the PostgreSQL connection - # pool can return 500 if the idle SSL connection was dropped; it recovers on retry. - TOKEN="" - for _ATTEMPT in 1 2 3; do - TOKEN=$(kubectl exec -n mfa "$PI_POD" -- \ - env PI_ADMIN_PASSWORD="${PI_ADMIN_PASSWORD}" \ - python3 -c ' -import urllib.request, urllib.parse, json, os, sys -pw = os.environ["PI_ADMIN_PASSWORD"] -data = urllib.parse.urlencode({"username": "pi-admin", "password": pw}).encode() -req = urllib.request.Request("http://localhost:8080/auth", data=data) -try: - with urllib.request.urlopen(req, timeout=10) as r: - body = json.load(r) - print(body["result"]["value"]["token"]) -except Exception as e: - print(str(e), file=__import__("sys").stderr) - sys.exit(1) -' 2>/dev/null || echo "") - if [[ -n "$TOKEN" ]]; then break; fi - echo " Attempt $_ATTEMPT failed (likely transient DB connection drop) — retrying in 5s..." - sleep 5 - done -else - echo " Method: public URL https://pink.coulomb.social" - TOKEN=$(curl -sf \ - -X POST "https://pink.coulomb.social/auth" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \ - | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \ - 2>/dev/null || echo "") -fi - -if [[ -z "$TOKEN" ]]; then - echo "ERROR: failed to fetch token from privacyIDEA." >&2 - echo " Verify that privacyIDEA is Running and the pi-admin account exists." >&2 - echo " Check: kubectl logs -n mfa \$(kubectl get pod -n mfa -l app.kubernetes.io/name=privacyidea -o name | head -1)" >&2 - exit 1 -fi - -mkdir -p "$(dirname "$TOKEN_FILE")" -echo -n "$TOKEN" > "$TOKEN_FILE" -chmod 600 "$TOKEN_FILE" - -# Create the keycape-pi-token K8s Secret (KeyCape reads it at startup) -echo "Creating K8s Secret: keycape-pi-token (namespace: sso)" -kubectl create secret generic keycape-pi-token \ - --namespace=sso \ - --from-literal=token="$TOKEN" \ - --dry-run=client -o yaml | kubectl apply -f - -echo " Done." - -echo "" -echo "Token written to: $TOKEN_FILE" -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." -echo "" -echo "Next steps:" -echo " 1. Re-run create-secrets.sh to update keycape-config with the real token." -echo " 2. Restart KeyCape: kubectl rollout restart deployment/keycape -n sso" +printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2 +exit 1 diff --git a/sso-mfa/k8s/keycape/create-secrets.py b/sso-mfa/k8s/keycape/create-secrets.py new file mode 100644 index 0000000..f576a76 --- /dev/null +++ b/sso-mfa/k8s/keycape/create-secrets.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Create first-bootstrap config with private inputs and scoped factor delivery. + +Never replace an existing Secret, mint a provider token or rotate a signing key. +Child output is captured, including Kubernetes error bodies containing Secret data. +""" +import base64 +import contextlib +import json +import os +from pathlib import Path +import re +import secrets +import stat +import subprocess +import sys + +import yaml + + +class Refused(Exception): + pass + + +def private(info, directory=False): + kind = stat.S_ISDIR if directory else stat.S_ISREG + if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused() + if not directory and info.st_nlink != 1: + raise Refused() + + +@contextlib.contextmanager +def custody(root): + root = Path(os.path.abspath(root)) + if any(p.is_symlink() for p in (root, *root.parents)): + raise Refused() + descriptors = [] + try: + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + fd = os.open(root, flags); descriptors.append(fd) + private(os.fstat(fd), True) + children = {} + for name in ('lldap', 'authelia', 'keycape'): + child = os.open(name, flags, dir_fd=fd); descriptors.append(child) + private(os.fstat(child), True) + children[name] = child + yield children + finally: + for fd in reversed(descriptors): os.close(fd) + + +def read_file(parent, name): + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent) + try: + private(os.fstat(fd)) + raw = os.read(fd, 65537) + if len(raw) > 65536: raise Refused() + return raw + finally: + os.close(fd) + + +def env_value(parent, name): + values = [line.split('=', 1)[1] for line in read_file(parent, 'secrets.env').decode().splitlines() + if line.startswith(name + '=')] + # Consume the generator's literal format, never execute a shell env file. + if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]): + raise Refused() + return values[0] + + +def run(args, payload=None): + result = subprocess.run(args, input=payload, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, timeout=60, check=False) + if result.returncode or len(result.stdout) > 65536: raise Refused() + return result.stdout + + +def signing_key(parent): + try: + key = read_file(parent, 'key.pem') + except FileNotFoundError: + key = run(['openssl', 'genrsa', '2048']) + run(['openssl', 'rsa', '-check', '-noout'], key) + # Publish atomically without replacement, so concurrent creation cannot + # overwrite a pre-existing key. Keep this private key after API failure. + temp = '.key-' + secrets.token_hex(12) + fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent) + try: + with os.fdopen(fd, 'wb') as file: + file.write(key); file.flush(); os.fsync(file.fileno()) + os.link(temp, 'key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False) + os.fsync(parent) + finally: + os.unlink(temp, dir_fd=parent) + run(['openssl', 'rsa', '-check', '-noout'], key) + return key + + +def main(): + if len(sys.argv) > 2: raise Refused() + here = Path(__file__).resolve().parent + root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets' + with custody(root) as dirs: + bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS') + client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET') + config = json.loads((here / 'bootstrap-config.json').read_text()) + config['lldap']['bindPW'] = bind_password + config['authelia']['clientSecret'] = client_secret + # Existence is not an update grant. API create also closes the race + # between this preflight and publication; AlreadyExists stays a failure. + existing = run(['kubectl', 'get', 'secret', 'keycape-config', '-n', 'sso', '--ignore-not-found', '-o', 'name']) + if existing.strip(): raise Refused() + ready = run(['kubectl', 'get', 'externalsecret', 'keycape-factor-read', '-n', 'sso', + '-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}']) + factor = run(['kubectl', 'get', 'secret', 'keycape-factor-read', '-n', 'sso', '-o', 'name']) + if ready.strip() != b'True' or factor.strip() != b'secret/keycape-factor-read': raise Refused() + key = signing_key(dirs['keycape']) + data = {'config.yaml': yaml.safe_dump(config, sort_keys=False).encode(), 'key.pem': key} + manifest = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque', + 'metadata': {'name': 'keycape-config', 'namespace': 'sso'}, + 'data': {name: base64.b64encode(value).decode() for name, value in data.items()}} + response = run(['kubectl', 'create', '-o', 'name', '-f', '-'], json.dumps(manifest).encode()) + if response.strip() != b'secret/keycape-config': raise Refused() + print('KeyCape bootstrap Secret created with mounted factor credential configuration.') + print('Preserve the private signing key in approved custody. Native login and factor acceptance remain required.') + + +if __name__ == '__main__': + try: + main() + except (Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): + print('ERROR: KeyCape bootstrap refused or failed. Existing configuration is never replaced; ' + 'check private inputs and RPF-WP-0040 delivery using metadata-only diagnostics. ' + 'If creation was attempted, its remote outcome must be reconciled before retry.', file=sys.stderr) + sys.exit(1) diff --git a/sso-mfa/k8s/keycape/create-secrets.sh b/sso-mfa/k8s/keycape/create-secrets.sh index 54ae5e4..37e5a7b 100644 --- a/sso-mfa/k8s/keycape/create-secrets.sh +++ b/sso-mfa/k8s/keycape/create-secrets.sh @@ -1,175 +1,7 @@ #!/usr/bin/env bash -# create-secrets.sh — create the keycape-config K8s Secret -# -# Usage: -# ./create-secrets.sh [secrets-dir] -# -# Creates ONE Secret in the sso namespace: -# keycape-config — config.yaml (full KeyCape config) + key.pem (RSA signing key) -# -# The privacyIDEA admin token is a separate Secret (keycape-pi-token) created -# by create-pi-token.sh AFTER privacyIDEA is bootstrapped (T04 complete). -# The PI admin token is read from that Secret at startup via config.yaml. -# -# Re-run this script to: -# - Rotate the Authelia client secret (update secrets/authelia/secrets.env first) -# - Add or modify OIDC client registrations (edit CLIENTS block below) -# - Rotate the RSA signing key (delete and regenerate secrets/keycape/key.pem) - +# First creation only. Existing KeyCape configuration requires a scoped owner update. +set +x set -euo pipefail - -SECRETS_DIR="${1:-../../bootstrap/secrets}" -KEYCAPE_ENV="$SECRETS_DIR/keycape/secrets.env" -LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" -AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env" -KEY_FILE="$SECRETS_DIR/keycape/key.pem" - -for f in "$KEYCAPE_ENV" "$LLDAP_ENV" "$AUTHELIA_ENV"; do - if [[ ! -f "$f" ]]; then - echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2 - exit 1 - fi -done - -read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } - -LLDAP_BIND_PW=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS) -AUTHELIA_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET) - -if [[ -z "$LLDAP_BIND_PW" || -z "$AUTHELIA_CLIENT_SECRET" ]]; then - echo "ERROR: could not read LLDAP_LDAP_USER_PASS or AUTHELIA_KEYCAPE_CLIENT_SECRET" >&2 - exit 1 -fi - -# The privacyIDEA admin token is read from a separate Secret at runtime. -# Placeholder here — create-pi-token.sh populates the real value. -PI_ADMIN_TOKEN="PENDING_create-pi-token.sh" -if [[ -f "$SECRETS_DIR/keycape/pi_admin_token" ]]; then - PI_ADMIN_TOKEN=$(cat "$SECRETS_DIR/keycape/pi_admin_token") - echo "INFO: Using privacyIDEA admin token from $SECRETS_DIR/keycape/pi_admin_token" -fi - -# ── RSA signing key ─────────────────────────────────────────────────────────── -if [[ ! -f "$KEY_FILE" ]]; then - echo "Generating RSA-2048 signing key for KeyCape JWT tokens..." - mkdir -p "$(dirname "$KEY_FILE")" - openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null - chmod 600 "$KEY_FILE" - echo " Generated: $KEY_FILE" - echo " IMPORTANT: Store this key in KeePassXC → net-kingdom/KeyCape/jwt-signing-key" - echo " as a binary attachment. It cannot be recovered if lost." -else - echo "INFO: Using existing key: $KEY_FILE" -fi -KEY_CONTENT=$(cat "$KEY_FILE") - -# ── Build config.yaml ───────────────────────────────────────────────────────── -# Edit the OIDC clients block below to register downstream applications. -# Re-run this script after any change. -CONFIG_YAML=$(cat < /dev/tty - IFS= read -r -s PI_ADMIN_PASSWORD < /dev/tty - printf "\n" > /dev/tty -else - printf "privacyIDEA pi-admin password: " >&2 - IFS= read -r -s PI_ADMIN_PASSWORD - printf "\n" >&2 -fi -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 - -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 sys - -try: - import yaml -except ImportError: - print("PyYAML is required: install python3-yaml", file=sys.stderr) - sys.exit(1) - -config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {} -privacyidea = config.get("privacyidea") or {} -if not isinstance(privacyidea, dict): - print("") -else: - print(str(privacyidea.get("realm") or "").strip()) -' -)" - -selected_realm="${KEYCAPE_PI_REALM:-coulomb}" -selected_require_for_all="${KEYCAPE_PI_REQUIRE_FOR_ALL:-true}" -if [[ -z "${KEYCAPE_PI_REALM:-}" && -n "$current_realm" && "$current_realm" != "$selected_realm" ]]; then - echo "[WARN] KeyCape currently points privacyIDEA at realm '$current_realm'; repairing to '$selected_realm'." >&2 -fi - -if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then - echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2 - exit 1 -fi -if [[ "$selected_require_for_all" != "true" && "$selected_require_for_all" != "false" ]]; then - echo "[FAIL] KEYCAPE_PI_REQUIRE_FOR_ALL must be true or false." >&2 - exit 1 -fi - -echo "Selected privacyIDEA realm for KeyCape: $selected_realm" -echo "Selected privacyIDEA requireForAll for KeyCape: $selected_require_for_all" - -"$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" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \ - python3 -c ' -import os -import sys -try: - import yaml -except ImportError: - print("PyYAML is required: install python3-yaml", file=sys.stderr) - sys.exit(1) - -config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {} -if not isinstance(config, dict): - print("KeyCape config.yaml must decode to a YAML mapping.", file=sys.stderr) - sys.exit(1) -privacyidea = config.setdefault("privacyidea", {}) -if not isinstance(privacyidea, dict): - print("KeyCape privacyidea config must decode to a YAML mapping.", file=sys.stderr) - sys.exit(1) -privacyidea["adminToken"] = os.environ["PI_TOKEN"] -privacyidea["realm"] = os.environ["PI_REALM"] -privacyidea["requireForAll"] = os.environ["PI_REQUIRE_FOR_ALL"] == "true" -sys.stdout.write(yaml.safe_dump(config, sort_keys=False)) -' > "$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." +printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2 +exit 1 diff --git a/sso-mfa/k8s/keycape/test_portal_client_rollout.py b/sso-mfa/k8s/keycape/test_portal_client_rollout.py index 01b73a1..1028f12 100644 --- a/sso-mfa/k8s/keycape/test_portal_client_rollout.py +++ b/sso-mfa/k8s/keycape/test_portal_client_rollout.py @@ -62,9 +62,7 @@ class PortalClientTests(unittest.TestCase): rollout.replacement(self.fixture(clients)[0]) def test_bootstrap_retains_same_public_registration(self): - source = Path(__file__).with_name('create-secrets.sh').read_text() - raw = source.split('CONFIG_YAML=$(cat <