diff --git a/history/2026-08-23-keycape-exposure-dependency-map.md b/history/2026-08-23-keycape-exposure-dependency-map.md index 6c9d7a4..f4fb6fa 100644 --- a/history/2026-08-23-keycape-exposure-dependency-map.md +++ b/history/2026-08-23-keycape-exposure-dependency-map.md @@ -126,6 +126,12 @@ material. - A named operator window, driver, abort operator, provider authority, and protected verification method are still required before any live action. +NetKingdom added +`sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh` as the bounded +resolver-only operation. It requires explicit `--apply` plus an interactive +terminal, passes password files by pathname to a child process, updates only +`lldap-coulomb`, and emits only a boolean result. It has not been run. + ## Owner-reported cutover outcome KeyCape reported on 2026-08-23 that its governed recovery path completed the diff --git a/sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh b/sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh new file mode 100755 index 0000000..1f88859 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# update-lldap-resolver-live.sh — attended, resolver-only privacyIDEA update. +# +# This does not repair realms or policies. It updates only the persisted +# lldap-coulomb resolver after the LLDAP bind credential has changed. +# +# Usage: +# ./update-lldap-resolver-live.sh --apply +# +# The operator supplies both passwords interactively. Values are kept in a +# mode-0700 temporary directory and mode-0600 files, passed to a child process +# by pathname, and never printed or placed in command arguments. + +set -euo pipefail + +if [[ "${1:-}" != "--apply" || "${2:-}" != "" ]]; then + echo "Usage: $0 --apply" >&2 + exit 2 +fi +if [[ ! -t 0 ]]; then + echo "ERROR: --apply requires an interactive terminal." >&2 + exit 2 +fi + +PI_URL="${PI_URL:-https://pink.coulomb.social}" +RESOLVER_NAME="${RESOLVER_NAME:-lldap-coulomb}" +LLDAP_URL="${LLDAP_URL:-ldap://lldap.sso.svc.cluster.local:3890}" +LLDAP_BASE_DN="${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" +LLDAP_BIND_DN="${LLDAP_BIND_DN:-uid=admin,ou=people,dc=netkingdom,dc=local}" + +tmp="$(mktemp -d)" +chmod 700 "$tmp" +cleanup() { + if [[ -f "$tmp/pi-admin" ]]; then shred -u "$tmp/pi-admin" 2>/dev/null || rm -f "$tmp/pi-admin"; fi + if [[ -f "$tmp/lldap-bind" ]]; then shred -u "$tmp/lldap-bind" 2>/dev/null || rm -f "$tmp/lldap-bind"; fi + rmdir "$tmp" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +printf 'privacyIDEA pi-admin password: ' >&2 +IFS= read -r -s pi_admin_password +printf '\n' >&2 +printf 'LLDAP bind/admin password: ' >&2 +IFS= read -r -s lldap_bind_password +printf '\n' >&2 +if [[ -z "$pi_admin_password" || -z "$lldap_bind_password" ]]; then + echo "ERROR: passwords must not be empty." >&2 + exit 2 +fi +printf '%s' "$pi_admin_password" > "$tmp/pi-admin" +printf '%s' "$lldap_bind_password" > "$tmp/lldap-bind" +unset pi_admin_password lldap_bind_password +chmod 600 "$tmp/pi-admin" "$tmp/lldap-bind" + +python3 - "$tmp/pi-admin" "$tmp/lldap-bind" "$PI_URL" "$RESOLVER_NAME" \ + "$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" <<'PY' +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +pi_path, ldap_path, base_url, resolver, ldap_url, base_dn, bind_dn = sys.argv[1:] + +def read_secret(path: str) -> str: + value = Path(path).read_text(encoding="utf-8") + if not value: + raise RuntimeError("empty protected input") + return value + +def post(path: str, payload: dict, token: str | None = None) -> dict: + headers = {"Content-Type": "application/json"} + if token is not None: + headers["Authorization"] = token + request = urllib.request.Request( + base_url.rstrip("/") + path, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"HTTP status {exc.code}") from None + except (urllib.error.URLError, TimeoutError): + raise RuntimeError("request failed") from None + +try: + auth = post("/auth", {"username": "pi-admin", "password": read_secret(pi_path)}) + token = str(auth.get("result", {}).get("value", {}).get("token", "")) + if not token: + raise RuntimeError("privacyIDEA authentication failed") + + resolver_body = { + "type": "ldapresolver", + "LDAPURI": ldap_url, + "BINDDN": bind_dn, + "BINDPW": read_secret(ldap_path), + "LDAPBASE": base_dn, + "LOGINNAMEATTRIBUTE": "uid", + "LDAPSEARCHFILTER": "(objectClass=inetOrgPerson)", + "LDAPFILTER": "(&(objectClass=inetOrgPerson)(uid=%s))", + "USERINFO": json.dumps({ + "username": "uid", + "phone": "telephoneNumber", + "mobile": "mobile", + "email": "mail", + "surname": "sn", + "givenname": "givenName", + }), + "UIDTYPE": "uid", + "NOREFERRALS": True, + "NOSCHEMAS": True, + } + result = post(f"/resolver/{resolver}", resolver_body, token) + status = result.get("result", {}).get("status") + if status not in (True, "true", "True"): + raise RuntimeError("privacyIDEA resolver update was rejected") +except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) + +print("privacyIDEA resolver update: PASS") +PY diff --git a/workplans/NK-WP-0033-keycape-secret-exposure-rotation.md b/workplans/NK-WP-0033-keycape-secret-exposure-rotation.md index 15a9185..216d474 100644 --- a/workplans/NK-WP-0033-keycape-secret-exposure-rotation.md +++ b/workplans/NK-WP-0033-keycape-secret-exposure-rotation.md @@ -117,6 +117,12 @@ clients are a list, so the verifier now uses Authelia's supported template filter over the Secret-mounted file. The revision was applied live and Authelia returned Ready with startup complete. +NetKingdom also added the unattended-safe shape of the remaining provider +operation in `sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh`. It is +explicitly gated by `--apply`, requires an interactive terminal, uses protected +0600 files, updates only `lldap-coulomb`, and emits no credential values. It +has not been run; attended provider-admin reconciliation remains pending. + Do not use `sso-mfa/bootstrap/creds-rotate.sh` through an agent as currently written: it prints generated replacement values and its signing-key path causes immediate invalidation. Do not use a helper that reads the live Secret