diff --git a/Makefile b/Makefile index c17d103..4ccab51 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 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)" diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index 4b9367b..f1a7c51 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -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 issuance, MFA ceremony, TLS/browser flow, application session or productive 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. diff --git a/docs/release/native-recovery-browser-2026-10-31.json b/docs/release/native-recovery-browser-2026-10-31.json new file mode 100644 index 0000000..5f91425 --- /dev/null +++ b/docs/release/native-recovery-browser-2026-10-31.json @@ -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" + ] +} diff --git a/docs/release/native-recovery-browser-2026-10-31.sources.txt b/docs/release/native-recovery-browser-2026-10-31.sources.txt new file mode 100644 index 0000000..4c6e79f --- /dev/null +++ b/docs/release/native-recovery-browser-2026-10-31.sources.txt @@ -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 diff --git a/docs/release/native-recovery-browser-review-2026-10-31.md b/docs/release/native-recovery-browser-review-2026-10-31.md new file mode 100644 index 0000000..539a7ee --- /dev/null +++ b/docs/release/native-recovery-browser-review-2026-10-31.md @@ -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. diff --git a/docs/usecases/platform-communication.md b/docs/usecases/platform-communication.md index f67b946..56a61b2 100644 --- a/docs/usecases/platform-communication.md +++ b/docs/usecases/platform-communication.md @@ -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. This filesystem-notifier/API prerequisite does not replace the still-open full 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. diff --git a/docs/usecases/registry.json b/docs/usecases/registry.json index 8e8a78c..5bb6647 100644 --- a/docs/usecases/registry.json +++ b/docs/usecases/registry.json @@ -67,7 +67,11 @@ "manual_review": [ "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", diff --git a/scripts/check-native-recovery.py b/scripts/check-native-recovery.py new file mode 100644 index 0000000..6ffa976 --- /dev/null +++ b/scripts/check-native-recovery.py @@ -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))) diff --git a/scripts/native_recovery/app_browser.mjs b/scripts/native_recovery/app_browser.mjs new file mode 100644 index 0000000..ffb187c --- /dev/null +++ b/scripts/native_recovery/app_browser.mjs @@ -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(); +} diff --git a/scripts/native_recovery/app_stage.py b/scripts/native_recovery/app_stage.py new file mode 100644 index 0000000..6b135f5 --- /dev/null +++ b/scripts/native_recovery/app_stage.py @@ -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()} diff --git a/scripts/native_recovery/check_peers.py b/scripts/native_recovery/check_peers.py new file mode 100644 index 0000000..d1d071f --- /dev/null +++ b/scripts/native_recovery/check_peers.py @@ -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) diff --git a/scripts/native_recovery/mail_sink.py b/scripts/native_recovery/mail_sink.py new file mode 100644 index 0000000..4bf1c22 --- /dev/null +++ b/scripts/native_recovery/mail_sink.py @@ -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:' + reply('250 OK' if sender else '550 fixture sender only') + elif upper.startswith('RCPT TO:'): + recipient = ('alice@example.test' + if upper == 'RCPT TO:' 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() diff --git a/scripts/native_recovery/reset_browser.mjs b/scripts/native_recovery/reset_browser.mjs new file mode 100644 index 0000000..ed24e43 --- /dev/null +++ b/scripts/native_recovery/reset_browser.mjs @@ -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(); +} diff --git a/scripts/native_recovery/test_journey.py b/scripts/native_recovery/test_journey.py new file mode 100644 index 0000000..bdb80d9 --- /dev/null +++ b/scripts/native_recovery/test_journey.py @@ -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 diff --git a/scripts/native_recovery/user_portal.py b/scripts/native_recovery/user_portal.py new file mode 100644 index 0000000..8e47e99 --- /dev/null +++ b/scripts/native_recovery/user_portal.py @@ -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() diff --git a/scripts/rehearse-native-reset-browser.py b/scripts/rehearse-native-reset-browser.py new file mode 100644 index 0000000..4a12702 --- /dev/null +++ b/scripts/rehearse-native-reset-browser.py @@ -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, '') + text = re.sub(r'https?://[^\s\"\']+', '', text) + text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '', text) + stream.write(current + '\n' + text + '\n') + print('Private redacted fixture diagnostic: ' + diagnostic) + raise + finally: + for current in 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)) diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index dec2dec..dbd4626 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: coulomb-social created: "2026-10-08" -updated: "2026-10-30" +updated: "2026-10-31" origin: residual origin_ref: CSOC-WP-0010 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 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 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.