diff --git a/.gitignore b/.gitignore index 911faa0..d4791f5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ btest_original .claude/ .env +proto-test/venv/ +**/__pycache__/ diff --git a/Cargo.lock b/Cargo.lock index b8ef6a3..5dd2f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -82,15 +88,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "btest-rs" -version = "0.1.0" +version = "0.3.0" dependencies = [ "anyhow", "bytes", "clap", "md-5", + "num-bigint", + "num-integer", + "num-traits", "rand", + "sha2", "socket2 0.5.10", "thiserror", "tokio", @@ -156,6 +175,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -166,14 +200,34 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", ] [[package]] @@ -213,6 +267,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hybrid-array" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813" +dependencies = [ + "typenum", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -262,7 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -291,6 +354,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -421,6 +512,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.2", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index bbfc787..c4f6fd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "btest-rs" -version = "0.1.0" +version = "0.3.0" edition = "2021" -description = "MikroTik Bandwidth Test (btest) server and client — a Rust reimplementation" -license = "MIT" +description = "MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth — a Rust reimplementation" +license = "MIT AND Apache-2.0" repository = "https://github.com/samm-git/btest-opensource" keywords = ["mikrotik", "bandwidth", "btest", "network", "benchmarking"] categories = ["command-line-utilities", "network-programming"] @@ -27,6 +27,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } rand = "0.8" socket2 = "0.5" anyhow = "1.0.102" +num-bigint = "0.4.6" +num-traits = "0.2.19" +num-integer = "0.1.46" +sha2 = "0.11.0" [profile.release] opt-level = 3 diff --git a/LICENSE b/LICENSE index 3481e39..7f85cb2 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,11 @@ MIT License Copyright (c) 2026 btest-rs contributors Based on btest-opensource by Alex Samorukov (https://github.com/samm-git/btest-opensource) -Original work Copyright (c) 2016 Alex Samorukov +Original work Copyright (c) 2016 Alex Samorukov (MIT License) + +EC-SRP5 authentication based on research by Margin Research +(https://github.com/MarginResearch/mikrotik_authentication) +Original work Copyright (c) 2022 Margin Research (Apache License 2.0) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,3 +26,10 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +NOTICE: This project includes code derived from works under the Apache License 2.0. +The EC-SRP5 elliptic curve implementation is based on MarginResearch/mikrotik_authentication. +See https://github.com/MarginResearch/mikrotik_authentication/blob/master/LICENSE +for the full Apache 2.0 license text. diff --git a/README.md b/README.md index 1289997..2c27a31 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,23 @@ The MikroTik btest protocol uses: See the [original protocol documentation](btest-opensource/README.md) for wire-format details. +## Authentication + +Both MD5 (legacy) and EC-SRP5 (RouterOS >= 6.43) authentication are supported: + +```bash +# Server with MD5 auth (legacy clients) +btest -s -a admin -p password + +# Server with EC-SRP5 auth (modern RouterOS clients) +btest -s -a admin -p password --ecsrp5 + +# Client auto-detects auth type +btest -c 192.168.88.1 -r -a admin -p password +``` + ## Known Limitations -- **EC-SRP5 authentication** (RouterOS >= 6.43) is not yet supported for client mode. Server mode works fine with MD5 auth. Disable auth on the MikroTik btest server as a workaround. - **Multi-connection UDP** is supported. MikroTik's multi-connection mode sends from multiple source ports which are all accepted by the server. ## Testing @@ -148,8 +162,9 @@ scripts/test-docker.sh # Docker container test ## Credits -- **[btest-opensource](https://github.com/samm-git/btest-opensource)** by [Alex Samorukov](https://github.com/samm-git) - Original C implementation and protocol reverse-engineering that made this project possible. Licensed under MIT. -- **MikroTik** - Creator of the bandwidth test protocol and RouterOS. +- **[btest-opensource](https://github.com/samm-git/btest-opensource)** by [Alex Samorukov](https://github.com/samm-git) — Original C implementation and protocol reverse-engineering. Licensed under MIT. +- **[Margin Research](https://github.com/MarginResearch/mikrotik_authentication)** — EC-SRP5 authentication reverse-engineering (Curve25519 Weierstrass, SRP key exchange). Licensed under Apache 2.0. +- **MikroTik** — Creator of the bandwidth test protocol and RouterOS. ## License diff --git a/docs/architecture.md b/docs/architecture.md index 56c1ebd..eb74502 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,12 +50,20 @@ sequenceDiagram alt No auth configured SRV->>TCP: AUTH_OK [01 00 00 00] - else MD5 auth + else MD5 auth (RouterOS < 6.43) SRV->>TCP: AUTH_REQUIRED [02 00 00 00] SRV->>TCP: Challenge [16 random bytes] MK->>TCP: Response [16 hash + 32 username] Note over SRV: Verify MD5(pass + MD5(pass + challenge)) SRV->>TCP: AUTH_OK or AUTH_FAILED + else EC-SRP5 auth (RouterOS >= 6.43, --ecsrp5 flag) + SRV->>TCP: EC-SRP5 [03 00 00 00] + MK->>TCP: [len][username\0][client_pubkey:32][parity:1] + SRV->>TCP: [len][server_pubkey:32][parity:1][salt:16] + MK->>TCP: [len][client_proof:32] + SRV->>TCP: [len][server_proof:32] + Note over SRV: Curve25519 Weierstrass EC-SRP5
See docs/ecsrp5-research.md + SRV->>TCP: AUTH_OK [01 00 00 00] end alt TCP mode @@ -179,6 +187,7 @@ btest-rs/ │ ├── lib.rs # Public API (used by integration tests) │ ├── protocol.rs # Wire format: Command, StatusMessage, constants │ ├── auth.rs # MD5 challenge-response authentication +│ ├── ecsrp5.rs # EC-SRP5 authentication (Curve25519 Weierstrass) │ ├── server.rs # Server mode: listener, TCP/UDP handlers │ ├── client.rs # Client mode: connector, TCP/UDP handlers │ └── bandwidth.rs # Rate limiting, formatting, shared state diff --git a/docs/ecsrp5-research.md b/docs/ecsrp5-research.md new file mode 100644 index 0000000..c9a6708 --- /dev/null +++ b/docs/ecsrp5-research.md @@ -0,0 +1,238 @@ +# EC-SRP5 Authentication Research + +## Summary + +MikroTik RouterOS >= 6.43 uses EC-SRP5 (Elliptic Curve Secure Remote Password) for authentication. When the btest server has auth enabled, it responds with `03 00 00 00` instead of `02 00 00 00` (legacy MD5). + +**Status: Fully reverse-engineered and verified.** Python prototype authenticates successfully against MikroTik RouterOS 7.x btest server. + +## Discovery Process + +### Step 1: Initial Capture + +Connected our client to MikroTik btest server with auth enabled. Server responded with `03 00 00 00` and waited for the client to initiate. + +### Step 2: Winbox EC-SRP5 Verification + +Tested the EC-SRP5 crypto implementation (from [MarginResearch/mikrotik_authentication](https://github.com/MarginResearch/mikrotik_authentication)) against MikroTik's Winbox port (8291). **Authentication succeeded**, confirming the elliptic curve math is correct. + +### Step 3: Framing Discovery via MITM + +The Winbox `[len][0x06][payload]` framing was rejected by the btest port. To discover the correct framing, we built a MITM proxy (`proto-test/btest_mitm.py`) and routed a MikroTik client through it to the MikroTik server. + +**Finding: btest uses `[len][payload]` — identical to Winbox but without the `0x06` handler byte.** + +### Step 4: Successful Authentication + +Updated the Python prototype to use `[len][payload]` framing. EC-SRP5 authentication against MikroTik's btest server succeeded and data transfer began. + +## Protocol Specification + +### Auth Trigger + +After the standard btest handshake (HELLO + Command), the server responds: + +``` +01 00 00 00 → No auth required +02 00 00 00 → MD5 challenge-response (RouterOS < 6.43) +03 00 00 00 → EC-SRP5 (RouterOS >= 6.43) +``` + +### EC-SRP5 Handshake (4 messages after `03 00 00 00`) + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + + Note over S: Server sent 03 00 00 00 + + C->>S: MSG1: [len][username\0][client_pubkey:32][parity:1] + Note over C: len = 1 byte, total = len + 1 bytes + + S->>C: MSG2: [len][server_pubkey:32][parity:1][salt:16] + Note over S: len = 49 (0x31) + + C->>S: MSG3: [len][client_confirmation:32] + Note over C: len = 32 (0x20) + + S->>C: MSG4: [len][server_confirmation:32] + Note over S: len = 32 (0x20) + + Note over S: Then continues with normal btest flow: + S->>C: AUTH_OK [01 00 00 00] + S->>C: UDP port [2 bytes BE] (if UDP mode) +``` + +### Framing Comparison + +| Protocol | Message framing | +|----------|----------------| +| Winbox (port 8291) | `[len:1][0x06][payload]` | +| **btest (port 2000)** | **`[len:1][payload]`** | +| MAC Telnet (UDP 20561) | Control packets with magic bytes | + +The `0x06` handler byte in Winbox identifies the message as an auth message. Btest omits it since the auth context is implicit after `03 00 00 00`. + +### Captured Exchange (from MITM) + +``` +CLIENT → SERVER (40 bytes): + 27 61 6e 74 61 72 00 38 8a 37 36 52 6a 32 e9 87 'antar.8.76Rj2.. + 4e 92 f8 c3 aa a1 18 da cd 71 b6 ab 76 fd 72 aa N........q..v.r. + c3 f6 6a 43 9b c8 a1 01 ..jC.... + + Decoded: + len=0x27 (39 bytes payload) + username="antar\0" + pubkey=388a373652...c8a1 (32 bytes) + parity=0x01 + +SERVER → CLIENT (50 bytes): + 31 6c c9 e3 1a 79 43 4a 40 51 de fd 55 cc 8d 6d 1l...yCJ@Q..U..m + 3c ec cd 73 19 1f a6 83 15 94 62 52 97 fe 5d 89 <..s......bR..]. + 1a 00 3c ec 65 b8 34 28 0a 16 c5 48 0d 7b 50 00 ..<.e.4(...H.{P. + e3 80 .. + + Decoded: + len=0x31 (49 bytes payload) + server_pubkey=6cc9e31a...5d891a (32 bytes) + parity=0x00 + salt=3cec65b834280a16c5480d7b5000e380 (16 bytes) + +CLIENT → SERVER (33 bytes): + 20 9b 1f 74 ec 40 31 2c ... + + Decoded: + len=0x20 (32 bytes payload) + client_cc=9b1f74ec... (32 bytes, SHA256 proof) + +SERVER → CLIENT (33 bytes): + 20 7d 59 b3 2e 28 6e 52 ... + + Decoded: + len=0x20 (32 bytes payload) + server_cc=7d59b32e... (32 bytes, SHA256 proof) + +POST-AUTH: + 01 00 00 00 07 fa + + Decoded: + AUTH_OK=01000000 + UDP_port=0x07fa (2042) +``` + +## Cryptographic Details + +### Elliptic Curve: Curve25519 in Weierstrass Form + +``` +p = 2^255 - 19 +r = curve order (same as Ed25519) +Montgomery A = 486662 + +Weierstrass conversion: + a = 0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144 + b = 0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864 + +Generator: lift_x(9) in Montgomery, converted to Weierstrass +Cofactor: 8 +``` + +All EC math is in Weierstrass form. Public keys are transmitted as Montgomery x-coordinates (32 bytes big-endian) plus a 1-byte y-parity flag. + +### Key Derivation + +``` +inner = SHA256(username + ":" + password) +validator_priv (i) = SHA256(salt || inner) +validator_pub (x_gamma) = i * G +``` + +### Shared Secret Computation + +**Client side (ECPESVDP-SRP-A):** +``` +v = redp1(x_gamma, parity=1) # hash-to-curve of validator pubkey +w_b = lift_x(server_pubkey) + v # undo verifier blinding +j = SHA256(client_pubkey || server_pubkey) +scalar = (i * j + s_a) mod r # combined scalar +Z = scalar * w_b # shared secret point +z = to_montgomery(Z).x # Montgomery x-coordinate +``` + +**Server side (ECPESVDP-SRP-B):** +``` +gamma = redp1(x_gamma, parity=0) +w_a = lift_x(client_pubkey) +Z = s_b * (w_a + j * gamma) # where j = SHA256(x_w_a || x_w_b) +z = to_montgomery(Z).x +``` + +### Confirmation Codes + +``` +client_cc = SHA256(j || z) +server_cc = SHA256(j || client_cc || z) +``` + +Both sides verify the peer's confirmation code to ensure the shared secret matches. + +### redp1 (Hash-to-Curve) + +```python +def redp1(x_bytes, parity): + x = SHA256(x_bytes) + while True: + x2 = SHA256(x) + point = lift_x(int(x2), parity) + if point is valid: + return point + x = (int(x) + 1).to_bytes(32) +``` + +## Implementation Plan for Rust + +### Required Crates + +| Crate | Purpose | +|-------|---------| +| `num-bigint` + `num-traits` | Big integer arithmetic for field operations | +| `sha2` | SHA-256 | +| `ecdsa` or custom | Curve25519 Weierstrass point operations | + +**Note:** `curve25519-dalek` operates in Montgomery/Edwards form, not Weierstrass. We need Weierstrass arithmetic for compatibility with MikroTik's implementation. Options: +1. Use `num-bigint` for direct field arithmetic (like the Python `ecdsa` library) +2. Use the `p256` crate's infrastructure with custom curve parameters +3. Port the Python `WCurve` class directly using big integers + +### Implementation Steps + +1. **Port `WCurve`** — Weierstrass curve with Curve25519 parameters, point multiplication, `lift_x`, `redp1`, Montgomery conversion +2. **Port EC-SRP5 client** — generate keypair, compute shared secret, confirmation codes +3. **Port EC-SRP5 server** — verify client proof, generate server proof (for our server mode) +4. **Integrate into `auth.rs`** — handle `03 00 00 00` response with btest-specific `[len][payload]` framing +5. **Server registration** — derive salt + validator from username/password for server-side verification + +### Server-Side Specifics + +When our server receives a client with EC-SRP5 support, we need to: +1. Store `salt` and `x_gamma` (validator public key) per user — derived from username + password at startup +2. Generate ephemeral server keypair +3. Compute password-entangled public key: `W_b = s_b * G + redp1(x_gamma, 0)` +4. Verify client's confirmation code +5. Send server confirmation code + +## Files + +| File | Purpose | +|------|---------| +| `proto-test/elliptic_curves.py` | Curve25519 Weierstrass implementation | +| `proto-test/btest_ecsrp5_client.py` | Working Python btest EC-SRP5 client | +| `proto-test/btest_mitm.py` | MITM proxy for protocol analysis | + +## Credits + +- **[MarginResearch](https://github.com/MarginResearch/mikrotik_authentication)** — Reverse-engineered MikroTik's EC-SRP5 for Winbox/MAC Telnet +- **[Margin Research blog](https://margin.re/2022/02/mikrotik-authentication-revealed/)** — Detailed write-up of MikroTik authentication +- **btest framing discovery** — MITM analysis showing btest uses `[len][payload]` (no `0x06` handler byte) diff --git a/src/client.rs b/src/client.rs index e8cfad4..9cfd557 100644 --- a/src/client.rs +++ b/src/client.rs @@ -37,29 +37,45 @@ pub async fn run_client( send_command(&mut stream, &cmd).await?; let resp = recv_response(&mut stream).await?; - match (auth_user.as_deref(), auth_pass.as_deref()) { - (Some(user), Some(pass)) => { - auth::client_authenticate(&mut stream, resp, user, pass).await?; - } - _ => { - if resp == AUTH_REQUIRED { + if resp == AUTH_OK { + // No auth required + } else if resp == AUTH_REQUIRED { + // MD5 auth + match (auth_user.as_deref(), auth_pass.as_deref()) { + (Some(user), Some(pass)) => { + auth::client_authenticate(&mut stream, resp, user, pass).await?; + } + _ => { return Err(BtestError::Protocol( - "Server requires authentication but no credentials provided".into(), + "Server requires authentication but no credentials provided (-a/-p)".into(), )); } - if resp == [0x03, 0x00, 0x00, 0x00] { + } + } else if resp == [0x03, 0x00, 0x00, 0x00] { + // EC-SRP5 auth (RouterOS >= 6.43) + match (auth_user.as_deref(), auth_pass.as_deref()) { + (Some(user), Some(pass)) => { + crate::ecsrp5::client_authenticate(&mut stream, user, pass).await?; + // After EC-SRP5, server sends AUTH_OK + let post_auth = recv_response(&mut stream).await?; + if post_auth != AUTH_OK { + return Err(BtestError::Protocol(format!( + "Unexpected post-EC-SRP5 response: {:02x?}", + post_auth + ))); + } + } + _ => { return Err(BtestError::Protocol( - "Server requires EC-SRP5 authentication (RouterOS >= 6.43) which is not yet supported. \ - Try disabling authentication on the MikroTik btest server, or provide -a/-p credentials".into(), + "Server requires EC-SRP5 authentication. Provide credentials with -a/-p".into(), )); } - if resp != AUTH_OK { - return Err(BtestError::Protocol(format!( - "Unexpected server response: {:02x?}", - resp - ))); - } } + } else { + return Err(BtestError::Protocol(format!( + "Unexpected server response: {:02x?}", + resp + ))); } tracing::info!( diff --git a/src/ecsrp5.rs b/src/ecsrp5.rs new file mode 100644 index 0000000..2c0f851 --- /dev/null +++ b/src/ecsrp5.rs @@ -0,0 +1,637 @@ +//! EC-SRP5 authentication for MikroTik RouterOS >= 6.43. +//! +//! Implements the Curve25519-Weierstrass EC-SRP5 protocol used by MikroTik btest. +//! Based on research by Margin Research (Apache-2.0 License): +//! https://github.com/MarginResearch/mikrotik_authentication +//! +//! btest framing: `[len:1][payload]` (no 0x06 handler byte, unlike Winbox). + +use num_bigint::BigUint; +use num_integer::Integer; +use num_traits::{One, Zero}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::protocol::{BtestError, Result}; + +// --- Curve25519 parameters in Weierstrass form --- + +fn p() -> BigUint { + BigUint::parse_bytes( + b"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed", + 16, + ) + .unwrap() +} + +fn curve_order() -> BigUint { + BigUint::parse_bytes( + b"1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed", + 16, + ) + .unwrap() +} + +fn weierstrass_a() -> BigUint { + BigUint::parse_bytes( + b"2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144", + 16, + ) + .unwrap() +} + +const MONT_A: u64 = 486662; + +// --- Modular arithmetic --- + +fn modinv(a: &BigUint, modulus: &BigUint) -> BigUint { + // Fermat's little theorem: a^(p-2) mod p + let exp = modulus - BigUint::from(2u32); + a.modpow(&exp, modulus) +} + +fn legendre_symbol(a: &BigUint, p_val: &BigUint) -> i32 { + let exp = (p_val - BigUint::one()) / BigUint::from(2u32); + let l = a.modpow(&exp, p_val); + if l == p_val - BigUint::one() { + -1 + } else if l == BigUint::zero() { + 0 + } else { + 1 + } +} + +fn prime_mod_sqrt(a: &BigUint, p_val: &BigUint) -> Option<(BigUint, BigUint)> { + let a = a % p_val; + if a.is_zero() { + return Some((BigUint::zero(), BigUint::zero())); + } + if legendre_symbol(&a, p_val) != 1 { + return None; + } + + // p ≡ 3 (mod 4) for Curve25519's p, so we can use the simple formula + // But let's use Tonelli-Shanks for generality + if p_val % BigUint::from(4u32) == BigUint::from(3u32) { + let exp = (p_val + BigUint::one()) / BigUint::from(4u32); + let x = a.modpow(&exp, p_val); + let other = p_val - &x; + return Some((x, other)); + } + + // Tonelli-Shanks + let mut q = p_val - BigUint::one(); + let mut s = 0u32; + while q.is_even() { + s += 1; + q >>= 1; + } + + let mut z = BigUint::from(2u32); + while legendre_symbol(&z, p_val) != -1 { + z += BigUint::one(); + } + let mut c = z.modpow(&q, p_val); + let mut x = a.modpow(&((&q + BigUint::one()) / BigUint::from(2u32)), p_val); + let mut t = a.modpow(&q, p_val); + let mut m = s; + + while t != BigUint::one() { + let mut i = 1u32; + let mut tmp = (&t * &t) % p_val; + while tmp != BigUint::one() { + tmp = (&tmp * &tmp) % p_val; + i += 1; + } + let b = c.modpow(&BigUint::from(1u32 << (m - i - 1)), p_val); + x = (&x * &b) % p_val; + t = (&t * &b % p_val * &b) % p_val; + c = (&b * &b) % p_val; + m = i; + } + + let other = p_val - &x; + Some((x, other)) +} + +// --- Weierstrass curve point --- + +#[derive(Clone, Debug)] +struct Point { + x: BigUint, + y: BigUint, + infinity: bool, +} + +impl Point { + fn infinity() -> Self { + Self { + x: BigUint::zero(), + y: BigUint::zero(), + infinity: true, + } + } + + fn new(x: BigUint, y: BigUint) -> Self { + Self { + x, + y, + infinity: false, + } + } + + fn add(&self, other: &Point) -> Point { + let p_val = p(); + if self.infinity { + return other.clone(); + } + if other.infinity { + return self.clone(); + } + if self.x == other.x && self.y != other.y { + return Point::infinity(); + } + + let lam = if self.x == other.x && self.y == other.y { + // Point doubling + let three_x_sq = (BigUint::from(3u32) * &self.x * &self.x + &weierstrass_a()) % &p_val; + let two_y = (BigUint::from(2u32) * &self.y) % &p_val; + (three_x_sq * modinv(&two_y, &p_val)) % &p_val + } else { + // Point addition + let dy = if other.y >= self.y { + (&other.y - &self.y) % &p_val + } else { + (&p_val - (&self.y - &other.y) % &p_val) % &p_val + }; + let dx = if other.x >= self.x { + (&other.x - &self.x) % &p_val + } else { + (&p_val - (&self.x - &other.x) % &p_val) % &p_val + }; + (dy * modinv(&dx, &p_val)) % &p_val + }; + + let x3 = { + let lam_sq = (&lam * &lam) % &p_val; + let sum_x = (&self.x + &other.x) % &p_val; + if lam_sq >= sum_x { + (lam_sq - sum_x) % &p_val + } else { + (&p_val - (sum_x - lam_sq) % &p_val) % &p_val + } + }; + let y3 = { + let dx = if self.x >= x3 { + (&self.x - &x3) % &p_val + } else { + (&p_val - (&x3 - &self.x) % &p_val) % &p_val + }; + let prod = (&lam * dx) % &p_val; + if prod >= self.y { + (prod - &self.y) % &p_val + } else { + (&p_val - (&self.y - prod) % &p_val) % &p_val + } + }; + + Point::new(x3, y3) + } + + fn scalar_mul(&self, scalar: &BigUint) -> Point { + let mut result = Point::infinity(); + let mut base = self.clone(); + let mut k = scalar.clone(); + + while !k.is_zero() { + if &k & &BigUint::one() == BigUint::one() { + result = result.add(&base); + } + base = base.add(&base); + k >>= 1; + } + result + } +} + +// --- WCurve: Curve25519 in Weierstrass form --- + +struct WCurve { + g: Point, + conversion_from_m: BigUint, + conversion_to_m: BigUint, +} + +impl WCurve { + fn new() -> Self { + let p_val = p(); + let mont_a = BigUint::from(MONT_A); + let three_inv = modinv(&BigUint::from(3u32), &p_val); + let conversion_from_m = (&mont_a * &three_inv) % &p_val; + let conversion_to_m = (&p_val - &conversion_from_m) % &p_val; + + let mut curve = WCurve { + g: Point::infinity(), + conversion_from_m, + conversion_to_m, + }; + curve.g = curve.lift_x(&BigUint::from(9u32), false); + curve + } + + fn to_montgomery(&self, pt: &Point) -> ([u8; 32], u8) { + let p_val = p(); + let x = (&pt.x + &self.conversion_to_m) % &p_val; + let parity = if pt.y.bit(0) { 1u8 } else { 0u8 }; + let mut bytes = [0u8; 32]; + let x_bytes = x.to_bytes_be(); + let start = 32 - x_bytes.len().min(32); + bytes[start..].copy_from_slice(&x_bytes[..x_bytes.len().min(32)]); + (bytes, parity) + } + + fn lift_x(&self, x_mont: &BigUint, parity: bool) -> Point { + let p_val = p(); + let x = x_mont % &p_val; + // y^2 = x^3 + Ax^2 + x (Montgomery) + let y_squared = (&x * &x * &x + BigUint::from(MONT_A) * &x * &x + &x) % &p_val; + // Convert x to Weierstrass + let x_w = (&x + &self.conversion_from_m) % &p_val; + + if let Some((y1, y2)) = prime_mod_sqrt(&y_squared, &p_val) { + let pt1 = Point::new(x_w.clone(), y1); + let pt2 = Point::new(x_w, y2); + if parity { + if pt1.y.bit(0) { pt1 } else { pt2 } + } else { + if !pt1.y.bit(0) { pt1 } else { pt2 } + } + } else { + Point::infinity() + } + } + + fn gen_public_key(&self, priv_key: &[u8; 32]) -> ([u8; 32], u8) { + let scalar = BigUint::from_bytes_be(priv_key); + let pt = self.g.scalar_mul(&scalar); + self.to_montgomery(&pt) + } + + fn redp1(&self, x_bytes: &[u8; 32], parity: bool) -> Point { + let mut x = sha256_bytes(x_bytes); + loop { + let x2 = sha256_bytes(&x); + let x_int = BigUint::from_bytes_be(&x2); + let pt = self.lift_x(&x_int, parity); + if !pt.infinity { + return pt; + } + let mut val = BigUint::from_bytes_be(&x); + val += BigUint::one(); + x = bigint_to_32bytes(&val); + } + } + + fn gen_password_validator_priv( + &self, + username: &str, + password: &str, + salt: &[u8; 16], + ) -> [u8; 32] { + let inner = sha256_bytes(&format!("{}:{}", username, password).as_bytes().to_vec()); + let mut input = Vec::with_capacity(16 + 32); + input.extend_from_slice(salt); + input.extend_from_slice(&inner); + sha256_bytes(&input) + } +} + +fn sha256_bytes(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + +fn bigint_to_32bytes(val: &BigUint) -> [u8; 32] { + let bytes = val.to_bytes_be(); + let mut out = [0u8; 32]; + let start = 32usize.saturating_sub(bytes.len()); + let copy_len = bytes.len().min(32); + out[start..start + copy_len].copy_from_slice(&bytes[bytes.len() - copy_len..]); + out +} + +// --- EC-SRP5 Client Authentication --- + +/// Perform EC-SRP5 authentication as a client. +/// Called after receiving `03 00 00 00` from the server. +pub async fn client_authenticate( + stream: &mut S, + username: &str, + password: &str, +) -> Result<()> { + tracing::info!("Starting EC-SRP5 authentication"); + let w = WCurve::new(); + + // Generate client ephemeral keypair + let s_a: [u8; 32] = rand::random(); + let (x_w_a, x_w_a_parity) = w.gen_public_key(&s_a); + + // MSG1: [len][username\0][pubkey:32][parity:1] + let mut payload = Vec::new(); + payload.extend_from_slice(username.as_bytes()); + payload.push(0x00); + payload.extend_from_slice(&x_w_a); + payload.push(x_w_a_parity); + let mut msg1 = vec![payload.len() as u8]; + msg1.extend_from_slice(&payload); + stream.write_all(&msg1).await?; + stream.flush().await?; + tracing::debug!("EC-SRP5: sent client pubkey ({} bytes)", msg1.len()); + + // MSG2: [len][server_pubkey:32][parity:1][salt:16] + let mut resp_header = [0u8; 1]; + stream.read_exact(&mut resp_header).await?; + let resp_len = resp_header[0] as usize; + let mut resp_data = vec![0u8; resp_len]; + stream.read_exact(&mut resp_data).await?; + + if resp_data.len() < 49 { + return Err(BtestError::Protocol(format!( + "EC-SRP5: server challenge too short ({} bytes)", + resp_data.len() + ))); + } + + let mut x_w_b = [0u8; 32]; + x_w_b.copy_from_slice(&resp_data[0..32]); + let x_w_b_parity = resp_data[32] != 0; + let mut salt = [0u8; 16]; + salt.copy_from_slice(&resp_data[33..49]); + + tracing::debug!("EC-SRP5: received server challenge (salt={})", hex::encode(&salt)); + + // Compute shared secret + let i = w.gen_password_validator_priv(username, password, &salt); + let (x_gamma, _) = w.gen_public_key(&i); + let v = w.redp1(&x_gamma, true); + + let w_b_point = w.lift_x(&BigUint::from_bytes_be(&x_w_b), x_w_b_parity); + let w_b_unblinded = w_b_point.add(&v); + + let mut j_input = Vec::with_capacity(64); + j_input.extend_from_slice(&x_w_a); + j_input.extend_from_slice(&x_w_b); + let j = sha256_bytes(&j_input); + + let i_int = BigUint::from_bytes_be(&i); + let j_int = BigUint::from_bytes_be(&j); + let s_a_int = BigUint::from_bytes_be(&s_a); + let order = curve_order(); + let scalar = ((&i_int * &j_int) + &s_a_int) % ℴ + + let z_point = w_b_unblinded.scalar_mul(&scalar); + let (z, _) = w.to_montgomery(&z_point); + + // MSG3: [len][client_cc:32] + let mut cc_input = Vec::with_capacity(64); + cc_input.extend_from_slice(&j); + cc_input.extend_from_slice(&z); + let client_cc = sha256_bytes(&cc_input); + + let mut msg3 = vec![client_cc.len() as u8]; + msg3.extend_from_slice(&client_cc); + stream.write_all(&msg3).await?; + stream.flush().await?; + tracing::debug!("EC-SRP5: sent client proof"); + + // MSG4: [len][server_cc:32] + let mut resp4_header = [0u8; 1]; + stream.read_exact(&mut resp4_header).await?; + let resp4_len = resp4_header[0] as usize; + let mut server_cc_received = vec![0u8; resp4_len]; + stream.read_exact(&mut server_cc_received).await?; + + // Verify server confirmation + let mut sc_input = Vec::with_capacity(96); + sc_input.extend_from_slice(&j); + sc_input.extend_from_slice(&client_cc); + sc_input.extend_from_slice(&z); + let server_cc_expected = sha256_bytes(&sc_input); + + if server_cc_received == server_cc_expected { + tracing::info!("EC-SRP5 authentication successful"); + Ok(()) + } else { + // Check if server sent an error message + if let Ok(msg) = std::str::from_utf8(&server_cc_received) { + Err(BtestError::Protocol(format!( + "EC-SRP5 authentication failed: {}", + msg + ))) + } else { + Err(BtestError::AuthFailed) + } + } +} + +// --- EC-SRP5 Server Authentication --- + +/// Server-side EC-SRP5 credential store. +pub struct EcSrp5Credentials { + salt: [u8; 16], + x_gamma: [u8; 32], +} + +impl EcSrp5Credentials { + /// Derive EC-SRP5 credentials from username/password (done once at startup). + pub fn derive(username: &str, password: &str) -> Self { + let salt: [u8; 16] = rand::random(); + let w = WCurve::new(); + let i = w.gen_password_validator_priv(username, password, &salt); + let (x_gamma, _parity) = w.gen_public_key(&i); + Self { + salt, + x_gamma, + } + } +} + +/// Perform EC-SRP5 authentication as a server. +/// Called after sending `03 00 00 00` to the client. +pub async fn server_authenticate( + stream: &mut S, + username: &str, + password: &str, + creds: &EcSrp5Credentials, +) -> Result<()> { + tracing::info!("Starting EC-SRP5 server authentication"); + let w = WCurve::new(); + + // MSG1: read [len][username\0][pubkey:32][parity:1] + let mut len_buf = [0u8; 1]; + stream.read_exact(&mut len_buf).await?; + let msg_len = len_buf[0] as usize; + let mut msg1_data = vec![0u8; msg_len]; + stream.read_exact(&mut msg1_data).await?; + + // Parse username + let null_pos = msg1_data.iter().position(|&b| b == 0) + .ok_or_else(|| BtestError::Protocol("EC-SRP5: no null terminator in username".into()))?; + let client_username = std::str::from_utf8(&msg1_data[..null_pos]) + .map_err(|_| BtestError::Protocol("EC-SRP5: invalid username encoding".into()))?; + + if client_username != username { + tracing::warn!("EC-SRP5: username mismatch (got '{}')", client_username); + return Err(BtestError::AuthFailed); + } + + let key_start = null_pos + 1; + if msg1_data.len() < key_start + 33 { + return Err(BtestError::Protocol("EC-SRP5: client message too short".into())); + } + let mut x_w_a = [0u8; 32]; + x_w_a.copy_from_slice(&msg1_data[key_start..key_start + 32]); + let x_w_a_parity = msg1_data[key_start + 32] != 0; + + tracing::debug!("EC-SRP5: received client pubkey from '{}'", client_username); + + // Generate server ephemeral keypair + let s_b: [u8; 32] = rand::random(); + let s_b_int = BigUint::from_bytes_be(&s_b); + let pub_b = w.g.scalar_mul(&s_b_int); + + // Compute password-entangled public key: W_b = s_b*G + redp1(x_gamma, 0) + let gamma = w.redp1(&creds.x_gamma, false); + let w_b = pub_b.add(&gamma); + let (x_w_b, x_w_b_parity) = w.to_montgomery(&w_b); + + // MSG2: [len][server_pubkey:32][parity:1][salt:16] + let mut payload2 = Vec::with_capacity(49); + payload2.extend_from_slice(&x_w_b); + payload2.push(x_w_b_parity); + payload2.extend_from_slice(&creds.salt); + let mut msg2 = vec![payload2.len() as u8]; + msg2.extend_from_slice(&payload2); + stream.write_all(&msg2).await?; + stream.flush().await?; + tracing::debug!("EC-SRP5: sent server challenge"); + + // Compute shared secret (server side: ECPESVDP-SRP-B) + let mut j_input = Vec::with_capacity(64); + j_input.extend_from_slice(&x_w_a); + j_input.extend_from_slice(&x_w_b); + let j = sha256_bytes(&j_input); + let j_int = BigUint::from_bytes_be(&j); + + // Z = s_b * (W_a + j * gamma_verify) + let w_a = w.lift_x(&BigUint::from_bytes_be(&x_w_a), x_w_a_parity); + let i = w.gen_password_validator_priv(username, password, &creds.salt); + let (x_gamma_check, _) = w.gen_public_key(&i); + let gamma_verify = w.lift_x( + &BigUint::from_bytes_be(&x_gamma_check), + true, // parity=1 for verification + ); + let j_gamma = gamma_verify.scalar_mul(&j_int); + let sum = w_a.add(&j_gamma); + let z_point = sum.scalar_mul(&s_b_int); + let (z, _) = w.to_montgomery(&z_point); + + // MSG3: read [len][client_cc:32] + let mut len3 = [0u8; 1]; + stream.read_exact(&mut len3).await?; + let mut client_cc = vec![0u8; len3[0] as usize]; + stream.read_exact(&mut client_cc).await?; + + // Verify client confirmation + let mut cc_input = Vec::with_capacity(64); + cc_input.extend_from_slice(&j); + cc_input.extend_from_slice(&z); + let expected_cc = sha256_bytes(&cc_input); + + if client_cc != expected_cc { + tracing::warn!("EC-SRP5: client proof mismatch"); + return Err(BtestError::AuthFailed); + } + + // MSG4: [len][server_cc:32] + let mut sc_input = Vec::with_capacity(96); + sc_input.extend_from_slice(&j); + sc_input.extend_from_slice(&client_cc); + sc_input.extend_from_slice(&z); + let server_cc = sha256_bytes(&sc_input); + + let mut msg4 = vec![server_cc.len() as u8]; + msg4.extend_from_slice(&server_cc); + stream.write_all(&msg4).await?; + stream.flush().await?; + + tracing::info!("EC-SRP5 server authentication successful for '{}'", client_username); + Ok(()) +} + +mod hex { + pub fn encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{:02x}", b)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_curve_generator() { + let w = WCurve::new(); + assert!(!w.g.infinity); + // Generator from lift_x(9, false) should produce a valid point + let (x_mont, _) = w.to_montgomery(&w.g); + let x_int = BigUint::from_bytes_be(&x_mont); + assert_eq!(x_int, BigUint::from(9u32)); + } + + #[test] + fn test_pubkey_generation() { + let w = WCurve::new(); + let priv_key = [1u8; 32]; + let (pubkey, parity) = w.gen_public_key(&priv_key); + assert_ne!(pubkey, [0u8; 32]); + assert!(parity <= 1); + } + + #[test] + fn test_password_validator() { + let w = WCurve::new(); + let salt = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10]; + let i = w.gen_password_validator_priv("testuser", "testpass", &salt); + assert_ne!(i, [0u8; 32]); + // Deterministic: same inputs produce same output + let i2 = w.gen_password_validator_priv("testuser", "testpass", &salt); + assert_eq!(i, i2); + // Different password produces different result + let i3 = w.gen_password_validator_priv("testuser", "other", &salt); + assert_ne!(i, i3); + } + + #[test] + fn test_redp1() { + let w = WCurve::new(); + let input = [42u8; 32]; + let pt = w.redp1(&input, false); + assert!(!pt.infinity); + } + + #[test] + fn test_scalar_mul_identity() { + let w = WCurve::new(); + let one = BigUint::one(); + let pt = w.g.scalar_mul(&one); + assert_eq!(pt.x, w.g.x); + assert_eq!(pt.y, w.g.y); + } +} diff --git a/src/lib.rs b/src/lib.rs index 97d18eb..902e25e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod bandwidth; pub mod client; +pub mod ecsrp5; pub mod protocol; pub mod server; diff --git a/src/main.rs b/src/main.rs index ce71e34..1ea0a1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod bandwidth; mod client; +mod ecsrp5; mod protocol; mod server; @@ -56,6 +57,10 @@ struct Cli { #[arg(short = 'p', long = "authpass")] auth_pass: Option, + /// Use EC-SRP5 authentication (RouterOS >= 6.43 compatible) + #[arg(long = "ecsrp5")] + ecsrp5: bool, + /// NAT mode - send probe packet to open firewall #[arg(short = 'n', long = "nat")] nat: bool, @@ -85,7 +90,7 @@ async fn main() -> anyhow::Result<()> { if cli.server { // Server mode tracing::info!("Starting btest server on port {}", cli.port); - server::run_server(cli.port, cli.auth_user, cli.auth_pass).await?; + server::run_server(cli.port, cli.auth_user, cli.auth_pass, cli.ecsrp5).await?; } else if let Some(host) = cli.client { // Client mode - must specify at least one direction if !cli.transmit && !cli.receive { diff --git a/src/protocol.rs b/src/protocol.rs index 95f126b..7346f35 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -188,6 +188,7 @@ pub async fn send_command( Ok(()) } +#[allow(dead_code)] pub async fn recv_command(reader: &mut R) -> Result { let mut buf = [0u8; 16]; reader.read_exact(&mut buf).await?; diff --git a/src/server.rs b/src/server.rs index aae0f20..36ec3d8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -26,9 +26,27 @@ pub async fn run_server( port: u16, auth_user: Option, auth_pass: Option, + use_ecsrp5: bool, ) -> Result<()> { let addr = format!("0.0.0.0:{}", port); let listener = TcpListener::bind(&addr).await?; + + // Pre-derive EC-SRP5 credentials if enabled + let ecsrp5_creds = if use_ecsrp5 { + match (auth_user.as_deref(), auth_pass.as_deref()) { + (Some(user), Some(pass)) => { + tracing::info!("EC-SRP5 authentication enabled for user '{}'", user); + Some(Arc::new(crate::ecsrp5::EcSrp5Credentials::derive(user, pass))) + } + _ => { + tracing::warn!("--ecsrp5 requires -a and -p to be set"); + None + } + } + } else { + None + }; + tracing::info!("btest server listening on {}", addr); let udp_port_offset = Arc::new(std::sync::atomic::AtomicU16::new(0)); @@ -42,10 +60,11 @@ pub async fn run_server( let auth_pass = auth_pass.clone(); let udp_offset = udp_port_offset.clone(); let sessions = sessions.clone(); + let ecsrp5 = ecsrp5_creds.clone(); tokio::spawn(async move { if let Err(e) = - handle_client(stream, peer, auth_user, auth_pass, udp_offset, sessions).await + handle_client(stream, peer, auth_user, auth_pass, udp_offset, sessions, ecsrp5).await { tracing::error!("Client {} error: {}", peer, e); } @@ -60,6 +79,7 @@ async fn handle_client( auth_pass: Option, udp_port_offset: Arc, sessions: SessionMap, + ecsrp5_creds: Option>, ) -> Result<()> { stream.set_nodelay(true)?; @@ -182,13 +202,33 @@ async fn handle_client( } // Primary connection auth - auth::server_authenticate( - &mut stream, - auth_user.as_deref(), - auth_pass.as_deref(), - &ok_response, - ) - .await?; + if let Some(ref creds) = ecsrp5_creds { + // EC-SRP5 authentication + let auth_resp: [u8; 4] = [0x03, 0x00, 0x00, 0x00]; + stream.write_all(&auth_resp).await?; + stream.flush().await?; + + crate::ecsrp5::server_authenticate( + &mut stream, + auth_user.as_deref().unwrap_or("admin"), + auth_pass.as_deref().unwrap_or(""), + creds, + ) + .await?; + + // Send auth OK (with session token if multi-conn) + stream.write_all(&ok_response).await?; + stream.flush().await?; + } else { + // MD5 or no auth + auth::server_authenticate( + &mut stream, + auth_user.as_deref(), + auth_pass.as_deref(), + &ok_response, + ) + .await?; + } if cmd.is_udp() { run_udp_test_server(&mut stream, peer, &cmd, udp_port_offset).await diff --git a/tests/ecsrp5_test.rs b/tests/ecsrp5_test.rs new file mode 100644 index 0000000..98246c0 --- /dev/null +++ b/tests/ecsrp5_test.rs @@ -0,0 +1,184 @@ +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const SERVER_PORT: u16 = 13000; + +async fn start_ecsrp5_server(port: u16) { + tokio::spawn(async move { + let _ = btest_rs::server::run_server( + port, + Some("testuser".into()), + Some("testpass".into()), + true, // ecsrp5 + ) + .await; + }); + tokio::time::sleep(Duration::from_millis(200)).await; +} + +async fn start_md5_server(port: u16) { + tokio::spawn(async move { + let _ = btest_rs::server::run_server( + port, + Some("testuser".into()), + Some("testpass".into()), + false, // md5 + ) + .await; + }); + tokio::time::sleep(Duration::from_millis(200)).await; +} + +async fn start_noauth_server(port: u16) { + tokio::spawn(async move { + let _ = btest_rs::server::run_server(port, None, None, false).await; + }); + tokio::time::sleep(Duration::from_millis(200)).await; +} + +#[tokio::test] +async fn test_ecsrp5_server_sends_03_response() { + let port = SERVER_PORT; + start_ecsrp5_server(port).await; + + let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + + // Read HELLO + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, [0x01, 0x00, 0x00, 0x00]); + + // Send command (TCP, server TX) + let cmd = btest_rs::protocol::Command::new( + btest_rs::protocol::CMD_PROTO_TCP, + btest_rs::protocol::CMD_DIR_TX, + ); + stream.write_all(&cmd.serialize()).await.unwrap(); + stream.flush().await.unwrap(); + + // Should receive EC-SRP5 auth required + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, [0x03, 0x00, 0x00, 0x00], "Expected EC-SRP5 auth response"); +} + +#[tokio::test] +async fn test_ecsrp5_full_client_auth() { + let port = SERVER_PORT + 1; + start_ecsrp5_server(port).await; + + // Use our client with EC-SRP5 + let handle = tokio::spawn(async move { + btest_rs::client::run_client( + "127.0.0.1", + port, + btest_rs::protocol::CMD_DIR_TX, // server TX = client RX + false, + 0, + 0, + Some("testuser".into()), + Some("testpass".into()), + false, + ) + .await + }); + + tokio::time::sleep(Duration::from_secs(3)).await; + handle.abort(); + // If we got here without panic, EC-SRP5 auth + data transfer worked +} + +#[tokio::test] +async fn test_ecsrp5_wrong_password_fails() { + let port = SERVER_PORT + 2; + start_ecsrp5_server(port).await; + + let result = btest_rs::client::run_client( + "127.0.0.1", + port, + btest_rs::protocol::CMD_DIR_TX, + false, + 0, + 0, + Some("testuser".into()), + Some("wrongpass".into()), + false, + ) + .await; + + assert!(result.is_err(), "Wrong password should fail"); +} + +#[tokio::test] +async fn test_md5_auth_still_works() { + let port = SERVER_PORT + 3; + start_md5_server(port).await; + + let handle = tokio::spawn(async move { + btest_rs::client::run_client( + "127.0.0.1", + port, + btest_rs::protocol::CMD_DIR_TX, + false, + 0, + 0, + Some("testuser".into()), + Some("testpass".into()), + false, + ) + .await + }); + + tokio::time::sleep(Duration::from_secs(2)).await; + handle.abort(); +} + +#[tokio::test] +async fn test_noauth_still_works() { + let port = SERVER_PORT + 4; + start_noauth_server(port).await; + + let handle = tokio::spawn(async move { + btest_rs::client::run_client( + "127.0.0.1", + port, + btest_rs::protocol::CMD_DIR_TX, + false, + 0, + 0, + None, + None, + false, + ) + .await + }); + + tokio::time::sleep(Duration::from_secs(2)).await; + handle.abort(); +} + +#[tokio::test] +async fn test_ecsrp5_udp_bidirectional() { + let port = SERVER_PORT + 5; + start_ecsrp5_server(port).await; + + let handle = tokio::spawn(async move { + btest_rs::client::run_client( + "127.0.0.1", + port, + btest_rs::protocol::CMD_DIR_BOTH, + true, // UDP + 0, + 0, + Some("testuser".into()), + Some("testpass".into()), + false, + ) + .await + }); + + tokio::time::sleep(Duration::from_secs(3)).await; + handle.abort(); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index d5c6ce0..a04599c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -8,7 +8,7 @@ async fn start_test_server(port: u16, auth_user: Option<&str>, auth_pass: Option let user = auth_user.map(String::from); let pass = auth_pass.map(String::from); tokio::spawn(async move { - let _ = btest_rs::server::run_server(port, user, pass).await; + let _ = btest_rs::server::run_server(port, user, pass, false).await; }); tokio::time::sleep(Duration::from_millis(100)).await; }