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//! 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}