Verify native reset SMTP issuer and app recovery through Chromium
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
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:
parent
b00c13fb42
commit
7b4b75089c
17 changed files with 1326 additions and 3 deletions
5
Makefile
5
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
|
.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)"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
125
docs/release/native-recovery-browser-2026-10-31.json
Normal file
125
docs/release/native-recovery-browser-2026-10-31.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
docs/release/native-recovery-browser-2026-10-31.sources.txt
Normal file
12
docs/release/native-recovery-browser-2026-10-31.sources.txt
Normal 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
|
||||||
118
docs/release/native-recovery-browser-review-2026-10-31.md
Normal file
118
docs/release/native-recovery-browser-review-2026-10-31.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
68
scripts/check-native-recovery.py
Normal file
68
scripts/check-native-recovery.py
Normal 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)))
|
||||||
124
scripts/native_recovery/app_browser.mjs
Normal file
124
scripts/native_recovery/app_browser.mjs
Normal 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();
|
||||||
|
}
|
||||||
170
scripts/native_recovery/app_stage.py
Normal file
170
scripts/native_recovery/app_stage.py
Normal 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()}
|
||||||
84
scripts/native_recovery/check_peers.py
Normal file
84
scripts/native_recovery/check_peers.py
Normal 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)
|
||||||
119
scripts/native_recovery/mail_sink.py
Normal file
119
scripts/native_recovery/mail_sink.py
Normal 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()
|
||||||
119
scripts/native_recovery/reset_browser.mjs
Normal file
119
scripts/native_recovery/reset_browser.mjs
Normal 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();
|
||||||
|
}
|
||||||
33
scripts/native_recovery/test_journey.py
Normal file
33
scripts/native_recovery/test_journey.py
Normal 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
|
||||||
37
scripts/native_recovery/user_portal.py
Normal file
37
scripts/native_recovery/user_portal.py
Normal 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()
|
||||||
267
scripts/rehearse-native-reset-browser.py
Normal file
267
scripts/rehearse-native-reset-browser.py
Normal 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))
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue