Skip to main content

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}