Add EC-SRP5 authentication (RouterOS >= 6.43)
All checks were successful
CI / test (push) Successful in 1m18s
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
|||||||
btest_original
|
btest_original
|
||||||
.claude/
|
.claude/
|
||||||
.env
|
.env
|
||||||
|
proto-test/venv/
|
||||||
|
**/__pycache__/
|
||||||
|
|||||||
110
Cargo.lock
generated
110
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -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
13
LICENSE
@@ -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.
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
238
docs/ecsrp5-research.md
Normal 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)
|
||||||
@@ -37,29 +37,45 @@ 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?;
|
||||||
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
if resp == AUTH_OK {
|
||||||
(Some(user), Some(pass)) => {
|
// No auth required
|
||||||
auth::client_authenticate(&mut stream, resp, user, pass).await?;
|
} else if resp == AUTH_REQUIRED {
|
||||||
}
|
// MD5 auth
|
||||||
_ => {
|
match (auth_user.as_deref(), auth_pass.as_deref()) {
|
||||||
if resp == AUTH_REQUIRED {
|
(Some(user), Some(pass)) => {
|
||||||
|
auth::client_authenticate(&mut stream, resp, user, pass).await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
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 {
|
|
||||||
return Err(BtestError::Protocol(format!(
|
|
||||||
"Unexpected server response: {:02x?}",
|
|
||||||
resp
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return Err(BtestError::Protocol(format!(
|
||||||
|
"Unexpected server response: {:02x?}",
|
||||||
|
resp
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
637
src/ecsrp5.rs
Normal file
637
src/ecsrp5.rs
Normal 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) % ℴ
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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,13 +202,33 @@ async fn handle_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Primary connection auth
|
// Primary connection auth
|
||||||
auth::server_authenticate(
|
if let Some(ref creds) = ecsrp5_creds {
|
||||||
&mut stream,
|
// EC-SRP5 authentication
|
||||||
auth_user.as_deref(),
|
let auth_resp: [u8; 4] = [0x03, 0x00, 0x00, 0x00];
|
||||||
auth_pass.as_deref(),
|
stream.write_all(&auth_resp).await?;
|
||||||
&ok_response,
|
stream.flush().await?;
|
||||||
)
|
|
||||||
.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() {
|
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
184
tests/ecsrp5_test.rs
Normal 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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user