Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
631458e
feat(api): report-only TLS key-exchange groups posture field; correct…
wshallwshall Aug 6, 2026
693f439
docs(backlog): flip #338 banner to shipped (BACKLOG #338)
wshallwshall Aug 6, 2026
2d64db6
docs(research): OpenFlow step-attribute vocabulary gap-map (BACKLOG #…
wshallwshall Aug 6, 2026
7ddc690
docs(backlog): flip #238 banner to CLOSED (BACKLOG #238)
wshallwshall Aug 6, 2026
fec380b
fix(serve): auth-off startup arm now refuses a declared terminator (B…
wshallwshall Aug 6, 2026
5cb4661
docs(backlog): mark #1013 fixed; record the resolved load-order quest…
wshallwshall Aug 6, 2026
f87143e
docs(coord): the session record has no branch, and two rosters disagr…
wshallwshall Aug 6, 2026
3443de8
docs(supply-chain): correct at least two claims the shipped v0.3.2 re…
wshallwshall Aug 7, 2026
524fc77
Merge branch 'main' into fix-338-tls-kex-report
wshallwshall Aug 7, 2026
a7cd6f4
Merge branch 'main' into review-238-openflow-attrs
wshallwshall Aug 7, 2026
9fa01f9
Merge branch 'main' into fix-1013-authoff-startup-arm
wshallwshall Aug 7, 2026
675cd94
Merge branch 'main' into claude/coord-roster-semantics
wshallwshall Aug 7, 2026
e05b2ad
Merge branch 'main' into claude/draft-sbom-ec9763
wshallwshall Aug 7, 2026
7f887dd
Merge remote-tracking branch 'origin/fix-338-tls-kex-report' into mer…
wshallwshall Aug 7, 2026
39ff8bd
Merge remote-tracking branch 'origin/review-238-openflow-attrs' into …
wshallwshall Aug 7, 2026
13ff036
Merge remote-tracking branch 'origin/fix-1013-authoff-startup-arm' in…
wshallwshall Aug 7, 2026
e501d9c
Merge remote-tracking branch 'origin/claude/coord-roster-semantics' i…
wshallwshall Aug 7, 2026
103280c
Merge remote-tracking branch 'origin/claude/draft-sbom-ec9763' into m…
wshallwshall Aug 7, 2026
e744f85
Merge branch 'main' into merge-train-b
wshallwshall Aug 7, 2026
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
6 changes: 4 additions & 2 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3140,7 +3140,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 Expand Up @@ -4659,7 +4659,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre

## 1013. The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts

> 🔢 **Filed 2026-08-04 — not started.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm reads `not settings.auth.enabled and not settings.api.is_loopback`, so it does not fire for a declared TLS-terminating proxy. A PHI instance with authentication **entirely off** behind a declared terminator starts with **no refusal and no warning** — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagree about what "exposed" means, in the same file, for the same topology.
> **Fixed 2026-08-06.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm read `not settings.auth.enabled and not settings.api.is_loopback` (the bind alone), so it did not fire for a declared TLS-terminating proxy: a PHI instance with authentication **entirely off** behind a declared terminator would have started with **no refusal and no warning** on first deployment — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagreed about what "exposed" means, in the same file, for the same topology. The auth-off arm now consults the single `instance_exposed` definition (hoisted above it), so it refuses on a non-loopback bind OR a declared terminator.

**Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — no authentication at all on an off-loopback PHI instance.

Expand All @@ -4675,6 +4675,8 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre

⚠️ **THE REMEDY IS UNPROVEN — do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles.

> **AMENDED 2026-08-06 — remedy proven; the load-order question is resolved.** The prerequisite this item flagged as unproven holds. `instance_exposed`'s inputs are fully resolved where the auth-off arm runs: its two fields — `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` — are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the arm and the former definition site is `serve_ui` (twice), which the predicate does not read. So the single definition was hoisted above the auth-off arm with a byte-identical value, and the arm was widened to consult it (refuse on a non-loopback bind OR a declared terminator). Exactly one definition site remains, per the pointer comment #326 left ("`instance_exposed` is NOT re-derived here") — the hoist shifts that comment's line, so it is named rather than pinned to a number.

**#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed.

⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not — a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here.
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
4 changes: 2 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ engine binds. Three planes sit at different exposure levels:

| Plane | What it is | Where it binds | Posture |
|---|---|---|---|
| **Management** | web console (`/ui`) / IDE → engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) — disabling it is refused on a non-loopback bind, but on **loopback** it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface — keep it off general-user VLANs |
| **Management** | web console (`/ui`) / IDE → engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) — disabling it is refused on a non-loopback bind **or a loopback bind behind a declared TLS terminator**, but on a bare **loopback** bind with no declared terminator it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface — keep it off general-user VLANs |
| **Data** | inbound feeds you *receive* (MLLP, TCP/X12, DB-poll) | the **internal network interface** — feeds come from other systems on your LAN, not `127.0.0.1` | **TLS on the wire where the channel has it** (enable MLLP-over-TLS; **TCP/X12 have none** — segment them) + the `[egress]`/ingress allow-lists + your network segmentation. PHI must not cross the LAN in cleartext |
| **Inbound web service** | a partner *calls into* MEFOR (`Http()` source) | its own connector-owned socket | built (ADR 0023) — per-connection TLS + opt-in mTLS + IP allow-list, **no bearer/basic partner auth**. Both peer controls are **optional and unenforced** — a TLS-on listener with neither accepts any peer; see the caveat below |

Expand Down Expand Up @@ -274,7 +274,7 @@ authentication on the channel · **Egress gate** = the `[egress]` allow-list tha

| Channel | Bind default | TLS support | Auth | Ingress/egress gate | Off-loopback guarded? |
|---|---|---|---|---|---|
| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true → `127.0.0.1` | **Yes** — in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (≥1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC — **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind, and on loopback yields a full-privilege *system* identity with no RBAC | — (auth-gated) | **Yes** — refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind |
| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true → `127.0.0.1` | **Yes** — in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (≥1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC — **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind or a loopback bind behind a declared TLS terminator, and on a bare loopback bind with no declared terminator yields a full-privilege *system* identity with no RBAC | — (auth-gated) | **Yes** — refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind or a loopback bind behind a declared terminator |
| **MLLP source** | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`; ≥TLS 1.2. **Plaintext by default** | None (MLLP has no app auth) | — | **Yes** — non-loopback plaintext refused (`check_mllp_tls_exposure`) |
| **HTTP source** (`Http()`, ADR 0023) | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | mTLS client cert only — **no bearer/basic partner auth**, and **neither mTLS nor the IP allow-list is required**: with TLS on and both unset the listener accepts any peer | per-connection `source_ip_allowlist` — **optional, defaults to no restriction** | **Yes** — non-loopback plaintext refused (`check_http_tls_exposure`) — but the gate checks **only** that TLS is on, **never** that a peer control exists (unlike the DICOM SCP row below) |
| **DICOM C-STORE SCP** (`DICOM()`, ADR 0025) | `[inbound].bind_host` = `127.0.0.1` | **Yes** — per-connection opt-in `tls=true` + cert/key; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | `calling_ae_allowlist` / `require_called_ae_title` / mTLS (DIMSE has no transport auth of its own) | per-connection `source_ip_allowlist` | **Yes** — non-loopback plaintext refused (`check_dimse_tls_exposure`), **and** a non-loopback SCP with *no* peer control (calling-AE allow-list, IP allow-list, or mTLS) is refused at construction |
Expand Down
5 changes: 3 additions & 2 deletions docs/REMOTE-CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ carries the recommended hardening for an exposed console (client-certificate dev

Auth is on by default; remote users sign in with local accounts (± TOTP MFA) or AD/LDAP. Note:

- With `[security].require_sign_in = false`, an off-loopback bind is **hard-refused** (loopback is the
only no-auth posture).
- With `[security].require_sign_in = false`, an exposed instance is **hard-refused** — an off-loopback
bind, or a loopback bind behind a declared TLS terminator (a bare loopback bind with no declared
terminator is the only no-auth posture).
- `[security].require_mfa` is **on by default**, and MFA is an access gate: an enrolled-pending session
gets `403` + `X-MFA-Required: 1` on every authorized route. **Leave it on** — that default, not the
startup gate below, is the control.
Expand Down
11 changes: 6 additions & 5 deletions docs/SECURITY-LOOSENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ trail.
### `require_sign_in = false` — disable authentication
- **What you lose:** every request runs as a full-privilege *system* identity; no RBAC.
- **When acceptable:** a **loopback-only** embedding/dev harness.
- **Compensating controls:** loopback bind only.
- **Still refused:** a non-loopback bind with auth off is a **hard refuse** — serving full-privilege admin to
the network is never one "I accept the risk" away, at any posture.
- **Compensating controls:** a loopback bind with no declared TLS terminator only.
- **Still refused:** an exposed instance with auth off — a non-loopback bind, **or** a loopback bind behind a
declared TLS terminator — is a **hard refuse** — serving full-privilege admin to the network is never one "I
accept the risk" away, at any posture.

### `require_mfa = false` — single-factor admin
- **What you lose:** the Administrator role authenticates with a password only (no native TOTP second
Expand Down Expand Up @@ -299,8 +300,8 @@ trail.
- **Compensating controls:** return to `enforce` before carrying real patient traffic; the warnings + startup
**AUDIT** line + posture view keep the deviation visible.
- **Still refused (even at `warn`):** the **no-auth-to-the-network** hard refuse (`require_sign_in = false` on
a non-loopback bind) is unconditional at **any** enforcement level — `enforcement = warn` does **not** open
it — and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing
an exposed instance — a non-loopback bind, or a loopback bind behind a declared TLS terminator) is
unconditional at **any** enforcement level — `enforcement = warn` does **not** open it — and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing
a PHI cleartext hop *entirely* is only reachable by declaring the box synthetic
(`handles_real_patient_data = false`), never by the dial ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)).

Expand Down
Loading
Loading