diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/create-secrets.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/create-secrets.sh new file mode 100644 index 00000000..37e5a7b4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/create-secrets.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# First creation only. Existing KeyCape configuration requires a scoped owner update. +set +x +set -euo pipefail +umask 077 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$SCRIPT_DIR/create-secrets.py" "$@" diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/exercise-approval-clients.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/exercise-approval-clients.py new file mode 100644 index 00000000..ca549050 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/exercise-approval-clients.py @@ -0,0 +1,92 @@ +import base64, importlib.util, json, os, secrets, socket, subprocess, tempfile, time +from pathlib import Path +from unittest.mock import patch +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +import yaml +import http.server, ssl, threading, urllib.request, urllib.error, datetime, ipaddress +from cryptography import x509 +from cryptography.x509.oid import NameOID +import argparse +parser=argparse.ArgumentParser(description='Exercise the pinned image and verifier over loopback HTTPS using synthetic credentials only.') +parser.add_argument('--receipt', required=True, type=Path) +args=parser.parse_args() +spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('approval-clients-rollout.py')) +m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +m.verify_artifact() +def call(args): + p=subprocess.run(args,capture_output=True,timeout=45) + if p.returncode: raise RuntimeError('contained_docker_command_failed') + return p +sock=socket.socket(); sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]; sock.close() +class Proxy(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): pass + def handle_proxy(self): + body = self.rfile.read(int(self.headers.get('Content-Length', 0))) if self.command == 'POST' else None + req = urllib.request.Request(f'http://127.0.0.1:{port}'+self.path, data=body, + headers={k:self.headers[k] for k in ['Content-Type','Authorization'] if k in self.headers}) + try: + response=urllib.request.urlopen(req,timeout=20) + except urllib.error.HTTPError as error: response=error + with response: + self.send_response(response.code) + self.send_header('Content-Type', response.headers.get('Content-Type','application/json')) + self.end_headers(); self.wfile.write(response.read()) + do_GET=handle_proxy + do_POST=handle_proxy +server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Proxy) +https_port=server.server_address[1] +name='keycape-approval-exercise-'+secrets.token_hex(5) +with tempfile.TemporaryDirectory(prefix='keycape-private-exercise-') as temp: + root=Path(temp) + key=rsa.generate_private_key(public_exponent=65537,key_size=2048) + (root/'key.pem').write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())) + os.chmod(root/'key.pem',0o600) + name_attr=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'KeyCape local exercise')]) + cert=(x509.CertificateBuilder().subject_name(name_attr).issuer_name(name_attr).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(minutes=1)) + .not_valid_after(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(hours=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]),False) + .add_extension(x509.BasicConstraints(ca=True,path_length=None),True)) + from cryptography.hazmat.primitives import hashes + (root/'cert.pem').write_bytes(cert.sign(key,hashes.SHA256()).public_bytes(serialization.Encoding.PEM)) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(root/'cert.pem',root/'key.pem') + server.socket=context.wrap_socket(server.socket,server_side=True) + threading.Thread(target=server.serve_forever,daemon=True).start() + config=yaml.safe_load((m.KEYCAPE/'config/dev-config.yaml').read_text()) + config.update(issuer=f'https://127.0.0.1:{https_port}') + config['authelia']['issuer']='https://auth.coulomb.social' + config['clients'].extend(m.registrations()) + (root/'config.yaml').write_text(yaml.safe_dump(config,sort_keys=False)) + os.chmod(root/'config.yaml',0o600) + values={c['clientId']:secrets.token_urlsafe(48) for c in m.registrations()} + (root/'env').write_text('KEYCAPE_CONFIG=/etc/keycape/config.yaml\n'+''.join(c['secretRef'].removeprefix('env:')+'='+values[c['clientId']]+'\n' for c in m.registrations())) + os.chmod(root/'env',0o600) + created=False + try: + call(['docker','run','-d','--rm','--name',name,'--user',str(os.getuid()),'--publish',f'127.0.0.1:{port}:8080', + '--env-file',str(root/'env'),'--mount','type=bind,source='+temp+',target=/etc/keycape,readonly',m.IMAGE]) + created=True + with patch.object(m,'ISSUER',config['issuer']), patch.dict(os.environ,{'SSL_CERT_FILE':str(root/'cert.pem')}): + for _ in range(30): + try: + status,_=m.http('/.well-known/openid-configuration') + if status==200: break + except Exception: pass + time.sleep(.5) + else: raise RuntimeError('scratch_keycape_not_ready') + def fake_get(kube,kind,name): + index=m.SECRET_NAMES.index(name) + return {'data': {'client-secret':base64.b64encode(values[m.IDS[index]].encode()).decode()}} + receipt={} + with patch.object(m,'get',side_effect=fake_get): + m.acceptance([],receipt) + receipt.update(target='disposable pinned KeyCape image; synthetic keys and clients only', + pinned_native_cli_verification='passed against loopback HTTPS with synthetic credentials',cleanup_complete=False) + finally: + if created: call(['docker','stop','--time=5',name]) + server.shutdown(); server.server_close() + receipt['cleanup_complete']=True + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + with os.fdopen(fd,'w') as out: out.write(json.dumps(receipt,indent=2)+'\n') +print('Pinned-image scratch acceptance passed; synthetic credentials removed.') diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-client.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-client.py new file mode 100644 index 00000000..433779d1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-client.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Register or remove exactly one KeyCape client from a SecurityCase, without exporting Secret values. + +Entry point of the playbook capability net-kingdom.keycape-client-registration, +composed by the SecurityCaseType oidc-browser-relying-party +(security-case-types/, canon security-case-types_v0.1). It generalizes the +per-client helpers in this directory (portal-client-rollout, +register-coulomb-social, vergabe-client-rollout, +secrets-engine-openbao-client-rollout) and keeps their safeguards: + +- contained kubectl, cluster-identity pin and verified upstream issuer pin +- byte-preserving merge: only this client's lines change; every other config + byte, the signing key and unrelated clients are preserved +- exact match: absent -> add; identical -> noop; drift, duplicates or a shared + secretRef -> refuse (update = remove, then add, each accepted) +- compare-and-swap on the Secret's uid and resourceVersion, then read-back +- confidential clients: the KeyCape Deployment must already bind the client's + secret env var (delivered from custody), or the next pod start fails closed +- KeyCape's own startup checks (keycape check-config, key-cape c528daa) on the + live and the proposed registry, fed on stdin and never written to disk; apply + refuses a registry that would stop the single replica at its next start +- no Secret value, parser message or Kubernetes error text leaves the process + +Modes: inspect (default), dry-run (server-side), apply. apply requires the +case's acceptance record instead of a per-client founder decision id: the case +was accepted under an acknowledged type. + + keycape-client.py client-from-case + keycape-client.py inspect --client-file client.yaml [--operation remove] + keycape-client.py apply --client-file client.yaml --case-acceptance \\ + --check-config --expected-uid --expected-resource-version +""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +lane = module('portal_rollout', 'portal-client-rollout.py') +pin = lane.pin +Refused = lane.Refused +require = lane.require + +CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$') +CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$') +# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments. +REDIRECT = re.compile(r'^https://[^*?#\s,]+$') +SCOPES = {'openid', 'profile', 'email', 'groups'} + + +def env_name(client_id): + return 'KEYCAPE_' + client_id.upper().replace('-', '_') + '_CLIENT_SECRET' + + +def env_secret_name(client_id): + return f'keycape-{client_id}-client' + + +def client_from_params(params): + """The KeyCape client record for a case of oidc-browser-relying-party.""" + cid = params['client_id'] + require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id') + redirects = [r for r in params['redirect_uris'].split(',')] + require(redirects and all(REDIRECT.fullmatch(r) for r in redirects), 'redirect_uris_must_be_exact_https') + scopes = params['allowed_scopes'].split(',') + require(scopes[0] == 'openid' and set(scopes) <= SCOPES and len(set(scopes)) == len(scopes), + 'allowed_scopes_invalid') + # KeyCape has no per-client group gate; the consumer reads groups, and UserInfo + # returns them only for the groups scope. + require(not params.get('access_groups') or 'groups' in scopes, 'access_groups_require_groups_scope') + require(params['client_type'] in ('confidential', 'public'), 'client_type_invalid') + # The type derives custody at platform/workloads/keycape/; a case cannot choose it. + require('secret_ref' not in params, 'secret_ref_is_derived_by_the_type') + require(params['mfa_required'] in ('true', 'false'), 'mfa_required_invalid') + client = { + 'clientId': cid, + 'displayName': params['display_name'], + 'redirectUris': redirects, + 'allowedScopes': scopes, + 'grantTypes': ['authorization_code'], + 'clientType': params['client_type'], + } + if params['client_type'] == 'confidential': + client['secretRef'] = 'env:' + env_name(cid) + if params['mfa_required'] == 'true': + client['mfaRequired'] = True + return client + + +def client_from_case(case): + spec = case.get('spec') or {} + require(CASE_TYPE.fullmatch(str(spec.get('type', ''))) is not None, 'case_type_not_oidc_browser_relying_party') + return client_from_params(spec.get('parameters') or {}) + + +def env_binding(client): + return {'name': env_name(client['clientId']), 'valueFrom': { + 'secretKeyRef': {'name': env_secret_name(client['clientId']), 'key': 'client-secret'}}} + + +def _client_node(raw): + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + return node + + +def _line_start(raw, index): + start = raw.rfind('\n', 0, index) + 1 + return start if not raw[start:index].strip() else index + + +def _inspect(secret, client): + raw, config, _ = pin.issuer_document(secret) + require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, 'verified_issuer_pin_required') + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected') + ids = [c['clientId'] for c in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + if 'secretRef' in client: + others = [c for c in clients if c['clientId'] != client['clientId']] + require(all(c.get('secretRef') != client['secretRef'] for c in others), 'secret_ref_already_bound') + return raw, config, ids + + +def replacement(secret, client): + """Return (base64 config, changed) with only this client added.""" + raw, config, ids = _inspect(secret, client) + if client['clientId'] in ids: + require(config['clients'][ids.index(client['clientId'])] == client, + 'existing_registration_differs_requires_reconciliation') + return secret['data']['config.yaml'], False + node = _client_node(raw) + index = _line_start(raw, node.end_mark.index) + indent = ' ' * node.start_mark.column + fragment = yaml.safe_dump([client], sort_keys=False, width=120) + fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True)) + if index and raw[index - 1] != '\n': + fragment = '\n' + fragment + updated = raw[:index] + fragment + raw[index:] + expected = copy.deepcopy(config) + expected['clients'].append(client) + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def removal(secret, client): + """Rollback: return (base64 config, changed) with only this exact client removed.""" + raw, config, ids = _inspect(secret, client) + if client['clientId'] not in ids: + return secret['data']['config.yaml'], False + position = ids.index(client['clientId']) + require(config['clients'][position] == client, 'existing_registration_differs_requires_reconciliation') + require(len(ids) > 1, 'removal_would_empty_client_sequence') + node = _client_node(raw) + item = node.value[position] + start = raw.rfind('\n', 0, item.start_mark.index) + 1 + require(raw[start:item.start_mark.index].strip() == '-', 'unexpected_item_layout') + if position + 1 < len(node.value): + end = raw.rfind('\n', 0, node.value[position + 1].start_mark.index) + 1 + else: + end = _line_start(raw, node.end_mark.index) + updated = raw[:start] + raw[end:] + expected = copy.deepcopy(config) + del expected['clients'][position] + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def deployment_bound(deployment, client): + """Whether the live Deployment carries exactly this client's secret binding.""" + if 'secretRef' not in client: + return None + containers = deployment['spec']['template']['spec']['containers'] + container = next(c for c in containers if c['name'] == 'keycape') + matches = [e for e in container.get('env', []) if e.get('name') == env_name(client['clientId'])] + require(len(matches) <= 1 and all(e == env_binding(client) for e in matches), 'env_binding_drift') + return bool(matches) + + +def check_config(binary, config_text, env_names, policy_store, *, executable_fd=None): + """KeyCape's startup checks on one registry: names and messages only, no values.""" + executable = str(Path(binary).resolve()) if executable_fd is None else f'/proc/self/fd/{executable_fd}' + command = [executable, 'check-config', '--config', '/dev/stdin', '--env-names', ','.join(env_names)] + if policy_store: + command.append('--policy-store') + result = subprocess.run(command, input=config_text.encode(), capture_output=True, timeout=60, env={}, + pass_fds=() if executable_fd is None else (executable_fd,)) + report = json.loads(result.stdout) + require(isinstance(report, dict) and isinstance(report.get('valid'), bool) + and report['valid'] == (result.returncode == 0), 'check_config_output_shape') + return {'valid': report['valid'], 'clients': report.get('clients'), + 'errors': [str(e) for e in report.get('errors') or []]} + + +def deployment_env_names(deployment): + containers = deployment['spec']['template']['spec']['containers'] + container = next(c for c in containers if c['name'] == 'keycape') + return sorted(e['name'] for e in container.get('env', [])) + + +def run(args, client): + kubectl = lane.kubectl + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == lane.CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = (replacement if args.operation == 'add' else removal)(before, client) + bound = deployment = None + if 'secretRef' in client or args.check_config: + deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json']) + bound = deployment_bound(deployment, client) + checks = None + if args.check_config: + names = deployment_env_names(deployment) + policy_store = 'KEYCAPE_POLICY_PATH' in names + checker_options = ({'executable_fd': args.check_config_fd} + if getattr(args, 'check_config_fd', None) is not None else {}) + checks = {stage: check_config(args.check_config, base64.b64decode(config).decode(), names, policy_store, **checker_options) + for stage, config in (('live', before['data']['config.yaml']), ('proposed', encoded))} + receipt = {'schema': 'netkingdom.keycape-client.v1', 'target': 'sso/keycape-config', + 'before': metadata, 'operation': args.operation, 'client_id': client['clientId'], + 'client_type': client['clientType'], 'change_needed': changed, 'mode': args.mode, + 'deployment_env_bound': bound, 'check_config': checks, 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + if args.mode == 'apply': + require(bool(args.case_acceptance), 'case_acceptance_record_required') + receipt['case_acceptance'] = args.case_acceptance + require(checks is not None, 'keycape_check_config_required') + require(checks['proposed']['valid'], 'proposed_registry_fails_keycape_check_config') + if args.operation == 'add' and bound is not None: + require(bound, 'deployment_env_binding_required_first') + require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] + and after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after), restart_required=changed) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('mode', nargs='?', default='inspect', + choices=['inspect', 'dry-run', 'apply', 'client-from-case']) + parser.add_argument('case', nargs='?', help='case file, for client-from-case') + parser.add_argument('--client-file', help='KeyCape client record (output of client-from-case)') + parser.add_argument('--operation', default='add', choices=['add', 'remove']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + parser.add_argument('--case-acceptance', help='the case acceptance record; required for apply') + parser.add_argument('--check-config', help='a keycape binary with check-config (key-cape c528daa or later); required for apply') + args = parser.parse_args(argv) + try: + if args.mode == 'client-from-case': + require(bool(args.case), 'case_file_required') + print(yaml.safe_dump(client_from_case(yaml.safe_load(Path(args.case).read_text())), sort_keys=False), end='') + return 0 + require(bool(args.client_file), 'client_file_required') + client = yaml.safe_load(Path(args.client_file).read_text()) + require(isinstance(client, dict) and 'clientId' in client, 'client_file_shape') + print(json.dumps(run(args, client), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except pin.IssuerPinError as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-registration-adapter.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-registration-adapter.py new file mode 100644 index 00000000..ae59a62f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/keycape-registration-adapter.py @@ -0,0 +1,217 @@ +"""Unexercised, injection-only attended registration seam for Warden's v1 kernel. + +No CLI, credentials, evidence trust root or live registration is installed here. +Owner publication/authentication and parent-session revocation are mandatory +injected capabilities. The kernel must independently admit both evidence kinds. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import fcntl +import os +import stat +from dataclasses import asdict, replace +import json +import importlib.util +from pathlib import Path + +import yaml + +from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER, DIGEST +from warden.case_receipt_state import Outcome, Refused + +ROOT = Path(__file__).resolve().parent +SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py', + 'portal-client-rollout.py', 'openbao-client-config.py', + 'register-user-engine-portal.py') +REFERENCE = 'net-kingdom/keycape-registration@v1' + +# Importing the maintained helper performs no tooling operation. +_spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py') +helper = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(helper) + + +@contextmanager +def checker_snapshot(path, expected_digest): + """Execute only verified immutable bytes, independent of later path changes. + + Linux sealed memfd and procfs are required. This is a per-attempt executable + snapshot, not native owner storage custody or process/credential isolation. + """ + if not isinstance(expected_digest, str) or not DIGEST.fullmatch(expected_digest): + raise Refused('exact reviewed checker digest required') + source_fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(source_fd, 'rb') as source: + metadata = os.fstat(source.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise Refused('regular checker artifact required') + if metadata.st_size > 64 * 1024 * 1024: + raise Refused('checker artifact exceeds snapshot limit') + data = source.read(64 * 1024 * 1024 + 1) + if len(data) > 64 * 1024 * 1024 or digest(data) != expected_digest: + raise Refused('check-config binary changed or exceeds snapshot limit') + fd = os.memfd_create('keycape-reviewed-checker', os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING) + try: + with os.fdopen(os.dup(fd), 'wb') as output: + output.write(data) + os.fchmod(fd, 0o500) + seals = fcntl.F_SEAL_WRITE | fcntl.F_SEAL_GROW | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_SEAL + fcntl.fcntl(fd, fcntl.F_ADD_SEALS, seals) + if fcntl.fcntl(fd, fcntl.F_GET_SEALS) != seals: + raise Refused('immutable checker snapshot required') + yield fd + finally: + os.close(fd) + + +def source_digest(check_config_digest): + """Pin adapter plus the complete local helper import closure, in filename order.""" + if not isinstance(check_config_digest, str) or not DIGEST.fullmatch(check_config_digest): + raise Refused('exact reviewed checker digest required') + composition = {'files': {name: digest((ROOT / name).read_bytes()) for name in SOURCE_FILES}, + 'check_config_binary': check_config_digest} + return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode()) + + +def descriptor(check_config_digest): + return Descriptor('warden.case.execution.v1', 'register-client', 'net-kingdom', + REFERENCE, source_digest(check_config_digest), 'human', 'key-cape-oidc-login', + 'client_id', 'change', ('keycape-client',), False, + ('parent-session',)) + + +def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, + expected_resource_version: str, case_acceptance: str, + check_config: Path, check_config_digest: str, publish, authenticate, + revoke_session): + """Bind one admitted case and observed revision; return a closed kernel Adapter. + + publish(bytes)->identifier durably retains only the closed metadata payload. + authenticate(identifier, bytes)->True must verify independent owner custody. + revoke_session(invocation)->identifier must revoke this attempt's parent + session; Warden independently authenticates it using its own evidence verifier. + These callbacks are capabilities, never command strings or case parameters. + + Only a verified creation in this process may be rolled back. Process loss, + write/readback ambiguity, publication/revocation failure and source drift + require owner reconciliation; none are converted to retry-safe failures. + """ + reviewed_source = source_digest(check_config_digest) + desc = descriptor(check_config_digest) + if type(admitted) is not Invocation or not all( + isinstance(value, str) and IDENTIFIER.fullmatch(value) + for value in (admitted.step, admitted.owner, admitted.target, + admitted.actor, admitted.principal, admitted.lane)): + raise Refused('bounded invocation required') + if desc.adapter_source != reviewed_source: + raise Refused('owner source changed during binding') + if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != ( + desc.step, desc.owner, desc.actor, desc.lane): + raise Refused('registration invocation boundary differs') + if admitted.operation != 'admission' or admitted.attempt_id: + raise Refused('expected admission invocation') + if type(case_source) is not bytes or len(case_source) > 262144 or digest(case_source) != admitted.pins.case: + raise Refused('case source pin differs') + # Pins.adapter is the descriptor digest, not the owner source digest. + descriptor_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + if admitted.pins.adapter != digest(descriptor_bytes): + raise Refused('descriptor pin differs') + if not all(isinstance(x, str) and 0 < len(x) <= 192 for x in + (expected_uid, expected_resource_version, case_acceptance)): + raise Refused('bounded attended binding required') + if digest(case_acceptance.encode()) != admitted.pins.acceptance: + raise Refused('acceptance evidence binding differs') + if not all(callable(x) for x in (publish, authenticate, revoke_session)): + raise Refused('owner evidence and revocation capabilities required') + with contained_output(): + case = yaml.safe_load(case_source) + client = helper.client_from_case(case) + if admitted.target != client['clientId']: + raise Refused('case target differs') + check_config = Path(check_config) + records = {} + creation = None + + def binding(inv, operation): + if (replace(inv, attempt_id='', operation='admission') != admitted + or inv.operation != operation or not IDENTIFIER.fullmatch(inv.attempt_id)): + raise Refused('attempt binding differs') + if source_digest(check_config_digest) != reviewed_source: + raise Refused('owner source changed') + + def execute(inv, operation, created=()): + nonlocal creation + try: + with contained_output(): + binding(inv, 'run' if operation == 'add' else 'rollback') + uid, revision = expected_uid, expected_resource_version + if operation == 'remove': + if (created != ('keycape-client',) or creation is None + or not verify(*creation)): + raise Refused('no authenticated case-created client') + original_payload = json.loads(records[creation[1].evidence][1]) + uid, revision = original_payload['after']['uid'], original_payload['after']['resource_version'] + args = argparse.Namespace(mode='apply', operation=operation, + expected_uid=uid, expected_resource_version=revision, + case_acceptance=case_acceptance, check_config=str(check_config)) + with checker_snapshot(check_config, check_config_digest) as checker_fd: + args.check_config_fd = checker_fd + receipt = helper.run(args, client) + # The helper has completed CAS, whole-data readback and preservation. + # Never retain check-config diagnostics or arbitrary helper fields. + if not (receipt['schema'] == 'netkingdom.keycape-client.v1' + and receipt['target'] == 'sso/keycape-config' + and receipt['client_id'] == admitted.target + and receipt['mode'] == 'apply' and receipt['operation'] == operation + and receipt['values_emitted'] is False + and type(receipt['changed']) is bool + and receipt['unrelated_config_bytes_preserved'] is True + and receipt['other_secret_data_unchanged'] is True + and receipt['before'] == {'uid': uid, 'resource_version': revision} + and receipt['after']['uid'] == uid + and receipt['check_config']['proposed']['valid'] is True): + raise Refused('owner readback contract differs') + after = receipt['after'] + if (set(after) != {'uid', 'resource_version'} or + not all(isinstance(v, str) and 0 < len(v) <= 192 for v in after.values())): + raise Refused('owner revision contract differs') + changed = receipt['changed'] + outcome = (Outcome.ROLLED_BACK if operation == 'remove' else + Outcome.SUCCESS if changed else Outcome.NO_CHANGE) + objects = ('keycape-client',) if operation == 'add' and changed else () + finally: + # Even helper failure must end the parent session. Failures propagate + # contained to the kernel and become uncertain, never safe to replay. + with contained_output(): + revocation = revoke_session(inv) + if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation): + raise Refused('session revocation evidence missing') + payload = json.dumps({'schema': 'netkingdom.keycape-registration.attempt.v1', + 'invocation': asdict(inv), 'source': reviewed_source, + 'outcome': outcome.value, 'created': objects, 'before': receipt['before'], + 'after': after, 'session_revocation': revocation}, + sort_keys=True, separators=(',', ':')).encode() + with contained_output(): + evidence = publish(payload) + if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records: + raise Refused('owner evidence identifier invalid or reused') + result = OwnerResult(outcome, evidence, objects, True, revocation, + attempt_id=inv.attempt_id, operation=inv.operation) + records[evidence] = (inv, payload, result) + if operation == 'add' and changed: + creation = (inv, result) + elif operation == 'remove': + creation = None + return result + + def verify(inv, result): + record = records.get(result.evidence) if type(result) is OwnerResult else None + if record is None or record[0] != inv or record[2] != result: + return False + with contained_output(): + return authenticate(result.evidence, record[1]) is True + + return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'), + verify, lambda inv, created: execute(inv, 'remove', created)) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-client-config.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-client-config.py new file mode 100644 index 00000000..b123627b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-client-config.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""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 +kubectl, or a short non-secret verification message. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import copy +import re +import subprocess +import sys +from datetime import datetime, timezone +from typing import Any + +try: + import yaml +except ImportError as exc: # pragma: no cover - operator environment guard + raise SystemExit("PyYAML is required: install python3-yaml or run in the NetKingdom tool environment") from exc + + +OPENBAO_CLIENT = { + "clientId": "openbao-admin", + "displayName": "Railiance OpenBao Admin", + "redirectUris": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback", + ], + "allowedScopes": ["openid", "profile", "email", "groups"], + "grantTypes": ["authorization_code"], + "clientType": "public", +} + +# bao.coulomb.social was retired on 2026-09-15; its callbacks must not return. +RETIRED_OPENBAO_REDIRECT_URIS = ( + "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", + "https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback", +) + +RAPP_QONTO_CLIENT = { + "clientId": "rapp-qonto-client", + "displayName": "rapp-qonto workload", + "allowedScopes": ["qonto:read"], + "grantTypes": ["client_credentials"], + "clientType": "confidential", + "secretRef": "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET", + "serviceSubject": "rapp-qonto", + "tenant": "tenant:friendly:binky", + "roles": ["qonto-reader"], +} + +LLDAP_REQUIRED = { + "userOU": "ou=people", + "groupOU": "ou=groups", +} + +# Verified against an actual signed upstream token on 2026-09-08 (KEY-WP-0013). +VERIFIED_UPSTREAM_ISSUER = "https://auth.coulomb.social" + + +class IssuerPinError(Exception): + """Only fixed, non-secret reason codes may leave the contained operation.""" + + +class UniqueLoader(yaml.SafeLoader): + pass + + +def unique_mapping(loader: Any, node: Any) -> dict[str, Any]: + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=True) + if not isinstance(key, str) or key in result: + raise IssuerPinError("ambiguous_yaml_mapping") + result[key] = loader.construct_object(value_node, deep=True) + return result + + +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) + + +def issuer_document(secret: dict[str, Any]) -> tuple[str, dict[str, Any], Any]: + """Parse privately; never include a parser exception or document in output.""" + raw = base64.b64decode(secret['data']['config.yaml'], validate=True).decode('utf-8') + if any(isinstance(t, (yaml.tokens.AnchorToken, yaml.tokens.AliasToken)) for t in yaml.scan(raw)): + raise IssuerPinError("yaml_alias_or_anchor") + config = yaml.load(raw, Loader=UniqueLoader) + if not isinstance(config, dict) or not isinstance(config.get('authelia'), dict): + raise IssuerPinError("unexpected_configuration_shape") + node = yaml.compose(raw) + authelia = next(v for k, v in node.value if k.value == 'authelia') + if authelia.flow_style or not authelia.value: + raise IssuerPinError("unsupported_authelia_mapping") + return raw, config, authelia + + +def issuer_replacement(secret: dict[str, Any]) -> tuple[str, bool, str]: + raw, config, authelia = issuer_document(secret) + old = config['authelia'].get('issuer') + prior = 'absent' if 'issuer' not in config['authelia'] else 'matching' if old == VERIFIED_UPSTREAM_ISSUER else 'different' + if old == VERIFIED_UPSTREAM_ISSUER: + return secret['data']['config.yaml'], False, prior + field = next((v for k, v in authelia.value if k.value == 'issuer'), None) + if field is not None: + if not isinstance(field, yaml.nodes.ScalarNode): + raise IssuerPinError("unsupported_issuer_value") + updated = raw[:field.start_mark.index] + json.dumps(VERIFIED_UPSTREAM_ISSUER) + raw[field.end_mark.index:] + else: + index = authelia.start_mark.index - authelia.start_mark.column + indent = ' ' * authelia.start_mark.column + updated = raw[:index] + indent + 'issuer: ' + json.dumps(VERIFIED_UPSTREAM_ISSUER) + '\n' + raw[index:] + expected = copy.deepcopy(config) + expected['authelia']['issuer'] = VERIFIED_UPSTREAM_ISSUER + if yaml.load(updated, Loader=UniqueLoader) != expected: + raise IssuerPinError("unexpected_configuration_change") + return base64.b64encode(updated.encode()).decode('ascii'), True, prior + + +def contained_kube(args: list[str], payload: Any = None) -> Any: + # Secret JSON and patch bodies stay in process memory and child stdin. + # In particular, never use -p , a shell pipeline, or inherited stderr. + result = subprocess.run( + ['kubectl', '--request-timeout=20s', '-n', 'sso', *args], + input=None if payload is None else json.dumps(payload), + capture_output=True, text=True, timeout=30, check=False, + ) + if result.returncode: + raise IssuerPinError("kubernetes_operation_failed") + return json.loads(result.stdout) + + +def safe_metadata(obj: dict[str, Any]) -> dict[str, str]: + metadata = obj['metadata'] + uid, revision = metadata['uid'], metadata['resourceVersion'] + if not re.fullmatch(r'[0-9a-f-]{36}', uid) or not re.fullmatch(r'[0-9]+', revision): + raise IssuerPinError("unexpected_metadata_shape") + return {'uid': uid, 'resource_version': revision} + + +def issuer_live(mode: str, expected_uid: str | None = None, expected_revision: str | None = None) -> dict[str, Any]: + if mode == 'issuer-pin-live' and (not expected_uid or not expected_revision): + raise IssuerPinError("pin_requires_observed_uid_and_revision") + before = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json']) + if before.get('kind') != 'Secret' or before['metadata'].get('name') != 'keycape-config' or before['metadata'].get('namespace') != 'sso': + raise IssuerPinError("unexpected_target") + metadata = safe_metadata(before) + if mode == 'issuer-pin-live' and metadata != {'uid': expected_uid, 'resource_version': expected_revision}: + raise IssuerPinError("observed_revision_changed") + replacement, changed, prior = issuer_replacement(before) + receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'mode': mode, + 'observed_at': datetime.now(timezone.utc).isoformat(), + 'target': 'sso/keycape-config', 'verified_issuer': VERIFIED_UPSTREAM_ISSUER, + 'before': metadata, 'prior_pin_state': prior, + 'issuer_matches': not changed, 'config_changed': False, + 'secret_values_emitted': False, 'deployment_changed': False} + if mode == 'issuer-check-live': + return receipt + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': expected_uid}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': expected_revision}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': replacement}, + ] + contained_kube(['patch', 'secret', 'keycape-config', '--type=json', '--patch-file=/dev/stdin', '-o', 'json'], patch) + after = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json']) + expected_data = dict(before['data'], **{'config.yaml': replacement}) + if after['metadata']['uid'] != metadata['uid'] or after['data'] != expected_data: + raise IssuerPinError("readback_mismatch_stop_without_replaying_stale_config") + receipt.update({'after': safe_metadata(after), 'issuer_matches': True, + 'config_changed': changed, 'other_secret_data_unchanged': True, + 'unrelated_config_bytes_preserved': True}) + return receipt + + +def issuer_main(args: Any) -> int: + try: + receipt = issuer_live(args.mode, args.expected_uid, args.expected_resource_version) + except IssuerPinError as exc: + receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': str(exc)} + except Exception: + # Kubernetes/YAML exceptions may contain entire response/config fragments. + receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': 'contained_operation_failed'} + print(json.dumps(receipt, sort_keys=True)) + return 1 if receipt.get('status') == 'failed' else 0 + + +def load_config() -> dict[str, Any]: + secret = json.load(sys.stdin) + encoded_config = (secret.get("data") or {}).get("config.yaml") + if not encoded_config: + raise SystemExit("keycape-config Secret does not contain data.config.yaml") + try: + config_text = base64.b64decode(encoded_config).decode("utf-8") + except Exception as exc: # noqa: BLE001 - concise operator error + raise SystemExit("could not decode private config; diagnostics suppressed") from None + config = yaml.safe_load(config_text) or {} + if not isinstance(config, dict): + raise SystemExit("KeyCape config.yaml must decode to a YAML mapping") + return config + + +def client_errors(config: dict[str, Any], required: dict[str, Any]) -> list[str]: + clients = config.get("clients") + if not isinstance(clients, list): + return ["clients must be a list"] + target = next( + (client for client in clients if isinstance(client, dict) and client.get("clientId") == required["clientId"]), + None, + ) + if target is None: + return [f"missing {required['clientId']} client"] + + errors: list[str] = [] + for key, expected in required.items(): + if key in ("redirectUris", "allowedScopes", "grantTypes", "roles"): + missing = sorted(set(expected) - set(target.get(key) or [])) + if missing: + errors.append(f"{required['clientId']}.{key} missing: {', '.join(missing)}") + elif target.get(key) != expected: + errors.append(f"{required['clientId']}.{key} should be {expected!r}") + return errors + + +def retired_redirect_errors(config: dict[str, Any]) -> list[str]: + clients = config.get("clients") if isinstance(config.get("clients"), list) else [] + target = next( + (client for client in clients if isinstance(client, dict) and client.get("clientId") == OPENBAO_CLIENT["clientId"]), + {}, + ) + retired = sorted(set(RETIRED_OPENBAO_REDIRECT_URIS) & set(target.get("redirectUris") or [])) + return [f"{OPENBAO_CLIENT['clientId']}.redirectUris retired: {', '.join(retired)}"] if retired else [] + + +def upsert_client(config: dict[str, Any], required: dict[str, Any]) -> dict[str, Any]: + clients = config.get("clients") + if not isinstance(clients, list): + clients = [] + config["clients"] = clients + for index, client in enumerate(clients): + if isinstance(client, dict) and client.get("clientId") == required["clientId"]: + clients[index] = dict(required) + return config + clients.append(dict(required)) + 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: + raise SystemExit("Retired: raw registry patch output is disabled; use the reviewed owner rollout adapter.") + + +def verify(config: dict[str, Any]) -> None: + errors = client_errors(config, OPENBAO_CLIENT) + client_errors(config, RAPP_QONTO_CLIENT) + retired_redirect_errors(config) + lldap_errors(config) + if errors: + for error in errors: + print(f"[FAIL] {error}") + raise SystemExit(1) + print("[PASS] openbao-admin and rapp-qonto clients and LLDAP OU lookup settings are present") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("patch", "verify", "issuer-check-live", "issuer-pin-live")) + parser.add_argument("--expected-uid") + parser.add_argument("--expected-resource-version") + args = parser.parse_args() + if args.mode.startswith('issuer-'): + raise SystemExit(issuer_main(args)) + if args.mode == "patch": + render_patch({}) # Refuse before reading secret-bearing stdin. + try: + config = load_config() + verify(config) + except (ValueError, TypeError, KeyError, AttributeError, yaml.YAMLError): + raise SystemExit("KeyCape verification failed; private input diagnostics suppressed.") from None + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-node-wrapper.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-node-wrapper.py new file mode 100644 index 00000000..f53ad400 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-node-wrapper.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Founder-admitted node wrapper: private local child-ready pipe -> SSH control stdout. +No token read/copy, login, tunnel or approval authentication. Parent must capture +stdout over its admitted authenticated SSH peer; never use the silent warden lane. +""" +import argparse +import contextlib +import hashlib +import json +import math +import os +from pathlib import Path +import signal +import stat +import subprocess +import sys +import threading +import time + +SCHEMA='key39-private-input-ready/v1' +PROPOSAL='KEY-WP-0039-T02/attended-lifecycle-v9-ready-input' +TARGET='sso/keycape' +MAX_READY=4096 +MAX_ENVELOPE=65536 +READY_TOTAL_SECONDS=5 + +class Refused(Exception):pass + +def need(ok,code): + if not ok:raise Refused(code) + +def finite(value): + try:return type(value) in (int,float) and math.isfinite(value) + except OverflowError:return False + +def hash_bytes(raw):return hashlib.sha256(raw).hexdigest() + +def hex_pin(value):return isinstance(value,str) and len(value)==64 and all(c in '0123456789abcdef' for c in value) + +@contextlib.contextmanager +def deadline(seconds): + need(finite(seconds) and 00,'wrapper_control_write_failed') + offset+=written + +def run(args): + proc=None;read_fd=None;write_fd=None + try: + need(hex_pin(args.session_nonce),'wrapper_nonce_required') + with deadline(READY_TOTAL_SECONDS): + child=pinned_file(args.child,args.child_sha256,256*1024) + envelope_raw=pinned_file(args.envelope,args.envelope_sha256,MAX_ENVELOPE,private=True) + envelope=decode(envelope_raw) + need(envelope.get('proposal_id')==PROPOSAL and envelope.get('target')==TARGET + and envelope.get('disposition')=='approved' and envelope.get('founder_decision_id'), + 'wrapper_exact_owner_envelope_required') + need(envelope.get('source_hashes',{}).get(str(Path(args.child)))==args.child_sha256 + and envelope.get('source_hashes',{}).get(str(Path(__file__).resolve()))==hash_bytes(Path(__file__).read_bytes()), + 'wrapper_child_and_wrapper_pins_required') + expiry=envelope['valid_until_epoch'];maximum=envelope['maximum_session_seconds'] + need(finite(expiry) and type(maximum) is int and 420<=maximum<=900 + and 00,'wrapper_session_window_expired') + with deadline(remaining): + rc=proc.wait(timeout=remaining) + # Bounded captures: even accidental child output is never forwarded. + unexpected=proc.stdout.read(MAX_READY+1) or proc.stderr.read(MAX_READY+1) + need(not unexpected,'wrapper_child_output_refused') + return 0 if rc==0 else 1 + except BaseException: + return 1 + finally: + for fd in (read_fd,write_fd): + if fd is not None: + try:os.close(fd) + except OSError:pass + if proc is not None: + if proc.poll() is None: + # Unknown/partial native outcomes remain owner recovery, never retry. + try: + with deadline(5): + proc.terminate();proc.wait(timeout=5) + except Exception: + try: + with deadline(2): + proc.kill();proc.wait(timeout=2) + except Exception:pass + for stream in (proc.stdout,proc.stderr): + if stream is not None: + try: + with deadline(0.02):stream.close() + except Exception:pass + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + for name in ('child','child-sha256','envelope','envelope-sha256','receipt','session-nonce'): + parser.add_argument('--'+name,required=True) + return run(parser.parse_args()) + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-session.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-session.py new file mode 100644 index 00000000..d360df78 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-negative-session.py @@ -0,0 +1,860 @@ +#!/usr/bin/env python3 +"""Founder-attended KEY39 lifecycle. Silent; private values never leave memory/K8s. + +inspect is metadata-only (no Secret read). execute requires an exact approved +envelope, source/runtime pins and founder-held private tokens on stdin. No login, +custody fetch, production credential, role or policy mutation is implemented. +""" +import argparse +import base64 +import copy +import hashlib +import importlib.util +import json +import math +import fcntl +import stat +import os +from pathlib import Path +import secrets +import signal +import subprocess +import threading +import time +from contextlib import contextmanager +import urllib.parse +import urllib.request +import urllib.error + +import yaml + +CLUSTER = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +TARGET = 'sso/keycape' +PROPOSAL = 'KEY-WP-0039-T02/attended-lifecycle-v9-ready-input' +IDS = ['openbao-probe-' + c for c in ['aud', 'sub', 'tenant', 'roles', 'scope', 'expiry']] +ENVS = ['KEYCAPE_' + c.upper().replace('-', '_') + '_CLIENT_SECRET' for c in IDS] +TEMP = 'keycape-openbao-negative-key39' +BAO_ORIGINS = {'http://127.0.0.1:18200', 'http://openbao.openbao.svc:8200'} +REQUEST_DEADLINE = None +BUSINESS_MONOTONIC_DEADLINE = None +CLEANUP_MONOTONIC_DEADLINE = None +CLEANING = False +MAX_RESPONSE = 256 * 1024 +HTTP_TOTAL_SECONDS = 15 +CLEANUP_TOTAL_SECONDS = 300 +HTTP_ABORT_SECONDS = 0.02 +PRIVATE_INPUT_MAX_BYTES = 65536 +PRIVATE_TOKEN_MAX_BYTES = 16384 +PRIVATE_INPUT_TOTAL_SECONDS = 15 +READY_SCHEMA = 'key39-private-input-ready/v1' +READY_MAX_BYTES = 4096 + + +class Refused(Exception): + pass + + +def require(ok, reason): + if not ok: + raise Refused(reason) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def canonical(value): + return digest(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()) + + +def finite_number(value): + try: + return type(value) in (int, float) and math.isfinite(value) + except OverflowError: + return False + + +def read_json(raw): + def reject_constant(_): + raise Refused('nonfinite_json_constant') + return json.loads(raw, parse_constant=reject_constant) + + +def begin_cleanup(): + global CLEANUP_MONOTONIC_DEADLINE + if CLEANUP_MONOTONIC_DEADLINE is None: + CLEANUP_MONOTONIC_DEADLINE = time.monotonic() + CLEANUP_TOTAL_SECONDS + + +def check_deadline(): + if CLEANING: + require(CLEANUP_MONOTONIC_DEADLINE is not None + and time.monotonic() < CLEANUP_MONOTONIC_DEADLINE, 'cleanup_budget_expired') + return + if REQUEST_DEADLINE is not None and not CLEANING: + require(finite_number(REQUEST_DEADLINE), 'approved_window_invalid') + require(time.time() < REQUEST_DEADLINE, 'approved_window_expired') + if BUSINESS_MONOTONIC_DEADLINE is not None: + require(time.monotonic() < BUSINESS_MONOTONIC_DEADLINE, 'session_business_budget_expired') + + +def request_budget(cap, cleanup=False): + require(finite_number(cap) and 0 < cap <= 30, 'request_timeout_invalid') + budget = float(cap) + if cleanup or CLEANING: + if CLEANUP_MONOTONIC_DEADLINE is not None: + budget = min(budget, CLEANUP_MONOTONIC_DEADLINE - time.monotonic()) + else: + check_deadline() + if REQUEST_DEADLINE is not None: + budget = min(budget, REQUEST_DEADLINE - time.time()) + if BUSINESS_MONOTONIC_DEADLINE is not None: + budget = min(budget, BUSINESS_MONOTONIC_DEADLINE - time.monotonic()) + require(budget > 0 and math.isfinite(budget), 'request_window_exhausted') + return budget + + +@contextmanager +def total_timeout(seconds): + """Linux main-thread hard elapsed bound, including blocking headers/body reads.""" + require(finite_number(seconds) and 0 < seconds <= 30, 'request_timeout_invalid') + require(threading.current_thread() is threading.main_thread() + and hasattr(signal, 'setitimer'), 'main_thread_linux_timer_required') + previous_timer = signal.getitimer(signal.ITIMER_REAL) + require(previous_timer == (0.0, 0.0), 'existing_alarm_timer_refused') + previous_handler = signal.getsignal(signal.SIGALRM) + def expired(*_): + raise Refused('request_total_timeout') + started = time.monotonic() + signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + require(time.monotonic() - started <= seconds, 'request_total_timeout') + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +class SessionTransport: + """Fixed origins, no ambient proxy, bounded reads, same guards in cleanup.""" + def __init__(self, bao_origin, receipt=None): + require(bao_origin in BAO_ORIGINS, 'bao_origin_invalid') + self.bao_origin = bao_origin + self.receipt = receipt if receipt is not None else {} + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + + def request(self, url, *, data=None, headers=None, method='POST', timeout=15): + allowed = {'https://kc.coulomb.social/token', + 'https://kc.coulomb.social/.well-known/openid-configuration', + self.bao_origin + '/v1/auth/keycape-services/login', + self.bao_origin + '/v1/auth/token/revoke-self', + self.bao_origin + '/v1/sys/health'} + require(url in allowed, 'http_target_not_reviewed') + require(method == ('GET' if url.endswith(('/.well-known/openid-configuration', '/v1/sys/health')) else 'POST'), + 'http_method_not_reviewed') + cleanup = url.endswith('/v1/auth/token/revoke-self') + budget = request_budget(min(timeout, HTTP_TOTAL_SECONDS), cleanup=cleanup) + total_deadline = time.monotonic() + budget + req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) + response, status, raw = None, None, None + try: + with total_timeout(budget): + try: + response = self.opener.open(req, timeout=budget) + except urllib.error.HTTPError as error: + response = error + except (urllib.error.URLError, OSError): + if url == self.bao_origin + '/v1/auth/keycape-services/login': + self.receipt['openbao_login_outcome_unknown'] = True + return None, b'' + status = response.getcode() + raw = response.read(MAX_RESPONSE + 1) + require(len(raw) <= MAX_RESPONSE, 'http_response_oversized') + response.close() + response = None + if not cleanup: + check_deadline() + return status, raw + except Refused: + if url == self.bao_origin + '/v1/auth/keycape-services/login' and status in (None, 200): + # A late response cannot pass; preserve acquired-token cleanup when available. + if raw is not None and status == 200: + self.revoke_late_session(raw) + else: + self.receipt['openbao_login_outcome_unknown'] = True + raise + except (urllib.error.URLError, OSError): + if url == self.bao_origin + '/v1/auth/keycape-services/login': + if raw is not None and status == 200: + self.revoke_late_session(raw) + else: + self.receipt['openbao_login_outcome_unknown'] = True + return None, b'' + except Exception: + if url == self.bao_origin + '/v1/auth/keycape-services/login': + if raw is not None and status == 200: + self.revoke_late_session(raw) + else: + self.receipt['openbao_login_outcome_unknown'] = True + raise Refused('http_response_failed') from None + finally: + if response is not None: + self.abort_response(response, total_deadline) + + def abort_response(self, response, original_deadline): + """One bounded exceptional close; never retry or reset the cleanup clock.""" + self.receipt['http_abort_close_attempted'] = False + self.receipt['http_abort_close_succeeded'] = False + begin_cleanup() + try: + cap = request_budget(HTTP_ABORT_SECONDS, cleanup=True) + remaining = original_deadline - time.monotonic() + if remaining > 0: + cap = min(cap, remaining) + self.receipt['http_abort_close_attempted'] = True + with total_timeout(cap): + response.close() + self.receipt['http_abort_close_succeeded'] = True + except Exception: + self.receipt['http_response_abort_unconfirmed'] = True + raise Refused('http_response_abort_unconfirmed') from None + + def revoke_late_session(self, raw): + self.receipt['late_acceptance_revoke_attempted'] = False + self.receipt['late_acceptance_revoke_succeeded'] = False + try: + token = read_json(raw)['auth']['client_token'] + require(isinstance(token, str) and token, 'late_session_response_invalid') + except (KeyError, TypeError, ValueError, Refused): + self.receipt['openbao_login_outcome_unknown'] = True + return + begin_cleanup() + self.receipt['late_acceptance_revoke_attempted'] = True + try: + status, _ = self.post(self.bao_origin + '/v1/auth/token/revoke-self', + body={}, headers={'X-Vault-Token': token}) + self.receipt['late_acceptance_revoke_succeeded'] = status == 204 + except Exception: + self.receipt['late_acceptance_revoke_succeeded'] = False + finally: + del token + + def post(self, url, form=None, body=None, headers=None, timeout=15): + headers = dict(headers or {}) + if form is not None: + data = urllib.parse.urlencode(form).encode() + headers['Content-Type'] = 'application/x-www-form-urlencoded' + else: + data = json.dumps(body or {}).encode() + headers['Content-Type'] = 'application/json' + return self.request(url, data=data, headers=headers, timeout=timeout) + + def health_identity(self): + # Public endpoint only: no token/header, login, Secret or route creation. + status, raw = self.request(self.bao_origin + '/v1/sys/health', method='GET') + require(status == 200, 'bao_public_health_unavailable') + try: + body = read_json(raw) + require(isinstance(body, dict) and body.get('initialized') is True + and body.get('sealed') is False and body.get('standby') is False + and body.get('performance_standby', False) is False, + 'bao_public_health_not_active') + cluster_id = body.get('cluster_id') + require(isinstance(cluster_id, str) and 0 < len(cluster_id) <= 128, + 'bao_public_health_identity_required') + return digest(cluster_id.encode()) + except (TypeError, ValueError): + raise Refused('bao_public_health_invalid') from None + + def discovery(self): + status, raw = self.request('https://kc.coulomb.social/.well-known/openid-configuration', method='GET') + require(status == 200, 'discovery_failed') + return json.loads(raw) + + +def k(arguments, payload=None): + # Execute on railiance01 in the founder's session, never workstation defaults. + check_deadline() + budget = request_budget(30) + with total_timeout(budget): + result = subprocess.run(['kubectl', '--kubeconfig=/etc/rancher/k3s/k3s.yaml', + '--request-timeout=20s', *arguments], + input=None if payload is None else json.dumps(payload), + text=True, capture_output=True, timeout=budget) + check_deadline() + require(result.returncode == 0, 'kubernetes_operation_failed') + return json.loads(result.stdout) if result.stdout.strip() else None + + +def get(kind, name): + return k(['-n', 'sso', 'get', kind, name, '-o', 'json']) + + +def meta(obj): + return {key: obj['metadata'][key] for key in ['uid', 'resourceVersion']} + + +def patch(kind, obj, path, value, dry=False): + ops = [{'op': 'test', 'path': '/metadata/' + key, 'value': val} + for key, val in meta(obj).items()] + ops.append({'op': 'replace', 'path': path, 'value': value}) + args = ['-n', 'sso', 'patch', kind, obj['metadata']['name'], '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if dry: + args += ['--dry-run=server'] + return k(args, ops) + + +def registrations(): + reference = {'displayName': 'Temporary KEY39 negative probe', + 'allowedScopes': ['openbao:login'], 'grantTypes': ['client_credentials'], + 'clientType': 'confidential', 'serviceSubject': 'service:secrets-engine', + 'tenant': 'tenant:platform', 'roles': ['secrets-engine'], 'tokenLifetime': '1m'} + result = [] + for index, client_id in enumerate(IDS): + c = dict(reference, clientId=client_id, secretRef='env:' + ENVS[index]) + if index: + c['audience'] = 'secrets-engine-openbao' + if index == 1: + c['serviceSubject'] = 'service:openbao-probe' + if index == 2: + c['tenant'] = 'tenant:openbao-probe' + if index == 3: + c['roles'] = ['openbao-probe'] + if index == 4: + c['allowedScopes'] = ['openbao:probe'] + result.append(c) + return result + + +class UniqueLoader(yaml.SafeLoader): + pass + + +def unique(loader, node): + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node) + require(key not in result, 'duplicate_yaml_key') + result[key] = loader.construct_object(value_node) + return result + + +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique) + + +def candidate_config(raw): + config = yaml.load(raw, Loader=UniqueLoader) + clients = config['clients'] + require(isinstance(clients, list) and clients, 'clients_required') + ids = [c['clientId'] for c in clients] + require(len(ids) == len(set(ids)) and not set(ids).intersection(IDS), 'existing_probe_or_duplicate') + require(not {c.get('secretRef') for c in clients}.intersection('env:' + e for e in ENVS), + 'probe_secretref_collision') + production = [c for c in clients if c['clientId'] == 'secrets-engine-openbao'] + require(len(production) == 1, 'production_client_missing') + expected = registrations()[-1] + for field in ['allowedScopes', 'grantTypes', 'clientType', 'serviceSubject', 'tenant', 'roles']: + require(production[0].get(field) == expected[field], 'production_claim_contract_drift') + require(production[0].get('audience', production[0]['clientId']) == 'secrets-engine-openbao', + 'production_audience_drift') + nodes = yaml.compose(raw) + node = next(v for key, v in nodes.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + index = node.end_mark.index + start = raw.rfind('\n', 0, index) + 1 + if not raw[start:index].strip(): + index = start + fragment = yaml.safe_dump(registrations(), sort_keys=False) + fragment = ''.join(' ' * node.start_mark.column + line if line.strip() else line + for line in fragment.splitlines(True)) + if index and raw[index - 1] != '\n': + fragment = '\n' + fragment + updated = raw[:index] + fragment + raw[index:] + intended = copy.deepcopy(config) + intended['clients'].extend(registrations()) + require(yaml.load(updated, Loader=UniqueLoader) == intended, 'unrelated_config_changed') + return updated + + +def candidate_spec(spec): + result = copy.deepcopy(spec) + require(result.get('replicas', 1) == 1 and result['strategy']['type'] == 'Recreate', + 'single_replica_recreate_required') + container = next(c for c in result['template']['spec']['containers'] if c['name'] == 'keycape') + env = container['env'] + require(not {e['name'] for e in env}.intersection(ENVS), 'probe_env_collision') + env.extend({'name': e, 'valueFrom': {'secretKeyRef': {'name': TEMP, 'key': c}}} + for c, e in zip(IDS, ENVS)) + return result + + +def ready(spec, timeout=150): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + dep = get('deployment', 'keycape') + require(dep['spec'] == spec, 'deployment_drift_during_readiness') + status = dep.get('status', {}) + if (status.get('observedGeneration') == dep['metadata']['generation'] + and all(status.get(key) == 1 for key in + ['updatedReplicas', 'readyReplicas', 'availableReplicas', 'replicas'])): + return True + time.sleep(3) + raise Refused('readiness_timeout') + + +def verify_sources(envelope, recovery=False): + require(envelope.get('proposal_id') == PROPOSAL and envelope.get('target') == TARGET, + 'proposal_target_mismatch') + require(envelope.get('disposition') == ('recovery-approved' if recovery else 'approved') + and envelope.get('founder_decision_id'), + 'founder_exact_go_required') + require(envelope.get('source_hashes') and + str(Path(__file__).resolve()) in envelope['source_hashes'], 'executor_pin_required') + for filename, expected in envelope['source_hashes'].items(): + require(digest(Path(filename).read_bytes()) == expected, 'reviewed_source_changed') + required = ['net-kingdom', 'key-cape', 'railiance-platform'] + require(all(envelope.get('owner_acknowledgments', {}).get(owner) for owner in required), + 'owner_acknowledgments_required') + expiry = envelope['valid_until_epoch'] + maximum = envelope['maximum_session_seconds'] + require(finite_number(expiry) and type(maximum) is int and + 420 <= maximum <= 900 and 0 < expiry - time.time() <= maximum, + 'attended_window_invalid') + if recovery: + return None + role = envelope['role_observation'] + require(role['mount'] == 'keycape-services' and role['role'] == 'secrets-engine-openbao' + and role['issuer'] == 'https://kc.coulomb.social' and role['algorithm'] == 'RS256' + and role['bindings_exact'] is True and role['policy_exact'] is True, + 'role_owner_observation_required') + leeway = role['raw_expiration_leeway'] + require(type(leeway) is int and -1 <= leeway <= 150, 'leeway_outside_session_bound') + effective = 0 if leeway == -1 else leeway or 150 + require(role['effective_expiration_leeway'] == effective, 'leeway_normalization_mismatch') + observed = role['observed_at_epoch'] + require(finite_number(observed) and 0 <= time.time() - observed <= 300, + 'role_observation_stale_or_future') + require(envelope['bao_addr'] in BAO_ORIGINS, 'bao_origin_invalid') + return effective + + +def load_probe(path): + spec = importlib.util.spec_from_file_location('key39_reviewed_probe', path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +def removed_client_denied(status, raw): + try: + body = json.loads(raw) + return status == 401 and body.get('error') == 'invalid_profile_usage' and body.get('feature') == 'client_id' + except (ValueError, AttributeError, TypeError): + return False + + +def recovered_config(raw): + config = yaml.load(raw, Loader=UniqueLoader) + clients = config['clients'] + ids = [c['clientId'] for c in clients] + present = set(ids).intersection(IDS) + require(len(ids) == len(set(ids)) and present in [set(), set(IDS)], 'partial_probe_registry') + if not present: + return raw + require(clients[-6:] == registrations(), 'recovery_registration_drift') + node = next(v for key, v in yaml.compose(raw).value if key.value == 'clients') + first = node.value[-6].start_mark.index + start = raw.rfind('\n', 0, first) + 1 + end = node.end_mark.index + line_start = raw.rfind('\n', 0, end) + 1 + if not raw[line_start:end].strip(): + end = line_start + restored = raw[:start] + raw[end:] + require(candidate_config(restored) == raw, 'recovery_byte_roundtrip_failed') + return restored + + +def recovered_spec(spec): + restored = copy.deepcopy(spec) + container = next(c for c in restored['template']['spec']['containers'] if c['name'] == 'keycape') + env = container['env'] + expected = [{'name': e, 'valueFrom': {'secretKeyRef': {'name': TEMP, 'key': c}}} + for c, e in zip(IDS, ENVS)] + matches = [e for e in env if e['name'] in ENVS] + require(matches in [[], expected], 'partial_or_drifted_probe_env') + container['env'] = [e for e in env if e['name'] not in ENVS] + return restored + + +def delete_temporary(created): + # The exact raw API delete needs the same aggregate/per-request guards as patches. + k(['delete', '--raw=/api/v1/namespaces/sso/secrets/' + TEMP, '-f', '/dev/stdin'], + {'apiVersion': 'v1', 'kind': 'DeleteOptions', 'preconditions': meta(created)}) + require(k(['-n', 'sso', 'get', 'secret', TEMP, '--ignore-not-found', '-o', 'json']) is None, + 'temporary_secret_still_present') + + +def verify_bao_route(transport, envelope, receipt): + expected = envelope.get('bao_health_identity_sha256') + require(isinstance(expected, str) and len(expected) == 64 + and all(c in '0123456789abcdef' for c in expected), + 'approved_bao_health_identity_required') + require(transport.health_identity() == expected, 'bao_public_health_identity_changed') + receipt['bao_public_route_verified'] = True + + +def recover(envelope, receipt): + global REQUEST_DEADLINE, BUSINESS_MONOTONIC_DEADLINE, CLEANUP_MONOTONIC_DEADLINE, CLEANING + verify_sources(envelope, recovery=True) + # Separately approved recovery obligations are cleanup, never business writes. + REQUEST_DEADLINE = None + BUSINESS_MONOTONIC_DEADLINE = None + CLEANUP_MONOTONIC_DEADLINE = None + begin_cleanup() + CLEANING = True + transport = SessionTransport(envelope['bao_addr'], receipt=receipt) + verify_bao_route(transport, envelope, receipt) + require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER, + 'cluster_identity_changed') + config = get('secret', 'keycape-config') + dep = get('deployment', 'keycape') + raw = base64.b64decode(config['data']['config.yaml'], validate=True).decode() + restored = recovered_config(raw) + spec = recovered_spec(dep['spec']) + require(config['metadata']['uid'] == envelope['config_metadata']['uid'] + and dep['metadata']['uid'] == envelope['deployment_metadata']['uid'] + and digest(restored.encode()) == envelope['registry_sha256'] + and canonical(spec) == envelope['deployment_spec_sha256'], 'recovery_baseline_drift') + temp = get('secret', TEMP) + require(set(temp.get('data', {})) == set(IDS) + and temp['metadata']['uid'] == envelope['temporary_secret_uid'], 'recovery_custody_identity_required') + values = {c: base64.b64decode(temp['data'][c], validate=True).decode() for c in IDS} + if raw != restored: + patch('secret', config, '/data/config.yaml', base64.b64encode(restored.encode()).decode()) + if dep['spec'] != spec: + patch('deployment', dep, '/spec', spec) + ready(spec) + after = get('secret', 'keycape-config') + require(after['data'] == dict(config['data'], **{'config.yaml': base64.b64encode(restored.encode()).decode()}), + 'recovery_readback_failed') + require(envelope['runner_path'] in envelope['source_hashes'], 'runner_pin_required') + probe = load_probe(envelope['runner_path']) + for client, value in values.items(): + basic = base64.b64encode((client + ':' + value).encode()).decode() + status, body = transport.post('https://kc.coulomb.social/token', + form={'grant_type': 'client_credentials', 'scope': 'openbao:login'}, + headers={'Authorization': 'Basic ' + basic}) + require(removed_client_denied(status, body), 'removed_client_denial_unconfirmed') + observed = get('secret', TEMP) + require(observed['data'] == temp['data'] and observed['metadata']['uid'] == temp['metadata']['uid'], + 'recovery_temporary_secret_drift') + delete_temporary(observed) + receipt.update(cleanup_verified=True, recovery_only=True) + + +def emit_private_ready(fd, nonce, expected_envelope_sha256, envelope_sha256, envelope, receipt): + # A value-free synchronization marker, never authentication or a grant. + require(type(fd) is int and fd >= 3, 'private_ready_pipe_required') + require(isinstance(nonce, str) and len(nonce) == 64 + and all(c in '0123456789abcdef' for c in nonce), 'ready_nonce_required') + require(expected_envelope_sha256 == envelope_sha256, 'ready_envelope_pin_changed') + validated = False + try: + current = os.fstat(fd) + require(stat.S_ISFIFO(current.st_mode) + and fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_ACCMODE == os.O_WRONLY, + 'private_ready_pipe_required') + identity = (current.st_dev, current.st_ino) + require(all(identity != (os.fstat(other).st_dev, os.fstat(other).st_ino) + for other in (0, 1, 2)), 'private_ready_stdio_alias_refused') + validated = True + check_deadline() + marker = {'schema': READY_SCHEMA, 'session_nonce': nonce, + 'proposal_id': PROPOSAL, 'target': TARGET, + 'executor_sha256': digest(Path(__file__).read_bytes()), + 'envelope_sha256': envelope_sha256, + 'bao_health_identity_sha256': envelope['bao_health_identity_sha256'], + 'valid_until_epoch': envelope['valid_until_epoch'], + 'receive_budget_seconds': request_budget(PRIVATE_INPUT_TOTAL_SECONDS)} + payload = json.dumps(marker, sort_keys=True, separators=(',', ':')).encode() + b'\n' + require(len(payload) <= READY_MAX_BYTES, 'private_ready_oversized') + with total_timeout(request_budget(2)): + offset = 0 + while offset < len(payload): + written = os.write(fd, payload[offset:]) + require(type(written) is int and written > 0, 'private_ready_delivery_failed') + offset += written + check_deadline() + receipt['private_ready_emitted'] = True + except Refused: + raise + except Exception: + raise Refused('private_ready_delivery_failed') from None + finally: + if validated: + os.close(fd) + + +def read_private_input(stream): + # Complete private JSON plus EOF, within this original approved action window. + # No unbounded read, text buffering, token logging or renewed deadline. + check_deadline() + budget = request_budget(PRIVATE_INPUT_TOTAL_SECONDS) + binary = getattr(stream, 'buffer', None) + require(binary is not None, 'private_binary_input_required') + chunks, size = [], 0 + with total_timeout(budget): + while True: + chunk = binary.read(min(4096, PRIVATE_INPUT_MAX_BYTES + 1 - size)) + require(isinstance(chunk, bytes), 'private_binary_input_required') + if not chunk: + break + size += len(chunk) + require(size <= PRIVATE_INPUT_MAX_BYTES, 'private_input_oversized') + chunks.append(chunk) + check_deadline() + require(size > 0, 'private_input_empty') + try: + private = read_json(b''.join(chunks)) + require(isinstance(private, dict) + and set(private) == {'human_token', 'coding_agent_token'} + and all(isinstance(value, str) and + 0 < len(value.encode('utf-8')) <= PRIVATE_TOKEN_MAX_BYTES + for value in private.values()), 'private_input_invalid') + except (TypeError, ValueError, UnicodeError, Refused): + raise Refused('private_input_invalid') from None + check_deadline() + return private + + +def start_execution_window(envelope): + global REQUEST_DEADLINE, BUSINESS_MONOTONIC_DEADLINE, CLEANUP_MONOTONIC_DEADLINE, CLEANING + REQUEST_DEADLINE = envelope['valid_until_epoch'] + BUSINESS_MONOTONIC_DEADLINE = time.monotonic() + min( + REQUEST_DEADLINE - time.time(), envelope['maximum_session_seconds'] - 180) + CLEANUP_MONOTONIC_DEADLINE = None + CLEANING = False + + +def execute(envelope, private, receipt, checkpoint=lambda: None, window_started=False): + global CLEANING + leeway = verify_sources(envelope) + if not window_started: + start_execution_window(envelope) + else: + # main already began this exact window before consuming private stdin. + # Preserve its original monotonic budget; input waiting cannot renew it. + require(REQUEST_DEADLINE == envelope['valid_until_epoch'] + and BUSINESS_MONOTONIC_DEADLINE is not None and not CLEANING, + 'preinput_execution_window_required') + check_deadline() + runner_path = envelope['runner_path'] + require(runner_path in envelope['source_hashes'], 'runner_pin_required') + probe = load_probe(runner_path) + transport = SessionTransport(envelope['bao_addr'], receipt=receipt) + discovery = transport.discovery() + require(discovery.get('issuer') == 'https://kc.coulomb.social' and + discovery.get('token_endpoint') == 'https://kc.coulomb.social/token', + 'issuer_discovery_changed') + verify_bao_route(transport, envelope, receipt) + require(isinstance(private.get('human_token'), str) and private['human_token'] + and isinstance(private.get('coding_agent_token'), str) and private['coding_agent_token'], + 'founder_private_tokens_required') + require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER, + 'cluster_identity_changed') + before = get('secret', 'keycape-config') + deployment = get('deployment', 'keycape') + require(not before['metadata'].get('ownerReferences'), 'controller_owned_config') + require(meta(before) == envelope['config_metadata'] + and meta(deployment) == envelope['deployment_metadata'] + and canonical(deployment['spec']) == envelope['deployment_spec_sha256'], + 'approved_runtime_revision_changed') + raw = base64.b64decode(before['data']['config.yaml'], validate=True).decode() + require(digest(raw.encode()) == envelope['registry_sha256'], 'approved_registry_changed') + updated = base64.b64encode(candidate_config(raw).encode()).decode() + intended_data = dict(before['data'], **{'config.yaml': updated}) + candidate = candidate_spec(deployment['spec']) + ready(deployment['spec']) + # Exact GET existence is tested via create (never adopt/delete an existing Secret). + values = {c: secrets.token_urlsafe(48) for c in IDS} + temporary = {'apiVersion': 'v1', 'kind': 'Secret', 'metadata': {'namespace': 'sso', 'name': TEMP}, + 'type': 'Opaque', 'data': {c: base64.b64encode(v.encode()).decode() for c, v in values.items()}} + patch('secret', before, '/data/config.yaml', updated, dry=True) + patch('deployment', deployment, '/spec', candidate, dry=True) + k(['create', '-f', '/dev/stdin', '--dry-run=server', '-o', 'json'], temporary) + created = None + mutations_attempted = False + began = time.monotonic() + try: + # Set before API call: uncertain response must still trigger reconciliation. + check_deadline() + verify_bao_route(transport, envelope, receipt) + mutations_attempted = True + created = k(['create', '-f', '/dev/stdin', '-o', 'json'], temporary) + receipt['temporary_secret_uid'] = created['metadata']['uid'] + checkpoint() + patch('secret', before, '/data/config.yaml', updated) + patch('deployment', deployment, '/spec', candidate) + ready(candidate) + require(get('secret', 'keycape-config')['data'] == intended_data, 'registry_drift') + require(time.monotonic() - began < 300, 'probe_start_too_late') + # Current admitted agent token stays founder-owned; no existing secret fetched. + original_exchange = probe.exchange + def exchange(post, endpoint, client, secret, scope): + if client == 'codex-railiance-platform': + return private['coding_agent_token'] + return original_exchange(post, endpoint, client, secret, scope) + probe.exchange = exchange + supplied = dict(values, **{'codex-railiance-platform': 'founder-held-token'}) + def bounded_post(*args, **kwargs): + if not args[0].endswith('/v1/auth/token/revoke-self'): + require(time.monotonic() - began < envelope['maximum_session_seconds'] - 180, + 'session_cleanup_reserve_reached') + return transport.post(*args, **kwargs) + def bounded_sleep(seconds): + while seconds > 0: + check_deadline() + require(time.monotonic() - began < envelope['maximum_session_seconds'] - 180, + 'session_cleanup_reserve_reached') + part = min(seconds, 3) + time.sleep(part) + seconds -= part + result = probe.run(bounded_post, supplied, 'https://kc.coulomb.social/token', + envelope['bao_addr'], 'keycape-services', 'secrets-engine-openbao', + human_token=private['human_token'], leeway=leeway, sleep=bounded_sleep) + receipt['negative_probe'] = result + checkpoint() + check_deadline() + require(time.monotonic() - began <= envelope['maximum_session_seconds'], 'session_window_exceeded') + require(result['all_refused'], 'negative_proof_incomplete') + finally: + if mutations_attempted: + begin_cleanup() + CLEANING = True + # Never restore over an unrelated edit; keep custody for owner recovery on drift. + now = get('secret', 'keycape-config') + require(now['metadata']['uid'] == before['metadata']['uid'] and + now['data'] in [before['data'], intended_data], 'cleanup_registry_drift') + if now['data'] == intended_data: + patch('secret', now, '/data/config.yaml', before['data']['config.yaml']) + now = get('deployment', 'keycape') + require(now['metadata']['uid'] == deployment['metadata']['uid'] and + now['spec'] in [deployment['spec'], candidate], 'cleanup_deployment_drift') + if now['spec'] == candidate: + patch('deployment', now, '/spec', deployment['spec']) + ready(deployment['spec']) + require(get('secret', 'keycape-config')['data'] == before['data'], 'cleanup_readback_failed') + # No fresh JWT is minted: deleted clients must fail at the issuer. + for client in IDS: + basic = base64.b64encode((client + ':' + values[client]).encode()).decode() + status, body = transport.post('https://kc.coulomb.social/token', + form={'grant_type': 'client_credentials', 'scope': 'openbao:login'}, + headers={'Authorization': 'Basic ' + basic}) + require(removed_client_denied(status, body), + 'removed_client_denial_unconfirmed') + if created is None: + # A timed-out create may have succeeded; compare generated bytes before delete. + observed = get('secret', TEMP) + require(observed.get('data') == temporary['data'], 'temporary_secret_identity_unconfirmed') + created = observed + observed = get('secret', TEMP) + require(observed['metadata']['uid'] == created['metadata']['uid'] + and observed.get('data') == temporary['data'], 'temporary_secret_drift') + delete_temporary(observed) + receipt['cleanup_verified'] = True + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('mode', choices=['inspect', 'route-preflight', 'founder-preflight', 'execute', 'recover']) + ap.add_argument('--envelope') + ap.add_argument('--bao-addr', choices=sorted(BAO_ORIGINS)) + ap.add_argument('--ready-fd', type=int) + ap.add_argument('--session-nonce') + ap.add_argument('--ready-envelope-sha256') + ap.add_argument('--confirm') + ap.add_argument('--receipt', required=True) + args = ap.parse_args() + receipt = {'proposal_id': PROPOSAL, 'target': TARGET, 'native_accepted': False, + 'values_emitted': False, 'cleanup_verified': False} + # Do not truncate or follow a link; protected receipts cannot overwrite credentials. + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + def checkpoint(): + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, json.dumps(receipt, sort_keys=True, indent=2).encode()) + os.fsync(fd) + rc = 1 + try: + if args.mode == 'route-preflight': + require(args.bao_addr is not None, 'bao_origin_required') + transport = SessionTransport(args.bao_addr, receipt=receipt) + receipt.update(bao_health_identity_sha256=transport.health_identity(), + bao_public_route_verified=True, mode='public_route_preflight', + probe_location='current_process_only') + elif args.mode in ['inspect', 'founder-preflight']: + require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER, + 'cluster_identity_changed') + dep = get('deployment', 'keycape') + receipt.update(deployment_metadata=meta(dep), deployment_spec_sha256=canonical(dep['spec']), + image=next(c['image'] for c in dep['spec']['template']['spec']['containers'] + if c['name'] == 'keycape'), mode='metadata_only') + if args.mode == 'founder-preflight': + require(args.confirm == 'FOUNDER ATTENDED KEY39 PREFLIGHT', 'founder_attendance_required') + require(args.bao_addr is not None, 'bao_origin_required') + transport = SessionTransport(args.bao_addr, receipt=receipt) + receipt.update(bao_health_identity_sha256=transport.health_identity(), + bao_public_route_verified=True) + config = get('secret', 'keycape-config') + require(not config['metadata'].get('ownerReferences'), 'controller_owned_config') + raw = base64.b64decode(config['data']['config.yaml'], validate=True).decode() + candidate_config(raw) + candidate_spec(dep['spec']) + require(k(['-n', 'sso', 'get', 'secret', TEMP, '--ignore-not-found', '-o', 'json']) is None, + 'temporary_secret_already_exists') + ready(dep['spec']) + receipt.update(config_metadata=meta(config), registry_sha256=digest(raw.encode()), + baseline_ready=True, mode='founder_protected_preflight', + probe_ids_absent=True, probe_env_absent=True, temporary_secret_absent=True) + else: + require(args.envelope is not None, 'approval_envelope_required') + envelope_raw = Path(args.envelope).read_bytes() + envelope = read_json(envelope_raw) + envelope_sha256 = digest(envelope_raw) + require(Path(args.envelope).stat().st_mode & 0o022 == 0, 'approval_envelope_writable') + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(Refused('session_interrupted'))) + if args.mode == 'recover': + recover(envelope, receipt) + else: + # Public route/source readiness precedes private token consumption. + verify_sources(envelope) + start_execution_window(envelope) + transport = SessionTransport(envelope['bao_addr'], receipt=receipt) + verify_bao_route(transport, envelope, receipt) + emit_private_ready(args.ready_fd, args.session_nonce, args.ready_envelope_sha256, + envelope_sha256, envelope, receipt) + private = read_private_input(__import__('sys').stdin) + execute(envelope, private, receipt, checkpoint=checkpoint, window_started=True) + receipt['negative_and_cleanup_verified'] = True + rc = 0 + except Refused as error: + receipt.update(status='incomplete_requires_owner_review', reason=str(error)) + except BaseException: + # Fixed outcome only: exception text may contain protected Kubernetes/HTTP data. + receipt['status'] = 'incomplete_requires_owner_review' + finally: + checkpoint() + os.close(fd) + return rc + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py new file mode 100644 index 00000000..2fa9a44a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Turn on KeyCape's sign-in diagnosis page for the openbao-admin client. + +NK-WP-0051-T02 / KEY-WP-0041-T02. Adds one line, `signInDiagnosis: true`, to +the existing openbao-admin registration in sso/keycape-config. The field +contract is docs/sign-in-diagnosis.md; the image that honours the flag is +key-cape sha256:cdec9dd7 (source 8c6c071), live since 2026-10-15. + +Modes default to non-mutating. `inspect` reads and plans; `dry-run` sends a +server-side dry-run patch; `apply` writes and is reserved for the attended +operator with `--attended` and the observed uid/resourceVersion. `--operation +remove` is the rollback: it removes only that one line. Either way the issuer +reads the change only after `kubectl -n sso rollout restart deploy/keycape`. + +Exact-match edit: flag absent -> add; already true -> noop; any other value, +a missing or duplicated openbao-admin client -> fail closed. The signing key, +every other client and every other config byte are preserved. No Secret value, +parser message or Kubernetes error text leaves the process. +""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +# Reuse the owner lane's contained kubectl, refusal type and cluster pin. +lane = module('portal_rollout', 'portal-client-rollout.py') +pin = lane.pin +Refused = lane.Refused +require = lane.require + +CLIENT_ID = 'openbao-admin' +FLAG = 'signInDiagnosis' +LINE = FLAG + ': true' + + +def _inspect(secret): + raw, config, _ = pin.issuer_document(secret) + require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, + 'verified_issuer_pin_required') + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected') + ids = [c['clientId'] for c in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + require(CLIENT_ID in ids, 'openbao_client_missing') + position = ids.index(CLIENT_ID) + value = clients[position].get(FLAG, None) + require(value in (None, True), 'existing_flag_differs_requires_reconciliation') + root = yaml.compose(raw) + sequence = next(v for k, v in root.value if k.value == 'clients') + require(isinstance(sequence, yaml.nodes.SequenceNode) and not sequence.flow_style, + 'block_client_sequence_required') + item = sequence.value[position] + require(isinstance(item, yaml.nodes.MappingNode) and not item.flow_style, + 'block_client_mapping_required') + return raw, config, position, item + + +def _line_end(raw, index): + end = raw.find('\n', index) + return len(raw) if end < 0 else end + 1 + + +def enable(secret): + """Return (base64 config, changed) with only the flag line added.""" + raw, config, position, item = _inspect(secret) + if config['clients'][position].get(FLAG) is True: + return secret['data']['config.yaml'], False + key, value = item.value[0] + require(isinstance(value, yaml.nodes.ScalarNode) + and key.start_mark.line == value.end_mark.line, 'unexpected_item_layout') + index = _line_end(raw, value.end_mark.index) + prefix = '' if raw[index - 1:index] == '\n' else '\n' + updated = raw[:index] + prefix + ' ' * key.start_mark.column + LINE + '\n' + raw[index:] + expected = copy.deepcopy(config) + expected['clients'][position][FLAG] = True + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def disable(secret): + """Rollback: return (base64 config, changed) with only the flag line removed.""" + raw, config, position, item = _inspect(secret) + if FLAG not in config['clients'][position]: + return secret['data']['config.yaml'], False + key = next(k for k, _ in item.value if k.value == FLAG) + require(key is not item.value[0][0], 'unexpected_item_layout') + start = raw.rfind('\n', 0, key.start_mark.index) + 1 + end = _line_end(raw, key.start_mark.index) + require(raw[start:end].strip() == LINE, 'unexpected_item_layout') + updated = raw[:start] + raw[end:] + expected = copy.deepcopy(config) + del expected['clients'][position][FLAG] + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def run(args, kubectl=None): + kubectl = kubectl or lane.kubectl + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == lane.CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = (enable if args.operation == 'add' else disable)(before) + receipt = {'schema': 'netkingdom.keycape-openbao-signin-diagnosis.v1', + 'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation, + 'client_id': CLIENT_ID, 'flag': FLAG, 'change_needed': changed, 'mode': args.mode, + 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + if args.mode == 'apply': + require(args.attended, 'attended_operator_required') + require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] + and after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after), restart_required=changed) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(argv=None, kubectl=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('mode', nargs='?', default='inspect', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--operation', default='add', choices=['add', 'remove']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + parser.add_argument('--attended', action='store_true', help='required for apply') + args = parser.parse_args(argv) + try: + print(json.dumps(run(args, kubectl), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except pin.IssuerPinError as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/patch-openbao-client.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/patch-openbao-client.sh new file mode 100644 index 00000000..d007f388 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/patch-openbao-client.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Legacy unguarded registry mutation withdrawn under NK-WP-0049. +set +x +printf '%s\n' 'Retired: use the reviewed KeyCape client registration/rollout adapter with exact source, object/version checks, contained diagnostics and owner authorization. No registry or credential is read or changed here.' >&2 +exit 2 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-activation.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-activation.py new file mode 100644 index 00000000..211e7fb6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-activation.py @@ -0,0 +1,236 @@ +"""Private, digest-bound Playwright registry/Deployment patch preparation. + +No CLI or Kubernetes client: the existing protected owner execution path must +provide fresh objects, an independently approved proposal digest, admitted +custody, exclusive maintenance, and readiness/login verification. Proposal and +patch bodies contain private registry data; never log or serialize them to chat. +Only summary() is an output receipt. This library grants no execution authority. +""" +from __future__ import annotations + +import base64 +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import subprocess + +spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py')) +registration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(registration) +require = registration.require +Refused = registration.Refused + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + allow_nan=False).encode()).hexdigest() + + +def target(obj, kind, name): + require(obj.get('kind') == kind and obj.get('metadata', {}).get('name') == name + and obj['metadata'].get('namespace') == 'sso', 'unexpected_target') + require(not obj['metadata'].get('deletionTimestamp') and + not obj['metadata'].get('ownerReferences'), 'target_not_independently_available') + return registration.registry.pin.safe_metadata(obj) + + +def container(specification): + items = specification['template']['spec']['containers'] + matches = [item for item in items if item.get('name') == 'keycape'] + require(len(matches) == 1, 'keycape_container_required') + return matches[0] + + +def stopped(specification): + result = copy.deepcopy(specification) + result['replicas'] = 0 + return result + + +def build(secret, deployment, identities, source_revision): + secret_meta = target(secret, 'Secret', 'keycape-config') + deploy_meta = target(deployment, 'Deployment', 'keycape') + require(not secret.get('immutable'), 'mutable_registry_required') + require(re.fullmatch(r'[0-9a-f]{40}', source_revision or '') is not None, 'exact_source_revision_required') + original = deployment['spec'] + require(original.get('replicas') == 1 and original.get('strategy', {}).get('type') == 'Recreate' + and not original.get('paused'), 'single_recreate_issuer_required') + current = container(original) + require(re.fullmatch(r'forgejo\.coulomb\.social/coulomb/key-cape@sha256:[0-9a-f]{64}', + current.get('image', '')) is not None, 'immutable_owner_image_required') + require(not current.get('envFrom'), 'explicit_env_bindings_required') + env = current.get('env', []) + require(isinstance(env, list) and all(isinstance(e, dict) and isinstance(e.get('name'), str) for e in env), + 'env_shape_invalid') + names = [e['name'] for e in env] + require(len(names) == len(set(names)), 'duplicate_env_name') + require([e for e in env if e['name'] == 'KEYCAPE_CONFIG'] == + [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], 'config_path_drift') + volumes = original['template']['spec'].get('volumes', []) + mounts = current.get('volumeMounts', []) + expected = [v['name'] for v in volumes if v.get('secret', {}).get('secretName') == 'keycape-config'] + require(len(expected) == 1 and any(m.get('name') == expected[0] and + m.get('mountPath') == '/etc/keycape' and m.get('readOnly') is True and + not m.get('subPath') and not m.get('subPathExpr') for m in mounts), 'registry_mount_drift') + encoded, created = registration.prepare_registry(secret, identities) + proposed_data = dict(secret['data'], **{'config.yaml': encoded}) + proposed_spec = copy.deepcopy(original) + proposed_env = container(proposed_spec).setdefault('env', []) + added_env = [] + records = registration.clients(identities) + for record in records[1:]: + binding = registration.registry.env_binding(record) + target_ref = binding['valueFrom']['secretKeyRef'] + for entry in proposed_env: + reference = entry.get('valueFrom', {}).get('secretKeyRef', {}) + require(entry['name'] == binding['name'] or + (reference.get('name'), reference.get('key')) != (target_ref['name'], target_ref['key']), + 'delivery_secret_already_bound') + existing = [e for e in proposed_env if e['name'] == binding['name']] + require(not existing or existing == [binding], 'existing_binding_requires_reconciliation') + if not existing: + proposed_env.append(binding) + added_env.append(binding['name']) + return {'schema': 'netkingdom.playwright-activation.v1', 'source_revision': source_revision, + 'secret_identity': secret_meta, 'deployment_identity': deploy_meta, + 'before_data': copy.deepcopy(secret['data']), 'after_data': proposed_data, + 'before_spec': copy.deepcopy(original), 'after_spec': proposed_spec, + 'identity_inventory': copy.deepcopy(identities), 'created_client_ids': created, + 'created_env_names': added_env} + + +def summary(proposal): + changed = proposal['before_data'] != proposal['after_data'] or proposal['before_spec'] != proposal['after_spec'] + return {'schema': proposal['schema'], 'proposal_digest': digest(proposal), + 'source_revision': proposal['source_revision'], + 'created_client_ids': proposal['created_client_ids'], + 'created_env_names': proposal['created_env_names'], + 'change_needed': changed, 'maintenance_required': changed, + 'live_activated': False, 'custody_admitted': False} + + +def check_registry(proposal, binary, expected_binary_sha256): + """Run the exact admitted native verifier, no ambient env or raw diagnostics.""" + path = Path(binary) + require(path.is_file() and not path.is_symlink() and + hashlib.sha256(path.read_bytes()).hexdigest() == expected_binary_sha256, + 'native_verifier_digest_mismatch') + for stage in ('before', 'after'): + env_names = [e['name'] for e in container(proposal[stage + '_spec']).get('env', [])] + command = [str(path), 'check-config', '--config', '/dev/stdin', '--env-names', + ','.join(env_names) or '__NO_ENV_NAMES__'] + if 'KEYCAPE_POLICY_PATH' in env_names: + command.append('--policy-store') + try: + result = subprocess.run(command, input=base64.b64decode(proposal[stage + '_data']['config.yaml'], validate=True), + capture_output=True, timeout=60, env={'PATH': '/usr/bin:/bin'}) + report = json.loads(result.stdout) + valid = result.returncode == 0 and report.get('valid') is True and not report.get('errors') + except Exception: + valid = False + require(valid, 'native_registry_check_failed') + return {'original_registry_valid': True, 'proposed_registry_valid': True, + 'verifier_sha256': expected_binary_sha256} + + +def quiescent(deployment, pods): + """Caller supplies an unfiltered complete sso PodList from the same cluster.""" + status = deployment.get('status', {}) + require(status.get('observedGeneration') == deployment['metadata'].get('generation') + and deployment['spec'].get('replicas') == 0 and + all(status.get(k, 0) == 0 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']), + 'issuer_not_stopped') + require(pods.get('kind') == 'PodList' and not pods.get('metadata', {}).get('continue') + and isinstance(pods.get('items'), list), 'complete_namespace_pod_list_required') + # Terminating pods are still consumers. Do not infer quiescence from readiness. + for pod in pods['items']: + require(pod.get('metadata', {}).get('namespace') == 'sso', 'pod_inventory_namespace_mismatch') + pod_spec = pod.get('spec', {}) + uses_registry = any(v.get('secret', {}).get('secretName') == 'keycape-config' or + any(s.get('secret', {}).get('name') == 'keycape-config' + for s in v.get('projected', {}).get('sources', [])) + for v in pod_spec.get('volumes', [])) + for c in pod_spec.get('containers', []) + pod_spec.get('initContainers', []) + pod_spec.get('ephemeralContainers', []): + uses_registry = uses_registry or any(e.get('secretRef', {}).get('name') == 'keycape-config' + for e in c.get('envFrom', [])) or any( + e.get('valueFrom', {}).get('secretKeyRef', {}).get('name') == 'keycape-config' + for e in c.get('env', [])) + require(not uses_registry, 'registry_consumer_still_present') + + +def propose_step(proposal, approved_digest, phase, secret, deployment, pods): + """Return one private CAS patch or None for an exact no-op; never execute it. + + The caller must freshly read BOTH objects before EACH phase, verify every + response, and stop on unknown outcomes. The digest binds content, not authority. + Kubernetes has no cross-object transaction; exclusive maintenance is required. + """ + require(digest(proposal) == approved_digest, 'proposal_revision_changed') + secret_meta = target(secret, 'Secret', 'keycape-config') + deploy_meta = target(deployment, 'Deployment', 'keycape') + require(secret_meta['uid'] == proposal['secret_identity']['uid'] and + deploy_meta['uid'] == proposal['deployment_identity']['uid'], 'target_identity_changed') + old_data, new_data = proposal['before_data'], proposal['after_data'] + old_spec, new_spec = proposal['before_spec'], proposal['after_spec'] + data, current = secret['data'], deployment['spec'] + require(data == old_data or data == new_data, 'registry_drift_stop_without_stale_replay') + require(any(current == item for item in [old_spec, new_spec, stopped(old_spec), stopped(new_spec)]), + 'deployment_drift_stop_without_stale_replay') + require(phase in ('stop', 'registry', 'bindings', 'resume', 'rollback-stop', + 'rollback-registry', 'rollback-bindings', 'rollback-resume'), 'phase_invalid') + if phase.startswith('rollback-') and data == old_data and current == old_spec: + return None + if old_data == new_data and old_spec == new_spec: + require(data == old_data and current == old_spec, 'noop_state_changed') + return None + if phase == 'stop': + require(secret_meta == proposal['secret_identity'] and deploy_meta == proposal['deployment_identity'] + and data == old_data and current == old_spec, 'initial_revision_changed') + status = deployment.get('status', {}) + require(status.get('observedGeneration') == deployment['metadata'].get('generation') and + all(status.get(k) == 1 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']), + 'initial_issuer_not_ready') + obj, field, wanted = deployment, 'spec', stopped(old_spec) + elif phase == 'rollback-stop': + obj, field, wanted = deployment, 'spec', stopped(current) + else: + quiescent(deployment, pods) + if phase == 'registry': + require(current == stopped(old_spec) and data == old_data, 'registry_phase_out_of_order') + obj, field, wanted = secret, 'data', new_data + elif phase == 'bindings': + require(current == stopped(old_spec) and data == new_data, 'bindings_phase_out_of_order') + obj, field, wanted = deployment, 'spec', stopped(new_spec) + elif phase == 'resume': + require(current == stopped(new_spec) and data == new_data, 'resume_phase_out_of_order') + obj, field, wanted = deployment, 'spec', new_spec + elif phase == 'rollback-registry': + obj, field, wanted = secret, 'data', old_data + elif phase == 'rollback-bindings': + require(data == old_data, 'restore_registry_before_bindings') + obj, field, wanted = deployment, 'spec', stopped(old_spec) + else: + require(data == old_data and current == stopped(old_spec), 'rollback_resume_out_of_order') + obj, field, wanted = deployment, 'spec', old_spec + if obj[field] == wanted: + return None + metadata = registration.registry.pin.safe_metadata(obj) + patch = [{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'test', 'path': '/' + field, 'value': copy.deepcopy(obj[field])}, + {'op': 'replace', 'path': '/' + field, 'value': copy.deepcopy(wanted)}] + return {'kind': obj['kind'], 'namespace': 'sso', 'name': obj['metadata']['name'], + 'uid': metadata['uid'], 'field': field, 'expected': copy.deepcopy(wanted), + 'proposal_digest': approved_digest, 'phase': phase, 'patch': patch} + + +def verify_response(operation, obj): + """Also use on a fresh GET after an acknowledged patch; failures stop the caller.""" + metadata = target(obj, operation['kind'], operation['name']) + require(metadata['uid'] == operation['uid'] and obj.get(operation['field']) == operation['expected'], + 'mutation_readback_mismatch') + return {'phase': operation['phase'], 'proposal_digest': operation['proposal_digest'], + 'uid': metadata['uid'], 'resource_version': metadata['resource_version'], 'readback_matches': True} diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-registration.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-registration.py new file mode 100644 index 00000000..0f78edd0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/playwright-registration.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Prepare Playwright client records; no credential access or live mutation. + +The registry adapter below is for an owner-controlled activation ceremony. Its +encoded result contains the original private registry and must stay in memory. +The CLI emits only newly constructed non-secret client/binding proposals. +""" +from __future__ import annotations + +import argparse +import copy +from decimal import Decimal +import importlib.util +import json +from pathlib import Path +import re +import sys + +spec = importlib.util.spec_from_file_location('playwright_registry', Path(__file__).with_name('keycape-client.py')) +registry = importlib.util.module_from_spec(spec) +spec.loader.exec_module(registry) +require = registry.require +Refused = registry.Refused +AUDIENCE = 'playwright-kubernetes' +SCOPE = 'playwright:connect' +CALLBACK = 'http://127.0.0.1:18764/callback' +ISSUER = 'https://kc.coulomb.social' +SLUG = re.compile(r'[a-z0-9][a-z0-9-]{0,39}\Z') +IDENTIFIER = re.compile(r'[a-z0-9][a-z0-9:._-]{0,159}\Z') + + +def public_client(): + return {'clientId': 'playwright-cli', 'audience': AUDIENCE, + 'displayName': 'Playwright platform consumer CLI', + 'redirectUris': [CALLBACK], + 'allowedScopes': ['openid', 'profile', 'groups', SCOPE], + 'grantTypes': ['authorization_code'], 'clientType': 'public'} + + +def agent_client(identity): + """Reference an existing registered identity; never manufacture its subject/tenant.""" + require(isinstance(identity, dict) and set(identity) == + {'agent_id', 'service_subject', 'tenant', 'identity_record'}, 'agent_identity_fields_required') + require(all(isinstance(v, str) for v in identity.values()), 'agent_identity_strings_required') + require(SLUG.fullmatch(identity['agent_id']) is not None, 'agent_id_invalid') + require(identity['service_subject'].startswith('service:') and + IDENTIFIER.fullmatch(identity['service_subject']) is not None and + len(identity['service_subject']) > len('service:'), 'registered_service_subject_required') + require(identity['tenant'].startswith('tenant:') and + IDENTIFIER.fullmatch(identity['tenant']) is not None and + len(identity['tenant']) > len('tenant:'), 'registered_tenant_required') + # This is a reference for owner review, not proof that registration exists. + reference = identity['identity_record'] + require(1 <= len(reference) <= 512 and all(32 < ord(c) < 127 for c in reference), + 'identity_record_reference_required') + cid = 'playwright-agent-' + identity['agent_id'] + return {'clientId': cid, 'audience': AUDIENCE, + 'displayName': 'Playwright agent ' + identity['agent_id'], + 'allowedScopes': [SCOPE], 'grantTypes': ['client_credentials'], + 'clientType': 'confidential', 'secretRef': 'env:' + registry.env_name(cid), + 'serviceSubject': identity['service_subject'], 'tenant': identity['tenant'], + 'roles': ['coding-agent'], 'tokenLifetime': '15m'} + + +def clients(identities): + require(isinstance(identities, list) and len(identities) <= 100, 'agent_inventory_invalid') + result = [public_client()] + [agent_client(identity) for identity in identities] + for field in ['clientId', 'serviceSubject', 'secretRef']: + values = [c[field] for c in result if field in c] + require(len(values) == len(set(values)), 'duplicate_agent_identity_or_binding') + return result + + +def human_lifetime(config): + """Bound the existing issuer setting; do not alter all humans' token lifetime.""" + raw = config.get('tokenLifetime') + require(isinstance(raw, str) and 0 < len(raw) <= 128, 'explicit_human_token_lifetime_required') + units = {'h': Decimal(3600), 'm': Decimal(60), 's': Decimal(1), + 'ms': Decimal('.001'), 'us': Decimal('.000001'), + 'µs': Decimal('.000001'), 'μs': Decimal('.000001'), 'ns': Decimal('.000000001')} + parts = re.findall(r'([0-9]+(?:\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h)', raw) + require(parts and ''.join(value + unit for value, unit in parts) == raw, + 'human_token_lifetime_invalid') + seconds = sum((Decimal(value) * units[unit] for value, unit in parts), Decimal(0)) + require(1 <= seconds <= 3600, 'human_token_lifetime_outside_cluster_bound') + return seconds + + +def prepare_registry(secret, identities, operation='add'): + """Byte-preserving proposal only. Caller owns CAS, check-config and readback. + + Refuse drift before constructing any result. A missing client during removal + is an idempotent no-op. Removal does not revoke issued tokens/connections. + """ + require(operation in ('add', 'remove'), 'operation_invalid') + wanted = clients(identities) + _, config, _ = registry.pin.issuer_document(secret) + require(config.get('issuer') == ISSUER, 'keycape_issuer_mismatch') + human_lifetime(config) + require(isinstance(config.get('clients'), list), 'client_sequence_required') + for existing in config['clients']: + require(isinstance(existing, dict), 'client_shape_unexpected') + if (existing.get('audience') or existing.get('clientId')) == AUDIENCE: + require(existing in wanted, 'unreviewed_playwright_audience_registration') + # Preflight every record against the same original, including collisions. + for client in wanted: + _, current, _ = registry._inspect(secret, client) + if 'serviceSubject' in client: + for other in current['clients']: + if other['clientId'] != client['clientId'] and (other.get('audience') or other.get('clientId')) == AUDIENCE: + require(other.get('serviceSubject') != client['serviceSubject'], + 'playwright_subject_already_registered') + matching = [c for c in current['clients'] if c['clientId'] == client['clientId']] + require(not matching or matching == [client], 'existing_registration_differs_requires_reconciliation') + candidate = copy.deepcopy(secret) + changed_ids = [] + merge = registry.replacement if operation == 'add' else registry.removal + for client in wanted: + encoded, changed = merge(candidate, client) + candidate['data']['config.yaml'] = encoded + if changed: + changed_ids.append(client['clientId']) + return candidate['data']['config.yaml'], changed_ids + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate_input_field') + result[key] = value + return result + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('--agents', help='Non-secret JSON array of existing agent identity references') + args = parser.parse_args(argv) + try: + identities = [] + if args.agents: + with Path(args.agents).open('rb') as stream: + raw = stream.read(65537) + require(len(raw) <= 65536, 'agent_inventory_too_large') + identities = json.loads(raw, object_pairs_hook=unique_object) + records = clients(identities) + print(json.dumps({'schema': 'netkingdom.playwright-registration.v1', + 'status': 'source_proposal', 'clients': records, + 'identity_references': {c['clientId']: identity['identity_record'] + for c, identity in zip(records[1:], identities)}, + 'deployment_env': [registry.env_binding(c) for c in records if 'secretRef' in c], + 'identity_evidence_verified': False, + 'custody_admitted': False, 'live_activated': False}, indent=2)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + print(json.dumps({'status': 'refused', 'reason': 'invalid_registration_input'})) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/portal-client-rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/portal-client-rollout.py new file mode 100644 index 00000000..da4ee2d2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/portal-client-rollout.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Restore the approved public portal client without exporting Secret values.""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +from pathlib import Path +import subprocess + +import yaml + +ROOT = Path(__file__).resolve().parent + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +pin = module('issuer_pin', 'openbao-client-config.py') +portal = module('portal_registration', 'register-user-engine-portal.py') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' + + +class Refused(Exception): + """Only fixed reason codes may leave the operation.""" + + +def require(condition, reason): + if not condition: + raise Refused(reason) + + +def replacement(secret): + raw, config, _ = pin.issuer_document(secret) + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + ids = [client['clientId'] for client in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + if portal.CLIENT_ID in ids: + current = clients[ids.index(portal.CLIENT_ID)] + if current == portal.CLIENT: + return secret['data']['config.yaml'], False + legacy = dict(portal.CLIENT, redirectUris=[ + 'https://users.92-205-62-239.nip.io/oidc/callback']) + require(current == legacy, 'existing_registration_differs_requires_reconciliation') + # Only the exact reviewed legacy public client may gain this callback. + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + item = node.value[ids.index(portal.CLIENT_ID)] + redirects = next(value for key, value in item.value if key.value == 'redirectUris') + require(isinstance(redirects, yaml.nodes.SequenceNode) and not redirects.flow_style, + 'block_redirect_sequence_required') + index = redirects.end_mark.index + line_start = raw.rfind('\n', 0, index) + 1 + if not raw[line_start:index].strip(): + index = line_start + addition = ' ' * redirects.start_mark.column + '- https://users.coulomb.social/oidc/callback\n' + if index and raw[index - 1] != '\n': + addition = '\n' + addition + updated = raw[:index] + addition + raw[index:] + expected = copy.deepcopy(config) + expected['clients'][ids.index(portal.CLIENT_ID)] = portal.CLIENT + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, + 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + index = node.end_mark.index + line_start = raw.rfind('\n', 0, index) + 1 + if not raw[line_start:index].strip(): + index = line_start + indent = ' ' * node.start_mark.column + addition = yaml.safe_dump([portal.CLIENT], sort_keys=False) + addition = ''.join(indent + line if line.strip() else line + for line in addition.splitlines(True)) + if index and raw[index - 1] != '\n': + addition = '\n' + addition + updated = raw[:index] + addition + raw[index:] + expected = copy.deepcopy(config) + expected['clients'].append(portal.CLIENT) + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, + 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def kubectl(arguments, payload=None): + args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', + '--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments] + result = subprocess.run(args, input=None if payload is None else json.dumps(payload), + text=True, capture_output=True, timeout=30) + require(result.returncode == 0, 'kubernetes_operation_failed') + return json.loads(result.stdout) + + +def run(args): + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = replacement(before) + receipt = {'target': 'sso/keycape-config', 'before': metadata, + 'client': portal.CLIENT, 'change_needed': changed, + 'mode': args.mode, 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + require(metadata == {'uid': args.expected_uid, + 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] and + after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after)) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + args = parser.parse_args() + try: + print(json.dumps(run(args), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/refresh-pi-token-live.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/refresh-pi-token-live.sh new file mode 100644 index 00000000..d72871b8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/refresh-pi-token-live.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Retired: KeyCape uses the scoped, renewable factor-reader lane. +set +x +set -euo pipefail +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/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.py new file mode 100755 index 00000000..624fac50 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Idempotently register coulomb.social in the live KeyCape Secret. + +The complete Secret travels over stdin/stdout between kubectl and this +process. Secret values are never printed to the terminal or written to disk. +""" + +from __future__ import annotations + +import base64 +import json +import sys + +import yaml + +CLIENT_ID = "coulomb-social" +CLIENT = { + "clientId": CLIENT_ID, + "displayName": "coulomb.social", + "redirectUris": [ + "http://127.0.0.1:8008/auth/callback/", + "http://localhost:8008/auth/callback/", + # Parallel public host while apex coulomb.social remains Bubble + "https://app.coulomb.social/auth/callback/", + # Future apex cutover (keep registered; not the live app host yet) + "https://coulomb.social/auth/callback/", + ], + "allowedScopes": ["openid", "profile", "email", "groups"], + "grantTypes": ["authorization_code"], + "clientType": "public", + # Ordinary coulomb.social sessions are AAL1 unless the client explicitly + # requests AAL2 through acr_values. Platform/admin clients keep their + # existing provider-driven mandatory MFA behavior. + "mfaRequired": False, +} + + +def main() -> None: + print("Retired: use the owner-reviewed KeyCape client registration adapter; raw registry output is disabled.", file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.sh new file mode 100755 index 00000000..d007f388 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-coulomb-social.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Legacy unguarded registry mutation withdrawn under NK-WP-0049. +set +x +printf '%s\n' 'Retired: use the reviewed KeyCape client registration/rollout adapter with exact source, object/version checks, contained diagnostics and owner authorization. No registry or credential is read or changed here.' >&2 +exit 2 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-user-engine-portal.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-user-engine-portal.py new file mode 100644 index 00000000..3fe6eab0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/register-user-engine-portal.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Idempotently register the user-engine portal in the live KeyCape Secret. + +The complete Secret travels over stdin/stdout between kubectl and this +process. Secret values are never printed to the terminal or written to disk. +""" + +from __future__ import annotations + +import base64 +import json +import sys + +import yaml + +CLIENT_ID = "user-engine-portal" +CLIENT = { + "clientId": CLIENT_ID, + "displayName": "User Engine Portal", + "redirectUris": ["https://users.92-205-62-239.nip.io/oidc/callback", + "https://users.coulomb.social/oidc/callback"], + "allowedScopes": ["openid", "profile", "email", "groups"], + "grantTypes": ["authorization_code"], + "clientType": "public", +} + + +def main() -> None: + secret = json.load(sys.stdin) + encoded = secret.get("data", {}).get("config.yaml") + if not encoded: + raise SystemExit("keycape-config does not contain config.yaml") + config = yaml.safe_load(base64.b64decode(encoded)) + clients = config.setdefault("clients", []) + existing = next( + (index for index, client in enumerate(clients) if client.get("clientId") == CLIENT_ID), + None, + ) + if existing is None: + clients.append(CLIENT) + else: + clients[existing] = CLIENT + rendered = yaml.safe_dump(config, sort_keys=False).encode() + secret["data"]["config.yaml"] = base64.b64encode(rendered).decode() + secret.pop("status", None) + metadata = secret.get("metadata", {}) + for key in ("creationTimestamp", "managedFields", "resourceVersion", "uid"): + metadata.pop(key, None) + json.dump(secret, sys.stdout, separators=(",", ":")) + + +if __name__ == "__main__": + main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py new file mode 100644 index 00000000..2e9ea5c5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Owner-specific KeyCape registration of the secrets-engine-openbao service client. + +KEY-WP-0037-T03 / NK-WP-0046. Source preparation only: the live operation +needs the founder's separate go (State Hub decision +caafe4be-4f68-47b4-9567-316d47b94992) and the platform-materialised Secret +keycape-secrets-engine-openbao-client (key client-secret). This helper merges +only the client registration into sso/keycape-config; the single Deployment +env binding is the separate proposed patch +secrets-engine-openbao-client.proposed.patch.yaml. + +Modes default to non-mutating. `inspect` reads and plans; `dry-run` sends a +server-side dry-run patch; `apply` writes and is reserved for the attended +operator under the live go. `--operation remove` is the rollback: it removes +only this exact registration and nothing else. + +Exact-match merge: absent -> add only this client; identical -> noop; +duplicates, drift or a shared secretRef -> fail closed. The signing key, +unrelated clients, policy and every other config byte are preserved. No Secret +value, parser message or Kubernetes error text leaves the process. +""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +# Reuse the owner lane's contained kubectl, refusal type and cluster pin. +lane = module('portal_rollout', 'portal-client-rollout.py') +pin = lane.pin +Refused = lane.Refused +require = lane.require + +LIVE_GO_DECISION = 'caafe4be-4f68-47b4-9567-316d47b94992' +CLIENT_ID = 'secrets-engine-openbao' +ENV_NAME = 'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET' +SECRET_NAME = 'keycape-secrets-engine-openbao-client' +SECRET_KEY = 'client-secret' +# Verbatim from key-cape config/service-clients.example.yaml (reviewed at +# KeyCape e22029e). No `audience` key: the audience defaults to the clientId. +CLIENT = { + 'clientId': CLIENT_ID, + 'displayName': 'secrets-engine OpenBao login', + 'allowedScopes': ['openbao:login'], + 'grantTypes': ['client_credentials'], + 'clientType': 'confidential', + 'secretRef': 'env:' + ENV_NAME, + 'serviceSubject': 'service:secrets-engine', + 'tenant': 'tenant:platform', + 'roles': ['secrets-engine'], + 'tokenLifetime': '15m', +} +ENV_BINDING = {'name': ENV_NAME, 'valueFrom': { + 'secretKeyRef': {'name': SECRET_NAME, 'key': SECRET_KEY}}} + + +def _client_node(raw): + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + return node + + +def _line_start(raw, index): + start = raw.rfind('\n', 0, index) + 1 + return start if not raw[start:index].strip() else index + + +def _inspect(secret): + raw, config, _ = pin.issuer_document(secret) + require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, + 'verified_issuer_pin_required') + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected') + ids = [c['clientId'] for c in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + others = [c for c in clients if c['clientId'] != CLIENT_ID] + require(all(c.get('secretRef') != CLIENT['secretRef'] for c in others), 'secret_ref_already_bound') + return raw, config, ids + + +def replacement(secret): + """Return (base64 config, changed) with only this client added.""" + raw, config, ids = _inspect(secret) + if CLIENT_ID in ids: + require(config['clients'][ids.index(CLIENT_ID)] == CLIENT, + 'existing_registration_differs_requires_reconciliation') + return secret['data']['config.yaml'], False + node = _client_node(raw) + index = _line_start(raw, node.end_mark.index) + indent = ' ' * node.start_mark.column + fragment = yaml.safe_dump([CLIENT], sort_keys=False, width=120) + fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True)) + if index and raw[index - 1] != '\n': + fragment = '\n' + fragment + updated = raw[:index] + fragment + raw[index:] + expected = copy.deepcopy(config) + expected['clients'].append(CLIENT) + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def removal(secret): + """Rollback: return (base64 config, changed) with only this exact client removed.""" + raw, config, ids = _inspect(secret) + if CLIENT_ID not in ids: + return secret['data']['config.yaml'], False + position = ids.index(CLIENT_ID) + require(config['clients'][position] == CLIENT, 'existing_registration_differs_requires_reconciliation') + require(len(ids) > 1, 'removal_would_empty_client_sequence') + node = _client_node(raw) + item = node.value[position] + start = raw.rfind('\n', 0, item.start_mark.index) + 1 + require(raw[start:item.start_mark.index].strip() == '-', 'unexpected_item_layout') + if position + 1 < len(node.value): + following = node.value[position + 1].start_mark.index + end = raw.rfind('\n', 0, following) + 1 + else: + end = _line_start(raw, node.end_mark.index) + updated = raw[:start] + raw[end:] + expected = copy.deepcopy(config) + del expected['clients'][position] + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def deployment_binding(deployment): + """Report whether the live Deployment already carries exactly the one binding.""" + containers = deployment['spec']['template']['spec']['containers'] + container = next(c for c in containers if c['name'] == 'keycape') + matches = [e for e in container.get('env', []) if e.get('name') == ENV_NAME] + require(len(matches) <= 1 and all(e == ENV_BINDING for e in matches), 'env_binding_drift') + return bool(matches) + + +def run(args): + kubectl = lane.kubectl + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == lane.CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = (replacement if args.operation == 'add' else removal)(before) + deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json']) + bound = deployment_binding(deployment) + receipt = {'schema': 'netkingdom.keycape-secrets-engine-openbao-client.v1', + 'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation, + 'client_id': CLIENT_ID, 'change_needed': changed, 'mode': args.mode, + 'deployment_env_bound': bound, 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + if args.mode == 'apply': + # Attended only: the founder's live go must be named and the plan revision pinned. + require(args.live_go_decision == LIVE_GO_DECISION, 'founder_live_go_required') + if args.operation == 'add': + # Startup resolves secretRef; a registration without its env binding + # would make the next pod start fail closed. + require(bound, 'deployment_env_binding_required_first') + require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] + and after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after), restart_required=changed) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('mode', nargs='?', default='inspect', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--operation', default='add', choices=['add', 'remove']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + parser.add_argument('--live-go-decision', help='required for apply: ' + LIVE_GO_DECISION) + args = parser.parse_args(argv) + try: + print(json.dumps(run(args), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except pin.IssuerPinError as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_approval_clients_rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_approval_clients_rollout.py new file mode 100644 index 00000000..204c6c66 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_approval_clients_rollout.py @@ -0,0 +1,141 @@ +import base64 +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import yaml + +PATH = Path(__file__).with_name('approval-clients-rollout.py') +spec = importlib.util.spec_from_file_location('rollout', PATH) +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) + + +def fixture(raw=None): + raw = raw or 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-fixture\nclients:\n - clientId: existing-human\n allowedScopes: [openid]\n# trailing owner comment\nother: preserved\n' + return {'kind': 'Secret', 'metadata': {'name': 'keycape-config', 'namespace': 'sso', + 'uid': '2e94519d-1550-41c7-9701-2efe47fe1fd3', 'resourceVersion': '100'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'c3ludGhldGlj'}} + + +def deployment(): + source = next(yaml.safe_load_all((PATH.parent / 'deployment.yaml').read_text())) + source['metadata'].update(uid='99ddd83c-cb3f-4847-bcf8-35f1aa87627f', resourceVersion='101') + # The helper is a one-shot rollout from PRIOR_IMAGE; deployment.yaml has + # since advanced past it, so reduce the fixture to the pre-rollout state: + # the prior image and none of the approval-client env the helper adds. + container = next(c for c in source['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + container['image'] = m.PRIOR_IMAGE + added = {x['name'] for x in yaml.safe_load( + (m.KEYCAPE / 'docs/approval-clients-deployment.patch.yaml').read_text() + )['spec']['template']['spec']['containers'][0]['env']} + container['env'] = [x for x in container.get('env', []) if x['name'] not in added] + return source + + +class RolloutTests(unittest.TestCase): + def test_append_preserves_original_bytes_and_signing_key(self): + secret = fixture(); before = copy.deepcopy(secret) + actual = base64.b64decode(m.replacement(secret, m.registrations())).decode() + original = base64.b64decode(secret['data']['config.yaml']).decode() + insertion_start = original.index('other:') + self.assertTrue(actual.startswith(original[:insertion_start])) + self.assertTrue(actual.endswith(original[insertion_start:])) + self.assertEqual(secret, before) + self.assertEqual(len(yaml.safe_load(actual)['clients']), 3) + + def test_indentless_and_final_client_sequence(self): + for text in ['authelia:\n issuer: https://auth.coulomb.social\nclients:\n- clientId: human\nother: keep\n', + 'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: human']: + parsed = yaml.safe_load(base64.b64decode(m.replacement(fixture(text), m.registrations()))) + self.assertEqual(len(parsed['clients']), 3) + + def test_reject_duplicate_existing_registration(self): + text = 'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: secrets-engine-approval\n' + with self.assertRaisesRegex(m.LaneError, 'existing_client_registration'): + m.replacement(fixture(text), m.registrations()) + + def test_reject_ambiguous_yaml_and_wrong_issuer(self): + for raw in ['authelia: {}\nauthelia: {}\nclients: []', + 'authelia:\n issuer: https://wrong.invalid\nclients:\n - clientId: human\n']: + with self.assertRaises((m.LaneError, m.pin.IssuerPinError)): + m.replacement(fixture(raw), m.registrations()) + + def test_candidate_preserves_existing_settings(self): + old = deployment(); result = m.candidate_spec(old) + before = old['spec']['template']['spec']['containers'][0] + after = result['template']['spec']['containers'][0] + self.assertEqual(result['strategy'], {'type': 'Recreate'}) + self.assertEqual(after['env'][:len(before['env'])], before['env']) + for field in ['livenessProbe', 'resources', 'volumeMounts', 'startupProbe']: + self.assertEqual(after[field], before[field]) + self.assertEqual(after['image'], m.IMAGE) + + def test_candidate_refuses_current_declared_image(self): + current = next(yaml.safe_load_all((PATH.parent / 'deployment.yaml').read_text())) + with self.assertRaisesRegex(m.LaneError, 'prior_image_drift'): + m.candidate_spec(current) + + def test_cas_patch_contains_both_preconditions_and_stdin_only(self): + with patch.object(m, 'command') as command: + command.return_value.stdout = b'{}' + m.patch_object(['kubectl'], 'secret', fixture(), '/data/config.yaml', 'synthetic-payload') + args, kwargs = command.call_args + self.assertNotIn('synthetic-payload', args[0]) + self.assertEqual([p['path'] for p in kwargs['payload'][:2]], ['/metadata/uid', '/metadata/resourceVersion']) + + def test_failed_acceptance_restores_compatible_pair(self): + state = {'secret': fixture(), 'deployment': deployment()} + original = copy.deepcopy(state) + def fake_patch(kube, kind, obj, path, value, dry=False): + changed = copy.deepcopy(obj) + if path == '/spec': changed['spec'] = value + else: changed['data']['config.yaml'] = value + if not dry: + changed['metadata']['resourceVersion'] = str(int(changed['metadata']['resourceVersion']) + 1) + state[kind] = changed + return copy.deepcopy(changed) + with tempfile.TemporaryDirectory() as directory, patch.object(m, 'assert_cluster'), patch.object(m, 'verify_artifact'), \ + patch.object(m, 'get', side_effect=lambda kube, kind, name: copy.deepcopy(state[kind])), \ + patch.object(m, 'patch_object', side_effect=fake_patch), patch.object(m, 'ready', return_value={}), \ + patch.object(m, 'acceptance', side_effect=m.LaneError('synthetic_acceptance_failure')): + receipt = {} + with self.assertRaisesRegex(m.LaneError, 'synthetic_acceptance_failure'): + m.rollout([], receipt, Path(directory) / 'recovery.json') + self.assertTrue(receipt['compatible_pair_restored']) + self.assertEqual(state['secret']['data'], original['secret']['data']) + self.assertEqual(state['deployment']['spec'], original['deployment']['spec']) + self.assertEqual((Path(directory) / 'recovery.json').stat().st_mode & 0o777, 0o600) + + def test_ready_matches_manifest_imageid_not_runtime_config_id(self): + dep = deployment(); dep['metadata']['generation'] = 30 + dep['status'] = {'observedGeneration': 30, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1, 'replicas': 1} + pod = {'metadata': {'uid': 'fixture-pod'}, 'status': {'containerStatuses': [ + {'name': 'keycape', 'ready': True, 'image': 'sha256:runtime-config-id', 'imageID': m.IMAGE}]}} + from types import SimpleNamespace + with patch.object(m, 'get', return_value=dep), patch.object(m, 'command', return_value=SimpleNamespace(stdout=json.dumps({'items': [pod]}).encode())): + self.assertTrue(m.ready([], m.IMAGE, timeout=1)['single_ready_replica']) + + def test_iat_skew_matches_native_contract_without_extending_expiry(self): + from cryptography.hazmat.primitives.asymmetric import rsa + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = int(m.time.time()) + claims = {'iss': m.ISSUER, 'aud': 'approval-engine', 'sub': 'synthetic-service', 'iat': now + 2, 'exp': now + 902} + encoded = m.jwt.encode(claims, key, algorithm='RS256') + self.assertEqual(m.verified_claims(encoded, key.public_key())['iat'], now + 2) + future = m.jwt.encode(dict(claims, iat=now+60), key, algorithm='RS256') + with self.assertRaisesRegex(m.LaneError, 'issued_at_binding_failed'): + m.verified_claims(future, key.public_key()) + expired = m.jwt.encode(dict(claims, iat=now-900, exp=now-1), key, algorithm='RS256') + with self.assertRaises(m.jwt.ExpiredSignatureError): + m.verified_claims(expired, key.public_key()) + + def test_unrelated_refusal_never_passes(self): + for status, body in [(500, {}), (400, {'error': 'invalid_profile_usage', 'feature': 'client_id'}), + (401, {'error': 'invalid_profile_usage', 'feature': 'scope'})]: + with self.assertRaises(m.LaneError): + m.denied((status, body), 400, 'scope') + +if __name__ == '__main__': unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_authentication_policy_rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_authentication_policy_rollout.py new file mode 100644 index 00000000..379de2e3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_authentication_policy_rollout.py @@ -0,0 +1,35 @@ +import base64,importlib.util,json,unittest +from pathlib import Path +import yaml +spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('authentication-policy-rollout.py')) +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) +class PolicyMigration(unittest.TestCase): + def secret(self): + raw='''# exact bytes outside reviewed fields must survive +issuer: https://fixture.test +authelia: + issuer: https://primary.test +clients: + - clientId: user-engine-portal + clientType: public + grantTypes: [authorization_code] + - clientId: untouched + mfaRequired: true + clientSecret: fixture-secret + - clientId: vergabe-demo-company + clientType: public + grantTypes: [authorization_code] +privacyidea: + requireForAll: true +''' + return {'data':{'config.yaml':base64.b64encode(raw.encode()).decode(),'key.pem':'fixture-key'}} + def test_exact_migration_preserves_siblings_and_retries(self): + secret=self.secret();value,changed=module.replacement(secret);self.assertTrue(changed) + raw=base64.b64decode(value).decode();self.assertIn('# exact bytes',raw);self.assertIn(' clientSecret: fixture-secret\n',raw) + config=yaml.safe_load(raw);self.assertTrue(config['privacyidea']['requireForAll']) + self.assertTrue(config['clients'][0]['mfaOptional']);self.assertTrue(config['clients'][2]['mfaOptional']) + secret['data']['config.yaml']=value;again,changed=module.replacement(secret);self.assertFalse(changed);self.assertEqual(value,again) + def test_explicit_or_duplicate_registration_refused(self): + for edit in [lambda s:s.replace(' clientType: public',' mfaRequired: false\n clientType: public',1),lambda s:s.replace('clientId: untouched','clientId: user-engine-portal')]: + secret=self.secret();secret['data']['config.yaml']=base64.b64encode(edit(base64.b64decode(secret['data']['config.yaml']).decode()).encode()).decode() + with self.assertRaises(module.Refused):module.replacement(secret) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_issuer_pin.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_issuer_pin.py new file mode 100644 index 00000000..b84b6523 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_issuer_pin.py @@ -0,0 +1,115 @@ +"""Issuer-only mutation, concurrency and output-containment regression checks.""" +import argparse +import base64 +import copy +import importlib.util +import io +import json +import subprocess +import unittest +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('owner_config', Path(__file__).with_name('openbao-client-config.py')) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +UID = '2e94519d-1550-41c7-9701-2efe47fe1fd3' +SENTINEL = 'DO-NOT-EMIT-fixture-private-value' +RAW = '# retained comment\nauthelia:\n baseURL: https://auth.coulomb.social\n clientSecret: '+SENTINEL+'\nclients:\n - clientId: existing\n secretRef: env:EXISTING\n' + + +def secret(raw=RAW): + return {'kind': 'Secret', 'metadata': {'name': 'keycape-config', 'namespace': 'sso', 'uid': UID, 'resourceVersion': '12'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': base64.b64encode(SENTINEL.encode()).decode()}} + + +class IssuerPinTest(unittest.TestCase): + def test_add_preserves_every_other_byte(self): + encoded, changed, prior = m.issuer_replacement(secret()) + updated = base64.b64decode(encoded).decode() + self.assertTrue(changed) + self.assertEqual(prior, 'absent') + self.assertEqual(updated.replace(' issuer: "https://auth.coulomb.social"\n', ''), RAW) + + def test_replace_retains_comments_and_other_fields(self): + raw = RAW.replace('authelia:\n', 'authelia:\n issuer: "http://old.example" # keep\n') + encoded, changed, prior = m.issuer_replacement(secret(raw)) + self.assertTrue(changed) + self.assertEqual(prior, 'different') + self.assertEqual(base64.b64decode(encoded).decode(), raw.replace('"http://old.example"', '"https://auth.coulomb.social"')) + + def test_matching_is_byte_identical_noop(self): + raw = RAW.replace('authelia:\n', 'authelia:\n issuer: https://auth.coulomb.social\n') + source = secret(raw) + self.assertEqual(m.issuer_replacement(source), (source['data']['config.yaml'], False, 'matching')) + + def test_ambiguous_documents_and_aliases_are_refused(self): + for raw in [RAW+'authelia: {}\n', RAW.replace(' baseURL:', ' issuer: a\n issuer: b\n baseURL:'), + 'authelia: {issuer: old}\n', RAW+'---\nauthelia: {}\n', RAW+'alias: &shared value\ncopy: *shared\n']: + with self.subTest(raw=raw), self.assertRaises(Exception): + m.issuer_replacement(secret(raw)) + + def test_check_never_patches_or_reports_old_value(self): + source = secret(RAW.replace('authelia:\n', 'authelia:\n issuer: '+SENTINEL+'\n')) + with patch.object(m, 'contained_kube', return_value=source) as kube: + result = m.issuer_live('issuer-check-live') + self.assertEqual(kube.call_count, 1) + self.assertFalse(result['issuer_matches']) + self.assertNotIn(SENTINEL, json.dumps(result)) + + def test_pin_checks_observed_revision_before_mutation(self): + with patch.object(m, 'contained_kube', return_value=secret()) as kube: + with self.assertRaisesRegex(m.IssuerPinError, 'observed_revision_changed'): + m.issuer_live('issuer-pin-live', UID, '11') + self.assertEqual(kube.call_count, 1) + + def test_pin_uses_cas_and_proves_other_secret_data_unchanged(self): + state = secret() + calls = [] + def kube(args, payload=None): + calls.append((args, payload)) + if args[0] == 'patch': + self.assertEqual(payload[:2], [{'op': 'test', 'path': '/metadata/uid', 'value': UID}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': '12'}]) + self.assertEqual(payload[2]['path'], '/data/config.yaml') + state['data']['config.yaml'] = payload[2]['value'] + state['metadata']['resourceVersion'] = '13' + return copy.deepcopy(state) + with patch.object(m, 'contained_kube', side_effect=kube): + result = m.issuer_live('issuer-pin-live', UID, '12') + self.assertEqual(len(calls), 3) + self.assertTrue(result['issuer_matches']) + self.assertTrue(result['other_secret_data_unchanged']) + self.assertNotIn(SENTINEL, json.dumps(result)) + + def test_readback_race_is_not_retried_or_overwritten(self): + after = secret() + after['data']['key.pem'] = 'changed-by-another-actor' + with patch.object(m, 'contained_kube', side_effect=[secret(), {}, after]) as kube: + with self.assertRaisesRegex(m.IssuerPinError, 'readback_mismatch'): + m.issuer_live('issuer-pin-live', UID, '12') + self.assertEqual(kube.call_count, 3) + + def test_subprocess_error_output_and_parser_errors_stay_contained(self): + args = argparse.Namespace(mode='issuer-check-live', expected_uid=None, expected_resource_version=None) + failures = [ValueError(SENTINEL), subprocess.TimeoutExpired(['kubectl'], 30, output=SENTINEL)] + for failure in failures: + output = io.StringIO() + with patch.object(m, 'contained_kube', side_effect=failure), redirect_stdout(output), redirect_stderr(output): + self.assertEqual(m.issuer_main(args), 1) + self.assertNotIn(SENTINEL, output.getvalue()) + self.assertEqual(json.loads(output.getvalue())['reason'], 'contained_operation_failed') + + def test_patch_payload_only_goes_to_captured_child_stdin(self): + with patch.object(m.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, SENTINEL, SENTINEL)) as run: + with self.assertRaisesRegex(m.IssuerPinError, '^kubernetes_operation_failed$'): + m.contained_kube(['patch', 'secret', 'keycape-config'], {'data': SENTINEL}) + args, kwargs = run.call_args + self.assertNotIn(SENTINEL, json.dumps(args)) + self.assertIn(SENTINEL, kwargs['input']) + self.assertTrue(kwargs['capture_output']) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_environment.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_environment.py new file mode 100644 index 00000000..6e738100 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_environment.py @@ -0,0 +1,66 @@ +"""Opt-in real-checker boundary regressions; no production inputs or checker stub. + +Set KEYCAPE_TEST_CHECKER_BINARY and KEYCAPE_TEST_CHECKER_SHA256 to an independently +built artifact. Ordinary helper-only runs skip this class when none is supplied. +""" +from contextlib import chdir +import hashlib +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from test_keycape_client import kc + +VALID = '''issuer: https://issuer.example.invalid +port: 8080 +privateKeyPem: /nonexistent/synthetic-key.pem +clients: + - clientId: synthetic-client + displayName: Synthetic client + redirectUris: [https://client.example.invalid/cb] + allowedScopes: [openid] + grantTypes: [authorization_code] + clientType: public +''' + + +class RealCheckerEnvironmentTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + source = os.environ.get('KEYCAPE_TEST_CHECKER_BINARY') + if not source: + raise unittest.SkipTest('exact real checker artifact not supplied') + data = Path(source).read_bytes() + expected = os.environ.get('KEYCAPE_TEST_CHECKER_SHA256') + if expected != 'sha256:' + hashlib.sha256(data).hexdigest(): + raise ValueError('real checker artifact digest differs') + cls.directory = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.directory.cleanup) + cls.binary = Path(cls.directory.name) / 'keycape' + cls.binary.write_bytes(data) + cls.binary.chmod(0o700) + + def check(self, raw, binary=None): + return kc.check_config(str(binary or self.binary), raw, [], False) + + def test_ambient_overrides_cannot_reject_supplied_valid_config(self): + with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'http://polluted.example.invalid', + 'KEYCAPE_BROWSER_LOGOUT_URL': 'http://polluted.example.invalid/logout'}): + self.assertTrue(self.check(VALID)['valid']) + + def test_ambient_overrides_cannot_admit_supplied_invalid_config(self): + invalid = VALID + 'accountPortalURL: http://invalid.example.invalid\nbrowserLogoutURL: http://invalid.example.invalid/logout\n' + with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'https://polluted.example.invalid', + 'KEYCAPE_BROWSER_LOGOUT_URL': 'https://polluted.example.invalid/logout'}): + self.assertFalse(self.check(invalid)['valid']) + + def test_relative_selected_artifact_is_not_replaced_by_path_decoy(self): + decoy = Path(self.directory.name) / 'decoy' + decoy.mkdir() + trap = decoy / 'keycape' + trap.write_text('#!/bin/sh\nexit 27\n') + trap.chmod(0o700) + with chdir(self.directory.name), patch.dict(os.environ, {'PATH': str(decoy)}): + self.assertTrue(self.check(VALID, binary=Path('keycape'))['valid']) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_snapshot.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_snapshot.py new file mode 100644 index 00000000..2dbb3945 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_checker_snapshot.py @@ -0,0 +1,122 @@ +"""Linux executable-byte custody fixtures; no credentials or native authority.""" +import fcntl +import hashlib +import mmap +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from test_keycape_registration_adapter import a +from test_keycape_checker_environment import VALID + + +class SnapshotTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.source = Path(self.temp.name) / 'checker' + self.source.write_bytes(b'synthetic artifact') + self.expected = a.digest(self.source.read_bytes()) + + def test_snapshot_bytes_sealed_and_descriptor_closed_on_exit(self): + with a.checker_snapshot(self.source, self.expected) as fd: + self.assertEqual(Path(f'/proc/self/fd/{fd}').read_bytes(), self.source.read_bytes()) + for action in (lambda: os.write(fd, b'x'), lambda: os.ftruncate(fd, 0), + lambda: os.ftruncate(fd, 128)): + with self.assertRaises(OSError): + action() + # Even if this process restores write permission, seals forbid writes. + os.fchmod(fd, 0o700) + reopened = os.open(f'/proc/self/fd/{fd}', os.O_RDWR) + try: + with self.assertRaises(OSError): + os.write(reopened, b'x') + with self.assertRaises(OSError): + mmap.mmap(reopened, len(b'synthetic artifact'), access=mmap.ACCESS_WRITE) + finally: + os.close(reopened) + with self.assertRaises(OSError): + os.fstat(fd) + + def test_hash_mismatch_refused_before_memfd(self): + self.source.write_bytes(b'changed') + with patch.object(a.os, 'memfd_create') as create: + with self.assertRaises(a.Refused): + with a.checker_snapshot(self.source, self.expected): + self.fail('unexpected snapshot') + create.assert_not_called() + + def test_source_symlink_refused(self): + link = self.source.with_name('link') + link.symlink_to(self.source) + with self.assertRaises(OSError): + with a.checker_snapshot(link, self.expected): + self.fail('unexpected snapshot') + + def test_fifo_refused_without_blocking(self): + fifo = self.source.with_name('fifo') + os.mkfifo(fifo) + with self.assertRaises(a.Refused): + with a.checker_snapshot(fifo, self.expected): + self.fail('unexpected snapshot') + + def test_sealing_failure_closes_snapshot(self): + original = a.os.memfd_create + opened = [] + def create(*args): + fd = original(*args) + opened.append(fd) + return fd + with patch.object(a.os, 'memfd_create', side_effect=create), patch.object(a.fcntl, 'fcntl', side_effect=OSError('synthetic failure')): + with self.assertRaises(OSError): + with a.checker_snapshot(self.source, self.expected): + self.fail('unexpected snapshot') + with self.assertRaises(OSError): + os.fstat(opened[0]) + + def test_seal_readback_mismatch_refused(self): + original = a.fcntl.fcntl + def invoke(fd, command, *args): + return 0 if command == fcntl.F_GET_SEALS else original(fd, command, *args) + with patch.object(a.fcntl, 'fcntl', side_effect=invoke): + with self.assertRaises(a.Refused): + with a.checker_snapshot(self.source, self.expected): + self.fail('unexpected snapshot') + + +class RealSnapshotTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + source = os.environ.get('KEYCAPE_TEST_CHECKER_BINARY') + if not source: + raise unittest.SkipTest('exact real checker artifact not supplied') + cls.data = Path(source).read_bytes() + cls.expected = os.environ.get('KEYCAPE_TEST_CHECKER_SHA256') + if cls.expected != 'sha256:' + hashlib.sha256(cls.data).hexdigest(): + raise ValueError('real checker artifact digest differs') + + def test_real_checker_executes_pinned_snapshot_after_selected_path_replaced(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'keycape' + source.write_bytes(self.data) + source.chmod(0o700) + with a.checker_snapshot(source, self.expected) as fd: + replacement = source.with_name('replacement') + replacement.write_text('#!/bin/sh\nexit 27\n') + replacement.chmod(0o700) + replacement.replace(source) + result = a.helper.check_config(source, VALID, [], False, executable_fd=fd) + self.assertTrue(result['valid']) + invalid = VALID + 'accountPortalURL: http://invalid.example.invalid\n' + self.assertFalse(a.helper.check_config(source, invalid, [], False, executable_fd=fd)['valid']) + + def test_real_checker_ignores_in_place_source_overwrite_after_snapshot(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'keycape' + source.write_bytes(self.data) + with a.checker_snapshot(source, self.expected) as fd: + source.write_bytes(b'no executable remains at the selected path') + with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'http://polluted.example.invalid'}): + self.assertTrue(a.helper.check_config(source, VALID, [], False, executable_fd=fd)['valid']) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py new file mode 100644 index 00000000..d9bb3c61 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py @@ -0,0 +1,317 @@ +"""Actual helper/CAS execution against synthetic Kubernetes objects only.""" +from dataclasses import asdict, replace +import copy +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from warden.case_run import Invocation, digest +from warden.case_receipt_state import Outcome, Pins, Refused +import yaml + +ROOT = Path(__file__).parent +spec = importlib.util.spec_from_file_location('registration_adapter', ROOT / 'keycape-registration-adapter.py') +a = importlib.util.module_from_spec(spec) +spec.loader.exec_module(a) +spec = importlib.util.spec_from_file_location('registration_fixture', ROOT / 'test_keycape_client.py') +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) + + +class RegistrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.binary = Path(self.temp.name) / 'keycape' + self.binary.write_bytes(b'synthetic checker binary') + self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1', + 'parameters': f.FORGEJO_PARAMS}}).encode() + desc = a.descriptor(digest(self.binary.read_bytes())) + self.desc_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes), + digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet')) + self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human', + 'founder', 'key-cape-oidc-login', pins) + self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') + self.secret, self.raw = f.fixture() + self.effects = [] + self.evidence = {} + self.revocations = [] + self.kw = dict(admitted=self.admitted, case_source=self.case, + expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test', + check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()), + publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload, + revoke_session=self.revoke) + self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True}) + self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube) + self.check_patch.start() + self.kube_patch.start() + self.addCleanup(self.check_patch.stop) + self.addCleanup(self.kube_patch.stop) + self.addCleanup(self.temp.cleanup) + + def publish(self, payload): + ref = 'owner-evidence:attempt-' + str(len(self.evidence)) + self.evidence[ref] = payload + return ref + + def revoke(self, inv): + self.revocations.append(inv) + return 'session-revoked:' + inv.operation + ':' + inv.attempt_id + + def kube(self, args, payload=None): + if args[:3] == ['get', 'namespace', 'kube-system']: + return {'metadata': {'uid': a.helper.lane.CLUSTER_UID}} + if args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + return copy.deepcopy(self.secret) + if args[:5] == ['-n', 'sso', 'get', 'deployment', 'keycape']: + return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', + 'env': [a.helper.env_binding(f.FORGEJO)]}]}}}} + if args[:5] == ['-n', 'sso', 'patch', 'secret', 'keycape-config']: + self.assertEqual(payload[:2], [ + {'op': 'test', 'path': '/metadata/uid', 'value': self.secret['metadata']['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': self.secret['metadata']['resourceVersion']}]) + self.effects.append(copy.deepcopy(payload)) + self.secret['data']['config.yaml'] = payload[2]['value'] + self.secret['metadata']['resourceVersion'] = str(int(self.secret['metadata']['resourceVersion']) + 1) + return copy.deepcopy(self.secret) + raise AssertionError('unexpected tooling operation') + + def test_actual_add_and_created_only_rollback_preserve_original_bytes(self): + adapter = a.build(**self.kw) + original = copy.deepcopy(self.secret['data']) + result = adapter.invoke(self.inv) + self.assertEqual(result.outcome, Outcome.SUCCESS) + self.assertEqual(result.created, ('keycape-client',)) + self.assertTrue(adapter.verify(self.inv, result)) + undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created) + self.assertEqual(undone.outcome, Outcome.ROLLED_BACK) + self.assertEqual(self.secret['data'], original) + self.assertEqual(len(self.effects), 2) + self.assertEqual(len(self.revocations), 2) + for payload in self.evidence.values(): + self.assertNotIn(b'synthetic-marker', payload) + self.assertNotIn(b'config.yaml', payload) + self.assertNotIn(b'signing-key', payload) + + def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self): + symlink = self.binary.with_name('symlink') + symlink.symlink_to(self.binary) + fifo = self.binary.with_name('fifo') + os.mkfifo(fifo) + oversized = self.binary.with_name('oversized') + with oversized.open('wb') as output: + output.truncate(64 * 1024 * 1024 + 1) + for source in (symlink, fifo, oversized): + with self.subTest(source=source.name), patch.object(a.helper, 'run') as run: + adapter = a.build(**dict(self.kw, check_config=source)) + with self.assertRaises(Refused): + adapter.invoke(self.inv) + run.assert_not_called() + self.assertEqual(self.revocations, [self.inv] * 3) + self.assertFalse(self.effects) + self.assertFalse(self.evidence) + + def test_snapshot_unavailable_refuses_before_tooling_and_revokes_session(self): + adapter = a.build(**self.kw) + with patch.object(a.os, 'memfd_create', side_effect=OSError('synthetic policy denial')): + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertFalse(self.effects) + self.assertFalse(self.evidence) + self.assertEqual(self.revocations, [self.inv]) + + def test_identical_client_is_no_change_and_cannot_be_removed(self): + self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO]) + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + self.assertEqual(result.outcome, Outcome.NO_CHANGE) + self.assertEqual(result.created, ()) + with self.assertRaises(Refused): + adapter.rollback(replace(self.inv, operation='rollback'), ('keycape-client',)) + self.assertFalse(self.effects) + + def test_existing_drift_refused_and_session_revoked(self): + self.secret, _ = f.fixture(f.EXISTING + [dict(f.FORGEJO, displayName='Drift')]) + with self.assertRaises(Refused): + a.build(**self.kw).invoke(self.inv) + self.assertFalse(self.effects) + self.assertEqual(self.revocations, [self.inv]) + + def test_invocation_rebinding_refused_before_tooling(self): + for changes in ({'target': 'other'}, {'principal': 'other'}, {'actor': 'agent'}, + {'lane': 'other'}, {'operation': 'rollback'}, {'attempt_id': ''}, + {'pins': replace(self.inv.pins, case=digest(b'other'))}): + with self.subTest(changes=changes), self.assertRaises(Refused): + a.build(**self.kw).invoke(replace(self.inv, **changes)) + self.assertFalse(self.effects) + + def test_initial_case_descriptor_and_target_pins_are_required(self): + for changes in ({'case_source': b'{}'}, {'case_acceptance': 'other'}, {'admitted': replace(self.admitted, target='other')}, + {'admitted': replace(self.admitted, actor='agent')}, + {'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'wrong')))}): + with self.subTest(changes=changes), self.assertRaises(Refused): + a.build(**dict(self.kw, **changes)) + self.assertFalse(self.effects) + + def test_binary_or_source_drift_refused_before_tooling(self): + adapter = a.build(**self.kw) + self.binary.write_bytes(b'changed') + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.binary.write_bytes(b'synthetic checker binary') + with patch.object(a, 'source_digest', return_value=digest(b'changed')): + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertFalse(self.effects) + + def test_substituting_caller_selected_checker_under_same_descriptor_is_refused(self): + substitute = Path(self.temp.name) / 'other-checker' + substitute.write_bytes(b'caller-selected executable') + with self.assertRaisesRegex(Refused, 'descriptor pin differs'): + a.build(**dict(self.kw, check_config=substitute, + check_config_digest=digest(substitute.read_bytes()))) + self.assertFalse(self.effects) + + def test_stale_revision_refuses_mutation(self): + adapter = a.build(**self.kw) + self.secret['metadata']['resourceVersion'] = '8' + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertFalse(self.effects) + + def test_rollback_requires_exact_provenance_and_postcreation_revision(self): + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + rollback = replace(self.inv, attempt_id='attempt-two', operation='rollback') + for created in ((), ('other',), ('keycape-client', 'other')): + with self.subTest(created=created), self.assertRaises(Refused): + adapter.rollback(rollback, created) + self.secret['metadata']['resourceVersion'] = '9' + with self.assertRaises(Refused): + adapter.rollback(rollback, result.created) + self.assertEqual(len(self.effects), 1) + + def test_missing_or_tampered_evidence_never_verifies_or_authorizes_undo(self): + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + self.assertFalse(adapter.verify(replace(self.inv, attempt_id='other'), result)) + self.assertFalse(adapter.verify(self.inv, replace(result, revoked=False))) + self.evidence[result.evidence] = b'forged' + self.assertFalse(adapter.verify(self.inv, result)) + with self.assertRaises(Refused): + adapter.rollback(replace(self.inv, operation='rollback'), result.created) + self.assertEqual(len(self.effects), 1) + + def test_process_loss_does_not_reconstruct_created_authority(self): + result = a.build(**self.kw).invoke(self.inv) + fresh = a.build(**self.kw) + with self.assertRaises(Refused): + fresh.rollback(replace(self.inv, operation='rollback'), result.created) + self.assertIsNone(fresh.reconcile) + self.assertEqual(len(self.effects), 1) + + def test_failed_readback_is_uncertain_and_never_provides_creation(self): + original_kube = self.kube + def lost(args, payload=None): + if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + raise RuntimeError('synthetic sensitive detail') + return original_kube(args, payload) + with patch.object(a.helper.lane, 'kubectl', side_effect=lost): + adapter = a.build(**self.kw) + with self.assertRaisesRegex(Refused, '^owner callback failed$'): + adapter.invoke(self.inv) + self.assertEqual(len(self.effects), 1) + self.assertFalse(self.evidence) + self.assertEqual(self.revocations, [self.inv]) + + def test_revocation_or_publication_failure_cannot_produce_verified_success(self): + for capability in ('publish', 'revoke_session'): + self.secret, _ = f.fixture() + with self.subTest(capability=capability): + kw = dict(self.kw, **{capability: lambda *args: (_ for _ in ()).throw(RuntimeError('private'))}) + adapter = a.build(**kw) + with self.assertRaisesRegex(Refused, '^owner callback failed$'): + adapter.invoke(self.inv) + self.assertFalse(self.evidence) + + def kernel_fixture(self): + # Fixture-only proposed type/case; canonical files are never edited. + # Warden's published producer pins apply unchanged. Owner trust and + # proposed descriptor/type/case are fixture-only, never native admission. + import warden.case_inputs as inputs + from warden.case_run import Executor, Journal + root = ROOT.parents[2] + case = yaml.safe_load((root / 'security-cases/forgejo-netkingdom-oidc.yaml').read_bytes()) + case['spec']['status'] = 'materialized' + case_source = yaml.safe_dump(case).encode() + typ = yaml.safe_load((root / 'security-case-types/oidc-browser-relying-party/type.yaml').read_bytes()) + typ['spec']['execution_descriptors'] = {'register-client': digest(self.desc_bytes)} + type_source = yaml.safe_dump(typ).encode() + catalog = b'fixture-only-catalog' + sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human', + 'founder', 'key-cape-oidc-login', 'fixture-acceptance', 'fixture-materialization', + 'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode() + pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes), + digest(catalog), digest(b'fixture-acceptance'), digest(sheet)) + invocation = replace(self.admitted, pins=pins) + adapter = a.build(**dict(self.kw, admitted=invocation, case_source=case_source, + case_acceptance='fixture-acceptance')) + def fixture_verifier(purpose, source, evidence): + if purpose.startswith('session-revocation:'): + _, lane, operation, attempt = purpose.split(':') + return source == digest(case_source) and lane == invocation.lane and evidence == ( + 'session-revoked:' + operation + ':' + attempt) + return evidence in {'fixture-acceptance', 'fixture-materialization', + 'fixture-descriptor', 'fixture-session'} + executor = Executor(Journal(Path(self.temp.name) / 'private'), + {adapter.reference: adapter}, fixture_verifier, browser_inputs=inputs.BrowserInputs(root)) + admitted = executor.admit(case_source, type_source, self.desc_bytes, catalog, + principal='founder', actor='human', lane=invocation.lane, + acceptance_evidence='fixture-acceptance', materialization_evidence='fixture-materialization', + descriptor_evidence='fixture-descriptor', session_evidence='fixture-session') + self.assertEqual(admitted[1], invocation) + return executor, admitted + + def test_actual_kernel_admission_dispatch_and_rollback_with_fixture_only_trust(self): + executor, admitted = self.kernel_fixture() + result = executor.run(*admitted) + self.assertEqual(result.last.outcome, Outcome.SUCCESS) + self.assertEqual(result.last.created, ('keycape-client',)) + undone = executor.rollback(*admitted) + self.assertEqual(undone.last.outcome, Outcome.ROLLED_BACK) + self.assertEqual(len(self.effects), 2) + self.assertEqual(len(self.revocations), 2) + + def test_kernel_lost_readback_retains_uncertain_and_refuses_replay_and_undo(self): + executor, admitted = self.kernel_fixture() + original_kube = self.kube + def lost(args, payload=None): + if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + raise RuntimeError('synthetic sensitive detail') + return original_kube(args, payload) + with patch.object(a.helper.lane, 'kubectl', side_effect=lost): + result = executor.run(*admitted) + self.assertEqual(result.last.outcome, Outcome.UNCERTAIN) + self.assertFalse(result.last.created) + with self.assertRaises(Refused): + executor.run(*admitted, retry=True) + with self.assertRaises(Refused): + executor.rollback(*admitted) + self.assertEqual(len(self.effects), 1) + self.assertEqual(len(self.revocations), 1) + + def test_kernel_external_revision_change_retains_uncertain_rollback(self): + executor, admitted = self.kernel_fixture() + self.assertEqual(executor.run(*admitted).last.outcome, Outcome.SUCCESS) + self.secret['metadata']['resourceVersion'] = '9' + self.assertEqual(executor.rollback(*admitted).last.outcome, Outcome.ROLLBACK_UNCERTAIN) + self.assertEqual(len(self.effects), 1) + self.assertEqual(len(self.revocations), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_client_config.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_client_config.py new file mode 100644 index 00000000..2d75c77c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_client_config.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Focused regression tests for the code-defined OpenBao KeyCape client.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("openbao-client-config.py") +SPEC = importlib.util.spec_from_file_location("openbao_client_config", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +LOOPBACK_CALLBACK = "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" + + +class OpenBaoClientConfigTest(unittest.TestCase): + def test_operator_tunnel_callback_is_required(self) -> None: + self.assertIn(LOOPBACK_CALLBACK, MODULE.OPENBAO_CLIENT["redirectUris"]) + + def test_missing_operator_tunnel_callback_fails_verification(self) -> None: + client = dict(MODULE.OPENBAO_CLIENT) + client["redirectUris"] = [ + uri for uri in MODULE.OPENBAO_CLIENT["redirectUris"] if uri != LOOPBACK_CALLBACK + ] + errors = MODULE.client_errors({"clients": [client]}, MODULE.OPENBAO_CLIENT) + self.assertEqual( + errors, + [f"openbao-admin.redirectUris missing: {LOOPBACK_CALLBACK}"], + ) + + def test_retired_public_callbacks_are_not_declared(self) -> None: + for uri in MODULE.RETIRED_OPENBAO_REDIRECT_URIS: + self.assertNotIn(uri, MODULE.OPENBAO_CLIENT["redirectUris"]) + + def test_retired_public_callback_fails_verification(self) -> None: + client = dict(MODULE.OPENBAO_CLIENT) + retired = MODULE.RETIRED_OPENBAO_REDIRECT_URIS[0] + client["redirectUris"] = [*MODULE.OPENBAO_CLIENT["redirectUris"], retired] + self.assertEqual( + MODULE.retired_redirect_errors({"clients": [client]}), + [f"openbao-admin.redirectUris retired: {retired}"], + ) + + def test_upsert_removes_retired_callbacks(self) -> None: + stale = dict(MODULE.OPENBAO_CLIENT) + stale["redirectUris"] = [*MODULE.OPENBAO_CLIENT["redirectUris"], *MODULE.RETIRED_OPENBAO_REDIRECT_URIS] + updated = MODULE.upsert_client({"clients": [stale]}, MODULE.OPENBAO_CLIENT) + self.assertEqual(MODULE.retired_redirect_errors(updated), []) + + def test_upsert_preserves_unrelated_clients(self) -> None: + config = {"clients": [{"clientId": "unrelated", "clientType": "public"}]} + updated = MODULE.upsert_client(config, MODULE.OPENBAO_CLIENT) + self.assertEqual(updated["clients"][0]["clientId"], "unrelated") + self.assertIn(LOOPBACK_CALLBACK, updated["clients"][1]["redirectUris"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_negative_session.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_negative_session.py new file mode 100644 index 00000000..a0fc7cbb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_negative_session.py @@ -0,0 +1,605 @@ +import base64 +import copy +import importlib.util +import json +from pathlib import Path +import time +import io +import os +import urllib.error +import signal +import threading +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('session', Path(__file__).with_name('openbao-negative-session.py')) +s = importlib.util.module_from_spec(spec) +spec.loader.exec_module(s) + + +def baseline(): + reference = dict(s.registrations()[-1], clientId='secrets-engine-openbao', + secretRef='env:PRODUCTION', tokenLifetime='15m') + return '# preserve this comment\nclients:\n' + s.yaml.safe_dump([reference], sort_keys=False) + 'other: unchanged\n' + + +def envelope(): + return {'proposal_id': s.PROPOSAL, 'target': s.TARGET, 'disposition': 'approved', + 'founder_decision_id': 'fixture-decision', + 'source_hashes': {str(Path(s.__file__).resolve()): s.digest(Path(s.__file__).read_bytes())}, + 'owner_acknowledgments': {o: 'fixture-owner-review' for o in + ['net-kingdom', 'key-cape', 'railiance-platform']}, + 'valid_until_epoch': time.time() + 900, 'maximum_session_seconds': 900, + 'bao_addr': 'http://openbao.openbao.svc:8200', + 'bao_health_identity_sha256': s.digest(b'fixture-health-cluster'), + 'role_observation': {'mount': 'keycape-services', 'role': 'secrets-engine-openbao', + 'issuer': 'https://kc.coulomb.social', 'algorithm': 'RS256', + 'bindings_exact': True, 'policy_exact': True, 'raw_expiration_leeway': 0, + 'effective_expiration_leeway': 150, 'observed_at_epoch': time.time()}} + + +class Tests(unittest.TestCase): + def setUp(self): + s.REQUEST_DEADLINE = None + s.BUSINESS_MONOTONIC_DEADLINE = None + s.CLEANUP_MONOTONIC_DEADLINE = None + s.CLEANING = False + def test_preserve_original_bytes(self): + raw = baseline() + result = s.candidate_config(raw) + start = result.index('clientId: openbao-probe-aud') + self.assertIn('# preserve this comment', result[:start]) + parsed = s.yaml.safe_load(result) + self.assertEqual(parsed['clients'][:-6], s.yaml.safe_load(raw)['clients']) + self.assertEqual(parsed['other'], 'unchanged') + + def test_isolation_and_sixth_matching_expiry(self): + clients = s.registrations() + self.assertNotIn('audience', clients[0]) + for c in clients[1:]: + self.assertEqual(c['audience'], 'secrets-engine-openbao') + for key in ['roles', 'tenant', 'serviceSubject', 'allowedScopes']: + self.assertEqual(clients[-1][key], s.yaml.safe_load(baseline())['clients'][0][key]) + + def test_preexisting_probe_refused(self): + raw = baseline().replace('other: unchanged', '- clientId: openbao-probe-aud\nother: unchanged') + with self.assertRaises(s.Refused): + s.candidate_config(raw) + + def test_duplicate_yaml_refused(self): + with self.assertRaises(s.Refused): + s.candidate_config(baseline() + 'other: shadow\n') + + def test_production_drift_refused(self): + with self.assertRaises(s.Refused): + s.candidate_config(baseline().replace('tenant:platform', 'tenant:other')) + + def test_env_collision_refused(self): + spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [ + {'name': 'keycape', 'env': [{'name': s.ENVS[0], 'value': 'fixture'}]}]}}} + with self.assertRaises(s.Refused): + s.candidate_spec(spec) + + def test_recovery_roundtrip_preserves_every_baseline_byte(self): + raw = baseline() + self.assertEqual(s.recovered_config(s.candidate_config(raw)), raw) + self.assertEqual(s.recovered_config(raw), raw) + + def test_recovery_claim_drift_and_partial_registration_refused(self): + raw = s.candidate_config(baseline()) + with self.assertRaises(s.Refused): + s.recovered_config(raw.replace('service:openbao-probe', 'service:changed')) + with self.assertRaises(s.Refused): + s.recovered_config(raw.replace('clientId: openbao-probe-aud', 'clientId: different-probe')) + + def test_recovery_env_roundtrip(self): + spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [ + {'name': 'keycape', 'env': [{'name': 'ORIGINAL', 'value': 'fixture'}]}]}}} + self.assertEqual(s.recovered_spec(s.candidate_spec(spec)), spec) + + def test_approval_is_not_optional(self): + e = envelope() + del e['founder_decision_id'] + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_source_change_refused(self): + e = envelope() + e['source_hashes'][str(Path(s.__file__).resolve())] = '0' * 64 + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_owner_missing_refused(self): + e = envelope() + del e['owner_acknowledgments']['net-kingdom'] + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_leeway_default_and_disabled(self): + e = envelope() + self.assertEqual(s.verify_sources(e), 150) + e['role_observation'].update(raw_expiration_leeway=-1, effective_expiration_leeway=0) + self.assertEqual(s.verify_sources(e), 0) + + def test_stale_role_refused(self): + e = envelope() + e['role_observation']['observed_at_epoch'] -= 301 + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_wrong_effective_leeway_refused(self): + e = envelope() + e['role_observation']['effective_expiration_leeway'] = 0 + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_bao_credentials_in_origin_refused(self): + e = envelope() + e['bao_addr'] = 'https://user:password@example.invalid' + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_future_role_observation_refused(self): + e = envelope() + e['role_observation']['observed_at_epoch'] = time.time() + 60 + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_arbitrary_https_origin_refused(self): + e = envelope() + e['bao_addr'] = 'https://attacker.invalid' + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_hostile_proxy_is_disabled(self): + build = s.urllib.request.build_opener + with patch.dict(os.environ, {'HTTPS_PROXY':'http://hostile-proxy.invalid:8000', + 'HTTP_PROXY':'http://hostile-proxy.invalid:8000'}), \ + patch.object(s.urllib.request, 'build_opener', wraps=build) as observed: + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + handlers = observed.call_args.args + self.assertIsInstance(handlers[0], s.urllib.request.ProxyHandler) + self.assertEqual(handlers[0].proxies, {}) + self.assertIsInstance(handlers[1], s.NoRedirect) + self.assertIsNone(handlers[1].redirect_request(None, None, 302, '', {}, 'https://hostile.invalid')) + + def test_redirect_never_follows_or_counts_as_refusal(self): + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + response = urllib.error.HTTPError('https://kc.coulomb.social/token', 302, + 'redirect', {}, io.BytesIO(b'{}')) + with patch.object(transport.opener, 'open', side_effect=response) as observed: + status, raw = transport.post('https://kc.coulomb.social/token', form={}) + self.assertEqual(status, 302) + self.assertEqual(observed.call_count, 1) + + def test_oversized_http_body_is_closed_and_refused(self): + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + response = type('Response', (), {'getcode':lambda self: 200, + 'read':lambda self, size: b'x' * size, 'close':lambda self: setattr(self, 'closed', True)})() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'http_response_oversized'): + transport.post('https://kc.coulomb.social/token', form={}) + self.assertTrue(response.closed) + + def test_expired_window_prevents_protected_kubernetes_and_http(self): + s.REQUEST_DEADLINE = time.time() - 1 + with patch.object(s.subprocess, 'run') as backend: + with self.assertRaisesRegex(s.Refused, 'approved_window_expired'): + s.k(['create', '-f', '/dev/stdin'], {}) + backend.assert_not_called() + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + with patch.object(transport.opener, 'open') as backend: + with self.assertRaisesRegex(s.Refused, 'approved_window_expired'): + transport.post('https://kc.coulomb.social/token', form={}) + backend.assert_not_called() + + def test_cleanup_revoke_allowed_after_window_expiry(self): + s.REQUEST_DEADLINE = time.time() - 1 + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + response = type('Response', (), {'getcode':lambda self: 204, + 'read':lambda self, size: b'', 'close':lambda self: None})() + with patch.object(transport.opener, 'open', return_value=response): + self.assertEqual(transport.post('http://openbao.openbao.svc:8200/v1/auth/token/revoke-self'), (204, b'')) + + def test_http_read_has_finite_size(self): + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + sizes = [] + response = type('Response', (), {'getcode':lambda self: 200, + 'read':lambda self, size: sizes.append(size) or b'{}', 'close':lambda self: None})() + with patch.object(transport.opener, 'open', return_value=response): + transport.post('https://kc.coulomb.social/token') + self.assertEqual(sizes, [s.MAX_RESPONSE + 1]) + + def test_nonfinite_and_boolean_timestamps_refused(self): + for value in [float('inf'), float('-inf'), float('nan'), True, False, 'Infinity', 10 ** 1000]: + for field in ['valid_until_epoch', 'observed_at_epoch']: + with self.subTest(field=field, value_type=type(value).__name__): + e = envelope() + if field == 'valid_until_epoch': + e[field] = value + else: + e['role_observation'][field] = value + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_finite_far_future_and_noninteger_window_refused(self): + e = envelope() + e['valid_until_epoch'] = time.time() + 86400 + with self.assertRaises(s.Refused): + s.verify_sources(e) + for value in [True, 900.0, float('inf'), float('nan'), '900']: + e = envelope() + e['maximum_session_seconds'] = value + with self.assertRaises(s.Refused): + s.verify_sources(e) + + def test_recovery_also_requires_finite_typed_window(self): + e = envelope() + e['disposition'] = 'recovery-approved' + e['valid_until_epoch'] = float('inf') + with self.assertRaises(s.Refused): + s.verify_sources(e, recovery=True) + + def test_nonfinite_json_constants_refused(self): + for constant in ['Infinity', '-Infinity', 'NaN']: + with self.assertRaises(s.Refused): + s.read_json('{"valid_until_epoch":' + constant + '}') + self.assertFalse(s.finite_number(s.read_json('{"valid_until_epoch":1e999}')['valid_until_epoch'])) + + def test_hard_timer_covers_slow_trickle_body_and_closes_response(self): + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + class Trickle: + def getcode(self): + return 200 + def read(self, size): + for _ in range(100): + time.sleep(0.01) + return b'{}' + def close(self): + self.closed = True + response = Trickle() + previous = signal.getsignal(signal.SIGALRM) + started = time.monotonic() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'request_total_timeout'): + transport.post('https://kc.coulomb.social/token', timeout=0.04) + self.assertLess(time.monotonic() - started, 0.25) + self.assertTrue(response.closed) + self.assertIs(signal.getsignal(signal.SIGALRM), previous) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0)) + + def test_hard_timer_spans_headers_and_clips_to_business_window(self): + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + s.REQUEST_DEADLINE = time.time() + 60 + s.BUSINESS_MONOTONIC_DEADLINE = time.monotonic() + 0.04 + def slow_headers(*args, **kwargs): + time.sleep(1) + raise AssertionError('inactivity timeout would have permitted this') + started = time.monotonic() + with patch.object(transport.opener, 'open', side_effect=slow_headers): + with self.assertRaisesRegex(s.Refused, 'request_total_timeout'): + transport.post('https://kc.coulomb.social/token') + self.assertLess(time.monotonic() - started, 0.25) + + def test_late_full_acceptance_is_revoked_then_refused(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + token = 'not-a-real-late-token' + class Late: + def getcode(self): + return 200 + def read(self, size): + s.REQUEST_DEADLINE = time.time() - 1 + return json.dumps({'auth': {'client_token': token}}).encode() + def close(self): + pass + revoke = type('Response', (), {'getcode':lambda self: 204, + 'read':lambda self, size: b'', 'close':lambda self: None})() + with patch.object(transport.opener, 'open', side_effect=[Late(), revoke]) as calls: + with self.assertRaisesRegex(s.Refused, 'approved_window_expired'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login') + self.assertEqual(calls.call_count, 2) + self.assertEqual(calls.call_args_list[1].args[0].full_url, + 'http://openbao.openbao.svc:8200/v1/auth/token/revoke-self') + self.assertTrue(receipt['late_acceptance_revoke_attempted']) + self.assertTrue(receipt['late_acceptance_revoke_succeeded']) + self.assertNotIn(token, json.dumps(receipt)) + + def test_late_acceptance_failed_revoke_remains_unconfirmed(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + class Late: + def getcode(self): + return 200 + def read(self, size): + s.REQUEST_DEADLINE = time.time() - 1 + return b'{"auth":{"client_token":"fake-token"}}' + def close(self): + pass + failed = type('Response', (), {'getcode':lambda self: 403, + 'read':lambda self, size: b'{}', 'close':lambda self: None})() + with patch.object(transport.opener, 'open', side_effect=[Late(), failed]): + with self.assertRaises(s.Refused): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login') + self.assertTrue(receipt['late_acceptance_revoke_attempted']) + self.assertFalse(receipt['late_acceptance_revoke_succeeded']) + + def test_partial_accepted_response_timeout_retains_unknown_outcome(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + response = type('Response', (), {'getcode':lambda self: 200, + 'read':lambda self, size: time.sleep(1), 'close':lambda self: None})() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'request_total_timeout'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.04) + self.assertTrue(receipt['openbao_login_outcome_unknown']) + + def test_cleanup_has_finite_aggregate_deadline_and_restoration(self): + s.REQUEST_DEADLINE = time.time() - 1 + s.begin_cleanup() + first = s.CLEANUP_MONOTONIC_DEADLINE + s.begin_cleanup() + self.assertEqual(s.CLEANUP_MONOTONIC_DEADLINE, first) + s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() - 1 + s.CLEANING = True + with self.assertRaisesRegex(s.Refused, 'cleanup_budget_expired'): + s.check_deadline() + transport = s.SessionTransport('http://openbao.openbao.svc:8200') + with patch.object(transport.opener, 'open') as backend: + with self.assertRaises(s.Refused): + transport.post('http://openbao.openbao.svc:8200/v1/auth/token/revoke-self') + backend.assert_not_called() + + def test_expired_cleanup_cannot_issue_native_secret_delete(self): + s.CLEANING = True + s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() - 1 + obj = {'metadata': {'uid':'temporary', 'resourceVersion':'7'}} + with patch.object(s.subprocess, 'run') as native: + with self.assertRaisesRegex(s.Refused, 'cleanup_budget_expired'): + s.delete_temporary(obj) + native.assert_not_called() + + def test_secret_delete_total_timeout_clipped_to_cleanup_budget(self): + s.CLEANING = True + s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() + 0.04 + obj = {'metadata': {'uid':'temporary', 'resourceVersion':'7'}} + captured = {} + def slow_delete(args, **kwargs): + captured.update(kwargs) + time.sleep(1) + return type('Result', (), {'returncode':0, 'stdout':'{}'})() + started = time.monotonic() + with patch.object(s.subprocess, 'run', side_effect=slow_delete): + with self.assertRaisesRegex(s.Refused, 'request_total_timeout'): + s.delete_temporary(obj) + self.assertLess(time.monotonic() - started, 0.25) + self.assertLessEqual(captured['timeout'], 0.04) + self.assertEqual(json.loads(captured['input'])['preconditions'], {'uid':'temporary', 'resourceVersion':'7'}) + + def test_early_read_failure_slow_close_is_bounded_once(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + class Response: + closes = 0 + def getcode(self): + return 200 + def read(self, size): + raise s.Refused('synthetic_read_failure') + def close(self): + self.closes += 1 + time.sleep(0.07) + response = Response() + previous = signal.getsignal(signal.SIGALRM) + started = time.monotonic() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01) + self.assertLess(time.monotonic() - started, 0.05) + self.assertEqual(response.closes, 1) + self.assertTrue(receipt['openbao_login_outcome_unknown']) + self.assertTrue(receipt['http_abort_close_attempted']) + self.assertFalse(receipt['http_abort_close_succeeded']) + self.assertTrue(receipt['http_response_abort_unconfirmed']) + self.assertIs(signal.getsignal(signal.SIGALRM), previous) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0)) + + def test_total_read_timeout_then_slow_close_uses_only_abort_cap(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + class Response: + closes = 0 + def getcode(self): + return 200 + def read(self, size): + time.sleep(0.1) + def close(self): + self.closes += 1 + time.sleep(0.1) + response = Response() + started = time.monotonic() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01) + self.assertLess(time.monotonic() - started, 0.06) + self.assertEqual(response.closes, 1) + self.assertTrue(receipt['openbao_login_outcome_unknown']) + self.assertFalse(receipt['http_abort_close_succeeded']) + + def test_oversized_body_slow_abort_remains_unproved_and_bounded(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + response = type('Response', (), {'getcode':lambda self: 200, + 'read':lambda self, size: b'x' * size, + 'close':lambda self: time.sleep(0.1)})() + started = time.monotonic() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01) + self.assertLess(time.monotonic() - started, 0.05) + self.assertTrue(receipt['openbao_login_outcome_unknown']) + self.assertFalse(receipt['http_abort_close_succeeded']) + + def test_raising_normal_close_then_slow_abort_is_private_bounded_unknown(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + class Response: + closes = 0 + def getcode(self): + return 200 + def read(self, size): + return b'{}' + def close(self): + self.closes += 1 + if self.closes == 1: + raise RuntimeError('private protected error must not escape') + time.sleep(0.1) + response = Response() + previous = signal.getsignal(signal.SIGALRM) + started = time.monotonic() + with patch.object(transport.opener, 'open', return_value=response): + with self.assertRaisesRegex(s.Refused, '^http_response_abort_unconfirmed$'): + transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01) + self.assertLess(time.monotonic() - started, 0.05) + self.assertEqual(response.closes, 2) + self.assertTrue(receipt['openbao_login_outcome_unknown']) + self.assertFalse(receipt['http_abort_close_succeeded']) + self.assertIs(signal.getsignal(signal.SIGALRM), previous) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0)) + + def test_expired_cleanup_never_attempts_exceptional_close_or_resets(self): + receipt = {} + transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt) + deadline = time.monotonic() - 1 + s.CLEANUP_MONOTONIC_DEADLINE = deadline + response = type('Response', (), {'close':lambda self: self.fail('must not close')})() + with patch.object(response, 'close') as closes: + with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'): + transport.abort_response(response, time.monotonic() - 1) + closes.assert_not_called() + self.assertEqual(s.CLEANUP_MONOTONIC_DEADLINE, deadline) + self.assertFalse(receipt['http_abort_close_attempted']) + self.assertTrue(receipt['http_response_abort_unconfirmed']) + + def test_thread_or_existing_timer_refused_without_overwrite(self): + results = [] + def in_thread(): + try: + with s.total_timeout(0.1): + results.append('unreachable') + except s.Refused: + results.append('refused') + thread = threading.Thread(target=in_thread) + thread.start() + thread.join() + self.assertEqual(results, ['refused']) + signal.setitimer(signal.ITIMER_REAL, 1) + try: + with self.assertRaisesRegex(s.Refused, 'existing_alarm_timer_refused'): + with s.total_timeout(0.1): + pass + self.assertGreater(signal.getitimer(signal.ITIMER_REAL)[0], 0.5) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + + def test_cleanup_refuses_concurrent_registry_change(self): + self.lifecycle(drift=True) + + def test_deleted_client_contract_is_specific(self): + self.assertTrue(s.removed_client_denied(401, b'{"error":"invalid_profile_usage","feature":"client_id"}')) + for status, body in [(503, b'{}'), (401, b'{"error":"invalid_profile_usage","feature":"Authorization"}'), + (401, b'{"error":"invalid_client"}'), (401, b'not-json')]: + self.assertFalse(s.removed_client_denied(status, body)) + + def test_cleanup_even_when_probe_fails(self): + self.lifecycle(probe_pass=False) + + def test_complete_lifecycle(self): + self.lifecycle() + + def lifecycle(self, drift=False, probe_pass=True): + raw = baseline() + dep_spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [ + {'name': 'keycape', 'image': 'fixture@sha256:abc', 'env': []}]}}} + objects = {'keycape-config': {'metadata': {'name': 'keycape-config', 'uid': 'cfg', 'resourceVersion': '1'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), + 'key.pem': 'FAKE_KEY_NOT_READ'}}, + 'keycape': {'metadata': {'name': 'keycape', 'uid': 'dep', 'resourceVersion': '1'}, + 'spec': dep_spec}} + original = copy.deepcopy(objects) + e = envelope() + e.update(runner_path='fake-runner', config_metadata=s.meta(objects['keycape-config']), + deployment_metadata=s.meta(objects['keycape']), deployment_spec_sha256=s.canonical(dep_spec), + registry_sha256=s.digest(raw.encode())) + e['source_hashes']['fake-runner'] = 'fixture' + receipt = {'cleanup_verified': False} + class Probe: + def exchange(self, *args): + return 'fixture-token' + def http_post(self, *args, **kwargs): + return 401, b'{"error":"invalid_profile_usage","feature":"client_id"}' + def run(self, *args, **kwargs): + if drift: + objects['keycape-config']['data']['unrelated'] = 'concurrent-owner-change' + return {'all_refused': probe_pass} + def kube(args, payload=None): + if args[0] == 'get': + return {'metadata': {'uid': s.CLUSTER}} + if args[0] == 'create': + if '--dry-run=server' in args: + return copy.deepcopy(payload) + created = copy.deepcopy(payload) + created['metadata'].update(uid='temp', resourceVersion='1') + objects[s.TEMP] = created + return created + if args[0] == 'delete': + self.assertEqual(payload['preconditions'], {'uid':'temp', 'resourceVersion':'1'}) + del objects[s.TEMP] + return {'kind':'Status', 'status':'Success'} + if '--ignore-not-found' in args: + return objects.get(s.TEMP) + raise AssertionError('unexpected kube call') + def patched(kind, obj, path, value, dry=False): + result = copy.deepcopy(obj) + if path == '/spec': + result['spec'] = copy.deepcopy(value) + else: + result['data']['config.yaml'] = value + if not dry: + objects[obj['metadata']['name']] = result + return result + def delete(*args, **kwargs): + del objects[s.TEMP] + return type('Result', (), {'returncode': 0})() + class Discovery: + def __enter__(self): + import io + return io.StringIO('{"issuer":"https://kc.coulomb.social","token_endpoint":"https://kc.coulomb.social/token"}') + def __exit__(self, *args): + pass + with patch.object(s, 'verify_sources', return_value=150), patch.object(s, 'load_probe', return_value=Probe()), \ + patch.object(s, 'SessionTransport') as transport_factory, \ + patch.object(s, 'k', side_effect=kube), patch.object(s, 'get', side_effect=lambda kind, name: copy.deepcopy(objects[name])), \ + patch.object(s, 'patch', side_effect=patched), patch.object(s, 'ready', return_value=True), \ + patch.object(s.subprocess, 'run', side_effect=delete): + transport_factory.return_value.discovery.return_value = { + 'issuer':'https://kc.coulomb.social', 'token_endpoint':'https://kc.coulomb.social/token'} + transport_factory.return_value.health_identity.return_value = e['bao_health_identity_sha256'] + transport_factory.return_value.post.side_effect = Probe().http_post + if drift or not probe_pass: + with self.assertRaises(s.Refused): + s.execute(e, {'human_token': 'fake', 'coding_agent_token': 'fake'}, receipt) + else: + s.execute(e, {'human_token': 'fake', 'coding_agent_token': 'fake'}, receipt) + if drift: + self.assertFalse(receipt['cleanup_verified']) + self.assertIn(s.TEMP, objects) + self.assertEqual(objects['keycape-config']['data']['unrelated'], 'concurrent-owner-change') + else: + self.assertTrue(receipt['cleanup_verified']) + self.assertNotIn(s.TEMP, objects) + self.assertEqual(objects['keycape-config']['data'], original['keycape-config']['data']) + self.assertEqual(objects['keycape']['spec'], original['keycape']['spec']) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_node_route.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_node_route.py new file mode 100644 index 00000000..df994442 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_node_route.py @@ -0,0 +1,198 @@ +"""Offline route readiness: public responses and lifecycle mocks only.""" +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch +import copy +import time +import tempfile +import sys +import signal +import io + +spec=importlib.util.spec_from_file_location('fixtures',Path(__file__).with_name('test_openbao_negative_session.py')) +f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f);s=f.s +HEALTH={'initialized':True,'sealed':False,'standby':False,'performance_standby':False,'cluster_id':'synthetic-public-cluster'} +PIN=s.digest(HEALTH['cluster_id'].encode()) + +class NodeRouteTests(unittest.TestCase): + def setUp(self): + s.CLEANING=False;s.REQUEST_DEADLINE=None + s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None + + def test_public_health_get_has_no_authentication_and_only_digest_retained(self): + transport=s.SessionTransport('http://127.0.0.1:18200');receipt={} + response=type('Response',(),{'getcode':lambda self:200,'read':lambda self,size:json.dumps(HEALTH).encode(),'close':lambda self:None})() + with patch.object(transport.opener,'open',return_value=response) as calls: + s.verify_bao_route(transport,{'bao_health_identity_sha256':PIN},receipt) + req=calls.call_args.args[0] + self.assertEqual(req.full_url,'http://127.0.0.1:18200/v1/sys/health') + self.assertEqual(req.method,'GET');self.assertEqual(req.headers,{}) + self.assertEqual(receipt,{'bao_public_route_verified':True}) + self.assertNotIn(HEALTH['cluster_id'],json.dumps(receipt)) + + def test_unreachable_redirect_sealed_standby_uninitialized_and_malformed_health_refuse(self): + transport=s.SessionTransport('http://openbao.openbao.svc:8200') + cases=[(None,b''),(302,b'{}'),(503,json.dumps(HEALTH).encode()),(200,b'not-json'), + (200,b'[]'),(200,json.dumps(dict(HEALTH,sealed=True)).encode()), + (200,json.dumps(dict(HEALTH,initialized=False)).encode()), + (200,json.dumps(dict(HEALTH,standby=True)).encode()), + (200,json.dumps(dict(HEALTH,sealed=0)).encode()), + (200,json.dumps(dict(HEALTH,cluster_id='')).encode())] + for status,body in cases: + with self.subTest(status=status,body_length=len(body)),patch.object(transport,'request',return_value=(status,body)): + with self.assertRaises(s.Refused):transport.health_identity() + + def test_identity_drift_or_missing_approved_pin_refuse(self): + transport=s.SessionTransport('http://127.0.0.1:18200') + with patch.object(transport,'health_identity',return_value=PIN) as health: + with self.assertRaisesRegex(s.Refused,'bao_public_health_identity_changed'): + s.verify_bao_route(transport,{'bao_health_identity_sha256':'0'*64},{}) + health.reset_mock() + with self.assertRaisesRegex(s.Refused,'approved_bao_health_identity_required'): + s.verify_bao_route(transport,{}, {}) + health.assert_not_called() + + def setup_execute(self): + raw=f.baseline() + spec={'replicas':1,'strategy':{'type':'Recreate'},'template':{'spec':{'containers':[{'name':'keycape','env':[]}]}}} + config={'metadata':{'name':'keycape-config','uid':'cfg','resourceVersion':'1'},'data':{'config.yaml':s.base64.b64encode(raw.encode()).decode()}} + dep={'metadata':{'name':'keycape','uid':'dep','resourceVersion':'1'},'spec':spec} + e=f.envelope();e.update(runner_path='fixture-runner',bao_health_identity_sha256=PIN, + config_metadata=s.meta(config),deployment_metadata=s.meta(dep),registry_sha256=s.digest(raw.encode()),deployment_spec_sha256=s.canonical(spec)) + e['source_hashes']['fixture-runner']='fixture' + return e,config,dep + + def test_unavailable_initial_node_route_stops_before_any_native_or_secret_access(self): + e,config,dep=self.setup_execute() + with patch.object(s,'verify_sources',return_value=150),patch.object(s,'load_probe'), \ + patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k,patch.object(s,'get') as get: + factory.return_value.discovery.return_value={'issuer':'https://kc.coulomb.social','token_endpoint':'https://kc.coulomb.social/token'} + factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable') + with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'): + s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {}) + k.assert_not_called();get.assert_not_called() + + def test_route_loss_after_dry_runs_stops_before_temporary_create_or_restarts(self): + e,config,dep=self.setup_execute() + with patch.object(s,'verify_sources',return_value=150),patch.object(s,'load_probe'), \ + patch.object(s,'SessionTransport') as factory, \ + patch.object(s,'k',return_value={'metadata':{'uid':s.CLUSTER}}) as k, \ + patch.object(s,'get',side_effect=[config,dep]),patch.object(s,'patch') as patches,patch.object(s,'ready',return_value=True): + factory.return_value.discovery.return_value={'issuer':'https://kc.coulomb.social','token_endpoint':'https://kc.coulomb.social/token'} + factory.return_value.health_identity.side_effect=[PIN,s.Refused('bao_public_health_unavailable')] + with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'): + s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {}) + self.assertEqual(factory.return_value.health_identity.call_count,2) + self.assertTrue(all(c.kwargs.get('dry') is True for c in patches.call_args_list)) + self.assertTrue(all(c.args[0][0]!='create' or '--dry-run=server' in c.args[0] for c in k.call_args_list)) + + def test_recovery_route_refusal_precedes_native_reads(self): + e,config,dep=self.setup_execute();e['disposition']='recovery-approved' + with patch.object(s,'verify_sources',return_value=None),patch.object(s,'SessionTransport') as factory, \ + patch.object(s,'k') as k,patch.object(s,'get') as get: + factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable') + with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'): + s.recover(e,{}) + k.assert_not_called();get.assert_not_called() + + def test_public_health_uses_total_elapsed_limit(self): + transport=s.SessionTransport('http://127.0.0.1:18200') + class Slow: + def getcode(self):return 200 + def read(self,size):time.sleep(0.5);return json.dumps(HEALTH).encode() + def close(self):pass + started=time.monotonic() + with patch.object(s,'HTTP_TOTAL_SECONDS',0.008),patch.object(transport.opener,'open',return_value=Slow()): + with self.assertRaisesRegex(s.Refused,'request_total_timeout'):transport.health_identity() + self.assertLess(time.monotonic()-started,0.08) + + def test_public_route_mode_has_no_native_secret_or_private_input(self): + with tempfile.TemporaryDirectory() as folder: + receipt=Path(folder)/'receipt.json' + argv=['session','route-preflight','--bao-addr','http://127.0.0.1:18200','--receipt',str(receipt)] + with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \ + patch.object(s,'k') as k,patch.object(s,'get') as get,patch.object(sys,'stdin') as stdin: + factory.return_value.health_identity.return_value=PIN + self.assertEqual(s.main(),0) + k.assert_not_called();get.assert_not_called();stdin.read.assert_not_called() + data=json.loads(receipt.read_text()) + self.assertEqual(data['bao_health_identity_sha256'],PIN) + self.assertEqual(data['probe_location'],'current_process_only') + self.assertFalse(data['native_accepted']) + + def main_fixture(self,folder,mode): + envelope=f.envelope() + envelope['bao_health_identity_sha256']=PIN + if mode=='recover':envelope['disposition']='recovery-approved' + path=Path(folder)/'approved.json';path.write_text(json.dumps(envelope));path.chmod(0o600) + receipt=Path(folder)/'receipt.json' + argv=['session',mode,'--envelope',str(path),'--receipt',str(receipt)] + return envelope,receipt,argv + + def test_actual_main_health_failure_never_reads_private_stdin_execute_or_recover(self): + previous=signal.getsignal(signal.SIGTERM) + try: + for mode in ['execute','recover']: + with self.subTest(mode=mode),tempfile.TemporaryDirectory() as folder: + e,receipt,argv=self.main_fixture(folder,mode) + with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \ + patch.object(sys,'stdin') as stdin,patch.object(s,'k') as k,patch.object(s,'get') as get: + factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable') + self.assertEqual(s.main(),1) + stdin.read.assert_not_called();stdin.buffer.read.assert_not_called();k.assert_not_called();get.assert_not_called() + data=json.loads(receipt.read_text()) + self.assertEqual(data['reason'],'bao_public_health_unavailable') + self.assertFalse(data['cleanup_verified']) + s.CLEANING=False;s.CLEANUP_MONOTONIC_DEADLINE=None + finally:signal.signal(signal.SIGTERM,previous) + + def test_actual_main_missing_approved_health_pin_never_reads_stdin(self): + previous=signal.getsignal(signal.SIGTERM) + try: + with tempfile.TemporaryDirectory() as folder: + e,receipt,argv=self.main_fixture(folder,'execute') + del e['bao_health_identity_sha256'] + Path(argv[3]).write_text(json.dumps(e)) + with patch.object(sys,'argv',argv),patch.object(sys,'stdin') as stdin, \ + patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k: + self.assertEqual(s.main(),1) + stdin.read.assert_not_called();stdin.buffer.read.assert_not_called();factory.return_value.health_identity.assert_not_called();k.assert_not_called() + self.assertEqual(json.loads(receipt.read_text())['reason'],'approved_bao_health_identity_required') + finally:signal.signal(signal.SIGTERM,previous) + + def test_actual_main_health_succeeds_before_stdin_and_preserves_started_window(self): + previous=signal.getsignal(signal.SIGTERM);events=[];started={} + try: + with tempfile.TemporaryDirectory() as folder: + e,receipt,argv=self.main_fixture(folder,'execute') + def health(): + events.append('health');started['deadline']=s.BUSINESS_MONOTONIC_DEADLINE;return PIN + payloads=iter([b'{"human_token":"synthetic","coding_agent_token":"synthetic"}',b'']) + def private_read(size): + if 'stdin' not in events:events.append('stdin') + self.assertGreater(size,0);return next(payloads) + def execute(*args,**kwargs): + events.append('execute');self.assertTrue(kwargs['window_started']) + self.assertEqual(s.BUSINESS_MONOTONIC_DEADLINE,started['deadline']) + with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \ + patch.object(sys,'stdin') as stdin,patch.object(s,'emit_private_ready'),patch.object(s,'execute',side_effect=execute), \ + patch.object(s,'k') as k: + factory.return_value.health_identity.side_effect=health + stdin.buffer.read.side_effect=private_read + self.assertEqual(s.main(),0);k.assert_not_called() + self.assertEqual(events,['health','stdin','execute']) + finally:signal.signal(signal.SIGTERM,previous) + + def test_execute_does_not_renew_expired_preinput_monotonic_window(self): + e=f.envelope();s.start_execution_window(e) + original=s.BUSINESS_MONOTONIC_DEADLINE + with patch.object(s.time,'monotonic',return_value=original+0.01),patch.object(s,'load_probe') as load, \ + patch.object(s,'k') as k: + with self.assertRaisesRegex(s.Refused,'session_business_budget_expired'): + s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {},window_started=True) + load.assert_not_called();k.assert_not_called() + self.assertEqual(s.BUSINESS_MONOTONIC_DEADLINE,original) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_input.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_input.py new file mode 100644 index 00000000..cdf96701 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_input.py @@ -0,0 +1,111 @@ +"""Actual main bounded private receive; synthetic local binary pipes only.""" +import importlib.util +from pathlib import Path +import io +import json +import os +import signal +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +spec=importlib.util.spec_from_file_location('routes',Path(__file__).with_name('test_openbao_node_route.py')) +r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r);s=r.s;f=r.f +PAYLOAD=b'{"human_token":"synthetic-human","coding_agent_token":"synthetic-agent"}' + +class PrivateInputTests(unittest.TestCase): + def setUp(self): + s.CLEANING=False;s.REQUEST_DEADLINE=None + s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None + + def main_with(self,stream,*,lifetime=0.05,health=None): + previous=signal.getsignal(signal.SIGTERM) + try: + with tempfile.TemporaryDirectory() as folder: + envelope=f.envelope();envelope['valid_until_epoch']=time.time()+lifetime + envelope['bao_health_identity_sha256']=r.PIN + path=Path(folder)/'approved.json';path.write_text(json.dumps(envelope));path.chmod(0o600) + receipt=Path(folder)/'receipt.json' + argv=['session','execute','--envelope',str(path),'--receipt',str(receipt)] + with patch.object(sys,'argv',argv),patch.object(sys,'stdin',stream), \ + patch.object(s,'SessionTransport') as factory,patch.object(s,'emit_private_ready'),patch.object(s,'execute') as execute, \ + patch.object(s,'k') as k,patch.object(s,'get') as get: + factory.return_value.health_identity.side_effect=health + factory.return_value.health_identity.return_value=r.PIN + rc=s.main() + k.assert_not_called();get.assert_not_called() + return rc,json.loads(receipt.read_text()),execute.call_args,execute.call_count + finally:signal.signal(signal.SIGTERM,previous) + + def test_actual_main_real_pipe_stall_has_original_window_total_bound(self): + read_fd,write_fd=os.pipe() + binary=os.fdopen(read_fd,'rb',buffering=0) + stream=type('Input',(),{'buffer':binary})() + started=time.monotonic();previous=signal.getsignal(signal.SIGALRM) + try: + rc,receipt,args,count=self.main_with(stream,lifetime=0.02) + finally:os.close(write_fd);binary.close() + self.assertEqual(rc,1);self.assertEqual(count,0) + self.assertEqual(receipt['reason'],'request_total_timeout') + self.assertLess(time.monotonic()-started,0.15) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0)) + self.assertEqual(signal.getsignal(signal.SIGALRM),previous) + + def test_actual_main_complete_json_without_eof_still_refuses(self): + read_fd,write_fd=os.pipe();os.write(write_fd,PAYLOAD) + binary=os.fdopen(read_fd,'rb',buffering=0);stream=type('Input',(),{'buffer':binary})() + try:rc,receipt,args,count=self.main_with(stream,lifetime=0.02) + finally:os.close(write_fd);binary.close() + self.assertEqual(rc,1);self.assertEqual(count,0) + self.assertEqual(receipt['reason'],'request_total_timeout') + + def test_actual_main_oversized_receive_refuses_without_execution(self): + stream=type('Input',(),{'buffer':io.BytesIO(b'x'*(s.PRIVATE_INPUT_MAX_BYTES+1))})() + rc,receipt,args,count=self.main_with(stream) + self.assertEqual(rc,1);self.assertEqual(count,0) + self.assertEqual(receipt['reason'],'private_input_oversized') + + def test_actual_main_empty_partial_invalid_or_excessive_token_refuses(self): + inputs=[(b'','private_input_empty'),(b'{"human_token":','private_input_invalid'), + (b'not-json','private_input_invalid'),(b'{"human_token":true,"coding_agent_token":"a"}','private_input_invalid'), + (json.dumps({'human_token':'x'*(s.PRIVATE_TOKEN_MAX_BYTES+1),'coding_agent_token':'a'}).encode(),'private_input_invalid')] + for raw,reason in inputs: + with self.subTest(reason=reason,length=len(raw)): + stream=type('Input',(),{'buffer':io.BytesIO(raw)})() + rc,receipt,args,count=self.main_with(stream) + self.assertEqual(rc,1);self.assertEqual(count,0);self.assertEqual(receipt['reason'],reason) + + def test_actual_main_expiry_after_health_never_starts_private_receive(self): + stream=type('Input',(),{'buffer':io.BytesIO(PAYLOAD)})() + def late_health(): + s.REQUEST_DEADLINE=time.time()-1 + return r.PIN + with patch.object(stream.buffer,'read',wraps=stream.buffer.read) as read: + rc,receipt,args,count=self.main_with(stream,health=late_health) + self.assertEqual(rc,1);self.assertEqual(count,0) + self.assertEqual(receipt['reason'],'approved_window_expired');read.assert_not_called() + + def test_actual_main_late_eof_never_decodes_or_executes(self): + class LateInput: + reads=0 + def read(self,size): + self.reads+=1 + if self.reads==1:return PAYLOAD + s.BUSINESS_MONOTONIC_DEADLINE=time.monotonic()-1 + return b'' + stream=type('Input',(),{'buffer':LateInput()})() + rc,receipt,args,count=self.main_with(stream) + self.assertEqual(rc,1);self.assertEqual(count,0) + self.assertEqual(receipt['reason'],'session_business_budget_expired') + + def test_actual_main_complete_bounded_json_plus_eof_reaches_execution_once(self): + stream=type('Input',(),{'buffer':io.BytesIO(PAYLOAD)})() + rc,receipt,args,count=self.main_with(stream) + self.assertEqual(rc,0);self.assertEqual(count,1) + self.assertEqual(args.args[1],json.loads(PAYLOAD)) + self.assertTrue(args.kwargs['window_started']) + self.assertNotIn('synthetic-human',json.dumps(receipt)) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_ready.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_ready.py new file mode 100644 index 00000000..7a23c003 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_private_ready.py @@ -0,0 +1,165 @@ +"""Synthetic ready FD/control transport tests; no SSH/native/credentials.""" +import importlib.util +from pathlib import Path +import fcntl +import io +import json +import os +import signal +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +spec=importlib.util.spec_from_file_location('routes',Path(__file__).with_name('test_openbao_node_route.py')) +r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r);s=r.s;f=r.f +spec=importlib.util.spec_from_file_location('wrapper',Path(__file__).with_name('openbao-negative-node-wrapper.py')) +w=importlib.util.module_from_spec(spec);spec.loader.exec_module(w) +NONCE='a'*64 + +class ReadyTests(unittest.TestCase): + def setUp(self): + s.CLEANING=False;s.REQUEST_DEADLINE=None;s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None + def marker(self,envelope=None): + e=envelope or f.envelope() + return {'schema':s.READY_SCHEMA,'session_nonce':NONCE,'proposal_id':s.PROPOSAL,'target':s.TARGET, + 'executor_sha256':s.digest(Path(s.__file__).read_bytes()),'envelope_sha256':'b'*64, + 'bao_health_identity_sha256':e['bao_health_identity_sha256'],'valid_until_epoch':e['valid_until_epoch'], + 'receive_budget_seconds':15} + + def test_emit_exact_ready_marker_to_distinct_local_pipe_and_close_endpoint(self): + e=f.envelope();s.start_execution_window(e);read_fd,write_fd=os.pipe();receipt={} + s.emit_private_ready(write_fd,NONCE,'b'*64,'b'*64,e,receipt) + raw=os.read(read_fd,4097);self.assertEqual(os.read(read_fd,1),b'');os.close(read_fd) + obj=json.loads(raw) + self.assertEqual(set(obj),set(self.marker(e))) + self.assertEqual(obj['session_nonce'],NONCE) + self.assertEqual(obj['executor_sha256'],s.digest(Path(s.__file__).read_bytes())) + self.assertTrue(receipt['private_ready_emitted']) + self.assertNotIn('human_token',obj);self.assertNotIn('coding_agent_token',obj) + + def test_stdout_alias_pipe_fd_above_two_refused_without_marker(self): + e=f.envelope();s.start_execution_window(e);read_fd,write_fd=os.pipe();saved=os.dup(1);alias=None + try: + os.dup2(write_fd,1);alias=os.dup(1) + with self.assertRaisesRegex(s.Refused,'private_ready_stdio_alias_refused'): + s.emit_private_ready(alias,NONCE,'b'*64,'b'*64,e,{}) + finally: + os.dup2(saved,1);os.close(saved);os.close(write_fd) + if alias is not None:os.close(alias) + self.assertEqual(os.read(read_fd,4096),b'');os.close(read_fd) + + def test_nonce_or_envelope_binding_failure_emits_no_ready(self): + e=f.envelope();s.start_execution_window(e) + for nonce,pin in [('wrong','b'*64),(NONCE,'c'*64)]: + read_fd,write_fd=os.pipe() + try: + with self.assertRaises(s.Refused):s.emit_private_ready(write_fd,nonce,pin,'b'*64,e,{}) + finally: + try:os.close(write_fd) + except OSError:pass + self.assertEqual(os.read(read_fd,4096),b'');os.close(read_fd) + + def test_actual_full_ack_pipe_write_times_out_inside_original_window(self): + e=f.envelope();s.start_execution_window(e) + s.BUSINESS_MONOTONIC_DEADLINE=time.monotonic()+0.015 + read_fd,write_fd=os.pipe();fcntl.fcntl(write_fd,fcntl.F_SETPIPE_SZ,4096) + flags=fcntl.fcntl(write_fd,fcntl.F_GETFL);fcntl.fcntl(write_fd,fcntl.F_SETFL,flags|os.O_NONBLOCK) + try: + while True:os.write(write_fd,b'x'*4096) + except BlockingIOError:pass + fcntl.fcntl(write_fd,fcntl.F_SETFL,flags) + started=time.monotonic();handler=signal.getsignal(signal.SIGALRM) + with self.assertRaisesRegex(s.Refused,'request_total_timeout'): + s.emit_private_ready(write_fd,NONCE,'b'*64,'b'*64,e,{}) + self.assertLess(time.monotonic()-started,0.15);os.close(read_fd) + self.assertEqual(signal.getsignal(signal.SIGALRM),handler) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0)) + + def test_actual_main_health_ready_then_private_read_exact_order(self): + previous=signal.getsignal(signal.SIGTERM);read_fd,write_fd=os.pipe();events=[];received={} + try: + with tempfile.TemporaryDirectory() as folder: + e=f.envelope();path=Path(folder)/'approved.json';path.write_text(json.dumps(e));path.chmod(0o600) + pin=s.digest(path.read_bytes());receipt=Path(folder)/'receipt.json' + argv=['session','execute','--envelope',str(path),'--receipt',str(receipt),'--ready-fd',str(write_fd), + '--session-nonce',NONCE,'--ready-envelope-sha256',pin] + class Input: + first=True + def read(self,size): + if not self.first:return b'' + self.first=False + received.update(json.loads(os.read(read_fd,4096))) + self_test.assertEqual(received['envelope_sha256'],pin) + events.append('private-read') + return b'{"human_token":"synthetic","coding_agent_token":"synthetic"}' + self_test=self;stream=type('Stream',(),{'buffer':Input()})() + def health():events.append('health');return e['bao_health_identity_sha256'] + def execute(*args,**kwargs):events.append('execute');self.assertTrue(kwargs['window_started']) + with patch.object(sys,'argv',argv),patch.object(sys,'stdin',stream), \ + patch.object(s,'SessionTransport') as factory,patch.object(s,'execute',side_effect=execute),patch.object(s,'k') as k: + factory.return_value.health_identity.side_effect=health + self.assertEqual(s.main(),0);k.assert_not_called() + self.assertEqual(events,['health','private-read','execute']) + self.assertEqual(received['session_nonce'],NONCE) + finally: + signal.signal(signal.SIGTERM,previous);os.close(read_fd) + try:os.close(write_fd) + except OSError:pass + + def test_actual_main_missing_ack_fd_never_reads_private_input(self): + previous=signal.getsignal(signal.SIGTERM) + try: + with tempfile.TemporaryDirectory() as folder: + e=f.envelope();path=Path(folder)/'approved.json';path.write_text(json.dumps(e));path.chmod(0o600) + receipt=Path(folder)/'receipt.json';argv=['session','execute','--envelope',str(path),'--receipt',str(receipt)] + with patch.object(sys,'argv',argv),patch.object(sys,'stdin') as stdin,patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k: + factory.return_value.health_identity.return_value=e['bao_health_identity_sha256'] + self.assertEqual(s.main(),1);stdin.buffer.read.assert_not_called();k.assert_not_called() + self.assertEqual(json.loads(receipt.read_text())['reason'],'private_ready_pipe_required') + finally:signal.signal(signal.SIGTERM,previous) + + def test_wrapper_marker_pin_nonce_fields_and_window_failures_refuse(self): + e=f.envelope();marker=self.marker(e) + for key,value in [('session_nonce','c'*64),('executor_sha256','c'*64),('envelope_sha256','c'*64), + ('target','other'),('proposal_id','other'),('bao_health_identity_sha256','c'*64), + ('valid_until_epoch',True),('receive_budget_seconds',True),('receive_budget_seconds',float('inf'))]: + bad=dict(marker,**{key:value}) + with self.subTest(key=key),self.assertRaises(w.Refused): + w.validate_marker(json.dumps(bad).encode()+b'\n',nonce=NONCE,executor_pin=marker['executor_sha256'],envelope_pin='b'*64,envelope=e) + with self.assertRaises(w.Refused): + w.validate_marker(json.dumps(dict(marker,extra=True)).encode()+b'\n',nonce=NONCE,executor_pin=marker['executor_sha256'],envelope_pin='b'*64,envelope=e) + + def test_wrapper_ready_pipe_no_eof_is_bounded(self): + read_fd,write_fd=os.pipe();os.write(write_fd,b'{"incomplete":true}') + started=time.monotonic() + try: + with self.assertRaisesRegex(w.Refused,'wrapper_timeout'): + with w.deadline(0.015):w.read_ready(read_fd) + finally:os.close(write_fd);os.close(read_fd) + self.assertLess(time.monotonic()-started,0.15) + + def test_wrapper_pinned_node_child_launch_is_local_pipe_not_ssh_fd_forward(self): + with tempfile.TemporaryDirectory() as folder: + child=Path(folder)/'child.py';child.write_text('# synthetic child, never executed\n') + envelope=f.envelope();envelope['source_hashes']={str(child):w.hash_bytes(child.read_bytes()),str(Path(w.__file__).resolve()):w.hash_bytes(Path(w.__file__).read_bytes())} + env=Path(folder)/'approved.json';env.write_text(json.dumps(envelope));env.chmod(0o600) + args=type('Args',(),{'child':str(child),'child_sha256':w.hash_bytes(child.read_bytes()),'envelope':str(env),'envelope_sha256':w.hash_bytes(env.read_bytes()),'receipt':str(Path(folder)/'receipt.json'),'session_nonce':NONCE})() + class Process: + stdout=io.BytesIO();stderr=io.BytesIO() + def wait(self,timeout):return 0 + def poll(self):return 0 + def launch(command,**kwargs): + self.assertEqual(command[0],sys.executable) + self.assertEqual(command[1],str(child));self.assertNotIn('ssh',command) + self.assertEqual(kwargs['stdin'],sys.stdin.buffer) + fd=int(command[command.index('--ready-fd')+1]);self.assertEqual(kwargs['pass_fds'],(fd,)) + marker=self.marker(envelope);marker.update(executor_sha256=args.child_sha256,envelope_sha256=args.envelope_sha256) + os.write(fd,json.dumps(marker).encode()+b'\n') + return Process() + with patch.object(w.subprocess,'Popen',side_effect=launch),patch.object(w,'forward_ready') as forward: + self.assertEqual(w.run(args),0);self.assertEqual(forward.call_count,1) + self.assertNotIn('human_token',json.loads(forward.call_args.args[0])) + +if __name__=='__main__':unittest.main(verbosity=2) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py new file mode 100644 index 00000000..9877c403 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py @@ -0,0 +1,156 @@ +import base64 +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location( + 'openbao_signin_diagnosis_rollout', Path(__file__).with_name('openbao-signin-diagnosis-rollout.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +UID = '00000000-0000-4000-8000-000000000001' +CLUSTER = {'metadata': {'uid': module.lane.CLUSTER_UID}} +OPENBAO = (' - clientId: openbao-admin\n' + ' displayName: Railiance OpenBao Admin\n' + ' redirectUris:\n' + ' - http://localhost:8250/oidc/callback\n' + ' clientType: public\n') +OTHER = (' - clientId: user-engine-portal\n' + ' clientType: public\n') + + +def fixture(clients=OTHER + OPENBAO, tail='tokenLifetime: 15m\n'): + # Synthetic marker values only; no live Secret is loaded by this suite. + raw = ('# preserve this comment\nissuer: https://kc.coulomb.social\n' + 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic\n' + 'clients:\n' + clients + tail) + secret = {'metadata': {'uid': UID, 'resourceVersion': '7'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), + 'signing-key.pem': base64.b64encode(b'synthetic').decode()}} + return secret, raw + + +def decode(encoded): + return base64.b64decode(encoded).decode() + + +class EditTests(unittest.TestCase): + def test_enable_adds_exactly_one_line_after_client_id(self): + secret, raw = fixture() + encoded, changed = module.enable(secret) + self.assertTrue(changed) + expected = raw.replace(' - clientId: openbao-admin\n', + ' - clientId: openbao-admin\n signInDiagnosis: true\n') + self.assertEqual(decode(encoded), expected) + + def test_enable_is_noop_when_already_true(self): + secret, _ = fixture(OPENBAO.replace('clientType: public', 'clientType: public\n signInDiagnosis: true')) + encoded, changed = module.enable(secret) + self.assertFalse(changed) + self.assertEqual(encoded, secret['data']['config.yaml']) + + def test_other_flag_value_fails_closed(self): + secret, _ = fixture(OPENBAO + ' signInDiagnosis: false\n') + for operation in (module.enable, module.disable): + with self.assertRaises(module.Refused): + operation(secret) + + def test_missing_or_duplicate_client_fails_closed(self): + for clients in (OTHER, OPENBAO + OPENBAO): + with self.assertRaises(module.Refused): + module.enable(fixture(clients)[0]) + + def test_unverified_upstream_issuer_refused(self): + secret, raw = fixture() + raw = raw.replace('https://auth.coulomb.social', 'https://evil.test') + secret['data']['config.yaml'] = base64.b64encode(raw.encode()).decode() + with self.assertRaises(module.Refused): + module.enable(secret) + + def test_rollback_restores_exact_bytes(self): + secret, raw = fixture() + enabled, _ = module.enable(secret) + secret['data']['config.yaml'] = enabled + restored, changed = module.disable(secret) + self.assertTrue(changed) + self.assertEqual(decode(restored), raw) + + def test_rollback_absent_is_noop(self): + secret, _ = fixture() + encoded, changed = module.disable(secret) + self.assertFalse(changed) + self.assertEqual(encoded, secret['data']['config.yaml']) + + +class RunTests(unittest.TestCase): + """Drive run() against a fake kubectl; nothing reaches a cluster.""" + + def setUp(self): + self.secret, _ = fixture() + self.calls = [] + + def fake(self, arguments, payload=None): + self.calls.append((arguments, payload)) + if arguments[:2] == ['get', 'namespace']: + return CLUSTER + if 'patch' in arguments: + data = dict(self.secret['data'], **{'config.yaml': payload[-1]['value']}) + if '--dry-run=server' not in arguments: + self.secret = {'metadata': {'uid': UID, 'resourceVersion': '8'}, 'data': data} + return {'data': data} + return self.secret + + def main(self, *argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = module.main(list(argv), kubectl=self.fake) + return code, out.getvalue() + + def patches(self): + return [c for c in self.calls if 'patch' in c[0]] + + def test_default_is_inspect_and_never_patches(self): + code, out = self.main() + self.assertEqual(code, 0) + receipt = json.loads(out) + self.assertTrue(receipt['change_needed']) + self.assertEqual(receipt['before'], {'uid': UID, 'resource_version': '7'}) + self.assertEqual(self.patches(), []) + self.assertNotIn('synthetic', out) + + def test_dry_run_requires_observed_revision_and_is_server_side(self): + code, out = self.main('dry-run') + self.assertEqual((code, json.loads(out)['reason']), (1, 'observed_revision_changed')) + code, out = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '7') + self.assertEqual(code, 0) + self.assertIn('--dry-run=server', self.patches()[-1][0]) + self.assertNotIn('synthetic', out) + + def test_apply_requires_attended_and_reports_restart(self): + code, out = self.main('apply', '--expected-uid', UID, '--expected-resource-version', '7') + self.assertEqual((code, json.loads(out)['reason']), (1, 'attended_operator_required')) + self.assertEqual(self.patches(), []) + code, out = self.main('apply', '--attended', '--expected-uid', UID, '--expected-resource-version', '7') + receipt = json.loads(out) + self.assertEqual(code, 0) + self.assertTrue(receipt['changed'] and receipt['restart_required']) + self.assertEqual(receipt['after']['resource_version'], '8') + self.assertEqual(self.patches()[-1][1][:2], [ + {'op': 'test', 'path': '/metadata/uid', 'value': UID}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': '7'}]) + self.assertNotIn('synthetic', out) + + def test_failures_never_print_secret_material(self): + def broken(arguments, payload=None): + raise RuntimeError('synthetic value leaked in error text') + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(module.main([], kubectl=broken), 1) + self.assertNotIn('synthetic', out.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_activation.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_activation.py new file mode 100644 index 00000000..056223aa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_activation.py @@ -0,0 +1,228 @@ +import base64 +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import jsonpatch + +ROOT = Path(__file__).parent +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module +a = load('activation', 'playwright-activation.py') +f = load('registration_fixture', 'test_playwright_registration.py') +REVISION = '5dd74dfbdf3fd38adb1bfc1bdc1326181435f0e9' +IDENTITIES = [f.IDENTITY, dict(f.IDENTITY, agent_id='second', service_subject='service:second')] + + +def fixtures(): + secret, raw = f.fixture() + secret.update(kind='Secret', apiVersion='v1') + secret['metadata'].update(name='keycape-config', namespace='sso', uid='00000000-0000-4000-8000-000000000001') + deployment = {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': { + 'name': 'keycape', 'namespace': 'sso', 'uid': '00000000-0000-4000-8000-000000000002', + 'resourceVersion': '12', 'generation': 5}, 'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'}, + 'template': {'metadata': {'labels': {'app': 'keycape'}}, 'spec': { + 'containers': [{'name': 'keycape', 'image': 'forgejo.coulomb.social/coulomb/key-cape@sha256:' + 'a' * 64, + 'env': [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], + 'volumeMounts': [{'name': 'registry', 'mountPath': '/etc/keycape', 'readOnly': True}]}], + 'volumes': [{'name': 'registry', 'secret': {'secretName': 'keycape-config'}}]}}}} + controller(deployment) + return secret, deployment + + +def controller(deployment): + # Simulates observed controller completion, separate from patch admission. + replicas = deployment['spec']['replicas'] + deployment['status'] = {'observedGeneration': deployment['metadata']['generation'], + **{k: replicas for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']}} + + +class World: + def __init__(self, secret, deployment): + self.secret, self.deployment = copy.deepcopy(secret), copy.deepcopy(deployment) + self.pods = {'kind': 'PodList', 'metadata': {}, 'items': []} + self.history = [] + + def step(self, proposal, phase, observe=True): + operation = a.propose_step(proposal, a.digest(proposal), phase, self.secret, self.deployment, self.pods) + if operation is None: return None + attr = 'secret' if operation['kind'] == 'Secret' else 'deployment' + # Independent RFC 6902 implementation verifies actual generated CAS tests. + updated = jsonpatch.apply_patch(getattr(self, attr), operation['patch'], in_place=False) + updated['metadata']['resourceVersion'] = str(int(updated['metadata']['resourceVersion']) + 1) + if attr == 'deployment': updated['metadata']['generation'] += 1 + self.history.append(phase) + setattr(self, attr, updated) + receipt = a.verify_response(operation, updated) + if attr == 'deployment' and observe: controller(updated) + return receipt + + +class ActivationTests(unittest.TestCase): + def setUp(self): + self.secret, self.deployment = fixtures() + self.proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION) + + def test_exact_sequence_keeps_original_source_and_returns_safe_receipts(self): + world = World(self.secret, self.deployment) + original = copy.deepcopy((self.secret, self.deployment)) + for phase in ['stop', 'registry', 'bindings', 'resume']: + receipt = world.step(self.proposal, phase) + self.assertTrue(receipt['readback_matches']) + self.assertNotIn('synthetic-private-marker', json.dumps(receipt)) + self.assertEqual(world.deployment['spec']['replicas'], 1) + self.assertEqual(world.secret['data'], self.proposal['after_data']) + self.assertEqual((self.secret, self.deployment), original) + self.assertNotIn('synthetic-private-marker', json.dumps(a.summary(self.proposal))) + + def test_rollback_preserves_preexisting_public_and_agent_registrations(self): + encoded, _ = a.registration.prepare_registry(self.secret, [IDENTITIES[0]]) + self.secret['data']['config.yaml'] = encoded + binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0])) + a.container(self.deployment['spec'])['env'].append(binding) + proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION) + self.assertEqual(proposal['created_client_ids'], ['playwright-agent-second']) + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings', 'resume', 'rollback-stop', + 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + world.step(proposal, phase) + self.assertEqual(world.secret['data'], self.secret['data']) + self.assertEqual(world.deployment['spec'], self.deployment['spec']) + + def test_each_partial_state_recovers_without_removing_original_entries(self): + phases = ['stop', 'registry', 'bindings', 'resume'] + for count in range(5): + world = World(self.secret, self.deployment) + for phase in phases[:count]: world.step(self.proposal, phase) + for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + world.step(self.proposal, phase) + self.assertEqual(world.secret['data'], self.secret['data']) + self.assertEqual(world.deployment['spec'], self.deployment['spec']) + + def test_identical_complete_registration_is_noop_without_outage(self): + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings', 'resume']: world.step(self.proposal, phase) + proposal = a.build(world.secret, world.deployment, IDENTITIES, REVISION) + self.assertFalse(a.summary(proposal)['change_needed']) + before = len(world.history) + for phase in ['stop', 'registry', 'bindings', 'resume']: self.assertIsNone(world.step(proposal, phase)) + self.assertEqual(len(world.history), before) + + def test_rollback_before_any_execution_never_stops_the_issuer(self): + world = World(self.secret, self.deployment) + for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + self.assertIsNone(world.step(self.proposal, phase)) + self.assertEqual(world.history, []) + + def test_reordered_phases_and_unobserved_controller_are_refused(self): + world = World(self.secret, self.deployment) + for phase in ['registry', 'bindings', 'resume']: + with self.subTest(phase=phase), self.assertRaises(a.Refused): world.step(self.proposal, phase) + world.step(self.proposal, 'stop', observe=False) + with self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + controller(world.deployment) + with self.assertRaises(a.Refused): world.step(self.proposal, 'resume') + + def test_terminating_direct_projected_and_env_consumers_prevent_registry_write(self): + specs = [{'volumes': [{'secret': {'secretName': 'keycape-config'}}]}, + {'volumes': [{'projected': {'sources': [{'secret': {'name': 'keycape-config'}}]}}]}, + {'containers': [{'envFrom': [{'secretRef': {'name': 'keycape-config'}}]}]}, + {'initContainers': [{'env': [{'valueFrom': {'secretKeyRef': {'name': 'keycape-config'}}}]}]}] + for spec in specs: + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + world.pods['items'] = [{'metadata': {'namespace': 'sso', 'deletionTimestamp': 'synthetic'}, 'spec': spec}] + with self.subTest(spec=spec), self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + + def test_partial_pod_inventory_refused(self): + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + world.pods['metadata']['continue'] = 'another-page' + with self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + + def test_original_revision_and_proposal_digest_are_bound(self): + for attr in ['secret', 'deployment']: + world = World(self.secret, self.deployment) + getattr(world, attr)['metadata']['resourceVersion'] = '999' + with self.subTest(attr=attr), self.assertRaises(a.Refused): world.step(self.proposal, 'stop') + changed = copy.deepcopy(self.proposal); changed['after_data']['other'] = 'changed' + with self.assertRaises(a.Refused): + a.propose_step(changed, a.digest(self.proposal), 'stop', self.secret, self.deployment, {'kind': 'PodList', 'items': []}) + + def test_independent_jsonpatch_rejects_racing_version_or_spec(self): + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', + self.secret, self.deployment, {'kind': 'PodList', 'items': []}) + for field in ['version', 'spec']: + raced = copy.deepcopy(self.deployment) + if field == 'version': raced['metadata']['resourceVersion'] = '999' + else: raced['spec']['replicas'] = 2 + with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed): + jsonpatch.apply_patch(raced, operation['patch']) + + def test_private_registry_patch_checks_complete_data_and_uid(self): + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'registry', world.secret, world.deployment, world.pods) + for field in ['data', 'uid']: + raced = copy.deepcopy(world.secret) + if field == 'data': raced['data']['other'] = 'racing-write' + else: raced['metadata']['uid'] = 'recreated' + with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed): + jsonpatch.apply_patch(raced, operation['patch']) + + def test_unrelated_registry_or_deployment_drift_refuses_rollback(self): + for field in ['data', 'spec']: + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings']: world.step(self.proposal, phase) + if field == 'data': world.secret['data']['unrelated-new-field'] = 'keep' + else: world.deployment['spec']['template']['metadata']['labels']['new'] = 'keep' + with self.subTest(field=field), self.assertRaises(a.Refused): world.step(self.proposal, 'rollback-registry') + + def test_readback_mismatch_refused_without_raw_output(self): + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', self.secret, self.deployment, {}) + with self.assertRaisesRegex(a.Refused, '^mutation_readback_mismatch$'): + a.verify_response(operation, self.deployment) + + def test_unsupported_deployment_and_binding_drift_refused(self): + changes = [lambda d: d['spec'].update(replicas=2), + lambda d: d['spec']['strategy'].update(type='RollingUpdate'), + lambda d: a.container(d['spec']).update(image='unreviewed:latest'), + lambda d: a.container(d['spec']).update(envFrom=[{'secretRef': {'name': 'other'}}]), + lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_CONFIG', 'value': 'other'}), + lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_PLAYWRIGHT_AGENT_TEST_AGENT_CLIENT_SECRET', 'value': 'synthetic-private-marker'})] + for change in changes: + deployment = copy.deepcopy(self.deployment); change(deployment) + with self.subTest(change=change), self.assertRaises(a.Refused): + a.build(self.secret, deployment, IDENTITIES, REVISION) + + def test_native_verifier_is_pinned_contained_and_ignores_ambient_environment(self): + with tempfile.TemporaryDirectory() as folder: + binary = Path(folder) / 'verifier'; binary.write_bytes(b'synthetic-binary') + sha = hashlib.sha256(binary.read_bytes()).hexdigest() + with patch.object(a.subprocess, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, b'{"valid": true, "errors": []}', b'') + self.assertTrue(a.check_registry(self.proposal, binary, sha)['proposed_registry_valid']) + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args.kwargs['env'], {'PATH': '/usr/bin:/bin'}) + self.assertIn('synthetic-private-marker', run.call_args.kwargs['input'].decode()) + self.assertNotIn('synthetic-private-marker', str(run.call_args.args)) + run.return_value = subprocess.CompletedProcess([], 1, b'{"valid": false, "errors": ["synthetic-private-marker"]}', b'synthetic-private-marker') + with self.assertRaisesRegex(a.Refused, '^native_registry_check_failed$'): + a.check_registry(self.proposal, binary, sha) + with self.assertRaisesRegex(a.Refused, '^native_verifier_digest_mismatch$'): + a.check_registry(self.proposal, binary, 'wrong') + + def test_other_env_name_cannot_alias_a_proposed_agent_secret(self): + binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0])) + binding['name'] = 'OTHER_CLIENT_SECRET' + a.container(self.deployment['spec'])['env'].append(binding) + with self.assertRaisesRegex(a.Refused, 'delivery_secret_already_bound'): + a.build(self.secret, self.deployment, IDENTITIES, REVISION) + + +if __name__ == '__main__': unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_registration.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_registration.py new file mode 100644 index 00000000..2ac03fc6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_playwright_registration.py @@ -0,0 +1,158 @@ +import base64 +import contextlib +import copy +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest + +import yaml + +spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py')) +p = importlib.util.module_from_spec(spec) +spec.loader.exec_module(p) +IDENTITY = {'agent_id': 'test-agent', 'service_subject': 'service:test-agent:platform', + 'tenant': 'tenant:test', 'identity_record': 'synthetic/agent-record.json'} + + +def fixture(): + raw = ('# preserve the issuer and existing clients\nissuer: https://kc.coulomb.social\n' + 'port: 8080\nprivateKeyPem: /synthetic/key.pem\ntokenLifetime: 15m\n' + 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-private-marker\n' + 'clients:\n - clientId: existing\n clientType: public\n' + ' redirectUris: [https://example.test/callback]\n' + ' allowedScopes: [openid]\n grantTypes: [authorization_code]\n' + '# trailing configuration comment\nlogLevel: info\n') + return {'metadata': {'uid': 'synthetic', 'resourceVersion': '7'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'other': 'preserve'}}, raw + + +def decode(value): + return base64.b64decode(value).decode() + + +def altered(secret, change): + result = copy.deepcopy(secret) + config = yaml.safe_load(decode(result['data']['config.yaml'])) + change(config) + result['data']['config.yaml'] = base64.b64encode(yaml.safe_dump(config, sort_keys=False).encode()).decode() + return result + + +class RegistrationTests(unittest.TestCase): + def test_public_matches_platform_and_agent_has_distinct_delivery(self): + client = p.public_client() + self.assertEqual(client['redirectUris'], ['http://127.0.0.1:18764/callback']) + self.assertNotIn('tokenLifetime', client) + self.assertNotIn('secretRef', client) + agent = p.agent_client(IDENTITY) + self.assertEqual(agent['allowedScopes'], ['playwright:connect']) + self.assertEqual(agent['roles'], ['coding-agent']) + self.assertEqual(agent['tenant'], IDENTITY['tenant']) + self.assertEqual(agent['serviceSubject'], IDENTITY['service_subject']) + self.assertEqual(agent['tokenLifetime'], '15m') + self.assertEqual(p.registry.env_binding(agent)['valueFrom']['secretKeyRef'], + {'name': 'keycape-playwright-agent-test-agent-client', 'key': 'client-secret'}) + + def test_unknown_or_missing_identity_fields_refused(self): + for identity in [{}, [], dict(IDENTITY, secret='not-emitted'), dict(IDENTITY, tenant=''), + dict(IDENTITY, service_subject='human:someone'), + dict(IDENTITY, identity_record='bad\nreference'), + dict(IDENTITY, agent_id='bad_$agent'), dict(IDENTITY, tenant=True)]: + with self.subTest(identity=identity), self.assertRaises(p.Refused): + p.agent_client(identity) + + def test_duplicate_agent_or_subject_refused(self): + for second in [IDENTITY, dict(IDENTITY, agent_id='second'), + dict(IDENTITY, service_subject='service:second')]: + with self.subTest(second=second), self.assertRaises(p.Refused): + p.clients([IDENTITY, second]) + + def test_go_lifetimes_are_bounded_without_global_change(self): + for value in ['15m', '1h', '1h0m0s', '3599.5s', '1000ms', '1.5m', '.5m']: + with self.subTest(value=value): p.human_lifetime({'tokenLifetime': value}) + for value in [None, 900, '', '0s', '-1m', '1h1ns', '0.5s', 'NaN', '15mgarbage', '1d']: + with self.subTest(value=value), self.assertRaises(p.Refused): + p.human_lifetime({'tokenLifetime': value}) + + def test_add_preserves_bytes_and_is_repeatable_then_exact_remove(self): + secret, raw = fixture() + original = copy.deepcopy(secret) + encoded, changed = p.prepare_registry(secret, [IDENTITY]) + self.assertEqual(changed, ['playwright-cli', 'playwright-agent-test-agent']) + updated = decode(encoded) + prefix, suffix = raw.split('logLevel:') + self.assertTrue(updated.startswith(prefix)) + self.assertTrue(updated.endswith('logLevel:' + suffix)) + self.assertEqual(secret, original) + current = copy.deepcopy(secret); current['data']['config.yaml'] = encoded + self.assertEqual(p.prepare_registry(current, [IDENTITY]), (encoded, [])) + restored, removed = p.prepare_registry(current, [IDENTITY], 'remove') + self.assertEqual(decode(restored), raw) + self.assertEqual(removed, changed) + self.assertEqual(p.prepare_registry(secret, [IDENTITY], 'remove'), + (secret['data']['config.yaml'], [])) + + def test_refuses_drift_conflicts_and_unreviewed_audience(self): + base, _ = fixture() + public = p.public_client(); agent = p.agent_client(IDENTITY) + cases = [lambda c: c.update(issuer='https://other.test'), + lambda c: c.update(tokenLifetime='2h'), + lambda c: c['clients'].append(dict(public, allowedScopes=['openid'])), + lambda c: c['clients'].append(dict(public, clientId='other-consumer')), + lambda c: c['clients'].append(dict(agent, clientId='duplicate-subject')), + lambda c: c['clients'].append({'clientId': 'other', 'secretRef': agent['secretRef']}), + lambda c: c['clients'].append(copy.deepcopy(c['clients'][0]))] + for change in cases: + for operation in ['add', 'remove']: + with self.subTest(change=change, operation=operation), self.assertRaises(p.Refused): + p.prepare_registry(altered(base, change), [IDENTITY], operation) + + def test_same_agent_can_keep_separate_existing_openbao_client(self): + base, _ = fixture() + existing = dict(p.agent_client(IDENTITY), clientId='existing-openbao-agent', + audience='openbao', allowedScopes=['openbao:login'], + secretRef='env:EXISTING_OPENBAO_SECRET') + secret = altered(base, lambda c: c['clients'].append(existing)) + encoded, _ = p.prepare_registry(secret, [IDENTITY]) + self.assertIn(existing, yaml.safe_load(decode(encoded))['clients']) + + def test_default_audience_client_id_cannot_bypass_dedicated_inventory(self): + base, _ = fixture() + default_audience = dict(p.public_client(), clientId=p.AUDIENCE) + del default_audience['audience'] + secret = altered(base, lambda c: c['clients'].append(default_audience)) + with self.assertRaises(p.Refused): + p.prepare_registry(secret, [IDENTITY]) + + def test_cli_outputs_only_proposal_and_no_false_admission(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'agents.json'; path.write_text(json.dumps([IDENTITY])) + out = io.StringIO() + with contextlib.redirect_stdout(out): code = p.main(['--agents', str(path)]) + self.assertEqual(code, 0) + result = json.loads(out.getvalue()) + self.assertEqual(result['status'], 'source_proposal') + for flag in ['identity_evidence_verified', 'custody_admitted', 'live_activated']: + self.assertFalse(result[flag]) + self.assertEqual(len(result['clients']), 2) + self.assertEqual(result['identity_references']['playwright-agent-test-agent'], + IDENTITY['identity_record']) + + def test_cli_contains_invalid_private_or_duplicate_input(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'input.json' + for raw in ['synthetic-private-marker', '[{"agent_id":"a","agent_id":"b"}]', + json.dumps([dict(IDENTITY, secret='synthetic-private-marker')]), 'x' * 65537]: + path.write_text(raw) + out = io.StringIO() + with contextlib.redirect_stdout(out): code = p.main(['--agents', str(path)]) + self.assertEqual(code, 1) + self.assertNotIn('synthetic-private-marker', out.getvalue()) + self.assertEqual(json.loads(out.getvalue())['status'], 'refused') + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_portal_client_rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_portal_client_rollout.py new file mode 100644 index 00000000..1028f127 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_portal_client_rollout.py @@ -0,0 +1,71 @@ +import base64 +import importlib.util +import json +from pathlib import Path +import unittest + +import yaml + +spec = importlib.util.spec_from_file_location('portal_rollout', Path(__file__).with_name('portal-client-rollout.py')) +rollout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollout) + + +class PortalClientTests(unittest.TestCase): + def fixture(self, clients=None): + # Synthetic marker values only; no live Secret is loaded by this suite. + raw = ('# preserve this comment\nauthelia:\n issuer: https://auth.example.invalid\n' + ' clientSecret: synthetic-marker\nclients:\n' + + yaml.safe_dump(json.loads(json.dumps(clients or [{'clientId': 'existing', 'clientType': 'public'}])), sort_keys=False) + + 'tokenLifetime: 15m\n') + return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'synthetic-key'}}, raw + + def test_add_preserves_other_values_and_original_bytes(self): + secret, raw = self.fixture() + encoded, changed = rollout.replacement(secret) + updated = base64.b64decode(encoded).decode() + self.assertTrue(changed) + self.assertTrue(updated.startswith(raw[:raw.index('tokenLifetime:')])) + self.assertTrue(updated.endswith('tokenLifetime: 15m\n')) + expected = yaml.safe_load(raw) + expected['clients'].append(rollout.portal.CLIENT) + self.assertEqual(yaml.safe_load(updated), expected) + self.assertEqual(secret['data']['key.pem'], 'synthetic-key') + + def test_legacy_callback_upgrade_preserves_bytes_and_is_idempotent(self): + legacy = dict(rollout.portal.CLIENT, redirectUris=[ + 'https://users.92-205-62-239.nip.io/oidc/callback']) + secret, raw = self.fixture([{'clientId': 'existing', 'clientType': 'public'}, legacy]) + encoded, changed = rollout.replacement(secret) + updated = base64.b64decode(encoded).decode() + self.assertTrue(changed) + self.assertEqual(updated.replace(' - https://users.coulomb.social/oidc/callback\n', ''), raw) + self.assertEqual(yaml.safe_load(updated)['clients'][-1], rollout.portal.CLIENT) + secret['data']['config.yaml'] = encoded + self.assertEqual(rollout.replacement(secret), (encoded, False)) + + def test_upgrade_refuses_extra_scope_or_unreviewed_callback(self): + for extra in [dict(allowedScopes=['openid', 'admin']), + dict(redirectUris=['https://users.coulomb.social/oidc/callback']), + dict(clientType='confidential')]: + with self.subTest(extra=extra), self.assertRaises(rollout.Refused): + rollout.replacement(self.fixture([dict(rollout.portal.CLIENT, **extra)])[0]) + + def test_exact_existing_registration_is_noop(self): + secret, _ = self.fixture([rollout.portal.CLIENT]) + self.assertEqual(rollout.replacement(secret), (secret['data']['config.yaml'], False)) + + def test_differing_or_duplicate_registration_refused(self): + for clients in [[dict(rollout.portal.CLIENT, redirectUris=['https://other.invalid/callback'])], + [rollout.portal.CLIENT, rollout.portal.CLIENT]]: + with self.subTest(clients=len(clients)), self.assertRaises(rollout.Refused): + rollout.replacement(self.fixture(clients)[0]) + + def test_bootstrap_retains_same_public_registration(self): + clients = json.loads(Path(__file__).with_name('bootstrap-config.json').read_text())['clients'] + self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID], + [rollout.portal.CLIENT]) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py new file mode 100644 index 00000000..cc2d011c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/test_vergabe_client_rollout.py @@ -0,0 +1,34 @@ +import base64 +import importlib.util +from pathlib import Path +import unittest +import yaml + +spec = importlib.util.spec_from_file_location('vergabe', Path(__file__).with_name('vergabe-client-rollout.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class RegistrationTests(unittest.TestCase): + def secret(self, clients): + self.config = {'issuer': 'https://kc.coulomb.social', 'authelia': {'issuer': 'https://auth.coulomb.social'}, + 'clients': clients} + raw = yaml.safe_dump(self.config, sort_keys=False) + return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode()}} + + def test_adds_only_exact_public_client_and_is_idempotent(self): + prior = {'clientId': 'existing', 'clientType': 'public', + 'redirectUris': ['https://existing.example/callback']} + secret = self.secret([prior]) + encoded, changed = module.replacement(secret) + self.assertTrue(changed) + result = yaml.safe_load(base64.b64decode(encoded)) + self.assertEqual(dict(self.config, clients=[prior, module.CLIENT]), result) + secret['data']['config.yaml'] = encoded + self.assertEqual((encoded, False), module.replacement(secret)) + + def test_no_existing_client_rewrite_and_new_binary_invalid_fields_refused(self): + for client in [dict(module.CLIENT, redirectUris=['https://wrong.example/']), + {'clientId': 'existing', 'roles': ['operator']}]: + with self.assertRaises(module.rollout.Refused): + module.replacement(self.secret([client])) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/vergabe-client-rollout.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/vergabe-client-rollout.py new file mode 100644 index 00000000..1875b962 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/vergabe-client-rollout.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Fixed Vergabe registration using the existing guarded KeyCape rollout lane.""" +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('portal_rollout', ROOT / 'portal-client-rollout.py') +rollout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rollout) +CLIENT = { + 'clientId': 'vergabe-demo-company', + 'displayName': 'Vergabe Demo Company', + 'redirectUris': ['https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/'], + 'allowedScopes': ['openid', 'profile', 'groups'], + 'grantTypes': ['authorization_code'], + 'clientType': 'public', +} +# Reuse the owner's byte-preserving insertion, CAS, cluster pin and safe receipts. +# The portal-specific legacy migration branch is explicitly unreachable here. +original_replacement = rollout.replacement +rollout.portal = SimpleNamespace(CLIENT_ID=CLIENT['clientId'], CLIENT=CLIENT) + + +def replacement(secret): + _, config, _ = rollout.pin.issuer_document(secret) + for client in config.get('clients', []): + if 'client_credentials' not in client.get('grantTypes', []): + rollout.require(not client.get('roles') and not client.get('serviceSubject'), + 'browser_service_identity_fields_block_new_binary') + if client.get('clientId') == CLIENT['clientId']: + rollout.require(client == CLIENT, 'existing_vergabe_registration_differs') + return original_replacement(secret) + + +rollout.replacement = replacement + +if __name__ == '__main__': + raise SystemExit(rollout.main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-openbao-client.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-openbao-client.sh new file mode 100755 index 00000000..962ec6d5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-openbao-client.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Verify the live KeyCape config carries the OpenBao admin client and KeyCape +# is serving OIDC discovery after rollout. + +set +x +set -euo pipefail +umask 077 + +NAMESPACE="${KEYCAPE_NAMESPACE:-sso}" +SECRET="${KEYCAPE_CONFIG_SECRET:-keycape-config}" +KUBECTL="${KUBECTL:-kubectl}" +PORT="${KEYCAPE_VERIFY_PORT:-18080}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$KUBECTL" get secret "$SECRET" -n "$NAMESPACE" -o json \ + | python3 "$SCRIPT_DIR/openbao-client-config.py" verify + +PUBLIC_URL="${KEYCAPE_PUBLIC_URL:-https://kc.coulomb.social}" +PUBLIC_AUTHORIZE_URL="${PUBLIC_URL%/}/authorize" + +probe_redirect() { + local label="$1" + local redirect_uri="$2" + local output + output=$( + curl -sS -i -G "$PUBLIC_AUTHORIZE_URL" \ + --data-urlencode "client_id=openbao-admin" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + --data-urlencode "response_type=code" \ + --data-urlencode "scope=openid profile email groups" \ + --data-urlencode "state=netkingdom-openbao-client-probe" \ + --data-urlencode "code_challenge=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ" \ + --data-urlencode "code_challenge_method=S256" \ + 2>&1 || true + ) + + if grep -q '"unknown client_id"' <<<"$output"; then + echo "[FAIL] $PUBLIC_AUTHORIZE_URL rejects openbao-admin with unknown client_id" >&2 + echo " Check DNS for kc.coulomb.social and ensure it reaches the KeyCape ingress that was patched." >&2 + exit 1 + fi + if ! grep -qE '^HTTP/[0-9.]+ 302 ' <<<"$output"; then + echo "[FAIL] $PUBLIC_AUTHORIZE_URL did not accept the $label redirect URI for openbao-admin" >&2 + echo " Redirect URI: $redirect_uri" >&2 + echo " Provider response withheld; use owner-scoped metadata diagnostics." >&2 + exit 1 + fi + echo "[PASS] public KeyCape authorize endpoint accepts $label redirect" +} + +probe_redirect "CLI" "http://localhost:8250/oidc/callback" +probe_redirect "operator-tunneled browser UI netkingdom mount" "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" + +KC_POD=$("$KUBECTL" get pod -n "$NAMESPACE" \ + -l app.kubernetes.io/name=keycape \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + +if [[ -z "$KC_POD" ]]; then + echo "[FAIL] no running KeyCape pod found in namespace $NAMESPACE" >&2 + exit 1 +fi + +PF_LOG=$(mktemp "${TMPDIR:-/tmp}/netkingdom-keycape-verify-XXXXXXXX") + +"$KUBECTL" -n "$NAMESPACE" port-forward --address 127.0.0.1 svc/keycape "$PORT:8080" \ + >"$PF_LOG" 2>&1 & +PF_PID=$! + +cleanup() { + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + rm -f -- "$PF_LOG" +} +trap cleanup EXIT + +for _ in $(seq 1 30); do + if python3 - "$PORT" <<'PY' >/dev/null 2>&1 +import json +import sys +import urllib.request + +port = sys.argv[1] +with urllib.request.urlopen(f"http://127.0.0.1:{port}/.well-known/openid-configuration", timeout=2) as response: + payload = json.load(response) +if not payload.get("issuer"): + raise SystemExit("missing issuer") +PY + then + echo "[PASS] KeyCape discovery endpoint responds via local port-forward to pod $KC_POD" + exit 0 + fi + if ! kill -0 "$PF_PID" 2>/dev/null; then + echo "[FAIL] KeyCape port-forward exited before discovery responded" >&2 + echo "Port-forward diagnostics withheld." >&2 + exit 1 + fi + sleep 1 +done + +echo "[FAIL] KeyCape discovery endpoint did not respond via local port-forward" >&2 +echo "Port-forward diagnostics withheld." >&2 +exit 1 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-user-engine-portal.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-user-engine-portal.py new file mode 100644 index 00000000..828b4766 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/keycape/verify-user-engine-portal.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Read-only native authorize checks; print no state, cookies or auth tokens.""" +import base64 +import hashlib +import json +import secrets +from urllib.error import HTTPError +from urllib.parse import urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, build_opener + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def verify(): + opener = build_opener(NoRedirect) + base = { + "client_id": "user-engine-portal", "response_type": "code", + "redirect_uri": "https://users.92-205-62-239.nip.io/oidc/callback", + "scope": "openid profile email groups", "state": secrets.token_urlsafe(24), + "code_challenge": base64.urlsafe_b64encode( + hashlib.sha256(secrets.token_bytes(32)).digest() + ).rstrip(b"=").decode(), "code_challenge_method": "S256", + } + results = [] + for name in ("valid", "wrong_callback", "missing_pkce"): + params = dict(base) + if name == "wrong_callback": + params["redirect_uri"] = "https://invalid.example/oidc/callback" + if name == "missing_pkce": + params.pop("code_challenge") + params.pop("code_challenge_method") + try: + response = opener.open("https://kc.coulomb.social/authorize?" + urlencode(params), timeout=20) + except HTTPError as error: + response = error + with response: + row = {"check": name, "status": response.code} + if name == "valid": + location = urlsplit(response.headers.get("Location", "")) + row["redirect_origin"] = location.scheme + "://" + location.netloc + row["passed"] = response.code == 302 and row["redirect_origin"] == "https://auth.coulomb.social" + else: + body = json.loads(response.read(4096)) + row.update(error=body.get("error"), feature=body.get("feature")) + expected = "redirect_uri" if name == "wrong_callback" else "missing_pkce" + row["passed"] = response.code == 400 and row["error"] == "invalid_profile_usage" and row["feature"] == expected + results.append(row) + print(json.dumps(results, indent=2)) + return all(row["passed"] for row in results) + + +if __name__ == "__main__": + raise SystemExit(0 if verify() else 1) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/bootstrap-users.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/bootstrap-users.sh new file mode 100755 index 00000000..359c1994 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/bootstrap-users.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md. +set +x +set -euo pipefail +exec python3 "$(dirname -- "$0")/lldap_admin.py" bootstrap "$@" diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/break-glass.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/break-glass.sh new file mode 100755 index 00000000..c2bc7721 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/break-glass.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials. +# Keep this entry point so stale callers fail visibly instead of mutating IAM. +printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2 +exit 2 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-secrets.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-secrets.sh new file mode 100644 index 00000000..d7fa9cc2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-secrets.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Private creation or exact-match verification; rotations require --rotate-key. +set +x +set -euo pipefail +umask 077 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" lldap "$@" diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-user.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-user.sh new file mode 100755 index 00000000..c2bc7721 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/create-user.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials. +# Keep this entry point so stale callers fail visibly instead of mutating IAM. +printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2 +exit 2 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh new file mode 100755 index 00000000..c2bc7721 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials. +# Keep this entry point so stale callers fail visibly instead of mutating IAM. +printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2 +exit 2 diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py new file mode 100644 index 00000000..77ac1207 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Attended repair of one consumer field using an operator-held current password. + +Never reads a Secret payload, changes a provider password, or copies a bundle. +""" +from __future__ import annotations + +import argparse +import base64 +import getpass +import json +import re +import subprocess +import sys + +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', + '--server', 'https://127.0.0.1:16444', '--request-timeout=20s'] +NAMESPACE = 'sso' +SECRET = 'lldap-secrets' +KEY = 'LLDAP_LDAP_USER_PASS' +# The password and returned directory token remain in this child process only. +# Child stdin carries the operator input; no credential is a process argument. +VERIFY = '''import json,os,sys +from urllib.error import HTTPError +from provisioner import LLDAPProvisioner +try: + assert os.environ['LLDAP_URL']=='http://lldap.sso.svc.cluster.local:17170' + p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password']) + token=p._login() + p._gql(token,'query { groups { id } }',{}) +except Exception: + print('directory-check-failed');sys.exit(1) +print('directory-check-passed') +''' +VERIFY_RELOADED = '''import os,sys +from provisioner import LLDAPProvisioner +try: + p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD']) + token=p._login() + p._gql(token,'query { groups { id } }',{}) +except Exception: + print('reloaded-check-failed');sys.exit(1) +print('reloaded-check-passed') +''' + + +class Refused(Exception): + """Fixed safe code only; never carry child output or an exception repr.""" + + +def run(arguments, *, data=None, timeout=30): + try: + result = subprocess.run(KUBE + arguments, input=data, text=True, + capture_output=True, timeout=timeout) + except (OSError, subprocess.TimeoutExpired): + raise Refused('command_unavailable') from None + if result.returncode: + raise Refused('command_refused') from None + return result.stdout.strip() + + +def inspect(): + uid = run(['get', 'namespace', 'kube-system', '-o', 'jsonpath={.metadata.uid}']) + if uid != CLUSTER_UID: + raise Refused('wrong_cluster') + # Do not include annotations: last-applied configuration can contain data. + fields = run(['-n', NAMESPACE, 'get', 'secret', SECRET, '-o', + 'go-template={{.metadata.uid}} {{.metadata.resourceVersion}} {{if .metadata.ownerReferences}}{{len .metadata.ownerReferences}}{{else}}0{{end}}']).split() + if len(fields) != 3 or not re.fullmatch(r'[a-f0-9-]{36}', fields[0]) or not fields[1].isdigit(): + raise Refused('invalid_metadata') + if fields[2] != '0': + raise Refused('controller_owned_secret') + return {'uid': fields[0], 'resource_version': fields[1]} + + +def patch(password, metadata): + return [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/' + KEY, + 'value': base64.b64encode(password.encode()).decode()}, + ] + + +def reconcile(password, *, mode, expected_uid=None, expected_resource_version=None): + if not password or len(password) > 4096 or '\x00' in password: + raise Refused('invalid_protected_input') + metadata = inspect() + if mode == 'apply' and metadata != {'uid': expected_uid, 'resource_version': expected_resource_version}: + raise Refused('stale_metadata') + result = run(['-n', NAMESPACE, 'exec', '-i', 'deployment/identity-provisioner', + '--', 'python', '-c', VERIFY], data=json.dumps({'password': password})) + if result != 'directory-check-passed': + raise Refused('current_provider_credential_unverified') + if mode == 'check': + return {'result': 'checked', 'provider_login': True, 'secret_written': False, **metadata} + payload = json.dumps(patch(password, metadata)) + command = ['-n', NAMESPACE, 'patch', 'secret', SECRET, '--type=json', + '--patch-file=/dev/stdin', '-o', 'name'] + run(command + ['--dry-run=server'], data=payload) + run(command, data=payload) + # Do not restore the known nonworking field on failure. Report partial repair. + try: + run(['-n', NAMESPACE, 'rollout', 'restart', 'deployment/identity-provisioner']) + run(['-n', NAMESPACE, 'rollout', 'status', 'deployment/identity-provisioner', + '--timeout=120s'], timeout=150) + result = run(['-n', NAMESPACE, 'exec', 'deployment/identity-provisioner', + '--', 'python', '-c', VERIFY_RELOADED]) + if result != 'reloaded-check-passed': + raise Refused('consumer_verification_failed') + except Refused: + raise Refused('field_updated_consumer_verification_incomplete') from None + return {'result': 'reconciled', 'provider_login': True, 'consumer_login': True, + 'provider_password_changed': False, 'secret_field': KEY, **inspect()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['inspect', 'check', 'apply']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + args = parser.parse_args() + try: + if args.mode == 'inspect': + result = {'result': 'inspected', **inspect()} + else: + if args.mode == 'apply' and not (args.expected_uid and args.expected_resource_version): + raise Refused('exact_metadata_required') + if not sys.stdin.isatty(): + raise Refused('attended_terminal_required') + password = getpass.getpass('Current working LLDAP admin password (hidden): ') + result = reconcile(password, mode=args.mode, expected_uid=args.expected_uid, + expected_resource_version=args.expected_resource_version) + del password + print(json.dumps(result, sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'result': 'refused', 'code': str(error)})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/lldap_admin.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/lldap_admin.py new file mode 100644 index 00000000..0f846dbe --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/lldap_admin.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Owner-invoked LDAP group maintenance; no credential discovery or authority grant.""" +import argparse +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / 'identity-provisioner')) +from provisioner import LLDAPProvisioner, _service_group_request # noqa: E402 + +BASELINE = ('net-kingdom-users', 'net-kingdom-admins', 'activity-core-operators') +MAX_BODY = 1024 * 1024 + + +class Refused(Exception): + pass + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise Refused() + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused() + result[key] = value + return result + + +def endpoint(value): + if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value): + raise Refused() + parsed = urllib.parse.urlsplit(value) + if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'): + raise Refused() + if value.rstrip('/') in ('https://lldap.coulomb.social', 'https://lldap.coulomb.social:443'): + return value.rstrip('/') + if parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https') or parsed.port is None: + raise Refused() + if not 1 <= parsed.port <= 65535: + raise Refused() + expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}' + if value.rstrip('/') != expected: + raise Refused() + return expected + + +def private_password(root): + root = Path(os.path.abspath(root)) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in (root, *root.parents)): + raise Refused() + spec = importlib.util.spec_from_file_location('lldap_custody', ROOT / 'sso-mfa/bootstrap/apply-service-secrets.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with module.custody(root, ['lldap']) as directories: + value = module.read_env(directories['lldap']).get('LLDAP_LDAP_USER_PASS') + if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value): + raise Refused() + return value + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +class Transport: + """Inject bounded, origin-checked transport into the existing provider.""" + def __init__(self, base): + self.base = endpoint(base) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile) + self.opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=context), + ) + + def __call__(self, request, timeout): + if request.full_url not in {self.base + '/auth/simple/login', self.base + '/api/graphql'}: + raise Refused() + if request.method != 'POST' or not isinstance(request.data, bytes) or len(request.data) > MAX_BODY: + raise Refused() + try: + with self.opener.open(request, timeout=min(timeout, 15)) as response: + if response.status != 200: + raise Refused() + raw = response.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: + raise Refused() + body = json.loads(raw, object_pairs_hook=unique_object) + if not isinstance(body, dict): + raise Refused() + if request.full_url.endswith('/auth/simple/login'): + token = body.get('token') + if not isinstance(token, str) or not 1 <= len(token) <= 16384 or any(not 33 <= ord(c) <= 126 for c in token): + raise Refused() + return io.BytesIO(raw) + except urllib.error.HTTPError as exc: + exc.close() + raise Refused() from None + except Exception: + raise Refused() from None + + +def snapshot(provider, token): + value = provider._gql(token, 'query { users { id } groups { id displayName users { id } } }', {}) + if set(value) != {'users', 'groups'} or not isinstance(value['users'], list) or not isinstance(value['groups'], list): + raise Refused() + if len(value['users']) > 10000 or len(value['groups']) > 1000: + raise Refused() + users = [] + for user in value['users']: + uid = user.get('id') if isinstance(user, dict) else None + if not isinstance(uid, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', uid): + raise Refused() + users.append(uid) + if len(set(users)) != len(users): + raise Refused() + groups = {} + ids = set() + for group in value['groups']: + if not isinstance(group, dict): + raise Refused() + name, gid, members = group.get('displayName'), group.get('id'), group.get('users') + if (not isinstance(name, str) or not name or len(name) > 256 or any(ord(c) < 32 for c in name) + or type(gid) is not int or gid < 1 or not isinstance(members, list) + or name in groups or gid in ids or len(members) > 10000): + raise Refused() + member_ids = [member.get('id') if isinstance(member, dict) else None for member in members] + if any(not isinstance(uid, str) or uid not in users for uid in member_ids) or len(set(member_ids)) != len(member_ids): + raise Refused() + groups[name] = {'id': gid, 'members': sorted(member_ids)} + ids.add(gid) + return {'users': sorted(users), 'groups': groups} + + +def validate_operation(operation, action=None, group=None, user=None): + if operation not in {'bootstrap', 'membership', 'verify', 'groups'}: + raise Refused() + if operation == 'groups': + # Read-only sign-in diagnosis preview (docs/sign-in-diagnosis.md). + if action is not None or group is not None or not isinstance(user, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', user): + raise Refused() + return + if operation != 'membership': + if any(v is not None for v in (action, group, user)): + raise Refused() + return + if action not in {'add', 'remove', 'list'} or not isinstance(group, str): + raise Refused() + if action == 'list': + if user is not None: + raise Refused() + members = {} + else: + members = {group: [user]} + _service_group_request({'groups': [group], 'members': members, + 'idempotency_key': 'owner-group-maintenance', 'correlation_id': 'owner-group-maintenance'}) + + +def maintain(provider, base, operation, *, action=None, group=None, user=None, apply=False, expected_digest=None): + validate_operation(operation, action, group, user) + if (apply and (operation in ('verify', 'groups') or action == 'list')) or (expected_digest is not None and not apply): + raise Refused() + if apply and (not isinstance(expected_digest, str) or not re.fullmatch(r'[0-9a-f]{64}', expected_digest)): + raise Refused() + token = provider._login() + before = snapshot(provider, token) + if operation == 'verify': + present = {name: name in before['groups'] for name in BASELINE} + return {'result': 'structure_observed', 'baseline_groups_present': present, + 'directory_user_count': len(before['users']), + 'break_glass_account_present': 'break-glass' in before['users'], + 'break_glass_platform_membership_present': 'break-glass' in before['groups'].get('net-kingdom-admins', {}).get('members', []), + 'native_acceptance': 'unverified', + 'remaining': ['governed_lifecycle', 'actual_login_and_mfa', 'issued_claims_and_consumer_access', + 'session_revocation', 'independent_recovery']} + if operation == 'groups': + if user not in before['users']: + raise Refused() + names = sorted(name for name, item in before['groups'].items() if user in item['members']) + return {'result': 'groups_observed', 'uid': user, 'groups': names, 'native_acceptance': 'unverified'} + if operation == 'membership' and (group not in before['groups'] or (user is not None and user not in before['users'])): + raise Refused() + binding = {'base': endpoint(base), 'operation': operation, 'action': action, 'group': group, 'user': user, 'before': before} + digest = hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + if operation == 'bootstrap': + changes = [{'create_group': name} for name in BASELINE if name not in before['groups']] + elif action == 'list': + return {'result': 'membership_observed', 'group': group, 'members': before['groups'][group]['members'], 'native_acceptance': 'unverified'} + else: + member = user in before['groups'][group]['members'] + changes = [{'action': action, 'group': group, 'user': user}] if member != (action == 'add') else [] + result = {'operation': operation, 'changes': changes, 'snapshot_sha256': digest, 'native_acceptance': 'unverified'} + if not apply: + return dict(result, result='preview') + if digest != expected_digest: + raise Refused() + # LLDAP has no compare-and-swap transaction here. Caller must hold the + # owner-controlled exclusive window; check again before the first mutation. + if snapshot(provider, token) != before: + raise Refused() + if operation == 'bootstrap': + rows = [{'displayName': name, 'id': item['id']} for name, item in before['groups'].items()] + for change in changes: + provider._ensure_group(token, rows, change['create_group']) + elif changes: + method = provider._add_group if action == 'add' else provider._remove_group + method(token, user, before['groups'][group]['id']) + after = snapshot(provider, token) + expected = json.loads(json.dumps(before)) + if operation == 'bootstrap': + for change in changes: + name = change['create_group'] + if name not in after['groups'] or after['groups'][name]['members']: + raise Refused() + expected['groups'][name] = after['groups'][name] + elif changes: + members = set(expected['groups'][group]['members']) + if action == 'add': + members.add(user) + else: + members.discard(user) + expected['groups'][group]['members'] = sorted(members) + if after != expected: + raise Refused() + return dict(result, result='verified', recovery='no_automatic_rollback') + + +def main(argv=None): + phase = 'input' + try: + parser = Parser(description=__doc__) + sub = parser.add_subparsers(dest='operation', required=True, parser_class=Parser) + for operation in ('bootstrap', 'membership', 'verify', 'groups'): + command = sub.add_parser(operation) + command.add_argument('--root', required=True, help='Explicit private custody directory outside Git') + command.add_argument('--lldap-url', default='https://lldap.coulomb.social') + if operation == 'groups': + command.add_argument('--user', required=True) + elif operation != 'verify': + command.add_argument('--apply', action='store_true') + command.add_argument('--expected-digest') + if operation == 'membership': + command.add_argument('action', choices=('add', 'remove', 'list')) + command.add_argument('--group', required=True) + command.add_argument('--user') + args = parser.parse_args(argv) + base = endpoint(args.lldap_url) + action, group, user = (getattr(args, name, None) for name in ('action', 'group', 'user')) + validate_operation(args.operation, action, group, user) + apply = getattr(args, 'apply', False) + expected = getattr(args, 'expected_digest', None) + if (expected is not None and not apply) or (apply and (action == 'list' or not isinstance(expected, str) or not re.fullmatch(r'[0-9a-f]{64}', expected))): + raise Refused() + transport = Transport(base) + password = private_password(args.root) + provider = LLDAPProvisioner(base_url=base, admin_password=password, opener=transport) + phase = 'maintenance' if apply else 'observation' + result = maintain(provider, base, args.operation, action=action, group=group, user=user, apply=apply, expected_digest=expected) + print(json.dumps(result, sort_keys=True)) + return 2 if args.operation == 'verify' else 0 + except Exception: + print(json.dumps({'result': 'unverified', 'phase': phase, 'recovery': 'reconcile_current_state_before_retry'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/manage-group-members.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/manage-group-members.sh new file mode 100755 index 00000000..3d158aad --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/manage-group-members.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md. +set +x +set -euo pipefail +exec python3 "$(dirname -- "$0")/lldap_admin.py" membership "$@" diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py new file mode 100644 index 00000000..cd03871d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py @@ -0,0 +1,74 @@ +import base64 +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + +spec=importlib.util.spec_from_file_location('repair',Path(__file__).with_name('identity-provisioner-reconcile.py')) +repair=importlib.util.module_from_spec(spec) +spec.loader.exec_module(repair) +META={'uid':'c6a9e6be-5bb5-47e6-9faa-06b8d72afec3','resource_version':'1234'} +SYNTHETIC='synthetic-only-current-password' + + +class ReconcileTests(unittest.TestCase): + def test_patch_has_exact_guards_and_changes_only_the_named_field(self): + changes=repair.patch(SYNTHETIC,META) + self.assertEqual(['test','test','replace'],[x['op'] for x in changes]) + self.assertEqual('/metadata/uid',changes[0]['path']) + self.assertEqual('/metadata/resourceVersion',changes[1]['path']) + self.assertEqual('/data/LLDAP_LDAP_USER_PASS',changes[2]['path']) + self.assertEqual(SYNTHETIC,base64.b64decode(changes[2]['value']).decode()) + + def test_stale_metadata_stops_before_credential_use(self): + with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run') as run: + with self.assertRaisesRegex(repair.Refused,'stale_metadata'): + repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version='old') + run.assert_not_called() + + def test_provider_refusal_stops_before_any_write(self): + with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=repair.Refused('command_refused')) as run: + with self.assertRaises(repair.Refused): + repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version']) + self.assertEqual(1,run.call_count) + self.assertNotIn('patch',run.call_args.args[0]) + + def test_check_does_not_write_and_keeps_password_out_of_arguments(self): + with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',return_value='directory-check-passed') as run: + result=repair.reconcile(SYNTHETIC,mode='check') + self.assertFalse(result['secret_written']) + self.assertNotIn(SYNTHETIC,str(run.call_args.args)) + self.assertNotIn(SYNTHETIC,json.dumps(result)) + self.assertEqual(SYNTHETIC,json.loads(run.call_args.kwargs['data'])['password']) + + def test_apply_dry_runs_then_writes_reloads_and_verifies(self): + results=['directory-check-passed','secret/lldap-secrets','secret/lldap-secrets','restarted','ready','reloaded-check-passed'] + with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=results) as run: + result=repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version']) + self.assertEqual('reconciled',result['result']) + self.assertFalse(result['provider_password_changed']) + calls=run.call_args_list + self.assertIn('--dry-run=server',calls[1].args[0]) + self.assertNotIn('--dry-run=server',calls[2].args[0]) + for call in calls: + self.assertNotIn(SYNTHETIC,str(call.args)) + self.assertNotIn(base64.b64encode(SYNTHETIC.encode()).decode(),str(call.args)) + + def test_child_errors_never_reveal_output(self): + failed=subprocess.CompletedProcess([],1,stdout=SYNTHETIC,stderr=SYNTHETIC) + with patch.object(subprocess,'run',return_value=failed): + with self.assertRaises(repair.Refused) as result: + repair.run(['anything']) + self.assertEqual('command_refused',str(result.exception)) + + def test_wrong_cluster_and_controller_owner_refused(self): + with patch.object(repair,'run',return_value='foreign-cluster'): + with self.assertRaisesRegex(repair.Refused,'wrong_cluster'): repair.inspect() + with patch.object(repair,'run',side_effect=[repair.CLUSTER_UID,META['uid']+' 1234 1']): + with self.assertRaisesRegex(repair.Refused,'controller_owned_secret'): repair.inspect() + + +if __name__=='__main__': unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/postgresql/create-secrets.sh b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/postgresql/create-secrets.sh new file mode 100755 index 00000000..793bda75 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/postgresql/create-secrets.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Private creation or exact-match verification; rotations require --rotate-key. +set +x +set -euo pipefail +umask 077 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" postgresql "$@" diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/activate-onboarding-policy.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/activate-onboarding-policy.py new file mode 100644 index 00000000..65c8ab69 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/activate-onboarding-policy.py @@ -0,0 +1,25 @@ +"""Provider-local activation after the guarded deployment; never changes tokens.""" +import contextlib,io,json,logging +result={'success':False} +with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + try: + logging.disable(logging.CRITICAL) + from privacyidea.app import create_app + from privacyidea.lib.policy import PolicyClass,set_policy + app=create_app(config_name='production',silent=True) + with app.app_context(): + if app.config.get('PI_INIT_CHECK_HOOK')!='keycape_onboarding_guard.check':raise ValueError('hook_not_active') + from keycape_onboarding_guard import check + rows=[r for r in PolicyClass().policies if r['scope']=='user' and r['active']] + expected=next(r for r in rows if r['name']=='totp-self-enrollment') + if expected['realm']!=['coulomb'] or expected.get('conditions') or expected['action'] not in [{'enrollTOTP':True,'delete':True,'disable':True},{'enrollTOTP':True}]:raise ValueError('self_service_policy_changed') + if any(r['name'] not in {'totp-self-enrollment','keycape-pending-enrollment-cancel'} and (not r['realm'] or 'coulomb' in r['realm']) for r in rows):raise ValueError('additional_user_policy_requires_review') + set_policy(name='totp-self-enrollment',scope='user',action='enrollTOTP',realm='coulomb') + set_policy(name='keycape-pending-enrollment-cancel',scope='user',action='delete',realm='coulomb',conditions=[('token','rollout_state','equals','verify',True)]) + fresh=PolicyClass().policies + active=next(r for r in fresh if r['name']=='totp-self-enrollment') + pending=next(r for r in fresh if r['name']=='keycape-pending-enrollment-cancel') + if active['action']!={'enrollTOTP':True} or not pending['conditions']:raise ValueError('readback_failed') + result={'success':True,'hook':'keycape_onboarding_guard.check','self_service_enrollment':True,'pending_cancel_only':True,'active_factor_changes_require_recovery':True,'existing_tokens_changed':False} + except Exception as error:result={'success':False,'failure_type':type(error).__name__} +print(json.dumps(result));raise SystemExit(0 if result['success'] else 1) diff --git a/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py new file mode 100644 index 00000000..97a6b5dd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/test-root/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py @@ -0,0 +1,100 @@ +"""Version-scoped in-pod bootstrap. Password input is JSON on stdin only. + +Existing credentials/policies are never updated. Insert-only DB writes and +unique constraints protect against concurrent creation. Native MFA/effective +policy acceptance is a separate attended procedure. +""" +import json +import logging +import re +import sys + +NAME = 'pi-admin' +POLICY = 'pi-admin-all-rights' + + +class Refused(Exception): + pass + + +def admitted_policy(policy): + if (policy.get('name') != POLICY or policy.get('scope') != 'admin' + or policy.get('adminuser') != [NAME] or policy.get('action') != {'*': True} + or policy.get('active') is not True or policy.get('priority') != 1 + or policy.get('check_all_resolvers') is not False + or policy.get('user_case_insensitive') is not False): + return False + return all(not policy.get(key) for key in ( + 'realm', 'adminrealm', 'resolver', 'pinode', 'user', 'client', 'time', 'conditions', 'user_agents')) + + +def ensure(password, admins, policies, verify, insert): + existing = NAME in admins + named = [policy for policy in policies if policy.get('name') == POLICY] + if len(named) > 1 or (named and not admitted_policy(named[0])): raise Refused() + if not named: + # Introducing the first admin policy changes defaults for every admin. + # Existing deployments need their owner's policy migration, not bootstrap. + if any(admin != NAME for admin in admins) or any(p.get('scope') == 'admin' for p in policies): + raise Refused() + if existing and not verify(NAME, password): raise Refused() + if not existing or not named: + insert(password, account=not existing, policy=not named) + if not verify(NAME, password): raise Refused() + return {'result': 'verified' if existing else 'created', 'mfa_verified': False} + + +def main(): + from importlib.metadata import version + if version('privacyIDEA') != '3.12.2': raise Refused() + raw = sys.stdin.buffer.read(1025) + if len(raw) > 1024: raise Refused() + payload = json.loads(raw) + if (not isinstance(payload, dict) or set(payload) != {'password'} + or not isinstance(payload['password'], str) + or not re.fullmatch(r'[A-Za-z0-9]{32,128}', payload['password'])): + raise Refused() + # Avoid library diagnostics carrying credential/configuration data. + logging.disable(logging.CRITICAL) + from privacyidea.cli import create_silent_app + from privacyidea.lib.auth import list_db_admin, verify_db_admin + from privacyidea.lib.crypto import hash_with_pepper + from privacyidea.lib.policy import get_policies + from privacyidea.models import Admin, Policy, db + from privacyidea.models.config import save_config_timestamp + app = create_silent_app() + with app.app_context(): + # Username-only bootstrap policy must not admit namesakes in other realms. + if app.config.get('SUPERUSER_REALM', []): raise Refused() + def insert(password, *, account, policy): + try: + # Do not call Admin.save/create_db_admin or set_policy: they upsert. + if account: + db.session.add(Admin(username=NAME, password=hash_with_pepper(password))) + if policy: + db.session.add(Policy(name=POLICY, scope='admin', action='*', adminuser=NAME)) + db.session.flush() # constraint conflicts fail without updates + if policy: save_config_timestamp() + db.session.commit() + except Exception: + db.session.rollback() + raise Refused() from None + result = ensure(payload['password'], [admin.username for admin in list_db_admin()], + get_policies(), verify_db_admin, insert) + if not any(admitted_policy(p) for p in get_policies()): raise Refused() + return result + + +if __name__ == '__main__': + # Only the fixed receipt may reach the caller; suppress all library output. + import os + receipt_fd = os.dup(1) + with open(os.devnull, 'w') as sink: + os.dup2(sink.fileno(), 1); os.dup2(sink.fileno(), 2) + try: + result = main() + os.write(receipt_fd, (json.dumps(result) + '\n').encode()) + except Exception: + sys.exit(1) + finally: + os.close(receipt_fd)