Rehearse native password reset against isolated directory bind roles
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m57s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-30 23:29:06 +01:00
parent 5639603231
commit b00c13fb42
7 changed files with 551 additions and 0 deletions

View file

@ -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"
]
}

View file

@ -0,0 +1,2 @@
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
scripts/native_recovery/probe.py: 68f70d3702b411ecc3506da6e0f8f76accf8e336297221fa196465b1c4ac3f5b

View file

@ -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`.

View file

@ -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.

View file

@ -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)

View file

@ -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, '<fixture-value>')
text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '<redacted>', 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))

View file

@ -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.