Skip to content
Merged
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
9 changes: 6 additions & 3 deletions docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,12 @@ value. Under `[security].enforcement=ENFORCE` a DEK past its max-age + grace **e

For the off-loopback transports the TLS floor is **1.2+** (`tls_min_version`), which constrains key
exchange to forward-secret **(EC)DHE** suites. The **code-half is now built** (WP-L3-10 free lift):
[config/tls_policy.py](../../messagefoundry/config/tls_policy.py) pins the approved key-exchange groups
(`X25519:secp384r1:secp256r1` via `SSLContext.set_groups` on Python ≥3.13; OpenSSL already defaults to
these on 3.11/3.12) on every built API + MLLP TLS context (`harden_kex_groups`), and a `[api].tls_ciphers`
[config/tls_policy.py](../../messagefoundry/config/tls_policy.py) *attempts* the approved key-exchange
group pin (`X25519:secp384r1:secp256r1` via `SSLContext.set_groups`) on every built API + MLLP TLS
context (`harden_kex_groups`) — but **corrected 2026-07-29: `set_groups` is a Python 3.15 API, not 3.13,
so today it pins nothing and those contexts inherit OpenSSL's default group list** (forward-secret, but
wider than the approved three — see [PHI.md](PHI.md) §4 for the measured accepted set). The helper now
reports what it pinned so the gap is visible rather than assumed. A `[api].tls_ciphers`
settings validator (`validate_tls_ciphers`) rejects any non-forward-secret (static-RSA/DH) operator
cipher string at config load. On the default `127.0.0.1` bind no TLS is presented, so this is immaterial
today; the controls take effect once the engine terminates TLS off-loopback.
Expand Down
2 changes: 1 addition & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6293,7 +6293,7 @@ Two findings are worth surfacing here. **Posture B scores worse on Fails than Po

## 200. Transport enforcement: make the code refuse the insecure hop

> ✅ **SHIPPED 2026-07-13 (ADR 0092 + 2026-07-13 amendment) — the posture-keyed transport-hop refusal and ALL its DEFERRED residuals are closed.** The core (2026-07-11): the #200 cleartext-hop refusal **enforces on the primary `serve`/`reload` path, not only at `build_check`** — the live connector-build sites in `pipeline/wiring_runner.py` (`_start_outbound`, `_start_inbound_unsafe`, `_reconcile_outbounds`) stamp the derived `active_hop_posture`, so the raw-TCP/X12/MLLP/DICOM/anon-ftp guards **refuse a production-PHI cleartext outbound at serve**, and the strict verify-off cells (engine⇄store weakened TLS, MLLP/FTPS `tls_verify=false`, credentialed plain-ftp) route `MEFOR_ALLOW_INSECURE_TLS` through the **production-PHI clamp** (`config.settings.weakened_tls_escape_permitted`) so the escape can no longer relax a production-PHI hop. **Residuals now closed (2026-07-13):** (1) the **API PHI-read data-path guard** — `create_app` derives the API serve-hop disposition via the new pure `tls_policy.phi_read_hop_disposition` (reusing the ONE authority + the production-PHI clamp) and `api/security.enforce_phi_read_hop` (folded into `require_phi_read`; explicit on the step-up `search` route) **refuses (403, PHI-free)** a raw-view/attachment-download/summary read on a prod-PHI instance whose serve hop is not proven secure — loopback/TLS/proxy-terminated/synthetic/no-`[ai]` stay byte-identical; (2) the **`db_lookup`/`fhir_lookup` live-read posture stamp** — `_build_lookup_executor`/`_build_fhir_lookup_executor` now wrap construction in `active_hop_posture(self._hop_posture)`, so a prod-PHI weakened-TLS live read is refused (it previously keyed on the UNCLAMPED escape, posture unstamped) and a synthetic cleartext read is no longer false-closed; (3) **`messagefoundry check`** now runs the posture-stamped `build_check_registry` (new required `build-check` in `checks.py`; fail-safe SKIP with no `messagefoundry.toml`), so a prod-PHI cleartext hop is caught at commit/CI; (4) the **Posture-B tails** — a cert-authenticated `GET /service/identity` writes a `service_cert_auth` audit row, runtime-KEX enforcement (`harden_kex_groups` on the in-process context) + a real mutual-TLS handshake are handshake-tested. All compose with the #201 revocation guard / #199 cleartext-egress / #129 expiry-relaxation and never double-refuse a legitimate lane. Tests: `tests/test_hop_refusal_residuals.py` + `tests/test_api_tls.py`. **Genuinely deferred (infra-bound):** a full uvicorn-on-a-real-socket mTLS handshake through the live serve bind (Windows TLS CI legs) — the handshake tests exercise the same `build_api_ssl_context` context, so only the uvicorn wiring is uncovered. _(Re-scored 2026-07-10 → P2; filed by the ASVS 5.0 L3 re-score, PR #854.)_
> ✅ **SHIPPED 2026-07-13 (ADR 0092 + 2026-07-13 amendment) — the posture-keyed transport-hop refusal and ALL its DEFERRED residuals are closed.** The core (2026-07-11): the #200 cleartext-hop refusal **enforces on the primary `serve`/`reload` path, not only at `build_check`** — the live connector-build sites in `pipeline/wiring_runner.py` (`_start_outbound`, `_start_inbound_unsafe`, `_reconcile_outbounds`) stamp the derived `active_hop_posture`, so the raw-TCP/X12/MLLP/DICOM/anon-ftp guards **refuse a production-PHI cleartext outbound at serve**, and the strict verify-off cells (engine⇄store weakened TLS, MLLP/FTPS `tls_verify=false`, credentialed plain-ftp) route `MEFOR_ALLOW_INSECURE_TLS` through the **production-PHI clamp** (`config.settings.weakened_tls_escape_permitted`) so the escape can no longer relax a production-PHI hop. **Residuals now closed (2026-07-13):** (1) the **API PHI-read data-path guard** — `create_app` derives the API serve-hop disposition via the new pure `tls_policy.phi_read_hop_disposition` (reusing the ONE authority + the production-PHI clamp) and `api/security.enforce_phi_read_hop` (folded into `require_phi_read`; explicit on the step-up `search` route) **refuses (403, PHI-free)** a raw-view/attachment-download/summary read on a prod-PHI instance whose serve hop is not proven secure — loopback/TLS/proxy-terminated/synthetic/no-`[ai]` stay byte-identical; (2) the **`db_lookup`/`fhir_lookup` live-read posture stamp** — `_build_lookup_executor`/`_build_fhir_lookup_executor` now wrap construction in `active_hop_posture(self._hop_posture)`, so a prod-PHI weakened-TLS live read is refused (it previously keyed on the UNCLAMPED escape, posture unstamped) and a synthetic cleartext read is no longer false-closed; (3) **`messagefoundry check`** now runs the posture-stamped `build_check_registry` (new required `build-check` in `checks.py`; fail-safe SKIP with no `messagefoundry.toml`), so a prod-PHI cleartext hop is caught at commit/CI; (4) the **Posture-B tails** — a cert-authenticated `GET /service/identity` writes a `service_cert_auth` audit row, a real mutual-TLS handshake is handshake-tested (**corrected 2026-07-29:** the companion "runtime-KEX enforcement" claim here was wrong — `SSLContext.set_groups` is a Python 3.15 API, so `harden_kex_groups` pins nothing on today's interpreters and the test that asserted the FFDHE refusal self-skipped; measured, the context ACCEPTS ffdhe2048. Both fixed and the residual is now asserted — see the ADR 0092 2026-07-29 amendment and PHI.md §4). All compose with the #201 revocation guard / #199 cleartext-egress / #129 expiry-relaxation and never double-refuse a legitimate lane. Tests: `tests/test_hop_refusal_residuals.py` + `tests/test_api_tls.py`. **Genuinely deferred (infra-bound):** a full uvicorn-on-a-real-socket mTLS handshake through the live serve bind (Windows TLS CI legs) — the handshake tests exercise the same `build_api_ssl_context` context, so only the uvicorn wiring is uncovered. _(Re-scored 2026-07-10 → P2; filed by the ASVS 5.0 L3 re-score, PR #854.)_

**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium.

Expand Down
26 changes: 18 additions & 8 deletions docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,17 +635,27 @@ and a reverse-proxy / forwarded-header alternative are designed in
[ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md) (*Proposed* — build gated on a
scheduled off-loopback exposure).

**Key-exchange parameters `[BUILT — WP-L3-10 code half]` (ASVS 11.6.2).** Every TLS context the engine
**Key-exchange parameters `[PARTIAL — the 1.2+ floor and the cipher validator are enforced; the group
pin is INERT until Python 3.15]` (ASVS 11.6.2).** Every TLS context the engine
builds — the API/WebSocket listener ([api/tls.py](../messagefoundry/api/tls.py)) and the per-connection
MLLP server/client contexts ([transports/mllp.py](../messagefoundry/transports/mllp.py)) — enforces a
**TLS 1.2+ floor**, which constrains 1.2 to **(EC)DHE** key exchange and makes 1.3 ECDHE-only: forward-
secret key establishment, never static RSA/DH. Two controls in
[config/tls_policy.py](../messagefoundry/config/tls_policy.py) pin the *parameters*:

- **Approved groups pinned where supported.** Built contexts call `harden_kex_groups`, which sets the
approved ECDHE groups `X25519:secp384r1:secp256r1` via `SSLContext.set_groups` on Python ≥ 3.13. On
3.11/3.12 there is no public group-pinning API and OpenSSL's defaults already lead with exactly these
curves, so it is a deliberate no-op, not a downgrade.
secret key establishment, never static RSA/DH. **That floor is the enforced control.** Two further
controls in [config/tls_policy.py](../messagefoundry/config/tls_policy.py) address the *parameters* — and
only the second of them actually takes effect on today's interpreters:

- **Approved groups are *inherited*, not pinned — corrected 2026-07-29.** Built contexts call
`harden_kex_groups`, which pins the approved ECDHE groups `X25519:secp384r1:secp256r1` via
`SSLContext.set_groups` — an API that lands in **Python 3.15**. This bullet previously said "≥ 3.13",
and the practical effect of the error is that on every interpreter this project currently runs on
(measured: 3.14.6 / OpenSSL 3.5.7) the helper pins **nothing** and every built context inherits
OpenSSL's default group list. That default *is* forward-secret — the property the TLS 1.2+ floor
above exists to guarantee — but it is **wider than the approved list**: measured against the real API
context, it also accepts `ffdhe2048`, `ffdhe3072` and `secp521r1`. It refuses `secp224r1` and
`sect571r1`, so the gap is *wider than policy*, not *weak*. `harden_kex_groups` now **returns the
list it actually pinned** — `None` today — and `tests/test_tls_policy.py` asserts that `None`
unconditionally, so the first interpreter with the API turns the test red instead of letting the
claim drift back.
- **`tls_ciphers` is validated, not trusted.** An operator `[api].tls_ciphers` string is rejected at
config load if it would admit a **non-forward-secret** (static-RSA/DH) suite, so a misconfiguration
cannot widen the key exchange below policy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,35 @@ an execution. Before the next tag, run `security.yml`'s sbom job via `workflow_d
log — the install command there is byte-identical to `release.yml`'s, and
`test_sbom_install_is_byte_identical_in_release_and_security` now enforces that identity, because the
dry-run is evidence about the release step only for as long as the two commands are the same command.

## Amendment — 2026-07-29: `py/insecure-protocol` on the ASVS 12.1.1 TLS-floor probe

**Alert 145 — `py/insecure-protocol`, HIGH, `messagefoundry/config/tls_probe.py`. Dismissed `won't fix`.**

CodeQL is factually right and the finding does not apply. `tls_probe.py` is the **measurement** for ASVS
12.1.1: at startup, on a PHI instance behind a *declared* upstream TLS terminator under `enforce`, it dials
the operator's own `public_origin` and offers TLS 1.0 and 1.1. **A successful handshake is the finding** —
it proves the front door accepts a protocol NIST SP 800-52r2 withdrew, and the engine refuses to serve.
Offering the withdrawn version *is* the control; there is no implementation that measures whether a peer
accepts TLS 1.0 without asking it to.

Two settings the rule flags are load-bearing and mutation-proven in `tests/test_tls_floor_probe.py`:

- `minimum_version == maximum_version == TLSv1` **plus `ALL:@SECLEVEL=0`** — without the security-level
drop, modern OpenSSL will not even *send* the ClientHello, so the probe would measure **our** refusal to
ask rather than **their** refusal to answer, and a permissive front door would read as clean. Dropping
`SECLEVEL=0` is one of the five mutations that turn the suite red.
- `CERT_NONE` — the probe measures the **protocol floor**. An internal CA the engine does not trust would
abort the handshake *before the version was settled*, reporting "TLS 1.0 refused" for a door that was
never knocked on. Chain validation is a separate control (12.1.4 / `harden_verify_flags`).

**Scope, which is what makes the dismissal safe:** client contexts only, constructed in this module, used
for exactly one handshake, never returned to a caller, carrying no application data and no PHI. This is
**not a data path** and these settings must never be reused for one — the module docstring says so, and
the crypto-inventory row (`scripts/security/crypto_inventory_check.py`) repeats the warning at the place a
future author would look. Every TLS scanner (`testssl.sh`, `sslyze`, `nmap ssl-enum-ciphers`) is built the
same way; suppressing this rule for a scanner is the industry-standard disposition, not a local shortcut.

**Convergence note (per the rule above):** the anchor is `tls_probe.py:146`, inside `_offer_context`. That
module is new and small, so expect this to re-fire as a fresh alert number the first time anything is
inserted above line 146. Re-dismiss with this rationale rather than re-triaging from scratch.
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,11 @@ authority above:
4. **Posture-B tails.** (a) A **cert-authenticated intra-service auth** is now **audited**: the
`GET /service/identity` route (the only `require_service_cert` surface) writes a `service_cert_auth`
row into the tamper-evident chain naming the mapped principal (PHI/secret-free — auth plane + route
only). (b) **Runtime KEX enforcement** is verified: when the engine terminates TLS in-process,
only). (b) ~~**Runtime KEX enforcement** is verified: when the engine terminates TLS in-process,
`build_api_ssl_context` pins the approved forward-secret groups (`harden_kex_groups`), and a real
handshake test proves a client offering only a non-approved FFDHE group is refused — runtime
enforcement, not the operator attestation the proxy-terminated (Posture-B) case still relies on. (c) A
enforcement, not the operator attestation the proxy-terminated (Posture-B) case still relies on.~~
**WITHDRAWN 2026-07-29 — see the amendment below.** (c) A
real **mutual-TLS handshake** test exercises the exact server context the serve path builds
(`CERT_REQUIRED`): a trusted client cert completes, a missing one is refused. **Genuinely deferred
(infra-bound):** a full uvicorn-on-a-real-socket mTLS handshake through the live serve bind is left to
Expand Down Expand Up @@ -207,3 +208,40 @@ every escape-clamp is **byte-identical** to the former production-PHI behaviour
two carve-outs of the 2026-07-20 amendment re-key to `enforcement`, and the keyless-PHI ack is renamed
`allow_unencrypted_phi_in_production` → `allow_unencrypted_phi_under_strict_enforcement` (see the ADR 0140
amendment). The four hard-refused floor items and the unconditional ePHI audit are unchanged.

## Amendment (2026-07-29) — §4(b) "runtime KEX enforcement" is WITHDRAWN as a measurement error

The 2026-07-13 amendment's §4(b) made three assertions. **All three are wrong**, re-measured on the
shipping interpreter (Python 3.14.6 / OpenSSL 3.5.7):

1. *"`build_api_ssl_context` pins the approved forward-secret groups"* — it does not.
`SSLContext.set_groups` is a **Python 3.15** addition (typeshed guards it at
`sys.version_info >= (3, 15)`), so `harden_kex_groups` returns without pinning at **all six** of its
call sites and the built contexts inherit OpenSSL's default group list.
2. *"a real handshake test proves…"* — that test
(`test_kex_allow_list_enforced_at_runtime`) reached its assertion only through a **client-side**
`set_groups`, so on every interpreter this project runs on it hit `pytest.skip`. It never executed.
3. *"…a client offering only a non-approved FFDHE group is refused"* — the opposite is true. Measured by
pinning a client to one group at a time via `set_ecdh_curve` and handshaking against the real
`build_api_ssl_context`, at both `tls_min_version` 1.2 and 1.3: `ffdhe2048`, `ffdhe3072` and
`secp521r1` are all **accepted**. (`secp224r1` and `sect571r1` are refused.)

A skip was concealing a false assertion, and this ADR cited it as proof. That is worse than an untested
claim, because it reads as evidence.

**What this ADR relies on is unaffected.** The forward-secrecy property comes from the **TLS 1.2+ floor**,
which *is* enforced, and the inherited default list is itself forward-secret — the residual is *wider than
policy*, not *weak*. §4(a) (the `service_cert_auth` audit row) and §4(c) (the real mutual-TLS handshake
against the built `CERT_REQUIRED` context) were re-confirmed and stand.

**Fixed alongside this amendment:** `harden_kex_groups` now **returns the group list it actually pinned**
(`None` today), so the inertness is observable rather than silent; a failed pin returns `None` too, where
it previously fell through as if it had succeeded. The skipping test is replaced by one that *measures*
the accepted-group set and cannot skip, plus an unconditional receipt asserting the `None` — so the first
interpreter to grow the API turns the suite red and forces this amendment, `PHI.md` §4 and the ASVS
11.6.2 row to be re-derived together. See [PHI.md](../PHI.md) §4.

**Do not re-assert a KEX pin anywhere until `harden_kex_groups` returns non-`None`.**
`SSLContext.set_ecdh_curve` does exist and genuinely constrains the TLS 1.3 `supported_groups`, but it
takes exactly one OpenSSL curve short name — it cannot express a preference list, and pinning through it
would refuse two of the three approved groups. It is a costed hardening option, not a drop-in.
18 changes: 15 additions & 3 deletions messagefoundry/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2120,10 +2120,22 @@ def _require_oidc_fields(self) -> AuthSettings:
if missing:
raise ValueError(f"oidc_enabled requires: {', '.join(missing)}")

if self.oidc_client_secret is None and self.oidc_client_secret_ref is None:
# EMPTY, not just absent. `is None` was the test here, and it let the most common shape of a
# missing secret straight through: an env var exported with no value. `MEFOR_AUTH_OIDC_CLIENT_SECRET=`
# in a service wrapper or an NSSM environment entry produces `""`, which is not None, so the guard
# that exists to make a missing client secret fail at CONFIG LOAD did not fire — the failure moved
# to the first token exchange, as an IdP rejection an operator has to go read a proxy log to
# understand. `if not value` is already the emptiness test used by the `missing` list ten lines
# above; this line was the only one in the validator that disagreed. Whitespace is stripped for the
# test only — the value itself is never rewritten.
if (
not (self.oidc_client_secret or "").strip()
and not (self.oidc_client_secret_ref or "").strip()
):
raise ValueError(
"oidc_enabled requires a client secret: set oidc_client_secret (via "
"MEFOR_AUTH_OIDC_CLIENT_SECRET) or oidc_client_secret_ref (a [secrets].provider reference)"
"oidc_enabled requires a NON-EMPTY client secret: set oidc_client_secret (via "
"MEFOR_AUTH_OIDC_CLIENT_SECRET) or oidc_client_secret_ref (a [secrets].provider "
"reference). An env var exported with no value counts as missing."
)

# Every pinned URL must be https (no dev escape — this is an off-box trust boundary) and its
Expand Down
Loading
Loading