diff --git a/.gitignore b/.gitignore index 991ee26..2d105c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ secrets/ !sso-mfa/bootstrap/secrets.enc/**/*.age *.kdbx +# Local non-secret operator progress; keep workstation-specific setup state out +# of Git while preserving it across UI/server restarts. +.local/ + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -181,4 +185,3 @@ cython_debug/ # PyPI configuration file .pypirc - diff --git a/Makefile b/Makefile index 58f4585..3950076 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ SHELL := /usr/bin/env bash # Operator age public key — used as bundle encryption recipient OPERATOR_AGE_PUBKEY := $(shell cat keys/age.pub 2>/dev/null | tr -d '[:space:]') +SECURITY_BOOTSTRAP_METADATA ?= $(if $(METADATA),$(METADATA),.local/security-bootstrap.json) +SECURITY_BOOTSTRAP_HOST ?= $(if $(HOST),$(HOST),127.0.0.1) +SECURITY_BOOTSTRAP_PORT ?= $(if $(PORT),$(PORT),8876) # ── Help ────────────────────────────────────────────────────────────────────── help: ## Show this help @@ -156,22 +159,22 @@ iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests playbook-contract-test: ## Run Playbook Capability Contract fixture tests python3 -m pytest tools/playbook-capability-contract/tests -security-bootstrap-console: ## Show guided security bootstrap status and safe actions - python3 tools/security-bootstrap-console/security_bootstrap_console.py status +security-bootstrap-console: security-bootstrap-metadata-init ## Show guided security bootstrap status and safe actions + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ + status security-bootstrap-king-kit: ## Print the king credential kit checklist python3 tools/security-bootstrap-console/security_bootstrap_console.py king-kit -security-bootstrap-validate-kit: ## Validate non-secret king credential metadata: make security-bootstrap-validate-kit METADATA=/tmp/security-bootstrap.json - @[[ -n "$(METADATA)" ]] || (echo "Usage: make security-bootstrap-validate-kit METADATA=/path/to/non-secret.json"; exit 1) +security-bootstrap-validate-kit: ## Validate non-secret king credential metadata python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata "$(METADATA)" \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ validate-king-kit -security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody METADATA=/tmp/security-bootstrap.json ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed" - @[[ -n "$(METADATA)" ]] || (echo "Usage: make security-bootstrap-approve-custody METADATA=/path/to/non-secret.json ARGS='--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed'"; exit 1) +security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed" python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata "$(METADATA)" \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ approve-custody-mode \ --mode "$(if $(MODE),$(MODE),temporary-single-king)" \ $(ARGS) @@ -183,12 +186,25 @@ security-bootstrap-openbao-preflight: ## Show safe OpenBao preflight commands python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-preflight \ --railiance-path ../railiance-platform -security-bootstrap-ui: ## Serve local custody approval UI: make security-bootstrap-ui METADATA=/tmp/security-bootstrap.json PORT=8765 +security-bootstrap-metadata-init: ## Create durable local non-secret bootstrap metadata if missing + @mkdir -p "$$(dirname "$(SECURITY_BOOTSTRAP_METADATA)")" + @if [[ -f "$(SECURITY_BOOTSTRAP_METADATA)" ]]; then \ + echo "✔ Metadata already exists: $(SECURITY_BOOTSTRAP_METADATA)"; \ + elif [[ -f /tmp/net-kingdom-security-bootstrap.json ]]; then \ + cp /tmp/net-kingdom-security-bootstrap.json "$(SECURITY_BOOTSTRAP_METADATA)"; \ + echo "✔ Imported previous /tmp bootstrap metadata to $(SECURITY_BOOTSTRAP_METADATA)"; \ + else \ + python3 tools/security-bootstrap-console/security_bootstrap_console.py metadata-template \ + > "$(SECURITY_BOOTSTRAP_METADATA)"; \ + echo "✔ Created metadata: $(SECURITY_BOOTSTRAP_METADATA)"; \ + fi + +security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody approval UI on localhost:8876: make security-bootstrap-ui python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata "$(if $(METADATA),$(METADATA),/tmp/net-kingdom-security-bootstrap.json)" \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ web-ui \ - --host "$(if $(HOST),$(HOST),127.0.0.1)" \ - --port "$(if $(PORT),$(PORT),8765)" + --host "$(SECURITY_BOOTSTRAP_HOST)" \ + --port "$(SECURITY_BOOTSTRAP_PORT)" .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 \ @@ -198,4 +214,4 @@ security-bootstrap-ui: ## Serve local custody approval UI: make security-bootstr security-bootstrap-console security-bootstrap-king-kit \ security-bootstrap-validate-kit security-bootstrap-approve-custody \ security-bootstrap-custody-packet security-bootstrap-openbao-preflight \ - security-bootstrap-ui + security-bootstrap-metadata-init security-bootstrap-ui diff --git a/sso-mfa/k8s/authelia/configmap.yaml b/sso-mfa/k8s/authelia/configmap.yaml index ca1a675..25949d2 100644 --- a/sso-mfa/k8s/authelia/configmap.yaml +++ b/sso-mfa/k8s/authelia/configmap.yaml @@ -114,7 +114,7 @@ data: - groups grant_types: - authorization_code - token_endpoint_auth_method: client_secret_post + token_endpoint_auth_method: client_secret_basic response_types: - code response_modes: diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index c29d5ad..2b019cd 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -98,6 +98,21 @@ bash ./create-secrets.sh kubectl rollout restart deployment/keycape -n sso ``` +If the browser flow reaches the KeyCape OTP screen and then reports +`mfa check error`, refresh the live privacyIDEA token without printing it: + +```bash +cd sso-mfa/k8s/keycape +KEYCAPE_PI_REALM=coulomb KUBECTL="${KUBECTL:-kubectl}" \ + bash ./refresh-pi-token-live.sh platform-root +``` + +The helper prompts for the `pi-admin` password, writes the token only into +Kubernetes Secrets, and restarts KeyCape. The current live privacyIDEA realm is +`coulomb`; use `KEYCAPE_PI_REALM=netkingdom` only for an explicit future realm +migration. The helper also restores `privacyidea.requireForAll: true`, which +keeps KeyCape from using the admin token-list API as the MFA-required check. + ## OIDC client registration Downstream applications are registered in the `clients:` block in diff --git a/sso-mfa/k8s/keycape/configure-openbao-oidc.sh b/sso-mfa/k8s/keycape/configure-openbao-oidc.sh index 3c39d0e..07f8772 100644 --- a/sso-mfa/k8s/keycape/configure-openbao-oidc.sh +++ b/sso-mfa/k8s/keycape/configure-openbao-oidc.sh @@ -42,6 +42,9 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}" oidc_client_secret="keycape-public-pkce-compatibility-value" \ default_role="platform-admin" + # Keep array-valued groups in groups_claim/bound_claims only. OpenBao + # claim_mappings copy scalar claim values into metadata and will fail if the + # groups array is mapped there. cat >/tmp/openbao-platform-admin-role.json <<'"'"'ROLE_JSON'"'"' { "role_type": "oidc", @@ -57,8 +60,7 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}" }, "claim_mappings": { "email": "email", - "preferred_username": "username", - "groups": "groups" + "preferred_username": "username" }, "policies": ["platform-admin"], "ttl": "1h" diff --git a/sso-mfa/k8s/keycape/deployment.yaml b/sso-mfa/k8s/keycape/deployment.yaml index 8b63d9f..7b31523 100644 --- a/sso-mfa/k8s/keycape/deployment.yaml +++ b/sso-mfa/k8s/keycape/deployment.yaml @@ -54,7 +54,7 @@ spec: # 2026-05-24: direct-imported into railiance01 k3s for the # bootstrap-console OIDC/MFA rollout. Use IfNotPresent while the # HTTP registry push/pull path is being cleaned up. - image: 92.205.130.254:32166/coulomb/key-cape:main-06d20c3 + image: 92.205.130.254:32166/coulomb/key-cape:main-nonce-0601 imagePullPolicy: IfNotPresent ports: diff --git a/sso-mfa/k8s/keycape/openbao-client-config.py b/sso-mfa/k8s/keycape/openbao-client-config.py index b63d50e..8fb69af 100644 --- a/sso-mfa/k8s/keycape/openbao-client-config.py +++ b/sso-mfa/k8s/keycape/openbao-client-config.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Patch or verify the KeyCape openbao-admin client in a live Secret. +"""Patch or verify non-secret KeyCape live config requirements. The script reads a Kubernetes Secret JSON object from stdin. It never prints the decoded KeyCape config or private key; stdout is either a JSON merge patch for @@ -32,6 +32,11 @@ OPENBAO_CLIENT = { "clientType": "public", } +LLDAP_REQUIRED = { + "userOU": "ou=people", + "groupOU": "ou=groups", +} + def load_config() -> dict[str, Any]: secret = json.load(sys.stdin) @@ -83,8 +88,28 @@ def upsert_client(config: dict[str, Any]) -> dict[str, Any]: return config +def lldap_errors(config: dict[str, Any]) -> list[str]: + lldap = config.get("lldap") + if not isinstance(lldap, dict): + return ["lldap must be a mapping"] + return [ + f"lldap.{key} should be {expected!r}" + for key, expected in LLDAP_REQUIRED.items() + if lldap.get(key) != expected + ] + + +def enforce_lldap_defaults(config: dict[str, Any]) -> dict[str, Any]: + lldap = config.get("lldap") + if not isinstance(lldap, dict): + lldap = {} + config["lldap"] = lldap + lldap.update(LLDAP_REQUIRED) + return config + + def render_patch(config: dict[str, Any]) -> None: - updated = upsert_client(config) + updated = enforce_lldap_defaults(upsert_client(config)) config_text = yaml.safe_dump(updated, sort_keys=False) encoded = base64.b64encode(config_text.encode("utf-8")).decode("ascii") json.dump({"data": {"config.yaml": encoded}}, sys.stdout, separators=(",", ":")) @@ -92,12 +117,12 @@ def render_patch(config: dict[str, Any]) -> None: def verify(config: dict[str, Any]) -> None: - errors = client_errors(config) + errors = client_errors(config) + lldap_errors(config) if errors: for error in errors: print(f"[FAIL] {error}") raise SystemExit(1) - print("[PASS] openbao-admin client has required CLI redirects, scopes, grant type, and public PKCE profile") + print("[PASS] openbao-admin client and LLDAP OU lookup settings are present") def main() -> None: diff --git a/sso-mfa/k8s/keycape/patch-openbao-client.sh b/sso-mfa/k8s/keycape/patch-openbao-client.sh index 370ed70..50933cd 100644 --- a/sso-mfa/k8s/keycape/patch-openbao-client.sh +++ b/sso-mfa/k8s/keycape/patch-openbao-client.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Patch the live KeyCape config Secret with the code-defined OpenBao CLI client. +# Patch the live KeyCape config Secret with non-secret code-defined settings: +# the OpenBao CLI client and LLDAP OU lookup paths. # This does not require decrypted bootstrap secrets and does not print existing # Secret values. @@ -14,4 +15,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | python3 "$SCRIPT_DIR/openbao-client-config.py" patch \ | "$KUBECTL" patch secret "$SECRET" -n "$NAMESPACE" --type merge --patch-file /dev/stdin -echo "Patched $NAMESPACE/$SECRET with the openbao-admin client definition." +echo "Patched $NAMESPACE/$SECRET with the openbao-admin client and LLDAP OU lookup settings." diff --git a/sso-mfa/k8s/keycape/refresh-pi-token-live.sh b/sso-mfa/k8s/keycape/refresh-pi-token-live.sh index a64f340..db5071c 100644 --- a/sso-mfa/k8s/keycape/refresh-pi-token-live.sh +++ b/sso-mfa/k8s/keycape/refresh-pi-token-live.sh @@ -12,7 +12,8 @@ # # Optional environment: # KUBECTL=/path/to/kubectl -# KEYCAPE_PI_REALM=coulomb|netkingdom +# KEYCAPE_PI_REALM=coulomb|netkingdom # defaults to coulomb, the live privacyIDEA realm +# KEYCAPE_PI_REQUIRE_FOR_ALL=true|false # defaults to true to avoid admin token-list checks set -euo pipefail @@ -94,49 +95,67 @@ current_config="$( current_realm="$( CONFIG_YAML="$current_config" python3 -c ' import os -import re +import sys -match = re.search(r"(?m)^ realm:\s*[\"'\'']?([^\"'\'']+)", os.environ["CONFIG_YAML"]) -print(match.group(1).strip() if match else "") +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:-}" -if [[ -z "$selected_realm" && -n "$current_realm" ]]; then - selected_realm="$current_realm" -fi -if [[ -z "$selected_realm" ]]; then - selected_realm="coulomb" +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" \ +CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \ 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) +try: + import yaml +except ImportError: + print("PyYAML is required: install python3-yaml", file=sys.stderr) sys.exit(1) -sys.stdout.write(config) + +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 ..." diff --git a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh index dd9dfe5..a6ce60c 100755 --- a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh +++ b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh @@ -68,11 +68,13 @@ fi # ── Authenticate ────────────────────────────────────────────────────────────── echo "" echo "Authenticating to privacyIDEA at $PI_URL ..." -AUTH_RESPONSE=$(curl -sf -X POST "$PI_URL/auth" \ +if ! AUTH_RESPONSE=$(PI_ADMIN_PASS="$PI_ADMIN_PASS" python3 -c ' +import json +import os +print(json.dumps({"username": "pi-admin", "password": os.environ["PI_ADMIN_PASS"]})) +' | curl -sS -X POST "$PI_URL/auth" \ -H "Content-Type: application/json" \ - -d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" 2>/dev/null || echo "CURL_FAILED") - -if [[ "$AUTH_RESPONSE" == "CURL_FAILED" ]]; then + --data-binary @- 2>/dev/null); then echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2 echo " Run verify-t04.sh to diagnose." >&2 exit 1 @@ -94,10 +96,10 @@ pi_api() { # BadRequest if Content-Type: application/json is sent on a bodyless GET. local method="$1"; local path="$2"; local body="${3:-}" if [[ -n "$body" ]]; then - curl -sf -X "$method" "$PI_URL$path" \ + printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \ -H "Authorization: $PI_TOKEN" \ -H "Content-Type: application/json" \ - -d "$body" 2>/dev/null || echo "CURL_FAILED" + --data-binary @- 2>/dev/null || echo "CURL_FAILED" else curl -sf -X "$method" "$PI_URL$path" \ -H "Authorization: $PI_TOKEN" \ @@ -136,13 +138,13 @@ echo "Step 1: Creating LDAP resolver '$RESOLVER_NAME' ..." # LLDAP uses standard inetOrgPerson attributes. USERINFO='{"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}' -RESOLVER_BODY=$(python3 -c " -import json, sys +RESOLVER_BODY=$(LLDAP_BIND_PW="$LLDAP_BIND_PW" python3 -c " +import json, os body = { 'type': 'ldapresolver', 'LDAPURI': '$(echo "$LLDAP_URL" | sed "s/'/'\\''/g")', 'BINDDN': '$(echo "$LLDAP_BIND_DN" | sed "s/'/'\\''/g")', - 'BINDPW': sys.argv[1], + 'BINDPW': os.environ['LLDAP_BIND_PW'], 'LDAPBASE': '$LLDAP_BASE_DN', 'LOGINNAMEATTRIBUTE': 'uid', 'LDAPSEARCHFILTER': '(objectClass=inetOrgPerson)', @@ -153,7 +155,7 @@ body = { 'NOSCHEMAS': True } print(json.dumps(body)) -" "$LLDAP_BIND_PW") +") RESP=$(pi_api POST "/resolver/$RESOLVER_NAME" "$RESOLVER_BODY") check_result "LDAP resolver '$RESOLVER_NAME' created/updated" "$RESP" || true diff --git a/sso-mfa/k8s/privacyidea/repair-realm-live.sh b/sso-mfa/k8s/privacyidea/repair-realm-live.sh new file mode 100755 index 0000000..8854385 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/repair-realm-live.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# repair-realm-live.sh - attended repair for privacyIDEA realm bootstrap state. +# +# This wrapper prompts for live passwords, writes them only to a private +# temporary directory, runs the idempotent realm bootstrap, and removes the +# temporary files on exit. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +SSO_MFA_K8S_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +PI_URL="${PI_URL:-https://pink.coulomb.social}" + +if [[ ! -t 0 ]]; then + echo "ERROR: repair-realm-live.sh needs an interactive terminal for password prompts." >&2 + exit 1 +fi + +export PATH="/home/worsch/.local/bin:$PATH" +umask 077 + +tmp="$(mktemp -d)" +cleanup() { + rm -rf "$tmp" + unset PI_ADMIN_PASSWORD LLDAP_LDAP_USER_PASS +} +trap cleanup EXIT + +mkdir -p "$tmp/privacyidea" "$tmp/lldap" + +printf "privacyIDEA pi-admin password: " >&2 +read -rs PI_ADMIN_PASSWORD +printf "\n" >&2 +printf "LLDAP bind/admin password: " >&2 +read -rs LLDAP_LDAP_USER_PASS +printf "\n" >&2 + +printf "PI_ADMIN_PASSWORD=%q\n" "$PI_ADMIN_PASSWORD" > "$tmp/privacyidea/secrets.env" +printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets.env" + +bash "$SCRIPT_DIR/bootstrap-realm.sh" "$tmp" "$PI_URL" + +if ! bash "$SSO_MFA_K8S_DIR/verify-t06.sh" "$tmp"; then + cat >&2 <<'WARN' + +[WARN] verify-t06 still reports failures. If realm, resolver, policies, and +self-service pass but KeyCape token checks fail, run the KeyCape privacyIDEA +MFA token repair action after platform-root enrollment. +WARN +fi + +cat <<'OK' + +[OK] privacyIDEA coulomb realm repair command finished. Enroll or re-enroll +platform-root TOTP in privacyIDEA next. +OK diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index 05ee041..da2fa00 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -23,7 +23,7 @@ Validate non-secret kit metadata: ```bash python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata /tmp/security-bootstrap.json \ + --metadata .local/security-bootstrap.json \ validate-king-kit ``` @@ -31,7 +31,7 @@ Approve custody mode from the CLI: ```bash python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata /tmp/security-bootstrap.json \ + --metadata .local/security-bootstrap.json \ approve-custody-mode \ --mode temporary-single-king \ --mfa-enrolled-confirmed \ @@ -69,12 +69,14 @@ from local metadata and plaintext bootstrap-secret presence. Serve the local approval UI: ```bash -python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata /tmp/security-bootstrap.json \ - web-ui +make security-bootstrap-ui ``` -Open `http://127.0.0.1:8765`. +Open `http://127.0.0.1:8876`. + +The Make target stores non-secret progress in `.local/security-bootstrap.json`. +That directory is intentionally ignored by Git so local setup state survives +UI/server restarts without being committed. The web UI is structured as: @@ -85,7 +87,8 @@ The web UI is structured as: 3. **Integration & Tests** - OIDC and OpenBao preflight checks, with every operator command shown as a copyable console block. 4. **Usecases & Runbooks** - guided routines for key-material compromise, - trial-output exposure, and generating replacement unseal keys. + trial-output exposure, replacement unseal keys, and OpenBao token + revocation. 5. **Artefacts & Locations** - final non-secret overview of established artefacts and where to find their custody references. @@ -106,6 +109,12 @@ mark the trial output as exposed, stop treating the generated unseal shares or root token as production material, then either rotate unseal keys after unseal or reset the trial environment before any live secrets are migrated. +The **OpenBao token revocation** runbook includes a self-revoke action for the +token currently stored in the OpenBao pod token helper and an accessor-based +revocation action for accidentally disclosed tokens. The accessor path prompts +inside the pod for a root/sudo-capable OpenBao token and avoids placing token +values on the local command line. + The UI is a guide and approval surface, not the identity provider. Current lightweight-mode credential placement is: @@ -147,6 +156,16 @@ key and verify the factor. Admin-assisted token assignment is a fallback only; record it as the MFA enrollment source, but never record the seed, QR code, or recovery codes in this UI. +If the live privacyIDEA instance has lost the `coulomb` realm, LLDAP resolver, +or self-service policies, open **Usecases & Runbooks** and copy **Repair +privacyIDEA realm and self-service**. The action is attended: it prompts for +the `pi-admin` password and the LLDAP bind/admin password, writes them only to a +private temporary directory, runs +`sso-mfa/k8s/privacyidea/repair-realm-live.sh`, removes the temporary files on +exit, and then runs `sso-mfa/k8s/verify-t06.sh`. The UI does not store either +password, and TOTP enrollment or re-enrollment remains a human step in +`https://pink-account.coulomb.social`. + After doing that, return to the control surface, set account reference `platform-root@lldap`, check `Account created`, `Admin group assigned`, and `Password stored`, then save progress. @@ -207,10 +226,10 @@ Optional non-secret metadata can be supplied: ```bash python3 tools/security-bootstrap-console/security_bootstrap_console.py metadata-template \ - > /tmp/security-bootstrap.json + > .local/security-bootstrap.json python3 tools/security-bootstrap-console/security_bootstrap_console.py \ - --metadata /tmp/security-bootstrap.json \ + --metadata .local/security-bootstrap.json \ status ``` diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 632e73d..95e5863 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -29,8 +29,8 @@ from typing import Any DEFAULT_STAGE = "S1 - Low-trust assembly" STAGE_ORDER = ("S1", "S2", "S3", "S4", "S5", "S6") -DEFAULT_METADATA_PATH = Path("/tmp/net-kingdom-security-bootstrap.json") REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_METADATA_PATH = REPO_ROOT / ".local/security-bootstrap.json" APPROVAL_PHRASE = "approve custody mode" VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"} VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"} @@ -48,6 +48,7 @@ OIDC_SCOPE = "openid profile email groups" OIDC_CODE_VERIFIER = "netkingdom-bootstrap-local-oidc-verifier-2026-v1" KEYCAPE_OPENBAO_CLIENT_ID = "openbao-admin" KEYCAPE_OPENBAO_CLIENT_CONFIG = REPO_ROOT / "sso-mfa/k8s/keycape/create-secrets.sh" +PRIVACYIDEA_REALM_REPAIR = REPO_ROOT / "sso-mfa/k8s/privacyidea/repair-realm-live.sh" KEYCAPE_OPENBAO_CLIENT_REDIRECTS = ( "http://localhost:8250/oidc/callback", "http://127.0.0.1:8250/oidc/callback", @@ -1410,18 +1411,21 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str] refresh_pi_token_command = ( "set -euo pipefail\n" f"cd {keycape_dir}\n" - f"KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\n" + f"KEYCAPE_PI_REALM=coulomb KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\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" - "# Terminal 2: run the pod-bundled bao CLI, then copy the printed login URL into your local browser if needed.\n" - "kubectl exec -it -n openbao openbao-0 -- sh -lc '\n" + "# Terminal 1: start the pod-bundled bao CLI and wait until it prints the login URL.\n" + f"{kubectl_bin} exec -it -n openbao openbao-0 -- sh -lc '\n" " export BAO_ADDR=http://127.0.0.1:8200\n" " bao login -method=oidc -path=keycape role=platform-admin \\\n" " skip_browser=true listenaddress=0.0.0.0 callbackhost=127.0.0.1 port=8250\n" " bao token lookup\n" - "'" + "'\n\n" + "# Terminal 2: start this only after Terminal 1 is waiting for OIDC authentication.\n" + f"{kubectl_bin} -n openbao port-forward pod/openbao-0 8250:8250\n\n" + "# Browser: open the URL printed by Terminal 1. If Firefox already failed at\n" + "# 127.0.0.1:8250 before the port-forward was ready, reload that callback URL\n" + "# or restart Terminal 1 to get a fresh login URL." ) return [ @@ -1714,7 +1718,20 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]: if not initial_config_applied: restore_location = "Template: prepare now; execute after initial OpenBao configuration exists and before live secrets move in." + token_revocation_location = "Template: revoke the current helper token after attended checks, or revoke a disclosed token by accessor using a root/sudo-capable token." + if not initialized: + token_revocation_location = "Template: prepare now; execution needs an initialized OpenBao instance." + return [ + { + "name": "privacyIDEA realm repair", + "description": "Recreate the coulomb realm, LLDAP resolver, and self-service policies without storing live passwords.", + "subsystem": "privacyIDEA", + "responsibility": "identity-admin", + "email": role_email(data, "role_identity_admin_email"), + "location": "Template: run the attended realm repair action, then enroll or re-enroll platform-root TOTP in the repaired realm.", + "state": "template", + }, add_taint( { "name": "Key material compromised", @@ -1763,6 +1780,18 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]: }, openbao_downstream_taint if initialized else {}, ), + add_taint( + { + "name": "OpenBao token revocation", + "description": "Retire short-lived, temporary, or accidentally disclosed OpenBao tokens without putting token values on the local command line.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": token_revocation_location, + "state": "template", + }, + openbao_downstream_taint if initialized else {}, + ), ] @@ -1823,6 +1852,17 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: platform_admin_token_command = token_prompt_command( "bao token create -policy=platform-admin -period=24h -orphan" ) + revoke_self_token_command = ( + "kubectl exec -it -n openbao openbao-0 -- sh -lc '\n" + " export BAO_ADDR=http://127.0.0.1:8200\n" + " bao token lookup >/dev/null\n" + " bao token revoke -self\n" + "'" + ) + revoke_accessor_command = interactive_token_command( + 'printf "Token accessor to revoke: " >&2; read -r TARGET_ACCESSOR; ' + 'bao token revoke -accessor "$TARGET_ACCESSOR"; unset TARGET_ACCESSOR' + ) rotate_init_command = interactive_token_command( "bao operator rotate-keys -init -key-shares=3 -key-threshold=2" ) @@ -1880,8 +1920,14 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "7. Destroy the isolated environment and record only non-secret evidence in this UI.\n" "RESTORE_DRILL" ) + privacyidea_realm_command = f"bash {shlex.quote(str(PRIVACYIDEA_REALM_REPAIR))}" return [ + action( + "Repair privacyIDEA realm and self-service", + "Prompt for pi-admin and LLDAP bind passwords, recreate the coulomb realm, LLDAP resolver, self-enrollment policy, and passthrough bootstrap policy, then run T06 verification. The wrapper keeps passwords in a private temporary directory that is removed on exit.", + privacyidea_realm_command, + ), action( "OpenBao status", "Show seal, initialization, storage, and HA state for the OpenBao pod. This command does not require a token.", @@ -1918,6 +1964,18 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: platform_admin_token_command, downstream_taint, ), + action( + "Revoke current OpenBao token", + "Revoke the token currently stored in the OpenBao pod token helper, usually immediately after an attended verification flow.", + revoke_self_token_command, + downstream_taint, + ), + action( + "Revoke OpenBao token by accessor", + "Prompt inside the pod for a root/sudo-capable token and the disclosed token accessor, then revoke that accessor without placing the token value on the local command line.", + revoke_accessor_command, + downstream_taint, + ), action( "Start unseal-key rotation", "Run once to start a new 3-share, threshold-2 rotation. If rotation is already in progress, do not rerun init; check status and submit existing shares.", @@ -3288,7 +3346,6 @@ def ui_html() -> str: button.type = "button"; button.textContent = "Copy"; button.title = "Copy this console command to the clipboard."; - button.dataset.command = item.command; const commandActions = document.createElement("div"); commandActions.className = "inline-actions"; if (item.status) commandActions.append(makeStateBadge(item.status)); @@ -3477,7 +3534,9 @@ def ui_html() -> str: document.addEventListener("click", async (event) => { const button = event.target.closest(".copy-button"); if (!button) return; - const command = button.dataset.command || ""; + const row = button.closest(".command-row"); + const code = row ? row.querySelector(".command-code") : null; + const command = code ? code.textContent : ""; try { await navigator.clipboard.writeText(command); button.textContent = "Copied"; @@ -3642,6 +3701,8 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]: def serve_web_ui(args: argparse.Namespace) -> int: metadata_path = args.metadata or DEFAULT_METADATA_PATH + if not metadata_path.exists(): + write_metadata(metadata_path, metadata_template()) handler = make_ui_handler(metadata_path) httpd = ThreadingHTTPServer((args.host, args.port), handler) host = html.escape(args.host) @@ -3736,6 +3797,9 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) + metadata_commands = {"status", "validate-king-kit", "approve-custody-mode", "web-ui"} + if args.command in metadata_commands and args.metadata is None: + args.metadata = DEFAULT_METADATA_PATH data = load_metadata(args.metadata) if args.command == "status": diff --git a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md index 4e5964f..ef4ec2f 100644 --- a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md +++ b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: netkingdom created: "2026-05-24" -updated: "2026-05-26" +updated: "2026-06-01" depends_on: - NK-WP-0006 - NK-WP-0012 @@ -407,7 +407,7 @@ remain part of the user-onboarding readiness work in `NET-WP-0017`. ```task id: NET-WP-0015-T06 -status: in_progress +status: done priority: medium state_hub_task_id: "ef97f3cb-9792-4b9d-bd2b-8871d368a50f" ``` @@ -416,12 +416,32 @@ Replace temporary operator tokens with NetKingdom IAM-backed OpenBao admin auth when the issuer and claim mapping are ready. The OpenBao root token must not be the normal admin path. -**2026-05-26:** The KeyCape `openbao-admin` client is code-defined, patched +**2026-05-26:** The KeyCape `openbao-admin` client was code-defined, patched into the live `keycape-config` Secret, rolled out, and verified without -requiring decrypted bootstrap secrets. This task remains in progress because -OpenBao `auth/keycape` still needs the fixed helper command to complete and -the MFA-backed `bao login -method=oidc -path=keycape role=platform-admin` path -still needs verification. +requiring decrypted bootstrap secrets. At that point, OpenBao `auth/keycape` +still needed the fixed helper command and the MFA-backed +`bao login -method=oidc -path=keycape role=platform-admin` path still needed +verification. + +**2026-06-01:** Added a guided bootstrap runbook action for the live +privacyIDEA state-loss case encountered during OpenBao OIDC login testing. The +new action recreates the `coulomb` realm, `lldap-coulomb` resolver, +self-enrollment policy, and phase-one passthrough policy by prompting for +`pi-admin` and LLDAP bind/admin passwords, writing them only to temporary +files through `repair-realm-live.sh`, and running `bootstrap-realm.sh` plus +`verify-t06.sh`. TOTP enrollment/re-enrollment and the final MFA-backed +OpenBao login verification remain operator steps. + +**2026-06-01:** Closed after the `platform-root` MFA-backed OpenBao OIDC login +completed through KeyCape and the resulting token lookup showed +`platform-admin` in both token policy fields. The remaining OpenBao hardening, +audit, escrow, reset/rotation, and reopening gates continue under T07/T08 and +`NET-WP-0017`. + +**2026-06-01:** Added OpenBao token revocation to the guided +Usecases & Runbooks section. The UI now includes a self-revoke card for the +current pod token-helper token and an accessor-based revocation card for +disclosed tokens, both keeping OpenBao token values off the local command line. ### T07 - Verify Recovery, Audit, And Rotation diff --git a/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md b/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md index 1f9e16d..1575bf1 100644 --- a/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md +++ b/workplans/NET-WP-0017-it-security-readiness-for-user-onboarding.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: netkingdom created: "2026-05-26" -updated: "2026-05-29" +updated: "2026-06-01" depends_on: - NET-WP-0015 - NET-WP-0016 @@ -40,8 +40,9 @@ first non-root onboarding dry run must prove the lifecycle model. - Trial unseal shares were rotated. - The KeyCape `openbao-admin` client is live and verified, including the public `https://kc.coulomb.social` route and certificate. -- OpenBao OIDC auth configuration is applied; MFA-backed OpenBao admin login is - still pending. +- OpenBao OIDC auth configuration is applied; MFA-backed OpenBao admin login + completed successfully and the resulting token lookup showed the + `platform-admin` policy for `platform-root`. - Declarative/durable audit handling, residual taint closeout, cleanup/rotation, and the first ordinary-user onboarding dry run are still pending. @@ -51,7 +52,7 @@ first non-root onboarding dry run must prove the lifecycle model. ```task id: NET-WP-0017-T01 -status: in_progress +status: done priority: high state_hub_task_id: "9b087bbd-631b-4316-b94d-a8265a05b065" ``` @@ -74,6 +75,51 @@ the OpenBao `auth/keycape` OIDC configuration and `platform-admin` role. The remaining T01 gate is the human browser login with MFA and a token lookup that shows the expected OpenBao `platform-admin` policy. +**2026-06-01:** Added a guided console recovery action for the observed +privacyIDEA state-loss blocker: if the live instance lacks the `coulomb` realm, +LLDAP resolver, or self-service policies, the operator can run **Repair +privacyIDEA realm and self-service** from **Usecases & Runbooks**. The action +does not store secrets; it calls `repair-realm-live.sh`, prompts live, creates +temporary env files for `bootstrap-realm.sh`, removes them on exit, and then +runs `verify-t06.sh`. After repair, `platform-root` TOTP +enrollment/re-enrollment and the MFA-backed `bao login` proof are still +required. + +**2026-06-01:** Fixed the follow-up OpenBao OIDC token exchange +`user not found` error caused by live `keycape-config` drift: the Secret had +lost the non-secret LLDAP lookup fields `userOU: ou=people` and +`groupOU: ou=groups`. The KeyCape live patch helper now enforces those fields +alongside the `openbao-admin` client, the live Secret was patched, KeyCape was +restarted, and `verify-openbao-client.sh` passes again. + +**2026-06-01:** Deployed a KeyCape runtime lookup fix for the remaining +`user not found` token-exchange failure after config drift was ruled out. The +LDAP adapter now treats provisioning metadata validation failures as runtime +warnings instead of blocking token issuance for an otherwise resolved LLDAP +user. The patched image `main-runtime-lookup-0601` is live and +`verify-openbao-client.sh` passes after rollout. + +**2026-06-01:** Deployed the follow-up KeyCape OIDC nonce fix after OpenBao +rejected the exchanged ID token with `invalid id_token nonce`. KeyCape now +persists the original authorization `nonce` through pending state and the +authorization-code session, then emits it in the ID token. The patched image +`main-nonce-0601` is live, reports 1/1 ready, and `verify-openbao-client.sh` +passes after rollout. + +**2026-06-01:** Fixed the next OpenBao role configuration failure, +`error converting claim 'groups' to string`. KeyCape correctly emits `groups` +as an array for `groups_claim`; OpenBao only failed because the role also copied +that array through scalar `claim_mappings`. The helper now leaves groups in +`groups_claim`/`bound_claims` and maps only scalar `email` and +`preferred_username` metadata. + +**2026-06-01:** The operator reached the OpenBao success page, "Signed in via +your OIDC provider", after reapplying the corrected role. The follow-up +terminal proof showed `token_policies`/`policies` containing `platform-admin`, +`token_meta_role: platform-admin`, and `token_meta_username: platform-root`. +T01 is closed; the pasted short-lived token should be treated as disclosed and +revoked or allowed to expire after the check. + ### T02 - Close OpenBao Audit And Recovery Production Gates ```task