Skip to content

tests: mine nginx, haproxy and h2o for the cases our own reviews missed - #198

Merged
MDA2AV merged 2 commits into
mainfrom
tests/oracle-cases
Aug 19, 2026
Merged

tests: mine nginx, haproxy and h2o for the cases our own reviews missed#198
MDA2AV merged 2 commits into
mainfrom
tests/oracle-cases

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Our own review rounds kept producing findings we then had to argue with — 11 of the last 31 were refuted. So this round asks a different source: the regression suites of servers that already ship what we ship. nginx has 113 TLS/h3/QUIC test files, haproxy's reg-tests/ssl is a catalogue of runtime certificate replacement going wrong (two files are named after escaped bugs), and h2o is picotls-based like our QUIC side. Those cases are evidence of what actually breaks in production, which an agent's opinion about our code is not.

The first two batches are here. One of them found a live defect.

A server with an encrypted key hangs instead of failing

Porting nginx's ssl_password_file.t asked a question our suite never had. TlsOptions has no passphrase option — so what happens when someone points it at an encrypted key anyway?

It prompts. OpenSSL's PEM readers take a password callback, and passing none does not mean "there is no password", it means PEM_def_callback — which reads the terminal. With the fix removed, the suite prints

Enter PEM pass phrase:

and the server "bound its listener but never finished OnStart". It blocked there and the run had to be killed. Redirecting stdin does not help: OpenSSL's default UI opens /dev/tty directly.

On a server started from a terminal that is a hang rather than an error. On a rotation thread it would be a hang while the old certificates were still being served. The fix installs a callback reporting no passphrase available, before any key is read, which turns the prompt into the ordinary refusal every other unusable key gets: private key '.../enc.key': error:1C800064:Provider routines::bad decrypt.

The test asserts the refusal and a deadline, because a test that only caught the throw would also pass against a build that blocks — the watchdog would kill the suite rather than that test.

Rotation as an operation on a running server

RotationTests establishes that a rotation changes what the next handshake is served. Every one of its cases connects after the call returned, which is the easy half.

  • a connection established before a rotation keeps serving on what it authenticated — the case the design's central promise is for. ReplaceCertificates deliberately keeps the contexts it replaces, because a handshake may be between reading a set and using it. Nothing depended on that until now; no test held a connection across a rotation at all.
  • the same paths are re-read when the files are rewritten in place — how renewal actually happens. certbot rewrites the same paths, often preserving the inode, and nginx's version pins the mtime too. Every existing test hands a different path, so a "skip unchanged paths" optimisation would have broken this silently.
  • every PEM shape that starts a service also rotates into one — haproxy issue #2265, where the runtime loader accepted less than the startup loader and a server that had booted for months could not be renewed. ioxide is structurally immune because BuildCertificates is the single builder for both, but nothing held it to that.
  • a host entry whose key does not match its certificate is refused — already true; what was missing was anything holding it to that, and anything pinning that the refusal names the offending host, which is what an operator rotating twenty names needs.

The production shape, executed for the first time

a rotation that reaches only some reactors serves two certificates at once, with a control that rotates all four.

A TlsService belongs to one reactor. Reactors share a port through SO_REUSEPORT, so which one a client lands on is the kernel's choice — and a renewal that iterated the wrong collection leaves one name answering with two different certificates depending on where the connection landed, with nothing to tell the operator.

Until now this was unreachable: every TLS entry point in the harness pinned ReactorCount = 1, so the shape we actually ship had never been executed by a test. StartSharded gains an optional per-shard OnStart, run before that shard reports ready, which is the only way to hold all N services.

The control matters as much as the test: a fleet that always served two certificates would be a worse bug and a green test.

Also

TlsSession.NegotiatedProtocolVersion and NegotiatedCipherSuiteId had no test anywhere, while ioxide.Kestrel maps both into ITlsHandshakeFeature for applications to make authorization decisions on. Driven against a client pinned to TLS 1.3 and again to TLS 1.2, so the constants are shown to be read rather than assumed, with Kestrel's own mapping asserted alongside.

All suites: 427 passed, 0 failed, 20 pending.

Still to mine: the SNI family (wildcard entries that silently never match, trailing dots, IDN A-labels), the chain cases (TRUSTED CERTIFICATE intermediates being silently dropped), cross-host ticket resumption, and the QUIC side — which needs a sharded QUIC starter, and where migration and CID routing will land as Pending since neither is implemented.

MDA2AV added 2 commits August 19, 2026 12:18
…nd nginx's suites

RotationTests establishes that a rotation changes what the NEXT handshake is served. Every one of
its cases connects after the call returned, which is the easy half. These come from haproxy's
reg-tests/ssl and nginx's ssl_cache_reload.t - both projects ship runtime certificate replacement,
so their suites are a catalogue of how it fails in production - and they ask what happens to a
connection that was already up, to a file rewritten underneath the server, and to a fleet where the
rotation reached only some reactors.

  a connection established before a rotation keeps serving on what it authenticated
      The case the design's central promise is for: ReplaceCertificates KEEPS the contexts it
      replaces, because a handshake may be between reading a set and using it. Nothing depended on
      that until now - no test held a connection across a rotation at all.

  the same paths are re-read when the files are rewritten in place
      How renewal actually happens: certbot rewrites the same paths, often preserving the inode,
      and nginx's version of this pins the mtime too. Every existing test hands a DIFFERENT path,
      so a "skip unchanged paths" optimisation would have broken this silently.

  every PEM shape that starts a service also rotates into one
      haproxy issue #2265, where the runtime loader accepted less than the startup loader and a
      server that had booted for months could not be renewed. ioxide is structurally immune because
      BuildCertificates is the single builder for both, but nothing held it to that.

  a host entry whose key does not match its certificate is refused
      Already true, and now pinned - including that the message names the offending HOST, which is
      what an operator rotating twenty names needs. QUIC covered this at three entry points; TCP
      reached it only through the default certificate.

  a rotation that reaches only some reactors serves two certificates at once, and its control
      The production shape, and until now unreachable: every TLS entry point in the harness pinned
      ReactorCount = 1. A TlsService belongs to one reactor and they share a port through
      SO_REUSEPORT, so a renewal that iterated the wrong collection leaves one name answering with
      two certificates depending on where the kernel put the connection. The control rotates all
      four, because a fleet that ALWAYS served two would be a worse bug and a green test.

StartSharded grew an optional per-shard OnStart, run before that shard reports ready, which is the
only way to hold all N TlsService instances. Tls suite: 115 passed, 0 failed.
…on the terminal

Found by porting nginx's ssl_password_file.t, which exists because nginx needed somewhere to put
passphrase handling. ioxide has no such option, so the interesting question was not whether the
feature works but what happens when someone hands it an encrypted key anyway.

It prompted. OpenSSL's PEM readers take a password callback, and passing none does not mean "there
is no password" - it means PEM_def_callback, which reads the passphrase from the TERMINAL. Proven
both ways: with the callback removed, the suite printed

    Enter PEM pass phrase:

and the server "bound its listener but never finished OnStart" - it blocked there, and the run had
to be killed. Redirecting stdin does not help, because OpenSSL's default UI opens /dev/tty
directly. On a server started from a terminal that is a hang rather than an error, and on a
rotation thread it would be a hang while the old certificates were still being served.

The fix is a callback that reports no passphrase available, installed on every context before any
key is read, which turns the prompt into the ordinary refusal every other unusable key already
gets: "private key '/tmp/.../enc.key': error:1C800064:Provider routines::bad decrypt". The test
asserts the refusal AND a deadline, because a test that only caught the throw would also pass
against a build that blocks - the watchdog would kill the suite rather than that test.

Also from nginx's ssl.t: TlsSession.NegotiatedProtocolVersion and NegotiatedCipherSuiteId had no
test anywhere, while ioxide.Kestrel maps both into ITlsHandshakeFeature for applications to make
authorization decisions on. Driven against a client pinned to TLS 1.3 and again to TLS 1.2, so the
constants are shown to be READ rather than assumed, with Kestrel's own 0x0304/0x0303 mapping
asserted alongside and a guard that the two handshakes did not report the same thing.

All suites: 340 passed, 0 failed, 4 pending.
@MDA2AV
MDA2AV merged commit fc0885a into main Aug 19, 2026
1 check passed
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