Skip to content

tls: mutual TLS on the TCP side, for both OpenSSL and kTLS - #185

Merged
MDA2AV merged 1 commit into
mainfrom
feat/tls-mtls
Aug 12, 2026
Merged

tls: mutual TLS on the TCP side, for both OpenSSL and kTLS#185
MDA2AV merged 1 commit into
mainfrom
feat/tls-mtls

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 12, 2026

Copy link
Copy Markdown
Owner

ioxide could require client certificates over QUIC (#180) and not over TCP. The TCP server had no peer verification at all - no SSL_CTX_set_verify, no load_verify_locations, and nothing in TlsOptions to configure one. An https:// port could prove who the server was and had no way to ask the other question.

Both TLS paths now support it. SslStream over TcpConnectionStream always could, because that is the BCL's implementation and the caller configures it - this closes the two paths ioxide owns.

The options

var tls = new TlsOptions
{
    CertificatePath = certPath,
    KeyPath         = keyPath,
    ClientCaPath    = "ca.crt",          // or ClientCaPem, for anchors carried as data
    RequireClientCertificate = false,    // refuse a client with none, at the handshake
};

Anchors from a file go through load_verify_locations; anchors from memory are parsed into the context's own store, mirroring how the server's own certificate already comes from either source. With a file we additionally send the CA names in the CertificateRequest, so a client holding several certificates picks the one this server accepts rather than guessing - failing to enumerate them costs the hint, not the verification.

Two things worth reviewing

RequireClientCertificate governs presenting NOTHING, not presenting ANYTHING. Off, a client with no certificate connects and PeerSubject is null, so a handler can serve a public route and refuse a protected one. On, that client is refused at the handshake. Either way a certificate that is offered gets verified - a rogue one is rejected in both modes. There is a test pinning exactly that, because the opposite reading would make the option mean "trust anything".

TlsSession.PeerSubject is the half that makes it useful. Enforcing an identity is not much use if nothing can read one. A value there means the chain VALIDATED: an invalid chain already failed the handshake, so a connection reaching a handler never carries a merely-offered certificate.

mTLS and kTLS are orthogonal

Which is why there are two samples and they differ by one line. The certificate is exchanged and verified during the handshake, which OpenSSL performs either way; the kernel only takes over record crypto afterwards. Playground/Tls/MtlsKtlsPipes is Playground/Tls/MtlsOpenSslPipes with KernelTx = true and nothing else changed - diff them and that is all there is. Both serve through TlsConnectionDualPipe.

Every mTLS test runs on both paths for the same reason: asserting it is cheaper than reasoning about it.

Nothing changes for anyone not using it

Every new path sits behind "are anchors configured". A server without them performs the handshake it performed before, and there is a test asserting that a client which HAS a certificate is never asked for one when no CA is set.

Verified end to end

Both samples, driven with curl and real certificates:

OpenSSL kTLS
valid client cert 200, X-Client: /CN=alice 200, X-Client: /CN=alice
cert from a rogue CA tlsv1 alert unknown ca tlsv1 alert unknown ca
no cert, optional 403 from the handler 403 from the handler
no cert, required tlsv13 alert certificate required -

The kTLS body decrypts to the full 8192 bytes, so kernel-produced records are intact after a handshake that exchanged a client certificate.

Tests

11 new cases, driven with SslStream as the client so a pass means agreeing with an independent implementation rather than only with ourselves: verified identity reaching the handler, refusal with none when required, refusal of another CA's certificate, the optional-mode matrix, in-memory anchors matching file anchors, the no-CA baseline, and three configuration mistakes that must fail at startup rather than at some later handshake.

Build clean. 29 TLS tests green with 0 skipped (sudo modprobe tls loaded), plus 25 unit and 28 http.

ioxide could require client certificates over QUIC (#180) and not over TCP.
The TCP server had no peer verification at all: no SSL_CTX_set_verify, no
load_verify_locations, and nothing in TlsOptions to configure one. An
https:// port could prove who the SERVER was and never ask the other
question.

TlsOptions gains ClientCaPath, ClientCaPem and RequireClientCertificate.
Anchors from a file go through load_verify_locations; anchors from memory are
parsed into the context's own store, mirroring how the server's certificate
already comes from either. With a file we also send the CA names in the
CertificateRequest, so a client holding several certificates picks the one
this server accepts instead of guessing - a failure to enumerate them costs
the hint, not the verification.

RequireClientCertificate is the interesting distinction. Off, a client with
no certificate still connects and TlsSession.PeerSubject is null, so a
handler can serve a public route and refuse a protected one. On, it is
refused at the handshake. Either way a certificate that IS offered is
verified: "optional" governs presenting nothing, not presenting anything.

PeerSubject is the other half. Enforcing an identity is not much use if
nothing can read one, and a value there means the chain VALIDATED - an
invalid one already failed the handshake, so a connection that reaches a
handler never carries a merely-offered certificate.

Orthogonal to kTLS, which is why both samples exist and differ by one line.
The certificate is exchanged and verified during the handshake, which OpenSSL
performs either way; the kernel only takes over record crypto afterwards.
MtlsKtlsPipes is MtlsOpenSslPipes with KernelTx = true and nothing else.

Nothing changes for anyone not using it: every path here sits behind "are
anchors configured", and a server without them performs the handshake it
performed before.

Verified end to end with curl against the sample:
  valid client cert  -> 200, X-Client: /CN=alice
  cert from a rogue CA -> tlsv1 alert unknown ca
  no cert, optional  -> 403 from the handler
  no cert, required  -> tlsv13 alert certificate required

11 new tests, driven with SslStream so a pass means agreeing with an
independent implementation. Build clean; 25 unit, 23 tls and 28 http green.
The three kTLS cases skip without `sudo modprobe tls`.
@MDA2AV
MDA2AV merged commit 6b6b6e5 into main Aug 12, 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