Skip to content

tls_check: re-land the opt-in TLS check that never reached main - #1301

Merged
MDA2AV merged 1 commit into
mainfrom
feat/tls-check
Aug 24, 2026
Merged

tls_check: re-land the opt-in TLS check that never reached main#1301
MDA2AV merged 1 commit into
mainfrom
feat/tls-check

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Re-lands the tls_check work from #1296, which merged but never reached main.

What happened

#1296 was stacked on feat/tls-validation (#1292) — I set that base to stop the two PRs showing the same diff twice. The timing then went wrong:

time event
15:35 #1292 squash-merged into main as 47b79d7c, and closed
16:03 #1296 merged into feat/tls-validation — a branch whose PR had already closed 28 minutes earlier

A squash merge collapses the branch into one new commit on main; the branch itself is not fast-forwarded and its PR closes. So when #1296 landed on that branch afterwards, there was nothing left to carry it onward. It has been sitting in feat/tls-validation ever since, merged and unreachable.

That branch cannot simply be merged now either — it predates #1298, #1299 and #1300, so merging it would revert the gRPC streaming removal and the wtx/sark result restore.

This PR

The single tls_check commit, cherry-picked onto current main. Same 8 files, nothing else moves:

  • scripts/validate.sh — the opt-in section: certificate rotation, rotation-under-load, SNI, resumption, close_notify, the testssl vulnerability suite, on :9000 with /certs-tls
  • scripts/gen_leaderboard_data.py — carries the verdict into meta.tlsCheck
  • site/leaderboard/index.html — the gold badge, H1 composite only
  • frameworks/aspnet-minimal/*RotatingCertificate, the :9000 listener, "tls_check": true
  • docs + the recorded verdict

Verified on this branch

scripts parse · board JS parses
tls_check in meta: 1 · tlsCheckBadge refs: 4
stream-grpc in profiles.sh: 0   (#1298 intact)
wtx.json present: yes           (#1299 intact)
generator exit=0 · badge parity ok — 530 ranks match

The parity count moved 522 → 530 because this branch sits on top of #1298/#1299 rather than beside them.

🤖 Generated with Claude Code

…osite

Opted into with a meta.json field:

    "tls_check": true

It needs a TLS listener on :9000 reading /certs-tls, a directory mounted for
that entry alone. Nothing is measured; passing earns a badge on the HTTP/1.1
composite, and only there -- the check covers :8081-class HTTP/1.1 TLS, so a
badge earned on it must not follow the entry into h2 and h3 views it says
nothing about.

  certificate rotation      the pair at /certs-tls is replaced under a
                            running server; the new certificate must be
                            served without a restart and still answer.
                            Measured first: caddy, bun and h2o-mruby all
                            keep serving the old one.
  rotation keeps serving    30 requests across the swap, all must succeed
  SNI                       handshakes with a server name and without
  session resumption        reported, not required
  close_notify              closed at the TLS layer, not just the socket
  vulnerability suite       testssl.sh -U: Heartbleed, ROBOT, POODLE,
                            SWEET32, LUCKY13 and 14 more. HIGH or CRITICAL
                            fails.
  the shared TLS checks     certificate identity, TLS 1.3, AEAD, ALPN,
                            obsolete protocols and weak ciphers

The dedicated port and private directory are the point of the design: the
check rotates certificates under a running server, and doing that to the
shared /certs would move the ground under json-tls, static-tls and every h2
profile in the same run. /certs is verified byte identical across a run that
rotates twice.

aspnet-minimal opts in and implements it. RotatingCertificate re-reads the
pair when its mtime moves, behind Kestrel's ServerCertificateSelector, which
runs per handshake. 114 passed, 0 failed; rotation lands in 1s and 30/30
requests survive the swap.
@MDA2AV
MDA2AV merged commit 3b3bf89 into main Aug 24, 2026
1 of 2 checks passed
nigrosimone pushed a commit to nigrosimone/HttpArena that referenced this pull request Aug 26, 2026
tls_check (MDA2AV#1301) is a hardening bar rather than a profile: nothing is
measured, and passing earns a badge on the H1 composite. It wants a TLS
listener on :9000 reading /certs-tls, a directory belonging to this entry
alone, because the section replaces the pair underneath the running server
and doing that to /certs would move the ground under json-tls, static-tls
and the h2 profiles in the same run.

The listener is the 8081 one again, pointed at the other directory. What
had to be worked out is the rotation.

## Why a new listener and not a new certificate

µWS reads the pair once, when it builds the SSL context, and nothing points
an existing context at a new file afterwards. addServerName() looks like
the answer and is not: it replaces the certificate for one SNI name and
leaves the default context alone, which is what answers a client that sends
no server name. Measured, on this uWS: after
addServerName('localhost', ...) an -servername handshake is served the new
certificate and a -noservername handshake is still served the old one.

Every cert-identity probe in validate.sh passes -servername, so the SNI-only
rotation would have passed the section while leaving a real client that
omits SNI on the expired pair. Rebuilding the listener is the rotation that
reaches both, and it is verified here in both directions: after the swap SNI
and no-SNI both serve the replacement, and after the restore both serve the
original again.

It costs no downtime because it does not have to. A worker binds the port
shared — SO_REUSEPORT — so the replacement is accepting on 9000 beside the
listener it replaces before that one is told to stop, and close() then
closes the old listen socket, lets what it is already serving finish, and
drops its idle keep-alives only after that. Nothing is refused, no response
is cut.

The watcher is armed inside the listen callback, which is what says this
process owns a listener: the primary of a clustered app returns from
listen() without binding, so only the workers arrive there and only they
have a certificate to rotate. The stamp is inode, size and mtime of both
files — the section swaps with mv, so the inode moves too, and a torn read
mid-swap reports nothing rather than a new pair, which retries on the next
tick instead of building a context out of half of each.

## Cost on a measured run: none

/certs-tls is mounted by validate.sh alone. Verified with the directory
absent: no second listener, no watcher, no stat, and :8080 and :8081
unaffected.

## Verified

The tls_check probes lifted whole out of validate.sh and run against each
entry's own app.js — 9 passed, 0 failed on both fulmine and fulmine-tuned:
the mounted certificate, TLS 1.3, an AEAD suite, ALPN, TLS quality (refuses
every obsolete protocol and weak cipher probed), SNI with and without a
server name, close_notify, rotation (new certificate served in 1s, original
back in 1s, still answering) and 30/30 requests answered across the swap.
Session resumption reports NOTE — no ticket issued — which the section
records rather than requires.

Then the control, because a check nothing can fail is not a check: the same
entry with the rotation stripped and the listener built once. It fails
[tls_check certificate rotation] after 30s and passes everything else, which
is the one line this change is responsible for.

The vulnerability suite could not run here — testssl.sh needs docker and
this sandbox has none — so that check is unverified rather than passing.
It is the one thing in the section still to confirm on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXewQKchJsgGrrXxFgGzQ5
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