diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..f6906f2ee
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# 已忽略包含查询文件的默认文件夹
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 000000000..ca1ed76ec
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/rqbit.iml b/.idea/rqbit.iml
new file mode 100644
index 000000000..97118da4f
--- /dev/null
+++ b/.idea/rqbit.iml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..35eb1ddfb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 7b4f8f903..c41af1af8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -721,6 +721,17 @@ dependencies = [
"half",
]
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout",
+]
+
[[package]]
name = "clap"
version = "4.6.6"
@@ -935,6 +946,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1061,6 +1078,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+dependencies = [
+ "cpubits",
+ "ctutils",
+ "num-traits",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -2670,6 +2699,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "intervaltree"
version = "0.2.7"
@@ -3071,6 +3109,7 @@ dependencies = [
"byteorder",
"bytes",
"console-subscriber",
+ "crypto-bigint",
"dashmap 6.2.1",
"futures",
"governor",
@@ -3101,6 +3140,7 @@ dependencies = [
"parking_lot",
"pollster",
"rand 0.10.2",
+ "rc4",
"regex",
"reqwest",
"rlimit",
@@ -4737,6 +4777,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "rc4"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
diff --git a/Cargo.toml b/Cargo.toml
index a5bee8318..d082893ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -83,6 +83,7 @@ axum-extra = "0.12"
backon = "1.5"
base64 = "0.23"
bitvec = "1"
+crypto-bigint = "0.7"
bstr = "1"
byteorder = "1"
bytes = "1"
diff --git a/MSE-PR633-REFACTOR-PLAN.md b/MSE-PR633-REFACTOR-PLAN.md
new file mode 100644
index 000000000..f34096c4b
--- /dev/null
+++ b/MSE-PR633-REFACTOR-PLAN.md
@@ -0,0 +1,58 @@
+# MSE PR #633 重构(已实施):概要与决策记录
+
+## 背景
+
+rqbit PR #633 实现 MSE(消息流加密)。上游维护者 ikatson 提交 CHANGES_REQUESTED,本文件记录重构的完整梗概:维护者意见、已实施的改动、关键设计决策与验证结果。分支 `feat/mse-crypto-primitives`,单 commit `0402294c`(基于 `de2b107e`)。
+
+## 一、维护者意见(CHANGES_REQUESTED)
+
+主 review:**合并为单 commit**("It's not too big, so let's just shrink into one commit")+ 两大担忧:
+1. **自写 crypto 太多**——应尽量用 crate(openssl 等,含硬件加速)
+2. **spaghetti**——MSE 侵入 session/peer_connection 核心逻辑,建议抽象为 `connect_with_handshake` / `accept`
+
+6 条 review comments:error.rs `MseForced` 去 SocketAddr、移除 Tcp-only 限制、去冗余行、RC4/DH 用 crate、session 抽象、uTP/socks 透明支持。
+
+## 二、已实施的改动
+
+### Phase 1 — 抽象层(解决 spaghetti)
+- **出站**:`StreamConnector::connect_with_handshake()`(stream_connect.rs)——封装连接 + MSE 决策 + 握手发送,返回 `OutgoingHandshake { kind, read, write, mse_applied }`
+- **入站**:`accept_with_handshake()` 自由函数 + `IncomingHandshake` 结构
+- `session.rs`/`peer_connection.rs` 不再携带任何 MSE 细节(`IncomingOutcome`/`OutgoingOutcome`/`Sha1`/`AsyncReadExt` 全部收进 stream_connect)
+- 移除 Tcp-only 限制:uTP/socks 透明支持 MSE
+- `MseForced` 去 SocketAddr
+
+### Phase 2 — 加密原语(解决自写 crypto)
+- **DH-768 → `crypto-bigint`**:`Uint<12>` + `FixedMontyForm::pow`,删除手写 mod_reduce hack。MSE 固定 prime 硬编码(与 libtorrent/transmission/aria2 同值)。已验证外部 bigint 向量一致
+- **RC4 → `rc4` crate**:
+ - 删除自实现 `mse/rc4.rs`(140 行)
+ - `Rc4Writer` 重写为**内循环方案**(参考 libtorrent `rc4_handler`):`poll_write` 加密整 buffer → 存 pending → 循环 flush,返回 `Ok(full_len)` 或 `Pending`,**绝不返回短写**(消除双加密 bug);Pending/短写密文缓存,重试不重复加密
+ - `Rc4Reader` 用 rc4 crate
+ - **PadB 扫描改 pattern-search**(libtorrent `read_pe_syncvc` / transmission `read_vc` 同款):独立实例算 VC 密文模式 → 字节搜索 → 解密实际 VC 验证,不再需要 clone
+- SHA-1 用现有 `sha1w`(crate + 可选硬件加速后端)
+
+### Phase 4 — squash
+- `git reset --soft de2b107e` → 单 commit `0402294c`,21 文件 +1843/−32
+
+## 三、关键设计决策
+
+### RC4 保留自实现 vs 用 crate 的决策过程
+1. 最初倾向保留自实现:RustCrypto `rc4` crate **无 `Clone`**,无法实现 `Rc4Writer` 短写保护(Pending 重试需试探状态)
+2. 调研发现:libtorrent/transmission **都不用 clone**——用 **pattern-search**(搜索加密后的 VC 密文模式)而非"试探解密";`Rc4Writer` 短写用"加密与发送分离 + pending 缓冲"
+3. 据此改用 rc4 crate:PadB 用 pattern-search,Rc4Writer 用内循环方案——**彻底绕开 clone 需求**
+
+### 写错误处理(与三引擎一致)
+libtorrent `disconnect(error, sock_write)`、transmission `call_error_callback`(非可重试)、aria2 `throw DL_RETRY_EX`(非 WOULDBLOCK)——**致命写错误一律断连**。rqbit 的 `poll_write` 对底层 `Poll::Pending` 返回 Pending(可重试,对应 EAGAIN),对 `Err` 传播(断连)。原测试 `write_error_does_not_advance_state`(错误后状态不推进)脱离真实场景,改为 `write_error_propagates`(错误传播 + sink 空)。
+
+## 四、验证结果
+
+- `cargo test -p librqbit --lib`:**45 passed / 0 failed / 5 ignored**
+ - stream:pending/short-write 状态保持、写错误传播
+ - mse:rc4 向量、dh768 外部向量、stream 包装、duplex 握手、明文嗅探回退、零长度 IA、分片前缀重放、fresh-redial、Disabled 单连接、Forced 失败、默认 Disabled 锁定
+- `cargo check --all-targets`:0 error
+- `cargo clippy`:新增改动 0 warning(剩余 5 个为基线既有 mod.rs cast/large-size 警告)
+
+## 五、状态
+
+- 单 commit `0402294c`(基于 `de2b107e`),工作区干净
+- **未 push、未回复 PR**(按用户指示)
+- 待办:回复维护者(含 pattern-search / 内循环方案说明)、force push
diff --git a/crates/librqbit/Cargo.toml b/crates/librqbit/Cargo.toml
index 1623e2254..e1d3cd588 100644
--- a/crates/librqbit/Cargo.toml
+++ b/crates/librqbit/Cargo.toml
@@ -54,6 +54,8 @@ clone_to_owned.workspace = true
peer_binary_protocol.workspace = true
sha1w.workspace = true
dht.workspace = true
+crypto-bigint.workspace = true
+rc4 = "0.2"
librqbit-upnp.workspace = true
upnp-serve = { workspace = true, optional = true }
diff --git a/crates/librqbit/examples/simulate_traffic.rs b/crates/librqbit/examples/simulate_traffic.rs
index 9e4286e48..5d91afa7a 100644
--- a/crates/librqbit/examples/simulate_traffic.rs
+++ b/crates/librqbit/examples/simulate_traffic.rs
@@ -186,6 +186,7 @@ impl TestHarness {
connect_timeout: Some(Duration::from_secs(1)),
read_write_timeout: Some(Duration::from_secs(32)),
keep_alive_interval: None,
+ ..Default::default()
}),
}),
..Default::default()
@@ -274,6 +275,7 @@ impl TestHarness {
connect_timeout: Some(Duration::from_secs(1)),
read_write_timeout: Some(Duration::from_secs(32)),
keep_alive_interval: None,
+ ..Default::default()
}),
}),
..Default::default()
diff --git a/crates/librqbit/src/error.rs b/crates/librqbit/src/error.rs
index c95250381..530e8665f 100644
--- a/crates/librqbit/src/error.rs
+++ b/crates/librqbit/src/error.rs
@@ -30,6 +30,8 @@ pub enum Error {
WrongInfoHash,
#[error("connecting to ourselves")]
ConnectingToOurselves,
+ #[error("MSE is forced, but the peer did not complete the MSE handshake")]
+ MseForced,
#[error("error writing handshake: {0:#}")]
WriteHandshake(#[source] std::io::Error),
diff --git a/crates/librqbit/src/lib.rs b/crates/librqbit/src/lib.rs
index 29843e008..5447971ef 100644
--- a/crates/librqbit/src/lib.rs
+++ b/crates/librqbit/src/lib.rs
@@ -61,6 +61,7 @@ mod ip_ranges;
pub mod limits;
mod listen;
mod merge_streams;
+mod mse;
mod peer_connection;
mod peer_info_reader;
mod piece_tracker;
@@ -90,6 +91,7 @@ pub use create_torrent_file::{CreateTorrentOptions, CreateTorrentResult, create_
pub use dht;
pub use librqbit_core::spawn_utils::spawn as librqbit_spawn;
pub use listen::{ListenerMode, ListenerOptions};
+pub use mse::MseMode;
pub use peer_connection::PeerConnectionOptions;
pub use session::{
AddTorrent, AddTorrentOptions, AddTorrentResponse, DhtSessionConfig, ListOnlyResponse,
diff --git a/crates/librqbit/src/mse/dh768.rs b/crates/librqbit/src/mse/dh768.rs
new file mode 100644
index 000000000..477fb5f97
--- /dev/null
+++ b/crates/librqbit/src/mse/dh768.rs
@@ -0,0 +1,166 @@
+//! 768-bit Diffie-Hellman key exchange for BitTorrent MSE.
+//!
+//! The MSE spec fixes a 768-bit MODP group (prime + generator 2) that is not
+//! one of the standard groups shipped by crypto libraries (OpenSSL etc.), so
+//! the group parameters are hardcoded here (matching libtorrent/transmission/
+//! aria2, which do the same) and the modular exponentiation is delegated to
+//! `crypto-bigint` (pure Rust, no C dependency, works with rqbit's `rust-tls`
+//! build). The private exponent is 160 bits, matching common MSE peers.
+//!
+//! Note: `FixedMontyParams::new_vartime` is not constant-time. This is
+//! acceptable here because the private exponent is a fresh ephemeral session
+//! key generated per connection (not a long-lived secret); libtorrent's
+//! non-openssl path uses boost `cpp_int::powm` (also variable-time) for the
+//! same reason.
+
+use crypto_bigint::{
+ Odd, Uint,
+ modular::{FixedMontyForm, FixedMontyParams},
+};
+use rand::Rng;
+
+/// 768-bit modulus: 12 limbs of 64 bits.
+type U768 = Uint<12>;
+
+/// MSE-specified prime (RFC 2409-style 768-bit group, same hex as libtorrent
+/// `pe_crypto.cpp` / transmission `peer-mse.cc` / aria2 `MSEHandshake.cc`).
+const DH_PRIME_HEX: &str = concat!(
+ "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1",
+ "29024E088A67CC74020BBEA63B139B22514A08798E3404DD",
+ "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245",
+ "E485B576625E7EC6F44C42E9A63A36210000000000090563"
+);
+
+const TWO: U768 = {
+ let mut value = [0u64; 12];
+ value[0] = 2;
+ U768::from_words(value)
+};
+
+fn prime() -> U768 {
+ U768::from_be_hex(DH_PRIME_HEX)
+}
+
+fn bytes_to_u768(bytes: &[u8; 96]) -> U768 {
+ U768::from_be_slice(bytes)
+}
+
+fn u768_to_bytes(value: &U768) -> [u8; 96] {
+ let encoded: [u8; 96] = value.to_be_bytes().into();
+ encoded
+}
+
+fn powm(base: &U768, exponent: &[u8; 20]) -> U768 {
+ let exp = secret_to_u768(exponent);
+ powm_u(base, &exp)
+}
+
+fn secret_to_u768(secret: &[u8; 20]) -> U768 {
+ U768::from_be_slice_truncated(secret, 160)
+}
+
+fn powm_u(base: &U768, exponent: &U768) -> U768 {
+ let p_odd: Odd = Odd::new(prime()).expect("MSE prime is odd");
+ // Variable-time Montgomery params: the private exponent is a fresh
+ // per-connection ephemeral key (not a long-lived secret), so constant-time
+ // is not required here; see the module note.
+ let params = FixedMontyParams::new_vartime(p_odd);
+ let monty_base = FixedMontyForm::new(base, ¶ms);
+ monty_base.pow(exponent).retrieve()
+}
+
+pub struct Dh768 {
+ secret: [u8; 20],
+ public: [u8; 96],
+}
+
+impl Dh768 {
+ pub fn generate(rng: &mut impl Rng) -> Self {
+ let mut secret = [0u8; 20];
+ while secret.iter().all(|byte| *byte == 0) {
+ rng.fill_bytes(&mut secret);
+ }
+ Self::from_secret(secret)
+ }
+
+ pub(super) fn from_secret(secret: [u8; 20]) -> Self {
+ let public = powm(&TWO, &secret);
+ Self {
+ secret,
+ public: u768_to_bytes(&public),
+ }
+ }
+
+ pub fn public_key_bytes(&self) -> [u8; 96] {
+ self.public
+ }
+
+ pub fn shared_secret(&self, remote: &[u8; 96]) -> Option<[u8; 96]> {
+ let remote = bytes_to_u768(remote);
+ // Reject degenerate keys (0, 1, or >= p-1), matching the old hand-rolled
+ // bounds check.
+ let two = U768::from(2u64);
+ let p = prime();
+ let p_minus_one = p.wrapping_sub(&U768::ONE);
+ if remote < two || remote >= p_minus_one {
+ return None;
+ }
+ let secret_u = secret_to_u768(&self.secret);
+ Some(u768_to_bytes(&powm_u(&remote, &secret_u)))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::{Context, Result};
+ use rand::SeedableRng;
+ use rand::rngs::SmallRng;
+
+ fn decode(text: &str) -> Result<[u8; N]> {
+ let bytes = hex::decode(text).context("invalid test vector hex")?;
+ bytes
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("test vector has the wrong length"))
+ }
+
+ #[test]
+ fn matches_external_bigint_vectors() -> Result<()> {
+ let secret_a = decode::<20>("000102030405060708090a0b0c0d0e0f10111213")?;
+ let public_a = decode::<96>(
+ "7fba71c678158bd55ef1cc04a919d1b05f79f9da403c67e82bb1a99a7b4bc4ec221cca6c3a78171a40f2cc12e3d9d4454338f7e4b9b33de5e82ab04e86f5cd43aaf9dad923988501c371d3159935de5499e5d726e740b1eabbf4a3dd03c68071",
+ )?;
+ let secret_b = decode::<20>("f0e0d0c0b0a09080706050403020100011223344")?;
+ let public_b = decode::<96>(
+ "f9fe7e1c27aee331ab8ff8a6183cfcc7bd08dc593fc4d52bc9a2694b7b787daa12e3b2695e3e9febf994447cefa427f9f5da34a4d3cd6c231a8d6517e7130de00a8a09e753ca12648ec18da389e68eeb66f8308b19cc60dfeaadb2540a821f53",
+ )?;
+ let shared = decode::<96>(
+ "909ea4557d5b9f43dafdc5b598850045b8689e4d652af58a63730b00c574bbe4962ab9c78b2f295e3ddb3b456f20a4c65761751bf5d79ec4dba8470fe66ed22b4a25f13528a9575607c77586785a36d560f8556b66e9c16deb87fed185ee07a7",
+ )?;
+
+ let a = Dh768::from_secret(secret_a);
+ let b = Dh768::from_secret(secret_b);
+ assert_eq!(a.public_key_bytes(), public_a);
+ assert_eq!(b.public_key_bytes(), public_b);
+ assert_eq!(a.shared_secret(&public_b), Some(shared));
+ assert_eq!(b.shared_secret(&public_a), Some(shared));
+ Ok(())
+ }
+
+ #[test]
+ fn generated_secret_is_nonzero() {
+ let mut rng = SmallRng::seed_from_u64(0x5eed);
+ let dh = Dh768::generate(&mut rng);
+ assert!(dh.secret.iter().any(|byte| *byte != 0));
+ }
+
+ #[test]
+ fn rejects_degenerate_remote_keys() {
+ let dh = Dh768::from_secret([1u8; 20]);
+ assert!(dh.shared_secret(&[0u8; 96]).is_none());
+ assert!(dh.shared_secret(&[0xffu8; 96]).is_none());
+ let mut one = [0u8; 96];
+ one[95] = 1;
+ assert!(dh.shared_secret(&one).is_none());
+ }
+}
diff --git a/crates/librqbit/src/mse/mod.rs b/crates/librqbit/src/mse/mod.rs
new file mode 100644
index 000000000..70c4252bb
--- /dev/null
+++ b/crates/librqbit/src/mse/mod.rs
@@ -0,0 +1,453 @@
+//! Message Stream Encryption (MSE) support.
+//!
+//! MSE obfuscates the BitTorrent handshake and stream with RC4 and a DH-768
+//! key exchange (Azureus protocol encryption). Some peers (Xunlei, BitComet,
+//! etc.) refuse plaintext handshakes, so MSE is required to talk to them.
+//!
+//! This module is built top-down: the [`MseMode`] config and the peer
+//! connection wiring landed first, then the incoming (responder) handshake,
+//! and the outgoing (initiator) handshake in a follow-up commit.
+
+pub mod dh768;
+pub mod stream;
+
+use std::time::Duration;
+
+use anyhow::{Context, Result, bail};
+use rand::{Rng, RngExt};
+use sha1w::{ISha1, Sha1};
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tracing::{debug, trace, warn};
+
+use ::rc4::{KeyInit, Rc4, StreamCipher};
+use dh768::Dh768;
+use stream::{Rc4Reader, Rc4Writer};
+
+const BT_PROTOCOL_PREFIX: &[u8; 20] = b"\x13BitTorrent protocol";
+const BT_HANDSHAKE_LEN: usize = 68;
+const MAX_PAD: usize = 512;
+const VC_LEN: usize = 8;
+const CRYPTO_RC4: u32 = 2;
+
+/// How long to wait (after sending Ya + PadA) for a peer to send its first 20
+/// bytes before treating it as a silent MSE responder. A plaintext peer sends
+/// its 68-byte BT handshake immediately, so we detect it here instead of
+/// waiting for the full read/write timeout.
+const PLAINTEXT_SNIFF_TIMEOUT: Duration = Duration::from_secs(2);
+
+/// How MSE is applied to peer connections.
+///
+/// `Disabled` (the default) skips MSE entirely (plaintext only). `Enabled`
+/// prefers MSE and falls back to a plaintext redial on failure. `Forced`
+/// requires MSE: any peer that does not complete the MSE handshake is dropped.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub enum MseMode {
+ Disabled,
+ Enabled,
+ Forced,
+}
+
+// Manual impl rather than `#[derive(Default)]` + `#[default]`: keeping the
+// default explicit here avoids silently carrying a `#[default]` annotation
+// over if the default is ever flipped to another variant.
+#[allow(clippy::derivable_impls)]
+impl Default for MseMode {
+ fn default() -> Self {
+ Self::Disabled
+ }
+}
+
+pub struct PrefixReader {
+ prefix: Vec,
+ position: usize,
+ inner: R,
+}
+
+impl PrefixReader {
+ fn new(prefix: Vec, inner: R) -> Self {
+ Self {
+ prefix,
+ position: 0,
+ inner,
+ }
+ }
+}
+
+impl AsyncRead for PrefixReader {
+ fn poll_read(
+ self: std::pin::Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ buf: &mut tokio::io::ReadBuf<'_>,
+ ) -> std::task::Poll> {
+ let this = self.get_mut();
+ if this.position < this.prefix.len() && buf.remaining() != 0 {
+ let count = (this.prefix.len() - this.position).min(buf.remaining());
+ buf.put_slice(&this.prefix[this.position..this.position + count]);
+ this.position += count;
+ return std::task::Poll::Ready(Ok(()));
+ }
+ std::pin::Pin::new(&mut this.inner).poll_read(cx, buf)
+ }
+}
+
+/// Encrypted carries the RC4 state and the full handshake; Plaintext is the
+/// fast path. The size gap is inherent to holding cipher state, not a defect.
+#[allow(clippy::large_enum_variant)]
+pub enum IncomingOutcome {
+ Encrypted {
+ read: Rc4Reader,
+ write: Rc4Writer,
+ handshake_bytes: Vec,
+ info_hash: [u8; 20],
+ },
+ Plaintext {
+ read: PrefixReader,
+ write: W,
+ },
+}
+
+/// Encrypted carries the RC4-wrapped streams; PlaintextPeer carries none.
+#[allow(clippy::large_enum_variant)]
+pub enum OutgoingOutcome {
+ /// MSE handshake completed; use the RC4-wrapped streams.
+ Encrypted(Rc4Reader, Rc4Writer),
+ /// The peer answered with a plaintext BitTorrent handshake within the
+ /// sniff window. Abort MSE and redial plaintext on a fresh connection.
+ PlaintextPeer,
+}
+
+fn sha1(parts: &[&[u8]]) -> [u8; 20] {
+ let mut hash = Sha1::new();
+ for part in parts {
+ hash.update(part);
+ }
+ hash.finish()
+}
+
+fn xor20(a: &[u8; 20], b: &[u8; 20]) -> [u8; 20] {
+ let mut result = [0u8; 20];
+ for i in 0..20 {
+ result[i] = a[i] ^ b[i];
+ }
+ result
+}
+
+fn derive_keys(secret: &[u8], skey: &[u8], outgoing: bool) -> (Rc4, Rc4, [u8; 20]) {
+ let (encrypt_key, decrypt_key) = if outgoing {
+ (
+ sha1(&[b"keyA", secret, skey]),
+ sha1(&[b"keyB", secret, skey]),
+ )
+ } else {
+ (
+ sha1(&[b"keyB", secret, skey]),
+ sha1(&[b"keyA", secret, skey]),
+ )
+ };
+ let mut encrypt = Rc4::new_from_slice(&encrypt_key).expect("20-byte RC4 key");
+ let mut decrypt = Rc4::new_from_slice(&decrypt_key).expect("20-byte RC4 key");
+ // MSE drop-1024: discard the first 1024 keystream bytes.
+ let mut drop = [0u8; 1024];
+ encrypt.apply_keystream(&mut drop);
+ decrypt.apply_keystream(&mut drop);
+ (encrypt, decrypt, decrypt_key)
+}
+
+async fn read_scan_for_needle(
+ read: &mut R,
+ needle: &[u8],
+ max_pad: usize,
+) -> Result {
+ let mut window = Vec::with_capacity(max_pad + needle.len());
+ let mut byte = [0u8; 1];
+ loop {
+ read.read_exact(&mut byte)
+ .await
+ .context("disconnected while scanning MSE handshake")?;
+ window.push(byte[0]);
+ if window.ends_with(needle) {
+ return Ok(window.len() - needle.len());
+ }
+ if window.len() >= max_pad + needle.len() {
+ bail!("MSE pattern not found within {max_pad} pad bytes");
+ }
+ }
+}
+
+async fn read_encrypted(
+ read: &mut R,
+ decrypt: &mut Rc4,
+ bytes: &mut [u8],
+) -> Result<()> {
+ read.read_exact(bytes).await?;
+ decrypt.apply_keystream(bytes);
+ Ok(())
+}
+
+fn random_pad(max: usize) -> Vec {
+ let length = rand::rng().random_range(0..=max);
+ let mut pad = vec![0u8; length];
+ rand::rng().fill_bytes(&mut pad);
+ pad
+}
+
+/// Probe the responder's first 20 bytes within a short window to detect a
+/// plaintext peer (which sends its BT handshake immediately on connect).
+///
+/// Returns `Ok(Some(prefix))` with the 20 bytes read when they start with the
+/// BT protocol prefix (peer is plaintext). Returns `Ok(None)` with the bytes
+/// read so far when they do not (they are the leading bytes of the MSE
+/// responder's DH public key), or when the window elapsed without a full 20
+/// bytes (treat as a slow MSE responder). The partial `sniffed` bytes must be
+/// preserved as the prefix of the DH public key.
+async fn sniff_plaintext(read: &mut R) -> Result<(Option>, Vec)> {
+ let mut sniffed = Vec::with_capacity(BT_PROTOCOL_PREFIX.len());
+ let probe = async {
+ let mut byte = [0u8; 1];
+ while sniffed.len() < BT_PROTOCOL_PREFIX.len() {
+ read.read_exact(&mut byte).await?;
+ sniffed.push(byte[0]);
+ }
+ Ok::<_, std::io::Error>(())
+ };
+ match tokio::time::timeout(PLAINTEXT_SNIFF_TIMEOUT, probe).await {
+ Ok(Ok(())) => {
+ let is_plaintext = sniffed == BT_PROTOCOL_PREFIX;
+ Ok((is_plaintext.then(|| sniffed.clone()), sniffed))
+ }
+ Ok(Err(e)) => Err(e.into()),
+ Err(_elapsed) => {
+ // Window elapsed mid-probe: whatever we got is the leading bytes of
+ // the responder's public key (or a slow plaintext peer). Continue MSE.
+ Ok((None, sniffed))
+ }
+ }
+}
+
+/// Initiate MSE on a connected stream. IA must be the complete 68-byte
+/// BitTorrent handshake and is consumed as part of the MSE exchange.
+///
+/// Before committing to MSE, briefly probes the responder for a plaintext BT
+/// handshake (see [`sniff_plaintext`]); if detected, returns
+/// [`OutgoingOutcome::PlaintextPeer`] so the caller can redial plaintext
+/// instead of waiting out the full MSE read timeout.
+pub async fn outgoing(
+ mut read: R,
+ mut write: W,
+ info_hash: &[u8; 20],
+ initial_payload: &[u8; BT_HANDSHAKE_LEN],
+) -> Result>
+where
+ R: AsyncRead + Unpin,
+ W: AsyncWrite + Unpin,
+{
+ let dh = Dh768::generate(&mut rand::rng());
+ write.write_all(&dh.public_key_bytes()).await?;
+ write.write_all(&random_pad(MAX_PAD)).await?;
+ trace!("sent MSE Ya + PadA, probing responder");
+
+ // Sniff for a plaintext responder before committing to MSE.
+ let (plaintext, sniffed) = sniff_plaintext(&mut read).await?;
+ if plaintext.is_some() {
+ debug!(
+ "peer answered with plaintext BT handshake within sniff window, falling back to plaintext redial"
+ );
+ return Ok(OutgoingOutcome::PlaintextPeer);
+ }
+
+ let mut server_public = [0u8; 96];
+ server_public[..sniffed.len()].copy_from_slice(&sniffed);
+ read.read_exact(&mut server_public[sniffed.len()..])
+ .await
+ .context("disconnected waiting for MSE responder public key")?;
+ let secret = dh
+ .shared_secret(&server_public)
+ .ok_or_else(|| anyhow::anyhow!("MSE degenerate remote DH key"))?;
+ let (mut encrypt, decrypt_base, decrypt_key) = derive_keys(&secret, info_hash, true);
+
+ write.write_all(&sha1(&[b"req1", &secret])).await?;
+ let skey_hash = sha1(&[b"req2", info_hash]);
+ let req3 = sha1(&[b"req3", &secret]);
+ write.write_all(&xor20(&skey_hash, &req3)).await?;
+
+ let pad_c = random_pad(MAX_PAD);
+ let mut encrypted =
+ Vec::with_capacity(VC_LEN + 4 + 2 + pad_c.len() + 2 + initial_payload.len());
+ encrypted.extend_from_slice(&[0u8; VC_LEN]);
+ encrypted.extend_from_slice(&CRYPTO_RC4.to_be_bytes());
+ encrypted.extend_from_slice(&u16::try_from(pad_c.len()).expect("pad_c <= MAX_PAD").to_be_bytes());
+ encrypted.extend_from_slice(&pad_c);
+ encrypted.extend_from_slice(&u16::try_from(BT_HANDSHAKE_LEN).expect("handshake <= 65535").to_be_bytes());
+ encrypted.extend_from_slice(initial_payload);
+ encrypt.apply_keystream(&mut encrypted);
+ write.write_all(&encrypted).await?;
+
+ // PadB is plaintext of unknown length followed by the encrypted VC. Rather
+ // than probing each offset with a cloned decrypt state (libtorrent does the
+ // same), compute the encrypted-VC pattern once and search the raw bytes for
+ // it (matching libtorrent `read_pe_syncvc` / transmission `read_vc`). The
+ // VC is the first 8 keystream bytes of the decrypt stream, so after the
+ // search `decrypt` continues from exactly the right position.
+ let vc_pattern = {
+ let mut pattern = [0u8; VC_LEN];
+ let mut probe = Rc4::new_from_slice(&decrypt_key).expect("20-byte RC4 key");
+ let mut drop = [0u8; 1024];
+ probe.apply_keystream(&mut drop);
+ probe.apply_keystream(&mut pattern);
+ pattern
+ };
+ let mut raw = Vec::with_capacity(MAX_PAD + VC_LEN);
+ while raw.len() < MAX_PAD + VC_LEN {
+ let mut byte = [0u8; 1];
+ read.read_exact(&mut byte).await?;
+ raw.push(byte[0]);
+ if raw.len() >= VC_LEN && raw[raw.len() - VC_LEN..] == vc_pattern {
+ break;
+ }
+ }
+ if raw.len() < VC_LEN || raw[raw.len() - VC_LEN..] != vc_pattern {
+ warn!("MSE verification constant not found within PadB, aborting handshake");
+ bail!("MSE verification constant not found within PadB");
+ }
+ let mut decrypt = decrypt_base;
+ // The VC is the first 8 keystream bytes of the decrypt stream. Decrypt the
+ // actual received VC ciphertext (raw's tail) and confirm it is the all-zero
+ // verification constant, which also advances the stream to the
+ // crypto-select / pad-length fields that follow.
+ let mut vc = raw[raw.len() - VC_LEN..].to_vec();
+ decrypt.apply_keystream(&mut vc);
+ if vc != [0u8; VC_LEN] {
+ warn!("MSE invalid verification constant after PadB, aborting handshake");
+ bail!("MSE invalid verification constant");
+ }
+
+ let mut select = [0u8; 4];
+ read_encrypted(&mut read, &mut decrypt, &mut select).await?;
+ if u32::from_be_bytes(select) != CRYPTO_RC4 {
+ warn!("MSE responder did not select RC4, aborting handshake");
+ bail!("MSE responder did not select RC4");
+ }
+ let mut pad_length = [0u8; 2];
+ read_encrypted(&mut read, &mut decrypt, &mut pad_length).await?;
+ let pad_length = u16::from_be_bytes(pad_length) as usize;
+ if pad_length > MAX_PAD {
+ bail!("MSE PadD exceeds {MAX_PAD} bytes");
+ }
+ let mut pad_d = vec![0u8; pad_length];
+ read_encrypted(&mut read, &mut decrypt, &mut pad_d).await?;
+
+ trace!("MSE outgoing handshake complete, RC4 established");
+ Ok(OutgoingOutcome::Encrypted(
+ Rc4Reader::new(read, decrypt),
+ Rc4Writer::new(write, encrypt),
+ ))
+}
+
+/// Accept either a complete plaintext BitTorrent handshake or MSE. A
+/// nonmatching partial plaintext prefix is retained as the beginning of YA.
+pub async fn incoming(
+ mut read: R,
+ mut write: W,
+ lookup: F,
+) -> Result>
+where
+ R: AsyncRead + Unpin,
+ W: AsyncWrite + Unpin,
+ F: Fn(&[u8; 20]) -> Option<[u8; 20]>,
+{
+ let mut prefix = Vec::with_capacity(BT_PROTOCOL_PREFIX.len());
+ while prefix.len() < BT_PROTOCOL_PREFIX.len() {
+ let mut byte = [0u8; 1];
+ read.read_exact(&mut byte).await?;
+ prefix.push(byte[0]);
+ if prefix != BT_PROTOCOL_PREFIX[..prefix.len()] {
+ break;
+ }
+ }
+ if prefix.len() == BT_PROTOCOL_PREFIX.len() {
+ debug!("peer sent plaintext BT handshake, using plaintext path");
+ return Ok(IncomingOutcome::Plaintext {
+ read: PrefixReader::new(prefix, read),
+ write,
+ });
+ }
+
+ debug!("peer did not send plaintext prefix, attempting MSE handshake");
+
+ let mut client_public = [0u8; 96];
+ client_public[..prefix.len()].copy_from_slice(&prefix);
+ read.read_exact(&mut client_public[prefix.len()..]).await?;
+
+ let dh = Dh768::generate(&mut rand::rng());
+ let secret = dh
+ .shared_secret(&client_public)
+ .ok_or_else(|| anyhow::anyhow!("MSE degenerate remote DH key"))?;
+
+ // The responder sends YB + PadB immediately, before waiting for req1.
+ write.write_all(&dh.public_key_bytes()).await?;
+ write.write_all(&random_pad(MAX_PAD)).await?;
+
+ let req1 = sha1(&[b"req1", &secret]);
+ read_scan_for_needle(&mut read, &req1, MAX_PAD).await?;
+ let mut obfuscated_skey = [0u8; 20];
+ read.read_exact(&mut obfuscated_skey).await?;
+ let skey_hash = xor20(&obfuscated_skey, &sha1(&[b"req3", &secret]));
+ let info_hash =
+ lookup(&skey_hash).ok_or_else(|| anyhow::anyhow!("MSE unknown info hash in SKEY"))?;
+ let (mut encrypt, mut decrypt, _decrypt_key) = derive_keys(&secret, &info_hash, false);
+
+ let mut vc = [0u8; VC_LEN];
+ read_encrypted(&mut read, &mut decrypt, &mut vc).await?;
+ if vc != [0u8; VC_LEN] {
+ warn!("MSE invalid verification constant, aborting handshake");
+ bail!("MSE invalid verification constant");
+ }
+ let mut provide = [0u8; 4];
+ read_encrypted(&mut read, &mut decrypt, &mut provide).await?;
+ if u32::from_be_bytes(provide) & CRYPTO_RC4 == 0 {
+ bail!("MSE peer does not offer RC4");
+ }
+ let mut pad_length = [0u8; 2];
+ read_encrypted(&mut read, &mut decrypt, &mut pad_length).await?;
+ let pad_length = u16::from_be_bytes(pad_length) as usize;
+ if pad_length > MAX_PAD {
+ bail!("MSE PadC exceeds {MAX_PAD} bytes");
+ }
+ let mut pad_c = vec![0u8; pad_length];
+ read_encrypted(&mut read, &mut decrypt, &mut pad_c).await?;
+
+ let mut ia_length = [0u8; 2];
+ read_encrypted(&mut read, &mut decrypt, &mut ia_length).await?;
+ let ia_length = u16::from_be_bytes(ia_length) as usize;
+ if ia_length > BT_HANDSHAKE_LEN {
+ bail!("MSE IA length exceeds {BT_HANDSHAKE_LEN} bytes");
+ }
+ let mut handshake_bytes = vec![0u8; ia_length];
+ read_encrypted(&mut read, &mut decrypt, &mut handshake_bytes).await?;
+
+ // Respond before waiting for the rest of the BT handshake. An initiator
+ // with IA=0 may not send that data until PE4 selects the cipher.
+ let pad_d = random_pad(MAX_PAD);
+ let mut response = Vec::with_capacity(VC_LEN + 4 + 2 + pad_d.len());
+ response.extend_from_slice(&[0u8; VC_LEN]);
+ response.extend_from_slice(&CRYPTO_RC4.to_be_bytes());
+ response.extend_from_slice(&u16::try_from(pad_d.len()).expect("pad_d <= MAX_PAD").to_be_bytes());
+ response.extend_from_slice(&pad_d);
+ encrypt.apply_keystream(&mut response);
+ write.write_all(&response).await?;
+
+ let mut remaining = vec![0u8; BT_HANDSHAKE_LEN - ia_length];
+ read_encrypted(&mut read, &mut decrypt, &mut remaining).await?;
+ handshake_bytes.extend_from_slice(&remaining);
+
+ trace!("MSE incoming handshake complete, RC4 established");
+ Ok(IncomingOutcome::Encrypted {
+ read: Rc4Reader::new(read, decrypt),
+ write: Rc4Writer::new(write, encrypt),
+ handshake_bytes,
+ info_hash,
+ })
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/librqbit/src/mse/stream.rs b/crates/librqbit/src/mse/stream.rs
new file mode 100644
index 000000000..4166c66b7
--- /dev/null
+++ b/crates/librqbit/src/mse/stream.rs
@@ -0,0 +1,268 @@
+//! Transparent RC4 stream wrappers for post-handshake traffic.
+
+use std::io;
+use std::pin::Pin;
+use std::task::{Context, Poll};
+
+use rc4::{Rc4, StreamCipher};
+use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
+
+pub struct Rc4Reader {
+ inner: R,
+ rc4: Rc4,
+}
+
+impl Rc4Reader {
+ pub fn new(inner: R, rc4: Rc4) -> Self {
+ Self { inner, rc4 }
+ }
+}
+
+impl AsyncRead for Rc4Reader {
+ fn poll_read(
+ self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ buf: &mut ReadBuf<'_>,
+ ) -> Poll> {
+ let this = self.get_mut();
+ let before = buf.filled().len();
+ match Pin::new(&mut this.inner).poll_read(cx, buf) {
+ Poll::Ready(Ok(())) => {
+ this.rc4.apply_keystream(&mut buf.filled_mut()[before..]);
+ Poll::Ready(Ok(()))
+ }
+ other => other,
+ }
+ }
+}
+
+pub struct Rc4Writer {
+ inner: W,
+ rc4: Rc4,
+ /// Ciphertext already produced (whose keystream has been consumed) but not
+ /// yet flushed to the underlying stream. Set when a write is Pending or
+ /// partially accepted; the caller must not be asked to re-encrypt.
+ pending: Vec,
+}
+
+impl Rc4Writer {
+ pub fn new(inner: W, rc4: Rc4) -> Self {
+ Self {
+ inner,
+ rc4,
+ pending: Vec::new(),
+ }
+ }
+
+ /// Flush any pending ciphertext to the underlying writer, consuming it.
+ /// Returns `Poll::Ready(Ok(()))` once everything is flushed, `Poll::Pending`
+ /// when the underlying writer is not writable.
+ fn flush_pending(&mut self, cx: &mut Context<'_>) -> Poll>
+ where
+ W: AsyncWrite + Unpin,
+ {
+ let this = self;
+ loop {
+ if this.pending.is_empty() {
+ return Poll::Ready(Ok(()));
+ }
+ match Pin::new(&mut this.inner).poll_write(cx, &this.pending) {
+ Poll::Ready(Ok(n)) => {
+ if n == 0 {
+ return Poll::Ready(Err(io::Error::new(
+ io::ErrorKind::WriteZero,
+ "failed to write RC4 stream",
+ )));
+ }
+ // Consume exactly the accepted prefix; the remaining
+ // ciphertext stays pending for the next poll.
+ this.pending.drain(..n);
+ }
+ Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+ Poll::Pending => return Poll::Pending,
+ }
+ }
+ }
+}
+
+impl AsyncWrite for Rc4Writer {
+ fn poll_write(
+ self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ data: &[u8],
+ ) -> Poll> {
+ let this = self.get_mut();
+ if data.is_empty() {
+ return Poll::Ready(Ok(0));
+ }
+
+ // If a previous poll left ciphertext pending (this is a retry after a
+ // Pending or partial flush), flush it and report the logical write as
+ // complete without re-encrypting the caller's data.
+ if !this.pending.is_empty() {
+ match this.flush_pending(cx) {
+ Poll::Pending => return Poll::Pending,
+ Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+ Poll::Ready(Ok(())) => return Poll::Ready(Ok(data.len())),
+ }
+ }
+
+ // Encrypt the whole buffer once, buffer the ciphertext, then flush it.
+ let mut encrypted = data.to_vec();
+ this.rc4.apply_keystream(&mut encrypted);
+ this.pending = encrypted;
+ match this.flush_pending(cx) {
+ Poll::Pending => Poll::Pending,
+ Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
+ Poll::Ready(Ok(())) => Poll::Ready(Ok(data.len())),
+ }
+ }
+
+ fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+ let this = self.get_mut();
+ match this.flush_pending(cx) {
+ Poll::Pending => Poll::Pending,
+ Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
+ Poll::Ready(Ok(())) => Pin::new(&mut this.inner).poll_flush(cx),
+ }
+ }
+
+ fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+ let this = self.get_mut();
+ match this.flush_pending(cx) {
+ Poll::Pending => Poll::Pending,
+ Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
+ Poll::Ready(Ok(())) => Pin::new(&mut this.inner).poll_shutdown(cx),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rc4::KeyInit;
+ use std::collections::VecDeque;
+ use std::sync::{Arc, Mutex};
+ use tokio::io::AsyncWriteExt;
+
+ enum Action {
+ Pending,
+ Limit(usize),
+ Error,
+ }
+
+ struct FaultWriter {
+ actions: VecDeque,
+ bytes: Arc>>,
+ }
+
+ impl AsyncWrite for FaultWriter {
+ fn poll_write(
+ mut self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ data: &[u8],
+ ) -> Poll> {
+ match self.actions.pop_front() {
+ Some(Action::Pending) => {
+ cx.waker().wake_by_ref();
+ Poll::Pending
+ }
+ Some(Action::Error) => Poll::Ready(Err(io::Error::other(
+ "injected write failure",
+ ))),
+ Some(Action::Limit(limit)) => {
+ let count = limit.min(data.len());
+ if let Ok(mut bytes) = self.bytes.lock() {
+ bytes.extend_from_slice(&data[..count]);
+ }
+ Poll::Ready(Ok(count))
+ }
+ None => {
+ if let Ok(mut bytes) = self.bytes.lock() {
+ bytes.extend_from_slice(data);
+ }
+ Poll::Ready(Ok(data.len()))
+ }
+ }
+ }
+
+ fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> {
+ Poll::Ready(Ok(()))
+ }
+
+ fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> {
+ Poll::Ready(Ok(()))
+ }
+ }
+
+ fn expected(key: &[u8], plaintext: &[u8]) -> Vec {
+ let mut bytes = plaintext.to_vec();
+ rc4::Rc4::new_from_slice(key)
+ .unwrap()
+ .apply_keystream(&mut bytes);
+ bytes
+ }
+
+ #[tokio::test]
+ async fn pending_and_short_writes_preserve_state() -> io::Result<()> {
+ let sink = Arc::new(Mutex::new(Vec::new()));
+ let inner = FaultWriter {
+ actions: VecDeque::from([Action::Pending, Action::Limit(3)]),
+ bytes: sink.clone(),
+ };
+ let key = b"writer-state";
+ let mut writer = Rc4Writer::new(inner, rc4::Rc4::new_from_slice(key).unwrap());
+ writer.write_all(b"abcdefgh").await?;
+ let actual = sink
+ .lock()
+ .map_err(|_| io::Error::other("poisoned test lock"))?
+ .clone();
+ assert_eq!(actual, expected(key, b"abcdefgh"));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn write_error_propagates() -> io::Result<()> {
+ let sink = Arc::new(Mutex::new(Vec::new()));
+ let inner = FaultWriter {
+ actions: VecDeque::from([Action::Error]),
+ bytes: sink.clone(),
+ };
+ let key = b"writer-error";
+ let mut writer = Rc4Writer::new(inner, rc4::Rc4::new_from_slice(key).unwrap());
+ // An underlying write error propagates to the caller; nothing is
+ // accepted by the sink. (Unlike the self-contained rc4 impl we can't
+ // guarantee the RC4 keystream is un-advanced after a failed write, and
+ // in practice a write error tears down the connection anyway.)
+ assert!(writer.write(b"discarded").await.is_err());
+ assert_eq!(
+ sink.lock()
+ .map_err(|_| io::Error::other("poisoned test lock"))?
+ .len(),
+ 0
+ );
+ Ok(())
+ }
+ #[tokio::test]
+ async fn repeated_pending_does_not_reencrypt() -> io::Result<()> {
+ // The underlying writer returns Pending several times before accepting
+ // data. Rc4Writer must not re-encrypt the caller's plaintext on retry
+ // (the ciphertext is buffered once), and the final bytes must match
+ // the single-pass RC4 encryption.
+ let sink = Arc::new(Mutex::new(Vec::new()));
+ let inner = FaultWriter {
+ actions: VecDeque::from([Action::Pending, Action::Pending, Action::Pending]),
+ bytes: sink.clone(),
+ };
+ let key = b"pending-retry";
+ let mut writer = Rc4Writer::new(inner, rc4::Rc4::new_from_slice(key).unwrap());
+ writer.write_all(b"persistent data").await?;
+ let actual = sink
+ .lock()
+ .map_err(|_| io::Error::other("poisoned test lock"))?
+ .clone();
+ assert_eq!(actual, expected(key, b"persistent data"));
+ Ok(())
+ }
+
+}
\ No newline at end of file
diff --git a/crates/librqbit/src/mse/tests.rs b/crates/librqbit/src/mse/tests.rs
new file mode 100644
index 000000000..b55561e61
--- /dev/null
+++ b/crates/librqbit/src/mse/tests.rs
@@ -0,0 +1,298 @@
+use rc4::KeyInit;
+use super::*;
+use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
+
+#[test]
+fn default_mse_mode_is_disabled() {
+ // The default must stay Disabled so merging the feature is a zero-behavior
+ // change; users opt in via SessionOptions::mse_mode.
+ assert_eq!(MseMode::default(), MseMode::Disabled);
+}
+
+fn handshake(info_hash: [u8; 20], peer_id: [u8; 20]) -> [u8; 68] {
+ let mut bytes = [0u8; 68];
+ bytes[..20].copy_from_slice(BT_PROTOCOL_PREFIX);
+ bytes[28..48].copy_from_slice(&info_hash);
+ bytes[48..].copy_from_slice(&peer_id);
+ bytes
+}
+
+#[tokio::test]
+async fn incoming_accepts_zero_length_ia_before_full_handshake() -> Result<()> {
+ let info_hash = [0x42; 20];
+ let expected_skey_hash = sha1(&[b"req2", &info_hash]);
+ let expected_handshake = handshake(info_hash, *b"-RQ0001-012345678901");
+ let (client, server) = duplex(4096);
+ let (mut client_read, mut client_write) = tokio::io::split(client);
+ let (server_read, server_write) = tokio::io::split(server);
+
+ let responder = tokio::spawn(async move {
+ incoming(server_read, server_write, move |skey_hash| {
+ (*skey_hash == expected_skey_hash).then_some(info_hash)
+ })
+ .await
+ });
+
+ let initiator_dh = Dh768::from_secret([0x37; 20]);
+ client_write
+ .write_all(&initiator_dh.public_key_bytes())
+ .await?;
+ let mut responder_public = [0u8; 96];
+ client_read.read_exact(&mut responder_public).await?;
+ let secret = initiator_dh
+ .shared_secret(&responder_public)
+ .context("responder returned an invalid DH key")?;
+ let (mut encrypt, mut decrypt, decrypt_key) = derive_keys(&secret, &info_hash, true);
+
+ client_write.write_all(&sha1(&[b"req1", &secret])).await?;
+ let req3 = sha1(&[b"req3", &secret]);
+ client_write
+ .write_all(&xor20(&expected_skey_hash, &req3))
+ .await?;
+
+ let mut pe3 = Vec::new();
+ pe3.extend_from_slice(&[0u8; VC_LEN]);
+ pe3.extend_from_slice(&CRYPTO_RC4.to_be_bytes());
+ pe3.extend_from_slice(&0u16.to_be_bytes());
+ pe3.extend_from_slice(&0u16.to_be_bytes());
+ encrypt.apply_keystream(&mut pe3);
+ client_write.write_all(&pe3).await?;
+
+ let mut encrypted_vc = [0u8; VC_LEN];
+ // The initiator's decrypt stream and the responder's VC encrypt stream are
+ // the same keystream; rebuild an independent instance (rc4 crate has no
+ // Clone) to compute the encrypted-VC pattern for the needle scan.
+ let mut vc_probe = ::rc4::Rc4::new_from_slice(&decrypt_key).expect("20-byte RC4 key");
+ let mut drop = [0u8; 1024];
+ vc_probe.apply_keystream(&mut drop);
+ vc_probe.apply_keystream(&mut encrypted_vc);
+ read_scan_for_needle(&mut client_read, &encrypted_vc, MAX_PAD).await?;
+ decrypt.apply_keystream(&mut encrypted_vc);
+ assert_eq!(encrypted_vc, [0u8; VC_LEN]);
+
+ let mut crypto_select = [0u8; 4];
+ client_read.read_exact(&mut crypto_select).await?;
+ decrypt.apply_keystream(&mut crypto_select);
+ assert_eq!(u32::from_be_bytes(crypto_select), CRYPTO_RC4);
+
+ let mut pad_d_length = [0u8; 2];
+ client_read.read_exact(&mut pad_d_length).await?;
+ decrypt.apply_keystream(&mut pad_d_length);
+ let mut pad_d = vec![0u8; u16::from_be_bytes(pad_d_length) as usize];
+ client_read.read_exact(&mut pad_d).await?;
+ decrypt.apply_keystream(&mut pad_d);
+
+ let mut encrypted_handshake = expected_handshake;
+ encrypt.apply_keystream(&mut encrypted_handshake);
+ client_write.write_all(&encrypted_handshake).await?;
+ let payload = b"post-handshake payload";
+ let mut encrypted_payload = *payload;
+ encrypt.apply_keystream(&mut encrypted_payload);
+ client_write.write_all(&encrypted_payload).await?;
+
+ let outcome = responder.await??;
+ match outcome {
+ IncomingOutcome::Encrypted {
+ mut read,
+ handshake_bytes,
+ info_hash: resolved_info_hash,
+ ..
+ } => {
+ assert_eq!(resolved_info_hash, info_hash);
+ assert_eq!(handshake_bytes, expected_handshake);
+ let mut received_payload = [0u8; 22];
+ read.read_exact(&mut received_payload).await?;
+ assert_eq!(&received_payload, payload);
+ }
+ IncomingOutcome::Plaintext { .. } => bail!("expected encrypted outcome"),
+ }
+ Ok(())
+}
+
+#[tokio::test]
+async fn fragmented_plaintext_prefix_is_replayed() -> Result<()> {
+ let info_hash = [0x23; 20];
+ let bytes = handshake(info_hash, [0x45; 20]);
+ let (client, server) = duplex(256);
+ let (server_read, server_write) = tokio::io::split(server);
+ let sender = async move {
+ let mut client = client;
+ for byte in bytes {
+ client.write_all(&[byte]).await?;
+ tokio::task::yield_now().await;
+ }
+ Ok::<_, std::io::Error>(())
+ };
+ let receiver = async move {
+ let outcome = incoming(server_read, server_write, |_| None).await?;
+ let mut read = match outcome {
+ IncomingOutcome::Plaintext { read, .. } => read,
+ IncomingOutcome::Encrypted { .. } => bail!("unexpected encrypted outcome"),
+ };
+ let mut replayed = [0u8; 68];
+ read.read_exact(&mut replayed).await?;
+ assert_eq!(replayed, bytes);
+ Ok::<_, anyhow::Error>(())
+ };
+ let (sent, received) = tokio::join!(sender, receiver);
+ sent?;
+ received?;
+ Ok(())
+}
+#[tokio::test]
+async fn duplex_handshake_preserves_payload() -> Result<()> {
+ let info_hash = [0x42; 20];
+ let initial = handshake(info_hash, [0x11; 20]);
+ let (client, server) = duplex(8192);
+ let (client_read, client_write) = tokio::io::split(client);
+ let (server_read, server_write) = tokio::io::split(server);
+
+ let initiator = outgoing(client_read, client_write, &info_hash, &initial);
+ let responder = incoming(server_read, server_write, |candidate| {
+ (candidate == &sha1(&[b"req2", &info_hash])).then_some(info_hash)
+ });
+ let (initiator_result, responder_result) = tokio::join!(initiator, responder);
+ let (mut client_read, mut client_write) = match initiator_result? {
+ OutgoingOutcome::Encrypted(r, w) => (r, w),
+ OutgoingOutcome::PlaintextPeer => bail!("unexpected plaintext peer"),
+ };
+ let outcome = responder_result?;
+ let (mut server_read, mut server_write, received) = match outcome {
+ IncomingOutcome::Encrypted {
+ read,
+ write,
+ handshake_bytes,
+ ..
+ } => (read, write, handshake_bytes),
+ IncomingOutcome::Plaintext { .. } => bail!("unexpected plaintext outcome"),
+ };
+ assert_eq!(received, initial);
+
+ client_write.write_all(b"client payload").await?;
+ let mut client_payload = [0u8; 14];
+ server_read.read_exact(&mut client_payload).await?;
+ assert_eq!(&client_payload, b"client payload");
+
+ server_write.write_all(b"server payload").await?;
+ let mut server_payload = [0u8; 14];
+ client_read.read_exact(&mut server_payload).await?;
+ assert_eq!(&server_payload, b"server payload");
+ Ok(())
+}
+
+#[tokio::test]
+async fn plaintext_first_response_triggers_immediate_fallback() -> Result<()> {
+ // A plaintext peer answers our Ya + PadA with its 68-byte BT handshake
+ // immediately. `outgoing` must detect the `\x13BitTorrent protocol`
+ // prefix and return `PlaintextPeer` well within the 10s read timeout
+ // (2s sniff window is the ceiling here).
+ let info_hash = [0x42; 20];
+ let (client, server) = duplex(8192);
+ let (client_read, client_write) = tokio::io::split(client);
+ let (mut server_read, mut server_write) = tokio::io::split(server);
+ let responder = async move {
+ // Read and discard Ya + PadA.
+ let mut discard = [0u8; 96];
+ server_read.read_exact(&mut discard).await?;
+ // Reply with a plaintext BT handshake.
+ server_write
+ .write_all(&handshake(info_hash, [0x55; 20]))
+ .await?;
+ Ok::<_, std::io::Error>(())
+ };
+
+ let initial = handshake(info_hash, [0x11; 20]);
+ let initiator = async {
+ let started = std::time::Instant::now();
+ let outcome = outgoing(client_read, client_write, &info_hash, &initial).await?;
+ let elapsed = started.elapsed();
+ match outcome {
+ OutgoingOutcome::PlaintextPeer => {
+ assert!(
+ elapsed < PLAINTEXT_SNIFF_TIMEOUT + Duration::from_millis(500),
+ "plaintext fallback took {elapsed:?}, expected <= {PLAINTEXT_SNIFF_TIMEOUT:?}"
+ );
+ Ok::<_, anyhow::Error>(())
+ }
+ OutgoingOutcome::Encrypted(..) => bail!("expected plaintext peer fallback"),
+ }
+ };
+
+ let (init, resp) = tokio::join!(initiator, responder);
+ init?;
+ resp?;
+ Ok(())
+}
+
+#[tokio::test]
+async fn mse_responder_still_works_after_sniff() -> Result<()> {
+ // The sniff reads the first 20 bytes; an MSE responder's public key
+ // must still arrive intact when it does not match the BT prefix.
+ let info_hash = [0x42; 20];
+ let initial = handshake(info_hash, [0x11; 20]);
+ let (client, server) = duplex(8192);
+ let (client_read, client_write) = tokio::io::split(client);
+ let (server_read, server_write) = tokio::io::split(server);
+
+ let initiator = outgoing(client_read, client_write, &info_hash, &initial);
+ let responder = incoming(server_read, server_write, |candidate| {
+ (candidate == &sha1(&[b"req2", &info_hash])).then_some(info_hash)
+ });
+ let (initiator_result, responder_result) = tokio::join!(initiator, responder);
+ let (mut client_read, mut client_write) = match initiator_result? {
+ OutgoingOutcome::Encrypted(r, w) => (r, w),
+ OutgoingOutcome::PlaintextPeer => bail!("unexpected plaintext peer"),
+ };
+ let (mut server_read, mut server_write, received) = match responder_result? {
+ IncomingOutcome::Encrypted {
+ read,
+ write,
+ handshake_bytes,
+ ..
+ } => (read, write, handshake_bytes),
+ IncomingOutcome::Plaintext { .. } => bail!("unexpected plaintext outcome"),
+ };
+ assert_eq!(received, initial);
+ client_write.write_all(b"ping").await?;
+ let mut buf = [0u8; 4];
+ server_read.read_exact(&mut buf).await?;
+ assert_eq!(&buf, b"ping");
+ server_write.write_all(b"pong").await?;
+ client_read.read_exact(&mut buf).await?;
+ assert_eq!(&buf, b"pong");
+ Ok(())
+}
+
+#[tokio::test]
+async fn padb_without_vc_pattern_aborts_handshake() -> Result<()> {
+ // If the responder never sends the encrypted VC within MAX_PAD bytes, the
+ // PadB pattern-search must fail the handshake rather than hang or accept
+ // a corrupted stream. This exercises the search bound + the formal VC
+ // verification added for pattern misdetection.
+ let info_hash = [0x42; 20];
+ let (client, server) = duplex(4096);
+ let (client_read, client_write) = tokio::io::split(client);
+ let (mut server_read, mut server_write) = tokio::io::split(server);
+
+ let responder = async move {
+ // Read and discard Ya + PadA.
+ let mut ya = [0u8; 96];
+ server_read.read_exact(&mut ya).await?;
+ // Send a fixed public key (0x01..) and a large PadB with no VC pattern.
+ let yb = [0x01u8; 96];
+ server_write.write_all(&yb).await?;
+ let padb = vec![0x11u8; MAX_PAD + VC_LEN];
+ server_write.write_all(&padb).await?;
+ Ok::<_, std::io::Error>(())
+ };
+
+ let initial = handshake(info_hash, [0x11; 20]);
+ let initiator = outgoing(client_read, client_write, &info_hash, &initial);
+ let (initiator_result, responder_result) = tokio::join!(initiator, responder);
+ responder_result?;
+ assert!(
+ initiator_result.is_err(),
+ "outgoing should fail when VC is not found within PadB"
+ );
+ Ok(())
+}
diff --git a/crates/librqbit/src/peer_connection.rs b/crates/librqbit/src/peer_connection.rs
index 6bfabe86e..4331cc3c6 100644
--- a/crates/librqbit/src/peer_connection.rs
+++ b/crates/librqbit/src/peer_connection.rs
@@ -4,7 +4,9 @@ use std::{
time::{Duration, Instant},
};
-use crate::{Error, Result, session::CheckedIncomingConnection, stream_connect::ConnectionKind};
+use crate::{
+ Error, Result, mse::MseMode, session::CheckedIncomingConnection, stream_connect::ConnectionKind,
+};
use buffers::{ByteBuf, ByteBufOwned};
use futures::TryFutureExt;
use librqbit_core::{
@@ -77,6 +79,10 @@ pub struct PeerConnectionOptions {
#[serde_as(as = "Option")]
pub keep_alive_interval: Option,
+
+ /// How MSE (Message Stream Encryption) is applied to this peer connection.
+ /// Defaults to [`MseMode::Disabled`].
+ pub mse_mode: MseMode,
}
pub(crate) struct PeerConnection {
@@ -205,7 +211,6 @@ impl PeerConnection {
outgoing_chan: tokio::sync::mpsc::UnboundedReceiver,
have_broadcast: tokio::sync::broadcast::Receiver,
) -> Result<()> {
- use tokio::io::AsyncWriteExt;
let rwtimeout = self
.options
.read_write_timeout
@@ -217,27 +222,22 @@ impl PeerConnection {
.unwrap_or_else(|| Duration::from_secs(10));
let now = Instant::now();
- let (ckind, mut read, mut write) = with_timeout(
- "connecting",
- connect_timeout,
- self.connector.connect(self.addr),
- )
- .await?;
-
- async move {
- self.handler.on_connected(now.elapsed());
-
- let mut write_buf = Box::new([0u8; MAX_MSG_LEN]);
- let handshake = Handshake::new(self.info_hash, self.peer_id);
- let hsz = handshake.serialize_unchecked_len(&mut *write_buf);
- with_timeout(
- "writing",
+ let conn = self
+ .connector
+ .connect_with_handshake(
+ self.addr,
+ &self.info_hash.0,
+ &self.peer_id.0,
+ connect_timeout,
rwtimeout,
- write
- .write_all(&write_buf[..hsz])
- .map_err(Error::WriteHandshake),
+ self.options.mse_mode,
)
.await?;
+ let (ckind, mut read, write) = (conn.kind, conn.read, conn.write);
+
+ async move {
+ self.handler.on_connected(now.elapsed());
+ let write_buf = Box::new([0u8; MAX_MSG_LEN]);
let mut read_buf = ReadBuf::new();
let h = read_buf.read_handshake(&mut read, rwtimeout).await?;
@@ -513,3 +513,145 @@ impl PeerConnection {
}
}
}
+#[cfg(test)]
+mod mse_fallback_tests {
+ use super::*;
+ use crate::vectored_traits::AsyncReadVectoredIntoCompat;
+ use tokio::io::{AsyncReadExt, duplex};
+
+ #[tokio::test]
+ async fn fresh_redial_fallback_uses_a_new_stream() -> anyhow::Result<()> {
+ let (first_client, mut first_peer) = duplex(4096);
+ let (second_client, mut second_peer) = duplex(4096);
+ let (first_read, first_write) = tokio::io::split(first_client);
+ let (second_read, second_write) = tokio::io::split(second_client);
+ let connector = StreamConnector::with_test_connections(vec![
+ (
+ Box::new(first_read.into_vectored_compat()),
+ Box::new(first_write),
+ ),
+ (
+ Box::new(second_read.into_vectored_compat()),
+ Box::new(second_write),
+ ),
+ ]);
+ let peer = async move {
+ // First connection: read Ya (96 bytes) then drop, so MSE fails.
+ let mut first_attempt = [0u8; 96];
+ first_peer.read_exact(&mut first_attempt).await?;
+ drop(first_peer);
+ // Second connection: expect the plaintext BT handshake that
+ // connect_with_handshake wrote on the redial.
+ let mut plaintext = [0u8; 68];
+ second_peer.read_exact(&mut plaintext).await?;
+ assert_eq!(&plaintext[..20], b"\x13BitTorrent protocol");
+ assert_eq!(&plaintext[28..48], &[0x42; 20]);
+ assert_eq!(&plaintext[48..], &[0x11; 20]);
+ Ok::<_, std::io::Error>(())
+ };
+ let client = async {
+ let conn = connector
+ .connect_with_handshake(
+ "127.0.0.1:1".parse()?,
+ &[0x42; 20],
+ &[0x11; 20],
+ Duration::from_secs(1),
+ Duration::from_secs(1),
+ MseMode::Enabled,
+ )
+ .await?;
+ assert!(!conn.mse_applied, "MSE should have failed and fallen back");
+ // connect_with_handshake already wrote the plaintext handshake on
+ // the redial; drain so the peer's read completes.
+ drop(conn.read);
+ assert_eq!(connector.remaining_test_connections()?, 0);
+ Ok::<_, anyhow::Error>(())
+ };
+ let (client_result, peer_result) = tokio::join!(client, peer);
+ client_result?;
+ peer_result?;
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn disabled_skips_mse_and_uses_single_connection() -> anyhow::Result<()> {
+ let (client, mut peer) = duplex(4096);
+ let (read, write) = tokio::io::split(client);
+ let connector = StreamConnector::with_test_connections(vec![(
+ Box::new(read.into_vectored_compat()),
+ Box::new(write),
+ )]);
+ let peer = async move {
+ // Disabled MSE: the peer must receive the plaintext handshake
+ // directly (68 bytes), never Ya + PadA (96+ bytes first).
+ let mut plaintext = [0u8; 68];
+ peer.read_exact(&mut plaintext).await?;
+ assert_eq!(&plaintext[..20], b"\x13BitTorrent protocol");
+ assert_eq!(&plaintext[28..48], &[0x42; 20]);
+ assert_eq!(&plaintext[48..], &[0x11; 20]);
+ Ok::<_, std::io::Error>(())
+ };
+ let client = async {
+ let conn = connector
+ .connect_with_handshake(
+ "127.0.0.1:1".parse()?,
+ &[0x42; 20],
+ &[0x11; 20],
+ Duration::from_secs(1),
+ Duration::from_secs(1),
+ MseMode::Disabled,
+ )
+ .await?;
+ assert!(!conn.mse_applied, "MSE must not be attempted in Disabled mode");
+ // connect_with_handshake wrote the plaintext handshake already.
+ drop(conn.read);
+ // Only one connection consumed: mse::outgoing was never invoked.
+ assert_eq!(connector.remaining_test_connections()?, 0);
+ Ok::<_, anyhow::Error>(())
+ };
+ let (client_result, peer_result) = tokio::join!(client, peer);
+ client_result?;
+ peer_result?;
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn forced_mse_failure_returns_error_without_redial() -> anyhow::Result<()> {
+ let (client, mut peer) = duplex(4096);
+ let (read, write) = tokio::io::split(client);
+ let connector = StreamConnector::with_test_connections(vec![(
+ Box::new(read.into_vectored_compat()),
+ Box::new(write),
+ )]);
+ let peer = async move {
+ // Read Ya (96 bytes) then drop, so MSE fails.
+ let mut ya = [0u8; 96];
+ peer.read_exact(&mut ya).await?;
+ drop(peer);
+ Ok::<_, std::io::Error>(())
+ };
+ let client = async {
+ let result = connector
+ .connect_with_handshake(
+ "127.0.0.1:1".parse()?,
+ &[0x42; 20],
+ &[0x11; 20],
+ Duration::from_secs(1),
+ Duration::from_secs(1),
+ MseMode::Forced,
+ )
+ .await;
+ assert!(
+ matches!(result, Err(Error::MseForced)),
+ "Forced mode must error on MSE failure"
+ );
+ // No redial in Forced mode: exactly one connection was consumed.
+ assert_eq!(connector.remaining_test_connections()?, 0);
+ Ok::<_, anyhow::Error>(())
+ };
+ let (client_result, peer_result) = tokio::join!(client, peer);
+ client_result?;
+ peer_result?;
+ Ok(())
+ }
+}
diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs
index 75071b9c5..92ef0bd9f 100644
--- a/crates/librqbit/src/session.rs
+++ b/crates/librqbit/src/session.rs
@@ -23,6 +23,7 @@ use crate::{
limits::{Limits, LimitsConfig},
listen::{Accept, ListenerOptions},
merge_streams::merge_streams,
+ mse::MseMode,
peer_connection::PeerConnectionOptions,
read_buf::ReadBuf,
session_persistence::{SessionPersistenceStore, json::JsonSessionPersistenceStore},
@@ -32,7 +33,8 @@ use crate::{
BoxStorageFactory, StorageFactoryExt, TorrentStorage, filesystem::FilesystemStorageFactory,
},
stream_connect::{
- ConnectionKind, ConnectionOptions, SocksProxyConfig, StreamConnector, StreamConnectorArgs,
+ ConnectionKind, ConnectionOptions, IncomingHandshake, SocksProxyConfig, StreamConnector,
+ StreamConnectorArgs,
},
torrent_state::{
ManagedTorrentHandle, ManagedTorrentLocked, ManagedTorrentOptions, ManagedTorrentState,
@@ -67,6 +69,7 @@ use librqbit_utp::BindDevice;
use parking_lot::RwLock;
use peer_binary_protocol::Handshake;
use serde::{Deserialize, Serialize};
+use sha1w::{ISha1, Sha1};
use tokio::sync::Notify;
use tokio_util::sync::{CancellationToken, DropGuard};
use tracing::{Instrument, debug, debug_span, error, info, trace, warn};
@@ -151,6 +154,7 @@ pub struct Session {
pub ipv4_only: bool,
pub peer_limit: Option,
client_name_and_version: String,
+ mse_mode: MseMode,
}
async fn torrent_from_url(
@@ -479,6 +483,10 @@ pub struct SessionOptions {
/// Override the client name and version used in User-Agent headers and
/// peer extended handshakes. Defaults to "rqbit X.Y.Z".
pub client_name_and_version: Option,
+
+ /// How MSE (Message Stream Encryption) is applied to peer connections.
+ /// Defaults to [`MseMode::Disabled`].
+ pub mse_mode: MseMode,
}
impl Default for SessionOptions {
@@ -507,6 +515,7 @@ impl Default for SessionOptions {
disable_local_service_discovery: false,
ipv4_only: false,
client_name_and_version: None,
+ mse_mode: MseMode::default(),
}
}
}
@@ -632,11 +641,15 @@ impl Session {
} else {
None
};
- let peer_opts = opts
+ let mut peer_opts = opts
.connect
.as_ref()
.and_then(|p| p.peer_opts)
.unwrap_or_default();
+ // The session-level MSE mode is authoritative for outgoing
+ // connections (including magnet metadata reads), regardless of any
+ // per-connection PeerConnectionOptions.
+ peer_opts.mse_mode = opts.mse_mode;
async fn persistence_factory(
opts: &SessionOptions,
@@ -806,6 +819,7 @@ impl Session {
disable_trackers: opts.disable_trackers,
peer_limit: opts.peer_limit,
client_name_and_version,
+ mse_mode: opts.mse_mode,
#[cfg(feature = "disable-upload")]
_disable_upload: opts.disable_upload,
@@ -905,7 +919,7 @@ impl Session {
self: Arc,
addr: SocketAddr,
kind: ConnectionKind,
- mut reader: BoxAsyncReadVectored,
+ reader: BoxAsyncReadVectored,
writer: BoxAsyncWrite,
) -> anyhow::Result<(Arc, CheckedIncomingConnection)> {
let rwtimeout = self
@@ -929,12 +943,56 @@ impl Session {
bail!("Incoming ip {incoming_ip} is not in allowlist");
}
- let mut read_buf = ReadBuf::new();
- let h = read_buf
- .read_handshake(&mut reader, rwtimeout)
- .await
- .context("error reading handshake")?;
- trace!("received handshake from {addr}: {:?}", h);
+ // Snapshot the (SKEY hash -> info_hash) mapping for every known torrent
+ // so the MSE acceptor can resolve the peer's obfuscated info hash.
+ let torrent_keys: Vec<([u8; 20], [u8; 20])> = self
+ .db
+ .read()
+ .torrents
+ .values()
+ .map(|torrent| {
+ let info_hash = torrent.info_hash().0;
+ let mut h = Sha1::new();
+ h.update(b"req2");
+ h.update(&info_hash);
+ (h.finish(), info_hash)
+ })
+ .collect();
+
+ let lookup = |skey_hash: &[u8; 20]| -> Option<[u8; 20]> {
+ torrent_keys
+ .iter()
+ .find(|(k, _)| k == skey_hash)
+ .map(|(_, info_hash)| *info_hash)
+ };
+
+ let incoming = crate::stream_connect::accept_with_handshake(
+ addr,
+ reader,
+ writer,
+ rwtimeout,
+ self.mse_mode,
+ lookup,
+ )
+ .await?;
+
+ self.finish_incoming_connection(addr, kind, incoming).await
+ }
+
+ async fn finish_incoming_connection(
+ self: Arc,
+ addr: SocketAddr,
+ kind: ConnectionKind,
+ incoming: IncomingHandshake,
+ ) -> anyhow::Result<(Arc, CheckedIncomingConnection)> {
+ let IncomingHandshake {
+ handshake: h,
+ read: reader,
+ write: writer,
+ read_buf,
+ encrypted,
+ } = incoming;
+ trace!("received handshake from {addr}: {:?} (encrypted={encrypted})", h);
if h.peer_id == self.peer_id {
bail!("seems like we are connecting to ourselves, ignoring");
@@ -1037,6 +1095,7 @@ impl Session {
keep_alive_interval: other
.keep_alive_interval
.or(self.peer_opts.keep_alive_interval),
+ mse_mode: self.mse_mode,
}
}
@@ -1255,8 +1314,10 @@ impl Session {
let peer_rx = make_peer_rx().context(
"no known way to resolve peers (no DHT, no trackers, no initial_peers)",
)?;
+ let mut magnet_peer_opts = opts.peer_opts.unwrap_or_default();
+ magnet_peer_opts.mse_mode = self.mse_mode;
let resolved_magnet = self
- .resolve_magnet(info_hash, peer_rx, &trackers, opts.peer_opts)
+ .resolve_magnet(info_hash, peer_rx, &trackers, Some(magnet_peer_opts))
.await?;
// Add back seen_peers into the peer stream, as we consumed some peers
@@ -1350,6 +1411,7 @@ impl Session {
force_tracker_interval: opts.force_tracker_interval,
peer_connect_timeout: peer_opts.connect_timeout,
peer_read_write_timeout: peer_opts.read_write_timeout,
+ mse_mode: self.mse_mode,
allow_overwrite: opts.overwrite,
output_folder,
ratelimits: opts.ratelimits,
diff --git a/crates/librqbit/src/stream_connect.rs b/crates/librqbit/src/stream_connect.rs
index ab9b0e9b1..3bb986fa4 100644
--- a/crates/librqbit/src/stream_connect.rs
+++ b/crates/librqbit/src/stream_connect.rs
@@ -4,13 +4,17 @@ use anyhow::{Context, bail};
use librqbit_dualstack_sockets::ConnectOpts;
use librqbit_utp::{BindDevice, UtpSocketUdp};
use serde::Serialize;
-use tracing::debug;
use crate::{
- Error, PeerConnectionOptions, Result,
+ Error, PeerConnectionOptions, Result, mse::{MseMode, OutgoingOutcome},
+ peer_connection::with_timeout,
+ read_buf::ReadBuf,
type_aliases::{BoxAsyncReadVectored, BoxAsyncWrite},
vectored_traits::AsyncReadVectoredIntoCompat,
};
+use peer_binary_protocol::Handshake;
+use tokio::io::AsyncWriteExt;
+use tracing::{debug, warn};
#[derive(Debug, Clone, Copy, Serialize)]
pub enum ConnectionKind {
@@ -51,6 +55,29 @@ impl Default for ConnectionOptions {
}
}
+/// Result of connecting and (optionally) performing an MSE handshake.
+pub struct OutgoingHandshake {
+ pub kind: ConnectionKind,
+ pub read: BoxAsyncReadVectored,
+ pub write: BoxAsyncWrite,
+ /// True when the MSE handshake succeeded and consumed the BT handshake as
+ /// its initial payload (IA); the caller must not write a plaintext
+ /// handshake in that case.
+ #[allow(dead_code)] // read by the MSE fallback tests
+ pub mse_applied: bool,
+}
+
+/// Result of accepting a connection and reading its (plaintext or MSE) BT
+/// handshake.
+pub(crate) struct IncomingHandshake {
+ pub handshake: Handshake,
+ pub read: BoxAsyncReadVectored,
+ pub write: BoxAsyncWrite,
+ pub read_buf: ReadBuf,
+ /// True when the handshake arrived over an MSE-encrypted stream.
+ pub encrypted: bool,
+}
+
#[derive(Debug, Clone)]
pub(crate) struct SocksProxyConfig {
pub host: String,
@@ -125,7 +152,6 @@ gen_stats!(ConnectStatsAtomic ConnectStatsSnapshot, [], [
utp PerFamilyAtomic PerFamilySnapshot
]);
-#[derive(Debug)]
pub(crate) struct StreamConnector {
proxy_config: Option,
enable_tcp: bool,
@@ -133,9 +159,43 @@ pub(crate) struct StreamConnector {
utp_socket: Option>,
stats: ConnectStatsAtomic,
ipv4_only: bool,
+ // Pre-built connections consumed by `connect()` in test builds, in order.
+ #[cfg(test)]
+ test_connections:
+ parking_lot::Mutex>,
+}
+
+impl std::fmt::Debug for StreamConnector {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("StreamConnector")
+ .field("proxy_config", &self.proxy_config)
+ .field("enable_tcp", &self.enable_tcp)
+ .field("bind_device", &self.bind_device)
+ .field("utp_socket", &self.utp_socket)
+ .field("ipv4_only", &self.ipv4_only)
+ .finish()
+ }
}
impl StreamConnector {
+ #[cfg(test)]
+ pub fn with_test_connections(conns: Vec<(BoxAsyncReadVectored, BoxAsyncWrite)>) -> Self {
+ Self {
+ proxy_config: None,
+ enable_tcp: true,
+ utp_socket: None,
+ bind_device: None,
+ stats: Default::default(),
+ ipv4_only: false,
+ test_connections: parking_lot::Mutex::new(conns.into()),
+ }
+ }
+
+ #[cfg(test)]
+ pub fn remaining_test_connections(&self) -> anyhow::Result {
+ Ok(self.test_connections.lock().len())
+ }
+
pub async fn new(config: StreamConnectorArgs) -> anyhow::Result {
#[allow(clippy::single_match)]
match (
@@ -158,6 +218,8 @@ impl StreamConnector {
bind_device: config.bind_device,
stats: Default::default(),
ipv4_only: config.ipv4_only,
+ #[cfg(test)]
+ test_connections: Default::default(),
})
}
@@ -211,6 +273,11 @@ impl StreamConnector {
&self,
addr: SocketAddr,
) -> Result<(ConnectionKind, BoxAsyncReadVectored, BoxAsyncWrite)> {
+ #[cfg(test)]
+ if let Some((r, w)) = self.test_connections.lock().pop_front() {
+ return Ok((ConnectionKind::Tcp, r, w));
+ }
+
if addr.port() == 0 {
return Err(Error::Anyhow(anyhow::anyhow!(
"invalid peer address (port 0): {}",
@@ -323,4 +390,214 @@ impl StreamConnector {
};
}
}
+
+ /// Connect to `addr` and complete the BitTorrent handshake, optionally
+ /// wrapping the stream in MSE (Message Stream Encryption) per `mse_mode`.
+ ///
+ /// Serializes the 68-byte BT handshake once; when MSE succeeds it is
+ /// consumed as the MSE initial payload (IA), otherwise it is written
+ /// plaintext. On MSE failure or a plaintext peer, redials plaintext on a
+ /// fresh connection (never reuses the MSE-polluted stream). `Forced` mode
+ /// errors instead of downgrading. The returned streams are always
+ /// handshake-complete: the caller must not write a BT handshake again.
+ pub async fn connect_with_handshake(
+ &self,
+ addr: SocketAddr,
+ info_hash: &[u8; 20],
+ peer_id: &[u8; 20],
+ connect_timeout: Duration,
+ rwtimeout: Duration,
+ mse_mode: MseMode,
+ ) -> Result {
+ let (ckind, read, write) =
+ with_timeout("connecting", connect_timeout, self.connect(addr)).await?;
+
+ let handshake = Handshake::new(
+ librqbit_core::hash_id::Id20::new(*info_hash),
+ librqbit_core::hash_id::Id20::new(*peer_id),
+ );
+ let mut write_buf = [0u8; 68];
+ let hsz = handshake.serialize_unchecked_len(&mut write_buf);
+
+ if mse_mode != MseMode::Disabled {
+ let mse_outcome = match tokio::time::timeout(
+ rwtimeout,
+ crate::mse::outgoing(read, write, info_hash, &write_buf),
+ )
+ .await
+ {
+ Ok(Ok(outcome)) => outcome,
+ Ok(Err(e)) => {
+ if mse_mode == MseMode::Forced {
+ warn!(?addr, "MSE forced but handshake failed, dropping peer: {e:#}");
+ return Err(Error::MseForced);
+ }
+ debug!(?addr, "MSE handshake failed, redialing plaintext: {e:#}");
+ return self
+ .redial_plaintext(addr, connect_timeout, rwtimeout, &write_buf, hsz)
+ .await;
+ }
+ Err(_elapsed) => {
+ if mse_mode == MseMode::Forced {
+ warn!(?addr, "MSE forced but handshake timed out, dropping peer");
+ return Err(Error::MseForced);
+ }
+ debug!(?addr, "MSE handshake timed out, redialing plaintext");
+ return self
+ .redial_plaintext(addr, connect_timeout, rwtimeout, &write_buf, hsz)
+ .await;
+ }
+ };
+
+ match mse_outcome {
+ OutgoingOutcome::Encrypted(r, w) => {
+ debug!(?addr, "MSE handshake succeeded, RC4 established");
+ return Ok(OutgoingHandshake {
+ kind: ckind,
+ read: Box::new(r.into_vectored_compat()),
+ write: Box::new(w),
+ mse_applied: true,
+ });
+ }
+ OutgoingOutcome::PlaintextPeer => {
+ if mse_mode == MseMode::Forced {
+ warn!(?addr, "MSE forced but peer answered plaintext, dropping peer");
+ return Err(Error::MseForced);
+ }
+ debug!(?addr, "peer answered plaintext, redialing plaintext");
+ return self
+ .redial_plaintext(addr, connect_timeout, rwtimeout, &write_buf, hsz)
+ .await;
+ }
+ }
+ }
+
+ Self::finish_plaintext_handshake(ckind, read, write, &write_buf, hsz, rwtimeout).await
+ }
+
+ /// Redial a fresh plaintext connection after MSE failed or the peer
+ /// answered plaintext, writing the serialized BT handshake.
+ async fn redial_plaintext(
+ &self,
+ addr: SocketAddr,
+ connect_timeout: Duration,
+ rwtimeout: Duration,
+ write_buf: &[u8; 68],
+ hsz: usize,
+ ) -> Result {
+ let (nk, nr, nw) =
+ with_timeout("connecting", connect_timeout, self.connect(addr)).await?;
+ Self::finish_plaintext_handshake(nk, nr, nw, write_buf, hsz, rwtimeout).await
+ }
+
+ /// Write the serialized BT handshake on a plaintext stream and wrap it.
+ async fn finish_plaintext_handshake(
+ ckind: ConnectionKind,
+ read: BoxAsyncReadVectored,
+ write: BoxAsyncWrite,
+ write_buf: &[u8; 68],
+ hsz: usize,
+ rwtimeout: Duration,
+ ) -> Result {
+ use futures::TryFutureExt;
+ let mut write = write;
+ with_timeout(
+ "writing",
+ rwtimeout,
+ write.write_all(&write_buf[..hsz]).map_err(Error::WriteHandshake),
+ )
+ .await?;
+ Ok(OutgoingHandshake { kind: ckind, read, write, mse_applied: false })
+ }
+}
+
+/// Accept an incoming connection and read its BT handshake, optionally
+/// attempting the MSE (Message Stream Encryption) handshake per `mse_mode`.
+///
+/// With MSE enabled, probes the peer for a plaintext BT prefix; if it is a
+/// plaintext peer it is accepted unless `mse_mode == Forced`, otherwise the
+/// MSE acceptor runs (resolving the obfuscated info hash via `lookup`). The
+/// returned streams are ready for post-handshake traffic.
+pub(crate) async fn accept_with_handshake(
+ addr: SocketAddr,
+ reader: BoxAsyncReadVectored,
+ writer: BoxAsyncWrite,
+ rwtimeout: Duration,
+ mse_mode: MseMode,
+ lookup: F,
+) -> anyhow::Result
+where
+ F: Fn(&[u8; 20]) -> Option<[u8; 20]>,
+{
+ use crate::mse::IncomingOutcome;
+ use tokio::io::AsyncReadExt;
+
+ if mse_mode == MseMode::Disabled {
+ let mut read_buf = ReadBuf::new();
+ let mut reader = reader;
+ let h = read_buf
+ .read_handshake(&mut reader, rwtimeout)
+ .await
+ .context("error reading handshake")?;
+ return Ok(IncomingHandshake {
+ handshake: h,
+ read: reader,
+ write: writer,
+ read_buf,
+ encrypted: false,
+ });
+ }
+
+ let incoming = crate::mse::incoming(reader, writer, lookup);
+ let incoming = tokio::time::timeout(rwtimeout, incoming)
+ .await
+ .context("MSE incoming handshake timed out")??;
+
+ match incoming {
+ IncomingOutcome::Encrypted {
+ read,
+ write,
+ handshake_bytes,
+ info_hash,
+ } => {
+ let (h, _size) = Handshake::deserialize(&handshake_bytes[..])
+ .map_err(|e| anyhow::anyhow!("error deserializing MSE handshake: {e:?}"))?;
+ if h.info_hash.0 != info_hash {
+ bail!("MSE handshake info hash does not match SKEY");
+ }
+ Ok(IncomingHandshake {
+ handshake: h,
+ read: Box::new(read.into_vectored_compat()),
+ write: Box::new(write),
+ read_buf: ReadBuf::new(),
+ encrypted: true,
+ })
+ }
+ IncomingOutcome::Plaintext { read, write } => {
+ if mse_mode == MseMode::Forced {
+ warn!(?addr, "MSE forced, rejecting plaintext connection");
+ bail!("MSE is forced, rejecting plaintext connection from {addr}");
+ }
+ // 9.0's `ReadBuf::read_handshake` performs a single `read()`
+ // before deserializing; a `PrefixReader` replaying the 20
+ // consumed prefix bytes would satisfy it with only those bytes.
+ // Read the full 68-byte handshake ourselves (read_exact loops),
+ // mirroring the Encrypted branch.
+ let mut handshake_bytes = [0u8; 68];
+ let mut read = read;
+ read.read_exact(&mut handshake_bytes)
+ .await
+ .context("error reading fragmented plaintext handshake")?;
+ let (h, _size) = Handshake::deserialize(&handshake_bytes[..]).map_err(|e| {
+ anyhow::anyhow!("error deserializing plaintext handshake: {e:?}")
+ })?;
+ Ok(IncomingHandshake {
+ handshake: h,
+ read: Box::new(read.into_vectored_compat()),
+ write: Box::new(write),
+ read_buf: ReadBuf::new(),
+ encrypted: false,
+ })
+ }
+ }
}
diff --git a/crates/librqbit/src/torrent_state/live/mod.rs b/crates/librqbit/src/torrent_state/live/mod.rs
index b4146d8a8..af70c5734 100644
--- a/crates/librqbit/src/torrent_state/live/mod.rs
+++ b/crates/librqbit/src/torrent_state/live/mod.rs
@@ -487,6 +487,7 @@ impl TorrentStateLive {
let options = PeerConnectionOptions {
connect_timeout: self.shared.options.peer_connect_timeout,
read_write_timeout: self.shared.options.peer_read_write_timeout,
+ mse_mode: self.shared.options.mse_mode,
..Default::default()
};
let peer_connection = PeerConnection::new(
@@ -552,6 +553,7 @@ impl TorrentStateLive {
let options = PeerConnectionOptions {
connect_timeout: state.shared.options.peer_connect_timeout,
read_write_timeout: state.shared.options.peer_read_write_timeout,
+ mse_mode: state.shared.options.mse_mode,
..Default::default()
};
let peer_connection = PeerConnection::new(
diff --git a/crates/librqbit/src/torrent_state/mod.rs b/crates/librqbit/src/torrent_state/mod.rs
index cfe483cdd..a29630bfd 100644
--- a/crates/librqbit/src/torrent_state/mod.rs
+++ b/crates/librqbit/src/torrent_state/mod.rs
@@ -112,6 +112,7 @@ pub(crate) struct ManagedTorrentOptions {
pub force_tracker_interval: Option,
pub peer_connect_timeout: Option,
pub peer_read_write_timeout: Option,
+ pub mse_mode: crate::mse::MseMode,
pub allow_overwrite: bool,
pub output_folder: PathBuf,
pub ratelimits: LimitsConfig,
diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs
index a171274f2..39d40eb21 100644
--- a/crates/rqbit/src/main.rs
+++ b/crates/rqbit/src/main.rs
@@ -691,6 +691,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result
runtime_worker_threads: Some(opts.max_blocking_threads as usize),
ipv4_only: opts.ipv4_only,
client_name_and_version: None,
+ mse_mode: Default::default(),
};
#[allow(clippy::needless_update)]