Skip to content

TLS 1.3

TLS 1.3 (RFC 8446) is the current standard for securing transport-layer connections. Its wire format layers multiple levels of framing: TLS records wrap handshake messages, which in turn contain typed extensions. Each level is a TLV (type-length-value) structure with its own dispatch table.

This page shows the complete wirespec model for TLS 1.3 and explains the features it exercises: enum types as match tags, the u24 primitive, nested capsules, and [T; fill] within EXPR — fill arrays bounded by a byte length.

Protocol Background

A TLS connection starts with a handshake, during which the client and server negotiate cipher suites, exchange keys, and authenticate each other. The record layer wraps all handshake (and later application) traffic in uniform 5-byte record headers. Handshake messages have their own 4-byte header (1 byte type + 3 bytes length), and extensions within handshake messages use 2+2 byte TLV headers.

The layered structure — Record → HandshakeMessage → Extensions — makes TLS 1.3 an excellent exercise in wirespec's nested capsule capability.

Complete wirespec Definition

wire
module tls.tls13
@endian big

# ── Enums ──────────────────────────────────────────────────────────────────

enum ContentType: u8 {
    ChangeCipherSpec = 20,
    Alert            = 21,
    Handshake        = 22,
    ApplicationData  = 23,
}

enum HandshakeType: u8 {
    ClientHello         = 1,
    ServerHello         = 2,
    NewSessionTicket    = 4,
    EncryptedExtensions = 8,
    Certificate         = 11,
    CertificateRequest  = 13,
    CertificateVerify   = 15,
    Finished            = 20,
    KeyUpdate           = 24,
}

# ── Extension (TLV) ────────────────────────────────────────────────────────

capsule Extension {
    extension_type: u16,
    length: u16,
    payload: match extension_type within length {
        0x002b => SupportedVersions  { data: bytes[remaining] },
        0x000d => SignatureAlgorithms { data: bytes[remaining] },
        0x0033 => KeyShare            { data: bytes[remaining] },
        0x0000 => ServerName          { data: bytes[remaining] },
        _      => Unknown             { data: bytes[remaining] },
    },
}

# ── Handshake Packets ──────────────────────────────────────────────────────

packet ClientHello {
    legacy_version:              u16,
    random:                      bytes[32],
    session_id_length:           u8,
    session_id:                  bytes[session_id_length],
    cipher_suites_length:        u16,
    cipher_suites:               [u16; cipher_suites_length / 2],
    compression_methods_length:  u8,
    compression_methods:         bytes[compression_methods_length],
    extensions_length:           u16,
    extensions:                  [Extension; fill] within extensions_length,
}

packet ServerHello {
    legacy_version:       u16,
    random:               bytes[32],
    session_id_length:    u8,
    session_id_echo:      bytes[session_id_length],
    cipher_suite:         u16,
    compression_method:   u8,
    extensions_length:    u16,
    extensions:           [Extension; fill] within extensions_length,
}

packet CertificateEntry {
    cert_data_length:    u24,
    cert_data:           bytes[cert_data_length],
    extensions_length:   u16,
    extensions:          bytes[extensions_length],
}

packet Certificate {
    request_context_length:  u8,
    request_context:         bytes[request_context_length],
    certificate_list_length: u24,
    certificate_list:        [CertificateEntry; fill] within certificate_list_length,
}

packet CertificateVerify {
    algorithm:        u16,
    signature_length: u16,
    signature:        bytes[signature_length],
}

packet Finished {
    verify_data: bytes[remaining],
}

packet NewSessionTicket {
    ticket_lifetime:    u32,
    ticket_age_add:     u32,
    nonce_length:       u8,
    nonce:              bytes[nonce_length],
    ticket_length:      u16,
    ticket:             bytes[ticket_length],
    extensions_length:  u16,
    extensions:         [Extension; fill] within extensions_length,
}

# ── Handshake Message Framing ──────────────────────────────────────────────

capsule HandshakeMessage {
    msg_type: HandshakeType,
    length:   u24,
    payload:  match msg_type within length {
        1  => ClientHello {
            legacy_version:             u16,
            random:                     bytes[32],
            session_id_length:          u8,
            session_id:                 bytes[session_id_length],
            cipher_suites_length:       u16,
            cipher_suites:              [u16; cipher_suites_length / 2],
            compression_methods_length: u8,
            compression_methods:        bytes[compression_methods_length],
            extensions_length:          u16,
            extensions:                 [Extension; fill] within extensions_length,
        },
        2  => ServerHello {
            legacy_version:      u16,
            random:              bytes[32],
            session_id_length:   u8,
            session_id_echo:     bytes[session_id_length],
            cipher_suite:        u16,
            compression_method:  u8,
            extensions_length:   u16,
            extensions:          [Extension; fill] within extensions_length,
        },
        4  => NewSessionTicket {
            ticket_lifetime:   u32,
            ticket_age_add:    u32,
            nonce_length:      u8,
            nonce:             bytes[nonce_length],
            ticket_length:     u16,
            ticket:            bytes[ticket_length],
            extensions_length: u16,
            extensions:        [Extension; fill] within extensions_length,
        },
        11 => Certificate {
            request_context_length:  u8,
            request_context:         bytes[request_context_length],
            certificate_list_length: u24,
            certificate_list:        [CertificateEntry; fill] within certificate_list_length,
        },
        15 => CertificateVerify {
            algorithm:        u16,
            signature_length: u16,
            signature:        bytes[signature_length],
        },
        20 => Finished { verify_data: bytes[remaining] },
        _  => Unknown  { data: bytes[remaining] },
    },
}

# ── TLS Record Layer ───────────────────────────────────────────────────────

capsule TlsRecord {
    content_type:    ContentType,
    legacy_version:  u16,
    length:          u16,
    payload:         match content_type within length {
        22 => Handshake       { data: bytes[remaining] },
        21 => Alert           { level: u8, description: u8 },
        20 => ChangeCipherSpec { value: u8 },
        23 => ApplicationData { data: bytes[remaining] },
        _  => Unknown         { data: bytes[remaining] },
    },
}

Features Demonstrated

FeatureWhere
enum as match tagmatch msg_type within lengthmsg_type: HandshakeType
enum as capsule tagmatch content_type within lengthcontent_type: ContentType
u24 primitivelength: u24 in HandshakeMessage, cert_data_length: u24
[T; fill] within EXPRextensions: [Extension; fill] within extensions_length
Nested capsulesTlsRecordHandshakeMessageExtension
Fixed-size bytesrandom: bytes[32]
Length-prefixed bytescert_data: bytes[cert_data_length]
Computed array countcipher_suites: [u16; cipher_suites_length / 2]
bytes[remaining]verify_data: bytes[remaining] in Finished
Integer literals as match patterns22 => Handshake { ... } against ContentType tag

Feature Notes

enum as a Match Tag

wire
capsule TlsRecord {
    content_type: ContentType,
    ...
    payload: match content_type within length {
        22 => Handshake       { ... },
        21 => Alert           { ... },
        ...
    },
}

When a field declared as an enum type is used as a capsule or frame match tag, wirespec resolves the underlying integer type (u8 here) for reading, then dispatches on the integer value. The branch patterns are integer literals — wirespec knows the enum's underlying type and performs the comparison accordingly.

You can also use the enum variant names as patterns in future versions; for now, the integer literal form is used to match directly against the numeric values defined in the enum block.

The same pattern appears in HandshakeMessage, where msg_type: HandshakeType (also u8) drives dispatch over the handshake type space.

u24 Primitive

wire
capsule HandshakeMessage {
    msg_type: HandshakeType,
    length:   u24,
    ...
}

TLS uses 3-byte (24-bit) unsigned integers for handshake message lengths and certificate list lengths. wirespec's u24 type reads exactly 3 bytes and stores the result in a uint32_t. The parse function reads bytes at offsets 0, 1, and 2 and assembles them with the module's endianness (big-endian here):

value = (byte[0] << 16) | (byte[1] << 8) | byte[2]

Serialize writes 3 bytes in the same order. The value must fit in 24 bits; a value ≥ 2²⁴ returns WIRESPEC_ERR_OVERFLOW.

u24be and u24le are also available for explicit endianness control independent of @endian.

[T; fill] within EXPR

wire
extensions: [Extension; fill] within extensions_length,

This construct parses as many Extension items as fit within extensions_length bytes. The compiler creates a sub-cursor of extensions_length bytes, then calls the Extension parser in a loop until the sub-cursor is exhausted. The result is stored in a fixed-capacity array in the generated C struct.

The default array capacity is WIRESPEC_MAX_ARRAY_ELEMENTS (64). You can override it per field with @max_len(N):

wire
@max_len(32)
extensions: [Extension; fill] within extensions_length,

If the sub-cursor is exhausted partway through an Extension (i.e., the bytes run out mid-item), parsing returns WIRESPEC_ERR_SHORT_BUFFER. If the sub-cursor is left with unconsumed bytes after the last complete item, those bytes are silently discarded (the within contract is satisfied as long as the loop stays within bounds).

This pattern appears three times in the TLS 1.3 definition:

  • ClientHello.extensions — bounded by extensions_length: u16
  • ServerHello.extensions — same
  • Certificate.certificate_list — bounded by certificate_list_length: u24

Nested Capsules

TlsRecord
  └─ payload: Handshake { data: bytes[remaining] }
        │  (application decodes data as HandshakeMessage)
HandshakeMessage
  └─ payload: ClientHello { ... extensions: [Extension; fill] within ... }

                                           Extension
                                             └─ payload: SupportedVersions / KeyShare / ...

At the TLS record layer, handshake content is delivered as bytes[remaining] — the record parser does not recurse into the handshake. The application layer then passes that byte span to tls_tls13_handshake_message_parse. This is intentional: TLS record reassembly (multiple records per handshake message, or multiple messages per record) happens at a layer above the wire parser.

Extensions are parsed inline within each handshake packet via [Extension; fill] within extensions_length.

Computed Array Count

wire
cipher_suites_length: u16,
cipher_suites:        [u16; cipher_suites_length / 2],

The cipher suites list is encoded as a byte length, not an element count. Dividing by 2 (each u16 takes 2 bytes) gives the element count. The expression cipher_suites_length / 2 is evaluated at parse/serialize time. If cipher_suites_length is odd, the count rounds down and one byte may be left unconsumed — in practice TLS implementations ensure even lengths, and a require clause could enforce this.

Compile

bash
wirespec compile examples/tls/tls13.wspec -o build/

This generates build/tls_tls13.h and build/tls_tls13.c.

Generated C API

c
/* Record layer */
wirespec_result_t tls_tls13_tls_record_parse(
    const uint8_t *buf, size_t len,
    tls_tls13_tls_record_t *out, size_t *consumed);

wirespec_result_t tls_tls13_tls_record_serialize(
    const tls_tls13_tls_record_t *in,
    uint8_t *buf, size_t cap, size_t *written);

/* Handshake framing */
wirespec_result_t tls_tls13_handshake_message_parse(
    const uint8_t *buf, size_t len,
    tls_tls13_handshake_message_t *out, size_t *consumed);

/* Extension TLV */
wirespec_result_t tls_tls13_extension_parse(
    const uint8_t *buf, size_t len,
    tls_tls13_extension_t *out, size_t *consumed);

Usage Example

c
#include "tls_tls13.h"

void process_tls_record(const uint8_t *buf, size_t len) {
    tls_tls13_tls_record_t rec;
    size_t consumed;

    wirespec_result_t r = tls_tls13_tls_record_parse(buf, len, &rec, &consumed);
    if (r != WIRESPEC_OK) return;

    if (rec.kind != TLS_TLS13_TLS_RECORD_HANDSHAKE) return;

    /* Handshake data is a zero-copy bytes view into buf */
    const wirespec_bytes_t *hs_data = &rec.handshake.data;

    tls_tls13_handshake_message_t msg;
    size_t msg_consumed;
    r = tls_tls13_handshake_message_parse(
            hs_data->ptr, hs_data->len, &msg, &msg_consumed);
    if (r != WIRESPEC_OK) return;

    if (msg.kind == TLS_TLS13_HANDSHAKE_MESSAGE_CLIENT_HELLO) {
        /* Iterate over parsed extensions */
        for (size_t i = 0; i < msg.client_hello.extensions_count; i++) {
            tls_tls13_extension_t *ext = &msg.client_hello.extensions[i];
            if (ext->kind == TLS_TLS13_EXTENSION_SUPPORTED_VERSIONS) {
                handle_supported_versions(ext->supported_versions.data.ptr,
                                          ext->supported_versions.data.len);
            }
        }
    }
}

What Next

  • BLE ATT — little-endian protocols, type aliases, bytes[remaining]
  • MQTT — continuation-bit VarInt, expression-based capsule tag, conditional fields
  • Checksums@checksum(internet), CRC32, CRC32C, Fletcher-16