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