Skip to content

Commit 482d7a8

Browse files
rootclaude
andcommitted
v0.0.2: musl build fix, plain XOR cipher mode, iperf3 bench
- src/tun.rs: use libc::Ioctl for TUNSETIFF so musl (c_int) builds alongside glibc (c_ulong). v0.0.1 failed to cross-compile to aarch64/armv7 musl on this point. - Add cipher_mode = "chained" | "plain" config. Chained stays default (per-packet avalanche). Plain is auto-vectorizable pure XOR; useful only when XOR becomes the bottleneck (10 Gbps+ / jumbo frames). - tests/netns/bench.sh: 5-netns topology + iperf3 UDP over VXLAN to measure throughput/CPU per cipher_mode for ip4 and ip6. VXLAN MTU set to the standard 1450 (v4) / 1430 (v6) against veth=1500. - tests/netns/run.sh: set vxlan MTU explicitly to match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8ac39a8 commit 482d7a8

8 files changed

Lines changed: 423 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "transparent-udp-obfus"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
edition = "2021"
55

66
[[bin]]

src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub struct Config {
1717
#[serde(default)]
1818
pub sender_mode: SenderMode,
1919
#[serde(default)]
20+
pub cipher_mode: CipherMode,
21+
#[serde(default)]
2022
pub local_traffic: Vec<TrafficRule>,
2123
#[serde(default)]
2224
pub remote_traffic: Vec<TrafficRule>,
@@ -54,6 +56,18 @@ pub enum AfSpec {
5456
Ip6,
5557
}
5658

59+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
60+
#[serde(rename_all = "snake_case")]
61+
pub enum CipherMode {
62+
/// Tail→head chained XOR. Intra-packet avalanche; not SIMD-vectorizable.
63+
#[default]
64+
Chained,
65+
/// Pure stateless XOR with the keystream. ~10–30× faster (AVX2/NEON)
66+
/// but identical plaintext segments at the same offset produce identical
67+
/// ciphertext.
68+
Plain,
69+
}
70+
5771
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
5872
#[serde(rename_all = "snake_case")]
5973
pub enum SenderMode {

src/keystream.rs

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,48 @@
11
use chacha20::cipher::{KeyIvInit, StreamCipher};
22
use chacha20::ChaCha20;
33

4+
use crate::config::CipherMode;
5+
46
pub const KS_LEN: usize = 9000;
57
const BUF_LEN: usize = KS_LEN + 1;
68

79
pub struct Keystream {
810
bytes: [u8; BUF_LEN],
11+
mode: CipherMode,
912
}
1013

1114
impl Keystream {
12-
pub fn from_passphrase(passphrase: &str) -> Self {
15+
pub fn from_passphrase(passphrase: &str, mode: CipherMode) -> Self {
1316
let key_hash = blake3::hash(passphrase.as_bytes());
1417
let key: [u8; 32] = *key_hash.as_bytes();
1518
let nonce = [0u8; 12];
1619
let mut cipher = ChaCha20::new(&key.into(), &nonce.into());
1720
let mut bytes = [0u8; BUF_LEN];
1821
cipher.apply_keystream(&mut bytes);
19-
Self { bytes }
22+
Self { bytes, mode }
2023
}
2124

2225
pub fn max_payload(&self) -> usize {
2326
KS_LEN
2427
}
2528

26-
/// Chained XOR encode, processed tail → head, in place.
27-
/// `C[i] = P[i] ^ K[i] ^ C[i+1]`, with `C[N] = K[N]`.
2829
pub fn encode(&self, buf: &mut [u8]) -> bool {
30+
match self.mode {
31+
CipherMode::Chained => self.encode_chained(buf),
32+
CipherMode::Plain => self.xor_plain(buf),
33+
}
34+
}
35+
36+
pub fn decode(&self, buf: &mut [u8]) -> bool {
37+
match self.mode {
38+
CipherMode::Chained => self.decode_chained(buf),
39+
CipherMode::Plain => self.xor_plain(buf),
40+
}
41+
}
42+
43+
/// Chained XOR encode, tail → head, in place.
44+
/// `C[i] = P[i] ^ K[i] ^ C[i+1]`, with `C[N] = K[N]`.
45+
fn encode_chained(&self, buf: &mut [u8]) -> bool {
2946
let n = buf.len();
3047
if n == 0 || n > KS_LEN {
3148
return false;
@@ -40,8 +57,8 @@ impl Keystream {
4057
true
4158
}
4259

43-
/// Reverse of `encode`. Processed head → tail, in place.
44-
pub fn decode(&self, buf: &mut [u8]) -> bool {
60+
/// Reverse of `encode_chained`. Head → tail, in place.
61+
fn decode_chained(&self, buf: &mut [u8]) -> bool {
4562
let n = buf.len();
4663
if n == 0 || n > KS_LEN {
4764
return false;
@@ -53,6 +70,20 @@ impl Keystream {
5370
}
5471
true
5572
}
73+
74+
/// Pure stateless XOR. Encode == decode. Auto-vectorizable (the compiler
75+
/// turns this into AVX2/NEON chunks).
76+
fn xor_plain(&self, buf: &mut [u8]) -> bool {
77+
let n = buf.len();
78+
if n == 0 || n > KS_LEN {
79+
return false;
80+
}
81+
let k = &self.bytes[..n];
82+
for (b, kb) in buf.iter_mut().zip(k.iter()) {
83+
*b ^= *kb;
84+
}
85+
true
86+
}
5687
}
5788

5889
#[cfg(test)]
@@ -61,20 +92,22 @@ mod tests {
6192

6293
#[test]
6394
fn roundtrip() {
64-
let ks = Keystream::from_passphrase("hunter2");
65-
for n in [1, 2, 7, 16, 100, 1500, 8999, 9000] {
66-
let pt: Vec<u8> = (0..n).map(|i| (i * 31) as u8).collect();
67-
let mut buf = pt.clone();
68-
assert!(ks.encode(&mut buf));
69-
assert_ne!(buf, pt, "ciphertext equals plaintext at n={n}");
70-
assert!(ks.decode(&mut buf));
71-
assert_eq!(buf, pt, "roundtrip mismatch at n={n}");
95+
for mode in [CipherMode::Chained, CipherMode::Plain] {
96+
let ks = Keystream::from_passphrase("hunter2", mode);
97+
for n in [1, 2, 7, 16, 100, 1500, 8999, 9000] {
98+
let pt: Vec<u8> = (0..n).map(|i| (i * 31) as u8).collect();
99+
let mut buf = pt.clone();
100+
assert!(ks.encode(&mut buf));
101+
assert_ne!(buf, pt, "ciphertext equals plaintext at n={n} mode={mode:?}");
102+
assert!(ks.decode(&mut buf));
103+
assert_eq!(buf, pt, "roundtrip mismatch at n={n} mode={mode:?}");
104+
}
72105
}
73106
}
74107

75108
#[test]
76-
fn tail_change_affects_head() {
77-
let ks = Keystream::from_passphrase("hunter2");
109+
fn chained_tail_change_affects_head() {
110+
let ks = Keystream::from_passphrase("hunter2", CipherMode::Chained);
78111
let mut a = vec![0u8; 64];
79112
let mut b = vec![0u8; 64];
80113
b[63] = 1;
@@ -83,9 +116,20 @@ mod tests {
83116
assert_ne!(a[0], b[0], "tail change must propagate to head");
84117
}
85118

119+
#[test]
120+
fn plain_is_stateless() {
121+
let ks = Keystream::from_passphrase("hunter2", CipherMode::Plain);
122+
let mut a = vec![0u8; 64];
123+
let mut b = vec![0u8; 64];
124+
b[63] = 1;
125+
ks.encode(&mut a);
126+
ks.encode(&mut b);
127+
assert_eq!(a[0], b[0], "plain XOR: head must be independent of tail");
128+
}
129+
86130
#[test]
87131
fn rejects_oversize() {
88-
let ks = Keystream::from_passphrase("x");
132+
let ks = Keystream::from_passphrase("x", CipherMode::Chained);
89133
let mut buf = vec![0u8; KS_LEN + 1];
90134
assert!(!ks.encode(&mut buf));
91135
}

src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ fn main() -> Result<()> {
3939

4040
let cli = Cli::parse();
4141
let cfg = Config::load(&cli.config).context("loading config")?;
42-
tracing::info!(?cfg.sender_mode, threads = cfg.num_threads, "starting");
42+
tracing::info!(?cfg.sender_mode, ?cfg.cipher_mode, threads = cfg.num_threads, "starting");
4343

4444
let rt = tokio::runtime::Builder::new_multi_thread()
4545
.worker_threads(cfg.num_threads)
@@ -52,7 +52,7 @@ fn main() -> Result<()> {
5252
async fn async_main(cfg: Config) -> Result<()> {
5353
let local_rules = Arc::new(CompiledRule::compile_many(&cfg.local_traffic)?);
5454
let remote_rules = Arc::new(CompiledRule::compile_many(&cfg.remote_traffic)?);
55-
let keystream = Arc::new(Keystream::from_passphrase(&cfg.passphrase));
55+
let keystream = Arc::new(Keystream::from_passphrase(&cfg.passphrase, cfg.cipher_mode));
5656

5757
// Create TUN queues: one read queue per worker for the sender, plus one
5858
// dedicated write queue shared across all receiver tasks.

src/tun.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ const IFF_TUN: u16 = 0x0001;
77
const IFF_NO_PI: u16 = 0x1000;
88
const IFF_MULTI_QUEUE: u16 = 0x0100;
99

10-
// TUNSETIFF = _IOW('T', 202, int) = 0x400454ca on Linux.
11-
const TUNSETIFF: libc::c_ulong = 0x400454ca;
10+
// TUNSETIFF = _IOW('T', 202, int) = 0x400454ca on Linux. The request type
11+
// differs between libcs (c_ulong on glibc, c_int on musl), so we use the
12+
// libc::Ioctl type alias so musl builds compile.
13+
const TUNSETIFF: libc::Ioctl = 0x400454ca as libc::Ioctl;
1214

1315
#[repr(C)]
1416
struct Ifreq {

0 commit comments

Comments
 (0)