std/tls

TLS 1.2 and 1.3 over a TcpStream - a client that checks the server's certificate the way the platform does, a server with an identity, a stream like the TCP one, and DNS over TLS.

The generated reference of std/tls has every public construct with its signature, its doc comment and its examples.

std/tls puts TLS over a std/network TcpStream: TlsStream.connect for a client, TlsStream.accept with a ServerIdentity for a server, and then a stream that receives and sends like the TCP one. The protocol is mbedTLS 3.6, TLS 1.2 and 1.3, vendored into the runtime and linked only into a program that reaches std/tls; such a program carries mbedTLS's Apache-2.0 license notice, unlike the rest of the runtime (MIT-0). std/http is built on it: https URLs and Server.listen(..., tls: identity). It needs the network capability inside a sandboxed script, and is not in the prelude.

Import#

use TlsStream, TlsSettings, ServerIdentity, ClientIdentity from "std/tls"
use TcpStream, NetworkError from "std/network"
use TlsStream from "std/tls"
use textOf from "std/stream"

/** Asks a server for its front page over TLS and answers the first chunk of the answer. */
fn frontPage(host: String): Task<Result<String, NetworkError>> {
  const tcp = TcpStream.connectTo(host, 443).await()?
  var stream = TlsStream.connect(tcp, host).await()?
  stream.send("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".bytes()).await()?
  const chunk = stream.receive().await()? ?? []
  textOf(chunk).ok() ?? ""
}

Declarations#

TlsStream#

shared type TlsStream with Close {
  static fn connect(stream: TcpStream, serverName: String, settings: TlsSettings = TlsSettings()): Task<Result<Self, NetworkError>>
  static fn accept(stream: TcpStream, identity: ServerIdentity, protocols: List<String> = [], clientRoots: List<String> = []): Task<Result<Self, NetworkError>>
  fn protocol(): String
  fn negotiatedProtocol(): String?
  fn localAddress(): Result<SocketAddress, NetworkError>
  fn remoteAddress(): Result<SocketAddress, NetworkError>
  var fn receive(maximum: Int = 65536): Task<Result<Bytes?, NetworkError>>
  var fn receiveBuffer(maximum: Int = 65536): Task<Result<Buffer<UInt8>?, NetworkError>>
  var fn send(bytes: Bytes): Task<Result<Void, NetworkError>>
  var fn sendBuffer(bytes: Buffer<UInt8>): Task<Result<Void, NetworkError>>
  var fn shutdown(): Task<Result<Void, NetworkError>>
  fn source(chunk: Int = 65536): TlsSource
  fn sink(): TlsSink
  var fn close()
}

One TLS connection. connect runs the handshake as a client and checks that the server's certificate is trusted for serverName; accept runs it as a server. Both finish before a byte of data crosses, so a certificate that is refused is refused before the program sends anything. protocol() is TLSv1.3 or TLSv1.2. negotiatedProtocol() is the application protocol that ALPN chose, Some("h2") or Some("postgresql"), and None where none was chosen. receive answers None once the peer ended the session with close_notify; a peer that just drops the connection is a failure, because an attacker can cut a connection but cannot forge its end. shutdown sends close_notify and ends the TCP half. Every wait is a wait of the TcpStream under it, so a TLS read is cancelled and timed out (within) exactly as a TCP read is. source() and sink() are the two directions as a Source<Bytes, NetworkError> and a Sink<Bytes, NetworkError> (see std/stream).

TlsSettings#

type TlsSettings {
  trusted: List<String> = []
  protocols: List<String> = []
  identity: ClientIdentity? = None
}

What a client trusts. The default is the platform's judgement: on Windows the certificate chain goes to the system's own verifier - its roots, the enterprise's roots, its policies - and elsewhere the system's bundle of roots decides. trusted names PEM certificates to trust instead, and then only those: a company's private root, a test's own root. There is no setting that turns the check off. protocols offers application protocols through ALPN (RFC 7301) in preference order, ["h2", "http/1.1"]; TlsStream.accept takes the list a server accepts, and the first of its own names that the client offers is chosen. A side with no list negotiates nothing and the data flows as before; two lists with no name in common end the handshake with a failure that isTlsFailure(). A name that is empty, longer than 255 bytes or holds a newline is refused by connect and accept, never cut short. identity is the certificate the client presents where the server asks for one (see ClientIdentity). Revocation is not checked, as in Go and rustls: a check goes to the network and waits.

ServerIdentity#

shared type ServerIdentity with Close {
  protected var handle: Int
  static fn of(certificates: String, privateKey: String): Result<Self, NetworkError>
  var fn close()
}

What a server proves who it is with: its PEM certificate chain - its own certificate first, then the intermediates - and the PEM private key of the first certificate. It is read once and shared by every connection; a key that is not the certificate's is refused here, not at the first handshake.

ClientIdentity#

shared type ClientIdentity with Close {
  protected var handle: Int
  static fn of(certificates: String, privateKey: String): Result<Self, NetworkError>
  var fn close()
}

What a client proves who it is with (mutual TLS): its PEM certificate chain - its own certificate first, then the intermediates - and the PEM private key of the first certificate, the same contract as ServerIdentity. A key that is not the certificate's, or text that is no PEM, is refused by of with a failure that isTlsFailure(). Put it in TlsSettings(identity: id); the certificate is sent only if the server asks for one, and a server that does not ignores it. A server asks through clientRoots of TlsStream.accept: a non-empty list makes the client's certificate mandatory and verifies it against exactly these PEM roots - never the platform's, a client certificate is issued by the server's operator. A client that presents none or another, or a list of roots of which one block cannot be read, is refused with isTlsFailure() (or isCertificateRejected()); the unreadable roots fail before the handshake. A client sees a refusal on connect in TLS 1.2 and on its first receive or send in TLS 1.3, an error either way. There is no optional mode.

Transport#

shared type Transport {
  static fn plain(stream: TcpStream): Self
  static fn encrypted(stream: TcpStream, session: TlsStream): Self
  fn isEncrypted(): Bool
  fn tls(): TlsStream?
  var fn upgrade(serverName: String, settings: TlsSettings = TlsSettings()): Task<Result<Void, NetworkError>>
  var fn upgradeAccepting(identity: ServerIdentity, protocols: List<String> = [], clientRoots: List<String> = []): Task<Result<Void, NetworkError>>
  var fn receiveBuffer(maximum: Int = 65536): Task<Result<Buffer<UInt8>?, NetworkError>>
  var fn sendBuffer(bytes: Buffer<UInt8>): Task<Result<Void, NetworkError>>
  var fn finish(): Task<Result<Void, NetworkError>>
  fn remote(): SocketAddress?
}

One connection that is plain TCP or TLS over TCP, behind the same four operations (receiveBuffer, sendBuffer, finish, remote), for a protocol that does not care which. Transport.plain(stream) starts in the clear and Transport.encrypted(stream, session) wraps a stream whose handshake TlsStream.connect or TlsStream.accept ran; isEncrypted() says which it is now, and tls() is the session (for negotiatedProtocol() and protocol()), None while the bytes are plain.

The upgrade is what protocols that ask for TLS after they began need - Postgres's TLS request, MySQL's SSL request, SMTP's STARTTLS: upgrade(serverName, settings) runs the client's handshake over the plain stream, with the semantics of TlsStream.connect, and upgradeAccepting(identity, protocols, clientRoots) the server's, with those of TlsStream.accept. Every byte after it is encrypted. An upgrade of a transport that is encrypted already is refused with a NetworkError that is not a TLS failure, and the transport goes on as it was. A handshake that fails (isCertificateRejected(), isTlsFailure()) leaves the stream holding half a handshake, so the transport is unusable after it: every later operation fails with isClosed().

A transport buffers nothing. A caller that reads through a buffer of its own has to have consumed it before the upgrade: bytes the peer sent in the clear after the request for TLS are an injection (CVE-2011-0411), and a caller that finds some left closes the connection instead of upgrading it. No send, receive or upgrade may be pending while an upgrade runs. std/http reads and writes http and https through this type.

var transport = Transport.plain(TcpStream.connectTo("mail.example.test", 587).await()?)
// the greeting, `EHLO`, then the request for TLS and the server's `220`, all in the clear
transport.sendBuffer("STARTTLS\r\n".bytes()).await()?
const _ = transport.receiveBuffer().await()?
transport.upgrade("mail.example.test").await()?
print transport.isEncrypted()

TlsResolver#

type TlsResolver with NameResolver {
  server: SocketAddress
  serverName: String
  settings: TlsSettings = TlsSettings()
  attemptMilliseconds: Int = 5000
  fn lookup(name: DomainName, recordType: RecordType): Task<Result<List<Record>, NetworkError>>
  fn exchange(name: DomainName, recordType: RecordType): Task<Result<Message, NetworkError>>
}

DNS over TLS (RFC 7858): the lookups of std/network's Resolver, asked of one name server over TLS - on port 853, the port RFC 7858 assigns - which has to prove serverName before a question is sent. The question and the answer go as over TCP, each after its length in two bytes, one connection per lookup; the messages are std/dns's. lookup answers the records of the type, CNAME chains followed, and fails with isHostNotFound() for a name that does not exist and isNameServerFailure() for a server that answered SERVFAIL, REFUSED or NOTIMP.

Failures#

A failure of TLS is a NetworkError: isCertificateRejected() where the peer's certificate is not trusted for the name, isTlsFailure() for every other failure of the protocol, and show() says why in the words of the platform's verifier or of mbedTLS.

Esc