From a4d8da246094aa7f5276b083e02bb1fb02abf976 Mon Sep 17 00:00:00 2001 From: ospab Date: Sun, 17 May 2026 15:32:07 +0300 Subject: [PATCH] =?UTF-8?q?security:=20Kerckhoffs's=20principle=20?= =?UTF-8?q?=E2=80=94=20all=20secrets=20derived=20from=20access=20key=20via?= =?UTF-8?q?=20HKDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied Kerckhoffs's principle: the protocol's security and obfuscation now depend SOLELY on the access key. An adversary who reverse-engineers the binary cannot build a DPI filter without knowing the key. Changes: - Replaced hardcoded salt string ('-ostp-psk-salt') with HKDF-SHA256. The salt is now derived from the key hash itself — no protocol-specific strings remain in the binary. - Unified all secret derivation into derive_all_secrets() which produces PSK, obfuscation key, and handshake padding range from a single HKDF invocation. - Handshake padding range is now key-derived: different access keys produce different size distributions (min: 16-79, max: +48..+175). A universal size-based filter is impossible without the key. - HKDF-SHA256 (RFC 5869) implemented inline using existing hmac+sha2 dependencies — no new crate required. What remains identifiable in the binary: - 'Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s' — standard Noise pattern string, shared with many other projects, NOT OSTP-specific. - Generic HMAC/SHA-256/ChaCha20-Poly1305 code — standard crypto primitives used by millions of applications. --- ostp-client/src/bridge.rs | 9 +- ostp-core/src/crypto/mod.rs | 5 +- ostp-core/src/crypto/obfuscation.rs | 161 +++++++++++++++++++++------- ostp-core/src/protocol.rs | 18 +++- ostp-server/src/dispatcher.rs | 15 +-- ostp-server/src/lib.rs | 3 + 6 files changed, 158 insertions(+), 53 deletions(-) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index ac36467..57655cd 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -592,23 +592,24 @@ impl Bridge { handshake_payload.extend_from_slice(&session_id.to_be_bytes()); handshake_payload.extend_from_slice(&self.access_key); - let obf_key = ostp_core::crypto::derive_obfuscation_key(&self.access_key); - let psk = ostp_core::crypto::derive_psk(&self.access_key); + let secrets = ostp_core::crypto::derive_all_secrets(&self.access_key); let mut machine = ProtocolMachine::new(ProtocolConfig { role: NoiseRole::Initiator, - psk, + psk: secrets.psk, session_id, handshake_payload, max_padding: 1280, // Safe MTU size to avoid UDP fragmentation on Windows/PPPoE padding_strategy: PaddingStrategy::Profile(self.profile), - obfuscation_key: obf_key, + obfuscation_key: secrets.obfuscation_key, max_reorder: 16384, // Max gap between expected and received nonce max_reorder_buffer: 8192, // Max buffered out-of-order frames ack_delay_ms: 5, rto_ms: 100, max_retries: 8, max_sent_history: 32768, // Reduced: gap recovery handles unrecoverable frames + handshake_pad_min: secrets.handshake_pad_min, + handshake_pad_max: secrets.handshake_pad_max, })?; let addr = self.local_bind_addr.parse::().map_err(|e| anyhow::anyhow!("invalid bind addr: {}", e))?; diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs index 9d06278..dfa5496 100644 --- a/ostp-core/src/crypto/mod.rs +++ b/ostp-core/src/crypto/mod.rs @@ -6,4 +6,7 @@ pub mod obfuscation; pub use aead::SessionCipher; pub use kex::{HybridSharedSecret, HybridKex}; pub use noise::{NoiseRole, NoiseSession}; -pub use obfuscation::{deobfuscate_header_inplace, 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, derive_all_secrets, DerivedSecrets, +}; diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index f7141b2..4af4e89 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -1,26 +1,135 @@ +// ============================================================================= +// 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; -pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] { +// ── 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 mut hasher = Sha256::new(); - hasher.update(access_key); - let result = hasher.finalize(); - let mut key = [0u8; 8]; - key.copy_from_slice(&result[0..8]); - key + 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] { - use sha2::Digest; - let mut hasher = Sha256::new(); - hasher.update(access_key); - hasher.update(b"-ostp-psk-salt"); - let result = hasher.finalize(); - let mut psk = [0u8; 32]; - psk.copy_from_slice(&result); - psk + 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: @@ -50,9 +159,6 @@ pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: boo } } } else if is_handshake && raw.len() > 6 { - // Handshake: sample the Noise payload (starts at byte 6) to derive - // a per-packet mask. The Noise payload begins with a random ephemeral - // key, so the mask will be unique for every handshake. let payload = &raw[6..]; let mask = derive_payload_mask(key, payload); @@ -74,7 +180,6 @@ pub fn deobfuscate_header_inplace( header[i] ^= mask[i]; } } - // Handshake deobfuscation is not done via this function — use deobfuscate_packet_inplace } pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { @@ -85,9 +190,6 @@ pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: b deobfuscate_header_inplace(&mut header, ciphertext, key, is_handshake); header_slice.copy_from_slice(&header); } else if is_handshake && raw.len() > 6 { - // Handshake: the payload (Noise data) starts at byte 6, - // and was NOT masked — only the header [0..6] was. - // Derive the same mask from the unmasked payload. let payload = &raw[6..]; let mask = derive_payload_mask(key, payload); @@ -96,18 +198,3 @@ pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: b } } } - -/// Derives a 32-byte mask from a payload sample using HMAC-SHA256. -/// Used by both data and handshake obfuscation to produce per-packet unique masks. -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 -} diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index 11f41c9..5ca183d 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -33,6 +33,10 @@ pub struct ProtocolConfig { pub rto_ms: u64, pub max_retries: u8, pub max_sent_history: usize, + /// Key-derived handshake padding range (Kerckhoffs's principle). + /// Different access keys produce different handshake packet sizes. + pub handshake_pad_min: usize, + pub handshake_pad_max: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -89,6 +93,9 @@ pub struct ProtocolMachine { /// evicted from sent_history, this timer detects the deadlock and skips /// the gap to restore liveness. last_recv_advance: Instant, + /// Key-derived handshake padding range + handshake_pad_min: usize, + handshake_pad_max: usize, } #[derive(Debug, Clone)] @@ -131,6 +138,8 @@ impl ProtocolMachine { last_ack_sent: Instant::now(), last_nack_sent: Instant::now() - Duration::from_secs(1), last_recv_advance: Instant::now(), + handshake_pad_min: config.handshake_pad_min.max(8), + handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16), }) } @@ -390,11 +399,12 @@ impl ProtocolMachine { fn wrap_datagram_handshake(&self, noise_payload: &[u8]) -> Result { // Anti-DPI: add random padding after the Noise payload to prevent - // size fingerprinting. Without this, every handshake is exactly 52 bytes - // which is trivially detectable by TSPU/DPI systems. + // size fingerprinting. The padding range is derived from the access key + // (Kerckhoffs's principle), so different keys produce different size + // distributions — no universal filter can be built from the binary alone. // - // Wire format: [session_id:4][noise_len:2][noise_payload:N][random_padding:32-128] - let pad_len: usize = rand::thread_rng().gen_range(32..=128); + // Wire format: [session_id:4][noise_len:2][noise_payload:N][random_padding] + let pad_len: usize = rand::thread_rng().gen_range(self.handshake_pad_min..=self.handshake_pad_max); let mut pad = vec![0u8; pad_len]; rand::thread_rng().fill(&mut pad[..]); diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 9934c5b..16cd866 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -154,22 +154,23 @@ impl Dispatcher { let keys_snapshot: Vec = self.access_keys.read().unwrap().keys().cloned().collect(); for candidate_key in keys_snapshot { - let obf_key = ostp_core::crypto::derive_obfuscation_key(candidate_key.as_bytes()); - let psk = ostp_core::crypto::derive_psk(candidate_key.as_bytes()); + let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); // Decode the session_id using this key's obfuscation // The handshake mask is derived from the Noise payload at bytes [6..], // so we must deobfuscate the full packet, not just the header. if packet.len() < 7 { continue; } let mut trial = packet.to_vec(); - ostp_core::crypto::deobfuscate_packet_inplace(&mut trial, &obf_key, true); + ostp_core::crypto::deobfuscate_packet_inplace(&mut trial, &secrets.obfuscation_key, true); let candidate_session_id = u32::from_be_bytes([trial[0], trial[1], trial[2], trial[3]]); let mut cfg = self.machine_config.clone(); cfg.session_id = candidate_session_id; - cfg.psk = psk; + cfg.psk = secrets.psk; cfg.handshake_payload = vec![]; - cfg.obfuscation_key = obf_key; + cfg.obfuscation_key = secrets.obfuscation_key; + cfg.handshake_pad_min = secrets.handshake_pad_min; + cfg.handshake_pad_max = secrets.handshake_pad_max; let mut machine = match ProtocolMachine::new(cfg) { Ok(m) => m, @@ -227,12 +228,12 @@ impl Dispatcher { self.replay_cache.insert(payload.to_vec(), ts); - machine.set_session_keys(candidate_session_id, obf_key); + machine.set_session_keys(candidate_session_id, secrets.obfuscation_key); self.peer_machines.insert(candidate_session_id, PeerState { machine, last_addr: peer, - obfuscation_key: obf_key, + obfuscation_key: secrets.obfuscation_key, last_seen: std::time::Instant::now(), }); self.addr_to_session.insert(peer, candidate_session_id); diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 7b15a29..cdf99ad 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -141,6 +141,9 @@ pub async fn run_server( rto_ms: 100, max_retries: 8, max_sent_history: 32768, + // Defaults — overridden per-session by dispatcher using derive_all_secrets() + handshake_pad_min: 32, + handshake_pad_max: 128, }; let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone());