Skip to content

feat(tls): measure the declared front door instead of trusting it (ASVS 12.1.1) - #53

Merged
wshallwshall merged 2 commits into
mainfrom
claude/asvs-12-1-1-tls-floor-probe
Jul 29, 2026
Merged

feat(tls): measure the declared front door instead of trusting it (ASVS 12.1.1)#53
wshallwshall merged 2 commits into
mainfrom
claude/asvs-12-1-1-tls-floor-probe

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

ASVS 12.1.1 — measure the declared front door instead of trusting it

[api].proxy_tls_min_version is an attestation: the operator types 1.2 and nothing checks it.
Off-loopback the browser TLS is terminated at their reverse proxy, so the engine negotiates none of it —
that is the residual of record. Making an unverified declaration mandatory does not close the
requirement; it just makes the unchecked claim compulsory. The probe is the cell; the WARN→REFUSE
flip alone is not.

New messagefoundry/config/tls_probe.py asks the declared public_origin three questions at startup:

Offer What a pass looks like
1 minimum == maximum == TLSv1, ALL:@SECLEVEL=0 the handshake fails — a successful one is the finding
2 same, TLSv1_1 same
3 default capability negotiates TLSv1.3

Question 3 is deliberately a full offer, not 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.

Verified live against pypi.org:443 during development: negotiated TLSv1.3; refused TLS 1.0 and 1.1.

Three design points that are load-bearing

SECLEVEL=0 is not decoration. Without it modern OpenSSL will not even send a TLS 1.0
ClientHello, so the probe would measure our refusal to ask rather than their refusal to answer —
a permissive front door would read clean. Mutation-proven.

The deprecated enum is asserted, never skipped. ssl.TLSVersion.TLSv1/TLSv1_1 will eventually be
removed. A probe that skipped at that point would become a gate that cannot fail and would report success
forever — the exact harden_kex_groups failure mode this repo already carries (six call sites, zero
effect). TlsProbeUnavailable is raised instead, and resolved before any dial so a build defect never
presents as a proxy problem.

Certificate validation is off (CERT_NONE), on purpose. The probe measures the protocol floor. An
untrusted internal CA would abort the handshake before the version was settled, reporting "TLS 1.0
refused" for a door that was never actually knocked on. Chain validation is a different control (12.1.4).
The context carries no application data and never leaves the module.

This also retires the loopback carve-out, which warned 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.

⚠️ Operational cost, stated rather than buried

On the posture where this refuses, startup now depends on the proxy being reachable. That is a real
tension for an interface engine and it is deliberate: warn-on-unreachable is defeated by start ordering
(bring the engine up before nginx and the check never runs), so it would not be a gate at all.

Blast radius is bounded to exactly the posture the requirement is about — a declared upstream
terminator, on PHI, under enforce
. Every other posture never reaches the probe and 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 and is not built here.

Testing

Real handshakes against a loopback TLS listener with a throwaway self-signed cert — a mocked ssl would
test the mock. Simulating the enum removal needs a stand-in type, because enum members cannot be deleted
(AttributeError: cannot reassign member).

Five mutations red: invert the finding so a permissive door reads clean; drop the version pin; report
unreachable as reachable; let the missing enum skip; drop SECLEVEL=0.

Three things the existing guards caught, all real

  • crypto-inventory gate refused tls_probe.py as an undocumented ssl import. Correct. Row added,
    with the warning that these are deliberately weakened client contexts (SECLEVEL=0, CERT_NONE)
    for measurement only and must never be reused for a data path.
  • test_serve_ui_declared_proxy_requires_mfa_on_prod_phi showed the probe pre-empting the
    MFA-at-exposure refusal. Ordering defect, not a test problem — the probe is the only gate that makes
    network calls, so firing it first means an operator fixes the TLS floor, restarts, and only then
    learns MFA was off. Moved after the config-only refusals. Cheap refusals first.
  • test_startup_dual_control_arm_is_documented_as_warn_only went red claiming "the
    approvals-at-exposure arm now REFUSES to start" — an arm this change does not touch. Half a real
    finding
    , fixed in the first commit: that guard sliced the arm from its if to the next # ---
    section banner, a boundary that is not reference-invariant, so it measured whatever landed in the gap.
    This block was also genuinely missing the section banner every sibling in the serve ladder carries.
    The guard now slices by the arm's own indentation and is mutation-proven both ways — a return 2
    in the arm still goes red; removing the neighbouring banner no longer does. Before the fix, the second
    of those was red, which is the whole defect.

Verification

ruff check · ruff format --check (975 files) · mypy --strict (257 modules) · crypto-inventory (57
documented sites, no drift) · full suite 9610 passed, 720 skipped.

One pre-existing environmental failure remains and is unrelated:
test_installed_metadata_matches_dunder_version compares the editable install's metadata (0.3.0)
against __version__ (0.3.2) — the test's own comment names this case and its remedy
(pip install -e . --no-deps). CI installs fresh.

…ext banner

test_startup_dual_control_arm_is_documented_as_warn_only asserts that the #189
approvals-at-exposure arm still WARNS rather than refusing, by slicing that arm
out of __main__.py and checking it contains no `return 2`. The slice ran from
the arm's `if` to the next `\n    # ---` section banner.

That boundary is not reference-invariant: it measures whatever happens to sit
between the arm and the next banner, not the arm. Insert any refusal after the
arm without a banner of its own and the guard goes red, blaming the approvals
arm for a `return 2` that is nowhere near it. That is exactly what happened --
the ASVS 12.1.1 TLS-floor probe landed in that gap and this test failed with
"the approvals-at-exposure arm now REFUSES to start" while the arm was
byte-identical.

A gate whose answer depends on unrelated neighbouring code is not measuring its
subject. Slice by the `if`'s own indentation instead: the block is the marker
line plus every following line indented deeper than it. Note the slice now
starts at the LINE START, not at the marker offset -- source.index lands past
the leading whitespace, so computing the indent from it would have produced a
4-space body_indent for an 8-space body and swallowed the whole file.

Added a liveness receipt, because the failure mode of a boundary bug is a slice
too SHORT, which makes the assertion pass vacuously: the test now asserts the
slice actually contains the arm's `warning:` print and more than five lines
before trusting the `return 2` check. A guard that could return "nothing found"
because it looked at nothing is the same defect in the other direction.

Both directions mutation-proven, since neither is evidence on its own:

  M1  `return 2` inserted into the approvals arm      -> RED   (still catches
                                                        its actual subject)
  M2  the 12.1.1 section banner removed from the
      neighbouring block                              -> GREEN (no longer
                                                        sensitive to neighbours)

Before the fix M2 was RED, which is the whole defect.
…VS 12.1.1)

[api].proxy_tls_min_version is an ATTESTATION: the operator types "1.2" and
nothing checks it. Off-loopback the browser TLS is terminated at their proxy, so
the engine negotiates none of it -- that is the residual of record. 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, which is why the probe IS this cell and the WARN->REFUSE flip alone
is not.

New config/tls_probe.py asks the declared public_origin three questions at
startup:

1. Do you still speak TLS 1.0? Offered with minimum==maximum==TLSv1 and
   ALL:@SECLEVEL=0. 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. Deliberately a full offer, not a 1.3-only probe: version
   selection is server-driven, so a full offer landing on 1.3 proves PREFERENCE,
   whereas a 1.3-only handshake proves only SUPPORT. Preference is the property.

SECLEVEL=0 is load-bearing and mutation-proven: without it modern OpenSSL will
not even send a TLS 1.0 ClientHello, so the probe would measure OUR refusal to
ask rather than THEIR refusal to answer -- a permissive door reading as clean.

The deprecated enum is ASSERTED, never skipped. A probe that quietly skipped
when ssl.TLSVersion.TLSv1 disappears becomes a gate that cannot fail and reports
success forever. That is precisely the harden_kex_groups failure mode this repo
already carries -- six call sites, zero effect, because set_groups does not exist
on 3.14.6 and it returns silently. TlsProbeUnavailable is raised instead, and
resolved BEFORE any dial so a build defect never looks like a proxy problem.

Certificate validation is deliberately off in the probe (CERT_NONE): it measures
the PROTOCOL FLOOR, and an untrusted internal CA would abort the handshake
before the version was settled -- reporting "TLS 1.0 refused" for a door never
actually knocked on. Chain validation is a different control (12.1.4). The
context handles no application data and never leaves the module.

This also retires the loopback carve-out. 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 speaking TLS 1.0 is a fact, loopback or not.

OPERATIONAL COST, stated rather than buried: on the posture where this refuses,
startup now depends on the proxy being reachable. That is a real tension for an
interface engine and it is deliberate -- warn-on-unreachable is defeated by
start ordering (bring the engine up first and the check never runs), so it is
not a gate at all. Blast radius is bounded to exactly the posture the
requirement is about: a declared upstream terminator, PHI, under enforce. Every
other posture never reaches the probe and 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 and is not built here.

Tests run against REAL handshakes -- a loopback TLS listener with a throwaway
cert -- because a mocked ssl would test the mock. Verified live during
development against pypi.org: "negotiated TLSv1.3; refused TLS 1.0 and 1.1".

Five mutations red: invert the finding so a permissive door reads clean; drop
the version pin; report unreachable as reachable; let the missing enum skip;
drop SECLEVEL=0. Simulating the enum removal needs a stand-in type -- enum
members cannot be deleted ("cannot reassign member") -- which is itself worth
knowing for anyone maintaining this.

Three things the existing guards caught, all real:

- The crypto-inventory gate refused tls_probe.py as an undocumented ssl import.
  Correct -- a new ssl/hashlib/hmac/cryptography root needs an inventory row.
  Added, with the warning that these are DELIBERATELY WEAKENED client contexts
  (SECLEVEL=0, CERT_NONE) for measurement only and must never be reused for a
  data path.

- test_serve_ui_declared_proxy_requires_mfa_on_prod_phi showed the probe
  PRE-EMPTING the MFA-at-exposure refusal. Ordering defect, not a test problem:
  the probe is the only gate that makes network calls, so firing it before the
  config-only refusals means an operator fixes the TLS floor, restarts, and only
  then learns MFA was off -- two round trips for one boot. Moved after the
  auth-off / /ui-exposure / MFA gates. Cheap refusals first.

- test_startup_dual_control_arm_is_documented_as_warn_only went red claiming
  "the approvals-at-exposure arm now REFUSES to start" -- an arm this change does
  not touch. It sliced that arm from its `if` to the next `# ---` section banner,
  and this block landed in the gap without one, so THIS refusal was attributed to
  THAT arm. Half a real finding: the guard's boundary was not
  reference-invariant (repaired in the preceding commit, mutation-proven in both
  directions), and this block was genuinely missing the section banner that every
  sibling in the serve ladder carries. Both fixed; the banner comment records why
  it is load-bearing so it does not get tidied away later.
Comment thread messagefoundry/config/tls_probe.py Dismissed
@wshallwshall
wshallwshall merged commit 4c6ca49 into main Jul 29, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/asvs-12-1-1-tls-floor-probe branch July 29, 2026 21:51
wshallwshall added a commit that referenced this pull request Jul 29, 2026
… pin that never pinned (ASVS 11.6.2) (#56)

* fix(auth): an EMPTY oidc_client_secret is a MISSING one (ASVS 6.x config validation)

The guard that makes a missing OIDC client secret fail at config load read

    if self.oidc_client_secret is None and self.oidc_client_secret_ref is None

which is not the shape a missing secret usually takes. `MEFOR_AUTH_OIDC_CLIENT_SECRET=`
in a service wrapper, or an NSSM environment entry with an empty value, yields
`""` -- and `"" is not None`, so oidc_enabled loaded happily with no client
secret at all.

Reproduced before the fix, with a working control so the repro proves something:

  secret='client-s3cret'  ACCEPTED  (control)
  secret=''               ACCEPTED  oidc_client_secret=''
  secret='   '            ACCEPTED  oidc_client_secret='   '
  secret absent           refused   "oidc_enabled requires a client secret"

Only the ABSENT case fired. Note the `missing` list ten lines above in the same
validator already uses `if not value` for all five pinned endpoints -- this one
line was the only place in the validator that disagreed with its own siblings,
which is why it read as correct.

Severity, stated accurately rather than inflated: this is NOT an auth bypass.
An empty client_secret reaches the IdP at the token exchange and is rejected
there, so the effect is a federated login that is broken instead of refused,
diagnosed from a proxy log rather than from a startup message. The defect is
that a fail-at-load guard silently became a fail-at-first-login one.

Whitespace is stripped for the emptiness TEST only; the value is never
rewritten, and a secret with meaningful leading/trailing whitespace still binds
byte-for-byte (asserted). The same test now applies to oidc_client_secret_ref,
or the fix would be half a fix -- an empty ref would otherwise satisfy
"one of the two is set" while naming no provider entry.

Both mutations killed against the shipped, formatted source:

  M1  restore `is None`                   -> RED (4 failed) on the empty cases
  M2  make the guard reject EVERY secret  -> RED on the POSITIVE CONTROL

M2 is the one that matters: without a positive control, both refusal tests
would stay green under a guard that refuses everything, and "a raise happened"
is not the same claim as "the right input was refused".

* fix(tls): the approved KEX groups were never pinned -- stop claiming they were (ASVS 11.6.2)

harden_kex_groups pins SSLContext.set_groups "where available (Python 3.13+)".
set_groups is a Python 3.15 API. typeshed guards it at
sys.version_info >= (3, 15), alongside the get_groups that will finally make the
pin assertable. Measured on this tree -- Python 3.14.6 / OpenSSL 3.5.7,
hasattr(ctx, "set_groups") is False -- so APPROVED_KEX_GROUPS reaches ZERO of
its six call sites and every built context inherits OpenSSL's default group
list.

That much was already suspected. What was not:

  client pinned to     result (real handshakes vs build_api_ssl_context,
                       identical at tls_min_version 1.2 and 1.3)
  X25519               accepted   <- positive control
  secp384r1            accepted   <- positive control
  prime256v1           accepted   <- positive control
  ffdhe2048            ACCEPTED
  ffdhe3072            ACCEPTED
  secp521r1            ACCEPTED
  secp224r1            refused (NO_SUITABLE_GROUPS)
  sect571r1            refused (NO_SUITABLE_GROUPS)

ADR 0092 section 4(b) asserts the opposite: "a real handshake test proves a
client offering only a non-approved FFDHE group is refused -- runtime
enforcement". That test reached its assertion only through a CLIENT-side
set_groups, so it hit pytest.skip on every interpreter this project runs on. A
skip was concealing a false assertion, and the ADR cited it as proof. That is
worse than an untested claim, because it reads as evidence.

Method, stated because the probe could have lied too: the client pins ONE group
via set_ecdh_curve, which was validated as a genuine constraint FIRST -- a
server pinned to prime256v1 refuses a client pinned to secp384r1
(NO_SUITABLE_KEY_SHARE) while the unpinned-server control accepts it. Without
that check, a probe that constrained nothing would have reported "everything
accepted" for a context that was in fact restrictive.

SEVERITY, stated accurately rather than inflated: the inherited list is
forward-secret, so the property the TLS 1.2+ floor exists to guarantee still
holds, and the genuinely weak curves are refused. This is a conformance gap
against an internal allow-list -- wider than policy, not weak. ASVS 11.6.2 is
Partial in the scorecard of record and stays Partial. The headline count does
not move. What changes is whether it can be defended.

What lands:

harden_kex_groups now RETURNS the list it actually pinned (None today). A
security control that cannot report whether it did anything reports success
forever -- that is how a call at six sites with zero effect survived three
assessments. The failure path returns None too: previously a pin that RAISED
logged a warning and then fell off the end, so a caller could not distinguish
"pinned" from "tried and failed". Its `# pragma: no cover` is gone, because a
stand-in context now drives that branch instead of leaving it to an unusual
OpenSSL build.

The three unit tests that stood over this asserted (a) the call does not raise,
(b) it no-ops on an object without the API, (c) a string constant has three
colon-separated names. All three pass identically whether or not a single group
is ever pinned. Replaced with an unconditional receipt asserting the None, whose
failure message is the re-derivation instruction for whoever trips it. Written
unconditionally on purpose: an `if hasattr(ctx, "set_groups")` branch would
restore exactly the property being removed.

The skipping handshake test is replaced by one that MEASURES the accepted-group
set and cannot skip. Asserted as invariants rather than an exact table, since
the accepted set comes from the linked OpenSSL and a CI leg may differ: every
approved group must get in (else the server is over-restricted and the next
assertion would pass for the wrong reason) and some non-approved group must get
in (proving the list is not enforced). The measured table prints on failure.

Five mutations, both directions:

  M1  report a pin never made           -> inertness receipt RED
  M2  revert to the old no-return shape -> reporting test RED
  M3  failed pin reports the list       -> raising test RED
  M4  probe accepts everything          -> measurement RED
  M5  probe refuses everything          -> measurement RED

M2 is the one that justifies its own test: under M2 the INERTNESS test stays
GREEN (measured: rc=0), because a function with no return statement also yields
None. Asserting `is None` therefore does not prove the reporting works, which is
why the pinning path is driven separately with a stand-in context.

Documents corrected in the same change, because the claim had spread to five:

- config/tls_policy.py -- the "3.13+" docstring and the module summary.
- docs/PHI.md section 4 -- "Approved groups pinned where supported" -> inherited,
  with the measured accepted set.
- docs/ASVS-L2-PHASE0-CHANGES.md -- the same sentence, same error.
- docs/adr/0092 section 4(b) -- struck through in place and WITHDRAWN by a dated
  amendment naming all three wrong assertions. The ADR's decision is unaffected:
  the forward secrecy it relies on comes from the TLS 1.2+ floor, which IS
  enforced. Sections 4(a) and 4(c) were re-confirmed and stand.
- docs/BACKLOG.md -- the "runtime-KEX enforcement ... handshake-tested" clause
  inside a shipped banner. Prose-only edit, banner invariant untouched,
  test_backlog_status_check.py green.

Deliberately NOT touched: docs/security/ASVS-L3-ASSESSMENT.md and the other
dated assessments carry the same claim and are marked "Retained unmodified for
diffing and audit history". They stay unmodified; the scorecard-of-record and
register corrections are vaulted separately (docs/security/ is git-ignored here).

Also NOT done, and it is a real option rather than an oversight:
SSLContext.set_ecdh_curve exists and genuinely pins, but 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 knob for a future lane, not a drop-in, and shipping it as though it
closed 11.6.2 would be the kind of lenient reading this project has already
retracted twice. Also note secp256r1 is a valid group-list alias but NOT a valid
EC curve name (that spelling is prime256v1) -- do not "normalise" the constant.

Swept the sibling helpers, because "is anything else inert?" is the obvious next
question and a claim is not an answer. tls_policy.py has exactly three
getattr-guarded best-effort call sites; all three were driven on this
interpreter:

  set_groups            (harden_kex_groups)   INERT -- the subject of this commit
  VERIFY_X509_STRICT    (harden_verify_flags) LIVE  -- present (32), and the flag
                                              is verifiably ORed into
                                              verify_flags (32768 -> 32800)
  _hashlib.get_fips_mode (fips_attestation)   LIVE  -- returns False, not None;
                                              None would have meant
                                              undeterminable, i.e. a vacuous
                                              attestation

So this was the only one of the three, and that is measured rather than assumed.

* docs(adr-0034): record the py/insecure-protocol dismissal on the 12.1.1 TLS probe

CodeQL alert 145 -- py/insecure-protocol, HIGH, messagefoundry/config/tls_probe.py
-- was raised by PR #53 and dismissed `won't fix` on the repo. ADR 0034's policy
is that every finding is triaged to Fix or Dismiss-with-a-recorded-reason, with
the per-alert rationale in the GitHub comment and the class rationale in the ADR.
The GitHub comment is capped at 280 characters, so the ADR is where the real
argument has to live; this is that entry.

The rule is factually right and does not apply: tls_probe.py offers TLS 1.0/1.1
BECAUSE a successful handshake at a withdrawn version is the finding it exists to
produce. Two flagged settings are load-bearing and mutation-proven -- SECLEVEL=0
(without it OpenSSL sends no ClientHello, so the probe would measure our refusal
to ask rather than their refusal to answer) and CERT_NONE (an untrusted internal
CA would abort before the version was settled, reporting "TLS 1.0 refused" for a
door never knocked on).

What makes the dismissal safe is scope, so the row states it: client contexts
only, built in that module, one handshake, never returned to a caller, no
application data, no PHI. Not a data path, and the settings must never be reused
for one -- which the module docstring and the crypto-inventory row both already
say, at the two places a future author would actually look.

Also recorded per this ADR's own convergence rule: the anchor is tls_probe.py:146
inside _offer_context, and that module is new and small, so this WILL 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants