// ============================================================================= // OSTP Key Derivation — Kerckhoffs's Principle // ============================================================================= // // All protocol secrets (PSK, obfuscation key, padding parameters) are derived // exclusively from the access key using HKDF-SHA256. There are NO hardcoded // salt strings, protocol identifiers, or magic constants in this module. // // An adversary who reverse-engineers the binary sees only generic HMAC/SHA-256 // operations with no protocol-specific strings to search for. Building a DPI // filter requires knowledge of the access key. // ============================================================================= use sha2::Sha256; use hmac::{Hmac, Mac}; type HmacSha256 = Hmac; // ── HKDF-SHA256 (RFC 5869) ────────────────────────────────────────────────── // Implemented inline to avoid adding a dependency. Uses only hmac + sha2. /// HKDF-Extract: PRK = HMAC-SHA256(salt, IKM) fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] { let mut mac = HmacSha256::new_from_slice(salt).expect("HMAC accepts any key length"); mac.update(ikm); let result = mac.finalize().into_bytes(); let mut prk = [0u8; 32]; prk.copy_from_slice(&result); prk } /// HKDF-Expand: OKM = T(1) || T(2) || ... truncated to `len` bytes. /// T(i) = HMAC-SHA256(PRK, T(i-1) || info || i) fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec { let mut okm = Vec::with_capacity(len); let mut t = Vec::new(); let mut counter = 1u8; while okm.len() < len { let mut mac = HmacSha256::new_from_slice(prk).expect("HMAC accepts any key length"); mac.update(&t); mac.update(info); mac.update(&[counter]); let block = mac.finalize().into_bytes(); t = block.to_vec(); okm.extend_from_slice(&t[..t.len().min(len - okm.len() + t.len()).min(t.len())]); counter = counter.wrapping_add(1); } okm.truncate(len); okm } /// Derive all protocol secrets from a single access key. /// Returns (obfuscation_key, psk, handshake_pad_min, handshake_pad_max). /// /// The derivation uses the access key as both IKM and salt material, /// split into two halves. No fixed strings are used — the access key /// alone determines all derived values. pub struct DerivedSecrets { pub obfuscation_key: [u8; 8], pub psk: [u8; 32], pub handshake_pad_min: usize, pub handshake_pad_max: usize, } pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets { // Split the key hash into two halves for salt/info separation. // This avoids using any hardcoded strings while still providing // domain separation between the derived values. use sha2::Digest; let key_hash = sha2::Sha256::digest(access_key); let salt = &key_hash[..16]; let info_base = &key_hash[16..]; // Extract PRK from access key using its own hash as salt let prk = hkdf_extract(salt, access_key); // Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01 let mut obf_info = info_base.to_vec(); obf_info.push(0x01); let obf_bytes = hkdf_expand(&prk, &obf_info, 8); let mut obfuscation_key = [0u8; 8]; obfuscation_key.copy_from_slice(&obf_bytes); // Derive PSK (32 bytes) — info = key_hash[16..] || 0x02 let mut psk_info = info_base.to_vec(); psk_info.push(0x02); let psk_bytes = hkdf_expand(&prk, &psk_info, 32); let mut psk = [0u8; 32]; psk.copy_from_slice(&psk_bytes); // Derive handshake padding range (2 bytes) — info = key_hash[16..] || 0x03 // This makes different access keys produce different handshake sizes, // preventing DPI from building a universal size-based filter. let mut pad_info = info_base.to_vec(); pad_info.push(0x03); let pad_bytes = hkdf_expand(&prk, &pad_info, 2); // Map to range: min ∈ [16..80], max ∈ [min+48..min+176] let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79 let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175 DerivedSecrets { obfuscation_key, psk, handshake_pad_min: pad_min, handshake_pad_max: pad_max, } } // ── Legacy API (delegates to derive_all_secrets) ───────────────────────────── pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] { derive_all_secrets(access_key).obfuscation_key } pub fn derive_psk(access_key: &[u8]) -> [u8; 32] { derive_all_secrets(access_key).psk } // ── Wire Obfuscation ───────────────────────────────────────────────────────── /// Derives a per-packet mask from the payload following the header. /// Used by both data and handshake packets so every mask is unique. fn derive_payload_mask(key: &[u8; 8], payload: &[u8]) -> [u8; 32] { let mut sample = [0u8; 32]; let take_len = payload.len().min(32); sample[..take_len].copy_from_slice(&payload[..take_len]); let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(&sample); let result = mac.finalize().into_bytes(); let mut mask = [0u8; 32]; mask.copy_from_slice(&result); mask } /// Wire layout for DATA packets: /// [0..4] = session_id XOR mask[0..4] /// [4..12] = nonce XOR mask[4..12] /// [12..] = AEAD ciphertext /// mask = HMAC-SHA256(obf_key, ciphertext_sample[0..32]) /// /// Wire layout for HANDSHAKE packets: /// [0..6] = (session_id || noise_len) XOR mask[0..6] /// [6..] = noise_payload || random_padding /// mask = HMAC-SHA256(obf_key, noise_payload_sample[0..32]) /// /// In both cases, the mask is derived from the payload that follows the header. /// Since the payload contains cryptographically random data (AEAD ciphertext /// or Noise ephemeral key), the mask is unique per packet, making the entire /// wire output indistinguishable from random noise. pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { if !is_handshake && raw.len() >= 12 { let header_len = 12; if raw.len() > header_len { let ciphertext = &raw[header_len..]; let mask = derive_payload_mask(key, ciphertext); for i in 0..12 { raw[i] ^= mask[i]; } } } else if is_handshake && raw.len() > 6 { let payload = &raw[6..]; let mask = derive_payload_mask(key, payload); for i in 0..6 { raw[i] ^= mask[i]; } } } pub fn deobfuscate_header_inplace( header: &mut [u8; 12], ciphertext: &[u8], key: &[u8; 8], is_handshake: bool, ) { if !is_handshake { let mask = derive_payload_mask(key, ciphertext); for i in 0..12 { header[i] ^= mask[i]; } } } pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { if !is_handshake && raw.len() >= 12 { let (header_slice, ciphertext) = raw.split_at_mut(12); let mut header = [0u8; 12]; header.copy_from_slice(header_slice); deobfuscate_header_inplace(&mut header, ciphertext, key, is_handshake); header_slice.copy_from_slice(&header); } else if is_handshake && raw.len() > 6 { let payload = &raw[6..]; let mask = derive_payload_mask(key, payload); for i in 0..6 { raw[i] ^= mask[i]; } } } #[cfg(test)] #[path = "obfuscation_tests.rs"] mod obfuscation_tests;