feat: introduce ciphertext-derived dynamic obfuscation to fully mask the nonce on the wire

This commit is contained in:
ospab 2026-05-16 23:58:07 +03:00
parent 52db766e87
commit 5c71c6cc9e
4 changed files with 65 additions and 38 deletions

10
Cargo.lock generated
View File

@ -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",

View File

@ -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};

View File

@ -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 {

View File

@ -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);