Add EC-SRP5 authentication (RouterOS >= 6.43)
All checks were successful
CI / test (push) Successful in 1m18s

Client: auto-detects 03 response and performs EC-SRP5 handshake
Server: --ecsrp5 flag enables Curve25519 Weierstrass EC-SRP5 auth
  btest -s -a admin -p password --ecsrp5

Protocol: [len][payload] framing (no 0x06 handler, unlike Winbox)
Crypto: Curve25519 in Weierstrass form, SHA256, SRP key exchange

Based on MarginResearch/mikrotik_authentication (Apache 2.0).
Verified against MikroTik RouterOS 7.x via MITM protocol analysis.

34 tests (10 unit, 6 EC-SRP5 integration, 8 base integration, 10 doc-tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-31 16:56:38 +04:00
parent 8fe4e72bb3
commit 58274da859
15 changed files with 1303 additions and 38 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
btest_original btest_original
.claude/ .claude/
.env .env
proto-test/venv/
**/__pycache__/

110
Cargo.lock generated
View File

@@ -67,6 +67,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -82,15 +88,28 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "btest-rs" name = "btest-rs"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"clap", "clap",
"md-5", "md-5",
"num-bigint",
"num-integer",
"num-traits",
"rand", "rand",
"sha2",
"socket2 0.5.10", "socket2 0.5.10",
"thiserror", "thiserror",
"tokio", "tokio",
@@ -156,6 +175,21 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -166,14 +200,34 @@ dependencies = [
"typenum", "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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"crypto-common", "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]] [[package]]
@@ -213,6 +267,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -262,7 +325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -291,6 +354,34 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -421,6 +512,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "btest-rs" name = "btest-rs"
version = "0.1.0" version = "0.3.0"
edition = "2021" edition = "2021"
description = "MikroTik Bandwidth Test (btest) server and client — a Rust reimplementation" description = "MikroTik Bandwidth Test (btest) server and client with EC-SRP5 auth — a Rust reimplementation"
license = "MIT" license = "MIT AND Apache-2.0"
repository = "https://github.com/samm-git/btest-opensource" repository = "https://github.com/samm-git/btest-opensource"
keywords = ["mikrotik", "bandwidth", "btest", "network", "benchmarking"] keywords = ["mikrotik", "bandwidth", "btest", "network", "benchmarking"]
categories = ["command-line-utilities", "network-programming"] categories = ["command-line-utilities", "network-programming"]
@@ -27,6 +27,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8" rand = "0.8"
socket2 = "0.5" socket2 = "0.5"
anyhow = "1.0.102" 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] [profile.release]
opt-level = 3 opt-level = 3

13
LICENSE
View File

@@ -3,7 +3,11 @@ MIT License
Copyright (c) 2026 btest-rs contributors Copyright (c) 2026 btest-rs contributors
Based on btest-opensource by Alex Samorukov (https://github.com/samm-git/btest-opensource) 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. 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.

View File

@@ -132,9 +132,23 @@ The MikroTik btest protocol uses:
See the [original protocol documentation](btest-opensource/README.md) for wire-format details. 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 ## 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. - **Multi-connection UDP** is supported. MikroTik's multi-connection mode sends from multiple source ports which are all accepted by the server.
## Testing ## Testing
@@ -148,8 +162,9 @@ scripts/test-docker.sh # Docker container test
## Credits ## 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. - **[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.
- **MikroTik** - Creator of the bandwidth test protocol and RouterOS. - **[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 ## License

View File

@@ -50,12 +50,20 @@ sequenceDiagram
alt No auth configured alt No auth configured
SRV->>TCP: AUTH_OK [01 00 00 00] 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: AUTH_REQUIRED [02 00 00 00]
SRV->>TCP: Challenge [16 random bytes] SRV->>TCP: Challenge [16 random bytes]
MK->>TCP: Response [16 hash + 32 username] MK->>TCP: Response [16 hash + 32 username]
Note over SRV: Verify MD5(pass + MD5(pass + challenge)) Note over SRV: Verify MD5(pass + MD5(pass + challenge))
SRV->>TCP: AUTH_OK or AUTH_FAILED 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<br/>See docs/ecsrp5-research.md
SRV->>TCP: AUTH_OK [01 00 00 00]
end end
alt TCP mode alt TCP mode
@@ -179,6 +187,7 @@ btest-rs/
│ ├── lib.rs # Public API (used by integration tests) │ ├── lib.rs # Public API (used by integration tests)
│ ├── protocol.rs # Wire format: Command, StatusMessage, constants │ ├── protocol.rs # Wire format: Command, StatusMessage, constants
│ ├── auth.rs # MD5 challenge-response authentication │ ├── auth.rs # MD5 challenge-response authentication
│ ├── ecsrp5.rs # EC-SRP5 authentication (Curve25519 Weierstrass)
│ ├── server.rs # Server mode: listener, TCP/UDP handlers │ ├── server.rs # Server mode: listener, TCP/UDP handlers
│ ├── client.rs # Client mode: connector, TCP/UDP handlers │ ├── client.rs # Client mode: connector, TCP/UDP handlers
│ └── bandwidth.rs # Rate limiting, formatting, shared state │ └── bandwidth.rs # Rate limiting, formatting, shared state

238
docs/ecsrp5-research.md Normal file
View File

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

View File

@@ -37,30 +37,46 @@ pub async fn run_client(
send_command(&mut stream, &cmd).await?; send_command(&mut stream, &cmd).await?;
let resp = recv_response(&mut stream).await?; let resp = recv_response(&mut stream).await?;
if resp == AUTH_OK {
// No auth required
} else if resp == AUTH_REQUIRED {
// MD5 auth
match (auth_user.as_deref(), auth_pass.as_deref()) { match (auth_user.as_deref(), auth_pass.as_deref()) {
(Some(user), Some(pass)) => { (Some(user), Some(pass)) => {
auth::client_authenticate(&mut stream, resp, user, pass).await?; auth::client_authenticate(&mut stream, resp, user, pass).await?;
} }
_ => { _ => {
if resp == AUTH_REQUIRED {
return Err(BtestError::Protocol( 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( return Err(BtestError::Protocol(
"Server requires EC-SRP5 authentication (RouterOS >= 6.43) which is not yet supported. \ "Server requires EC-SRP5 authentication. Provide credentials with -a/-p".into(),
Try disabling authentication on the MikroTik btest server, or provide -a/-p credentials".into(),
)); ));
} }
if resp != AUTH_OK { }
} else {
return Err(BtestError::Protocol(format!( return Err(BtestError::Protocol(format!(
"Unexpected server response: {:02x?}", "Unexpected server response: {:02x?}",
resp resp
))); )));
} }
}
}
tracing::info!( tracing::info!(
"Starting {} {} test", "Starting {} {} test",

637
src/ecsrp5.rs Normal file
View File

@@ -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<S: AsyncReadExt + AsyncWriteExt + Unpin>(
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) % &order;
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<S: AsyncReadExt + AsyncWriteExt + Unpin>(
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);
}
}

View File

@@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod bandwidth; pub mod bandwidth;
pub mod client; pub mod client;
pub mod ecsrp5;
pub mod protocol; pub mod protocol;
pub mod server; pub mod server;

View File

@@ -1,6 +1,7 @@
mod auth; mod auth;
mod bandwidth; mod bandwidth;
mod client; mod client;
mod ecsrp5;
mod protocol; mod protocol;
mod server; mod server;
@@ -56,6 +57,10 @@ struct Cli {
#[arg(short = 'p', long = "authpass")] #[arg(short = 'p', long = "authpass")]
auth_pass: Option<String>, auth_pass: Option<String>,
/// Use EC-SRP5 authentication (RouterOS >= 6.43 compatible)
#[arg(long = "ecsrp5")]
ecsrp5: bool,
/// NAT mode - send probe packet to open firewall /// NAT mode - send probe packet to open firewall
#[arg(short = 'n', long = "nat")] #[arg(short = 'n', long = "nat")]
nat: bool, nat: bool,
@@ -85,7 +90,7 @@ async fn main() -> anyhow::Result<()> {
if cli.server { if cli.server {
// Server mode // Server mode
tracing::info!("Starting btest server on port {}", cli.port); 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 { } else if let Some(host) = cli.client {
// Client mode - must specify at least one direction // Client mode - must specify at least one direction
if !cli.transmit && !cli.receive { if !cli.transmit && !cli.receive {

View File

@@ -188,6 +188,7 @@ pub async fn send_command<W: AsyncWriteExt + Unpin>(
Ok(()) Ok(())
} }
#[allow(dead_code)]
pub async fn recv_command<R: AsyncReadExt + Unpin>(reader: &mut R) -> Result<Command> { pub async fn recv_command<R: AsyncReadExt + Unpin>(reader: &mut R) -> Result<Command> {
let mut buf = [0u8; 16]; let mut buf = [0u8; 16];
reader.read_exact(&mut buf).await?; reader.read_exact(&mut buf).await?;

View File

@@ -26,9 +26,27 @@ pub async fn run_server(
port: u16, port: u16,
auth_user: Option<String>, auth_user: Option<String>,
auth_pass: Option<String>, auth_pass: Option<String>,
use_ecsrp5: bool,
) -> Result<()> { ) -> Result<()> {
let addr = format!("0.0.0.0:{}", port); let addr = format!("0.0.0.0:{}", port);
let listener = TcpListener::bind(&addr).await?; 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); tracing::info!("btest server listening on {}", addr);
let udp_port_offset = Arc::new(std::sync::atomic::AtomicU16::new(0)); 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 auth_pass = auth_pass.clone();
let udp_offset = udp_port_offset.clone(); let udp_offset = udp_port_offset.clone();
let sessions = sessions.clone(); let sessions = sessions.clone();
let ecsrp5 = ecsrp5_creds.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = 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); tracing::error!("Client {} error: {}", peer, e);
} }
@@ -60,6 +79,7 @@ async fn handle_client(
auth_pass: Option<String>, auth_pass: Option<String>,
udp_port_offset: Arc<std::sync::atomic::AtomicU16>, udp_port_offset: Arc<std::sync::atomic::AtomicU16>,
sessions: SessionMap, sessions: SessionMap,
ecsrp5_creds: Option<Arc<crate::ecsrp5::EcSrp5Credentials>>,
) -> Result<()> { ) -> Result<()> {
stream.set_nodelay(true)?; stream.set_nodelay(true)?;
@@ -182,6 +202,25 @@ async fn handle_client(
} }
// Primary connection auth // Primary connection auth
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( auth::server_authenticate(
&mut stream, &mut stream,
auth_user.as_deref(), auth_user.as_deref(),
@@ -189,6 +228,7 @@ async fn handle_client(
&ok_response, &ok_response,
) )
.await?; .await?;
}
if cmd.is_udp() { if cmd.is_udp() {
run_udp_test_server(&mut stream, peer, &cmd, udp_port_offset).await run_udp_test_server(&mut stream, peer, &cmd, udp_port_offset).await

184
tests/ecsrp5_test.rs Normal file
View File

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

View File

@@ -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 user = auth_user.map(String::from);
let pass = auth_pass.map(String::from); let pass = auth_pass.map(String::from);
tokio::spawn(async move { 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; tokio::time::sleep(Duration::from_millis(100)).await;
} }