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
63 changes: 63 additions & 0 deletions messagefoundry/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@
_DEFAULT_SERVICE_TOML = "messagefoundry.toml"


def _serve(args: argparse.Namespace) -> int:

Check warning on line 983 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / complexity triage (advisory)

Complexity increased

`_serve` complexity 95 -> 98 (mccabe threshold 10)
import uvicorn
from pydantic import ValidationError

Expand Down Expand Up @@ -1921,6 +1921,69 @@
file=sys.stderr,
)

# --- startup TLS-floor probe of the declared front door (ASVS 12.1.1) ---------------------------
# ORDER MATTERS: this sits AFTER the config-only exposure refusals (auth-off, /ui exposure,
# MFA-at-exposure) deliberately. It is the only gate that makes NETWORK CALLS, and pre-empting
# a config refusal with three handshake round-trips means an operator fixes the TLS floor,
# restarts, and only then learns MFA was off — two trips for one boot. Cheap refusals first.
#
# The banner above is not decoration: test_startup_dual_control_arm_is_documented_as_warn_only
# slices the #189 approvals arm out of this file and asserts it contains no `return 2`. Without a
# banner here that slice ran straight through into this block and attributed THIS refusal to that
# arm. The guard has since been made to slice the arm by its own indentation, but every section in
# this ladder carries a banner and a new one must too.
#
# `proxy_tls_min_version` is an attestation: the operator types "1.2" and nothing checks it.
# Making an unverified declaration mandatory does not close the requirement, so the gate above
# is not the cell — this is. The probe dials `public_origin` and offers TLS 1.0 and 1.1; a
# SUCCESSFUL handshake is the failure, because it proves the front door accepts a protocol
# NIST SP 800-52r2 withdrew. It then asks what a default-capability client actually negotiates,
# which measures the proxy's PREFERENCE rather than merely its support.
#
# This is also what retires the loopback carve-out above. That arm warns because "the engine
# cannot distinguish loopback-behind-a-declared-proxy from loopback-and-genuinely-unexposed
# beyond the declaration itself" — true of a declaration, false of a measurement. A reachable
# front door that speaks TLS 1.0 is a fact, on loopback or not.
#
# Scope is deliberately the posture the requirement is about: a declared terminator, PHI, and
# `enforce`. Every other posture never reaches here and is byte-identical.
if (
settings.api.tls_terminated_upstream
and data_class is DataClass.PHI
and enforcing
and settings.api.public_origin
):
from messagefoundry.config.tls_probe import TlsProbeUnavailable, probe_tls_floor

Check notice on line 1956 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 1956 missing coverage

try:
probe = probe_tls_floor(settings.api.public_origin)
except TlsProbeUnavailable as exc:

Check notice on line 1960 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 1958-1960 missing coverage
# NOT a skip. See tls_probe's module docstring: a check that degrades to a no-op when
# its mechanism disappears reports success forever afterwards.
print(f"error: the ASVS 12.1.1 TLS-floor probe cannot run: {exc}", file=sys.stderr)
return 2
if not probe.ok:

Check notice on line 1965 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 1963-1965 missing coverage
# Unreachable refuses too, and the reason is start-ordering: if "unreachable" merely
# warned, an operator could always bring the engine up before the proxy and the check
# would never run — a gate that is trivially defeated is not a gate. The cost is real
# and is stated in the message rather than left for an assessor to find.
print(

Check notice on line 1970 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 1970 missing coverage
f"error: refusing to serve on a PHI instance ({env_name!r}) behind a declared "
f"upstream TLS terminator whose TLS floor does not verify — {probe.describe()}. "
"The browser hop is the operator's proxy, so the engine measures it at startup "
"rather than trusting [api].proxy_tls_min_version (ASVS 12.1.1). Required: the "
"front door must refuse TLS 1.0 and 1.1 and negotiate TLS 1.3 with a "
"default-capability client. NOTE: this makes startup depend on the proxy being "
"reachable — deliberate, because warning on unreachable is defeated by start "
"ordering. See docs/security/OFF-LOOPBACK-DEPLOYMENT.md.",
file=sys.stderr,
)
return 2
print(

Check notice on line 1982 in messagefoundry/__main__.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 1981-1982 missing coverage
f"info: TLS-floor probe passed — {probe.describe()} (ASVS 12.1.1).",
file=sys.stderr,
)

# --- #186(a) secure-by-default data retention (ASVS 14.2.4) --------------------------------------
# RetentionSettings defaults every window to 0 (keep-forever) and RetentionRunner then purges
# NOTHING, so a PHI instance accumulates PHI bodies indefinitely. Both PHI-body windows must be
Expand Down
184 changes: 184 additions & 0 deletions messagefoundry/config/tls_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 MessageFoundry Organization and contributors
"""Startup TLS-floor probe against the declared front door (ASVS 12.1.1).

**This module is the cell.** Off-loopback the browser/API TLS is terminated at the operator's reverse
proxy, so the engine negotiates no browser-facing TLS and cannot enforce or inspect the minimum
version — that is the residual of record. `[api].proxy_tls_min_version` was the answer, and it is only
an **attestation**: the operator types ``1.2`` and nothing checks it. Making an unverified declaration
*mandatory* does not close the requirement; it just makes the unchecked claim compulsory.

A probe converts the declaration into a **measurement**. At startup the engine dials its own declared
``public_origin`` and asks the proxy three questions:

1. *Do you still speak TLS 1.0?* — offered with ``minimum_version == maximum_version == TLSv1`` and
``ALL:@SECLEVEL=0`` so the offer is genuinely made. A **successful handshake is the failure**: it
proves the front door accepts a protocol NIST SP 800-52r2 withdrew.
2. *Do you still speak TLS 1.1?* — same shape.
3. *What do you actually choose?* — a **default-capability** client, asserting the negotiated version.
This is deliberately a full offer rather than a 1.3-only probe: version selection is server-driven,
so a full offer landing on 1.3 proves the proxy **prefers** it, whereas a 1.3-only handshake proves
only that it *supports* it. Preference is the property 12.1.1 is about.

**The enum is asserted, never skipped.** ``ssl.TLSVersion.TLSv1``/``TLSv1_1`` are deprecated and will
eventually be removed. A probe that quietly skipped when the enum vanished would become a gate that
cannot fail — the exact ``harden_kex_groups`` failure mode this codebase already carries (it pins
nothing on 3.14.6 because ``set_groups`` does not exist, and returns silently). So the absence of the
enum is an **error**, not a skip: the check must be rebuilt, not silently retired.

**Certificate validation is deliberately OFF here** (``CERT_NONE``). The probe measures the *protocol
floor*, not the chain — an internal CA the engine does not trust would otherwise mask the answer, and
a self-signed proxy cert would read as "TLS 1.0 refused" when it was never asked. Chain validation for
real traffic is a different control (12.1.4 / ``harden_verify_flags``); this context is built here,
used for one handshake, and never returned to a caller.

**Operational cost, stated rather than buried.** On the posture where this refuses, a boot now depends
on the proxy being reachable. That is a real tension for an interface engine and it is deliberate: an
unreachable-means-warn rule is defeated by start ordering (start the engine first and the check never
runs), so warn-on-unreachable is not a gate at all. The blast radius is bounded to the posture the
requirement is about — a declared upstream terminator on a PHI instance under ``enforce`` — and every
other posture is byte-identical. A regression detected *later*, at runtime, must NOT kill the engine
and the hospital's feed with it; that path belongs to an AlertSink event.
"""

from __future__ import annotations

import socket
import ssl
from dataclasses import dataclass
from urllib.parse import urlsplit

__all__ = [
"LEGACY_TLS_VERSIONS",
"TlsFloorProbe",
"probe_tls_floor",
]

#: The withdrawn protocol versions the front door must refuse (NIST SP 800-52r2). Held as NAMES and
#: resolved through :func:`_legacy_version` so a runtime that has dropped the enum is an ERROR rather
#: than a silent skip.
LEGACY_TLS_VERSIONS = ("TLSv1", "TLSv1_1")

#: Seconds per handshake attempt. Three attempts, so the worst case adds ~3x this to startup. Short
#: enough that a wedged proxy fails fast, long enough for a loaded one to answer.
_PROBE_TIMEOUT_SECONDS = 5.0


class TlsProbeUnavailable(RuntimeError):
"""The probe cannot run as specified — e.g. the interpreter dropped a deprecated ``TLSVersion``.

Raised rather than returning a "skip" result, deliberately: a security check that degrades to a
no-op when its mechanism disappears reports success forever afterwards. Callers must treat this as
a build/runtime defect to fix, never as a pass.
"""


def _legacy_version(name: str) -> ssl.TLSVersion:
"""The :class:`ssl.TLSVersion` member for ``name``, or raise :class:`TlsProbeUnavailable`."""
member = getattr(ssl.TLSVersion, name, None)
if not isinstance(member, ssl.TLSVersion):
raise TlsProbeUnavailable(
f"ssl.TLSVersion.{name} is not available on this interpreter, so the TLS-floor probe "
f"cannot offer a {name} handshake. This check must be rebuilt against whatever the "
f"runtime now exposes — do NOT let it degrade to a skip, which would make it a gate that "
f"cannot fail (ASVS 12.1.1)."
)
return member


def _offer_context(version: ssl.TLSVersion | None) -> ssl.SSLContext:
"""A client context that offers exactly ``version`` (or the default capability when ``None``).

``check_hostname``/``verify_mode`` are off because this measures the PROTOCOL FLOOR: an untrusted
internal CA would otherwise abort the handshake before the version was settled, and the probe
would report "legacy refused" for a door it never actually knocked on. This context handles no
application data and never leaves this module.
"""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
if version is not None:
ctx.minimum_version = version
ctx.maximum_version = version
# SECLEVEL=0 is required for the offer to be genuine: modern OpenSSL refuses to even send a
# TLS 1.0 ClientHello at the default security level, so without this the probe would measure
# OUR refusal to ask rather than THEIR refusal to answer — a false pass.
ctx.set_ciphers("ALL:@SECLEVEL=0")
return ctx


@dataclass(frozen=True, slots=True)
class TlsFloorProbe:
"""What the declared front door actually did when asked."""

host: str
port: int
reachable: bool
#: Withdrawn versions the front door ACCEPTED. Non-empty is a failure.
legacy_accepted: tuple[str, ...]
#: The version a default-capability client negotiated, or ``None`` if it could not connect.
negotiated: str | None
#: Transport-level detail when unreachable. Never contains a credential.
error: str | None = None

@property
def ok(self) -> bool:
"""True only when the door was reached, refused every withdrawn version, and chose TLS 1.3."""
return self.reachable and not self.legacy_accepted and self.negotiated == "TLSv1.3"

def describe(self) -> str:
"""A one-line operator-facing summary. PHI-free by construction — host, port, versions only."""
if not self.reachable:
return f"{self.host}:{self.port} unreachable ({self.error})"
parts = [f"negotiated {self.negotiated}"]
if self.legacy_accepted:
parts.append(f"ACCEPTS withdrawn {', '.join(self.legacy_accepted)}")
else:
parts.append("refused TLS 1.0 and 1.1")

Check notice on line 137 in messagefoundry/config/tls_probe.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 137 missing coverage
return f"{self.host}:{self.port}: " + "; ".join(parts)


def _handshake(host: str, port: int, ctx: ssl.SSLContext) -> tuple[bool, str | None, str | None]:
"""``(completed, negotiated_version, error)`` for one handshake attempt."""
try:
with (
socket.create_connection((host, port), timeout=_PROBE_TIMEOUT_SECONDS) as raw,
ctx.wrap_socket(raw, server_hostname=host) as tls,
Comment thread
wshallwshall marked this conversation as resolved.
Dismissed
):
return True, tls.version(), None
except ssl.SSLError as exc:
# A refused protocol/cipher is the EXPECTED outcome of the legacy probes — not an error.
return False, None, str(exc)
except (OSError, TimeoutError) as exc:
return False, None, f"{type(exc).__name__}: {exc}"


def probe_tls_floor(origin: str) -> TlsFloorProbe:
"""Measure the TLS floor of ``origin`` (an ``https://host[:port]`` URL).

Raises :class:`TlsProbeUnavailable` if the interpreter cannot express the legacy offers — see the
module docstring on why that is an error rather than a skip. Never raises for a merely
unreachable or badly-behaved front door: those are reported in the result so the CALLER decides
the disposition, which keeps the policy in the serve gate and the measurement here.
"""
parts = urlsplit(origin)
host = parts.hostname or ""
port = parts.port or (443 if parts.scheme == "https" else 80)
if not host:
raise ValueError(f"cannot probe {origin!r}: no host")

Check notice on line 168 in messagefoundry/config/tls_probe.py

View workflow job for this annotation

GitHub Actions / diff-coverage (advisory)

Missing Coverage

Line 168 missing coverage

# Resolve BOTH enums up front so an unavailable one fails before any network call — the failure is
# about this build, not about the operator's proxy, and should not look like a proxy problem.
legacy = [(name, _legacy_version(name)) for name in LEGACY_TLS_VERSIONS]

accepted: list[str] = []
for name, version in legacy:
completed, _negotiated, _err = _handshake(host, port, _offer_context(version))
if completed:
# The door answered a withdrawn protocol. THIS is the finding.
accepted.append(name.replace("_", "."))

completed, negotiated, error = _handshake(host, port, _offer_context(None))
if not completed:
return TlsFloorProbe(host, port, False, tuple(accepted), None, error)
return TlsFloorProbe(host, port, True, tuple(accepted), negotiated, None)
6 changes: 6 additions & 0 deletions scripts/security/crypto_inventory_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@
# the anonymizer's pseudonymization is consistent-within-a-dataset yet one-way (re-id-resistant).
"messagefoundry/anon/keying.py": frozenset({"hashlib"}),
"messagefoundry/api/tls.py": frozenset({"ssl"}),
# ASVS 12.1.1: the startup TLS-floor probe. Client contexts ONLY, and deliberately weakened ones —
# a withdrawn-version offer (minimum==maximum==TLSv1/1.1) at ALL:@SECLEVEL=0 with CERT_NONE, so the
# ClientHello is actually sent and an untrusted internal CA cannot abort before the version is
# settled. It measures the operator's proxy and carries NO application data; the contexts are built,
# used for one handshake, and never returned. Not a data path — do not reuse these settings.
"messagefoundry/config/tls_probe.py": frozenset({"ssl"}),
"messagefoundry/auth/ldap.py": frozenset({"ssl"}),
# ADR 0142 (OIDC relying party, BACKLOG #274): the federated-SSO layer.
# claims.py — hmac.compare_digest for the constant-time nonce comparison; cryptography only for
Expand Down
29 changes: 24 additions & 5 deletions tests/test_security_doc_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -1676,14 +1676,33 @@ def test_startup_dual_control_arm_is_documented_as_warn_only() -> None:
``__main__.py`` records the refuse arm as an unresolved owner fork. Derived by slicing the
approvals block out of the source and asserting it contains no ``return 2``, so promoting it to a
refusal later reds the doc.

The slice is taken by **indentation**, not by "up to the next comment banner". The banner boundary
was not reference-invariant: it measured whatever happened to sit between the arm and the next
banner, so inserting an unrelated refusal after the arm (the ASVS 12.1.1 TLS-floor probe did
exactly this) turned the guard red and blamed the approvals arm for a ``return 2`` that was not in
it. A gate whose answer depends on unrelated neighbouring code is not measuring its subject.
"""
source = (_ROOT / "messagefoundry" / "__main__.py").read_text(encoding="utf-8")
marker = "if admin_exposed and not settings.approvals.enabled"
start = source.index(marker)
tail = source[start:]
# the arm ends at the next top-level comment banner in the serve ladder
end = tail.index("\n # ---", 1)
arm = tail[:end]
# Slice from the START OF THE LINE, not from the marker itself: the `if`'s own indentation is what
# defines its body, and `source.index` lands past the leading whitespace.
start = source.rindex("\n", 0, source.index(marker)) + 1
lines = source[start:].splitlines(keepends=True)
# The arm is the `if` statement and its own body, which is anything indented deeper than the `if`.
body_indent = " " * (len(lines[0]) - len(lines[0].lstrip()) + 1)
arm_lines = [lines[0]]
for line in lines[1:]:
if line.strip() and not line.startswith(body_indent):
break
arm_lines.append(line)
arm = "".join(arm_lines)
# Liveness receipt: a boundary bug that produced a 1-line slice would make the assertion below
# unfailable, so prove the slice actually captured the arm's body before trusting it.
assert "warning:" in arm and len(arm_lines) > 5, (
f"the arm slice looks wrong ({len(arm_lines)} lines) — the assertion below would pass "
f"vacuously. Slice was:\n{arm}"
)
assert "return 2" not in arm, (
"the approvals-at-exposure arm now REFUSES to start. Move its row out of the WARN action in "
"docs/SECURITY.md's Table A (and re-check `_CONTEXT_TABLE_A_ROWS`) in the same change."
Expand Down
Loading
Loading