Rehearse native password reset against isolated directory bind roles
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m57s
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:
parent
5639603231
commit
b00c13fb42
7 changed files with 551 additions and 0 deletions
76
docs/release/native-password-reset-2026-10-30.json
Normal file
76
docs/release/native-password-reset-2026-10-30.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
|
||||
scripts/native_recovery/probe.py: 68f70d3702b411ecc3506da6e0f8f76accf8e336297221fa196465b1c4ac3f5b
|
||||
91
docs/release/native-password-reset-review-2026-10-30.md
Normal file
91
docs/release/native-password-reset-review-2026-10-30.md
Normal 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`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
131
scripts/native_recovery/probe.py
Normal file
131
scripts/native_recovery/probe.py
Normal 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)
|
||||
223
scripts/rehearse-native-password-reset.py
Normal file
223
scripts/rehearse-native-password-reset.py
Normal 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))
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue