ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
dtls_record.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file pc_dtls_record.cpp
6 * @brief DTLS 1.3 record layer (RFC 9147 §4). See pc_dtls_record.h.
7 */
8
10
11#if PC_ENABLE_DTLS
12
15#include "server/mmgr/secure.h" // the secure pool: header-protection key schedule
16#include <string.h>
17
18namespace
19{
20// Unified-header first-byte fixed pattern and flag bits (RFC 9147 §4, Figure 3): 0 0 1 C S L E E.
21const uint8_t DTLS_UH_FIXED = 0x20; // 001x xxxx
22const uint8_t DTLS_UH_FIXED_MASK = 0xE0;
23const uint8_t DTLS_UH_CID = 0x10; // C: connection id present
24const uint8_t DTLS_UH_SEQ16 = 0x08; // S: 16-bit (vs 8-bit) sequence number
25const uint8_t DTLS_UH_LENGTH = 0x04; // L: length present
26const uint8_t DTLS_UH_EPOCH_MASK = 0x03;
27
28// Build the AEAD nonce: the 64-bit sequence number, right-aligned in the 12-byte IV, XOR the IV
29// (RFC 9147 §4.2.2 / RFC 8446 §5.3; the epoch is NOT mixed in). Same construction as QUIC.
30void build_nonce(const uint8_t iv[12], uint64_t seq, uint8_t nonce[12])
31{
32 memcpy(nonce, iv, 12);
33 for (int i = 0; i < 8; i++)
34 {
35 nonce[11 - i] ^= (uint8_t)(seq >> (8 * i));
36 }
37}
38
39// Reconstruct the full sequence number from its truncated on-wire bits (RFC 9147 §4.2.2, using the
40// RFC 9000 Appendix A.3 packet-number decoding: the candidate closest to `expected`).
41uint64_t seq_decode(uint64_t expected, uint64_t truncated, unsigned bits)
42{
43 // GCOVR_EXCL_START unreachable: seq_decode's only caller (below) always passes bits = seq_len * 8
44 // where seq_len is 1 or 2 (the unified header's S bit selects an 8- or 16-bit sequence number), so
45 // bits is always 8 or 16 - never 0 and never >= 64. Kept as a defensive guard against a future caller.
46 if (bits == 0 || bits >= 64)
47 {
48 return truncated;
49 }
50 // GCOVR_EXCL_STOP
51 uint64_t win = (uint64_t)1 << bits;
52 uint64_t hwin = win >> 1;
53 uint64_t mask = win - 1;
54 uint64_t candidate = (expected & ~mask) | (truncated & mask);
55 if (candidate + hwin <= expected && candidate + win > candidate)
56 {
57 return candidate + win;
58 }
59 if (candidate > expected + hwin && candidate >= win)
60 {
61 return candidate - win;
62 }
63 return candidate;
64}
65} // namespace
66
67void pc_dtls_record_keys_derive(DtlsRecordKeys *out, DtlsCipher cipher, uint16_t epoch, const uint8_t secret[32])
68{
69 out->cipher = cipher;
70 out->epoch = epoch;
71 // AEAD_AES_128_GCM: 16-byte key, 12-byte IV, 16-byte sequence-number key. The DTLS 1.3 variant
72 // carries the "dtls13" HKDF-Expand-Label prefix (RFC 9147 §4.2.3 / §5.9).
73 // The key becomes a keyed context here and the raw bytes are wiped; nothing downstream wants them.
74 // The pool cannot come up short here: PC_SECURE_ARENA_SIZE is the SUM of every PC_WORK_*, a
75 // strict upper bound rather than a deepest-nest estimate, so a borrow inside that budget always
76 // succeeds. The borrow also wipes on release, on every exit path.
77 SecureBorrow kb(PC_AES128GCM_KEY_LEN, 8);
78 uint8_t *k = kb.span().buf;
79 pc_tls13_kdf_expand_label(&DTLS13_KDF, secret, "key", k, PC_AES128GCM_KEY_LEN);
80 pc_aes128gcm_key_init(out->gcm, k);
81 pc_tls13_kdf_expand_label(&DTLS13_KDF, secret, "iv", out->iv, sizeof(out->iv));
82 SecureBorrow snb(PC_AES128GCM_KEY_LEN, 8);
83 uint8_t *snk = snb.span().buf;
84 pc_tls13_kdf_expand_label(&DTLS13_KDF, secret, "sn", snk, PC_AES128GCM_KEY_LEN);
85 pc_aes128_init(reinterpret_cast<pc_aes128 *>(out->sn_key), snk);
86}
87
88// ---------------------------------------------------------------------------
89// DTLSPlaintext
90// ---------------------------------------------------------------------------
91
92size_t pc_dtls_plaintext_build(uint8_t content_type, uint16_t epoch, uint64_t seq, const uint8_t *fragment,
93 size_t frag_len, uint8_t *out, size_t out_cap)
94{
95 size_t total = PC_DTLS_PLAINTEXT_HDR_LEN + frag_len;
96 if (total > out_cap || frag_len > 0xFFFF)
97 {
98 return 0;
99 }
100 out[0] = content_type;
101 out[1] = (uint8_t)(PC_DTLS_LEGACY_VERSION >> 8);
102 out[2] = (uint8_t)PC_DTLS_LEGACY_VERSION;
103 out[3] = (uint8_t)(epoch >> 8);
104 out[4] = (uint8_t)epoch;
105 out[5] = (uint8_t)(seq >> 40); // 48-bit sequence number, big-endian
106 out[6] = (uint8_t)(seq >> 32);
107 out[7] = (uint8_t)(seq >> 24);
108 out[8] = (uint8_t)(seq >> 16);
109 out[9] = (uint8_t)(seq >> 8);
110 out[10] = (uint8_t)seq;
111 out[11] = (uint8_t)(frag_len >> 8);
112 out[12] = (uint8_t)frag_len;
113 if (frag_len)
114 {
115 memcpy(out + PC_DTLS_PLAINTEXT_HDR_LEN, fragment, frag_len);
116 }
117 return total;
118}
119
120size_t pc_dtls_plaintext_parse(const uint8_t *rec, size_t rec_len, DtlsPlaintext *out)
121{
122 if (rec_len < PC_DTLS_PLAINTEXT_HDR_LEN)
123 {
124 return 0;
125 }
126 if (rec[1] != (uint8_t)(PC_DTLS_LEGACY_VERSION >> 8) || rec[2] != (uint8_t)PC_DTLS_LEGACY_VERSION)
127 {
128 return 0; // wrong legacy_version
129 }
130 out->content_type = rec[0];
131 out->epoch = (uint16_t)(((uint16_t)rec[3] << 8) | rec[4]);
132 out->seq = ((uint64_t)rec[5] << 40) | ((uint64_t)rec[6] << 32) | ((uint64_t)rec[7] << 24) |
133 ((uint64_t)rec[8] << 16) | ((uint64_t)rec[9] << 8) | (uint64_t)rec[10];
134 size_t length = ((size_t)rec[11] << 8) | rec[12];
135 if (PC_DTLS_PLAINTEXT_HDR_LEN + length > rec_len)
136 {
137 return 0;
138 }
139 out->fragment = rec + PC_DTLS_PLAINTEXT_HDR_LEN;
140 out->frag_len = length;
141 return PC_DTLS_PLAINTEXT_HDR_LEN + length;
142}
143
144// ---------------------------------------------------------------------------
145// DTLSCiphertext
146// ---------------------------------------------------------------------------
147
148size_t pc_dtls_ciphertext_protect(DtlsRecordKeys &keys, uint64_t seq, uint8_t content_type, const uint8_t *plaintext,
149 size_t pt_len, uint8_t *out, size_t out_cap, const uint8_t *cid, size_t cid_len)
150{
151 if (keys.cipher != DtlsCipher::AES_128_GCM_SHA256)
152 {
153 return 0;
154 }
155 if (cid_len > PC_DTLS_CID_MAX || (cid_len && !cid))
156 {
157 return 0;
158 }
159 // Unified header: [C] connection id, S=1 (16-bit seq), L=1 (length). hdr = byte0 || [cid] || seq16 ||
160 // length16. The CID (RFC 9146 / RFC 9147 §9) sits between the first byte and the sequence number.
161 const size_t hdr_len = 1 + cid_len + 2 + 2;
162 size_t inner_len = pt_len + 1; // DTLSInnerPlaintext = plaintext || content_type
163 size_t enc_len = inner_len + PC_DTLS_TAG_LEN; // AEAD ciphertext || tag
164 size_t total = hdr_len + enc_len;
165 if (total > out_cap)
166 {
167 return 0;
168 }
169
170 uint8_t flags = (uint8_t)(DTLS_UH_FIXED | DTLS_UH_SEQ16 | DTLS_UH_LENGTH | (keys.epoch & DTLS_UH_EPOCH_MASK));
171 if (cid_len)
172 {
173 flags |= DTLS_UH_CID;
174 }
175 out[0] = flags;
176 if (cid_len)
177 {
178 memcpy(out + 1, cid, cid_len);
179 }
180 size_t seq_off = 1 + cid_len;
181 out[seq_off] = (uint8_t)(seq >> 8); // plaintext sequence number (this header form is the AEAD AAD)
182 out[seq_off + 1] = (uint8_t)seq;
183 out[seq_off + 2] = (uint8_t)(enc_len >> 8);
184 out[seq_off + 3] = (uint8_t)enc_len;
185
186 // Assemble the inner plaintext where it will be sealed (seal permits out == pt).
187 memcpy(out + hdr_len, plaintext, pt_len);
188 out[hdr_len + pt_len] = content_type;
189
190 uint8_t nonce[12];
191 build_nonce(keys.iv, seq, nonce);
192 // AAD = the whole unified header (including any connection id) carrying the plaintext sequence
193 // number (before §4.2.3 encryption).
194 pc_aes128gcm_seal(reinterpret_cast<pc_aes128gcm_key *>(keys.gcm), nonce, out, hdr_len, out + hdr_len, inner_len,
195 out + hdr_len, out + hdr_len + inner_len);
196
197 // Encrypt the sequence number (RFC 9147 §4.2.3): mask = AES-ECB(sn_key, ciphertext[0..15]).
198 // enc_len = inner_len + 16 >= 17, so the 16-byte sample is always available.
199 // The sequence-number context is already keyed and lives in the key material; rebuilding it here
200 // costs ~556 cycles per record plus a pool borrow and wipe, independent of record size.
201 uint8_t mask[16];
202 pc_aes128_encrypt_block(reinterpret_cast<pc_aes128 *>(keys.sn_key), out + hdr_len, mask);
203 out[seq_off] ^= mask[0];
204 out[seq_off + 1] ^= mask[1];
205 return total;
206}
207
208bool pc_dtls_ciphertext_unprotect(DtlsRecordKeys &keys, uint64_t next_seq, const uint8_t *rec, size_t rec_len,
209 uint8_t *out, size_t out_cap, DtlsCiphertext *info, const uint8_t *expected_cid,
210 size_t expected_cid_len)
211{
212 if (keys.cipher != DtlsCipher::AES_128_GCM_SHA256 || rec_len < 1)
213 {
214 return false;
215 }
216 if (expected_cid_len > PC_DTLS_CID_MAX)
217 {
218 return false;
219 }
220 uint8_t b0 = rec[0];
221 if ((b0 & DTLS_UH_FIXED_MASK) != DTLS_UH_FIXED)
222 {
223 return false; // top 3 bits must be 001
224 }
225 if ((b0 & DTLS_UH_EPOCH_MASK) != (keys.epoch & DTLS_UH_EPOCH_MASK))
226 {
227 return false; // wrong epoch keys for this record
228 }
229
230 size_t off = 1;
231 if (b0 & DTLS_UH_CID)
232 {
233 // A connection-id record: a CID must have been negotiated for this direction, and it must be
234 // ours (the CID is not length-prefixed on the wire - its length is known only from negotiation).
235 if (expected_cid_len == 0 || off + expected_cid_len > rec_len ||
236 memcmp(rec + off, expected_cid, expected_cid_len) != 0)
237 {
238 return false;
239 }
240 off += expected_cid_len;
241 }
242 else if (expected_cid_len != 0)
243 {
244 return false; // a CID was negotiated for this direction but the record carries none
245 }
246
247 size_t seq_len = (b0 & DTLS_UH_SEQ16) ? 2 : 1;
248 if (off + seq_len > rec_len)
249 {
250 return false;
251 }
252 size_t seq_off = off;
253 off += seq_len;
254
255 size_t enc_len;
256 if (b0 & DTLS_UH_LENGTH)
257 {
258 if (off + 2 > rec_len)
259 {
260 return false;
261 }
262 enc_len = ((size_t)rec[off] << 8) | rec[off + 1];
263 off += 2;
264 }
265 else
266 {
267 enc_len = rec_len - off; // to end of datagram
268 }
269 if (off + enc_len > rec_len || enc_len < 16 || enc_len < PC_DTLS_TAG_LEN + 1)
270 {
271 return false; // need >= 16 bytes for the SN sample and >= tag + one inner byte
272 }
273
274 const uint8_t *enc = rec + off;
275 size_t hdr_len = off; // unified header length (including any connection id)
276
277 // Copy the header so we can write the decrypted sequence number into the AEAD AAD form.
278 uint8_t hdr[1 + PC_DTLS_CID_MAX + 4];
279 memcpy(hdr, rec, hdr_len);
280
281 // Decrypt the sequence number (RFC 9147 §4.2.3).
282 // The sequence-number context is already keyed and lives in the key material; rebuilding it here
283 // costs ~556 cycles per record plus a pool borrow and wipe, independent of record size.
284 uint8_t mask[16];
285 pc_aes128_encrypt_block(reinterpret_cast<pc_aes128 *>(keys.sn_key), enc, mask);
286 uint64_t trunc = 0;
287 for (size_t i = 0; i < seq_len; i++)
288 {
289 hdr[seq_off + i] ^= mask[i];
290 trunc = (trunc << 8) | hdr[seq_off + i];
291 }
292 uint64_t full_seq = seq_decode(next_seq, trunc, (unsigned)(seq_len * 8));
293
294 // Reject a record too short to hold a tag BEFORE subtracting. enc_len comes off the wire, and the
295 // detached-tag api no longer range-checks a combined length on the caller's behalf, so this would
296 // otherwise wrap to a huge size_t. The `> out_cap` test below happens to catch the wrapped value,
297 // but that is an accident of a downstream comparison, not a check.
298 if (enc_len < PC_DTLS_TAG_LEN)
299 {
300 return false;
301 }
302 size_t inner_len = enc_len - PC_DTLS_TAG_LEN; // == the ciphertext length the AEAD wants
303 if (inner_len > out_cap)
304 {
305 return false;
306 }
307
308 uint8_t nonce[12];
309 build_nonce(keys.iv, full_seq, nonce);
310 const size_t pt_len = inner_len;
311 if (!pc_aes128gcm_open(reinterpret_cast<pc_aes128gcm_key *>(keys.gcm), nonce, hdr, hdr_len, enc, pt_len,
312 enc + pt_len, out))
313 {
314 return false;
315 }
316
317 // Strip zero padding: the last non-zero byte of the inner plaintext is the content type (RFC 8446 §5.2).
318 size_t n = inner_len;
319 while (n > 0 && out[n - 1] == 0)
320 {
321 n--;
322 }
323 if (n == 0)
324 {
325 return false; // no content type -> invalid record
326 }
327 info->content_type = out[n - 1];
328 info->pt_len = n - 1;
329 info->seq = full_seq;
330 info->epoch = keys.epoch;
331 return true;
332}
333
334// ---------------------------------------------------------------------------
335// Anti-replay sliding window (RFC 9147 §4.5.1)
336// ---------------------------------------------------------------------------
337
338void pc_dtls_replay_init(DtlsReplayWindow *w)
339{
340 w->highest = 0;
341 w->bitmap = 0;
342 w->seeded = false;
343}
344
345bool pc_dtls_replay_check(const DtlsReplayWindow *w, uint64_t seq)
346{
347 if (!w->seeded || seq > w->highest)
348 {
349 return true; // first record, or ahead of the window
350 }
351 uint64_t diff = w->highest - seq;
352 if (diff >= 64)
353 {
354 return false; // older than the window
355 }
356 return ((w->bitmap >> diff) & 1u) == 0; // set bit => already seen (replay)
357}
358
359void pc_dtls_replay_mark(DtlsReplayWindow *w, uint64_t seq)
360{
361 if (!w->seeded)
362 {
363 w->seeded = true;
364 w->highest = seq;
365 w->bitmap = 1; // bit 0 = highest
366 return;
367 }
368 if (seq > w->highest)
369 {
370 uint64_t shift = seq - w->highest;
371 w->bitmap = (shift >= 64) ? 1u : ((w->bitmap << shift) | 1u);
372 w->highest = seq;
373 return;
374 }
375 uint64_t diff = w->highest - seq;
376 if (diff < 64)
377 {
378 w->bitmap |= ((uint64_t)1 << diff);
379 }
380}
381
382#endif // PC_ENABLE_DTLS
AES-128 block cipher + AEAD_AES_128_GCM (RFC 5116 / NIST SP 800-38D).
A secure borrow whose acquire and release are one call each.
Definition secure.h:153
uint32_t mask(uint8_t width)
Mask of width low bits (width 32 handled without a 32-bit shift, which is UB).
Definition crc.h:61
Secure pool accessor - borrows that hold key material.
TLS 1.3 key schedule (RFC 8446 sec 7.1) for the QUIC handshake.