Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.tls

import std.tls runs TLS 1.2 and 1.3 over a std.net TCP stream. A client verifies the server’s certificate chain against chosen trust roots and checks that it names the server; a server presents a certificate chain and key, and may ask clients for theirs. Handshakes, reads, and writes take the same deadline and cancellation token as the TCP stream under them. The module has one error type, TlsError, and is available on the supported x86-64 Linux GNU target.

Upstream code, used as-is. The TLS protocol, certificate verification and all cryptography are rustls with its ring provider, compiled into the runtime. Plenty implements none of them; this is deliberate, as for std.crypto.

A rustls session never touches a socket: std.tls moves ciphertext between the session and the TCP stream in checked Plenty, so every wait is a std.net wait.

import std.bytes
import std.tls
from std.net import NetError, TcpListener, connect, listen, loopback_v4
from std.sync import CancellationToken
from std.time import Deadline, deadline_after, milliseconds, monotonic_now
from std.tls import ServerConfig, TlsError

# Test-only CA and a localhost leaf it signed; never reuse a published key.
const CA_PEM: str = "-----BEGIN CERTIFICATE-----\nMIIBmjCCAT+gAwIBAgIULOQ2Qxr8CVZY2bG0uRYKKEdMD3swCgYIKoZIzj0EAwIw\nGTEXMBUGA1UEAwwOUGxlbnR5IFRlc3QgQ0EwIBcNMjQwMTAxMDAwMDAwWhgPMjEy\nNDAxMDEwMDAwMDBaMBkxFzAVBgNVBAMMDlBsZW50eSBUZXN0IENBMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEkiB3Ogehrrt6YbQghwDPv6tkhE+z9zTCVih8fvtR\nZ1/691Nfh7/J9IgaNgNSTMJjGTolzGnjaCUDqoARlFyM+6NjMGEwHQYDVR0OBBYE\nFOTFBDgWXHj/qmVJDMlAHYCEZFX5MB8GA1UdIwQYMBaAFOTFBDgWXHj/qmVJDMlA\nHYCEZFX5MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgIEMAoGCCqGSM49\nBAMCA0kAMEYCIQD41ATQnIjKkKMLQAb9AyoyOFmljYZyz348XJEIbqMgKwIhAL3m\nmWd8On5gvkfytspatH5mDWOcSyW+au7vuIC2gHhO\n-----END CERTIFICATE-----\n"
const LEAF_PEM: str = "-----BEGIN CERTIFICATE-----\nMIIBnDCCAUKgAwIBAgIBATAKBggqhkjOPQQDAjAZMRcwFQYDVQQDDA5QbGVudHkg\nVGVzdCBDQTAgFw0yNDAxMDEwMDAwMDBaGA8yMTI0MDEwMTAwMDAwMFowFDESMBAG\nA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8THNQeTi\nJ6Eq0tSxtHW5Q4TCsbIqB3MOtM98ERyKgl6kHeHCeDZYKx8B+VRGT2jkWuTeN24Q\nljwUfh7uh7Iaz6N+MHwwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMAkGA1Ud\nEwQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFEIFC2Fyd4cfAKTx\nMiNCMdDkqDNbMB8GA1UdIwQYMBaAFOTFBDgWXHj/qmVJDMlAHYCEZFX5MAoGCCqG\nSM49BAMCA0gAMEUCIAUF3HH43lnirgSN1Vr4twZrFmXKBxlbYNQni/h4ZaxhAiEA\n5rD/yE1B4IsbEGCMlyAeX507leXfGY9+wrHaxikU/w0=\n-----END CERTIFICATE-----\n"
const LEAF_KEY: str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQglvSEE3H7v+mInxSF\nAyWFDKYpzpXr20wH8mfFY3bCaB2hRANCAATxMc1B5OInoSrS1LG0dblDhMKxsioH\ncw60z3wRHIqCXqQd4cJ4NlgrHwH5VEZPaORa5N43bhCWPBR+Hu6HshrP\n-----END PRIVATE KEY-----\n"

def serve(listener: &TcpListener, config: &ServerConfig, deadline: &Deadline, cancel: &CancellationToken) -> Result[(), AllocError | TlsError | NetError | std.bytes.BytesError]:
    mut stream = std.tls.accept(listener.accept(deadline, cancel)?, config, deadline, cancel)?
    reply = std.bytes.from_text("hello over TLS")?
    stream.write_all_before(&reply, deadline, cancel)?
    stream.close_before(deadline, cancel)

def main() -> Result[(), SpawnError[()] | IoError | AllocError | RangeError | TlsError | NetError | std.bytes.BytesError]:
    cancel = CancellationToken()?
    deadline = deadline_after(&monotonic_now(), &milliseconds(5000))?
    client = std.tls.client_config(&std.tls.roots_from_pem(&CA_PEM)?)?
    server = std.tls.server_config(&LEAF_PEM, &LEAF_KEY)?
    listener = listen(&loopback_v4(0u16), 8)?
    address = listener.local_address()?
    with spawn(serve, &listener, &server, &deadline, &cancel)? as worker:
        tcp = connect(&address, &deadline, &cancel)?
        mut stream = std.tls.connect(tcp, &client, "localhost", &deadline, &cancel)?
        mut buffer = std.bytes.with_capacity(64)?
        buffer.resize(64, 0u8)?
        count = stream.read_before(&mut buffer, 0, 64, &deadline, &cancel)?
        print(buffer.to_text_range(0, count)?)?
        worker.join()?
    Ok(())
hello over TLS

Ciphertext cursors are bounded by their fixed buffers, and each native TLS or TCP transfer advances by at most the remaining slice. Their small arithmetic opt-outs do not change TlsError, partial progress after a failed wait, or closing the TCP stream after a failed close-notify.

Trust roots and configuration

FunctionResultContract
roots_from_pem(pem: &str)Result[TrustRoots, TlsError]The CERTIFICATE blocks of pem as trust anchors
system_roots()Result[TrustRoots, TlsError]The operating system’s certificate bundle
client_config(roots: &TrustRoots)Result[ClientConfig, TlsError]Client settings verifying servers against roots
server_config(chain_pem: &str, key_pem: &str)Result[ServerConfig, TlsError]Server settings presenting chain_pem, leaf first, with its private key

roots_from_pem skips blocks of other types. PEM that does not decode is InvalidCertificate. A decoded certificate that cannot serve as a trust anchor is skipped, since system bundles may hold some; when none is usable the result is NoTrustRoots. TrustRoots.len() counts the anchors kept.

system_roots reads the first readable file of:

  1. /etc/ssl/certs/ca-certificates.crt (Debian, Ubuntu, Arch, Alpine)
  2. /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem (Fedora, RHEL)
  3. /etc/ssl/ca-bundle.pem (openSUSE)
  4. /etc/ssl/cert.pem

None readable is NoTrustRoots. It does not consult environment variables; read another bundle with read_text and pass it to roots_from_pem. The bundle is read at the call, so a long-running program sees updates only when it builds new roots.

server_config accepts a PKCS#8, PKCS#1, or SEC1 private key. A chain or key that does not parse, an empty chain, or a key that does not match the leaf is InvalidConfiguration. Servers do not ask clients for certificates.

Both configurations use TLS 1.2 and 1.3 with rustls’s default cipher suites and key-exchange groups. Neither resumes sessions: clients keep no session cache and servers issue no tickets. A configuration is immutable and may be borrowed by any number of connections and workers.

A TLS 1.3 server therefore sends nothing after the client’s Finished. So that a client with Nagle’s algorithm on does not hold its first request until the kernel’s delayed acknowledgement (about 40 ms on Linux), a server stream acknowledges the client’s last handshake flight at once (Linux TCP_QUICKACK), unless a reply or the client’s first request is already there to carry the acknowledgement.

Builders

A builder gathers further settings; build checks them together and returns the configuration. A builder with no further calls builds the same configuration as client_config or server_config.

FunctionResult
client_builder(roots: &TrustRoots)Result[ClientBuilder, TlsError]
server_builder(chain_pem: &str, key_pem: &str)Result[ServerBuilder, TlsError]
MethodOnContract
certificate(chain_pem: &str, key_pem: &str) -> Result[(), TlsError]bothClient: the chain presented when a server asks. Server: another chain to choose by name
verify_clients(roots: &TrustRoots, certificates: ClientCertificates) -> ()serverAsk clients for a certificate and verify it against roots
application_protocol(name: &str) -> Result[(), TlsError]bothOffer or accept name in ALPN, after the names already given
minimum_version(version: TlsVersion) -> ()bothTls13 refuses TLS 1.2 peers
cipher_suite(suite: CipherSuite) -> ()bothAllow suite; once any is allowed, only allowed suites are used
revocation_list(crl_pem: &str) -> Result[(), TlsError]bothReject peer certificates that a CRL in crl_pem names
build() -> Result[ClientConfig or ServerConfig, TlsError]bothThe configuration
pub enum ClientCertificates:
    Required
    Optional

pub enum TlsVersion:
    Tls12
    Tls13

pub enum CipherSuite:
    Tls13Aes256GcmSha384
    Tls13Aes128GcmSha256
    Tls13Chacha20Poly1305Sha256
    EcdheEcdsaAes256GcmSha384
    EcdheEcdsaAes128GcmSha256
    EcdheEcdsaChacha20Poly1305Sha256
    EcdheRsaAes256GcmSha384
    EcdheRsaAes128GcmSha256
    EcdheRsaChacha20Poly1305Sha256

Client certificates. With verify_clients, a server asks for a client certificate and verifies its chain against roots, which are usually a private authority rather than the system bundle. With Required, a client that presents none fails with CertificateRequired on the server. With Optional, it is accepted, and peer_certificate() is Nothing. A client has at most one chain: a second certificate call is InvalidConfiguration. In TLS 1.3 the client finishes its handshake before the server has checked its certificate, so a rejected client sees Rejected at its first read.

Certificates by name. A server with several chains presents the first whose leaf names the host the client asked for through server name indication (SNI), or the first chain when none does or the client sent no name (clients connecting by IP address send none).

Application protocols. A server picks the first of its names, in the order given, that the client offers. A client that offers names, none of which the server accepts, fails with Incompatible on the server and Rejected on the client. When either side gives no names, none is chosen. A name is 1 to 255 bytes; others are InvalidConfiguration.

Versions and suites. Suites are the ring provider’s, listed in its default order of preference. The client’s preference decides among suites both sides allow. Allowed suites that cover no allowed version are InvalidConfiguration at build. Peers with no version or suite in common fail with Incompatible, or Rejected on the side that receives the alert.

Revocation lists. revocation_list adds each X509 CRL block of crl_pem; PEM without one is InvalidConfiguration. A certificate that a list from its issuer names is RevokedCertificate. Certificates whose issuer has no list are accepted, and a list’s next-update time is not enforced, so a stale list still applies. A server revocation_list without verify_clients is InvalidConfiguration at build. Lists are read once; load fresh ones by building a new configuration.

Connecting

FunctionResultContract
connect(tcp: TcpStream, config: &ClientConfig, server_name: &str, deadline: &Deadline, cancel: &CancellationToken)Result[TlsStream, TlsError]Complete a client handshake over tcp
accept(tcp: TcpStream, config: &ServerConfig, deadline: &Deadline, cancel: &CancellationToken)Result[TlsStream, TlsError]Complete a server handshake over tcp

Both take ownership of the TCP stream and return only after the handshake completes. server_name is a DNS name or an IP address; the server’s leaf certificate must name it in its subject alternative names. A name that is neither is InvalidName. Certificates are checked against the wall clock at the handshake.

When the handshake fails for a TLS reason, the alert rustls prepared is sent if the deadline allows, so the peer sees Rejected. On any failure the TCP stream is closed when the partial session is dropped.

start_connect(tcp, config, server_name) and start_accept(tcp, config) return a stream without waiting. Its handshake then runs inside its first reads and writes, in handshake_before, or in try_handshake; no application data passes before the peer is verified.

Streams

MethodResultContract
handshake_before(deadline: &Deadline, cancel: &CancellationToken)Result[(), TlsError]Finish the handshake. One that times out keeps its progress for a later call, so with a deadline already passed it does only the work possible without waiting
read_before(buffer: &mut Bytes, start: i64, stop: i64, deadline: &Deadline, cancel: &CancellationToken)Result[i64, TlsError]Decrypted bytes into buffer[start:stop], waiting for at least one
write_before(source: &[u8], deadline: &Deadline, cancel: &CancellationToken)Result[i64, TlsError]Encrypt and send at most 16 KiB of source; the count taken
write_all_before(source: &[u8], deadline: &Deadline, cancel: &CancellationToken)Result[(), TlsError]Send all of source
close_before(deadline: &Deadline, cancel: &CancellationToken)Result[(), TlsError]Send close_notify, then close the TCP stream
shutdown(direction: Shutdown)Result[(), TlsError]End the TCP stream in one direction without closing it; ending writes sends close_notify first without waiting, and ciphertext the socket does not take at once is lost. Reading continues
read(buffer: &mut Bytes, start: i64, stop: i64)Result[i64, StreamError]std.io Reader; waits without a deadline
write(source: &[u8])Result[i64, StreamError]std.io Writer; waits without a deadline
flush()Result[(), StreamError]Nothing to do: writes are sent as they are made
close()Result[(), StreamError]close_before without a deadline
application_protocol()Result[Option[str], TlsError]The protocol chosen through ALPN
server_name()Result[Option[str], TlsError]On a server, the name the client sent through SNI; Nothing on a client
peer_certificate()Result[Option[Bytes], TlsError]The DER encoding of the peer’s verified leaf certificate
version()Option[TlsVersion]The negotiated version
cipher_suite()Option[CipherSuite]The negotiated suite

Each source is a &[u8] view: a Bytes buffer, part of one, or a b"..." literal, written without a copy.

The session details are Nothing until the handshake has settled them. peer_certificate() is Nothing on a server that did not verify clients or whose client presented none.

Ranges follow the range rule. A read of zero bytes means the peer sent close_notify. A connection that ends without one is Truncated (StreamError.UnexpectedEof through read), because an attacker can cut a TCP connection short. Code that frames its own messages, such as Content-Length bodies, may treat a truncation after the last expected byte as an ordinary end.

close_before does not wait for the peer’s close_notify. It closes the TCP stream even when sending close_notify fails, and then returns that error. After a close, writes are Closed. A failed write has sent an unknown prefix of its range.

The std.io methods block as TcpStream’s do, so TlsStream works with std.io helpers and std.http framing. Through them, TLS failures appear as StreamError.Io(IoError.Other); use the _before methods to see the TlsError.

Without waiting

These methods never wait, so one thread can drive many streams from a std.ready selector:

MethodResultContract
try_handshake()Result[bool, TlsError]Advance the handshake; true once complete
try_read(buffer: &mut Bytes, start: i64, stop: i64)Result[Option[i64], TlsError]Decrypted bytes if any can be had now; Some(0) is close_notify
try_write(source: &[u8])Result[Option[i64], TlsError]Encrypt at most 16 KiB; Nothing, taking nothing, while the handshake is incomplete or earlier ciphertext is unsent
try_flush()Result[bool, TlsError]Send buffered ciphertext; true once none is left
interest()InterestWhat to wait for: ReadWrite while ciphertext waits to be sent, else Read
watch(selector: &mut Selector, key: u64, interest: Interest)Result[(), TlsError]Register the TCP stream
unwatch(selector: &mut Selector)Result[(), TlsError]Remove it

A try_ result of Nothing or false means the stream needs the socket. These methods use TCP’s immediate operations, without polling with an expired deadline. WouldBlock or interruption leaves the TLS progress buffered and returns Nothing or false; other network errors, including ConnectionTimedOut, propagate. Do all the work available without waiting, then register interest() again and wait: rustls may hold decrypted bytes the socket no longer signals, and a write may leave ciphertext that only a writable socket can take. Ciphertext a try_ call could not send stays buffered for the next call. Registrations are level-triggered, so ReadWrite on an idle stream wakes at once; register Read once interest() says so.

A read_before or handshake_before that times out at its deadline has also done all the work available, so a stream may be watched after one. std.http.server parks HTTPS connections this way, between handshake messages and while idle.

Sessions

A Session is a TLS session without a socket: the caller moves ciphertext through its own buffers. It never waits and owns no descriptor, so it suits event loops that already own the socket. std.actor.tls runs one over an actor connection. client_session(&config, server_name) and server_session(&config) make one with the same verification as connect and accept.

MethodResultContract
feed(source: &[u8])Result[i64, TlsError]Process received ciphertext; the count used, short while decrypted bytes wait for read
feed_end()Result[(), TlsError]The peer’s stream ended; without close_notify, read then fails with Truncated
take(target: &mut Bytes, start: i64, stop: i64)Result[i64, TlsError]Ciphertext to send; 0 when none waits
read(target: &mut Bytes, start: i64, stop: i64)Result[Option[i64], TlsError]Decrypted bytes; Nothing while none is ready, Some(0) for close_notify
write(source: &[u8])Result[i64, TlsError]Encrypt at most 16 KiB; short once 64 KiB of ciphertext waits for take; Closed after close
close()()Queue close_notify for take
handshaking(), wants_write(), wants_read()boolHandshake incomplete; ciphertext waits for take; more ciphertext is useful

application_protocol, server_name, peer_certificate, version and cipher_suite are as for TlsStream. While the handshake runs, write holds plaintext in the session; check handshaking() first to bound it. On a TLS failure the session has queued an alert: send what take returns before closing the socket. A session is allocated where it is made; in an actor handler that is the actor’s heap.

Read ranges follow the Reader contract: invalid buffer bounds trap with read: start/stop outside buffer before I/O or handshake work. The nonblocking session take operation also validates its caller buffer first and traps with take: start/stop outside buffer. Empty ranges must still be within the buffer.

Errors

pub enum TlsError:
    Net(NetError)
    UntrustedCertificate
    ExpiredCertificate
    WrongHost
    InvalidCertificate
    Rejected
    Protocol
    Truncated
    InvalidName
    InvalidConfiguration
    NoTrustRoots
    Closed
    Allocation(AllocError)
    RevokedCertificate
    CertificateRequired
    Incompatible
VariantMeaning
Net(cause)The TCP operation ended, including deadline TimedOut, kernel ConnectionTimedOut, and Cancelled
UntrustedCertificateThe peer’s chain does not lead to a trust root, or a signature is wrong
ExpiredCertificateA certificate in the chain is expired or not yet valid
WrongHostThe leaf certificate does not name server_name
InvalidCertificateA certificate is malformed or unusable for TLS, or PEM does not decode
RejectedThe peer sent a fatal alert, usually because it rejected our certificate
ProtocolThe peer broke the TLS protocol
TruncatedThe connection ended without close_notify
InvalidNameserver_name is neither a DNS name nor an IP address
InvalidConfigurationA chain, key, name, list, or combination of builder settings is unusable
NoTrustRootsNo usable trust anchor was found
ClosedThe stream was closed by this side
Allocation(cause)A buffer or runtime object could not be allocated
RevokedCertificateA revocation list names the peer’s certificate
CertificateRequiredA server requiring client certificates received none
IncompatibleThe peers share no version, cipher suite, or application protocol

Deadlines and cancellation

Every _before method passes its deadline and token to each std.net call it makes, so a handshake against a silent peer ends with Net(NetError.TimedOut) and a cancelled token with Net(NetError.Cancelled). The kernel’s connection timeout is Net(NetError.ConnectionTimedOut) and is not resumable. The try_ methods also return this error; they do not mistake it for a socket that would block. Use one deadline for a whole exchange, as with std.net. Ciphertext that a timed-out write did not send stays buffered and goes out with the next call, but which plaintext the peer has received is unknown; after a timeout in the middle of an exchange, close or drop the stream unless the protocol can recover.

Ownership and threads

TrustRoots, ClientConfig, ServerConfig, TlsStream, and Session each own one runtime object; dropping it releases the object, and dropping a stream closes its socket without close_notify. The configurations, builders, streams and sessions are redacted: printing one shows its type name and (@redacted), never key or session state. A stream may move to a worker. Trust roots, builders and configurations belong to the heap that allocates them. Outside actors, sessions share the parsed configuration. An actor session reconstructs a heap-local configuration from owned source material so a trap cannot retain another heap’s configuration. Starting a session draws randomness and verifying a certificate reads the clock; both are allowed in workers.

The __tls_* intrinsics behind the session are reserved for the standard library. Writing uses __tls_write, which reads the caller’s &[u8] view in place.

Costs

ItemMemory
TlsStreamTwo 16 KiB ciphertext buffers, plus rustls’s session state and its buffer for the incoming record (up to about 17 KiB)
Sessionrustls’s session state and its record buffers; see std.actor.tls for measurements
ConfigurationOwned certificate/key/trust/policy recipe plus parsed configuration; shared by sessions outside actors
TrustRootsThe parsed anchors; a full system bundle holds about 120

rustls allocates through the runtime’s Rust allocator, not Plenty’s fallible allocation, so an allocation failure inside rustls stops the program. rustls has no fallible allocation interface, and stable Rust aborts when the global allocator fails, so this is not recoverable. Inside an actor, exhaustion instead traps that actor and discards its heap, including partial TLS construction. Allocation failures in std.tls itself are Allocation. Actor sessions also pay for reconstructing a private configuration, including key parsing; see actor TLS memory.

Plaintext is copied once into rustls and ciphertext once into the outgoing buffer; incoming ciphertext is decrypted into rustls’s buffer and copied to the caller.

Supply

The runtime builds these crates from unmodified copies under plenty-runtime/vendor (ring’s pregenerated assembly is trimmed to the x86-64 ELF files), so building the compiler needs no network:

CrateVersionLicence
rustls0.23.46Apache-2.0, ISC, or MIT
rustls-webpki0.103.15ISC
rustls-pki-types1.15.1MIT or Apache-2.0
ring0.17.14Apache-2.0 and ISC, with OpenSSL and BoringSSL notices
untrusted, zeroize, once_cell0.9.0, 1.9.1, 1.21.4ISC; MIT or Apache-2.0; MIT or Apache-2.0
getrandom, libc0.2.17, 0.2.186MIT or Apache-2.0

ring’s C and assembly sources are compiled by build.rs with the system C compiler into a static archive, as SQLite’s are. The versions are pinned in Cargo.lock, and a test fails if the two disagree. To update, replace the directories with new releases, run cargo update -p CRATE --precise VERSION, and rerun tests/test_std_tls.rs. Follow rustls security advisories: an update is a change to the compiler, and programs pick it up when rebuilt.

Programs whose main never reaches std.tls code contain none of rustls or ring, even when they import a module that imports std.tls; the linker drops the unreachable functions and the TLS runtime only they call.

Example

The tutorial lesson serves an echo over TLS on a worker and shows a client rejecting the wrong host name. Mutual TLS adds client certificates and application protocols.

Limits

  • No certificate pinning; verify peer_certificate() bytes yourself.
  • No session resumption or 0-RTT data: a shared session cache is memory without a bound or an owner, and 0-RTT data can be replayed.
  • No OCSP: rustls does not check stapled responses, and fetching them would reach the network behind the program’s back. Revocation is by CRL only.
  • No choice of key-exchange groups, and no TLS 1.0 or 1.1.
  • Outside actors, allocation failure inside rustls stops the program; actor TLS contains it by discarding the actor heap.
  • HTTPS in std.http is not built in; wrap a TlsStream with its framing.
  • One target, x86-64 Linux GNU.

Acknowledgements

  • Wraps rustls 0.23.46 with the ring 0.17.14 crypto provider and rustls-webpki 0.103.15, vendored in the runtime; see Supply for licences.

  • The CA bundle paths are drawn from the Linux list in Go’s crypto/x509.

  • Uses zeroize 1.9.1 (MIT or Apache-2.0) to clear retained encoded private keys on normal destruction.