|
ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
|
DTLS is TLS for datagrams: it gives UDP the same confidentiality, integrity, and authentication that TLS gives TCP, without imposing a reliable, ordered byte stream. That matters for constrained devices, where the natural transport is a single UDP exchange - CoAP sensor reads, telemetry, LwM2M - and a TCP + TLS session would be pure overhead.
DTLS 1.3 (RFC 9147) reuses the TLS 1.3 handshake and key schedule wholesale and changes only what datagrams require: a compact record header, per-record sequence numbers that survive reordering and loss, encryption of those sequence numbers, and its own reliability (records can be dropped, duplicated, or reordered, so the handshake carries acknowledgements and retransmits). This server builds DTLS 1.3 on the same hand-rolled TLS 1.3 crypto that backs HTTP/3 - no second TLS stack.
Status. The DTLS 1.3 server handshake completes and interoperates with a reference implementation: the record layer (RFC 9147 §4), the handshake framing and reliability primitives (§5, §7), and the server-side handshake state machine (§5-6) are implemented, and the wolfSSL DTLS 1.3 client completes a full handshake and an application-data round trip against it (`test/servers/dtls_wolfssl`) - both directly and through a HelloRetryRequest group renegotiation. This is the one-round-trip full handshake (
TLS_AES_128_GCM_SHA256/ X25519 / Ed25519, no PSK / 0-RTT / client auth); a client that does not offer an X25519 key_share up front is answered with an HRR carrying an address-bound cookie and renegotiates the group (§5.1). Lost flights are recovered by a retransmission timer with exponential backoff (§5.8), and inbound ACKs cancel it. The CoAP-over-DTLS front-end is complete: the bridge (pc_coaps_process) drives the handshake and then runspc_coap_server_processinside the encrypted records, andcoaps_serverbinds a UDP port (5684,coaps://) to a per-peer connection pool - routing each datagram to its connection by peer address, driving every handshake and the retransmission timer from onepc_coaps_server_poll(), and reaping idle connections (example CoapSecure). Each layer is a complete, independently tested unit.
A DTLS 1.3 record comes in two shapes (RFC 9147 §4):
type · legacy_version · epoch · 48-bit sequence_number · length · fragment), used unencrypted for the very first handshake flight and for alerts in epoch 0.001) and four flags (connection-id present, 8- vs 16-bit sequence number, length present, and the low two bits of the epoch), so a protected record can be as small as its payload plus a few bytes of header and a 16-byte tag.Protecting a record:
AES-ECB(sn_key, ciphertext[0..15]) and XORed into the on-wire sequence-number bytes. This is the same construction as QUIC header protection, so it shares that code. A receiver reverses it, then reconstructs the full sequence number as the value nearest the one it expects next (RFC 9000 Appendix A.3).The record keys - the AEAD key, the write iv, and the sequence-number sn key - are each an HKDF-Expand-Label of a TLS 1.3 traffic secret (RFC 8446 §7.3, RFC 9147 §4.2.3).
An anti-replay sliding window (RFC 9147 §4.5.1) tracks the highest sequence number accepted in an epoch and a 64-record bitmap behind it, so a replayed or too-old record is rejected before it reaches the application.
TLS 1.3 assumes a reliable, ordered byte stream; DTLS carries the same handshake messages over datagrams that can be lost, reordered, or duplicated. The framing layer bridges that gap so the reused TLS 1.3 message builders never have to know they are on UDP.
message_seq and a fragment_offset / fragment_length, so one message can be split across records and each fragment placed independently of the datagram that carried it.(epoch, sequence_number) record numbers. DTLS acknowledges records rather than relying on TCP, so a receiver can tell the sender which fragments of a flight arrived and the sender can drop them from its next retransmission.HMAC-SHA256(server_secret, version · timestamp · client_address · payload), so the client's address is authenticated (a cookie minted for one peer is worthless to another) without the server storing anything, and a timestamp bounds its lifetime. The opaque payload is where the state machine parks what it needs to resume the handshake after the retry.The server state machine ties the layers together and reuses the TLS 1.3 message builders and key schedule that already back HTTP/3 - it is a DTLS driver over the same crypto, not a second TLS stack. A full handshake is one round trip:
DTLSPlaintext record. The server checks the offer (TLS 1.3, X25519, Ed25519), computes the X25519 shared secret, and starts the transcript.Transcript-Hash(ClientHello..ServerHello) and installs the epoch-2 record keys.DTLSCiphertext records. CertificateVerify is an Ed25519 signature over the transcript; Finished is the HMAC the client checks to authenticate the whole exchange.Transcript-Hash(ClientHello..Finished) the server derives the application-traffic secrets and installs the epoch-3 keys.Each TLS handshake message is wrapped in the 12-byte DTLS handshake header and carried in its own record; the transcript is computed over the reassembled TLS messages (the DTLS framing fields removed), exactly as RFC 9147 §5.2 requires. Epochs advance 0 → 2 → 3 as the keys are installed (RFC 9147 §6.1).
When the first ClientHello offers X25519 in supported_groups but carries no X25519 key_share, the server does not begin the expensive handshake. It answers with a HelloRetryRequest (RFC 9147 §5.1) selecting X25519 and a stateless cookie bound to the client's address, and restarts the transcript as the synthetic message_hash(ClientHello1) that RFC 8446 §4.4.1 requires. Only when the second ClientHello echoes a valid, fresh cookie - proving the client can receive at its claimed address - does the server spend the X25519 shared secret and its signature. The transcript both sides then authenticate is message_hash || HelloRetryRequest || ClientHello2 || ServerHello || ..., and the server's outbound message_seq counter shifts every later message up by one to account for the HRR.
With PC_ENABLE_TLS_RPK, the server can present a bare public key instead of an X.509 certificate - the RFC 7252 §9 CoAP-over-DTLS RawPublicKey profile, a natural fit for provisioned, key-pinned fleets. When the ClientHello carries the server_certificate_type extension (RFC 7250, IANA 20) offering RawPublicKey(2), the server selects it, echoes server_certificate_type = RawPublicKey in EncryptedExtensions, and its Certificate message carries a 44-byte DER SubjectPublicKeyInfo (the Ed25519 key, RFC 8410) in place of the X.509 chain. Everything else is unchanged: the same Ed25519 key signs CertificateVerify, so the client authenticates the exact key it was handed - no security downgrade, just a smaller handshake with nothing to parse. It is purely additive and server-side: a client that does not offer the extension still receives the X.509 certificate, and the server never requests a client certificate.
The whole record - header layout, AEAD nonce, associated-data selection, and sequence-number encryption - is pinned byte-for-byte to an independent reconstruction: HKDF-Expand-Label from stdlib HMAC-SHA256, and AES-128-GCM + AES-ECB from a separate crypto library. A byte-exact match proves every field is assembled the way the RFC specifies. Round-trips across payload sizes and content types, sequence-number reconstruction across a 16-bit rollover, tampered-tag and wrong-epoch rejection, and the replay window are all host-tested (native_dtls).
The framing layer is host-tested separately (native_dtls_hs): the handshake header round-trips and rejects malformed fragments; reassembly is exercised in order, out of order, and with overlapping and duplicate fragments, and its bounds are checked; the ACK message round-trips and rejects malformed input. The cookie's wire format is pinned byte-for-byte to an independent Python-stdlib HMAC-SHA256, and verification is shown to reject a wrong client address, a tampered byte, a truncated cookie, and a stale or future-dated timestamp. The TLS 1.3 messages the DTLS handshake adds - HelloRetryRequest, the cookie extension, and the message_hash transcript wrapper - are host-tested too (native_dtls_tls13), with the HelloRetryRequest pinned byte-exact.
The handshake itself is proven end-to-end (native_dtls_conn): a from-scratch peer, built from the same primitives but driven independently, completes a full handshake against the server. It deprotects the server's epoch-2 flight, verifies the CertificateVerify Ed25519 signature and the server Finished MAC over the real transcript, sends its own Finished, and confirms that both sides install identical application-traffic keys. Any wrong transcript byte, epoch, nonce, or derived secret would fail the AEAD open, the signature check, or the key comparison. The same test drives the HelloRetryRequest path: a ClientHello with no X25519 key_share draws an HRR, and the retry that echoes the cookie completes the handshake over the message_hash || HRR || ClientHello2 transcript; a retry that omits the cookie is rejected. A ClientHello that does not offer TLS 1.3 is rejected with the right alert. The retransmission timer (§5.8) is driven off a controllable clock: the flight is re-sent with fresh record sequence numbers once the PTO elapses and the client completes the handshake from that retransmission, the timeout doubles up to the cap and abandons the handshake at the retransmit ceiling, an ACK covering the flight stops the timer, and a retransmitted client Finished is re-acknowledged.
Most importantly, the handshake is checked against a real reference implementation: the wolfSSL DTLS 1.3 client completes a handshake and an application-data exchange with the server (`test/servers/dtls_wolfssl`), once leading with its default group so the server drives a HelloRetryRequest to X25519, and once offering X25519 directly. This is the strongest evidence the wire format is right, and it caught three DTLS-vs-TLS conformance bugs the self-referential KATs could not - the legacy_cookie field, the DTLS version codepoints, and the dtls13 HKDF-Expand-Label prefix (RFC 9147 §5.9), all now fixed (see BUGS.md). With PC_ENABLE_TLS_RPK, a fourth run drives the wolfSSL client in RawPublicKey-only mode (--rpk): it offers server_certificate_type = RawPublicKey and nothing else, so it rejects an X.509 answer - a completed handshake proves the server's RFC 7250 SubjectPublicKeyInfo is wire-conformant. The handshake is also checked against a second, unrelated reference stack - the Oryx CycloneSSL DTLS 1.3 client (`test/servers/cyclone_dtls`) completes the same handshake + application-data round trip, both with a plain X.509 certificate and with a RawPublicKey (its RFC 7250 verify callback fires only when the server actually sends a bare SubjectPublicKeyInfo). Two independent from-scratch implementations - wolfSSL and CycloneSSL - agreeing on the wire is far stronger than either alone. DTLS 1.3 derives every secret and record key with the dtls13 prefix rather than TLS 1.3's tls13; this library models that as a Tls13Kdf variant bound once into the key schedule, so the record layer and the handshake cannot disagree on it.
| Area | Standard | Status |
|---|---|---|
| DTLSPlaintext / DTLSCiphertext | RFC 9147 §4 | Implemented - unified header build/parse, DTLSPlaintext build/parse |
| Record AEAD (AEAD_AES_128_GCM) | RFC 9147 §4.2, SP 800-38D | Implemented - TLS 1.3 nonce (§4.2.2), header-as-AAD; reuses pc_quic_aead |
| Sequence-number encryption | RFC 9147 §4.2.3 | Implemented - AES-ECB(sn_key, ct[0..15]) mask; sn key via HKDF-Expand-Label |
| Sequence-number reconstruction | RFC 9147 §4.2.2 | Implemented - closest-to-expected (RFC 9000 App. A.3) |
| Anti-replay window | RFC 9147 §4.5.1 | Implemented - 64-record sliding window |
| Record-key derivation | RFC 8446 §7.3, RFC 5869 | Implemented - key / iv / sn = HKDF-Expand-Label(traffic secret) |
| Key-schedule label prefix | RFC 9147 §5.9 | Implemented - dtls13 (not tls13), a Tls13Kdf variant bound to the schedule |
| Handshake header + reassembly | RFC 9147 §5.2, §5.4 | Implemented - 12-byte header build/parse, overlap-tolerant reassembly |
| ACK message | RFC 9147 §7 | Implemented - content type 26; the client Finished is acknowledged (§5.8.3) |
| HelloRetryRequest cookie | RFC 9147 §5.1 | Implemented - stateless HMAC-SHA256 cookie binding the client address, minted + verified by the state machine |
| Server handshake state machine | RFC 9147 §5-6 | Implemented - full 1-RTT handshake, epoch 0→2→3; wolfSSL + CycloneSSL DTLS 1.3 interop |
| HelloRetryRequest exchange | RFC 9147 §5.1 | Implemented - group renegotiation to X25519; cookie round-trip + message_hash transcript; wolfSSL interop |
| ACK / timeout retransmission | RFC 9147 §5.8 | Implemented - PTO timer (exponential backoff, capped, retransmit ceiling), ACK cancellation, retransmitted-Finished re-ACK |
| Connection ID | RFC 9147 §9 | Roadmap - negotiated via extension; CID records are rejected for now |
| Raw Public Keys | RFC 7250, RFC 8410 | Implemented (PC_ENABLE_TLS_RPK) - server_certificate_type negotiation, Ed25519 SPKI Certificate; wolfSSL + CycloneSSL --rpk interop |
The handshake DTLS carries is the TLS 1.3 already documented for HTTP/3 (TLS_AES_128_GCM_SHA256 + X25519 + Ed25519); see SSH.md for the shared crypto primitives and STANDARDS.md for the full RFC map.