Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/CONTAINER-EXPOSURE-EVALUATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+. |
Expand Down
2 changes: 1 addition & 1 deletion docs/archive/backlog/BACKLOG-CLOSED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion messagefoundry/api/_ui_seam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion messagefoundry/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions messagefoundry/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions messagefoundry/config/tls_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion messagefoundry_webconsole/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions messagefoundry_webconsole/pages/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packaging/messagefoundry-webconsole/tests/test_webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1620,6 +1621,8 @@ def test_status_builder_escapes_and_formats() -> None:
assert "yes" in html and "single-node" in html # _yn + role
assert "<b>host</b>" not in html # hostile node host escaped
assert "&lt;b&gt;host&lt;/b&gt;" 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).
Expand Down
4 changes: 2 additions & 2 deletions tests/golden/webconsole_seam.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions tests/test_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading