swift-tls-nio is a protocol-neutral SwiftNIO channel adapter for the TLS 1.3
stream sessions provided by swift-tls.
It turns a NIO byte-stream channel into a plaintext channel while preserving the
TLS session, ownership, and failure contracts of swift-tls.
Important
swift-tls-nio 0.1.0 is the current tagged release. Use a versioned
dependency for applications. The API may change in future releases, and
production deployment requires an independent security review of the complete
application stack.
flowchart LR
TCP["NIO byte-stream channel"] --> Adapter["TLSNIOHandler\nrecord framing + lifecycle"]
Adapter --> Session["swift-tls\nTLS 1.3 session contract"]
Session --> SSL["swift-ssl\ncryptography + PKI + TLS mechanisms"]
Adapter --> Plaintext["Plaintext NIO pipeline"]
Plaintext --> Protocol["HTTP / WebSocket / gRPC / custom protocol"]
TLSNIO owns NIO channel lifecycle, record framing, bounded transport buffers,
timeouts, plaintext delivery, and orderly transport shutdown. It does not own a
second TLS state machine. HTTP, WebSocket, socket bootstrap policy, certificate
storage, and application policy remain with the consuming package.
- Client and server TLS 1.3 channel handling backed by
swift-tls - TLS record framing across fragmented and arbitrarily coalesced
ByteBufferreads - Bounded pre-handshake outbound buffering and bounded suspended inbound buffering
- Typed handshake, framing, capability, buffer, and shutdown failures
- Caller-owned trust, credential selection, and signing through resumable capability events
- TLS 1.3 NewSessionTicket issuance and one-shot session resumption state
- Graceful
close_notifyexchange with a shutdown deadline - Direct destination-buffer application-data path into final NIO owners
- Real TCP integration tests in addition to
NIOEmbeddedbehavioral tests
- Swift development snapshot
swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-08-14-a - SwiftNIO 2.101.0 or newer
- macOS 26+, iOS 26+, tvOS 26+, watchOS 26+, or visionOS 26+
The repository pins the matching Swift selector in .swift-version. Do not mix
the package with a different Swift 6.4 snapshot.
Add the current 0.1.0 release:
dependencies: [
.package(
url: "https://github.com/1amageek/swift-tls-nio.git",
from: "0.1.0"
),
]Add the TLSNIO product to the target that owns the NIO pipeline:
.target(
name: "YourTarget",
dependencies: [
.product(name: "TLSNIO", package: "swift-tls-nio"),
]
)TLSNIO reuses the TLS product internally. Import TLS in application code
when constructing identities, trust roots, or TLSConfiguration values.
Create one handler per channel and install it before every handler that expects
plaintext. syncOperations must run on the channel's event loop; a bootstrap's
channel initializer is the usual installation point.
import NIOCore
import TLS
import TLSNIO
var tlsConfiguration = TLSConfiguration.client(
serverName: "example.com",
alpn: ["http/1.1"]
)
tlsConfiguration.trustRoots = TLSTrustRoots(
x509Roots: trustedRootCertificates
)
let tlsHandler = try TLSNIOHandler(
clientConfiguration: tlsConfiguration
)
try channel.pipeline.syncOperations.addHandler(tlsHandler)trustedRootCertificates is an application-owned [Certificate] containing
the DER trust anchors for the peer. A custom certificateValidator may be used
instead. verifyPeer defaults to true; construction fails explicitly if no
trust roots or validator are configured.
import NIOCore
import TLS
import TLSNIO
let tlsConfiguration = TLSConfiguration.server(
identity: serverIdentity,
alpn: ["http/1.1"]
)
let tlsHandler = try TLSNIOHandler(
serverConfiguration: tlsConfiguration
)
try channel.pipeline.syncOperations.addHandlers(
tlsHandler,
applicationProtocolHandler
)serverIdentity is an application-owned TLSIdentity. Later inbound handlers
receive plaintext ByteBuffer values. Outbound ByteBuffer writes from those
handlers are encrypted into TLS records.
WSS is ordinary WebSocket framing carried over a TLS-protected TCP channel.
swift-tls-nio supplies the TLS layer; the HTTP upgrade and WebSocket handlers
remain standard SwiftNIO application handlers placed after it.
flowchart LR
TCP["TCP"] --> TLS["TLSNIOHandler"]
TLS --> HTTP["NIOHTTP1 codec + WebSocket upgrader"]
HTTP --> WS["WebSocket frame handlers"]
For HTTP/1.1 WebSocket upgrades, configure http/1.1 in the TLS ALPN list.
The same ordering applies to HTTP, gRPC, or a custom byte protocol: TLS first,
then the handler stack that consumes and produces plaintext.
stateDiagram-v2
[*] --> Handshaking: channelActive
Handshaking --> Active: authenticated handshake
Handshaking --> Failed: timeout / TLS failure
Active --> Closing: close(.all)
Active --> OutputClosed: close(.output)
OutputClosed --> Closing: close(.all)
Closing --> Closed: peer close_notify
Closing --> Failed: shutdown timeout / EOF
Failed --> Closed: transport close
channelActive remains visible to downstream handlers immediately. Plaintext
writes made before authentication are retained in order under the configured
byte budget. TLSNIOUserEvent.handshakeCompleted is emitted before those writes
are drained.
The handler emits these typed inbound user events:
| Event | Meaning |
|---|---|
handshakeCompleted(negotiatedProtocol:) |
Authentication and key establishment succeeded |
capabilityRequested(_:) |
The TLS transition is suspended for caller-owned trust, credential, or signing work |
sessionTicketReceived |
A client accepted a NewSessionTicket; call takeResumptionState() once |
sessionTicketCreated(requestID:resumptionState:) |
A server created a ticket and its paired state |
shutdownCompleted |
Both peers exchanged authenticated close_notify alerts |
Resume a capability request through NIO's outbound user-event path with
TLSNIOOutboundEvent.resumeCapability(response). The response token must match
the exact pending request. Servers issue a ticket with
TLSNIOOutboundEvent.issueSessionTicket(request).
Unknown user events are forwarded unchanged through the pipeline.
Resumption state is intentionally one-shot:
- The server sends
issueSessionTicketwith an opaque ticket value and a correlationrequestID. WhensessionTicketCreatedarrives, it stores the returned state under those ticket bytes. - The client receives
sessionTicketReceivedand takes its state exactly once withtakeResumptionState(). - Each side supplies its corresponding state to the next handler initializer.
let resumedClientHandler = try TLSNIOHandler(
clientConfiguration: clientConfiguration,
resumptionState: clientResumptionState
)
let resumedServerHandler = try TLSNIOHandler(
serverConfiguration: serverConfiguration,
resumptionState: serverResumptionState
)The application owns ticket storage, expiry policy, lookup, and replay policy. A state can be consumed by only one subsequent session.
TLSNIOHandlerConfiguration.defaults applies these transport bounds:
| Setting | Default |
|---|---|
handshakeTimeout |
10 seconds |
shutdownTimeout |
5 seconds |
maximumBufferedOutboundBytes |
1 MiB while TLS is suspended |
maximumBufferedInboundBytesWhileSuspended |
1 MiB |
Custom values must be positive and are validated by the throwing configuration
initializer. TLSCiphertext payload length is capped at RFC 8446's 2^14 + 256
bytes. Bound violations, mismatched capability tokens, timeouts, interrupted
handshakes, and unclean shutdowns are surfaced as TLSNIOError; they are never
silently converted into success.
The outbound buffer bound covers plaintext retained before handshake completion or during a capability request. Once active, the handler recordizes writes immediately and relies on SwiftNIO's downstream writability and backpressure; the suspension budget is not a per-message size limit.
close(mode: .all) sends close_notify and waits for the authenticated peer
response until the shutdown deadline. EOF without a peer close_notify is
reported as TLSNIOError.uncleanShutdown. Input-only close is unsupported.
Complete TLS records are borrowed as zero-copy ByteBuffer slices. A fragmented
record uses one bounded assembly owner because it must outlive the source read
callback. Coalesced records are streamed one at a time, so the framer does not
retain an unbounded batch.
For application traffic, the handler allocates the final destination
ByteBuffer first and lends its writable tail through swift-tls to
swift-ssl. Outbound plaintext is borrowed while authenticated encryption
writes directly into the final TLS record owner. Inbound ciphertext is borrowed
while authenticated plaintext is written directly into the final downstream
owner. No compatibility [UInt8] payload materialization is used on this path.
The tracked-allocator test for one application send and receive asserts exactly two final NIO allocations, zero reallocations, and zero allocator copy operations. This is the measured NIO application-data path; bounded control messages, session tickets, and fragmented-record assembly retain owners because their lifetimes cross state-machine or read-callback boundaries.
CI installs the pinned Swift snapshot, rejects local package dependencies, and runs the complete test suite with Xcode. See CONTRIBUTING.md for the local verification command.
Security reports should follow SECURITY.md.
Licensed under the Apache License, Version 2.0. See LICENSE.