mirror of https://github.com/ospab/ostp.git
feat: introduce ciphertext-derived dynamic obfuscation to fully mask the nonce on the wire
This commit is contained in:
parent
52db766e87
commit
5c71c6cc9e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue