phantom_protocol/crypto/header_protection.rs
1//! Header protection (QUIC RFC 9001 §5.4) — the per-packet mask that hides the
2//! header fields from a passive on-path observer. Since WIRE v6 the WHOLE
3//! 15-byte header is masked — `version ‖ packet_number ‖ flags ‖ stream_id ‖
4//! epoch ‖ path_id`, the bytes at wire offset `[0..15]` (`HP_MASK_LEN = 15`; no
5//! constant cleartext byte). (Was `[1..15]`/14 bytes in WIRE v5, `[33..47]` in
6//! v4 — see the `HP_MASK_LEN` doc for the per-version history.)
7//!
8//! The mask is `cipher(hp_key, sample)` where `sample` is the first 16 bytes of
9//! the packet's AEAD ciphertext (the tag is always present, so a sample exists
10//! even for an empty payload). The sample is drawn from the *payload* ciphertext,
11//! which is never masked — so there is **no circular dependency** (cleaner than
12//! QUIC, whose packet number sits inside the sampled span). The AEAD AAD remains
13//! the *cleartext* header image, so HP is an orthogonal outer wrapping: a wire
14//! mutation of the masked region unmasks to a wrong header → wrong AAD → AEAD
15//! fails. It adds **no new decryption oracle**.
16//!
17//! ## Why the HP key is session-stable (NOT epoch-rotated)
18//!
19//! QUIC RFC 9001 §6.1 keeps the header-protection key **constant across key
20//! updates**, and so do we. `epoch` lives *inside* the masked region, so the
21//! receiver must remove header protection **before** it knows the packet's
22//! epoch. If the hp key rotated per epoch, a receiver one epoch behind the
23//! sender (the exact case `Session::decrypt_packet_accepting_rekey` exists to
24//! handle) could not pick the right hp key → garbage epoch → the catch-up path
25//! can't read `header.epoch` → rekey-catchup deadlock. Deriving the hp keys once
26//! from the initial session secret and holding them stable avoids that. Forward
27//! secrecy of *confidentiality* is unaffected: the hp key masks only header
28//! metadata, never payload — the AEAD keys still ratchet with full FS.
29//!
30//! ## FIPS
31//!
32//! Header protection is anti-DPI obfuscation, **not** confidentiality (the same
33//! posture as the `mimicry` transport's outer record layer — see Security
34//! Invariant #3). Under
35//! `--features fips` the AES mask still routes through the FIPS substrate
36//! (`aws_lc_rs::cipher` AES-256-ECB) so the masking primitive stays inside the
37//! validated module; the ChaCha20 mask is unreachable there because the cipher
38//! suite is pinned to AES-256-GCM at session construction.
39
40use crate::crypto::adaptive_crypto::CipherSuite;
41use crate::crypto::kdf::derive_key_32;
42use crate::errors::CoreError;
43use zeroize::Zeroize;
44
45/// Bytes of the packet header protected by HP — WIRE v6 (anti-fingerprint): the
46/// contiguous region at wire offset `[0..15]` — the WHOLE 15-byte header,
47/// `version(1) ‖ packet_number(8) ‖ flags(2) ‖ stream_id(2) ‖ epoch(1) ‖
48/// path_id(1)`. The version byte is now masked too (no constant cleartext byte);
49/// the inner `session_id` is off-wire and routing is by the outer (rotating)
50/// ConnId. (Was `[1..15]`/14 in v5, `[33..47]` in v4.) The mask itself is a full
51/// 16-byte block; the first `HP_MASK_LEN` bytes are applied.
52pub const HP_MASK_LEN: usize = 15;
53
54/// Bytes of AEAD ciphertext sampled to seed the mask cipher — one AES block
55/// (RFC 9001 §5.4.2). Always available: every data-plane packet carries at least
56/// the 16-byte AEAD tag.
57pub const HP_SAMPLE_LEN: usize = 16;
58
59/// KDF labels for the per-direction header-protection keys. Domain-separated
60/// from the AEAD `phantom-{aes,cc20}-{send,recv}-v1` keys and the
61/// `phantom-nonce-pfx-v1` prefix, so the hp key reveals nothing about the AEAD
62/// key and vice-versa.
63const HP_SEND_LABEL: &str = "phantom-hp-send-v1";
64const HP_RECV_LABEL: &str = "phantom-hp-recv-v1";
65
66/// Per-direction, **session-stable** header-protection keys plus the negotiated
67/// cipher suite (which selects AES-256-ECB vs ChaCha20 for the mask). Derived
68/// once from the initial session secret and held for the session's lifetime; see
69/// the module docs for why this does not rotate with the AEAD epoch.
70pub struct HeaderProtector {
71 suite: CipherSuite,
72 hp_send: [u8; 32],
73 hp_recv: [u8; 32],
74}
75
76impl HeaderProtector {
77 /// Derive the per-direction HP keys from the initial session secret. `swap`
78 /// (= the session's `is_server` flag) swaps send/recv so one peer's `send`
79 /// key equals the other peer's `recv` key — mirroring the AEAD key layout in
80 /// `CryptoSession::build`.
81 pub fn derive(suite: CipherSuite, initial_secret: &[u8; 32], swap: bool) -> Self {
82 let hp_a = derive_key_32(HP_SEND_LABEL, initial_secret);
83 let hp_b = derive_key_32(HP_RECV_LABEL, initial_secret);
84 let (hp_send, hp_recv) = if swap { (hp_b, hp_a) } else { (hp_a, hp_b) };
85 Self {
86 suite,
87 hp_send,
88 hp_recv,
89 }
90 }
91
92 /// The negotiated cipher suite (drives the mask primitive). Exposed for
93 /// diagnostics / tests.
94 #[inline]
95 pub fn cipher_suite(&self) -> CipherSuite {
96 self.suite
97 }
98
99 /// Compute the mask for an **outbound** packet from its ciphertext `sample`.
100 #[inline]
101 pub fn mask_send(
102 &self,
103 sample: &[u8; HP_SAMPLE_LEN],
104 ) -> Result<[u8; HP_SAMPLE_LEN], CoreError> {
105 compute_mask(self.suite, &self.hp_send, sample)
106 }
107
108 /// Compute the mask for an **inbound** packet from its ciphertext `sample`.
109 #[inline]
110 pub fn mask_recv(
111 &self,
112 sample: &[u8; HP_SAMPLE_LEN],
113 ) -> Result<[u8; HP_SAMPLE_LEN], CoreError> {
114 compute_mask(self.suite, &self.hp_recv, sample)
115 }
116}
117
118impl Drop for HeaderProtector {
119 fn drop(&mut self) {
120 self.hp_send.zeroize();
121 self.hp_recv.zeroize();
122 }
123}
124
125/// RFC 9001 §5.4.3 (AES) / §5.4.4 (ChaCha20) mask derivation.
126#[inline]
127fn compute_mask(
128 suite: CipherSuite,
129 key: &[u8; 32],
130 sample: &[u8; HP_SAMPLE_LEN],
131) -> Result<[u8; HP_SAMPLE_LEN], CoreError> {
132 match suite {
133 CipherSuite::Aes256Gcm => aes256_ecb_block(key, sample),
134 CipherSuite::ChaCha20Poly1305 => chacha20_mask(key, sample),
135 }
136}
137
138/// AES-256-ECB of a single 16-byte block (RFC 9001 §5.4.3) — the raw block
139/// cipher applied to the sample. Default build: the pure-Rust RustCrypto `aes`
140/// crate.
141#[cfg(not(feature = "fips"))]
142fn aes256_ecb_block(key: &[u8; 32], sample: &[u8; 16]) -> Result<[u8; 16], CoreError> {
143 use aes::cipher::generic_array::GenericArray;
144 use aes::cipher::{BlockEncrypt, KeyInit};
145
146 let cipher = aes::Aes256::new(GenericArray::from_slice(key));
147 let mut block = *GenericArray::<u8, aes::cipher::consts::U16>::from_slice(sample);
148 cipher.encrypt_block(&mut block);
149 let mut out = [0u8; 16];
150 out.copy_from_slice(block.as_slice());
151 Ok(out)
152}
153
154/// AES-256-ECB of a single 16-byte block (RFC 9001 §5.4.3). FIPS build: routed
155/// through the FIPS-validated `aws_lc_rs::cipher` ECB primitive so the masking
156/// stays inside the validated module. The `map_err`s are unreachable for the
157/// fixed 32-byte key / 16-byte block, but surfacing a typed error keeps the path
158/// panic-free (no `expect`).
159#[cfg(feature = "fips")]
160fn aes256_ecb_block(key: &[u8; 32], sample: &[u8; 16]) -> Result<[u8; 16], CoreError> {
161 use aws_lc_rs::cipher::{EncryptingKey, UnboundCipherKey, AES_256};
162
163 let unbound = UnboundCipherKey::new(&AES_256, key)
164 .map_err(|_| CoreError::CryptoError("HP: AES-256 key init failed".into()))?;
165 let enc = EncryptingKey::ecb(unbound)
166 .map_err(|_| CoreError::CryptoError("HP: AES-256-ECB init failed".into()))?;
167 let mut block = *sample;
168 enc.encrypt(&mut block)
169 .map_err(|_| CoreError::CryptoError("HP: AES-256-ECB encrypt failed".into()))?;
170 Ok(block)
171}
172
173/// ChaCha20 header-protection mask (RFC 9001 §5.4.4): `counter = sample[0..4]`
174/// as a little-endian `u32`, `nonce = sample[4..16]`; the mask is the first 16
175/// bytes of the ChaCha20 keystream at that `(counter, nonce)`. Used only on the
176/// default build's ChaCha20-Poly1305 suite — unreachable under `--features fips`
177/// (the suite is pinned to AES there), but the code is suite-, not feature-,
178/// gated, so it always compiles.
179fn chacha20_mask(key: &[u8; 32], sample: &[u8; 16]) -> Result<[u8; 16], CoreError> {
180 use chacha20::cipher::generic_array::GenericArray;
181 use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
182
183 let counter = u32::from_le_bytes([sample[0], sample[1], sample[2], sample[3]]);
184 let key_ga = GenericArray::from_slice(key);
185 let nonce_ga = GenericArray::from_slice(&sample[4..16]);
186 let mut cipher = chacha20::ChaCha20::new(key_ga, nonce_ga);
187 // The QUIC "counter" is the 32-bit block counter; ChaCha20 blocks are 64
188 // bytes, so seek to that block before pulling the keystream.
189 cipher.seek(u64::from(counter) * 64);
190 let mut out = [0u8; 16];
191 cipher.apply_keystream(&mut out);
192 Ok(out)
193}
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
197mod tests {
198 use super::*;
199
200 fn hex16(s: &str) -> [u8; 16] {
201 let v = hex::decode(s).unwrap();
202 let mut a = [0u8; 16];
203 a.copy_from_slice(&v);
204 a
205 }
206
207 fn hex32(s: &str) -> [u8; 32] {
208 let v = hex::decode(s).unwrap();
209 let mut a = [0u8; 32];
210 a.copy_from_slice(&v);
211 a
212 }
213
214 /// AES-256-ECB single-block known-answer test from NIST SP 800-38A F.1.5
215 /// (ECB-AES256.Encrypt, block #1). Pins the AES mask primitive against an
216 /// independent standard vector on **both** the default (`aes` crate) and the
217 /// FIPS (`aws-lc-rs`) backend — same algorithm, identical output.
218 #[test]
219 fn aes256_ecb_matches_nist_sp800_38a() {
220 let key = hex32("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4");
221 let pt = hex16("6bc1bee22e409f96e93d7e117393172a");
222 let ct = hex16("f3eed1bdb5d2a03c064b5a7e3db181f8");
223 assert_eq!(aes256_ecb_block(&key, &pt).unwrap(), ct);
224 }
225
226 /// ChaCha20 header-protection KAT from QUIC RFC 9001 §A.5. The mask's first
227 /// five bytes (the span QUIC protects) must equal the RFC's published value.
228 /// ChaCha20 HP is the default build's software-AES fallback suite and is
229 /// never selected under `--features fips`, so this runs non-fips only.
230 #[cfg(not(feature = "fips"))]
231 #[test]
232 fn chacha20_mask_matches_rfc9001_a5() {
233 let key = hex32("25a282b9e82f06f21f488917a4fc8f1b73573685608597d0efcb076b0ab7a7a4");
234 let sample = hex16("5e5cd55c41f69080575d7999c25a5bfb");
235 let mask = chacha20_mask(&key, &sample).unwrap();
236 assert_eq!(&mask[..5], &[0xae, 0xfe, 0xfe, 0x7d, 0x03]);
237 }
238
239 /// The send/recv swap: one peer's `mask_send` must equal the other peer's
240 /// `mask_recv` for the same sample, so a header masked by the sender unmasks
241 /// at the receiver. Mirrors the AEAD send/recv key swap.
242 #[test]
243 fn derive_swaps_send_recv_between_peers() {
244 let secret = [0x33u8; 32];
245 let client = HeaderProtector::derive(CipherSuite::Aes256Gcm, &secret, false);
246 let server = HeaderProtector::derive(CipherSuite::Aes256Gcm, &secret, true);
247 let sample = hex16("000102030405060708090a0b0c0d0e0f");
248
249 assert_eq!(
250 client.mask_send(&sample).unwrap(),
251 server.mask_recv(&sample).unwrap(),
252 "client send-mask must equal server recv-mask"
253 );
254 assert_eq!(
255 server.mask_send(&sample).unwrap(),
256 client.mask_recv(&sample).unwrap(),
257 "server send-mask must equal client recv-mask"
258 );
259 }
260
261 /// The two directions use independent keys, so the send and recv masks for a
262 /// given sample differ (a sanity check that send ≠ recv within one peer).
263 #[test]
264 fn send_and_recv_masks_differ() {
265 let secret = [0x77u8; 32];
266 let hp = HeaderProtector::derive(CipherSuite::Aes256Gcm, &secret, false);
267 let sample = hex16("0f0e0d0c0b0a09080706050403020100");
268 assert_ne!(
269 hp.mask_send(&sample).unwrap(),
270 hp.mask_recv(&sample).unwrap()
271 );
272 }
273
274 /// The ChaCha20 suite produces a different (but still swap-consistent) mask
275 /// than AES — exercises the non-AES branch of `compute_mask` end-to-end.
276 #[cfg(not(feature = "fips"))]
277 #[test]
278 fn chacha_suite_swap_is_consistent() {
279 let secret = [0x9au8; 32];
280 let client = HeaderProtector::derive(CipherSuite::ChaCha20Poly1305, &secret, false);
281 let server = HeaderProtector::derive(CipherSuite::ChaCha20Poly1305, &secret, true);
282 let sample = hex16("aabbccddeeff00112233445566778899");
283 assert_eq!(
284 client.mask_send(&sample).unwrap(),
285 server.mask_recv(&sample).unwrap()
286 );
287 }
288}