diff --git a/Cargo.lock b/Cargo.lock index 0ab51fa..e13ab5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,7 +745,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "ostp" -version = "0.1.49" +version = "0.1.50" dependencies = [ "anyhow", "base64", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "ostp-client" -version = "0.1.49" +version = "0.1.50" dependencies = [ "anyhow", "bytes", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "ostp-core" -version = "0.1.49" +version = "0.1.50" dependencies = [ "anyhow", "async-trait", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "ostp-server" -version = "0.1.49" +version = "0.1.50" dependencies = [ "anyhow", "bytes", @@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "ostp-tun-helper" -version = "0.1.49" +version = "0.1.50" dependencies = [ "anyhow", "chrono", diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs index 25a1582..d3925ea 100644 --- a/ostp-core/src/crypto/mod.rs +++ b/ostp-core/src/crypto/mod.rs @@ -6,4 +6,4 @@ pub mod obfuscation; pub use aead::SessionCipher; pub use kex::{HybridSharedSecret, KeyExchange}; pub use noise::{NoiseRole, NoiseSession}; -pub use obfuscation::{deobfuscate_packet_inplace, obfuscate_packet_inplace, derive_obfuscation_key, derive_psk}; +pub use obfuscation::{deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace, derive_obfuscation_key, derive_psk}; diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index adb27ac..6493ad9 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -24,8 +24,7 @@ pub fn derive_psk(access_key: &[u8]) -> [u8; 32] { } /// Derives a unique 4-byte session_id mask using HMAC-SHA256(key, nonce). -/// Because nonce is strictly monotonic, each packet gets a cryptographically -/// independent mask — consecutive headers are indistinguishable from random noise. +/// Used strictly for handshake phase obfuscation. fn derive_session_mask(key: &[u8; 8], nonce: u64) -> [u8; 4] { let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(&nonce.to_be_bytes()); @@ -36,30 +35,34 @@ fn derive_session_mask(key: &[u8; 8], nonce: u64) -> [u8; 4] { } /// Wire layout for DATA packets: -/// [0..4] = session_id XOR HMAC(obf_key, nonce)[0..4] ← masked, unique per-packet -/// [4..12] = nonce, plaintext ← needed by receiver to derive mask -/// [12..] = AEAD ciphertext ← authenticates everything +/// [0..4] = session_id XOR HMAC(obf_key, ciphertext_sample)[0..4] +/// [4..12] = nonce XOR HMAC(obf_key, ciphertext_sample)[4..12] +/// [12..] = AEAD ciphertext (at least 16 bytes tag) /// -/// The nonce is sent in plaintext but this is intentional and safe: -/// - It is authenticated by the AEAD tag; tampering is detected. -/// - The session_id mask changes with every packet, breaking header correlation. -/// - The ciphertext is fully opaque; only nonce sequence is visible. +/// Because the ciphertext sample is different for every packet, the derived +/// mask is cryptographically random and independent for each packet. +/// Thus, both session_id and nonce are completely masked and indistinguishable +/// from pure random noise on the wire. pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { if !is_handshake && raw.len() >= 12 { - // Read nonce from bytes 4..12 (plaintext on wire) - let nonce = u64::from_be_bytes([ - raw[4], raw[5], raw[6], raw[7], - raw[8], raw[9], raw[10], raw[11], - ]); - // Mask only session_id bytes using nonce-derived mask - let mask = derive_session_mask(key, nonce); - for i in 0..4 { - raw[i] ^= mask[i]; + let header_len = 12; + if raw.len() > header_len { + let ciphertext = &raw[header_len..]; + let mut sample = [0u8; 32]; + let take_len = ciphertext.len().min(32); + sample[..take_len].copy_from_slice(&ciphertext[..take_len]); + + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(&sample); + let mask_result = mac.finalize().into_bytes(); + + // Mask the entire 12-byte header (session_id + nonce) + for i in 0..12 { + raw[i] ^= mask_result[i]; + } } - // nonce bytes 4..12 remain as-is (plaintext, authenticated by AEAD) } else if raw.len() >= 4 { // Handshake packets: mask session_id with a fixed handshake-phase mask - // u64::MAX used as sentinel to produce a distinct HMAC output from any data nonce let mask = derive_session_mask(key, u64::MAX); for i in 0..4 { raw[i] ^= mask[i]; @@ -67,18 +70,40 @@ pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: boo } } +pub fn deobfuscate_header_inplace( + header: &mut [u8; 12], + ciphertext: &[u8], + key: &[u8; 8], + is_handshake: bool, +) { + if !is_handshake { + let mut sample = [0u8; 32]; + let take_len = ciphertext.len().min(32); + sample[..take_len].copy_from_slice(&ciphertext[..take_len]); + + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(&sample); + let mask_result = mac.finalize().into_bytes(); + + // Unmask the entire 12-byte header + for i in 0..12 { + header[i] ^= mask_result[i]; + } + } else { + let mask = derive_session_mask(key, u64::MAX); + for i in 0..4 { + header[i] ^= mask[i]; + } + } +} + pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { if !is_handshake && raw.len() >= 12 { - // Read nonce plaintext from bytes 4..12 - let nonce = u64::from_be_bytes([ - raw[4], raw[5], raw[6], raw[7], - raw[8], raw[9], raw[10], raw[11], - ]); - // Derive same mask and unmask session_id - let mask = derive_session_mask(key, nonce); - for i in 0..4 { - raw[i] ^= mask[i]; - } + 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 raw.len() >= 4 { let mask = derive_session_mask(key, u64::MAX); for i in 0..4 { diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 0b75220..46d416b 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -60,7 +60,8 @@ impl Dispatcher { let mut header = [0u8; 12]; if packet.len() >= 12 { header.copy_from_slice(&packet[0..12]); - ostp_core::crypto::deobfuscate_packet_inplace(&mut header, &peer_state.obfuscation_key, false); + let ciphertext = &packet[12..]; + ostp_core::crypto::deobfuscate_header_inplace(&mut header, ciphertext, &peer_state.obfuscation_key, false); let candidate_sid = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); if candidate_sid == sid { session_id_opt = Some(sid); @@ -84,7 +85,8 @@ impl Dispatcher { if packet.len() >= 12 { let mut header = [0u8; 12]; header.copy_from_slice(&packet[0..12]); - ostp_core::crypto::deobfuscate_packet_inplace(&mut header, &peer_state.obfuscation_key, false); + let ciphertext = &packet[12..]; + ostp_core::crypto::deobfuscate_header_inplace(&mut header, ciphertext, &peer_state.obfuscation_key, false); let candidate_sid = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); if candidate_sid == sid { session_id_opt = Some(sid);