tls
import tls from "tls";tls is a TLS 1.3 client (RFC 8446) that never touches a socket, a clock
or a random source. It is an engine: you feed it the bytes your transport
received, send the bytes it gives you, and read and write plaintext
through it. The time and 64 random bytes come in with its configuration.
So the same engine runs over OS sockets, over a kernel’s own network
stack, over a pipe, or in memory in a test.
var c: tls.Client; // one connection, about 53 KiB
c.start(&cfg) or { return; }; // the ClientHello is now in c.output()
while (!c.is_connected()) {
send(c.output()); // your transport
c.advance_output(c.output().len);
let n: i64 = receive(buf[0..4096]); // your transport
c.feed(buf[0..n]) or { return; }; // the alert to send is in c.output()
}It checks the server’s certificate chain with x509 against
the roots you trust (usually ca_roots) and the server
name you asked for, verifies the server’s signature and Finished, and
protects every record with an AEAD from crypto. There is no
way to skip the server’s authentication: a pinned certificate (below)
replaces the chain, nothing replaces both.
What it supports: TLS 1.3 only (a TLS 1.2 server is refused); the suites
TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384 and
TLS_CHACHA20_POLY1305_SHA256; X25519, and P-256 when the server asks for
it with a HelloRetryRequest; server signatures by ECDSA P-256/SHA-256,
ECDSA P-384/SHA-384 and RSA-PSS with SHA-256/384/512; ALPN; KeyUpdate both
ways. Not: resumption, 0-RTT, client certificates (a server that asks
gets an empty Certificate), being a server.
The module is pure and works in every profile, t0 included: it allocates
nothing; all its buffers are inside the Client. In a hosted program the
net module drives it over sockets for you; use tls directly for other
transports.
Configuration
pub struct Config {
server_name: []const u8; // host name or IP address literal
trust: ?*const x509.TrustStore; // the roots to trust; null only with a pin
now_unix: i64; // seconds since 1970 UTC
entropy: []const u8; // ENTROPY_LEN (64) random bytes
alpn: []const []const u8 = []; // protocols to offer, preferred first
pin_leaf_sha256: []const u8 = []; // the SHA-256 of the one leaf to accept
}
pub const ENTROPY_LEN: i64 = 64;
pub const MAX_ALPN: i64 = 256;server_nameis sent to the server as SNI (unless it is an IP address) and checked against the certificate’s subjectAltName.trustis read when the server’s certificate arrives; keep it alive and unchanged while the client runs.now_unixdecides whether the certificates are in their validity period. A wrong clock makes good certificates fail, never bad ones pass.entropymust be 64 bytes from a real random source (os.random_bytes); the engine draws its keys and its ClientHello random from them. Never reuse them.alpnis the Application-Layer Protocol Negotiation list, such as"h2"and"http/1.1"; the encoded list is at mostMAX_ALPNbytes.
import os from "os";
import time from "time";
import x509 from "x509";
import ca_roots from "ca_roots";
var anchors: [ca_roots.COUNT]x509.Cert;
var store: x509.TrustStore = x509.trust_store(anchors[0..ca_roots.COUNT]);
ca_roots.load(&store) or { return 1; };
var entropy: [64]u8;
os.random_bytes(entropy[0..64]) or { return 1; };
var protocols: [1][]const u8;
protocols[0] = "http/1.1";
let cfg: tls.Config = tls.Config {
server_name: "example.com";
trust: &store;
now_unix: time.unix_nanos() / 1000000000;
entropy: entropy[0..64];
alpn: protocols[0..1];
};Starting
pub fn Client.start(*self, cfg: *const Config) -> void or TlsErrorResets the client, checks the configuration and queues the ClientHello in
output(). A Client can be started again for the next connection.
BadServerName when server_name is neither a host name (letters,
digits, - and ., labels of 1 to 63 bytes, at most 253 bytes; a
trailing dot is dropped) nor an IP address. These are mistakes in the
program and stop it (a trap): entropy not 64 bytes, an ALPN name empty
or over 255 bytes, an ALPN list over MAX_ALPN, a pin that is not 32
bytes, and no trust with no pin.
c.start(&cfg) or {
io.println("cannot start: {err}"); // "server name is not a valid host name or IP address"
return 1;
};Moving bytes
pub fn Client.output(*const self) -> []const u8
pub fn Client.advance_output(*self, n: i64)
pub fn Client.input_space(*const self) -> i64
pub fn Client.feed(*self, data: []const u8) -> i64 or TlsError
pub fn Client.end_of_input(*self) -> void or TlsErroroutput() is what to send next: a view into the client, valid until the
next call that changes it. Send some or all of it and say how much with
advance_output(n).
feed takes what the transport delivered, as much as fits
(input_space() bytes), processes every whole record and returns how many
bytes it took. Records may arrive split anywhere, even a byte at a time.
When the server’s application data is waiting to be read, feed stops
taking records until you read it, and may take 0 bytes: read, then feed
the rest.
When the transport reaches its end, call end_of_input. After the
server’s close_notify that is fine; before it, it is Truncated: an
attacker who cuts the connection must not make a truncated answer look
complete.
Every error is fatal and comes with an alert for the server in
output(): send it if you can, then close the transport. From then on
feed returns the same error again.
fn flush(c: *tls.Client, t: *Transport) -> bool {
while (c.output().len > 0) {
let n: i64 = t.send(c.output()) or { return false; };
c.advance_output(n);
}
return true;
}
fn handshake(c: *tls.Client, t: *Transport) -> void or tls.TlsError {
var buf: [16384]u8;
while (!c.is_connected()) {
flush(c, t);
let n: i64 = t.receive(buf[0..16384]);
if (n == 0) { c.end_of_input() or return; }
var off: i64 = 0;
while (off < n) {
let took: i64 = c.feed(buf[off..n]) or {
flush(c, t); // the alert, best effort
return err;
};
off = off + took;
}
}
flush(c, t); // the client Finished
}Reading and writing
pub const MAX_PLAINTEXT: i64 = 16384;
pub const MAX_RECORD: i64 = 16645;
pub fn Client.read(*self, dst: []u8) -> i64
pub fn Client.pending(*const self) -> i64
pub fn Client.write(*self, src: []const u8) -> i64 or TlsError
pub fn Client.update_keys(*self, request_peer: bool) -> bool
pub fn Client.close(*self)read copies decrypted data into dst and returns the count, 0 when
nothing is waiting (pending says how much is). It never fails; errors come from feed. When it
empties a record, the client goes on with the records it already holds,
so after feeding everything you can keep reading.
write encrypts up to MAX_PLAINTEXT bytes of src into one record
queued in output() and returns how many it took; 0 when the output is
full: send it first. Writing before the handshake completes, or after
close, is a mistake in the program and traps (is_connected() says
when you may write).
update_keys replaces the client’s send key now (TLS 1.3 KeyUpdate), and
with request_peer asks the server to replace its own; false when the
output is full. The client also does this on its own before an AES-GCM
key has protected 2^24 records, and answers the server’s requests.
close sends close_notify. Writing after it traps, but reading goes on
until the server’s own close_notify (peer_closed()), as TLS 1.3
closes each direction on its own.
var sent: i64 = 0;
while (sent < body.len) {
let n: i64 = c.write(body[sent..body.len]) or { return; };
if (n == 0) { flush(c, t); } // output full: send, then go on
sent = sent + n;
}
flush(c, t);
var answer: [4096]u8;
let got: i64 = c.read(answer[0..4096]);Inspecting
pub fn Client.phase(*const self) -> State
pub fn Client.is_connected(*const self) -> bool
pub fn Client.peer_closed(*const self) -> bool
pub fn Client.error(*const self) -> ?TlsError
pub fn Client.failed_in(*const self) -> ?State
pub fn Client.cert_error(*const self) -> ?x509.X509Error
pub fn Client.alert(*const self) -> ?Alert
pub fn Client.suite(*const self) -> ?Suite
pub fn Client.group(*const self) -> ?Group
pub fn Client.retried(*const self) -> bool
pub fn Client.alpn(*const self) -> ?[]const u8
pub fn Client.leaf_sha256(*const self) -> ?[]const u8
pub fn alert_name(code: u8) -> []const u8| Function | Gives |
|---|---|
phase() | where the handshake is: State.WaitServerHello … Connected, or Failed |
is_connected() | the handshake is complete: you may write |
peer_closed() | the server sent close_notify: no more data will come |
error() | the error that failed the client, null while it works |
failed_in() | the state it was in when it failed |
cert_error() | with a certificate failure, x509’s precise reason |
alert() | the last alert the server sent: fatal, code (alert_name gives its RFC name) |
suite(), group() | the negotiated cipher suite and key-exchange group |
retried() | the server asked for another key share (HelloRetryRequest) |
alpn() | the protocol the server chose, null when none |
leaf_sha256() | the SHA-256 of the server’s certificate, once it arrived |
c.feed(data) or {
io.println("TLS failed: {err} while {c.failed_in()!.desc()}");
let why: ?x509.X509Error = c.cert_error();
if (why != null) { io.println(" certificate: {why!}"); }
let a: ?tls.Alert = c.alert();
if (a != null) { io.println(" the server said: {tls.alert_name(a!.code)}"); }
return;
};
io.println("{c.suite()!.desc()} over {c.group()!.desc()}"); // TLS_AES_128_GCM_SHA256 over x25519Pinning a certificate
For a server you know, you can pin its certificate by the SHA-256 of its
DER (what leaf_sha256() reports). With a trust store as well, the chain
must hold and the certificate must be the pinned one. With trust: null,
the pin is the whole check: no chain to a root, no name match and no
validity dates, so it works without a clock and for a self-signed
certificate. The server must still prove it holds the certificate’s key.
Any other certificate fails with LeafPinMismatch.
let pinned: tls.Config = tls.Config {
server_name: "debug.local";
trust: null; // the pin alone
now_unix: 0; // not used without a store
entropy: entropy[0..64];
pin_leaf_sha256: known_hash[0..32];
};A pin is only as good as the way the hash reached your program: ship it with the program, and ship a new one when the server’s certificate changes.
Types
pub enum Suite : u16 { Aes128GcmSha256 = 0x1301; Aes256GcmSha384 = 0x1302; ChaCha20Poly1305Sha256 = 0x1303; }
pub enum Group : u16 { Secp256r1 = 0x0017; X25519 = 0x001d; }
pub enum State : u8 {
Start; WaitServerHello; WaitRetriedServerHello; WaitEncryptedExtensions;
WaitCertificate; WaitCertificateVerify; WaitFinished; Connected; Failed;
}
pub struct Alert { fatal: bool; code: u8; }
pub struct Client { ... } // @opaqueEach desc() is the IANA name ("TLS_CHACHA20_POLY1305_SHA256",
"secp256r1") or the state in words ("waiting for Certificate").
Client is a plain struct of about 53 KiB with no pointers into itself:
put it in a global, on the heap or in a large stack frame, and use one
per connection.
Errors
pub enum TlsError : u8 {
BadRecord = 1: "malformed TLS record";
RecordOverflow = 2: "TLS record too large";
BadRecordMac = 3: "TLS record failed authentication";
UnexpectedMessage = 4: "unexpected TLS message";
DecodeError = 5: "malformed TLS handshake message";
HandshakeTooLarge = 6: "TLS handshake message exceeds the engine's buffer";
ProtocolVersion = 7: "server does not support TLS 1.3";
HandshakeFailure = 8: "no common cipher suite, group or signature algorithm";
IllegalParameter = 9: "server chose a value the client did not offer";
UnsupportedExtension = 10: "server sent an extension the client did not offer";
MissingExtension = 11: "server omitted a required extension";
BadCertificate = 12: "server certificate is malformed, unsupported or not allowed for this use";
CertificateExpired = 13: "server certificate has expired or is not yet valid";
UnknownCa = 14: "server certificate does not chain to a trusted root";
NameMismatch = 15: "server certificate does not match the server name";
BadCertificateVerify = 16: "server's CertificateVerify signature does not verify";
BadFinished = 17: "server's Finished does not verify";
PeerAlert = 18: "server sent a fatal alert";
Truncated = 19: "connection closed without close_notify";
BadServerName = 20: "server name is not a valid host name or IP address";
LeafPinMismatch = 21: "server certificate is not the pinned one";
}The certificate errors (BadCertificate, CertificateExpired,
UnknownCa, NameMismatch) keep x509’s reason in cert_error().
PeerAlert means the server ended the connection: alert() says why (a
no_application_protocol, 120, when it speaks none of your ALPN
protocols).
Secrets
The key schedule and the record protection are @constant_time; the
engine wipes its key-exchange key and handshake secrets as soon as it no
longer needs them.
pub fn Client.wipe(*self)wipe zeroes every secret left in the client (the traffic keys, the
buffers with their plaintext) when you are done with a connection; it can
then only be started again. What the inspecting functions report stays.
Notes
- The constant-time guarantee is for native code; the VM computes the same results without it.
- Tested with the RFC 8448 traces (the engine’s records match the RFC’s
byte for byte), in-memory handshakes against a test server with every
suite, chain, signature, HelloRetryRequest and ALPN case and every
misbehaviour it can be made to show, fed whole and a byte at a time,
and against OpenSSL’s
s_server.
Last modified: