diff --git a/Makefile b/Makefile index 05b31b9..6a2ce5a 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,9 @@ service-identity-test: ## Run service-identity schema/validator tests (NK-WP-005 service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline) python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml +bind-split-test: ## LLDAP bind-split helper tests on a throwaway pinned LLDAP (NK-WP-0054-T05; needs docker) + uv run --with pytest --with ldap3 --with pyyaml python -m pytest sso-mfa/k8s/lldap/test_bind_split.py sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py + rotation-lanes-test: ## Run rotation-lane register tests (NK-WP-0055-T15) python3 -m pytest tools/rotation-lanes/tests @@ -374,7 +377,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a --host "$(SECURITY_BOOTSTRAP_HOST)" \ --port "$(SECURITY_BOOTSTRAP_PORT)" -.PHONY: runbooks-validate rotation-lanes-test rotation-lanes-check help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \ +.PHONY: runbooks-validate bind-split-test rotation-lanes-test rotation-lanes-check 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 \ creds-agent-init creds-agent-status creds-emergency-reprint \ diff --git a/sso-mfa/bootstrap/apply-service-secrets.py b/sso-mfa/bootstrap/apply-service-secrets.py index 1e78a92..9281bd9 100644 --- a/sso-mfa/bootstrap/apply-service-secrets.py +++ b/sso-mfa/bootstrap/apply-service-secrets.py @@ -30,8 +30,8 @@ TARGETS = { ROTATIONS = { 'postgresql': {'PI_DB_PASSWORD': 'password'}, 'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'}, - 'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET'}, - 'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret'}, + 'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS': 'LLDAP_LDAP_USER_PASS'}, + 'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret', 'LLDAP_SVC_AUTHELIA_PASS': 'ldap_password'}, } ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' diff --git a/sso-mfa/k8s/lldap/bind-split.py b/sso-mfa/k8s/lldap/bind-split.py new file mode 100644 index 0000000..956d078 --- /dev/null +++ b/sso-mfa/k8s/lldap/bind-split.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Founder-run directory side of the LLDAP bind split (NK-WP-0054-T05). + +Subcommands, in runbook order: + + prepare-custody ROOT add LLDAP_SVC_*_PASS and a new ld-admin LLDAP_LDAP_USER_PASS to the + private custody copy; refuses if already prepared. Prints names only. + plan --endpoint URL read-only: which target accounts exist, with which groups. + accounts --endpoint URL ROOT [--apply] + create ld-admin and the svc-* accounts, set their groups and their + passwords from custody. Without --apply it only reports what it would do. + verify --endpoint URL ROOT [--old-admin-retired] + every target account signs in with its custody password and holds exactly + its groups; `admin` keeps lldap_admin; optionally the old shared password + no longer signs in as `admin`. + +The human `admin` signs in at a hidden prompt (or `--admin-password-fd` for an attended pipe); the +helper never takes a password on argv and never prints one. Each run writes a value-free receipt. +Passwords are set with the pinned image's own `lldap_set_password` (OPAQUE), run locally, with the +password in its environment only. This tool changes the directory, not any Kubernetes object. +""" + +from __future__ import annotations + +import argparse +import getpass +import hashlib +import importlib.util +import json +import os +import secrets +import string +import subprocess +import sys +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +IMAGE = "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" +EMAIL_DOMAIN = "service.netkingdom.invalid" +# account -> (custody key, groups it must hold exactly) +TARGETS = { + "ld-admin": ("LLDAP_LDAP_USER_PASS", ["lldap_admin"]), + "svc-authelia": ("LLDAP_SVC_AUTHELIA_PASS", ["lldap_strict_readonly"]), + "svc-keycape": ("LLDAP_SVC_KEYCAPE_PASS", ["lldap_strict_readonly"]), + "svc-privacyidea": ("LLDAP_SVC_PRIVACYIDEA_PASS", ["lldap_strict_readonly"]), + "svc-provisioner": ( + "LLDAP_SVC_PROVISIONER_PASS", + ["lldap_admin"], + ), # exception SI-X6 +} +SERVICE_KEYS = [key for key, _ in TARGETS.values() if key != "LLDAP_LDAP_USER_PASS"] +# apply-service-secrets.literal() accepts only [A-Za-z0-9]{32,128}. +ALPHABET = string.ascii_letters + string.digits + + +class Refused(Exception): + pass + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +custody_module = _load( + "bind_split_custody", ROOT / "sso-mfa/bootstrap/apply-service-secrets.py" +) +lldap_admin = _load("bind_split_lldap_admin", HERE / "lldap_admin.py") + + +def new_value(): + return "".join(secrets.choice(ALPHABET) for _ in range(40)) + + +def custody_values(root): + with custody_module.custody(Path(root), ["lldap"]) as directories: + values = custody_module.read_env(directories["lldap"]) + missing = [key for key, _ in TARGETS.values() if not values.get(key)] + if missing: + raise Refused( + "custody lacks " + ", ".join(missing) + "; run prepare-custody first" + ) + return {account: values[key] for account, (key, _) in TARGETS.items()} + + +def prepare_custody(root): + with custody_module.custody(Path(root), ["lldap"]) as directories: + parent = directories["lldap"] + values = custody_module.read_env(parent) + if any(key in values for key in SERVICE_KEYS): + raise Refused("custody is already prepared; nothing changed") + if not values.get("LLDAP_LDAP_USER_PASS"): + raise Refused("custody lacks LLDAP_LDAP_USER_PASS") + lines = [ + line + for line in custody_module.read_private(parent, "secrets.env") + .decode() + .splitlines() + if not line.startswith("LLDAP_LDAP_USER_PASS=") + ] + lines += [ + "# NK-WP-0054-T05 bind split: ld-admin (configured super-admin) and per-consumer accounts.", + "# The human admin password is personal and no longer kept here.", + f"LLDAP_LDAP_USER_PASS={new_value()}", + *(f"{key}={new_value()}" for key in SERVICE_KEYS), + ] + fd = os.open( + ".secrets.env.next", + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=parent, + ) + with os.fdopen(fd, "w") as out: + out.write("\n".join(lines) + "\n") + out.flush() + os.fsync(out.fileno()) + os.replace( + ".secrets.env.next", "secrets.env", src_dir_fd=parent, dst_dir_fd=parent + ) + return {"changed_keys": ["LLDAP_LDAP_USER_PASS", *SERVICE_KEYS]} + + +class Directory: + def __init__(self, endpoint, opener=None): + self.base = lldap_admin.endpoint(endpoint) + self.opener = opener or lldap_admin.Transport(self.base) + + def _post(self, path, payload, token=None): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request( + self.base + path, + data=json.dumps(payload).encode(), + headers=headers, + method="POST", + ) + return json.load(self.opener(request, timeout=15)) + + def login(self, user, password): + try: + return self._post( + "/auth/simple/login", {"username": user, "password": password} + )["token"] + except lldap_admin.Refused: + return None + + def gql(self, token, query, variables=None): + body = self._post( + "/api/graphql", {"query": query, "variables": variables or {}}, token + ) + if body.get("errors"): + raise Refused("directory refused a request") + return body["data"] + + def snapshot(self, token): + users = self.gql(token, "{ users { id email groups { displayName } } }")[ + "users" + ] + groups = self.gql(token, "{ groups { id displayName } }")["groups"] + return ( + { + u["id"]: { + "email": u["email"], + "groups": sorted(g["displayName"] for g in u["groups"]), + } + for u in users + }, + {g["displayName"]: g["id"] for g in groups}, + ) + + +def set_password(endpoint, token, user, password, runner=subprocess.run): + done = runner( + [ + "docker", + "run", + "--rm", + "--pull=never", + "--network", + "host", + "--env", + "LLDAP_USER_PASSWORD", + "--entrypoint", + "/app/lldap_set_password", + IMAGE, + "--base-url", + endpoint, + "--token", + token, + "--username", + user, + ], + env={ + "LLDAP_USER_PASSWORD": password, + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + }, + capture_output=True, + check=False, + ) + if done.returncode: + raise Refused(f"setting the password of {user} failed") + + +def admin_token(directory, fd): + if fd is not None: + with os.fdopen(fd, "r", closefd=True) as stream: + password = stream.readline().rstrip("\n") + else: + password = getpass.getpass("Password of the human admin account: ") + token = directory.login("admin", password) + if token is None: + raise Refused("admin sign-in refused") + return token + + +def assess(users): + report = {} + for account, (_, groups) in TARGETS.items(): + found = users.get(account) + if found is None: + report[account] = {"exists": False, "action": "create"} + elif not found["email"].endswith("@" + EMAIL_DOMAIN): + report[account] = { + "exists": True, + "action": "refuse", + "reason": "email outside " + EMAIL_DOMAIN, + } + else: + extra = sorted(set(found["groups"]) - set(groups)) + report[account] = { + "exists": True, + "groups": found["groups"], + "action": "refuse" + if extra + else ("ok" if found["groups"] == groups else "add-groups"), + **( + {"reason": "unexpected groups " + ", ".join(extra)} if extra else {} + ), + } + report["admin"] = { + "exists": "admin" in users, + "in_lldap_admin": "lldap_admin" in users.get("admin", {}).get("groups", []), + } + return report + + +def accounts(directory, token, passwords, apply, runner=subprocess.run): + users, group_ids = directory.snapshot(token) + report = assess(users) + if any(entry.get("action") == "refuse" for entry in report.values()): + return {"applied": False, "accounts": report} + if not apply: + return {"applied": False, "accounts": report} + for account, (_, groups) in TARGETS.items(): + if not report[account]["exists"]: + directory.gql( + token, + "mutation($u: CreateUserInput!) { createUser(user: $u) { id } }", + { + "u": { + "id": account, + "email": f"{account}@{EMAIL_DOMAIN}", + "displayName": account, + } + }, + ) + have = set(users.get(account, {}).get("groups", [])) + for group in groups: + if group not in have: + directory.gql( + token, + "mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }", + {"u": account, "g": group_ids[group]}, + ) + set_password(directory.base, token, account, passwords[account], runner) + return { + "applied": True, + "accounts": assess(directory.snapshot(token)[0]), + "signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS}, + } + + +def verify(directory, token, passwords, old_admin_password=None): + users, _ = directory.snapshot(token) + result = { + "accounts": assess(users), + "signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS}, + } + if old_admin_password is not None: + result["old_shared_password_refused"] = ( + directory.login("admin", old_admin_password) is None + ) + checks = [ + result["signs_in"][a] and result["accounts"][a].get("action") == "ok" + for a in TARGETS + ] + checks.append(result["accounts"]["admin"]["in_lldap_admin"]) + if old_admin_password is not None: + checks.append(result["old_shared_password_refused"]) + result["verdict"] = "pass" if all(checks) else "fail" + return result + + +def write_receipt(path, command, endpoint, result): + record = { + "schema": "netkingdom.lldap-bind-split.v1", + "task": "NK-WP-0054-T05", + "command": command, + "observed_at": datetime.now(UTC).isoformat(), + "endpoint_sha256": hashlib.sha256(endpoint.encode()).hexdigest() + if endpoint + else None, + "values_recorded": False, + "result": result, + } + fd = os.open( + Path(path).expanduser(), + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + ) + with os.fdopen(fd, "w") as out: + out.write(json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + prep = sub.add_parser("prepare-custody") + prep.add_argument("root") + for name in ("plan", "accounts", "verify"): + p = sub.add_parser(name) + p.add_argument("--endpoint", required=True) + p.add_argument("--admin-password-fd", type=int) + if name != "plan": + p.add_argument("root") + if name == "accounts": + p.add_argument("--apply", action="store_true") + if name == "verify": + p.add_argument( + "--old-admin-retired", + action="store_true", + help="also prompt for the old shared password and require that it is refused", + ) + for p in sub.choices.values(): + p.add_argument("--receipt", required=True) + args = parser.parse_args(argv) + try: + if Path(args.receipt).expanduser().exists(): + raise Refused("receipt exists") + endpoint = getattr(args, "endpoint", None) + if args.command == "prepare-custody": + result = prepare_custody(args.root) + else: + directory = Directory(endpoint) + passwords = custody_values(args.root) if args.command != "plan" else None + token = admin_token(directory, args.admin_password_fd) + if args.command == "plan": + result = {"accounts": assess(directory.snapshot(token)[0])} + elif args.command == "accounts": + result = accounts(directory, token, passwords, args.apply) + else: + old = ( + getpass.getpass("Old shared admin password (must now be refused): ") + if args.old_admin_retired + else None + ) + result = verify(directory, token, passwords, old) + write_receipt(args.receipt, args.command, endpoint, result) + except (Refused, lldap_admin.Refused, custody_module.Refused) as error: + print(f"refused: {error}", file=sys.stderr) + return 1 + print(json.dumps(result, indent=2, sort_keys=True)) + if result.get("verdict") == "fail" or any( + entry.get("action") == "refuse" for entry in result.get("accounts", {}).values() + ): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sso-mfa/k8s/lldap/test_bind_split.py b/sso-mfa/k8s/lldap/test_bind_split.py new file mode 100644 index 0000000..37fd9ac --- /dev/null +++ b/sso-mfa/k8s/lldap/test_bind_split.py @@ -0,0 +1,164 @@ +"""End-to-end test of bind-split.py against a throwaway LLDAP on the pinned image (NK-WP-0054-T05). + +Synthetic only: generated passwords, 127.0.0.1, container and volume removed afterwards. Skipped when +docker or the pinned image is unavailable. +""" + +import importlib.util +import json +import os +import re +import secrets +import subprocess +import uuid +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +split = load("bind_split", HERE / "bind-split.py") +available = ( + subprocess.run( + ["docker", "image", "inspect", split.IMAGE], capture_output=True, check=False + ).returncode + == 0 +) +pytestmark = pytest.mark.skipif( + not available, reason="docker or pinned LLDAP image unavailable" +) + + +@pytest.fixture +def lab(tmp_path): + pytest.importorskip("ldap3") + rehearsal = load("bind_split_rehearsal", HERE / "bind-split-rehearsal.py") + admin_password = secrets.token_hex(20) + root = tmp_path / "custody" + (root / "lldap").mkdir(parents=True, mode=0o700) + root.chmod(0o700) + env = root / "lldap/secrets.env" + env.write_text( + f"LLDAP_JWT_SECRET={secrets.token_hex(32)}\nLLDAP_LDAP_USER_PASS={admin_password}\n" + ) + env.chmod(0o600) + directory = rehearsal.Directory(uuid.uuid4().hex[:10]) + directory.start("admin", admin_password) + try: + yield root, directory.http, admin_password, directory + finally: + directory.remove() + + +def admin_fd(password): + read, write = os.pipe() + os.write(write, (password + "\n").encode()) + os.close(write) + return read + + +def test_full_split_on_a_production_shaped_directory(lab, tmp_path): + root, endpoint, admin_password, _ = lab + assert split.prepare_custody(root)["changed_keys"][0] == "LLDAP_LDAP_USER_PASS" + text = (root / "lldap/secrets.env").read_text() + assert ( + admin_password not in text + and (root / "lldap/secrets.env").stat().st_mode & 0o077 == 0 + ) + for key in ("LLDAP_LDAP_USER_PASS", *split.SERVICE_KEYS): + assert re.search(rf"^{key}=[A-Za-z0-9]{{40}}$", text, re.MULTILINE) + with pytest.raises(split.Refused): + split.prepare_custody(root) + + assert ( + split.main( + [ + "plan", + "--endpoint", + endpoint, + "--admin-password-fd", + str(admin_fd(admin_password)), + "--receipt", + str(tmp_path / "plan.json"), + ] + ) + == 0 + ) + plan = json.loads((tmp_path / "plan.json").read_text())["result"]["accounts"] + assert ( + all(plan[a]["action"] == "create" for a in split.TARGETS) + and plan["admin"]["in_lldap_admin"] + ) + + directory = split.Directory(endpoint) + token = directory.login("admin", admin_password) + passwords = split.custody_values(root) + dry = split.accounts(directory, token, passwords, apply=False) + assert not dry["applied"] and "ld-admin" not in directory.snapshot(token)[0] + + done = split.accounts(directory, token, passwords, apply=True) + assert done["applied"] and all(done["signs_in"].values()) + assert all(done["accounts"][a]["action"] == "ok" for a in split.TARGETS) + again = split.accounts(directory, token, passwords, apply=True) + assert all(again["accounts"][a]["action"] == "ok" for a in split.TARGETS) + + result = split.verify(directory, token, passwords) + assert result["verdict"] == "pass" + # The human then changes their own password; the old shared one must stop working. + split.set_password(endpoint, token, "admin", secrets.token_hex(20)) + retired = split.verify( + directory, + directory.login("ld-admin", passwords["ld-admin"]), + passwords, + admin_password, + ) + assert retired["old_shared_password_refused"] and retired["verdict"] == "pass" + + receipt = tmp_path / "plan.json" + assert admin_password not in receipt.read_text() + assert all(value not in receipt.read_text() for value in passwords.values()) + + +def test_existing_account_outside_the_service_domain_is_refused(lab): + root, endpoint, admin_password, _ = lab + split.prepare_custody(root) + directory = split.Directory(endpoint) + token = directory.login("admin", admin_password) + directory.gql( + token, + "mutation($u: CreateUserInput!) { createUser(user: $u) { id } }", + {"u": {"id": "svc-keycape", "email": "someone@example.test"}}, + ) + result = split.accounts(directory, token, split.custody_values(root), apply=True) + assert ( + not result["applied"] + and result["accounts"]["svc-keycape"]["action"] == "refuse" + ) + assert "ld-admin" not in directory.snapshot(token)[0] + + +def test_wrong_admin_password_is_refused_before_any_change(lab, tmp_path): + root, endpoint, _, _ = lab + split.prepare_custody(root) + code = split.main( + [ + "accounts", + "--endpoint", + endpoint, + "--admin-password-fd", + str(admin_fd("wrong")), + str(root), + "--apply", + "--receipt", + str(tmp_path / "r.json"), + ] + ) + assert code == 1 and not (tmp_path / "r.json").exists() diff --git a/tests/test_service_secret_custody.py b/tests/test_service_secret_custody.py index 9c3017b..4ee228e 100644 --- a/tests/test_service_secret_custody.py +++ b/tests/test_service_secret_custody.py @@ -21,7 +21,8 @@ VALUES = { COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner'] ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'), ('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'), - ('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret')] + ('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret'), + ('lldap','LLDAP_LDAP_USER_PASS','LLDAP_LDAP_USER_PASS'), ('authelia','LLDAP_SVC_AUTHELIA_PASS','ldap_password')] @pytest.fixture @@ -91,6 +92,7 @@ def check_private_result(result): assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr +def key_source(component,key):return 'lldap' if key.startswith('LLDAP_') else source(component) def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component) @@ -129,7 +131,7 @@ def test_repeated_apply_is_noop_and_preserves_other_data(case,component): def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field): assert invoke(case,component).returncode==0 old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old) - file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) + file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result) updated=state(case) assert updated['metadata']['annotations']=={'owner':'retained'} @@ -191,7 +193,7 @@ def test_wrong_or_controller_owned_existing_secret_is_refused(case,defect): @pytest.mark.parametrize('component,key,field',ROTATIONS) def test_concurrent_change_stops_rotation_without_retry(case,component,key,field): assert invoke(case,component).returncode==0;old=state(case) - file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) + file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}')) count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1') assert result.returncode!=0 and state(case)==old;check_private_result(result) assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1 diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 9f5ebf5..c751ba6 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -312,6 +312,20 @@ human login 200, wrong password 401, `svc-keycape`/`svc-privacyidea`/`ld-admin` filter all three were 200. The read-only `svc-authelia` bind is sufficient for Authelia's lookups. Phase 2 is complete; phases 3 and 4 remain. +**2026-10-29 phase 3a (live helper):** `sso-mfa/k8s/lldap/bind-split.py`, founder-run, changes the +directory only. `prepare-custody` adds `LLDAP_SVC_*_PASS` and a new `ld-admin` `LLDAP_LDAP_USER_PASS` +to the private custody copy (40 alphanumerics, the shape `apply-service-secrets.literal()` accepts) +and refuses a second run; `plan` is read-only; `accounts [--apply]` creates `ld-admin` and the `svc-*` +accounts with `@service.netkingdom.invalid` emails, sets exactly their groups and their passwords +(pinned `lldap_set_password`, password in the environment only) and refuses an existing account with +a foreign email or extra groups; `verify [--old-admin-retired]` proves every account signs in with +its custody password, `admin` keeps `lldap_admin`, and the old shared password is refused. The +human `admin` signs in at a hidden prompt; receipts are value-free. `make bind-split-test` runs it +end to end on a throwaway pinned LLDAP (3 tests). `apply-service-secrets.py` gains the named +rotations `lldap LLDAP_LDAP_USER_PASS` and `authelia LLDAP_SVC_AUTHELIA_PASS -> ldap_password`, so the +existing Secrets are switched only by explicit, conditional rotation (97 tests). Left: 3b (backup +profile and the three restore launchers), then phase 4. + ## Disposition of per-service admin accounts ```task