Skip to content

fix: two guards that could not fail — an empty OIDC secret, and a KEX pin that never pinned (ASVS 11.6.2) - #56

Merged
wshallwshall merged 3 commits into
mainfrom
claude/asvs-kex-truth-and-oidc-secret
Jul 29, 2026
Merged

fix: two guards that could not fail — an empty OIDC secret, and a KEX pin that never pinned (ASVS 11.6.2)#56
wshallwshall merged 3 commits into
mainfrom
claude/asvs-kex-truth-and-oidc-secret

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Wave-0 corrections — two guards that could not fail, and the documents that believed them

Three commits, two unrelated defects, one register entry. Neither defect moves the ASVS headline count;
both change whether it can be defended.


1 · fix(auth) — an EMPTY oidc_client_secret is a MISSING one

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:

That 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.

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

MEFOR_AUTH_OIDC_CLIENT_SECRET before
client-s3cret accepted (control)
"" accepted, oidc_client_secret == ''
" " accepted
absent refused

Only the absent case fired. 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: 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 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; a secret with meaningful surrounding whitespace
still binds byte-for-byte (asserted). The same test now covers oidc_client_secret_ref, or an empty ref
would satisfy "one of the two is set" while naming no provider entry.

Mutations: restore is Nonered (4 failed). Make the guard reject every secret → red on the
positive control
. The second is the one that matters — without it, both refusal tests stay green under
a guard that refuses everything, and "a raise happened" is not the claim being made.


2 · fix(tls) — the approved KEX groups were never pinned (ASVS 11.6.2)

harden_kex_groups pins via 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 here: 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.

That much was suspected. This was not — real handshakes against the actual build_api_ssl_context,
identical at tls_min_version 1.2 and 1.3:

client pinned to one group result
X25519, secp384r1, prime256v1 accepted (positive controls)
ffdhe2048 ACCEPTED
ffdhe3072 ACCEPTED
secp521r1 ACCEPTED
secp224r1 refused — NO_SUITABLE_GROUPS
sect571r1 refused — NO_SUITABLE_GROUPS

ADR 0092 §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 — 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
step, a probe that constrained nothing would have reported "everything accepted" for a context that was in
fact restrictive — the two answers look identical.

Severity, bounded. The inherited list is forward-secret, so the property the TLS 1.2+ floor guarantees
still holds, and the genuinely weak curves are refused. This is a conformance gap against an internal
allow-list — wider than policy, not weak. 11.6.2 is Partial and stays Partial.

What changes in code

harden_kex_groups now returns the list it actually pinned (None today). A 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.

The three tests standing over this asserted (a) the call does not raise, (b) it no-ops without the API,
(c) a string constant has three names. All three pass identically whether or not a group is ever pinned.
Replaced with an unconditional receipt whose failure message is the re-derivation instruction — an
if hasattr(...) branch here would restore exactly the property being removed. The skipping handshake
test is replaced by one that measures the accepted set and cannot skip, asserted as invariants rather
than an exact table (the set comes from the linked OpenSSL and a CI leg may differ): every approved group
must get in, and some non-approved group must get in.

Five mutations, both directions:

mutation expected
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 why it needs its own test: under M2 the inertness test stays green (measured rc=0),
because a function with no return also yields None. assert pinned is None therefore proves nothing
about the reporting.

Swept the siblings, because "is anything else inert?" deserves a measurement, not a claim.
tls_policy.py has exactly three getattr-guarded best-effort sites; all three were driven here:

site verdict
set_groups (harden_kex_groups) INERT — the subject of this PR
VERIFY_X509_STRICT (harden_verify_flags) LIVE — present (32), flag verifiably ORed in (32768 → 32800)
_hashlib.get_fips_mode (fips_attestation) LIVE — returns False, not None (None = undeterminable = a vacuous attestation)

Documents corrected, because the claim had spread to five

  • config/tls_policy.py — the "3.13+" docstring and the module summary.
  • docs/PHI.md §4 — bullet rewritten; the section marker changed from [BUILT — WP-L3-10 code half] to
    [PARTIAL — the 1.2+ floor and the cipher validator are enforced; the group pin is INERT until Python 3.15].
  • docs/ASVS-L2-PHASE0-CHANGES.md — the same sentence, the same error.
  • docs/adr/0092 §4(b) — struck through in place and withdrawn by a dated amendment naming all three
    wrong assertions. The ADR's decision is unaffected (its forward-secrecy dependency is the TLS 1.2+
    floor, which is enforced); §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; 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 say on their face "Retained unmodified for diffing and audit history." The
scorecard-of-record and register corrections are vaulted (docs/security/ is git-ignored here).

Deliberately not built, and it is an option rather than an oversight: set_ecdh_curve exists and
genuinely pins, but takes exactly one curve name — it cannot express a preference list, and pinning
through it would refuse two of the three approved groups. A [tls] single-curve knob is a real hardening
option with a real interop bill (an MLLP client pinned to X25519 refuses a P-256-only server — a realistic
shape for older Java/.NET interface engines). Shipping it as though it closed 11.6.2 would be the lenient
reading this project has already retracted twice.


3 · docs(adr-0034) — record the CodeQL dismissal from PR #53

py/insecure-protocol, HIGH, messagefoundry/config/tls_probe.py, dismissed won't fix on the repo.
The rule is factually right and does not apply: the probe offers TLS 1.0/1.1 because a successful
handshake at a withdrawn version is the finding it exists to produce. The GitHub dismissal comment is
capped at 280 characters, so ADR 0034 is where the argument has to live. Includes the convergence note —
the anchor is tls_probe.py:146, so this will re-fire as a new alert number on any line drift above
it; re-dismiss with the recorded rationale rather than re-triaging.


Verification

ruff check · ruff format --check (976 files) · mypy --strict (257 modules) · full suite on the
pre-rebase tree 9614 passed, 719 skipped; targeted re-run after rebasing onto the merged main
343 passed.

The one failure in the full run is the known environmental test_installed_metadata_matches_dunder_version
— the editable install's metadata (0.3.0) vs __version__ (0.3.2); the test's own comment names the
case and the remedy. CI installs fresh.

…fig 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".
…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.
….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.
@wshallwshall
wshallwshall merged commit be2fc08 into main Jul 29, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/asvs-kex-truth-and-oidc-secret branch July 29, 2026 22:16
wshallwshall added a commit that referenced this pull request Jul 29, 2026
… instead (#58)

main is RED. `test (windows-2022, py3.14)` failed at be2fc08, the squash-merge
of PR #56, whose own head was 33/33 green:

    assert harness.frames, "expected at least one stats frame before revocation"
    AssertionError: assert []

Not the merged change -- PR #56 touched TLS key-exchange policy, an OIDC settings
validator and docs, none of which this route reaches. The test is defective, and
this is deliberately NOT being treated as a flake to re-run: this repo has
already mistaken a livelock for one, and no rerun or timeout ever fixed that.

The defect: the first frame cannot be sent until `store.stats()` returns -- the
route awaits it, builds the frame, then sends -- and the fixed 50 ms sleep had to
cover that query PLUS scheduling the harness task, the handshake, and the
handshake-time authorize. None of which these tests measure. It passes locally
3/3 in 10.06s, 12.33s and 3.89s, and that spread is the tell.

Replaced the sleep with `_wait_for_first_frame`, a bounded poll on the ACTUAL
asserted condition. This does not weaken the precondition: a frame is still
required, and still required BEFORE the revocation. It also distinguishes a dead
task from a slow one -- `task.result()` re-raises the real failure, because a
route that blew up would otherwise be indistinguishable from a loaded runner, and
"timed out waiting for a frame" is the least useful description of a traceback.

A SECOND, quieter defect fixed at the same time. test_disabled_account_is_closed
also slept 50 ms and asserts only `close_code == 1008` -- which BOTH the
mid-stream revalidation and the pre-first-send re-check produce. So on a slow box
the account was disabled before the first send, the close came from the wrong
path, the assertion still held, and the test silently stopped covering what its
comment claims while duplicating what
test_revoke_before_first_send_yields_no_frames already owns. A coverage move with
no symptom. It now waits for a frame, so "mid-stream" is a fact.

New test_a_slow_first_stats_build_does_not_break_the_precondition makes the CI
failure DETERMINISTIC: it stalls `store.stats()` to 0.4s, 8x the old budget, so
the old form fails on any machine. That is the property the original failure
never had -- it only reproduced on a loaded Windows runner, which is not
something you can iterate against.

Two mutations, and the second half of the first is the interesting one:

  M1  restore `await asyncio.sleep(0.05)`
        -> the slow-stats regression test RED (1 failed)
        -> and test_disabled_account_is_closed stays GREEN (1 passed), which is
           the silent-coverage-move demonstrated rather than argued
  M2  `_FIRST_FRAME_TIMEOUT = 0.0`
        -> 3 failed, so the wait is load-bearing and not decorative

Timeouts: the first-frame wait is 10s and the harness budget is raised 5s -> 20s,
because the harness must outlive the wait PLUS the close it then waits for, or it
would time out mid-wait and the failure would point at the route instead of the
clock. Both sit inside the 60s pytest timeout, which stays the real backstop
against a hang.

Full suite 9636 passed / 719 skipped; the one failure is the pre-existing
environmental test_installed_metadata_matches_dunder_version (editable metadata
0.3.0 vs __version__ 0.3.2 -- its own comment names the case and the remedy).
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.

1 participant