ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
quic_crypto.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_quic_crypto.cpp
6 * @brief QUIC packet protection and Initial secrets (see pc_quic_crypto.h).
7 */
8
10
11#if PC_ENABLE_HTTP3
12
14#include "crypto/kdf/hkdf.h"
16#include "server/mmgr/secure.h" // the secure pool: header-protection key schedule
17#include <string.h>
18
19namespace
20{
21// RFC 9001 sec 5.2: the version-1 Initial salt.
22const uint8_t INITIAL_SALT[20] = {0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17,
23 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a};
24
25// RFC 9001 sec 5.8: the version-1 Retry integrity key and nonce.
26const uint8_t RETRY_KEY[16] = {0xbe, 0x0c, 0x69, 0x0b, 0x9f, 0x66, 0x57, 0x5a,
27 0x1d, 0x76, 0x6b, 0x54, 0xe3, 0x68, 0xc8, 0x4e};
28const uint8_t RETRY_NONCE[12] = {0x46, 0x15, 0x99, 0xd3, 0x5d, 0x63, 0x2b, 0xf2, 0x23, 0x98, 0x25, 0xbb};
29
30// Build the AEAD nonce: the packet number, left-padded to the 12-byte IV width, XOR the IV.
31void build_nonce(const uint8_t iv[12], uint64_t full_pn, uint8_t nonce[12])
32{
33 memcpy(nonce, iv, 12);
34 for (int i = 0; i < 8; i++)
35 {
36 nonce[11 - i] ^= (uint8_t)(full_pn >> (8 * i));
37 }
38}
39} // namespace
40
41void pc_quic_keys_from_secret(const uint8_t secret[PC_HKDF_HASH_LEN], QuicPacketKeys *out)
42{
43 // RFC 9001 sec 5.1: every encryption level's packet keys are these three Expand-Labels of the
44 // level's traffic secret (the Initial secrets below, or the TLS handshake / application secrets).
45 // The key becomes a context here and the raw bytes are wiped: nothing downstream needs them.
46 // The pool cannot come up short here: PC_SECURE_ARENA_SIZE is the SUM of every PC_WORK_*, a
47 // strict upper bound rather than a deepest-nest estimate, so a borrow inside that budget always
48 // succeeds. The borrow also wipes on release, on every exit path.
49 SecureBorrow kb(PC_AES128GCM_KEY_LEN, 8);
50 uint8_t *k = kb.span().buf;
51 pc_hkdf_expand_label(secret, "quic key", k, PC_AES128GCM_KEY_LEN);
52 pc_aes128gcm_key_init(out->gcm, k);
53 pc_hkdf_expand_label(secret, "quic iv", out->iv, sizeof(out->iv));
54 SecureBorrow hpb(PC_AES128GCM_KEY_LEN, 8);
55 uint8_t *hpk = hpb.span().buf;
56 pc_hkdf_expand_label(secret, "quic hp", hpk, PC_AES128GCM_KEY_LEN);
57 pc_aes128_init(reinterpret_cast<pc_aes128 *>(out->hp), hpk);
58}
59
60void pc_quic_derive_initial_secrets(const uint8_t *dcid, size_t dcid_len, QuicInitialSecrets *out)
61{
62 uint8_t initial_secret[PC_HKDF_HASH_LEN];
63 pc_hkdf_extract(INITIAL_SALT, sizeof(INITIAL_SALT), dcid, dcid_len, initial_secret);
64
65 uint8_t client_secret[PC_HKDF_HASH_LEN];
66 uint8_t server_secret[PC_HKDF_HASH_LEN];
67 pc_hkdf_expand_label(initial_secret, "client in", client_secret, sizeof(client_secret));
68 pc_hkdf_expand_label(initial_secret, "server in", server_secret, sizeof(server_secret));
69
70 pc_quic_keys_from_secret(client_secret, &out->client);
71 pc_quic_keys_from_secret(server_secret, &out->server);
72}
73
74size_t pc_quic_packet_protect(uint8_t *pkt, size_t cap, size_t pn_offset, uint8_t pn_len, uint64_t full_pn,
75 size_t payload_len, QuicPacketKeys &keys, bool is_long)
76{
77 if (pn_len < 1 || pn_len > 4)
78 {
79 return 0;
80 }
81 size_t hdr_len = pn_offset + pn_len;
82 size_t total = hdr_len + payload_len + PC_AES128GCM_TAG_LEN;
83 if (total > cap)
84 {
85 return 0;
86 }
87
88 // AEAD-seal the payload in place; associated data is the unprotected header.
89 uint8_t nonce[12];
90 build_nonce(keys.iv, full_pn, nonce);
91 pc_aes128gcm_seal(reinterpret_cast<pc_aes128gcm_key *>(keys.gcm), nonce, pkt, hdr_len, pkt + hdr_len, payload_len,
92 pkt + hdr_len, pkt + hdr_len + payload_len);
93
94 // Header protection (RFC 9001 sec 5.4): sample 16 bytes at pn_offset + 4 (always inside the
95 // ciphertext because pn_len <= 4), AES-ECB it under hp, mask the low first-byte bits and the PN.
96 // The header-protection context is already keyed and lives in the key material; rebuilding it here
97 // costs ~556 cycles per packet plus a pool borrow and wipe. (The ECB block itself is ~7,842 - a
98 // single HW-AES operation is expensive on this die, and that is the bigger target.)
99 uint8_t mask[16];
100 pc_aes128_encrypt_block(reinterpret_cast<pc_aes128 *>(keys.hp), pkt + pn_offset + 4, mask);
101
102 pkt[0] ^= mask[0] & (is_long ? 0x0f : 0x1f);
103 for (uint8_t i = 0; i < pn_len; i++)
104 {
105 pkt[pn_offset + i] ^= mask[1 + i];
106 }
107
108 return total;
109}
110
111size_t pc_quic_packet_unprotect(uint8_t *pkt, size_t pn_offset, size_t length, uint64_t largest_pn,
112 QuicPacketKeys &keys, bool is_long, uint8_t *out, uint64_t *out_pn)
113{
114 // Header protection needs a full 16-byte sample starting at pn_offset + 4, and the AEAD region
115 // must carry at least the 16-byte tag once the (<=4-byte) packet number is removed.
116 if (length < 4 + PC_AES128GCM_TAG_LEN)
117 {
118 return (size_t)-1;
119 }
120
121 // The header-protection context is already keyed and lives in the key material; building one here
122 // would cost ~8,400 cycles to encrypt sixteen bytes.
123 uint8_t mask[16];
124 pc_aes128_encrypt_block(reinterpret_cast<pc_aes128 *>(keys.hp), pkt + pn_offset + 4, mask);
125
126 pkt[0] ^= mask[0] & (is_long ? 0x0f : 0x1f);
127 uint8_t pn_len = (uint8_t)((pkt[0] & 0x03) + 1);
128
129 uint64_t truncated_pn = 0;
130 for (uint8_t i = 0; i < pn_len; i++)
131 {
132 pkt[pn_offset + i] ^= mask[1 + i];
133 truncated_pn = (truncated_pn << 8) | pkt[pn_offset + i];
134 }
135 uint64_t full_pn = pc_quic_pn_decode(largest_pn, truncated_pn, (uint8_t)(pn_len * 8));
136 if (out_pn)
137 {
138 *out_pn = full_pn;
139 }
140
141 size_t hdr_len = pn_offset + pn_len;
142 size_t ct_len = length - pn_len; // ciphertext + tag
143 // A record too short to hold a tag is rejected HERE. The detached-tag api takes the ciphertext
144 // length and the tag pointer separately, so it no longer has a combined length to range-check on
145 // the caller's behalf - and ct_len comes off the wire, so the subtraction below would wrap to a
146 // huge size_t rather than fail. This guard used to live inside pc_aes128gcm_open().
147 if (ct_len < PC_AES128GCM_TAG_LEN)
148 {
149 return (size_t)-1;
150 }
151 uint8_t nonce[12];
152 build_nonce(keys.iv, full_pn, nonce);
153 const size_t pt_len = ct_len - PC_AES128GCM_TAG_LEN;
154 if (!pc_aes128gcm_open(reinterpret_cast<pc_aes128gcm_key *>(keys.gcm), nonce, pkt, hdr_len, pkt + hdr_len, pt_len,
155 pkt + hdr_len + pt_len, out))
156 {
157 return (size_t)-1;
158 }
159
160 return ct_len - PC_AES128GCM_TAG_LEN;
161}
162
163void pc_quic_retry_integrity_tag(const uint8_t *odcid, size_t odcid_len, const uint8_t *retry, size_t retry_len,
164 uint8_t tag[16])
165{
166 // AAD = Retry Pseudo-Packet: ODCID Length (1 byte) || ODCID || Retry packet (sans tag).
167 // Assemble it into a scratch buffer; a Retry is small (short token), so a fixed cap suffices.
168 uint8_t aad[1 + QUIC_MAX_CID_LEN + 256];
169 size_t p = 0;
170 aad[p++] = (uint8_t)odcid_len;
171 if (odcid_len > QUIC_MAX_CID_LEN || 1 + odcid_len + retry_len > sizeof(aad))
172 {
173 memset(tag, 0, 16);
174 return;
175 }
176 memcpy(aad + p, odcid, odcid_len);
177 p += odcid_len;
178 memcpy(aad + p, retry, retry_len);
179 p += retry_len;
180
181 // Empty plaintext: seal writes only the 16-byte tag.
182 { // fixed RFC 9001 key, once per Retry packet - not worth a resident context
184 pc_aes128gcm_key *rk = pc_aes128gcm_key_init(rk_b.span().buf, RETRY_KEY);
185 pc_aes128gcm_seal(rk, RETRY_NONCE, aad, p, nullptr, 0, tag, tag);
186 pc_aes128gcm_key_wipe(rk);
187 }
188}
189
190#endif // PC_ENABLE_HTTP3
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
HKDF-SHA256 (RFC 5869) and TLS 1.3 HKDF-Expand-Label (RFC 8446 sec 7.1).
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
#define PC_WORK_AES128GCM
Secure pool accessor - borrows that hold key material.