Modules

Tls

moduleSource
macOSLinuxWindows

#import "Extensions/Tls";

TLS 1.2 and 1.3 over Io readers and writers, on a bundled mbedTLS. A jaic extension, not official Jai: other Jai compilers have no such module, and it may change between jaic releases. See docs/extensions/tls.md.

Fill in a Tls_Config, call tls_config_init once, then wrap an accepted or connected TCP stream with tls_connect (client) or tls_accept (server). A stream is also an Io.Reader and Io.Writer (reader_of, writer_of), so code written for those, like Http, runs over TLS unchanged. The handshake and every read and write are blocking calls on a fiber, inside Io.io_run: a deadline (Io.with_deadline) limits them and Io.cancel ends them, and one stream belongs to one scheduler thread.

Show moreShow less

Every call reports failure in its result and never stops the program: the handshake calls give a Tls_Error whose kind says what went wrong (error_text makes a line of it), while read and write give an Io.Io_Error and last_error(stream) the TLS reason. A client verifies the server against the system's trusted roots unless roots_pem gives others; names are matched against the certificate's subject alternative names. Memory: a stream is allocated with context.allocator by tls_connect or tls_accept and released by close; the config's parsed certificates are released by tls_config_free. mbedTLS is linked into the executable, so nothing has to be installed. WebAssembly has no sockets and no fibers.

The example runs a client and a server on loopback. The certificates and the key are throwaway ones made for tests, and the certificate is valid for localhost. Never use them for anything else.

Example
Io :: #import "Extensions/Io";

ROOT :: #string END
-----BEGIN CERTIFICATE-----
MIIBXzCCAQWgAwIBAgIBATAKBggqhkjOPQQDAjAWMRQwEgYDVQQDDAtKYWkgVGVz
dCBjYTAgFw0yMDAxMDEwMDAwMDBaGA8yMTIwMDEwMTAwMDAwMFowFjEUMBIGA1UE
AwwLSmFpIFRlc3QgY2EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQD1LxitDVO
QKMsMsgSpdi5JldYih52cephbw9hiejFcmOH3zQJAkGrcEwyH4CzLFJKjhUKaT54
4UDSgerXHZBpo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd
BgNVHQ4EFgQUunX0563ngeQ6w8JsMUiqadFmd6YwCgYIKoZIzj0EAwIDSAAwRQIh
AI1Q4QwolbUZQxGjLzBltR4I6BPZLStohfpAiI20nwPRAiAjsRP061IzprBMR5JF
WnaWEW4Nf8JpkkFzPPaEp947tQ==
-----END CERTIFICATE-----
END

CERTIFICATE :: #string END
-----BEGIN CERTIFICATE-----
MIIBzDCCAXKgAwIBAgIBAzAKBggqhkjOPQQDAjAWMRQwEgYDVQQDDAtKYWkgVGVz
dCBjYTAgFw0yMDAxMDEwMDAwMDBaGA8yMTIwMDEwMTAwMDAwMFowFjEUMBIGA1UE
AwwLc2VydmVyLnRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQC5kvwaJTy
O8qTdjNn9OgWvQ+QmLQDQDdzaUggjqJiooWHhj4peLTZFKWJHzwgZK3TRCnK9PXw
VYwzFQ5BLKq/o4GuMIGrMAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud
JQQMMAoGCCsGAQUFBwMBMDkGA1UdEQQyMDCCCWxvY2FsaG9zdIILc2VydmVyLnRl
c3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwHQYDVR0OBBYEFIDVLb76eRrc3ond
VlB/e95+HlPnMB8GA1UdIwQYMBaAFLp19Oet54HkOsPCbDFIqmnRZnemMAoGCCqG
SM49BAMCA0gAMEUCIE3wJvrmLnn+L4DdTxS3SZyHLO5z/yT/dh7hyz1vGv5oAiEA
v9a4lUdydpygfLwCjF7DCb8AD9qcpTQOAj8cFauUUCs=
-----END CERTIFICATE-----
END

KEY :: #string END
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILAaHg0fuEMf2UFJprp01LKXf54EQN3Xb7zkKzdoIxK1oAoGCCqGSM49
AwEHoUQDQgAEAuZL8GiU8jvKk3YzZ/ToFr0PkJi0A0A3c2lIII6iYqKFh4Y+KXi0
2RSliR88IGSt00QpyvT18FWMMxUOQSyqvw==
-----END EC PRIVATE KEY-----
END

Echo :: struct {
    listener: Io.Tcp_Listener;
    config: *Tls_Config;
}

// The server: accept one connection, answer a read with the same bytes, then close.
echo_once :: (job: *Echo) {
    tcp, io_error := Io.accept(job.listener);
    assert(io_error == .NONE);

    stream, error := tls_accept(job.config, Io.reader_of(tcp), Io.writer_of(tcp));

    if error.kind != .NONE {
        Io.close(tcp);
        return;
    }

    buffer: [64] u8;
    count, read_error := read(stream, buffer);

    if read_error == .NONE write(stream, string.{ count, buffer.data });

    close(stream);
}

main :: () {
    Io.io_run(() {
        server: Tls_Config;
        server.certificate_chain_pem = CERTIFICATE;
        server.private_key_pem = KEY;
        assert(tls_config_init(*server).kind == .NONE);
        defer tls_config_free(*server);

        // A client trusts the system's roots unless told otherwise; this one trusts only the test authority that signed the certificate.
        client: Tls_Config;
        client.roots_pem = ROOT;
        assert(tls_config_init(*client).kind == .NONE);
        defer tls_config_free(*client);

        listener, listen_error := Io.tcp_listen("127.0.0.1:0");
        assert(listen_error == .NONE);
        defer Io.close(listener);

        job := Echo.{ listener = listener, config = *server };
        Io.spawn(echo_once, *job);

        address, _ := Io.local_address(listener);
        tcp, connect_error := Io.connect(address);
        assert(connect_error == .NONE);

        // The name is sent as SNI and checked against the certificate.
        stream, error := tls_connect(*client, Io.reader_of(tcp), Io.writer_of(tcp), "localhost");

        // On an error nothing is closed: the TCP connection is still the caller's.
        assert(error.kind == .NONE, "%", error_text(error));
        defer close(stream);

        assert(version(stream) == "TLSv1.3");

        write(stream, "ping");

        buffer: [64] u8;
        count, read_error := read(stream, buffer);

        assert(read_error == .NONE && string.{ count, buffer.data } == "ping");
    });
}

Doesn't run in the browser (skipped on wasm).

Procedures#

Error#

(error: *Tls_Error) -> string

The sentence behind an error ("the certificate has expired"), or "". It is a copy in temporary storage, so it stays valid when the error is copied or goes out of scope, until the temporary storage is reset; copy it with copy_string to keep it longer.

alert_namesince 0.27.0
(alert: u8) -> string

The name of an alert description ("unknown_ca"), or "" for a number the module does not know.

(error: Tls_Error) -> string

One line for logs and messages: "certificate verification failed: the certificate has expired". Like detail, the text is in temporary storage, never inside the error.

Config#

(config: *Tls_Config) -> Tls_Error

Parses the certificates and keys and builds the mbedTLS configurations. Call it once before the first connection; the config must stay where it is and outlive every stream made from it. Returns .CONFIG with a reason for a certificate or key that does not parse, a version range that is empty, or a server certificate without its key. A config with no certificate is a client; one with a certificate and key can also accept.

(config: *Tls_Config)

Releases what tls_config_init built. Streams made from the config must be closed first.

(config: *Tls_Config) -> bool

Whether tls_accept works with this config: it was set up, and has a certificate and key.

Stream#

(config: *Tls_Config, reader: Io.Reader, writer: Io.Writer, server_name: string) -> (*Tls_Stream, Tls_Error)

Runs the client handshake over reader and writer and checks the server's certificate against server_name (which is also sent as the SNI name; "" sends none, and with verify_name off the name is not checked). On success the stream owns the connection: close closes it. On an error nothing is closed, the connection is the caller's to close.

(stream: *Tls_Stream) -> string

The cipher suite in use, by mbedTLS's name ("TLS1-3-AES-128-GCM-SHA256", "TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256").

(stream: *Tls_Stream) -> [] u8

The DER of the certificate the peer proved itself with (the first of its chain): the server's on a client stream, the client's on a server stream that asked for one. The bytes belong to the stream and stay valid until close. Empty before the handshake or when the peer sent none.

(stream: *Tls_Stream) -> string

The hash the peer certificate's signature uses, by lower-case name: "md5", "sha1", "sha224", "sha256", "sha384" or "sha512". "" when there is no certificate or the algorithm names no hash (Ed25519, unknown OIDs). It reads the certificate's own DER, not the library's view of it.

(stream: *Tls_Stream) -> string

The name a client asked for in its SNI extension, on a stream from tls_accept ("" if it sent none). It points into the stream.

(stream: *Tls_Stream, buffer: [] u8) -> (s64, Io.Io_Error)

Waits for data and reads what is there, up to buffer.count bytes. 0 is the end: the peer sent close_notify. A connection that ends without it is an error (.CONNECTION_RESET, and last_error says .TRUNCATED): the data may have been cut off.

(stream: *Tls_Stream, bytes: [] u8) -> (s64, Io.Io_Error)

Waits until the library has accepted at least one byte of bytes and sends it, together with the records of as many more bytes as fit in BATCH_BYTES; returns how many it took.

write2 overloads
(stream: *Tls_Stream, bytes: [] u8) -> (s64, Io.Io_Error)

Sends all of bytes. On an error it returns how many went out. Sends all of bytes, waiting for the peer as needed, and returns how many went out with an Io.Io_Error. On .NONE that is bytes.count; on an error it is the bytes that were already sent. It is the same as write_some repeated, so a large write is split into records and may take several scheduler turns. Use write_some to send what fits without looping.

(stream: *Tls_Stream) -> Io.Io_Error

Waits until read would not have to wait for the network: decrypted bytes are waiting, or something has arrived (which may still be only part of a record).

(stream: *Tls_Stream, now := false) -> Io.Io_Error

Ends the stream: sends close_notify (unless now, which does not wait and sends nothing) and closes the connection under it, which wakes fibers still waiting on it with .CLOSED. The stream is freed once those fibers have left, so it must not be used after close; close it once.

(reader: Io.Reader) -> bool

Whether reader is a TLS stream's reader (from reader_of) whose connection ended without the peer's close_notify (last_error(stream).kind == .TRUNCATED). For a protocol that frames its own messages, such as HTTP, where that end is no failure once the message is complete.

(stream: *Tls_Stream) -> Io.Reader

The stream as an Io.Reader, for code written against readers (Io.read_all, the Http client). Reading it is read; closing it closes the whole stream, once. The reader points at the stream, so it is only valid until close.

(stream: *Tls_Stream) -> Io.Writer

The stream as an Io.Writer, for code written against writers. Writing it is write_some; closing it closes the whole stream, once, so close either the reader or the writer of a stream and not both. It is only valid until close.

Names#

Releases the lists inside names (allocated with context.allocator by certificate_names) and resets it. The strings in it pointed into the DER, so they are not valid afterwards.

Types#

:: enum

What went wrong, in one value for everything the module does: a bad certificate in a config, a handshake that failed, a peer that is not who it says it is, a connection that broke.

config: Tls_Config;
config.certificate_chain_pem = "this is not a certificate";
config.private_key_pem = "this is not a key";

error := tls_config_init(*config);
assert(error.kind == .CONFIG);
assert(error_text(error).count > 0);

Doesn't run in the browser (skipped on wasm).

Tls_Errorstruct
:: struct

The sentence for people ("the certificate has expired") is kept inside the error in detail_bytes; read it with detail(*error) or print the whole error with error_text. code is mbedTLS's own negative error number (0 when the error did not come from it).

:: enum

A TLS protocol version, for Tls_Config.min_version and max_version: the range a client offers or a server accepts. The defaults are the whole range, TLS_1_2 to TLS_1_3, and the version that was settled on is reported by version(stream) as "TLSv1.2" or "TLSv1.3". Older versions are not supported. tls_config_init fails with .CONFIG when the range is empty (min_version above max_version).

config: Tls_Config;
config.min_version = .TLS_1_3;
config.max_version = .TLS_1_2;

error := tls_config_init(*config);
assert(error.kind == .CONFIG);

Doesn't run in the browser (skipped on wasm).

:: #type (chain: [][] u8, server_name: string, data: *void) -> string

Decides whether to trust a peer's certificate chain, instead of mbedTLS's own verification. chain is the peer's certificates in DER, its own first; server_name is what the client asked for (empty on the server). Returns "" to accept, or why the chain is rejected. It runs on the fiber that is doing the handshake, after the handshake itself succeeded, and may wait.

Tls_Key_Log_Proctypesince 0.27.0
:: #type (line: string, data: *void)

Gets one line of the NSS key log format (no newline), such as CLIENT_HANDSHAKE_TRAFFIC_SECRET <client random> <secret>, in hex.

:: struct

Zero-initialised, a config is a client that verifies servers against the system's roots. Fill in the fields you need, then call tls_config_init.

:: struct

A TLS connection over any Io.Reader / Io.Writer pair (a TCP stream's, a pipe's, an Http Link's). The handshake is a plain blocking call on the calling fiber: it waits through the Io calls under it, so Io.with_deadline limits it and Io.cancel ends it. After it, the stream is read and written like any other, through reader_of and writer_of, or directly with read, write_some and write. The example in the module's docs connects, writes and reads.

Certificate_Namesstructsince 0.27.0
:: struct

Slices into the DER the names were read from: they are valid as long as it is.

Constants#

:: 64 * 1024

The library makes one record per call (up to 8 KiB of data); a write of more goes through several records into out and reaches the connection in one send, up to this much, instead of one send (and one trip through the scheduler) per record.