From 631458e5bec89e5d635f8d71c945bd64daba2f07 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 05:02:12 -0500 Subject: [PATCH 1/2] feat(api): report-only TLS key-exchange groups posture field; correct stale "pinned" doc claims (BACKLOG #338) The engine's TLS key-exchange (KEX) groups are INHERITED from OpenSSL's default group list, not pinned to the approved set. harden_kex_groups pins nothing until SSLContext.set_groups lands in Python 3.15, so on every interpreter this project currently runs on the approved pin is inert. This is documentation accuracy plus observability -- it changes no live TLS behaviour (the TLS 1.2+ floor is the enforced control), and on a NOT-DEPLOYED beta there is no exposure today; the pin is a future 3.15 hardening. Two parts: 1. Report-only surfacing. New pure helper config/tls_policy.kex_groups_report() builds a throwaway probe context and asks the ONE authority, harden_kex_groups, what it manages to pin -- so the read-out can never drift from what the connectors actually do. It returns "inherited (...)" on a pre-3.15 interpreter and "pinned: ..." on 3.15+. Surfaced as an additive SecurityPosture.kex_groups field (str | None, default None), wired in create_app beside fips_attestation(), rendered as a status-page row in the web console beside the FIPS/OpenSSL rows. Report-only: it reflects, and changes, no TLS behaviour. 2. Three doc-accuracy edits correcting restatements that still read as "pinned": CONTAINER-EXPOSURE-EVALUATION.md (verification table), ASVS-L2-PHASE0-CHANGES.md (PQC roadmap row), and #200's Closes line in docs/archive/backlog/BACKLOG-CLOSED.md (11.6.2 annotated PARTIAL). Each links to PHI.md's data-in-transit section, the single source of record for the measured accepted set, rather than restating it. The two Python-3.15 tripwire tests in test_tls_policy.py that fire when set_groups/get_groups land are left untouched -- they are the signal to actually set the pin. Engine UI seam bumped 17 -> 18: the golden seam snapshot introspects SecurityPosture's field set, so a purely additive field trips the handshake; SUPPORTED_ENGINE_SEAMS and the golden snapshot updated to match. Tests: test_tls_policy.test_kex_groups_report_reports_inherited_today (the helper reports inherited + names the approved list, never "pinned:"); test_api_auth.test_security_posture_reports_kex_groups (the field flows through the MONITORING_READ-gated, audited posture route and matches the helper); a status-builder assertion that the console renders the row. All three falsified: blanking the helper reddens the two report tests (assert 'inherited' in ''); removing the console row reddens the status-builder test (assert 'key-exchange' in html). --- docs/ASVS-L2-PHASE0-CHANGES.md | 2 +- docs/CONTAINER-EXPOSURE-EVALUATION.md | 2 +- docs/archive/backlog/BACKLOG-CLOSED.md | 2 +- messagefoundry/api/_ui_seam.py | 8 +++++- messagefoundry/api/app.py | 10 +++++++- messagefoundry/api/models.py | 5 ++++ messagefoundry/config/tls_policy.py | 25 +++++++++++++++++++ messagefoundry_webconsole/__init__.py | 2 +- messagefoundry_webconsole/pages/monitoring.py | 5 ++++ .../tests/test_webui.py | 3 +++ tests/golden/webconsole_seam.snapshot | 4 +-- tests/test_api_auth.py | 22 ++++++++++++++++ tests/test_tls_policy.py | 19 ++++++++++++++ 13 files changed, 101 insertions(+), 8 deletions(-) diff --git a/docs/ASVS-L2-PHASE0-CHANGES.md b/docs/ASVS-L2-PHASE0-CHANGES.md index bd0f09e7..efc68247 100644 --- a/docs/ASVS-L2-PHASE0-CHANGES.md +++ b/docs/ASVS-L2-PHASE0-CHANGES.md @@ -251,7 +251,7 @@ maintainer wears every hat today; the label fixes accountability, not headcount) | Vault Transit at-rest (`vault_transit`) | Bulk crypto runs inside Vault, so a PQC data key is a **Transit key-type change in Vault** with no engine code change; existing `mfenc:v3` blobs re-key via `rotate-key` | Trigger: OpenBao/Vault Transit ships a PQC key type · **review 2027-01** | store-crypto maintainer | | Password hashing | argon2id `needs_rehash`-on-login upgrades params or the primitive transparently on next sign-in | Trigger: OWASP/NIST password-hash guidance change · **review 2027-01** | auth maintainer | | Hashing / signing chokepoints | SHA-256 (session-token storage, audit chain, integrity digests) → a longer digest / SHA-3 is a one-line primitive swap per chokepoint; the audit chain re-anchors from the swap point | Trigger: a SHA-2 deprecation signal · **review 2027-01** | store-crypto maintainer | -| Transport TLS → hybrid-KEM | Adopt X25519 + ML-KEM once stdlib `ssl` / platform OpenSSL ship it and add it to the pinned group/cipher policy (WP-L3-10); immaterial on the default loopback bind | Trigger: platform OpenSSL hybrid-KEM support · **track 2026-H2, review 2027-01** | transport/TLS maintainer | +| Transport TLS → hybrid-KEM | Adopt X25519 + ML-KEM once stdlib `ssl` / platform OpenSSL ship it and add it to the approved group/cipher policy (WP-L3-10); immaterial on the default loopback bind | Trigger: platform OpenSSL hybrid-KEM support · **track 2026-H2, review 2027-01** | transport/TLS maintainer | | WebAuthn COSE public keys (at rest) | Verification material via the `webauthn` library + a registered algorithm allow-list; a PQC COSE alg is an allow-list addition once authenticators + the library ship it | Trigger: FIDO2/WebAuthn PQC alg support in the `[webauthn]` extra · **review 2027-01** | auth/WebAuthn maintainer | | OIDC RP id-token verification | Cached IdP verifying keys behind the closed `SignatureAlgorithm` enum + the JWKS floor (`auth/oidc/jwks.py`); a PQC JOSE alg is an enum addition once IdPs issue it | Trigger: JOSE PQC signature standardization + IdP issuance · **review 2027-01** | auth/federation maintainer | | Per-message JWS (RS256/PS256/ES256) | `kid`-carried key rotation + the KeyProvider seam (`transports/signing.py`); a PQC JOSE signature alg is added additively per connection | Trigger: a JOSE PQC signature RFC + partner support · **review 2027-01** | transport/signing maintainer | diff --git a/docs/CONTAINER-EXPOSURE-EVALUATION.md b/docs/CONTAINER-EXPOSURE-EVALUATION.md index 591f248f..bd48e0df 100644 --- a/docs/CONTAINER-EXPOSURE-EVALUATION.md +++ b/docs/CONTAINER-EXPOSURE-EVALUATION.md @@ -47,7 +47,7 @@ operational notes. | Control | Where | Confirmed behavior | |---|---|---| -| API/WSS in-process TLS (WP-13a) | [`api/tls.py`](../messagefoundry/api/tls.py) `build_api_ssl_context`; [`config/settings.py`](../messagefoundry/config/settings.py) `ApiSettings.tls_*` | `PROTOCOL_TLS_SERVER`, `minimum_version` from `tls_min_version` (1.2/1.3 floor), `load_cert_chain(cert, key, password)`, optional ciphers, hardened KEX groups + strict X.509; opt-in mTLS via `tls_client_ca_file` → `CERT_REQUIRED`. Wired into the single `uvicorn.run(...)` via `ssl_context_factory` ([`__main__.py`](../messagefoundry/__main__.py) ~538-545). | +| API/WSS in-process TLS (WP-13a) | [`api/tls.py`](../messagefoundry/api/tls.py) `build_api_ssl_context`; [`config/settings.py`](../messagefoundry/config/settings.py) `ApiSettings.tls_*` | `PROTOCOL_TLS_SERVER`, `minimum_version` from `tls_min_version` (1.2/1.3 floor), `load_cert_chain(cert, key, password)`, optional ciphers, inherited KEX groups (approved-group pin inert until Python 3.15 - see [PHI.md](PHI.md) §4) + strict X.509; opt-in mTLS via `tls_client_ca_file` → `CERT_REQUIRED`. Wired into the single `uvicorn.run(...)` via `ssl_context_factory` ([`__main__.py`](../messagefoundry/__main__.py) ~538-545). | | API bind guard ("exposed" gate) | [`__main__.py`](../messagefoundry/__main__.py) ~419-451 | Non-loopback `[api].host` → **allow** if `tls_enabled`, **allow** if `tls_terminated_upstream` (+`trusted_proxies`), **warn** if `--allow-insecure-bind`, else **refuse (exit 2)**. Auth-disabled non-loopback is refused by a separate earlier gate **regardless of** `--allow-insecure-bind`. | | MFA-at-exposure gate | [`__main__.py`](../messagefoundry/__main__.py) ~462-481 | Non-loopback + `auth.enabled` + **not** `require_mfa`: **refuse** on a production PHI instance, **warn** on a non-production PHI instance, quiet on synthetic. Gates **local** Administrator accounts only (AD MFA delegated). | | MLLP-over-TLS (WP-13b) | [`transports/mllp.py`](../messagefoundry/transports/mllp.py) `_mllp_ssl_context`; `MLLP(...)` in [`config/wiring.py`](../messagefoundry/config/wiring.py) ~540-610 | Per-connection `tls=true`. Inbound presents `tls_cert_file`/`tls_key_file`; `tls_ca_file` opts into mTLS (`CERT_REQUIRED`). Outbound verifies the peer (`tls_verify=true` default; `false` refused unless `MEFOR_ALLOW_INSECURE_TLS`), optional client cert. `start_server(ssl=)` / `open_connection(ssl=, server_hostname=)`. TLS 1.2+. | diff --git a/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index df7f8704..ad3dd55e 100644 --- a/docs/archive/backlog/BACKLOG-CLOSED.md +++ b/docs/archive/backlog/BACKLOG-CLOSED.md @@ -4290,7 +4290,7 @@ Two findings are worth surfacing here. **Posture B scores worse on Fails than Po **Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium. -**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2, 12.1.3, 12.2.2, 12.3.1, 12.3.3, 12.3.5 · *(class 3)* +**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2 (PARTIAL - KEX-group pin inert until Python 3.15; see PHI.md §4), 12.1.3, 12.2.2, 12.3.1, 12.3.3, 12.3.5 · *(class 3)* **Scope:** Extend the existing exposed-gate pattern (which already refuses a non-loopback plaintext bind) to the remaining unencrypted and unauthenticated paths: the Posture-B proxy→engine cleartext `ws://` / `http://` hop, the `--allow-insecure-bind` escape, mTLS as an *identity* rather than a bare admission gate, KEX/cipher validation when TLS is proxy-terminated, and cert-authenticated (rather than IP-trusted) intra-service auth. diff --git a/messagefoundry/api/_ui_seam.py b/messagefoundry/api/_ui_seam.py index 5a86a599..c7d8978c 100644 --- a/messagefoundry/api/_ui_seam.py +++ b/messagefoundry/api/_ui_seam.py @@ -88,7 +88,13 @@ #: `app.state.auth` would register nothing in production while passing every test that constructs the #: app with `auth=` directly. Additive with defaults, and the defaults are the STRICT position — an #: older or partial caller gets the interstitial on every absolute destination, never none. -ENGINE_UI_SEAM: int = 17 +#: seam v18 (ASVS 11.6.2, #338): SecurityPosture gained the additive REPORT-ONLY `kex_groups` field — a +#: read-out of whether the approved TLS key-exchange groups are PINNED on built contexts or INHERITED +#: from OpenSSL's default group list (today always inherited: `SSLContext.set_groups` is a Python 3.15 +#: API). Report-only, reflects/changes NO live TLS behaviour; additive with a default, so an older +#: console simply ignores it. Bumped because the golden seam snapshot introspects SecurityPosture's +#: field set, so any added field trips the handshake even when it is purely additive. +ENGINE_UI_SEAM: int = 18 @dataclass(frozen=True, slots=True) diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index cc1a1833..b7fba40d 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -242,7 +242,11 @@ hop_posture_from_ai, security_loosenings, ) -from messagefoundry.config.tls_policy import fips_attestation, phi_read_hop_disposition +from messagefoundry.config.tls_policy import ( + fips_attestation, + kex_groups_report, + phi_read_hop_disposition, +) from messagefoundry.config.wiring import ( EnvRef, Registry, @@ -1535,6 +1539,9 @@ async def security_posture( # FIPS-provider attestation of the interpreter's ssl/_hashlib OpenSSL (report-only, #73 / ADR 0120): # metadata (a boolean + version string), never key material, never enforced. fips_mode, openssl_version = fips_attestation() + # TLS key-exchange groups read-out (report-only, #338). Pure helper over a throwaway probe + # context; reflects/changes NO live TLS behaviour, reports "inherited" until Python 3.15. + kex_groups = kex_groups_report() # Platform memory-encryption READ-OUT (report-only, ADR 0152 Phase 1). Pure platform read # (/proc/cpuinfo flags + guest device presence on Linux; all-None everywhere else), no engine # state, never raises. It reports what the HOST SAYS ABOUT ITSELF and therefore satisfies @@ -1576,6 +1583,7 @@ async def security_posture( synthetic_relaxation=synthetic_relaxation, fips_mode=fips_mode, # interpreter ssl/_hashlib OpenSSL FIPS-provider state; None=undeterminable openssl_version=openssl_version, # that OpenSSL's version string (public metadata) + kex_groups=kex_groups, # report-only: are the approved KEX groups pinned or inherited (#338)? # ADR 0152: a SELF-REPORT plus the operator's claim. Neither satisfies ASVS 11.7.1 at any # value — see the field comments on SecurityPosture. The disclaimer ships IN THE BODY # (memory_encryption_note), unconditionally: this endpoint is the designated evidence diff --git a/messagefoundry/api/models.py b/messagefoundry/api/models.py index 815c4931..79703658 100644 --- a/messagefoundry/api/models.py +++ b/messagefoundry/api/models.py @@ -969,6 +969,11 @@ class SecurityPosture(BaseModel): # cryptography-wheel OpenSSL that encrypts PHI at rest — so it is "reported", never "certified". fips_mode: bool | None = None openssl_version: str | None = None + # TLS key-exchange groups read-out (report-only, #338 / ASVS 11.6.2). A read of whether the approved + # KEX groups are PINNED on built contexts or INHERITED from OpenSSL's default group list — today + # always inherited, because ``SSLContext.set_groups`` is a Python 3.15 API. Report-only: it reflects, + # and changes, NO live TLS behaviour (the TLS 1.2+ floor is the enforced control; see docs/PHI.md §4). + kex_groups: str | None = None # Platform memory-encryption READ-OUT (report-only, ADR 0152 Phase 1 / ASVS 11.7.1) + the operator # declaration (Phase 2). Named "self_reported" on purpose: these are values the host OS emits # about ITSELF (/proc/cpuinfo flags, guest device-node presence), and 11.7.1 exists precisely diff --git a/messagefoundry/config/tls_policy.py b/messagefoundry/config/tls_policy.py index ffbe826c..c15fb6ba 100644 --- a/messagefoundry/config/tls_policy.py +++ b/messagefoundry/config/tls_policy.py @@ -70,6 +70,7 @@ "harden_cipher_suites", "harden_kex_groups", "harden_verify_flags", + "kex_groups_report", "relax_verify_expiry", "in_process_tls_revocation_refused", "insecure_hop_disposition", @@ -162,6 +163,30 @@ def harden_kex_groups(ctx: ssl.SSLContext) -> str | None: return APPROVED_KEX_GROUPS +def kex_groups_report() -> str: + """Report whether the approved KEX groups are PINNED on built contexts, or INHERITED (#338). + + A report-only read-out, the KEX sibling of :func:`fips_attestation` — it changes NO live TLS + behaviour and never raises. It builds a throwaway probe context and asks the ONE authority, + :func:`harden_kex_groups`, what it manages to pin there, so the report can never disagree with what + the connectors actually do (a second, hand-rolled version-check would be exactly the drift #338 is + about). On every interpreter this project currently runs on ``SSLContext.set_groups`` is absent + (it is a **Python 3.15** API), so ``harden_kex_groups`` pins nothing and this returns the + ``inherited`` string; the first interpreter that grows the API flips it to ``pinned: ...``. + + The ``inherited`` wording deliberately does NOT restate the measured accepted set (ffdhe2048 / + ffdhe3072 / secp521r1) — that lives in ``docs/PHI.md`` §4, and stating a load-bearing fact once + keeps the two from drifting (CLAUDE.md §11).""" + probe = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + pinned = harden_kex_groups(probe) + if pinned is not None: + return f"pinned: {pinned}" + return ( + f"inherited (OpenSSL default group list; the approved pin {APPROVED_KEX_GROUPS} is inert " + "until Python 3.15 - see docs/PHI.md §4)" + ) + + def harden_verify_flags(ctx: ssl.SSLContext) -> None: """Best-effort enable strict X.509 path validation on a *verifying* ``ctx`` (ASVS 12.1.4). diff --git a/messagefoundry_webconsole/__init__.py b/messagefoundry_webconsole/__init__.py index 186466be..c94e72a3 100644 --- a/messagefoundry_webconsole/__init__.py +++ b/messagefoundry_webconsole/__init__.py @@ -45,7 +45,7 @@ # If cross-seam support is ever genuinely wanted, re-widen this set AND add the CI matrix that # installs the MIN and MAX supported engine builds — the claim and its test land together, or not # at all. -SUPPORTED_ENGINE_SEAMS: frozenset[int] = frozenset({17}) +SUPPORTED_ENGINE_SEAMS: frozenset[int] = frozenset({18}) #: The vendored static assets shipped in THIS wheel (mounted at /ui/static by :func:`mount_ui`). STATIC_DIR = Path(__file__).parent / "static" diff --git a/messagefoundry_webconsole/pages/monitoring.py b/messagefoundry_webconsole/pages/monitoring.py index c3413437..c6bd67c0 100644 --- a/messagefoundry_webconsole/pages/monitoring.py +++ b/messagefoundry_webconsole/pages/monitoring.py @@ -403,6 +403,11 @@ def status( _fips(getattr(posture, "fips_mode", None)), ], ["OpenSSL version (ssl/_hashlib)", _opt(getattr(posture, "openssl_version", None))], + # TLS key-exchange groups read-out (report-only, #338 / ASVS 11.6.2). Mirrors the FIPS/ + # OpenSSL rows above: says whether the approved KEX groups are pinned on built contexts or + # inherited from OpenSSL's default (inherited until Python 3.15). getattr-with-default is + # defensive, not cross-seam compat (one supported seam, #279), so a None renders as a dash. + ["TLS key-exchange groups (reported)", _opt(getattr(posture, "kex_groups", None))], # Platform memory-encryption read-out (report-only, ADR 0152 Phase 1 / ASVS 11.7.1). # Wording is a security property here: every label says "self-reported", and capability # ("this silicon can") is a SEPARATE row from activation ("this guest is"), because a diff --git a/packaging/messagefoundry-webconsole/tests/test_webui.py b/packaging/messagefoundry-webconsole/tests/test_webui.py index b59db361..d7e18b46 100644 --- a/packaging/messagefoundry-webconsole/tests/test_webui.py +++ b/packaging/messagefoundry-webconsole/tests/test_webui.py @@ -1591,6 +1591,7 @@ def test_status_builder_escapes_and_formats() -> None: key_id="abc123", require_encryption=True, allow_unencrypted_phi=False, + kex_groups="inherited (test read-out)", # #338 report-only KEX read-out ) cluster = ClusterStatus( node_id="n1", clustered=False, is_leader=True, role="single-node", config_version=0 @@ -1620,6 +1621,8 @@ def test_status_builder_escapes_and_formats() -> None: assert "yes" in html and "single-node" in html # _yn + role assert "host" not in html # hostile node host escaped assert "<b>host</b>" in html + # #338: the report-only TLS key-exchange read-out row renders (label + the inherited value). + assert "key-exchange" in html and "inherited" in html # L6a: the hosting-service badge renders the state + name. assert "Hosting service" in html and "MEFOR_Engine" in html and "running" in html # When reporting is off, the badge says so (no state leaked). diff --git a/tests/golden/webconsole_seam.snapshot b/tests/golden/webconsole_seam.snapshot index 7ada857d..1a6011bf 100644 --- a/tests/golden/webconsole_seam.snapshot +++ b/tests/golden/webconsole_seam.snapshot @@ -5,7 +5,7 @@ # This is a GOLDEN gate: any diff means the seam contract changed - see the test's failure hint. ## ENGINE_UI_SEAM -17 +18 ## dataclass messagefoundry.api._ui_seam.UiDeps engine_seam @@ -170,7 +170,7 @@ MetricsHistorySample: outbox_by_status, ts PendingApprovalResponse: approval_id, detail, operation, status ReloadRequest: config_dir, dry_run ReloadResult: dry_run, handlers, inbound, outbound, routers, running -SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, data_class, encryption_enabled, enforcement, environment, fips_mode, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, synthetic_relaxation +SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, data_class, encryption_enabled, enforcement, environment, fips_mode, kex_groups, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, synthetic_relaxation ServiceStatusInfo: enabled, service_name, state StatsResetRequest: all, targets StatsResetTarget: channel_id, destination, role diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 06defb59..b06dca34 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -1306,6 +1306,28 @@ async def test_security_posture_reports_fips_attestation(engine: Engine) -> None assert rows # at least the read above was recorded +async def test_security_posture_reports_kex_groups(engine: Engine) -> None: + # #338 / ASVS 11.6.2: the posture route reports whether the approved TLS key-exchange groups are + # PINNED on built contexts or INHERITED from OpenSSL's default group list — report-only metadata, + # MONITORING_READ-gated + audited like the rest of the payload, and it changes NO live TLS behaviour + # (inherited on every interpreter before Python 3.15). + from messagefoundry.config.tls_policy import kex_groups_report + + service = await _service(engine) + await _add(service, "vw", Role.VIEWER) # holds monitoring:read + async with _posture_client(engine, service) as c: + vw = _auth((await _login(c, "vw")).json()["token"]) + resp = await c.get("/security/posture", headers=vw) + assert resp.status_code == 200 + body = resp.json() + # The additive field is present and matches the pure helper's read-out; inherited on this runtime. + assert body["kex_groups"] == kex_groups_report() + assert "inherited" in body["kex_groups"] + # The read stays audited (security.posture_view) — a viewer's read produced an audit row. + rows = await engine.store.list_audit(limit=20, action="security.posture_view") + assert rows # at least the read above was recorded + + async def test_security_posture_encrypted_exposes_fingerprint_not_key_bytes( tmp_path: Path, engine: Engine ) -> None: diff --git a/tests/test_tls_policy.py b/tests/test_tls_policy.py index c55864d9..ab5ef3f7 100644 --- a/tests/test_tls_policy.py +++ b/tests/test_tls_policy.py @@ -31,6 +31,7 @@ in_process_tls_revocation_refused, insecure_hop_disposition, is_loopback_hop_host, + kex_groups_report, tls_revocation_attested, validate_tls_ciphers, ) @@ -196,6 +197,24 @@ def test_approved_groups_are_ecdhe_curves() -> None: # API; do not "normalise" them to one. +# --- kex_groups_report: report-only KEX read-out (#338, ASVS 11.6.2) ---------------------------- +def test_kex_groups_report_reports_inherited_today() -> None: + """#338: the report-only KEX read-out says the approved groups are INHERITED on this runtime. + + ``SSLContext.set_groups`` is a Python 3.15 API, so ``harden_kex_groups`` pins nothing on any + interpreter this project currently runs on. The read-out must therefore report "inherited" (never + "pinned:") and name the approved group list it WOULD pin, so an operator reading it sees what is at + stake. It is a pure read-out over a throwaway probe context — report-only, and it never raises. On + the Python 3.15 interpreter that grows the API this flips to "pinned:", the same signal the + ``test_the_group_pin_is_inert_on_this_runtime_and_says_so`` tripwire fires on. + """ + report = kex_groups_report() + assert isinstance(report, str) and report # a non-empty string + assert "inherited" in report # nothing is pinned on a pre-3.15 interpreter + assert APPROVED_KEX_GROUPS in report # names the approved list it WOULD pin + assert "pinned:" not in report # the "pinned:" branch is 3.15-only + + # --- harden_verify_flags ----------------------------------------------------------------------- def test_harden_verify_flags_sets_strict() -> None: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) From 693f43989d9168ef66ec8515d71a4ff382156b29 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 05:02:32 -0500 Subject: [PATCH 2/2] docs(backlog): flip #338 banner to shipped (BACKLOG #338) Flip the #338 status banner from filed/not-started to shipped, now that the report-only kex_groups posture field and the three doc-accuracy corrections have landed. The banner records that the KEX groups are documented as inherited (the pin is inert until Python 3.15) plus the report-only surfacing behind engine seam v18. Banner line only, under the #338 heading, verified by number. The ranked table, the four census distribution lines, and every other item's banner are untouched. The census was NOT recomputed. --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 38305897..f7e1039a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -3127,7 +3127,7 @@ What it *is*: an adopter who turns on the strict gate gets a **green build** on ## 338. TLS key-exchange groups are inherited, not pinned -> 🔢 **Filed 2026-08-01 — not started.** Value **3/10** · Difficulty **2/10** · _fill-in_. `harden_kex_groups` still returns `None` when `set_groups` is absent, and all three restatements survive the 2026-07-29 sweep — `CONTAINER-EXPOSURE-EVALUATION.md` still says "hardened KEX groups" under a *verification* heading, `BACKLOG.md:6422` still lists 11.6.2 in #200's Closes line against PHI.md's PARTIAL, and `ASVS-L2-PHASE0-CHANGES.md:254` still presupposes a pin — but every group that gets in is forward-secret and the floor plus `harden_cipher_suites` admit nothing static, so this is documentation accuracy plus observability; three doc edits and one additive report-only `SecurityPosture` field beside `fips_attestation()`, with the two tripwire tests left alone as the 3.15 trigger. +> ✅ **SHIPPED 2026-08-06 (#338) — key-exchange groups documented as inherited, plus a report-only surfacing.** Value **3/10** · Difficulty **2/10**. `harden_kex_groups` pins nothing until `SSLContext.set_groups` lands in **Python 3.15**, so every built context inherits OpenSSL's default group list — forward-secret but wider than the approved pin — which makes this documentation accuracy plus observability, changing no live TLS behaviour. The three restatements that still read as *pinned* are corrected to say *inherited*: `CONTAINER-EXPOSURE-EVALUATION.md` and `ASVS-L2-PHASE0-CHANGES.md`, plus #200's Closes line in `docs/archive/backlog/BACKLOG-CLOSED.md` (11.6.2 annotated PARTIAL, see PHI.md §4). Added an additive report-only `kex_groups` field on `SecurityPosture` beside `fips_attestation()`, rendered on the console status page behind engine seam v18. The two Python-3.15 tripwire tests are left in place as the trigger to set the pin. **Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build. **Severity:** low.