Skip to content

tls, httpclient, quic: BoringSSL ships in the packages and replaces the system OpenSSL - #373

Merged
MDA2AV merged 10 commits into
mainfrom
feat/boringssl
Oct 14, 2026
Merged

MDA2AV merged 10 commits into
mainfrom
feat/boringssl

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

ioxide's TLS runs on BoringSSL instead of the system OpenSSL, everywhere OpenSSL was used: the server (ioxide.tls), the HTTP client, and the QUIC bundle's crypto. BoringSSL ships inside the packages, so TLS no longer needs anything installed. It is also cheaper: 15% less CPU per request on keep-alive TLS, 13% less per handshake.

Fixes #348.

The native build

  • scripts/build-boringssl-native.sh builds libioxide_boringssl.so from a pinned BoringSSL commit (5b83e5d, 2026-10-11), the same way the other native bundles are built: libssl and libcrypto linked whole into one stripped object (3.3 MB), packed into ioxide's runtimes/linux-x64/native. Its only dependency is libc.
    • -Bsymbolic keeps every call inside the library: .NET loads the system OpenSSL into the same process for its own crypto, under the same symbol names.
    • The static C++ runtime BoringSSL needs is linked in and hidden. The script checks every symbol ioxide imports, and that nothing from the C++ runtime is exported.
  • scripts/build-ngtcp2-native.sh builds picotls and ngtcp2 on BoringSSL's libcrypto, linked in statically with its symbols hidden. libioxide_ngtcp2.so no longer needs libcrypto.so.3 (1.2 MB -> 2.9 MB).

What changed in the code

  • OpenSsl is now BoringSsl (server and client bindings). BoringSSL has real functions where OpenSSL used SSL_CTX_ctrl macros (protocol bounds, SNI callback, extra chain certificates), and a few ABI types differ (size_t lengths, 32-bit error codes); the bindings follow BoringSSL's headers.
  • CipherSuites keeps its meaning through SSL_CTX_set1_tls13_ciphers. Every name has to be a TLS 1.3 suite BoringSSL implements, so a TLS 1.2 name or a typo is refused at startup. That is tls: a CipherSuites name that is not a TLS 1.3 suite is accepted, and the port then serves nobody #348, and the test tls: a CipherSuites name that is not a TLS 1.3 suite is refused at startup #367 added for it now passes as a regular test. kTLS still pins AES-128-GCM.
  • OpenSSL's PEM reader skips a UTF-8 BOM and BoringSSL's does not. A certificate, key or client-CA file that starts with one is read through the in-memory route with the BOM removed; key bytes read that way are zeroed afterwards.
  • A key that does not match its certificate is reported as "do not go together", whether BoringSSL notices at the key load or the explicit pair check does.
  • HTTP client:
    • An origin reached by its IP address is verified against the certificate's iPAddress names, and no SNI is sent for it (RFC 6066 3). BoringSSL's SSL_set1_host checks DNS names only; OpenSSL switched to the address check itself.
    • The SNI call's result is checked. A server name BoringSSL will not send (over 255 bytes) now fails the connect; OpenSSL's ctrl went unchecked and the name was silently not sent.
    • A refused certificate is reported as certificate verify failed (...) whatever the library calls it.
    • The alert sent after a failed handshake is best-effort: a peer that already closed refuses it, and that refusal no longer replaces the handshake's own error.
  • The QUIC client keeps offering the key-exchange groups it offered before. Built on BoringSSL, picotls's list starts with X25519MLKEM768, a 1.2 KB key share. Servers keep the full list, so a client that offers the post-quantum group gets it.

Found along the way

  • TlsSession.Dispose skipped its close_notify when the peer had sent one first. RFC 8446 6.1 (and 5246 7.2.1) has each side send close_notify before closing its write side, so a client that closed politely saw the server's stream end without one. New test: tls truncation: a client's close_notify is answered with the server's own, which fails with the old guard put back.
  • The Playground README documents PLAYGROUND_TLS_CERT / PLAYGROUND_TLS_KEY, and no sample read them. Env.OverrideTlsCert now does, in all 26 samples that take a certificate.
  • The QUIC test client wrote a request's packet exactly once, and ngtcp2 gives that write to a path-MTU probe when one is due (1444 bytes, consumed -1). quic: a send on a stream the peer already finished does not leak send retention failed whenever its request met the probe: run alone, 21 of 25 times on main and 25 of 25 here. Send now sends the probe and writes again; 25 of 25 pass on both.
  • The prologue reader fix meant for tls (ktls rx): the handoff counts records once the batch is over and takes a cut record's rest, and close_notify reaches TlsSession.Closed #372 (a drained TlsProloguePipeReader allocated 208 B per waiting read) is in here as its own commit.

Behaviour that differs from OpenSSL

  • Error text: BoringSSL reports reasons as identifiers (NO_SHARED_CIPHER, UNSUPPORTED_PROTOCOL, NO_START_LINE, BAD_END_LINE). The tests that matched OpenSSL's wording now match these.
  • A client certificate larger than about 30 KB can no longer be served: BoringSSL's TLS 1.3 session tickets carry the client certificate, and two of them plus the first response record have to fit one 64 KB buffer. The identity test that rendered a 40 KB subject now renders 20 KB.
  • SessionLifetimeTests used to call TlsSession.Write after Dispose, which is a use-after-free. OpenSSL happened to return an error there; BoringSSL segfaults. The library contract is unchanged (use after dispose is not guarded); the test no longer commits it.
  • MinimumVersion = 0 on the client means BoringSSL's default floor, TLS 1.2, where OpenSSL read it as no floor.

Tests

Every suite, Release, on the final commit:

Suite Passed Failed Pending
Unit 82 0 1
Tls 194 0 0
Http 88 0 0
E2E 288 0 5
Chaos 79 0 1
File 5 0 0
Pg 7 0 0 (11 skipped, no database)
Redis 0 0 1 (6 skipped, no server)

#348's test, pending on main, passes here, and the close_notify test is new. Three QuicEngineTests cases failed on the post-quantum key share (client write failed: 1444) until the client's group order was restored.

Benchmarks

Loopback, main (cda7e55, Ubuntu's OpenSSL 3.0.13) against this branch before its rebase onto #358-#370, none of which touch the request path; interleaved runs, server pinned to 2 cores.

Keep-alive HTTP/1.1, bench/ab.sh, 2 reactors, 64 connections, 5 rounds of 10 s, medians:

Sample main CPU/req branch CPU/req CPU/req req/s
Tls/OpenSslPipes 4.997 us 4.244 us -15.1% +17.0%
Tls/OpenSsl (raw ring) 4.559 us 3.883 us -14.8% +16.7%
Tls/KtlsPipes (kernel crypto) 5.111 us 4.920 us -3.7% (noise) +4.0%

Full TLS 1.3 handshakes, Tls/OpenSsl, RSA-2048, 32 Python clients each closing right after its handshake, 3 rounds of 5 s: 505.6 -> 438.2 us of server CPU per handshake (-13.3%), 3,886 -> 4,467 handshakes/s, no failures. The exploration behind this PR measured -37% for an ECDSA P-256 certificate.

bench/tls-matrix.sh on the branch, 4 reactors, best of two passes, requests/s and the ratio to plaintext. The TLS page, the Tls/OpenSsl sample and TlsOptions.KernelTx quote this table now instead of the OpenSSL one:

response plaintext kTLS TX BoringSSL
64 B 1,245,989 937,322 0.75x 976,752 0.78x
8 KiB 991,826 582,376 0.59x 676,158 0.68x
64 KiB 344,094 134,112 0.39x 153,538 0.45x
256 KiB 113,412 38,786 0.34x 40,250 0.35x

Over HTTP/2 the two stay within 5% of each other at 64 B and 8 KiB. bench/file-matrix.sh still has the userspace library ahead of kTLS+slab at 64 KiB (52,925 vs 38,310 req/s), as the file sample's notes say.

Docs

The TLS pages, READMEs, sample comments, generated panes, bench script comments and test names say BoringSSL where they described the library ioxide uses. Comments that record how a bug was found under OpenSSL keep saying OpenSSL. Claims about library behaviour were checked against BoringSSL's source: the SNI list parsing, the ALPN validation, the key/certificate check at load, the error-queue clearing and PEM_def_callback.

Not renamed here, as both would be breaking or noisy: the Tls/OpenSsl, Tls/OpenSslPipes and Tls/MtlsOpenSslPipes sample directories (with the bench labels that follow them), and the public OpenSslVersions constants class in ioxide.httpclient.

Rebased onto #358, #362, #366, #368, #369 and #370

What those PRs added, carried over to BoringSSL:

#367 is closed: this PR fixes #348 its own way and keeps that PR's test. #307 is the one open PR this conflicts with, and it already conflicts with main.

MDA2AV added 10 commits October 14, 2026 20:17
…into one library that needs only libc

scripts/build-boringssl-native.sh links libssl and libcrypto whole into one -Bsymbolic object and
packs it into ioxide's runtimes/linux-x64/native, like the ngtcp2, nghttp2 and nghttp3 bundles.
BoringSsl replaces OpenSsl. BoringSSL has real functions where OpenSSL used SSL_CTX_ctrl macros, and
its lengths and error codes are size_t and uint32; the binding follows its headers. CipherSuites goes
through SSL_CTX_set1_tls13_ciphers, refusing any name that is not a TLS 1.3 suite BoringSSL
implements (#348); kTLS still pins AES-128-GCM. A PEM file with a UTF-8 BOM, which OpenSSL's reader
skipped, goes through the in-memory route without it. A key that does not match its certificate
says so the same way whichever check notices. Tests that matched OpenSSL's error wording match
BoringSSL's; the identity test renders a 20 KB subject, since a larger client certificate no longer
fits BoringSSL's TLS 1.3 tickets; the lifetime test no longer writes to a disposed session.
…ess is verified against its iPAddress names

BoringSSL's SSL_set1_host checks DNS names only, where OpenSSL switched to the address check itself,
so an address goes through X509_VERIFY_PARAM_set1_ip - and is not sent as SNI (RFC 6066 3). A refused
certificate still reads 'certificate verify failed'. The alert after a failed handshake is
best-effort: a peer that already closed refuses it, and that no longer replaces the handshake's
own error.
…ng from the system

picotls's OpenSSL backend is built on BoringSSL, linked in with its symbols hidden; the bundle no
longer depends on libcrypto.so.3. The client keeps offering the groups it offered before: on
BoringSSL, picotls's list starts with X25519MLKEM768, a 1.2 KB key share. Servers keep the full list,
so a client that offers the post-quantum group gets it.
TlsSession.Dispose skipped its close_notify when the peer had sent one first. RFC 8446 6.1 (and 5246
7.2.1) has each side send close_notify before closing its write side, so a client that closed
politely saw the server's stream end in a truncation.
…E says

Env.OverrideTlsCert, called in the 26 samples that take a certificate. The README and four TLS
samples documented the two variables and nothing read them.
…they are, so a read that waits allocates nothing

The pass-through was an async method, so every read that had to wait allocated its state
machine: 208 B per keep-alive request on a kTLS-RX pipe connection. The carry path keeps its
async body.
…is refused

Its bundle goes through AddTrustAnchorsPem, whose two refusals said ClientCaPem whatever the source.
…s measured again on it

The TLS page, the Tls/OpenSsl sample and TlsOptions.KernelTx quoted OpenSSL measurements; they quote
bench/tls-matrix.sh on this branch now (kTLS TX costs 5-29% more CPU per request than BoringSSL,
never less). Comments that described OpenSSL's behaviour as the library's say what BoringSSL does,
checked against its source; the ones that record how a bug was found under OpenSSL stay as they
were. Test names that said OpenSSL for the default path say BoringSSL.
…eam data, rather than failing

ngtcp2 probes the path at 1406 and then 1444 bytes once the handshake is confirmed, and a probe that
is due takes the next write without the stream's data (consumed -1). Send wrote exactly once, so
'a send on a stream the peer already finished does not leak send retention' failed whenever its
request met the second probe: run alone it failed 21 of 25 times on main and 25 of 25 here, and
passes 25 of 25 on both with the probe sent first.
@MDA2AV
MDA2AV merged commit e18120c into main Oct 14, 2026
1 check passed
@MDA2AV MDA2AV mentioned this pull request Oct 14, 2026
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.

tls: a CipherSuites name that is not a TLS 1.3 suite is accepted, and the port then serves nobody

1 participant