You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Design: a BoringSSL-free QUIC-TLS interface for NIOSSL
Status. F0 (API + bridge), F1 (handshake tests), and the delegate minimization (#4: alerts folded into the error, flush dropped) have landed on this fork's main, with follow-on hardening: #6 consumes the pending alert, #7 fails the secret callback on a null cipher, #8 tests the fatal-alert path, and #9 folds post-handshake processing into advance(). F2 has started: hostname verification and SNI land in #11 (issue #10). This issue is the living design doc and reflects the current API. Appendix comments below record prior art and the rationale for specific decisions. Nothing is committed upstream yet.
Motivation
QUIC (RFC 9001) does not run TLS over TCP records. It drives the TLS 1.3 handshake state machine directly and consumes its outputs per encryption level (Initial, 0-RTT, Handshake, 1-RTT): the handshake bytes to put in CRYPTO frames, and the read/write traffic secrets used to derive packet-protection keys. BoringSSL exposes this through its QUIC API (SSL_set_quic_method, SSL_provide_quic_data, SSL_process_quic_post_handshake, the SSL_QUIC_METHOD callbacks). swift-nio-ssl vendors a BoringSSL that has this compiled in, but NIOSSL surfaces none of it.
alta/swift-nio-quic, a pure-Swift QUIC transport on SwiftNIO, needs a TLS 1.3 handshake driver. This adds a thin, safe Swift surface over the BoringSSL QUIC API. (QUIC was part of the original motivation for moving swift-nio-ssl to BoringSSL.)
Hard constraints
No BoringSSL in the public API. Per the maintainer's policy (#484), the public surface contains no part of BoringSSL — it has no API/ABI stability. The API uses only Swift / NIOSSL types; the cipher suite is the IANA UInt16, translated from SSL_CIPHER * inside the module. No ssl_encryption_level_t, SSL_CIPHER, OpaquePointer, or CNIOBoringSSL import in any public declaration.
Minimal, additive, complete. Smallest surface that fully drives a QUIC handshake. New code under Sources/NIOSSL/QUIC/; edits to existing files surgical.
Convention-exact. API/doc style, Sendable correctness, availability, NIOSSLError, the platform matrix, and XCTest. Reuse NIOSSLContext / TLSConfiguration for certificates, keys, trust, verification, ALPN, SNI.
Security and correctness. Traffic secrets are copied out of callbacks immediately; never logged. Behavior matches BoringSSL's documented QUIC contract. Adversarial and boundary cases tested.
Current public API
/// A TLS encryption level, as used by QUIC (RFC 9001 §2.1).
publicenumNIOTLSEncryptionLevel:Sendable,Hashable{case initial, earlyData, handshake, application
}
/// Whether the handshake acts as client or server.
publicenumNIOSSLQUICRole:Sendable,Hashable{case client, server }
/// An error raised by a handshake.
publicenumNIOSSLQUICError:Error,Hashable,Sendable{
/// A fatal TLS alert (RFC 8446 §6); the QUIC layer maps it to
/// CONNECTION_CLOSE 0x0100|alert (RFC 9001 §4.8).
case tlsAlert(UInt8)}
/// Receives the handshake's outputs. Invoked synchronously from
/// `provideHandshakeData(_:)` / `advance()`. There is no flush callback (flights
/// are bounded by the call that produces them) and no alert callback (alerts are
/// fatal and surface as a thrown NIOSSLQUICError).
publicprotocolNIOSSLQUICDelegate:AnyObject{func setReadSecret(level:NIOTLSEncryptionLevel, cipherSuite:UInt16, secret:[UInt8])func setWriteSecret(level:NIOTLSEncryptionLevel, cipherSuite:UInt16, secret:[UInt8])func writeHandshakeData(level:NIOTLSEncryptionLevel, _ data:[UInt8])}
/// Drives a single QUIC TLS 1.3 handshake. Created from a configured
/// NIOSSLContext, so certificate/verification/ALPN/SNI config is reused.
/// Not thread-safe; use from one execution context (a connection's event loop).
publicfinalclassNIOSSLQUICHandshake{publicenumState:Sendable,Hashable{case wantsMoreData, complete }publicinit(
context:NIOSSLContext,
role:NIOSSLQUICRole,
serverHostname:String?=nil,
localTransportParameters:[UInt8],
delegate:anyNIOSSLQUICDelegate)throws
/// Feed peer CRYPTO bytes at `level` (SSL_provide_quic_data).
publicfunc provideHandshakeData(level:NIOTLSEncryptionLevel, _ data:ByteBuffer)throws
/// Advance the handshake (SSL_do_handshake), driving the delegate. After
/// completion, drains buffered post-handshake messages (NewSessionTicket,
/// KeyUpdate) via SSL_process_quic_post_handshake (#9). Throws
/// NIOSSLQUICError on a fatal alert, else NIOSSLError.handshakeFailed.
@discardableResult public func advance() throws -> State
public var peerTransportParameters:[UInt8]?{ get }publicvarnegotiatedProtocol:String?{get}}
Handshake bytes are fed in as ByteBuffer (zero-copy) and handed out as [UInt8] (copied out of BoringSSL-owned memory). Secrets are [UInt8].
Internal mapping to BoringSSL (behind the wall)
Public element
BoringSSL
init
SSL_new from the context's SSL_CTX; SSL_set_quic_method; SSL_set_connect/accept_state; SSL_set_quic_transport_params; SSL_set_ex_data for the trampolines
init (serverHostname, client)
SSL_set_tlsext_host_name (SNI); SSL_set1_host under .fullVerification, after validateSNIServerName (#11)
provideHandshakeData
SSL_provide_quic_data
advance
SSL_do_handshake while in init, else SSL_process_quic_post_handshake (#9); WANT_READ/WANT_WRITE → .wantsMoreData, success → .complete
peerTransportParameters
SSL_get_peer_quic_transport_params
negotiatedProtocol
SSL_get0_alpn_selected
setReadSecret/setWriteSecret
set_read_secret/set_write_secret; cipher via SSL_CIPHER_get_protocol_id; fails on a null cipher (#7)
writeHandshakeData
add_handshake_data
NIOSSLQUICError.tlsAlert
send_alert (captured, consumed, thrown from the next step)
(none)
flush_flight — internal no-op
The five C callbacks are non-capturing function pointers in one static SSL_QUIC_METHOD; each recovers self via SSL_get_ex_data + Unmanaged.fromOpaque (the pattern SSLConnection uses).
Edits to existing files
SSLContext.createQUICSSLHandle() — an internal accessor returning a bare SSL (no BIO). The only change to existing code.
Resolved design questions
Full rationale and prior art are in the appendix comments; in brief:
Naming — NIOSSLQUICHandshake + provideHandshakeData (quiche names its BoringSSL wrapper Handshake and its feed provide_data; avoids collision with SSLConnection).
Cipher — IANA UInt16 (as Go, neqo); the only BoringSSL-free option that does not draw packet-protection key types into TLS.
Transport parameters — opaque [UInt8]; unanimous across stacks. TLS does not parse them.
Packaging — in-module under Sources/NIOSSL/QUIC/; a separate glue library (ngtcp2's model) is motivated by multi-backend support, which does not apply.
Alerts — folded into a thrown NIOSSLQUICError (no public alert precedent in NIOSSL; matches Go/neqo/s2n-quic).
Flush — none; an explicit flush is unique to BoringSSL and is a no-op in quiche and ngtcp2.
Push vs pull — delegate (push). This is a genuine industry split (push: quiche, OpenSSL 3.5; pull: Go, neqo, s2n-quic, msquic); justified here by single-event-loop ownership and the buffer-lifetime advantage. See Appendix A.
Deferred / known gaps
Certificate verification on the QUIC path. Chain verification (trust, expiry, signature) is inherited from the context's SSL_CTX, so it already works under .fullVerification with trust roots configured — no custom callback needed. Hostname verification and SNI land in Verify the server hostname in the QUIC TLS handshake #11 via SSL_set1_host (issue Expose serverHostname on NIOSSLQUICHandshake for hostname verification #10). Still deferred: the user-supplied custom verify path (sslContextCallback / NIOSSLCustomVerificationCallback), which expects an SSLConnection the raw QUIC SSL does not have; and IP-address SAN matching (X509_VERIFY_PARAM_set1_ip), since SNI rejects IP literals.
Transport parameters after the ClientHello. The constructor takes them up front, which cannot serve a server choosing them by SNI. An additive path is reserved (cf. Go's QUICTransportParametersRequired).
0-RTT / session tickets. Deferred to a later milestone; when added, model on Go's SendSessionTicket(EarlyData:Extra:) / QUICResumeSession / QUICStoreSession, including the opaque Extra ticket payload.
ChaCha20 header protection (a swift-nio-quic, not NIOSSL, concern): swift-crypto exposes no raw ChaCha20 keystream; AES suites are fully covered by CryptoExtras.
Testing (XCTest, self-contained)
Drive a client and server NIOSSLQUICHandshake against each other in-process: feed each side's writeHandshakeData output to the other's provideHandshakeData (one flight at a time, advancing between — BoringSSL rejects data ahead of its read level), then assert both reach .complete, the read/write secrets agree peer-to-peer per level, a cipher suite is agreed, ALPN negotiates, and transport parameters round-trip. No sockets, no packets. Landed in F1.
Design: a BoringSSL-free QUIC-TLS interface for
NIOSSLMotivation
QUIC (RFC 9001) does not run TLS over TCP records. It drives the TLS 1.3 handshake state machine directly and consumes its outputs per encryption level (Initial, 0-RTT, Handshake, 1-RTT): the handshake bytes to put in CRYPTO frames, and the read/write traffic secrets used to derive packet-protection keys. BoringSSL exposes this through its QUIC API (
SSL_set_quic_method,SSL_provide_quic_data,SSL_process_quic_post_handshake, theSSL_QUIC_METHODcallbacks). swift-nio-ssl vendors a BoringSSL that has this compiled in, butNIOSSLsurfaces none of it.alta/swift-nio-quic, a pure-Swift QUIC transport on SwiftNIO, needs a TLS 1.3 handshake driver. This adds a thin, safe Swift surface over the BoringSSL QUIC API. (QUIC was part of the original motivation for moving swift-nio-ssl to BoringSSL.)Hard constraints
UInt16, translated fromSSL_CIPHER *inside the module. Nossl_encryption_level_t,SSL_CIPHER,OpaquePointer, orCNIOBoringSSLimport in any public declaration.Sources/NIOSSL/QUIC/; edits to existing files surgical.Sendablecorrectness, availability,NIOSSLError, the platform matrix, and XCTest. ReuseNIOSSLContext/TLSConfigurationfor certificates, keys, trust, verification, ALPN, SNI.Current public API
Handshake bytes are fed in as
ByteBuffer(zero-copy) and handed out as[UInt8](copied out of BoringSSL-owned memory). Secrets are[UInt8].Internal mapping to BoringSSL (behind the wall)
initSSL_newfrom the context'sSSL_CTX;SSL_set_quic_method;SSL_set_connect/accept_state;SSL_set_quic_transport_params;SSL_set_ex_datafor the trampolinesinit(serverHostname, client)SSL_set_tlsext_host_name(SNI);SSL_set1_hostunder.fullVerification, aftervalidateSNIServerName(#11)provideHandshakeDataSSL_provide_quic_dataadvanceSSL_do_handshakewhile in init, elseSSL_process_quic_post_handshake(#9);WANT_READ/WANT_WRITE→.wantsMoreData, success →.completepeerTransportParametersSSL_get_peer_quic_transport_paramsnegotiatedProtocolSSL_get0_alpn_selectedsetReadSecret/setWriteSecretset_read_secret/set_write_secret; cipher viaSSL_CIPHER_get_protocol_id; fails on a null cipher (#7)writeHandshakeDataadd_handshake_dataNIOSSLQUICError.tlsAlertsend_alert(captured, consumed, thrown from the next step)flush_flight— internal no-opThe five C callbacks are non-capturing function pointers in one static
SSL_QUIC_METHOD; each recoversselfviaSSL_get_ex_data+Unmanaged.fromOpaque(the patternSSLConnectionuses).Edits to existing files
SSLContext.createQUICSSLHandle()— aninternalaccessor returning a bareSSL(no BIO). The only change to existing code.Resolved design questions
Full rationale and prior art are in the appendix comments; in brief:
NIOSSLQUICHandshake+provideHandshakeData(quiche names its BoringSSL wrapperHandshakeand its feedprovide_data; avoids collision withSSLConnection).UInt16(as Go, neqo); the only BoringSSL-free option that does not draw packet-protection key types into TLS.[UInt8]; unanimous across stacks. TLS does not parse them.Sources/NIOSSL/QUIC/; a separate glue library (ngtcp2's model) is motivated by multi-backend support, which does not apply.NIOSSLQUICError(no public alert precedent in NIOSSL; matches Go/neqo/s2n-quic).Deferred / known gaps
SSL_CTX, so it already works under.fullVerificationwith trust roots configured — no custom callback needed. Hostname verification and SNI land in Verify the server hostname in the QUIC TLS handshake #11 viaSSL_set1_host(issue ExposeserverHostnameonNIOSSLQUICHandshakefor hostname verification #10). Still deferred: the user-supplied custom verify path (sslContextCallback/NIOSSLCustomVerificationCallback), which expects anSSLConnectionthe raw QUICSSLdoes not have; and IP-address SAN matching (X509_VERIFY_PARAM_set1_ip), since SNI rejects IP literals.QUICTransportParametersRequired).SendSessionTicket(EarlyData:Extra:)/QUICResumeSession/QUICStoreSession, including the opaqueExtraticket payload.CryptoExtras.Testing (XCTest, self-contained)
Drive a client and server
NIOSSLQUICHandshakeagainst each other in-process: feed each side'swriteHandshakeDataoutput to the other'sprovideHandshakeData(one flight at a time, advancing between — BoringSSL rejects data ahead of its read level), then assert both reach.complete, the read/write secrets agree peer-to-peer per level, a cipher suite is agreed, ALPN negotiates, and transport parameters round-trip. No sockets, no packets. Landed in F1.Milestones
NIOSSLQUICHandshake,SSL_QUIC_METHODinstall. ✅ landed.SSL_CTX). Post-handshake processing folded intoadvance()(Process post-handshake messages from advance() #9). Remaining: the user-supplied custom verify callback path; transport parameters after the ClientHello; (then) 0-RTT / session tickets.Appendices (comments)
crypto/tlsprior art and the decisions it informed (alerts, flush, deferred transport parameters, push-vs-pull rationale).