diff --git a/docs/release/native-password-reset-2026-10-30.json b/docs/release/native-password-reset-2026-10-30.json new file mode 100644 index 0000000..409c782 --- /dev/null +++ b/docs/release/native-password-reset-2026-10-30.json @@ -0,0 +1,76 @@ +{ + "schema": "coulomb.native-reset-prerequisite.v1", + "data": "synthetic", + "status": "native_bind_role_reset_boundary_verified", + "observed_at": "2026-10-30T22:26:16.456435+00:00", + "images": { + "lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54", + "probe": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + }, + "production_access": false, + "browser_app_or_mfa_acceptance": false, + "provider_smtp_contacted": false, + "notifier": "private_tmpfs_file", + "no_ports_host_mounts_or_external_routes": true, + "backend_container_flags_verified_before_fixture_import": true, + "owner_runtime_or_permission_change_performed": false, + "findings": { + "scenarios": [ + { + "bind_role": "lldap_strict_readonly", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false, + "directory_group_creation_refused": true, + "native_reset_notification_present": true + }, + { + "bind_role": "lldap_password_manager", + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true, + "directory_group_creation_refused": true, + "native_reset_notification_present": true + } + ], + "cleanup_verified": true + }, + "source_manifest_sha256": "b03f0ce4132a741e4bf90e5e2bd15d148c0490bd95d98fd5cb7b68e0e9dc8d35", + "full_gate": { + "command": "make check", + "passed": 396, + "skipped": 0, + "failures": 0, + "errors": 0, + "mandatory_browser_journeys_included": true, + "junit_sha256": "5028e448de19682e92da3820d519a558a1cf294adf5c21084a23b51fcaae57bf" + }, + "cli_refusals_verified": [ + "optimized_assertions", + "existing_receipt", + "existing_source_manifest" + ] +} diff --git a/docs/release/native-password-reset-2026-10-30.sources.txt b/docs/release/native-password-reset-2026-10-30.sources.txt new file mode 100644 index 0000000..d9ab2cc --- /dev/null +++ b/docs/release/native-password-reset-2026-10-30.sources.txt @@ -0,0 +1,2 @@ +scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d +scripts/native_recovery/probe.py: 68f70d3702b411ecc3506da6e0f8f76accf8e336297221fa196465b1c4ac3f5b diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md new file mode 100644 index 0000000..4b9367b --- /dev/null +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -0,0 +1,91 @@ +# Native password-reset bind prerequisite + +WP-0015-T04 remains open. An isolated native Authelia/LLDAP test identifies a +directory permission prerequisite before the full UC-009 browser journey can be +accepted. It does not change the existing personal reset acceptance, prove the +currently installed bind's privileges or authorize any live permission change. + +The inspected NetKingdom source assigns `svc-authelia` to +`lldap_strict_readonly` in `sso-mfa/k8s/lldap/bind-split.py`. The Authelia +ConfigMap binds as that account. The owner rehearsal checks first-factor login, +which succeeds under this role. Password reset needs a separate write operation; +first-factor success cannot establish recovery readiness. + +`scripts/rehearse-native-password-reset.py` now runs the actual pinned Authelia +4.39.28 and LLDAP 0.6.2 binaries against a fresh synthetic directory. It tests +the read-only role and then the password-manager role on a dedicated fixture +account, with a fresh Authelia store for each case. No owner checkout is changed. + +| Check | Strict read-only bind | Password-manager bind | +| --- | --- | --- | +| Human first-factor login | Succeeds | Succeeds | +| Native reset notification generated | Yes | Yes | +| Valid reset-link identity verification | Succeeds | Succeeds | +| Password change | Refused; HTTP 200 with `status: KO` | Succeeds; HTTP 200 with empty body | +| Old directory password afterward | Still accepted | Refused | +| Replacement directory password afterward | Refused | Accepted | +| Consumed reset-link replay | Refused | Refused | +| Reset without verified challenge | Refused | Refused | +| Interactive Authelia login as service bind | Refused by the service-account filter | Refused by the same filter | +| Directory group creation using the service identity | Refused | Refused | + +The test checks native response status **and actual directory authentication**. +An HTTP 200 alone would misclassify the read-only failure. Usable handles, +cookies, passwords and notification text remain private and are absent from +the public receipt. The password-manager result concerns an ordinary synthetic +human; it does not establish privileged-account recovery or every directory right. + +[Authelia's LLDAP integration documentation](https://www.authelia.com/integration/ldap/lldap/) +requires the bind to support password changes when reset is desired. The pinned +[native reset handler](https://raw.githubusercontent.com/authelia/authelia/v4.39.28/internal/handlers/handler_reset_password.go) +performs that update after identity verification. These native test results +confirm the boundary without relying on a protocol double. + +## Isolation and repetition + +```sh +python3 scripts/rehearse-native-password-reset.py \ + --output /tmp/fresh-native-reset-receipt.json +``` + +The command requires the three retained image digests named in its source; it +never pulls. The product image supplies only the Python standard-library probe; +its application entrypoint and Django are not started. The test uses the default +local Unix Docker socket, a unique network-none +directory container, and Authelia/probes sharing only that container's loopback +namespace. Backend containers are inspected before fixture import: read-only +root, nonroot UID, dropped capabilities, no new privileges, bounded CPU/memory, +no published ports, no devices and exclusively private tmpfs mounts. The probe +also checks that only loopback exists and no external IPv4 routes exist. + +Only generated synthetic credentials are delivered through pipes into tmpfs. +There is no live configuration, production data, key custody, SMTP provider, +external DNS reachability, host mount, persistent volume or network bridge. Native notifications +use Authelia's private filesystem notifier, not an SMTP/inbox delivery claim. +The API probe emulates the local proxy's forwarded headers and secure-cookie +transport; it is not browser or TLS verification. All backend/probe containers +are removed and their absence checked, including on failures. Failed fixture +startup leaves only a private redacted diagnostic on the workstation. + +Retain the JSON receipt with its adjacent `.sources.txt` manifest. The JSON binds +that manifest's hash; the manifest contains only canonical filename/content +digests. Existing outputs refuse. Assertions must be enabled. Negative CLI checks +prove that optimized assertions and either existing output refuse without +overwriting evidence. Ruff and the native rehearsal pass. The full `make check` +gate was also rerun:396 passed, zero failures/errors/skips, including required browsers. + +## Required owner return and remaining journey + +Keep the bind's read and password-reset responsibilities explicit. NetKingdom +must review a supported narrow reset authority or another supported recovery +path under its existing NK-WP-0054-T05 bind-split work, including any privileged +account limits. Do not silently promote the production bind to directory admin +or infer that the synthetic password-manager grant is approved for production. +The source-role mismatch is retained on the existing WP-0015-T04; it is not a +new credential request or a duplicated provider implementation task. + +After that owner return, complete isolated native browser reset/expiry/replay, +local mail-sink delivery, KeyCape/client/tenant isolation and real app login. +The current test proves the native reset prerequisite only. It proves no OIDC +issuance, MFA ceremony, TLS/browser flow, application session or productive +runtime/permission acceptance. UC-009 remains `established: false`. diff --git a/docs/usecases/platform-communication.md b/docs/usecases/platform-communication.md index 6235413..f67b946 100644 --- a/docs/usecases/platform-communication.md +++ b/docs/usecases/platform-communication.md @@ -190,3 +190,16 @@ profile enrichment and valid-token login when UserInfo is unavailable. Registry links remain under candidate UC-009; neither full native reset coverage nor productive release is inferred. Review and exact gate/source evidence: `../release/oidc-userinfo-binding-review-2026-10-30.md` and its JSON companion. + +## Native reset bind prerequisite 2026-10-30 + +The additional isolated native Authelia/LLDAP rehearsal reproduces a bind-role +gap: strict read-only lookup permits sign-in and reset-link verification but +refuses the password update; an ordinary synthetic human's reset succeeds with +a password-manager bind. Old/new directory password checks, consumed-link replay, +unverified-reset refusal and service/group-creation denials are real native checks. +No installed role, live permission or productive verdict is inferred. Review, +repetition and limits: `../release/native-password-reset-review-2026-10-30.md`. +NetKingdom's existing bind-split work owns the supported reset-authority return. +This filesystem-notifier/API prerequisite does not replace the still-open full +isolated browser/delivery/KeyCape/app journey or promote candidate UC-009. diff --git a/scripts/native_recovery/probe.py b/scripts/native_recovery/probe.py new file mode 100644 index 0000000..425a8b2 --- /dev/null +++ b/scripts/native_recovery/probe.py @@ -0,0 +1,131 @@ +"""Synthetic native peers only; stdin is private, stdout is bounded metadata.""" + +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +def run(v): + assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'} + assert len(Path('/proc/net/route').read_text().splitlines()) == 1 + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + cookie = '' + + def call(port, path, data=None, *, token=None, auth=False): + nonlocal cookie + assert port in {17170, 9091} and path.startswith('/') + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = 'Bearer ' + token + if auth: + headers.update({'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'auth.recovery.test', + 'X-Forwarded-For': '127.0.0.1', 'Cookie': cookie}) + request = urllib.request.Request( + f'http://127.0.0.1:{port}' + path, + data=json.dumps(data).encode() if data is not None else None, headers=headers) + try: + response = opener.open(request, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + if auth and response.headers.get('Set-Cookie'): + cookie = response.headers['Set-Cookie'].split(';', 1)[0] + raw = response.read() + return response.status, ({} if path == '/health' or response.status >= 400 + else json.loads(raw or b'{}')) + + def ready(port, path): + for _ in range(120): + try: + if call(port, path, auth=port == 9091)[0] == 200: + return + except (OSError, ValueError): + pass + time.sleep(.25) + raise RuntimeError('peer_startup_' + str(port)) + + def login(user, password): + code, body = call(17170, '/auth/simple/login', {'username': user, 'password': password}) + return body.get('token') if code == 200 else None + + def graphql(token, query, variables=None): + code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables or {}}, + token=token) + assert code == 200 and not body.get('errors'), 'directory_mutation_refused' + return body['data'] + + ready(17170, '/health') + admin = login('admin', v['admin_password']) + assert admin, 'synthetic_admin_login' + if v['mode'] == 'bootstrap': + for user in ['alice', 'svc-auth']: + graphql(admin, 'mutation($u:CreateUserInput!){createUser(user:$u){id}}', { + 'u': {'id': user, 'email': user + '@example.test', 'displayName': user}}) + return {'status': 'prepared', 'admin_token': admin} + if v['mode'] == 'bind_role': + groups = graphql(admin, '{groups{id displayName}}')['groups'] + memberships = {g['id'] for g in graphql(admin, + 'query($u:String!){user(userId:$u){groups{id}}}', + {'u': 'svc-auth'})['user']['groups']} + for group in ['lldap_strict_readonly', 'lldap_password_manager']: + group_id = next(g['id'] for g in groups if g['displayName'] == group) + if (group == v['role']) == (group_id in memberships): + continue + operation = 'addUserToGroup' if group == v['role'] else 'removeUserFromGroup' + graphql(admin, 'mutation($u:String!,$g:Int!){' + operation + + '(userId:$u,groupId:$g){ok}}', {'u': 'svc-auth', 'g': group_id}) + service = login('svc-auth', v['bind_password']) + assert service, 'service_directory_login' + code, body = call(17170, '/api/graphql', { + 'query': 'mutation{createGroup(name:"unexpected-admin-rights"){id}}'}, token=service) + assert code >= 400 or body.get('errors'), 'directory_group_creation_refused' + return {'status': 'role_set', 'directory_group_creation_refused': True} + ready(9091, '/api/health') + if v['mode'] == 'reset_finish': + code, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True) + assert code != 200 or body.get('status') == 'KO', 'reset_without_challenge_refused' + code, body = call(9091, '/api/reset-password/identity/finish', + {'token': v['reset_token']}, auth=True) + assert code == 200 and body.get('status') == 'OK', 'identity_finish' + replay, body = call(9091, '/api/reset-password/identity/finish', + {'token': v['reset_token']}, auth=True) + assert replay != 200 or body.get('status') != 'OK', 'identity_replay_refused' + reset, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True) + return {'status': ('reset_completed' if reset == 200 and body.get('status') in {None, 'OK'} + else 'reset_refused'), + 'reset_http_status': reset, 'reset_response_status': body.get('status'), + 'native_identity_finish': True, + 'reset_without_verified_challenge_refused': True, + 'consumed_identity_token_replay_refused': True, + 'old_password_accepted_after': bool(login('alice', v['old_password'])), + 'new_password_accepted_after': bool(login('alice', v['new_password']))} + assert login('alice', v['old_password']), 'initial_password' + assert not login('alice', v['new_password']), 'replacement_not_initially_accepted' + code, body = call(9091, '/api/firstfactor', { + 'username': 'svc-auth', 'password': v['bind_password'], 'keepMeLoggedIn': False}, auth=True) + assert code != 200 or body.get('status') != 'OK', 'interactive_service_login_refused' + code, body = call(9091, '/api/firstfactor', { + 'username': 'alice', 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True) + assert code == 200 and body.get('status') == 'OK', 'native_first_factor' + cookie = '' + code, _ = call(9091, '/api/reset-password/identity/start', {'username': 'alice'}, auth=True) + assert code == 200, 'reset_start' + return {'status': 'reset_requested', 'only_loopback_interface': True, + 'external_routes_absent': True, 'old_password_initially_accepted': True, + 'new_password_initially_refused': True, 'native_first_factor': True, + 'interactive_service_login_refused': True} + + +if __name__ == '__main__': + try: + print(json.dumps(run(json.load(sys.stdin)))) + except Exception as exc: + print(json.dumps({'status': 'probe_failed', 'failure_type': type(exc).__name__, + 'stage': (str(exc) if isinstance(exc, (AssertionError, RuntimeError)) + else 'native_probe')})) + raise SystemExit(1) diff --git a/scripts/rehearse-native-password-reset.py b/scripts/rehearse-native-password-reset.py new file mode 100644 index 0000000..c3e3aa5 --- /dev/null +++ b/scripts/rehearse-native-password-reset.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Synthetic native reset prerequisite, with no ports, host mounts or external routes. + +Native Authelia and LLDAP, not a browser/OIDC/app/MFA acceptance test. +Private fixture values stay in pipes or disposable container tmpfs. +""" + +import argparse +import hashlib +import io +import json +import os +import re +import secrets +import subprocess +import tarfile +import tempfile +import urllib.parse +import uuid +from datetime import UTC, datetime +from pathlib import Path + +AUTHELIA = 'sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54' +LLDAP = 'sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d' +CLIENT = 'sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd' +HERE = Path(__file__).resolve().parent +PROBE = (HERE / 'native_recovery/probe.py').read_text() + + +def docker(*args, data=None, check=True): + result = subprocess.run(['docker', '--context', 'default', *args], input=data, + capture_output=True, timeout=90, + env={'PATH': os.environ['PATH']}) + if check and result.returncode: + raise RuntimeError('docker_' + args[0] + '_refused') + return result + + +def archive(files): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode='w') as tar: + for name, data in files.items(): + item = tarfile.TarInfo(name) + item.size, item.mode, item.uid, item.gid = len(data), 0o600, 1000, 1000 + tar.addfile(item, io.BytesIO(data)) + return buffer.getvalue() + + +def confined(name, network): + value = json.loads(docker('inspect', name).stdout)[0] + host = value['HostConfig'] + if network.startswith('container:'): + anchor = json.loads(docker('inspect', network.removeprefix('container:')).stdout)[0] + network = 'container:' + anchor['Id'] + if (host['NetworkMode'] != network or not host['ReadonlyRootfs'] or host['Privileged'] + or host.get('Binds') or host.get('PortBindings') or host.get('Devices') + or 'ALL' not in host['CapDrop'] or 'no-new-privileges' not in host['SecurityOpt'] + or value['Config']['User'] != '1000:1000' + or any(m['Type'] != 'tmpfs' for m in value['Mounts'])): + raise RuntimeError('native_container_boundary') + + +def container(name, image, network, env): + args = ['run', '-d', '--pull=never', '--name', name, '--network', network, + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--user', '1000:1000', '--memory', '256m', '--cpus', '1', '--pids-limit', '128'] + for path, size in [('/data', 268435456), ('/run/fixture', 1048576), + ('/config', 1048576), ('/tmp', 16777216)]: + args += ['--tmpfs', path + + f':rw,nosuid,nodev,noexec,size={size},uid=1000,gid=1000,mode=0700'] + for key, value in env.items(): + args += ['--env', key + '=' + value] + docker(*args, '--entrypoint', '/bin/sh', image, '-c', 'sleep 1800') + confined(name, network) + + +def probe(anchor, value): + result = docker('run', '-i', '--rm', '--name', anchor + '-probe', '--pull=never', + '--network', 'container:' + anchor, + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--memory', '128m', '--cpus', '1', '--pids-limit', '64', + '--entrypoint', 'python', CLIENT, '-c', PROBE, + data=json.dumps(value).encode(), check=False) + body = json.loads(result.stdout) + if result.returncode: + raise RuntimeError('native_' + body.get('stage', 'probe_failed')) + return body + + +def configuration(bind_password): + return { + 'server': {'address': 'tcp://127.0.0.1:9091', 'disable_healthcheck': True}, + 'log': {'level': 'info'}, 'ntp': {'disable_startup_check': True}, + 'identity_validation': {'reset_password': {'jwt_secret': secrets.token_hex(32)}}, + 'authentication_backend': {'ldap': { + 'implementation': 'lldap', 'address': 'ldap://127.0.0.1:3890', + 'base_dn': 'dc=example,dc=test', 'additional_users_dn': 'ou=people', + 'users_filter': '(&(|({username_attribute}={input})({mail_attribute}={input}))' + '(objectClass=inetOrgPerson)(!({username_attribute}=svc-*)))', + 'additional_groups_dn': 'ou=groups', 'groups_filter': '(member={dn})', + 'user': 'uid=svc-auth,ou=people,dc=example,dc=test', 'password': bind_password}}, + 'access_control': {'default_policy': 'one_factor'}, + 'session': {'secret': secrets.token_hex(32), 'cookies': [{ + 'domain': 'recovery.test', 'authelia_url': 'https://auth.recovery.test'}]}, + 'storage': {'encryption_key': secrets.token_hex(32), + 'local': {'path': '/data/db.sqlite3'}}, + 'notifier': {'filesystem': {'filename': '/data/notifications.txt'}}} + + +def rehearse(): + if not __debug__: + raise RuntimeError('acceptance_assertions_must_be_enabled') + context = json.loads(docker('context', 'inspect', 'default').stdout)[0] + if context['Endpoints']['docker']['Host'] != 'unix:///var/run/docker.sock': + raise RuntimeError('local_docker_socket_required') + for image in (LLDAP, AUTHELIA, CLIENT): + assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image + name = 'csoc-native-reset-' + uuid.uuid4().hex[:12] + auth = name + '-auth' + values = {key: secrets.token_urlsafe(24) for key in [ + 'admin_password', 'bind_password', 'old_password', 'new_password']} + result = {} + try: + container(name, LLDAP, 'none', { + 'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt', + 'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password', + 'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test', + 'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'}) + docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'jwt': secrets.token_hex(32).encode(), + 'admin-password': values['admin_password'].encode(), + 'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = ' + + json.dumps(secrets.token_hex(32)) + '\n').encode()})) + docker('exec', '-d', name, '/bin/sh', '-c', + 'exec /app/lldap run --config-file /run/fixture/lldap.toml ' + '> /run/fixture/server.log 2>&1') + prepared = probe(name, {**values, 'mode': 'bootstrap'}) + for user, password in [('alice', values['old_password']), + ('svc-auth', values['bind_password'])]: + docker('exec', '-i', name, '/bin/sh', '-c', + 'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; ' + 'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 ' + '--token "$token" --username ' + user, + data=(prepared['admin_token'] + '\n' + password + '\n').encode()) + result['scenarios'] = [] + for role in ['lldap_strict_readonly', 'lldap_password_manager']: + assigned = probe(name, {**values, 'mode': 'bind_role', 'role': role}) + container(auth, AUTHELIA, 'container:' + name, {}) + docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'configuration.yml': json.dumps(configuration(values['bind_password'])).encode()})) + docker('exec', '-d', auth, '/bin/sh', '-c', + 'exec /app/authelia --config /run/fixture/configuration.yml ' + '> /run/fixture/server.log 2>&1') + requested = probe(name, {**values, 'mode': 'reset_request'}) + # Notification and usable handle stay private. No inbox/SMTP claim. + notification = docker('exec', auth, 'cat', '/data/notifications.txt').stdout.decode() + links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+', + notification) + assert links, 'native_challenge_missing' + reset_token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[0]).query)['token'][0] + settled = probe(name, {**values, 'mode': 'reset_finish', 'reset_token': reset_token}) + expected = role == 'lldap_password_manager' + assert settled['new_password_accepted_after'] is expected + assert settled['old_password_accepted_after'] is not expected + assert (settled['status'] == 'reset_completed') is expected + result['scenarios'].append({'bind_role': role, **requested, **settled, + 'directory_group_creation_refused': + assigned['directory_group_creation_refused'], + 'native_reset_notification_present': True}) + docker('rm', '-f', '-v', auth) + except Exception: + fd, diagnostic = tempfile.mkstemp(prefix='csoc-native-reset-diagnostic-', suffix='.txt') + with os.fdopen(fd, 'w') as stream: + for current in [name, auth]: + log = docker('exec', current, 'cat', '/run/fixture/server.log', check=False) + text = log.stdout.decode(errors='replace') + for secret in values.values(): + text = text.replace(secret, '') + text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '', text) + stream.write(current + '\n' + text + '\n') + print('Private redacted fixture diagnostic: ' + diagnostic) + raise + finally: + for current in [name + '-probe', auth, name]: + docker('rm', '-f', '-v', current, check=False) + result['cleanup_verified'] = all(not docker( + 'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip() + for n in [name + '-probe', auth, name]) + if not result['cleanup_verified']: + raise RuntimeError('fixture_cleanup_requires_review') + if any(secret in json.dumps(result) for secret in values.values()): + raise RuntimeError('fixture_value_in_receipt') + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + source_path = args.output.with_suffix('.sources.txt') + if args.output.exists() or source_path.exists(): + raise SystemExit('Fresh receipt and source manifest required') + findings = rehearse() + manifest = ''.join(str(p.relative_to(HERE.parent)) + ': ' + + hashlib.sha256(p.read_bytes()).hexdigest() + '\n' + for p in [Path(__file__), HERE / 'native_recovery/probe.py']).encode() + receipt = {'schema': 'coulomb.native-reset-prerequisite.v1', 'data': 'synthetic', + 'status': 'native_bind_role_reset_boundary_verified', + 'observed_at': datetime.now(UTC).isoformat(), + 'images': {'lldap': LLDAP, 'authelia': AUTHELIA, 'probe': CLIENT}, + 'production_access': False, 'browser_app_or_mfa_acceptance': False, + 'provider_smtp_contacted': False, 'notifier': 'private_tmpfs_file', + 'no_ports_host_mounts_or_external_routes': True, + 'backend_container_flags_verified_before_fixture_import': True, + 'owner_runtime_or_permission_change_performed': False, + 'findings': findings, + 'source_manifest_sha256': hashlib.sha256(manifest).hexdigest()} + with source_path.open('xb') as stream: + stream.write(manifest) + with args.output.open('x') as stream: + json.dump(receipt, stream, indent=2) + stream.write('\n') + print(json.dumps(findings)) diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index 2392507..dec2dec 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -107,6 +107,21 @@ Full `make check`:396 passed, zero skips; all newly linked UC-009 fast node IDs are present in its JUnit report. Bounded proof and source hashes are retained in docs/release/oidc-userinfo-binding-review-2026-10-30.json. +2026-10-30 native recovery prerequisite: added an isolated pinned Authelia4.39.28/ +LLDAP0.6.2 rehearsal with no ports, host mounts or external routes. The strict +read-only bind from NetKingdom's source permits first factor and reset-link +verification but refuses password update (HTTP200/KO); the synthetic password- +manager bind succeeds and actual old/new directory authentication switches. +Both refuse consumed-link replay, unverified reset, interactive service sign-in +and directory group creation. All containers removed; generated fixture values +remain private. Review/evidence: docs/release/native-password-reset-review-2026-10-30.md +and native-password-reset-2026-10-30.{json,sources.txt}. Retain the supported narrow +reset-authority review on the existing NK-WP-0054-T05 owner boundary; no live +grant, installed-role conclusion or provider task duplication. Full browser/ +expiry/mail-sink/KeyCape/app coverage and productive release remain open here. +Final source-bound native proof includes verified cleanup and three CLI refusal +cases. Full `make check` rerun:396 passed, zero skips, mandatory browsers included. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records.