Skip to content

Record a failed inbound TLS handshake on the HTTP intake listener, the API and MLLP under uvloop (BACKLOG #3302, #2613 item 3) - #2401

Merged
wshallwshall merged 1 commit into
mainfrom
b291/3302-tls-handshake-record
Oct 17, 2026
Merged

wshallwshall merged 1 commit into
mainfrom
b291/3302-tls-handshake-record

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Batch 291 (security-asvs), item vault BACKLOG #3302, plus #2613 open item 3. Cut as its own PR because it adds a detection control.

Merge danger

Reversible: yes -- a revert of the squashed commit undoes all of it; nothing acts outside the commit.

Blast radius: the TLS server contexts of the HTTP intake listener, the API (serve) and MLLP: each now installs an SSLObject subclass whose do_handshake reports a failure and re-raises it unchanged. New module messagefoundry/tls_handshake_failures.py; MLLP's throttle extracted into it (net reduction in mllp.py); http_listener.py; __main__.py serve wiring; scripts/security/crypto_inventory_check.py; docs PHI.md, SECURITY.md, CONNECTIONS.md; a web console comment; changelog.d/3302.security.md. No store, pipeline or API route change.

Evidence

  • Before: a failed inbound TLS handshake was recorded on MLLP only, and only on a stdlib loop. On uvloop, and on the HTTP intake listener and the API on any loop, nothing recorded it: the loop logs it at DEBUG only (read in CPython 3.14.7 asyncio/sslproto.py and uvloop 0.23.0 sslproto.pyx).
  • After: tests/test_tls_handshake_failures.py fails a real handshake on each surface (plaintext, and a client that refuses the certificate) and reads the record; each has a completed-handshake control that records nothing. With the hook disabled, 5 tests go red. MLLP runs on the stdlib loop forced onto the uvloop path everywhere, and on real uvloop (skipped on Windows).

Item 3302

Source branch: b291/3302-tls-handshake-record, head 9a24c35fb8a6242a5a4cf5e74fdb993271b9ba65, cut from 064700f2d.

  • Step 1 (HTTP intake listener and API): done. HTTP listener: tls_handshake_failed connection event plus a tls_handshake_failures counter. API: a throttled WARNING through ApiHandshakeRecord, wired in serve; the held count is logged once uvicorn returns. Throttle: first failure at once, then one record per listener per 60 s with the count. Cause = exception class plus OpenSSL's reason mnemonic matched against [A-Z0-9_]{1,64}; no payload and no certificate contents.
  • Step 2 (MLLP under uvloop): done, built once for #2613 item 3. On the stdlib loop MLLP keeps its own _start_tls count, which names the peer; a guard stops double counting.
  • Step 3 (tests): done, as above.
  • Step 4 (the vault re-read): not this PR. Vault record work after merge.

Limits, recorded rather than forced (no server or protocol class replaced):

  • Records the loop ran name no peer: the TLS object holds memory buffers, not a socket.
  • A handshake that only runs past its time bound never raises from do_handshake, so it is not counted where the loop runs the handshake. A wrong certificate, missing client certificate, downgrade and plaintext on a TLS port all raise and are counted.
  • A peer that hangs up mid-handshake is not counted, as on MLLP since #2613.
  • With [diagnostics].connection_events = false (switch unchanged, #3299's subject): MLLP and HTTP events are dropped as before; the in-process counters still count; the API record is a log line and does not depend on the switch.
  • The DICOM C-STORE SCP runs its own handshake through wrap_socket, which this hook does not reach; proposed as a new row.

Owner question (not answered by any seat)

The API logs a WARNING when a client refuses the engine's own auto-minted self-signed certificate (ADR 0172), e.g. a browser before the operator trusts it. Throttled to one line a minute, but a default install would see it routinely. Builder recommends keeping WARNING (the row asked for a WARNING line; the throttle bounds it), with a follow-up if the owner wants refusals of the generated pair at INFO.

Proposed vault banner and new row

Verbatim in the Builder's report to the Manager, carried to the Lander: CLOSING STEPS 1-3 BUILT; step 4 open as vault record work; #2613 open item 3 built on the same branch. Proposed new row: "a failed inbound TLS handshake on the DICOM C-STORE SCP leaves no record above DEBUG".

Known defects shipped (Builder's review round 2, open)

  1. The context, its dynamic class and the listener form a reference cycle (ctx -> class -> bound method -> listener); the cyclic GC frees it, not refcounting. A weakref.WeakMethod would break it.
  2. The throttle facts are restated in several docs, against SDS-3.5 "state once".
  3. The API line reuses the reason text "since the last event" though the API emits no events; changing it would alter MLLP's existing event text and tests.
  4. HTTP has no window test seam, and it duplicates small wiring with MLLP.
  5. The restart guards in both stop() methods are judged unnecessary by the reviewer (the runner joins abandoned stops first); kept as harmless. The "no report outlives stop()" guard lives in each listener rather than in the helper.

Checks

  • Ran: ruff check, ruff format --check on every changed file; mypy strict --platform linux (messagefoundry, webconsole, toolkit, excluding tray) and --platform win32, clean; tests leg on the changed test files, clean; crypto-inventory gate OK.
  • Full suite (-n 8) at a86c7bd84, before the review fixes: 11 failed, 47973 passed. Three were the Builder's, fixed in later commits; seven passed on re-run at a later head; test_connscale_smoke::test_the_sweep_produces_one_record_per_mode_and_count (a load test) was never re-run and is judged unrelated but not verified.
  • Targeted suites at the final head green: mllp_tls, inbound_http_*, phi_logging_inventory, communications_inventory, security_doc_drift, docs_security_pathways, listener_tls_exposure, and the new file.
  • Legs to read: gates (ubuntu-latest) for tests/test_tls_handshake_failures.py::test_mllp_records_a_failed_handshake_on_uvloop (must pass there; on Windows it must show skipped, not failed); gates (windows-latest). The full suite was not re-run after the review fixes.

Landing order: none. (Open PR 2378 also edits mllp.py; this branch merges clean against main at 58a9e32dc.)

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA line from the Builder of vault BACKLOG #3302 (posted verbatim by the Manager):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned (both rounds ran as forked execution; no "<level> effort -> ..." line came back).
Rounds: 2. Findings: round 1, 12: 10 fixed, 2 rejected, 0 open. Round 2, 12: 7 fixed, 5 open.

The skill's first lines, verbatim: round 1, "I found 12 issues in this branch's diff against origin/main."; round 2 returned a bare JSON list of 12 findings with no opener. The five open round-2 findings are listed in the PR body under Known defects.

@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 17, 2026
@wshallwshall

wshallwshall commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Proposed vault banner for #3302, verbatim from the Builder's report (for the Lander; the ledger was not edited):

CLOSING STEPS 1-3 BUILT (engine branch b291/3302-tls-handshake-record). A failed inbound TLS handshake is counted and recorded, throttled to one record per listener per minute, on the HTTP intake listener (tls_handshake_failed event), the API (WARNING line) and MLLP under uvloop (tls_handshake_failed event). Records the loop ran name no peer; a handshake past its bound is not counted there. Step 4, the vault re-read, is open vault record work. For #2613: open item 3 (the uvloop count) built on the same branch. The ledger was not edited.

Proposed new row title: "a failed inbound TLS handshake on the DICOM C-STORE SCP leaves no record above DEBUG". The DICOM C-STORE SCP logs a failed inbound TLS handshake at DEBUG only, with no counter or event (docs/DICOM.md:88); it runs its own handshake via wrap_socket, which this PR's hook does not reach.

(Edited by the Manager: one ASVS cell id removed from the quoted banner; the vault copy of this banner may carry it.)

ctx = ssl.create_default_context()
with socket.create_connection(("127.0.0.1", port), timeout=5.0) as raw:
try:
with ctx.wrap_socket(raw, server_hostname="127.0.0.1"):
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: HELD before enqueue. The engine merge queue squashes with COMMIT_MESSAGES, so this branch's commit bodies would land on main, and they pair an ASVS cell id with an open state (the vault presence-map rule). Please rewrite the branch history so no commit message carries that pairing, then post the new head with a QA line naming it.

…e API and MLLP under uvloop (BACKLOG #3302, #2613 item 3)

Squash of four Builder commits; the pre-squash head 9a24c35 is kept on branch b291/3302-pre-squash. Their messages follow, with one ASVS cell id removed.

Record a failed inbound TLS handshake the event loop runs (BACKLOG #3302, #2613 item 3)

Where the loop runs the TLS handshake (asyncio.start_server or
loop.create_server with ssl=, uvloop, uvicorn), a failed one never
reaches the application, and both loops log it at DEBUG only. So the
HTTP intake listener and the API recorded none on any loop, and MLLP
none on uvloop.

New messagefoundry/tls_handshake_failures.py:
- HandshakeFailures: the MLLP listener's counter and throttle (#2613),
  lifted out so all three surfaces share it. First failure at once,
  then one report per listener per 60 s with the count.
- watch_loop_handshakes: sets the server context's sslobject_class to
  an SSLObject subclass whose do_handshake reports an ssl.SSLError
  other than SSLWantReadError/SSLSyscallError, then re-raises. Read in
  CPython 3.14 asyncio/sslproto.py and uvloop 0.23.0 sslproto.pyx: both
  build the object with ctx.wrap_bio and drive do_handshake, treating
  exactly that set as failure. No server or protocol class replaced.

Wiring: MLLP installs the hook on the loop-run branch (uvloop) and keeps
its own peer-bearing count where it upgrades itself; the HTTP listener
emits tls_handshake_failed; the API logs a throttled WARNING, and serve
logs the held count once uvicorn returns.

Limits, recorded not forced: the hook knows no peer address, so these
records name none; a handshake that only runs past its bound is not
counted where the loop runs it. A wrong certificate, missing client
certificate, downgrade or plaintext all raise and are counted. With
[diagnostics].connection_events = false the MLLP and HTTP events are
dropped as before (#3299); the in-process counters still count. The
API line is a log line and does not depend on that switch.

Proposed PR title: Record a failed inbound TLS handshake on the HTTP
intake listener, the API and MLLP under uvloop (BACKLOG #3302, #2613
item 3)

Proposed vault banner for #3302: CLOSING STEPS 1-3 BUILT (engine branch
b291/3302-tls-handshake-record). A failed inbound TLS handshake is
counted and recorded, throttled to one record per listener per minute,
on the HTTP intake listener (tls_handshake_failed event), the API
(WARNING line) and MLLP under uvloop (tls_handshake_failed event).
Records the loop ran name no peer; a handshake past its bound is not
counted there. Step 4, the vault re-read, is open vault record work.
For #2613: open item 3 (the uvloop count) built on the same branch. The
ledger was not edited.

-----
Fix the handshake throttle's stale timer and the review's doc findings (BACKLOG #3302)

code-review xhigh, round 1:
- A report due now left an armed trailing timer in place. It could fire
  a second report inside the new window and lose its handle, so
  cancel() and flush() at stop() could not reach the next timer. _count
  now disarms it; the timer has its own callback.
- The API line is logged by a plain emitter, at once, so a task that
  uvicorn's closing loop cancels can no longer lose it.
- MLLP and HTTP stop() flush and cancel only while still stopping, so a
  stop() that resumes after a restart leaves the new run's failures be.
- Docs: CONNECTIONS.md Table B said a failed MLLP handshake had no
  connection event; SECURITY.md said MLLP never runs its allow-list
  before a failed handshake, false on the stdlib loop (#1606); the
  changelog claimed every TLS listener (the DICOM SCP still logs at
  DEBUG only); the console filter comment named MLLP only.
- The serve test restores the logger level it found.

Proposed PR title: Record a failed inbound TLS handshake on the HTTP
intake listener, the API and MLLP under uvloop (BACKLOG #3302, #2613
item 3)

Proposed vault banner for #3302: CLOSING STEPS 1-3 BUILT (engine branch
b291/3302-tls-handshake-record). A failed inbound TLS handshake is
counted and recorded, throttled to one record per listener per minute,
on the HTTP intake listener (tls_handshake_failed event), the API
(WARNING line) and MLLP under uvloop (tls_handshake_failed event).
Records the loop ran name no peer; a handshake past its bound is not
counted there. Step 4, the vault re-read, is open vault record work.
For #2613: open item 3 (the uvloop count) built on the same branch. The
ledger was not edited.

-----
Bound the recorder-fault log and tighten the handshake docs and tests (BACKLOG #3302)

code-review xhigh, round 2 (the last):
- A recorder that fails on every call logged a WARNING per failed
  handshake; now the first is a WARNING and later ones DEBUG.
- The module's list of what the hook cannot see is now "at least" and
  names wrap_bio faults and wrap_socket contexts (the DICOM SCP).
- The counters' docstrings say failures during stop() are not counted.
- CONNECTIONS.md no longer says a uvloop handshake logs at DEBUG or
  frees a slot, and points at PHI.md row 7 instead of restating the
  throttle; SECURITY.md names build_api_ssl_context instead of a stale
  line anchor.
- The timer test uses a 60 s window, and the API serve test polls the
  record's count instead of sleeping.

Open notes are in the Builder's report for the PR body.

Proposed PR title: Record a failed inbound TLS handshake on the HTTP
intake listener, the API and MLLP under uvloop (BACKLOG #3302, #2613
item 3)

Proposed vault banner for #3302: CLOSING STEPS 1-3 BUILT (engine branch
b291/3302-tls-handshake-record). A failed inbound TLS handshake is
counted and recorded, throttled to one record per listener per minute,
on the HTTP intake listener (tls_handshake_failed event), the API
(WARNING line) and MLLP under uvloop (tls_handshake_failed event).
Records the loop ran name no peer; a handshake past its bound is not
counted there. Step 4, the vault re-read, is open vault record work.
For #2613: open item 3 (the uvloop count) built on the same branch. The
ledger was not edited.

-----
Stub the API context with a real SSLContext in the exposure tests (BACKLOG #3302)

serve now watches the API context's handshakes, which sets its
sslobject_class. The revocation-gate tests stubbed the context builder
with a bare object(), so serve exited 1 there. They now get an empty
server SSLContext; the gate, not context building, is still what they
test. Found by the full suite on this branch.

Proposed PR title: Record a failed inbound TLS handshake on the HTTP
intake listener, the API and MLLP under uvloop (BACKLOG #3302, #2613
item 3)

Proposed vault banner for #3302: CLOSING STEPS 1-3 BUILT (engine branch
b291/3302-tls-handshake-record). A failed inbound TLS handshake is
counted and recorded, throttled to one record per listener per minute,
on the HTTP intake listener (tls_handshake_failed event), the API
(WARNING line) and MLLP under uvloop (tls_handshake_failed event).
Records the loop ran name no peer; a handshake past its bound is not
counted there. Step 4, the vault re-read, is open vault record work.
For #2613: open item 3 (the uvloop count) built on the same branch. The
ledger was not edited.

-----
@wshallwshall
wshallwshall force-pushed the b291/3302-tls-handshake-record branch from 9a24c35 to 26c6f6c Compare October 17, 2026 18:11
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Branch rewritten by the Manager at the Lander's request: the merge queue squashes with commit bodies, and four commit messages paired an ASVS cell id with an open state.

  • New head: 26c6f6cb49e252ad571d044e8107554c80654d8b, one commit, parent 064700f2d5 (the original merge base).
  • Old head: 9a24c35fb8a6242a5a4cf5e74fdb993271b9ba65, kept on branch b291/3302-pre-squash.
  • git diff 9a24c35fb 26c6f6cb4 is empty: the tree is byte-identical. Only the messages changed (the four Builder messages are kept in the new body, with that one cell id replaced by "the vault re-read").

QA line naming the new head:

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned (both rounds ran as forked execution; no "<level> effort -> ..." line came back).
Rounds: 2. Findings: round 1, 12: 10 fixed, 2 rejected, 0 open. Round 2, 12: 7 fixed, 5 open.
Head: 26c6f6cb4 -- the same tree as the reviewed 9a24c35fb (empty diff); no code changed in the rewrite.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: enqueuing at pinned head 26c6f6c. Review proof covers this head; open findings are filed in vault (pass U, vault PR 2506, merged).

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 0ed70dc Oct 17, 2026
52 of 54 checks passed
@wshallwshall
wshallwshall deleted the b291/3302-tls-handshake-record branch October 17, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants