moontls

    TLS 1.3 and DTLS 1.3 for MoonBit.

    tls
    tls13
    dtls
    rfc8446
    rfc9147
    moonbit
    Download zip
    Version
    0.9.12
    License
    Apache-2.0
    Last updated
    12 hours ago
    Downloads
    1K

    #moontls

    TLS for MoonBit — 1.3 (RFC 8446) and 1.2 (RFC 5246), client and server, with 1.1 and 1.0 (RFC 2246) on request — and the pieces of DTLS 1.3 (RFC 9147) that datagrams force. Ciphertext in, ciphertext and plaintext out. No sockets, and not one line of cryptography of its own: the algorithms are mooncrypt's and certificate validation is mooncred's.

    // A client: put what the socket gives into `receive`, send what `take` gives.
    let config = @tls.Client::new(@roots.mozilla(), alpn=["h2", "http/1.1"])
    let conn = @tls.Conn::client(config, "example.com", rand~, now~)
    socket.write(conn.take()) // the ClientHello
    while !conn.is_connected() {
    conn.receive(socket.read()[:])
    socket.write(conn.take())
    }
    conn.alpn() // Some("h2")
    conn.write(request[:])

    // A server: the same three calls. The key is any PEM file `openssl` writes.
    let chain = @x509.pems(cert_pem) // leaf first
    let config = @tls.Server::new(chain, @x509.PrivateKey::pem(key_pem), alpn=["h2"])
    let server = @tls.Conn::server(config, rand~)

    // The key schedule of RFC 8446 §7.1, one step at a time.
    let early = @keys.early() // no PSK
    let handshake = @keys.handshake(early[:], ecdhe[:]) // salted by ECDHE
    let master = @keys.master(handshake[:])

    let transcript = @keys.Transcript::new()
    transcript.add(client_hello[:])
    transcript.add(server_hello[:])

    let secret = @keys.client_handshake(handshake[:], transcript.hash()[:])
    let ok = @keys.finished_ok(secret[:], transcript.hash()[:], verify_data[:])

    Run moon run src/examples/tour for the whole surface in one go.

    #Packages

    SpecificationState
    wire§3 integer widths, the frozen legacy_version, and Reader, the cursor every decoder reads through0.2.0
    alert§6 the alert protocol0.2.0
    keys§7.1 key schedule, §4.4.1 transcript hash, §4.4.4 Finished0.1.0
    record§5 record layer under any of the three suites, §7.3 traffic keys; TLS 1.2's suite table and record protection0.3.0
    ext§4.2 extensions: server_name, supported_versions, supported_groups, signature_algorithms, key_share, ALPN, cookie, use_srtp0.4.0
    msg§4 the handshake messages: ClientHello, ServerHello, EncryptedExtensions, Certificate, CertificateVerify, Finished0.4.0
    hsAppendix A the state machine, §4.1.1 negotiation, §4.1.4 HelloRetryRequest, and the driver0.5.0
    dtlsRFC 9147 what datagrams change: both record shapes, record number encryption, the replay window, handshake fragmentation and reassembly, the ACK message, and the flight driver0.2.0
    tlsThe connection: client and server, X25519MLKEM768, X25519, P-256, P-384, P-521 and X448, and SecP256r1MLKEM768, SecP384r1MLKEM1024, ML-KEM alone and the RFC 7919 groups on request, the three suites, ECDSA, RSA-PSS and Ed25519 certificates, HelloRetryRequest, SNI, ALPN, KeyUpdate, close_notify, middlebox compatibility mode, declined 0-RTT data read past; TLS 1.2 beside 1.3 and the session resumption, client certificates, renegotiation and stapling that come with them; the server's chain judged by mooncred0.1.0
    srtpRFC 5764 the SRTP protection profiles a DTLS handshake negotiates, and the keying material it exports for them0.1.0

    msg is formats only. Which message may arrive next, and which parameters to pick, is the state machine's — that seam is what lets a tool read a ClientHello (an SNI router, a fingerprinter) without linking a handshake or a record layer.

    ext says what is on the wire and nothing about what to pick from it: which group, which scheme, which version is the handshake's decision and lives in hs. quic_transport_parameters has a type code there and no codec — the payload is QUIC's (RFC 9000 §18), and reading somebody else's structure would be this library claiming to know it.

    dtls derives its keys under dtls13, not tls13 (§5.9). Up to 0.9.0 it used the TLS prefix, which round-trips against itself and interoperates with nobody; pion/dtls's vectors caught it, and dtls/pion_wbtest.mbt now holds them.

    dtls is everything a datagram forces and nothing else: the handshake messages, the key schedule and the extensions are unchanged, so they come from the packages beside it — its Driver runs hs's state machines, for either end, over flights instead of a stream. It has no clock: it answers what is still outstanding and what has to be acknowledged, and the caller decides when to resend, because §5.8's timers are a retransmission policy and choosing one for a caller is not a library's job.

    The key exchange is post-quantum by default. groups puts X25519MLKEM768 first, as Chrome, Firefox, Go, OpenSSL 3.5 and rustls do, and shares sends a key share for the first two, so a server without the hybrid takes the X25519 share at once instead of asking again. The hybrid's share is 1,216 bytes against X25519's 32; a client that cannot afford it passes shares=1 or its own groups. SecP256r1MLKEM768, SecP384r1MLKEM1024 and ML-KEM alone (@ext.mlkem512 to @ext.mlkem1024) are there to be named. ML-KEM is mooncrypt's, and Conn::group says which group ran.

    tls speaks TLS 1.2 beside 1.3, both on by default through versions, and Conn::version says which was settled. TLS 1.1 and 1.0 are implemented and left out of that preset, as RFC 8996 asks: a caller that needs them names them, and the PRF over MD5 and SHA-1, the implicit CBC IV and the signature without a scheme come with them. TLS 1.2 has a table of its own in record, one row a suite: ECDHE, DHE and RSA key exchange under AES-GCM, AES-CCM, ChaCha20-Poly1305 and AES-CBC, 39 in all. suites12, the default, is the six with ECDHE and an AEAD; @record.all12 is everything, and asking for it is a decision, because RSA key exchange has no forward secrecy and CBC has a history. Beside the suites come extended master secret (RFC 7627), encrypt-then-MAC (RFC 7366), secure renegotiation indication (RFC 5746), the downgrade sentinel and TLS_FALLBACK_SCSV; a request to renegotiate is declined with a warning and the connection goes on.

    The old suites keep their countermeasures: a CBC record does the same work whatever is wrong with it, which is Lucky Thirteen's fix, and a wrapped premaster that fails to unwrap becomes a random secret and fails at Finished, which is Bleichenbacher's. A client refuses a DHE prime under dh_bits, 2048 by default. schemes12 is the same bargain for signatures: SHA-2, PSS and Ed25519 by default, and all_schemes12 adds SHA-1, which is what a TLS 1.2 client that sends no signature_algorithms is taken to mean. It governs TLS 1.2 only: 1.0 and 1.1 have no scheme to negotiate, and their hash comes with naming the version.

    A session can be resumed instead of re-authenticated. In TLS 1.3 a server hands out NewSessionTickets sealed under its ticket keys — the ticket carries its state, so the server keeps nothing — and a client offers one back as a pre_shared_key with a binder over the truncated ClientHello; with Tickets::early and a client that asks, 0-RTT data travels under the early traffic secret and is closed by EndOfEarlyData, and data a server declines is sent again as ordinary data. In TLS 1.2 the same Tickets hands out RFC 5077 tickets, and a Sessions cache resumes by session id; both run the abbreviated handshake, which skips the certificate and the key exchange.

    Client certificates work in both versions: a server that sets client_auth asks, a client with cert answers, and the chain is judged to the server's own anchors with the client usage. A request after the handshake (Conn::request_certificate) is the same exchange over the application traffic secret. Renegotiation is there for TLS 1.2 and refused unless both ends set renegotiation; when they do, the new handshake names the previous one's two Finished values, which is what RFC 5746 is for. OCSP stapling (ocsp), record_size_limit (RFC 8449) and certificate compression (RFC 8879, zlib through moonzip, used only when it actually makes the message shorter) sit beside them, and a heartbeat record is refused rather than answered.

    tls also runs the handshake for QUIC (RFC 9001 §4). Conn::quic_server and Conn::quic_client take CRYPTO bytes through crypto and give back, through steps, what to send at which level and when each level has keys: no records, no change_cipher_spec, no alerts on the wire.

    srtp stops where TLS stops. It negotiates the profile and exports the keys; the RFC 3711 transform that encrypts an RTP packet is moonrtc's, because RTP is not TLS's business.

    Every decoder reads through @wire.Reader, which checks each length it relies on: a vector that runs past its end, is shorter than its minimum, is not a whole number of elements, or leaves octets over is Malformed, and the handshake answers it with decode_error. None of them shortens a list to what fits.

    alert has no dependencies at all, on purpose: QUIC needs TLS's alerts without needing TLS's record layer, because RFC 9001 §5 replaces the record layer with QUIC's own packet protection.

    #The digest is a parameter

    RFC 8446 writes the whole ladder over Hash, the hash the negotiated cipher suite names. So every function here takes digest, and every output length follows from it:

    @keys.early() // SHA-256, the suite RFC 8446 §9.1 makes mandatory
    @keys.early(digest=Sha384) // 48 octets instead of 32, all the way down

    @keys.digest is the preset, and it is Sha256 because TLS_AES_128_GCM_SHA256 is the one implementation is required to provide.

    #What is not here

    Sockets — those are moonnet's. Cryptographic algorithms — those are mooncrypt's. QUIC's transport layer — that is moonquic, which borrows this handshake and reuses this key schedule verbatim (RFC 9001 §5.2). Not implemented yet: a DTLS connection on top of the dtls driver — RFC 9147's records, fragmentation, ACKs and replay window are here, the state machine that flies them is not — and DTLS 1.2, which WebRTC still uses. The ML-KEM hybrid group X25519MLKEM768 waits on something outside this repository: mooncrypt has no ML-KEM to bind to yet.

    #Verification

    The key schedule is checked against RFC 8446 §7.1 written out over Python's own HMAC — an implementation sharing no code with this one. The Early Secret for a zero PSK comes out 33ad0a1c…f170f92a, which is the value RFC 8448 publishes, so the ladder is anchored to the RFC's trace and not only to our own arithmetic.

    The record layer is checked the same way, and one matching ciphertext covers the whole chain at once: the key and IV §7.3 derives, the nonce §5.3 builds from the sequence number, the trailing content type and padding of §5.2's inner plaintext, the five header octets chosen as the AEAD's additional data, and the AEAD itself.

    DTLS has no RFC 8448 of its own — RFC 9147 publishes no traces — so its records are checked against the same kind of independent implementation, and one matching record again covers everything at once: the unified header's bits, the header as additional data, the nonce, and the AES-ECB mask that hides the sequence number.

    TLS 1.0's and 1.1's PRF is a different construction rather than a different hash, and it is checked against OpenSSL's own TLS1-PRF with digest:MD5-SHA1: three vectors, one of them with an odd secret length, which is where the two halves of the secret overlap by an octet.

    The connection is checked against OpenSSL 3.5 both ways by moonnet's scripts/tls_interop.sh: this client against openssl s_server under every key type, suite and group, record sizes, padding, tickets, KeyUpdate, ALPN, SNI, and the chains it must refuse (an intermediate not sent, an untrusted root, a forged signature, a wrong host, an expired or not yet valid leaf, a leaf for client authentication or acting as a CA); and openssl s_client against this server under every key type and suite, OpenSSL's default ML-KEM-first ClientHello, HelloRetryRequest, KeyUpdate from either side, and the ClientHellos it must refuse. Every one of the 39 TLS 1.2 suites runs in both directions, and the CBC ones run both with encrypt-then-MAC and with the MAC first.

    The server is also run through tlsfuzzer's TLS 1.3 scripts, which probe every message for wrong lengths, wrong order, wrong values and the alert each one is owed. What failed there when this round began, and is now implemented, was session resumption, client certificates and post-handshake authentication; what is still missing is ML-KEM, brainpool, Ed448 and RSASSA-PSS keys. Its TLS 1.2 scripts run against every suite turned on, and the ones that fuzz MACs, padding, ciphertext, CCM, GCM nonces and RSA key exchange messages all pass.

    The client's side of the same rules is pinned by tests that play a forged server: a flight without CertificateVerify, a Certificate before EncryptedExtensions or twice, a message sharing the ServerHello's record, an extension the client never offered.

    The gate is moon clean → moon fmt → moon check --target all --deny-warn → moon build --target all → moon test --target all, across wasm, wasm-gc, js and native.

    #License

    Apache-2.0.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io