Verify native reset SMTP issuer and app recovery through Chromium
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 00:15:24 +01:00
parent b00c13fb42
commit 7b4b75089c
17 changed files with 1326 additions and 3 deletions

View file

@ -1,4 +1,4 @@
.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication check-owner-communication .PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication check-owner-communication check-native-recovery
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@ -42,3 +42,6 @@ check-communication: ## Send-free owner retry/privacy checks; set EMAIL_CONNECT_
check-owner-communication: ## Real owner-source browser journey; set both owner roots and a fresh OWNER_BROWSER_RECEIPT check-owner-communication: ## Real owner-source browser journey; set both owner roots and a fresh OWNER_BROWSER_RECEIPT
uv run python scripts/check-owner-communication.py --user-engine "$(USER_ENGINE_ROOT)" --email-connect "$(EMAIL_CONNECT_ROOT)" --receipt "$(OWNER_BROWSER_RECEIPT)" uv run python scripts/check-owner-communication.py --user-engine "$(USER_ENGINE_ROOT)" --email-connect "$(EMAIL_CONNECT_ROOT)" --receipt "$(OWNER_BROWSER_RECEIPT)"
check-native-recovery: ## Native isolated reset/SMTP/directory/issuer/app browser; set a fresh NATIVE_BROWSER_RECEIPT
uv run python scripts/check-native-recovery.py --receipt "$(NATIVE_BROWSER_RECEIPT)"

View file

@ -89,3 +89,9 @@ 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 The current test proves the native reset prerequisite only. It proves no OIDC
issuance, MFA ceremony, TLS/browser flow, application session or productive issuance, MFA ceremony, TLS/browser flow, application session or productive
runtime/permission acceptance. UC-009 remains `established: false`. runtime/permission acceptance. UC-009 remains `established: false`.
October31 follow-through: the separate native browser gate now exercises SMTP,
expiry/replay, KeyCape, user-engine HTTP and the actual app callback using an
explicit synthetic password-manager bind. See native-recovery-browser-review-2026-10-31.md.
That new proof does not grant production reset authority or rewrite this API-only
receipt's scope.

View file

@ -0,0 +1,125 @@
{
"schema": "coulomb.native-account-recovery-browser.v1",
"status": "isolated_native_recovery_browser_verified",
"observed_at": "2026-10-30T23:03:39.786270+00:00",
"data": "synthetic",
"production_access": false,
"provider_smtp_contacted": false,
"native_oidc_and_app_journey_verified": true,
"production_or_mfa_acceptance": false,
"fixture_tls_browser_errors_ignored": true,
"images": {
"authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54",
"lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d",
"probe": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd",
"browser": "sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f",
"issuer": "sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
"user_portal": "sha256:f239bbf332b8eb87320c86390fddca1dcb4f7c628a7af75e79dee8058d446a7b"
},
"source_manifest_sha256": "d903da8042268d4b3d0ff2d22a91db43b6650c3470874a7831b54087ec1aa8c5",
"findings": {
"scenarios": [
{
"case": "read_only_refusal",
"bind_role": "lldap_strict_readonly",
"status": "browser_checks_passed",
"loopback_and_absent_routes_verified": true,
"native_tls_verified_with_fixture_ca": true,
"native_form_reset_requested": true,
"native_smtp_sink_delivery": true,
"native_link_verified": true,
"password_change_accepted": false,
"consumed_link_replay_refused": true,
"requests_outside_fixture_origins": 0,
"browser_version": "151.0.7922.34",
"old_password_accepted": true,
"new_password_accepted": false,
"directory_group_creation_refused": true
},
{
"case": "successful_reset",
"bind_role": "lldap_password_manager",
"status": "browser_checks_passed",
"loopback_and_absent_routes_verified": true,
"native_tls_verified_with_fixture_ca": true,
"native_form_reset_requested": true,
"native_smtp_sink_delivery": true,
"native_link_verified": true,
"password_change_accepted": true,
"consumed_link_replay_refused": true,
"requests_outside_fixture_origins": 0,
"browser_version": "151.0.7922.34",
"old_password_accepted": false,
"new_password_accepted": true,
"directory_group_creation_refused": true
},
{
"case": "expired_handle",
"bind_role": "lldap_password_manager",
"status": "browser_checks_passed",
"loopback_and_absent_routes_verified": true,
"native_tls_verified_with_fixture_ca": true,
"native_form_reset_requested": true,
"native_smtp_sink_delivery": true,
"expired_native_handle_refused": true,
"password_change_accepted": false,
"requests_outside_fixture_origins": 0,
"browser_version": "151.0.7922.34",
"old_password_accepted": true,
"new_password_accepted": false,
"directory_group_creation_refused": true
}
],
"playwright_version": "1.62.1",
"chromium_revision": "1234",
"runtime_assets_sha256": "47abecba125d1b0ed81588b3f154cad47c0e73aee48bdc7a85069aafc28a899e",
"runtime_asset_files": 462,
"app_journey": {
"status": "native_app_journey_passed",
"old_password_login_refused": true,
"replacement_password_login_completed": true,
"native_keycape_code_and_app_session": true,
"real_owner_http_identity_link": true,
"app_callback_replay_refused": true,
"native_unknown_client_refused": true,
"native_wrong_redirect_refused": true,
"native_foreign_tenant_token_refused": true,
"negative_client_callback_is_fixture": true,
"issuer_subject_tenant_provenance_verified": true,
"owner_users": 1,
"requests_outside_fixture_origins": 0,
"members": 1,
"unusable_passwords": true,
"source_overlay_used": true,
"owner_storage_and_pdp_fixtures": true,
"issuer_executable_sha256": "cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4",
"app_source_files": 78,
"app_source_tree_sha256": "c30d8acce6a72d621334c28151ac53496d6006d72ee8e6857a5361bc43fa1942"
},
"cleanup_verified": true
},
"native_browser_gate": {
"command": "make check-native-recovery",
"node": "scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app",
"passed": 1,
"skipped": 0,
"failures": 0,
"errors": 0,
"junit_sha256": "660ffe88f41a550b9645ef423ed6cc3d6a4f5ea074d009821861458353d60342"
},
"full_app_gate": {
"command": "make check",
"passed": 396,
"skipped": 0,
"failures": 0,
"errors": 0,
"mandatory_browser_journeys_included": true,
"junit_sha256": "3c28f0eb582067eb8ff695db9c483f81645fe839c399e2cda6ddd0dbbbf7faba"
},
"gatekeeper_unit_refusals": [
"wrong_node",
"skipped_node",
"failed_node",
"extra_node"
]
}

View file

@ -0,0 +1,12 @@
scripts/rehearse-native-reset-browser.py: 0f8d63a1469ee10f0489a4a4200f3ec332fb15608e38e241e6a9aff931c3ed8f
scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d
scripts/native_recovery/probe.py: 68f70d3702b411ecc3506da6e0f8f76accf8e336297221fa196465b1c4ac3f5b
scripts/native_recovery/mail_sink.py: 9f55c07563da5e15342207c5cc8249bb1cd7e8a5345404a9210505b29b54382a
scripts/native_recovery/check_peers.py: b3a9158b238b061867178371221a8dd087e325c74db78448bc0087327a67c0db
scripts/native_recovery/reset_browser.mjs: 183cdb8da1819154d94a46154ea711402ff63fbca884d593d991527870e75212
scripts/native_recovery/app_browser.mjs: d62092ed6ab3b284fac53c00fcd0a61215b0a789c846b809de72f6efa06a1098
scripts/native_recovery/app_stage.py: c0ad908b4c7d6c73cc53b85decfb1a0869026f65374b1c4cf83f255d7fa9e76c
scripts/native_recovery/user_portal.py: c2fe973139ee43567a77538ada970604772b9d9fdcf771a451d5e7e2808d21a3
scripts/package-lock.json: 47802f261f4006a57a66dc29dac4896c81a5fd68857df36f82319d3255748346
scripts/native_recovery/test_journey.py: 06efc76d00ed636ac50490bc0caa3a656c06cec7aac41097e4fbacf938c91cb0
scripts/check-native-recovery.py: 8f865e916f96b17dbd9f9e9ee7b59e28c8659d288aa5981344689ab9833069d8

View file

@ -0,0 +1,118 @@
# Isolated native account-recovery browser gate
WP-0015-T04 now has an exact native UC-009 browser node:
`scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app`.
It passes with no skips. It supplements the existing personal acceptance and
loopback-double regression; it does not promote UC-009 to an established automated
journey or supply a productive verdict. No production configuration, account,
permission, sender or deployment was changed.
## Verified path
Chromium uses the actual Authelia 4.39.28 reset forms. Authelia looks up the
synthetic user in actual LLDAP 0.6.2 and sends its native SMTP messages to a
bounded local sink. The browser reads the synthetic mailbox, follows the native
link and submits the replacement password. It verifies consumed-link replay and
an expired native handle; the expiry fixture uses a one-second native lifespan
and waits three seconds, without rewriting a token or database record.
The read-only bind reproduces the expected failed password change. The positive
fixture uses a password-manager bind; independent native directory login confirms
that the old password no longer works and the replacement does. Both service
identities are refused directory group creation. The separate native API proof
also retains service-login and unverified-reset denial coverage.
The browser then starts sign-in from the real app, observes refusal of the old
password and signs in with the replacement through native Authelia and KeyCape.
Actual PKCE/code exchange, issuer JWT/JWKS verification and the app callback run.
The app's real HTTP identity bridge calls the actual user-engine portal and
verified-claims normalizer. Session details show the expected issuer, canonical
LDAP subject, tenant and HTTP identity-link provenance. One owner user and one
app Member exist, and the app-local password is unusable. The consumed app
callback refuses replay.
Unknown-client and wrong-redirect requests include valid unrelated PKCE
preconditions, then verify the native issuer's specific refusal. For the tenant
test, Alice has an explicit directory tenant group. A separately registered
client declares a conflicting tenant: a real authorization code reaches a
synthetic callback receiver, and the native token endpoint refuses issuance with
403 and the tenant-binding reason. That receiver is a fixture, not the app's
callback. No token is forged and no issuer response is mocked.
A default tenant alone is not an explicit directory assignment. The issuer's
existing contract lets a trusted static registration supply a tenant for an
unplaced user. This fixture establishes the directory assignment before testing
a conflict; it does not change that contract or mislabel legitimate registration-
supplied tenancy as an issuer defect.
## Boundaries and artifacts
| Component | Execution boundary |
| --- | --- |
| Authelia and LLDAP | Exact retained native images, fresh synthetic stores and generated secrets |
| SMTP | Actual native SMTP transport to a fixture accepting only the reserved sender/recipient; no provider |
| Browser | Locked Playwright 1.62.1 and matching retained Chromium revision1234/version151.0.7922.34, copied as public runtime assets |
| KeyCape | Actual static executable extracted from the pinned deployment-owner image and hashed; a separate read-only directory bind |
| user-engine | Actual packaged HTTP portal and claims adapter; explicit in-memory storage and standalone authorization double |
| App | Current tracked source overlaid on the retained Python/dependency image; fresh SQLite/content, actual HTTP identity bridge, standalone app authorization |
Native identity, directory and SMTP are no longer protocol doubles in this node.
The owner storage/PDP fixtures do not certify production authorization or
PostgreSQL behavior. Ordinary AAL1 sign-in is tested using the fixture client's
explicit policy; no OTP/MFA ceremony, global token revocation, productive sender,
provider inbox receipt or full production TLS acceptance is claimed.
All running peers share a unique network-none container's loopback namespace.
There are no published ports, external routes, network bridges or host mounts.
Backend flags are inspected before fixture import: nonroot UID, read-only root,
dropped capabilities, no new privileges and bounded resources. Public browser
assets and the reviewed issuer executable use an executable private tmpfs;
credentials, stores and browser profiles are disposable. The browser allows only
the exact fixture origins and also observes requests across redirects; no outside
HTTP(S) request was observed. Native TLS is checked against the generated fixture
certificate by a separate probe. Chromium ignores trust errors for this private
certificate within the bounded context; that is not a production trust-store test.
Cleanup verifies that every backend, browser, probe and temporary image-extraction
container is absent. No persistent volume or production pair is imported.
Usable links, passwords, proxy secrets, tokens and message bodies are absent from
the public receipt. Failed attempts retain only private redacted diagnostics.
Run explicitly with the retained images and locked local browser prerequisites:
```sh
make check-native-recovery \
NATIVE_BROWSER_RECEIPT=/tmp/fresh-native-recovery.json
```
No image or browser download is performed. The runner strips inherited credentials
and configuration, disables pytest plugin autoload, requires the exact single
unskipped node, and refuses an existing receipt or manifest. Keep the JSON with
its adjacent `.sources.txt`; it binds executed fixture sources, runtime asset
hashes, image identities, app-source aggregate hash and native JUnit result.
The candidate registry names this explicit native gate. `make check` continues
to run the established app/browser gate without requiring these owner images.
The native gate passes one exact node with zero skips. Separate synthetic JUnit
envelope checks verify refusal of a wrong, skipped, failed or extra node and
confirm the child environment is restricted; these are verifier checks, not
additional native executions. The ordinary `make check` gate passes396cases,
zero failures/errors/skips, including its required browser journeys. Source
manifest hashes match the executed files, and whitespace/Ruff checks pass.
Evidence: `native-recovery-browser-2026-10-31.{json,sources.txt}`. The native node
passes all three reset scenarios and the issuer/app journey. The ordinary full
regression result is retained in the same evidence JSON.
## Remaining acceptance
The positive directory permission is synthetic. The existing NetKingdom
NK-WP-0054-T05 reset-authority review remains required; the source read-only bind
does not acquire password-manager rights because this test passed. No conclusion
about currently installed directory privileges is inferred.
WP-0015-T04 still owns productive UC-010/011 verdicts, runtime admission and the
remaining owner returns. UC-009's prior personal acceptance is preserved; its
automated establishment remains separate. WP-0015-T02 sender/reply/bounce
ownership and the other three workplans' release, recovery, source reconciliation
and productive acceptance gates remain open.

View file

@ -203,3 +203,19 @@ repetition and limits: `../release/native-password-reset-review-2026-10-30.md`.
NetKingdom's existing bind-split work owns the supported reset-authority return. NetKingdom's existing bind-split work owns the supported reset-authority return.
This filesystem-notifier/API prerequisite does not replace the still-open full This filesystem-notifier/API prerequisite does not replace the still-open full
isolated browser/delivery/KeyCape/app journey or promote candidate UC-009. isolated browser/delivery/KeyCape/app journey or promote candidate UC-009.
## Native recovery browser integration 2026-10-31
UC-009 now also names the exact native browser node
`scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app`.
Run `make check-native-recovery NATIVE_BROWSER_RECEIPT=/tmp/fresh-native-recovery.json`.
It exercises real Authelia forms, native LLDAP password change, native SMTP to a
local sink, reset expiry/replay, KeyCape PKCE/JWT and the actual app callback plus
user-engine HTTP identity link. Negative controls verify old-password, callback
replay, unknown-client, wrong-redirect and explicit directory/client-tenant conflict
refusals. No issuer token or response is mocked. Owner storage and authorization
remain fixtures, and the positive reset permission is synthetic. This is a separate
explicit image-backed gate; the default established-product gate is unchanged.
Review/limits: `../release/native-recovery-browser-review-2026-10-31.md`; bounded
proof is its dated JSON/source-manifest pair. Productive permissions, per-flow
verdicts and automated establishment are not inferred.

View file

@ -67,7 +67,11 @@
"manual_review": [ "manual_review": [
"Loopback issuer/mailbox double is bound; real portal, SMTP and owner admission remain open" "Loopback issuer/mailbox double is bound; real portal, SMTP and owner admission remain open"
], ],
"acceptance_ref": "history/20261006T185411Z-personal-workspace-acceptance.md" "acceptance_ref": "history/20261006T185411Z-personal-workspace-acceptance.md",
"native_browser_tests": [
"scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app"
],
"native_browser_gate": "make check-native-recovery"
}, },
{ {
"id": "CSOC-UC-010", "id": "CSOC-UC-010",

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Require the exact unskipped native recovery browser node and retain bounded proof."""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NODE = 'scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app'
def run(output):
source_output = output.with_suffix('.sources.txt')
if output.exists() or source_output.exists():
raise ValueError('Fresh receipt and source manifest required')
with tempfile.TemporaryDirectory(prefix='csoc-native-recovery-gate-') as directory:
work = Path(directory)
config = work / 'pytest.ini'
config.write_text('[pytest]\nmarkers=\n browser: real Chromium\n'
' usecase(id): stable journey\n')
receipt = work / 'native.json'
report = work / 'junit.xml'
env = {'PATH': os.environ['PATH'], 'HOME': str(Path.home()), 'LANG': 'C.UTF-8',
'PYTHONDONTWRITEBYTECODE': '1', 'PYTEST_DISABLE_PLUGIN_AUTOLOAD': '1',
'CSOC_NATIVE_RECEIPT': str(receipt)}
result = subprocess.run([
sys.executable, '-m', 'pytest', '-c', str(config), '--rootdir', str(ROOT),
'-p', 'no:cacheprovider',
'--basetemp', str(work / 'pytest'), '--junitxml', str(report), NODE],
cwd=ROOT, env=env, capture_output=True, timeout=360)
if result.returncode:
fd, diagnostic = tempfile.mkstemp(prefix='csoc-native-gate-failure-', suffix='.log')
with os.fdopen(fd, 'wb') as stream:
stream.write(result.stdout + result.stderr)
raise RuntimeError('Native gate failed; private fixture diagnostic: ' + diagnostic)
suites = ET.parse(report).getroot()
tests = suites.findall('.//testcase')
if (len(tests) != 1 or tests[0].get('classname') != 'scripts.native_recovery.test_journey'
or tests[0].get('name') != NODE.split('::')[1]
or any(tests[0].find(n) is not None for n in ['failure', 'error', 'skipped'])):
raise RuntimeError('Exact unskipped native node required')
proof = json.loads(receipt.read_text())
sources = receipt.with_suffix('.sources.txt').read_bytes()
if hashlib.sha256(sources).hexdigest() != proof['source_manifest_sha256']:
raise RuntimeError('Native source manifest differs')
proof['native_browser_gate'] = {'command': 'make check-native-recovery', 'node': NODE,
'passed': 1, 'skipped': 0, 'failures': 0, 'errors': 0,
'junit_sha256': hashlib.sha256(
report.read_bytes()).hexdigest()}
with source_output.open('xb') as stream:
stream.write(sources)
with output.open('x') as stream:
stream.write(json.dumps(proof, indent=2) + '\n')
return {'status': proof['status'], 'passed': 1, 'skipped': 0,
'production_or_mfa_acceptance': False}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--receipt', type=Path, required=True)
args = parser.parse_args()
print(json.dumps(run(args.receipt)))

View file

@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { chromium } from './node_modules/playwright/index.mjs';
const input = JSON.parse(await readFile('/run/fixture/app-browser-input.json', 'utf8'));
const auth = 'https://auth.recovery.test:9091';
const issuer = 'http://127.0.0.1:8080';
const app = 'http://127.0.0.1:8000';
const portal = 'http://127.0.0.1:8089';
const fixture = 'http://127.0.0.1:8088';
const allowed = new Set([auth, issuer, app, portal, fixture]);
let browser;
let stage = 'native_app_browser_startup';
let outside = 0;
try {
browser = await chromium.launch({
executablePath: '/run/fixture/browser/chrome-headless-shell-linux64/chrome-headless-shell',
headless: true, args: ['--host-resolver-rules=MAP auth.recovery.test 127.0.0.1',
'--no-proxy-server', '--disable-background-networking'],
});
const context = await browser.newContext({ignoreHTTPSErrors: true, serviceWorkers: 'block'});
context.on('request', request => {
const u = new URL(request.url());
if (['http:', 'https:'].includes(u.protocol) && !allowed.has(u.origin)) outside += 1;
});
await context.route('**/*', route => {
const u = new URL(route.request().url());
if (!allowed.has(u.origin)) {
return route.abort();
}
return route.continue();
});
const page = await context.newPage();
page.setDefaultTimeout(20000);
let callback;
page.on('request', request => {
const u = new URL(request.url());
if (u.origin === app && u.pathname === '/auth/callback/' && u.searchParams.has('code')) {
callback = u.href; // private, never emitted
}
});
stage = 'native_app_login_redirect';
await page.goto(app + '/auth/login/');
await page.locator('#username-textfield').fill('alice');
stage = 'native_old_password_refusal';
await page.locator('#password-textfield').fill(input.old_password);
const rejected = page.waitForResponse(r => r.url() === auth + '/api/firstfactor');
await page.locator('#sign-in-button').click();
const denied = await rejected;
assert(denied.status() >= 400 || (await denied.json()).status !== 'OK');
await page.getByText('Incorrect username or password', {exact: false}).first().waitFor();
stage = 'native_replacement_password_login';
await page.locator('#password-textfield').fill(input.new_password);
await page.locator('#sign-in-button').click();
await page.waitForURL(app + '/app/');
assert(callback);
await page.goto(app + '/account/session/');
await page.getByRole('heading', {name: 'Session details', exact: true}).waitFor();
const value = label => page.locator('dt').filter({hasText: label}).locator('xpath=following-sibling::dd[1]');
assert.equal((await value(/^Issuer$/).innerText()).trim(), issuer);
assert.equal((await value(/^Subject$/).innerText()).trim(), 'uid=alice,ou=people,dc=example,dc=test');
assert.equal((await value(/^Tenant$/).innerText()).trim(), 'tenant:coulomb');
assert.equal((await value(/^user-engine source$/).innerText()).trim(), 'http');
stage = 'native_app_callback_replay_refusal';
const replay = await context.request.get(callback, {maxRedirects: 0});
assert.equal(replay.status(), 400);
stage = 'native_unknown_client_refusal';
const deniedQuery = {redirect_uri: app + '/auth/callback/', response_type: 'code',
scope: 'openid profile email groups', state: randomBytes(32).toString('base64url'),
code_challenge_method: 'S256', code_challenge: randomBytes(32).toString('base64url')};
const invalid = await context.request.get(issuer + '/authorize?'
+ new URLSearchParams({...deniedQuery, client_id: 'not-registered'}),
{maxRedirects: 0});
assert.equal(invalid.status(), 400);
assert((await invalid.text()).includes('unknown client_id'));
const wrongRedirect = await context.request.get(issuer + '/authorize?'
+ new URLSearchParams({...deniedQuery, client_id: 'coulomb-recovery-test',
redirect_uri: app + '/wrong'}),
{maxRedirects: 0});
assert.equal(wrongRedirect.status(), 400);
assert((await wrongRedirect.text()).includes('redirect_uri does not match'));
stage = 'native_foreign_tenant_client_refusal';
const verifier = randomBytes(32).toString('base64url');
const foreignState = randomBytes(32).toString('base64url');
const query = new URLSearchParams({client_id: 'foreign-recovery-test',
redirect_uri: fixture + '/client-callback', response_type: 'code',
scope: 'openid profile email groups', state: foreignState,
code_challenge_method: 'S256',
code_challenge: createHash('sha256').update(verifier).digest('base64url')});
await page.goto(issuer + '/authorize?' + query);
await page.waitForURL(u => u.origin === fixture && u.pathname === '/client-callback');
const foreignCapture = new URL(page.url());
assert.equal(foreignCapture.searchParams.get('state'), foreignState);
if (!foreignCapture?.searchParams.get('code')) {
throw new Error('Foreign callback metadata: ' + JSON.stringify({captured: !!foreignCapture,
parameters: foreignCapture ? [...foreignCapture.searchParams.keys()] : [],
error: foreignCapture?.searchParams.get('error'),
tenant_error: /tenant/i.test(foreignCapture?.searchParams.get('error_description') || '')}));
}
const foreign = await context.request.post(issuer + '/token', {maxRedirects: 0,
form: {grant_type: 'authorization_code', client_id: 'foreign-recovery-test',
redirect_uri: fixture + '/client-callback', code: foreignCapture.searchParams.get('code'),
code_verifier: verifier}});
assert.equal(foreign.status(), 403);
assert((await foreign.text()).includes('tenant binding conflict'));
const state = await context.request.get(portal + '/fixture/state', {maxRedirects: 0});
assert.equal((await state.json()).users, 1);
assert.equal(outside, 0);
console.log(JSON.stringify({status: 'native_app_journey_passed', old_password_login_refused: true,
replacement_password_login_completed: true, native_keycape_code_and_app_session: true,
real_owner_http_identity_link: true, app_callback_replay_refused: true,
native_unknown_client_refused: true, native_wrong_redirect_refused: true,
native_foreign_tenant_token_refused: true, negative_client_callback_is_fixture: true,
issuer_subject_tenant_provenance_verified: true,
owner_users: 1, requests_outside_fixture_origins: outside}));
await context.close();
} catch (error) {
await writeFile('/run/fixture/browser-diagnostic.txt', String(error.message), {mode: 0o600});
console.log(JSON.stringify({status: 'native_app_journey_failed', stage, failure_type: error.name}));
process.exitCode = 1;
} finally {
if (browser) await browser.close();
}

View file

@ -0,0 +1,170 @@
"""Native issuer and actual app stage after the synthetic directory password reset."""
import hashlib
import io
import json
import runpy
import secrets
import subprocess
import tarfile
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
BASE = runpy.run_path(str(ROOT / 'scripts/rehearse-native-password-reset.py'))
docker, archive, container = [BASE[n] for n in ['docker', 'archive', 'container']]
ISSUER_IMAGE = 'sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010'
USER_IMAGE = 'sha256:f239bbf332b8eb87320c86390fddca1dcb4f7c628a7af75e79dee8058d446a7b'
def private_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
def executable_archive(binary):
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w') as tar:
item = tarfile.TarInfo('keycape')
item.mode, item.uid, item.gid, item.size = 0o700, 1000, 1000, len(binary)
tar.addfile(item, io.BytesIO(binary))
return buffer.getvalue()
def run(anchor, browser, values, cert, tls_key, peers):
for image in [ISSUER_IMAGE, USER_IMAGE]:
assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image
extract, hasher = anchor + '-extract', anchor + '-hasher'
app, portal, auth = anchor + '-app', anchor + '-portal', anchor + '-auth'
docker('create', '--name', extract, '--pull=never', '--network', 'none', '--read-only',
'--user', '1000:1000', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
ISSUER_IMAGE)
exported = docker('cp', extract + ':/keycape', '-').stdout
with tarfile.open(fileobj=io.BytesIO(exported)) as tar:
members = tar.getmembers()
assert len(members) == 1 and members[0].isfile()
executable = tar.extractfile(members[0]).read()
docker('exec', '-i', browser, 'tar', '-xf', '-', '-C', '/run/fixture',
data=executable_archive(executable))
upstream_secret = secrets.token_urlsafe(32)
hashed = docker('run', '-i', '--rm', '--name', hasher, '--pull=never', '--network', 'none',
'--read-only', '--user', '1000:1000', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--entrypoint', '/bin/sh',
BASE['AUTHELIA'], '-c', 'read -r value; exec /app/authelia crypto hash '
'generate pbkdf2 --variant sha512 --password "$value"',
data=(upstream_secret + '\n').encode()).stdout.decode()
verifier = hashed.split('Digest: ', 1)[1].strip()
config = BASE['configuration'](values['bind_password'])
config['server']['tls'] = {'certificate': '/run/fixture/cert.pem',
'key': '/run/fixture/key.pem'}
config['session']['cookies'][0]['authelia_url'] = 'https://auth.recovery.test:9091'
config['identity_providers'] = {'oidc': {
'hmac_secret': secrets.token_hex(32), 'jwks': [{
'key_id': 'synthetic-upstream', 'algorithm': 'RS256', 'use': 'sig',
'key': private_key().decode()}],
'claims_policies': {'keycape': {'id_token': ['preferred_username']}},
'clients': [{'client_id': 'keycape-recovery-test', 'client_name': 'Recovery fixture',
'client_secret': verifier, 'claims_policy': 'keycape', 'public': False,
'authorization_policy': 'one_factor', 'consent_mode': 'implicit',
'redirect_uris': ['http://127.0.0.1:8080/authorize/callback'],
'scopes': ['openid', 'profile', 'email', 'groups'],
'grant_types': ['authorization_code'], 'response_types': ['code'],
'response_modes': ['query'],
'token_endpoint_auth_method': 'client_secret_basic'}]}}
container(auth, BASE['AUTHELIA'], 'container:' + anchor, {})
docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'configuration.yml': json.dumps(config).encode(), 'cert.pem': cert, 'key.pem': tls_key}))
docker('exec', '-d', auth, '/bin/sh', '-c',
'exec /app/authelia --config /run/fixture/configuration.yml '
'> /run/fixture/server.log 2>&1')
peers(anchor, {'mode': 'ready', 'certificate': cert.decode()})
issuer_password = secrets.token_urlsafe(24)
prepared = peers(anchor, {**values, 'mode': 'prepare_issuer_bind',
'certificate': cert.decode()})
docker('exec', '-i', anchor, '/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 svc-issuer',
data=(prepared['admin_token'] + '\n' + issuer_password + '\n').encode())
client = {'clientId': 'coulomb-recovery-test', 'displayName': 'Synthetic recovery app',
'redirectUris': ['http://127.0.0.1:8000/auth/callback/'],
'allowedScopes': ['openid', 'profile', 'email', 'groups'],
'grantTypes': ['authorization_code'], 'clientType': 'public',
'mfaRequired': False, 'tenant': 'tenant:coulomb'}
issuer = {'issuer': 'http://127.0.0.1:8080', 'port': 8080, 'tokenLifetime': '5m',
'privateKeyPem': '/run/fixture/issuer-key.pem', 'environment': 'dev',
'lldap': {'url': 'ldap://127.0.0.1:3890',
'bindDN': 'uid=svc-issuer,ou=people,dc=example,dc=test',
'bindPW': issuer_password, 'baseDN': 'dc=example,dc=test'},
'authelia': {'baseURL': 'https://127.0.0.1:9091',
'browserBaseURL': 'https://auth.recovery.test:9091',
'tokenBaseURL': 'https://127.0.0.1:9091',
'issuer': 'https://auth.recovery.test:9091',
'clientId': 'keycape-recovery-test', 'clientSecret': upstream_secret,
'redirectURI': 'http://127.0.0.1:8080/authorize/callback'},
'clients': [client, {**client, 'clientId': 'foreign-recovery-test',
'tenant': 'tenant:foreign',
'redirectUris': ['http://127.0.0.1:8088/client-callback']}]}
docker('exec', '-i', browser, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'issuer.json': json.dumps(issuer).encode(), 'issuer-key.pem': private_key(),
'cert.pem': cert}))
docker('exec', '-d', '--env', 'SSL_CERT_FILE=/run/fixture/cert.pem', browser,
'/bin/sh', '-c', 'exec /run/fixture/keycape --config /run/fixture/issuer.json '
'> /run/fixture/issuer.log 2>&1')
proxy_secret = secrets.token_urlsafe(32)
container(portal, USER_IMAGE, 'container:' + anchor, {})
docker('exec', '-i', portal, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'user_portal.py': (HERE / 'user_portal.py').read_bytes(),
'portal-input.json': json.dumps({'proxy_secret': proxy_secret}).encode()}))
docker('exec', '-d', portal, '/bin/sh', '-c',
'exec python /run/fixture/user_portal.py > /run/fixture/server.log 2>&1')
container(app, BASE['CLIENT'], 'container:' + anchor, {
'DJANGO_SETTINGS_MODULE': 'fixture_settings', 'PYTHONPATH': '/data/source'})
paths = subprocess.check_output(['git', 'ls-files', 'coulomb_social', 'manage.py'],
cwd=ROOT, text=True).splitlines()
sources = {name: (ROOT / name).read_bytes() for name in paths}
source_hashes = {n: hashlib.sha256(data).hexdigest() for n, data in sources.items()}
settings = ("from coulomb_social.settings.base import *\n"
"DEBUG=False\nALLOWED_HOSTS=['127.0.0.1','localhost']\n"
"DATABASES={'default': {'ENGINE':'django.db.backends.sqlite3',"
"'NAME':'/data/app.sqlite3'}}\nCONTENT_ROOT='/data/content'\n"
"OIDC_ENABLED=True\nOIDC_ISSUER='http://127.0.0.1:8080'\n"
"OIDC_CLIENT_ID='coulomb-recovery-test'\n"
"OIDC_REDIRECT_URI='http://127.0.0.1:8000/auth/callback/'\n"
"USER_ENGINE_BASE_URL='http://127.0.0.1:8089'\n"
"USER_ENGINE_APPLICATION_ID='coulomb-recovery-test'\n"
"FORGEJO_BASE_URL=''\nFLEX_AUTH_BASE_URL=''\n"
"EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend'\n"
"SECRET_KEY=" + repr(secrets.token_urlsafe(32)) + '\n'
"USER_ENGINE_PROXY_SECRET=" + repr(proxy_secret) + '\n').encode()
docker('exec', app, 'mkdir', '/data/source')
docker('exec', '-i', app, 'tar', '-xf', '-', '-C', '/data/source',
data=archive({**sources, 'fixture_settings.py': settings}))
for operation in ['migrate', 'collectstatic']:
docker('exec', app, 'python', '/data/source/manage.py', operation, '--noinput')
docker('exec', '-d', app, '/bin/sh', '-c',
'exec gunicorn coulomb_social.wsgi:application --bind 127.0.0.1:8000 --workers 1 '
'--access-logfile /dev/null > /run/fixture/server.log 2>&1')
peers(anchor, {'mode': 'app_ready', 'certificate': cert.decode()})
docker('exec', '-i', browser, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'app_browser.mjs': (HERE / 'app_browser.mjs').read_bytes(),
'app-browser-input.json': json.dumps({'old_password': values['old_password'],
'new_password': values['new_password']}).encode()}))
checked = docker('exec', browser, 'node', '/run/fixture/app_browser.mjs', check=False)
result = json.loads(checked.stdout)
if checked.returncode:
raise RuntimeError('browser_' + result.get('stage', 'app_failed'))
postflight = json.loads(docker('exec', app, 'python', '/data/source/manage.py', 'shell', '-c',
"import json; from coulomb_social.apps.members.models import Member; "
"print(json.dumps({'members':Member.objects.count(), 'unusable_passwords': "
"all(not m.user.has_usable_password() for m in Member.objects.select_related('user'))}))"
).stdout.decode().splitlines()[-1])
assert postflight == {'members': 1, 'unusable_passwords': True}
return {**result, **postflight, 'source_overlay_used': True,
'owner_storage_and_pdp_fixtures': True,
'issuer_executable_sha256': hashlib.sha256(executable).hexdigest(),
'app_source_files': len(sources), 'app_source_tree_sha256': hashlib.sha256(
json.dumps(source_hashes, sort_keys=True).encode()).hexdigest()}

View file

@ -0,0 +1,84 @@
"""Only synthetic loopback peers; never accept an endpoint from input."""
import json
import ssl
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
def run(v):
assert __debug__
assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'}
assert len(Path('/proc/net/route').read_text().splitlines()) == 1
tls = ssl.create_default_context(cadata=v['certificate'])
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=tls))
def call(url, data=None, token=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
try:
return opener.open(urllib.request.Request(
url, data=json.dumps(data).encode() if data else None,
headers=headers), timeout=2)
except urllib.error.HTTPError as error:
return error
if v['mode'] == 'prepare_issuer_bind':
with call('http://127.0.0.1:17170/auth/simple/login', {
'username': 'admin', 'password': v['admin_password']}) as response:
token = json.load(response)['token']
def graphql(query, variables=None):
with call('http://127.0.0.1:17170/api/graphql', {
'query': query, 'variables': variables or {}}, token=token) as response:
body = json.load(response)
assert not body.get('errors')
return body['data']
graphql('mutation($u:CreateUserInput!){createUser(user:$u){id}}', {'u': {
'id': 'svc-issuer', 'email': 'svc-issuer@example.test', 'displayName': 'svc-issuer'}})
groups = graphql('{groups{id displayName}}')['groups']
group = next(g['id'] for g in groups if g['displayName'] == 'lldap_strict_readonly')
graphql('mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}',
{'u': 'svc-issuer', 'g': group})
tenant_group = graphql('mutation($n:String!){createGroup(name:$n){id}}',
{'n': 'tenant:coulomb:users'})['createGroup']['id']
graphql('mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}',
{'u': 'alice', 'g': tenant_group})
return {'admin_token': token}
if v['mode'] == 'directory_check':
def accepted(password):
with call('http://127.0.0.1:17170/auth/simple/login', {
'username': 'alice', 'password': password}) as response:
return response.status == 200
return {'old_password_accepted': accepted(v['old_password']),
'new_password_accepted': accepted(v['new_password'])}
urls = (['http://127.0.0.1:8080/.well-known/openid-configuration',
'http://127.0.0.1:8089/fixture/state', 'http://127.0.0.1:8000/healthz']
if v['mode'] == 'app_ready' else
['http://127.0.0.1:8088/health', 'https://127.0.0.1:9091/api/health'])
for url in urls:
for _ in range(120):
try:
with call(url) as response:
if response.status == 200:
break
except OSError:
pass
time.sleep(.25)
else:
raise RuntimeError('fixture_startup')
return {'status': 'peers_ready', 'loopback_and_absent_routes_verified': True,
'native_tls_verified_with_fixture_ca': True}
if __name__ == '__main__':
try:
print(json.dumps(run(json.load(sys.stdin))))
except Exception as exc:
print(json.dumps({'status': 'peer_check_failed', 'failure_type': type(exc).__name__}))
raise SystemExit(1)

View file

@ -0,0 +1,119 @@
"""Bounded synthetic SMTP/mailbox fixture, available only inside a network-none peer."""
import email
import email.policy
import json
import re
import socketserver
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MESSAGES = []
LOCK = threading.Lock()
class SMTP(socketserver.StreamRequestHandler):
def handle(self):
self.connection.settimeout(15)
recipient = None
sender = False
def reply(line):
self.wfile.write((line + '\r\n').encode())
self.wfile.flush()
reply('220 synthetic fixture')
while True:
raw = self.rfile.readline(8193)
if not raw or len(raw) > 8192:
return
command = raw.decode(errors='replace').strip()
upper = command.upper()
if upper.startswith(('EHLO ', 'HELO ')):
reply('250 synthetic fixture')
elif upper.startswith('MAIL FROM:'):
sender = upper == 'MAIL FROM:<NO-REPLY@EXAMPLE.TEST>'
reply('250 OK' if sender else '550 fixture sender only')
elif upper.startswith('RCPT TO:'):
recipient = ('alice@example.test'
if upper == 'RCPT TO:<ALICE@EXAMPLE.TEST>' else None)
reply('250 OK' if recipient else '550 fixture recipient only')
elif upper == 'DATA' and sender and recipient:
reply('354 End with dot')
lines, size = [], 0
while True:
line = self.rfile.readline(65537)
if not line or len(line) > 65536:
return
if line == b'.\r\n':
break
size += len(line)
if size > 65536:
reply('552 fixture message too large')
return
lines.append(line[1:] if line.startswith(b'..') else line)
message = email.message_from_bytes(b''.join(lines), policy=email.policy.default)
body = message.get_body(preferencelist=('plain', 'html'))
text = body.get_content() if body else message.get_content()
links = re.findall(r'https://auth\.recovery\.test:9091/'
r'reset-password/step2\?token=[A-Za-z0-9_.-]+', str(text))
with LOCK:
if len(MESSAGES) >= 20:
reply('452 fixture mailbox full')
return
MESSAGES.append({'number': len(MESSAGES) + 1, 'recipient': recipient,
'reset_links': list(dict.fromkeys(links))})
recipient, sender = None, False
reply('250 fixture accepted')
elif upper in {'RSET', 'NOOP'}:
recipient, sender = None, False
reply('250 OK')
elif upper == 'QUIT':
reply('221 bye')
return
else:
reply('502 fixture command refused')
class SMTPServer(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
class Mailbox(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self): # noqa: N802
if urllib.parse.urlsplit(self.path).path == '/client-callback':
body = b'{"status":"synthetic_callback_receiver"}'
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
return
if self.path not in {'/health', '/messages'}:
self.send_error(404)
return
with LOCK:
body = json.dumps({'messages': MESSAGES} if self.path == '/messages'
else {'status': 'fixture-ready'}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
if __name__ == '__main__':
if not __debug__ or {p.name for p in Path('/sys/class/net').iterdir()} != {'lo'}:
raise SystemExit('Fixture loopback boundary required')
with SMTPServer(('127.0.0.1', 2525), SMTP) as smtp:
thread = threading.Thread(target=smtp.serve_forever, daemon=True)
thread.start()
ThreadingHTTPServer(('127.0.0.1', 8088), Mailbox).serve_forever()

View file

@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { chromium } from './node_modules/playwright/index.mjs';
const input = JSON.parse(await readFile('/run/fixture/browser-input.json', 'utf8'));
const auth = 'https://auth.recovery.test:9091';
const mailbox = 'http://127.0.0.1:8088';
const allowed = new Set([auth, mailbox]);
let stage = 'browser_startup';
let outside = 0;
let browser;
try {
browser = await chromium.launch({
executablePath: '/run/fixture/browser/chrome-headless-shell-linux64/chrome-headless-shell',
headless: true,
args: ['--host-resolver-rules=MAP auth.recovery.test 127.0.0.1',
'--no-proxy-server', '--disable-background-networking'],
});
const context = await browser.newContext({
ignoreHTTPSErrors: true, serviceWorkers: 'block', locale: 'en-US',
});
context.on('request', request => {
const u = new URL(request.url());
if (['http:', 'https:'].includes(u.protocol) && !allowed.has(u.origin)) outside += 1;
});
await context.route('**/*', route => {
if (!allowed.has(new URL(route.request().url()).origin)) {
return route.abort();
}
return route.continue();
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
async function messages() {
const response = await context.request.get(mailbox + '/messages', { maxRedirects: 0 });
assert.equal(response.status(), 200);
return (await response.json()).messages;
}
stage = 'native_login_page';
for (let n = 0; n < 120; n++) {
try {
await page.goto(auth, { timeout: 2000 });
break;
} catch {
if (n === 119) throw new Error('Native startup');
await new Promise(resolve => setTimeout(resolve, 250));
}
}
await page.locator('#reset-password-button').waitFor();
const before = (await messages()).length;
stage = 'request_reset_through_native_form';
await page.locator('#reset-password-button').click();
await page.getByLabel('Username', { exact: true }).fill('alice');
const started = page.waitForResponse(r => r.url() === auth + '/api/reset-password/identity/start');
await page.locator('#reset-button').click();
assert.equal((await started).status(), 200);
let delivered;
for (let n = 0; n < 60; n++) {
const newer = (await messages()).slice(before).filter(m => m.reset_links.length > 0);
if (newer.length) { delivered = newer[0]; break; }
await new Promise(resolve => setTimeout(resolve, 100));
}
assert(delivered);
assert.equal(delivered.recipient, 'alice@example.test');
const link = delivered.reset_links[0];
assert.equal(new URL(link).origin, auth);
assert.equal(new URL(link).pathname, '/reset-password/step2');
if (input.expect_expired) {
stage = 'wait_for_native_handle_expiry';
await new Promise(resolve => setTimeout(resolve, 3000));
}
stage = 'consume_native_reset_link';
const verified = page.waitForResponse(r => r.url() === auth + '/api/reset-password/identity/finish');
await page.goto(link);
const verifiedBody = await (await verified).json();
if (input.expect_expired) {
assert.notEqual(verifiedBody.status, 'OK');
assert.equal(await page.locator('#reset-button').isEnabled(), false);
assert.equal(outside, 0);
console.log(JSON.stringify({status: 'browser_checks_passed', native_form_reset_requested: true,
native_smtp_sink_delivery: true, expired_native_handle_refused: true,
password_change_accepted: false, requests_outside_fixture_origins: outside,
browser_version: browser.version()}));
await context.close();
await browser.close();
browser = undefined;
process.exit(0);
}
await page.getByLabel('New password', { exact: true }).fill(input.new_password);
await page.getByLabel('Repeat new password', { exact: true }).fill(input.new_password);
stage = 'submit_native_replacement_password';
const changed = page.waitForResponse(r => r.url() === auth + '/api/reset-password'
&& r.request().method() === 'POST');
await page.locator('#reset-button').click();
const result = await changed;
const responseBody = await result.text();
const changeAccepted = result.status() === 200
&& (!responseBody || JSON.parse(responseBody).status !== 'KO');
assert.equal(changeAccepted, input.expected_success);
stage = 'replayed_reset_link';
const replay = page.waitForResponse(r => r.url() === auth + '/api/reset-password/identity/finish');
await page.goto(link);
const replayBody = await (await replay).json();
assert.notEqual(replayBody.status, 'OK');
assert.equal(outside, 0);
console.log(JSON.stringify({
status: 'browser_checks_passed', native_form_reset_requested: true,
native_smtp_sink_delivery: true, native_link_verified: true,
password_change_accepted: changeAccepted, consumed_link_replay_refused: true,
requests_outside_fixture_origins: outside, browser_version: browser.version(),
}));
await context.close();
} catch (error) {
await writeFile('/run/fixture/browser-diagnostic.txt', String(error.message), { mode: 0o600 });
console.log(JSON.stringify({ status: 'browser_check_failed', stage, failure_type: error.name }));
process.exitCode = 1;
} finally {
if (browser) await browser.close();
}

View file

@ -0,0 +1,33 @@
"""Explicit native peer gate; excluded from the default image-free test collection."""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.browser
@pytest.mark.usecase('CSOC-UC-009')
def test_native_reset_directory_smtp_issuer_and_app():
output = Path(os.environ['CSOC_NATIVE_RECEIPT'])
result = subprocess.run([
sys.executable, str(ROOT / 'scripts/rehearse-native-reset-browser.py'),
'--output', str(output)], cwd=ROOT, capture_output=True, text=True, timeout=300)
assert result.returncode == 0, result.stdout + result.stderr
proof = json.loads(output.read_text())
assert proof['status'] == 'isolated_native_recovery_browser_verified'
assert proof['findings']['cleanup_verified']
cases = {c['case']: c for c in proof['findings']['scenarios']}
assert cases['read_only_refusal']['password_change_accepted'] is False
assert cases['successful_reset']['password_change_accepted'] is True
assert cases['expired_handle']['expired_native_handle_refused'] is True
app = proof['findings']['app_journey']
assert app['native_keycape_code_and_app_session'] is True
assert app['real_owner_http_identity_link'] is True
assert app['native_foreign_tenant_token_refused'] is True
assert proof['production_or_mfa_acceptance'] is False

View file

@ -0,0 +1,37 @@
"""Actual owner HTTP/claims service with explicit in-memory/PDP fixture boundaries."""
import json
from pathlib import Path
from wsgiref.simple_server import WSGIRequestHandler, make_server
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.service import UserEngineService
from user_engine.web import PortalApplication
class Quiet(WSGIRequestHandler):
def log_message(self, *args):
pass
if __name__ == '__main__':
assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'}
v = json.loads(Path('/run/fixture/portal-input.json').read_text())
store = InMemoryUserEngineStore()
store.migrate()
service = UserEngineService(store=store, identity_adapter=VerifiedIdentityClaimsAdapter(
expected_issuer='http://127.0.0.1:8080', expected_audience='user-engine-portal'),
authorization=LocalAuthorizationCheckPort())
portal = PortalApplication(service, trusted_proxy_secret=v['proxy_secret'],
login_url='https://auth.recovery.test:9091')
def application(env, start):
if env['PATH_INFO'] == '/fixture/state':
data = json.dumps({'users': service.operability_snapshot().metrics['users']}).encode()
start('200 OK', [('Content-Type', 'application/json'),
('Content-Length', str(len(data)))])
return [data]
return portal(env, start)
make_server('127.0.0.1', 8089, application, handler_class=Quiet).serve_forever()

View file

@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Isolated native reset, SMTP, issuer and app browser journey. No production or MFA.
All peers share a network-none namespace. Only generated synthetic fixture
credentials are used. Runtime assets enter private tmpfs through tar pipes.
"""
import argparse
import hashlib
import ipaddress
import json
import os
import re
import runpy
import secrets
import subprocess
import tarfile
import tempfile
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
BASE = runpy.run_path(str(HERE / 'rehearse-native-password-reset.py'))
docker, archive, container, probe = [BASE[n] for n in ['docker', 'archive', 'container', 'probe']]
BROWSER = 'sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f'
ASSETS = HERE / 'native_recovery'
APP_STAGE = runpy.run_path(str(ASSETS / 'app_stage.py'))
def certificate():
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'auth.recovery.test')])
now = datetime.now(UTC)
cert = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
.public_key(key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1)).not_valid_after(now + timedelta(hours=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(x509.SubjectAlternativeName([
x509.DNSName('auth.recovery.test'),
x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]),
critical=False).sign(key, hashes.SHA256()))
return cert.public_bytes(serialization.Encoding.PEM), key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
def browser_assets(name):
lock = json.loads((HERE / 'package-lock.json').read_text())
version = lock['packages']['node_modules/playwright']['version']
runtime = json.loads((HERE / 'node_modules/playwright/package.json').read_text())['version']
if version != runtime:
raise RuntimeError('locked_playwright_required')
spec = json.loads((HERE / 'node_modules/playwright-core/browsers.json').read_text())
revision = next(b['revision'] for b in spec['browsers']
if b['name'] == 'chromium-headless-shell')
browser = Path.home() / '.cache/ms-playwright' / ('chromium_headless_shell-' + revision)
roots = [(HERE / 'node_modules/playwright', 'node_modules/playwright'),
(HERE / 'node_modules/playwright-core', 'node_modules/playwright-core'),
(browser, 'browser')]
manifest = {}
with subprocess.Popen(['docker', '--context', 'default', 'exec', '-i', name,
'tar', '-xf', '-', '-C', '/run/fixture'], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={'PATH': os.environ['PATH']}) as process:
with tarfile.open(fileobj=process.stdin, mode='w|') as tar:
for root, prefix in roots:
if root.is_symlink() or not root.is_dir():
raise RuntimeError('retained_runtime_assets_required')
for path in sorted(root.rglob('*')):
if path.is_symlink() or not (path.is_dir() or path.is_file()):
raise RuntimeError('runtime_asset_links_refused')
if path.is_dir():
continue
member = prefix + '/' + path.relative_to(root).as_posix()
digest = hashlib.sha256()
with path.open('rb') as stream:
while data := stream.read(1024 * 1024):
digest.update(data)
stream.seek(0)
item = tarfile.TarInfo(member)
item.size, item.mode = path.stat().st_size, path.stat().st_mode & 0o777
item.uid = item.gid = 1000
tar.addfile(item, stream)
manifest[member] = digest.hexdigest()
process.stdin.close()
if process.wait(timeout=60):
raise RuntimeError('runtime_asset_import_failed')
return {'playwright_version': version, 'chromium_revision': revision,
'runtime_assets_sha256': hashlib.sha256(json.dumps(
manifest, sort_keys=True).encode()).hexdigest(),
'runtime_asset_files': len(manifest)}
def browser_container(name, anchor):
docker('run', '-d', '--pull=never', '--name', name, '--network', 'container:' + anchor,
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--user', '1000:1000', '--memory', '1536m', '--cpus', '1', '--pids-limit', '256',
'--env', 'HOME=/tmp', '--tmpfs',
'/run/fixture:rw,exec,nosuid,nodev,size=805306368,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/tmp:rw,noexec,nosuid,nodev,size=536870912,uid=1000,gid=1000,mode=0700',
'--entrypoint', '/bin/sh', BROWSER, '-c', 'sleep 1800')
BASE['confined'](name, 'container:' + anchor)
def peers(anchor, value):
done = 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',
'--entrypoint', 'python', BASE['CLIENT'], '-c',
(ASSETS / 'check_peers.py').read_text(),
data=json.dumps(value).encode(), check=False)
if done.returncode:
raise RuntimeError('native_browser_peer_check_failed')
return json.loads(done.stdout)
def rehearse():
if not __debug__:
raise RuntimeError('acceptance_assertions_must_be_enabled')
context = json.loads(docker('context', 'inspect', 'default').stdout)[0]
assert context['Endpoints']['docker']['Host'] == 'unix:///var/run/docker.sock'
for image in [BASE['LLDAP'], BASE['AUTHELIA'], BASE['CLIENT'], BROWSER]:
assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image
name = 'csoc-native-browser-' + uuid.uuid4().hex[:12]
auth, mail, browser = [name + suffix for suffix in ['-auth', '-mail', '-browser']]
names = [name + '-probe', browser, auth, mail, name + '-app', name + '-portal',
name + '-extract', name + '-hasher', name]
values = {n: secrets.token_urlsafe(24) for n in [
'admin_password', 'bind_password', 'old_password', 'new_password', 'expired_password']}
result = {'scenarios': []}
cert, key = certificate()
try:
container(name, BASE['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())
container(mail, BASE['CLIENT'], 'container:' + name, {})
docker('exec', '-i', mail, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'mail_sink.py': (ASSETS / 'mail_sink.py').read_bytes()}))
docker('exec', '-d', mail, '/bin/sh', '-c',
'exec python /run/fixture/mail_sink.py > /run/fixture/server.log 2>&1')
browser_container(browser, name)
result.update(browser_assets(browser))
for case, role in [('read_only_refusal', 'lldap_strict_readonly'),
('successful_reset', 'lldap_password_manager'),
('expired_handle', 'lldap_password_manager')]:
assigned = probe(name, {**values, 'mode': 'bind_role', 'role': role})
config = BASE['configuration'](values['bind_password'])
config['server']['tls'] = {'certificate': '/run/fixture/cert.pem',
'key': '/run/fixture/key.pem'}
config['session']['cookies'][0]['authelia_url'] = 'https://auth.recovery.test:9091'
if case == 'expired_handle':
config['identity_validation']['reset_password']['jwt_lifespan'] = '1s'
config['notifier'] = {'disable_startup_check': True, 'smtp': {
'address': 'smtp://127.0.0.1:2525', 'sender': 'no-reply@example.test',
'identifier': 'auth.recovery.test', 'disable_require_tls': True,
'disable_starttls': True}}
container(auth, BASE['AUTHELIA'], 'container:' + name, {})
docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'configuration.yml': json.dumps(config).encode(),
'cert.pem': cert, 'key.pem': key}))
docker('exec', '-d', auth, '/bin/sh', '-c',
'exec /app/authelia --config /run/fixture/configuration.yml '
'> /run/fixture/server.log 2>&1')
ready = peers(name, {'mode': 'ready', 'certificate': cert.decode()})
docker('exec', '-i', browser, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({
'reset_browser.mjs': (ASSETS / 'reset_browser.mjs').read_bytes(),
'browser-input.json': json.dumps({
'new_password': values['new_password'],
'expect_expired': case == 'expired_handle',
'expected_success': case == 'successful_reset'}).encode()}))
checked = docker('exec', browser, 'node', '/run/fixture/reset_browser.mjs', check=False)
browser_result = json.loads(checked.stdout)
if checked.returncode:
raise RuntimeError('browser_' + browser_result.get('stage', 'failed'))
observed_values = (values if case != 'expired_handle' else {
**values, 'old_password': values['new_password'],
'new_password': values['expired_password']})
directory = peers(name, {**observed_values, 'mode': 'directory_check',
'certificate': cert.decode()})
assert directory['new_password_accepted'] is (case == 'successful_reset')
assert directory['old_password_accepted'] is (case != 'successful_reset')
result['scenarios'].append({'case': case, 'bind_role': role, **ready, **browser_result,
**directory, 'directory_group_creation_refused':
assigned['directory_group_creation_refused']})
docker('rm', '-f', '-v', auth)
result['app_journey'] = APP_STAGE['run'](name, browser, values, cert, key, peers)
except Exception:
fd, diagnostic = tempfile.mkstemp(prefix='csoc-native-browser-diagnostic-', suffix='.txt')
with os.fdopen(fd, 'w') as stream:
for current, filename in [(browser, 'browser-diagnostic.txt'),
(browser, 'issuer.log'), (auth, 'server.log'),
(mail, 'server.log'), (name + '-portal', 'server.log'),
(name + '-app', 'server.log')]:
text = docker('exec', current, 'cat', '/run/fixture/' + filename,
check=False).stdout.decode(errors='replace')
for value in values.values():
text = text.replace(value, '<fixture-value>')
text = re.sub(r'https?://[^\s\"\']+', '<fixture-url>', text)
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 names:
docker('rm', '-f', '-v', current, check=False)
assert all(not docker('ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip()
for n in names), 'fixture_cleanup'
result['cleanup_verified'] = True
assert not any(value in json.dumps(result) for value in values.values())
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()
sources = [Path(__file__), HERE / 'rehearse-native-password-reset.py',
ASSETS / 'probe.py', ASSETS / 'mail_sink.py', ASSETS / 'check_peers.py',
ASSETS / 'reset_browser.mjs', ASSETS / 'app_browser.mjs',
ASSETS / 'app_stage.py', ASSETS / 'user_portal.py', HERE / 'package-lock.json',
ASSETS / 'test_journey.py', HERE / 'check-native-recovery.py']
manifest = ''.join(str(p.relative_to(ROOT)) + ': ' + hashlib.sha256(p.read_bytes()).hexdigest()
+ '\n' for p in sources).encode()
receipt = {'schema': 'coulomb.native-account-recovery-browser.v1',
'status': 'isolated_native_recovery_browser_verified',
'observed_at': datetime.now(UTC).isoformat(), 'data': 'synthetic',
'production_access': False, 'provider_smtp_contacted': False,
'native_oidc_and_app_journey_verified': True,
'production_or_mfa_acceptance': False, 'fixture_tls_browser_errors_ignored': True,
'images': {'authelia': BASE['AUTHELIA'], 'lldap': BASE['LLDAP'],
'probe': BASE['CLIENT'], 'browser': BROWSER,
'issuer': APP_STAGE['ISSUER_IMAGE'],
'user_portal': APP_STAGE['USER_IMAGE']},
'source_manifest_sha256': hashlib.sha256(manifest).hexdigest(), 'findings': findings}
with source_path.open('xb') as stream:
stream.write(manifest)
with args.output.open('x') as stream:
stream.write(json.dumps(receipt, indent=2) + '\n')
print(json.dumps(findings))

View file

@ -8,7 +8,7 @@ status: active
owner: codex owner: codex
topic_slug: coulomb-social topic_slug: coulomb-social
created: "2026-10-08" created: "2026-10-08"
updated: "2026-10-30" updated: "2026-10-31"
origin: residual origin: residual
origin_ref: CSOC-WP-0010 origin_ref: CSOC-WP-0010
state_hub_workstream_id: "38208f72-c285-56fa-be44-de9b59a4e031" state_hub_workstream_id: "38208f72-c285-56fa-be44-de9b59a4e031"
@ -122,6 +122,24 @@ expiry/mail-sink/KeyCape/app coverage and productive release remain open here.
Final source-bound native proof includes verified cleanup and three CLI refusal Final source-bound native proof includes verified cleanup and three CLI refusal
cases. Full `make check` rerun:396 passed, zero skips, mandatory browsers included. cases. Full `make check` rerun:396 passed, zero skips, mandatory browsers included.
2026-10-31 native browser follow-through: the exact explicit node
scripts/native_recovery/test_journey.py::test_native_reset_directory_smtp_issuer_and_app
passes with zero skips. Real Authelia UI/LLDAP/SMTP-to-local-sink exercise read-only
refusal, successful password replacement, expired and replayed native links.
Real KeyCape PKCE/JWT plus the current app source and actual user-engine HTTP
claims service establish the app session; old-password and callback replay,
unknown-client, wrong-redirect and explicit directory/client tenant-conflict
controls refuse. One owner user/Member and unusable local password are verified.
Kernel network-none/loopback, scoped browser requests and all-container cleanup
are verified. Owner storage/PDP are fixtures; no productive grant, MFA or TLS
trust-store acceptance. Review/proof: docs/release/native-recovery-browser-review-2026-10-31.md
and native-recovery-browser-2026-10-31.{json,sources.txt}. Candidate registry binds
the node without promotion. Existing NK-WP-0054-T05 reset-authority return,
productive UC-010/011 verdicts and deployment/runtime acceptance remain open.
Full `make check`:396 passed, zero skips. Four separate synthetic JUnit-envelope
checks refuse wrong/skipped/failed/extra nodes without publishing a proof; the
real native gate itself passes its exact one node with zero skips.
## Completion ## 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. 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.