tls

tape
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.

tape
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

tape
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_name is sent to the server as SNI (unless it is an IP address) and checked against the certificate’s subjectAltName.
  • trust is read when the server’s certificate arrives; keep it alive and unchanged while the client runs.
  • now_unix decides whether the certificates are in their validity period. A wrong clock makes good certificates fail, never bad ones pass.
  • entropy must 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.
  • alpn is the Application-Layer Protocol Negotiation list, such as "h2" and "http/1.1"; the encoded list is at most MAX_ALPN bytes.
tape
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

tape
pub fn Client.start(*self, cfg: *const Config) -> void or TlsError

Resets 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.

tape
c.start(&cfg) or {
    io.println("cannot start: {err}");     // "server name is not a valid host name or IP address"
    return 1;
};

Moving bytes

tape
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 TlsError

output() 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.

tape
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

tape
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.

tape
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

tape
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
FunctionGives
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
tape
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 x25519

Pinning 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.

tape
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

tape
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 { ... }                      // @opaque

Each 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

tape
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.

tape
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: