All checks were successful
CI / test (push) Successful in 1m7s
When tcp_conn_count > 0, the auth OK response includes a session token in bytes 1-2: [01, HI, LO, 00] instead of [01, 00, 00, 00]. MikroTik checks these bytes to determine multi-connection support. Primary connection: full handshake, receives session token Secondary connections: auth with same token, join the session Server waits up to 10s for all connections to join before starting. This fixes MikroTik showing "test unsupported" for TCP multi-conn. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
5.4 KiB
Rust
165 lines
5.4 KiB
Rust
use md5::{Digest, Md5};
|
|
use rand::RngCore;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
use crate::protocol::{self, BtestError, Result, AUTH_FAILED, AUTH_OK, AUTH_REQUIRED};
|
|
|
|
pub fn generate_challenge() -> [u8; 16] {
|
|
let mut nonce = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut nonce);
|
|
nonce
|
|
}
|
|
|
|
/// Compute the double-MD5 response: MD5(password + MD5(password + challenge))
|
|
pub fn compute_auth_hash(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
|
|
// hash1 = MD5(password + challenge)
|
|
let mut hasher = Md5::new();
|
|
hasher.update(password.as_bytes());
|
|
hasher.update(challenge);
|
|
let hash1 = hasher.finalize();
|
|
|
|
// hash2 = MD5(password + hash1)
|
|
let mut hasher = Md5::new();
|
|
hasher.update(password.as_bytes());
|
|
hasher.update(&hash1);
|
|
hasher.finalize().into()
|
|
}
|
|
|
|
/// Server-side: send auth challenge and verify response.
|
|
/// `ok_response` is the 4-byte reply on success (normally AUTH_OK = [01,00,00,00]).
|
|
/// For TCP multi-connection, pass [01,HI,LO,00] with a session token.
|
|
/// Returns Ok(()) if auth succeeds or no auth is configured.
|
|
pub async fn server_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
|
stream: &mut S,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
ok_response: &[u8; 4],
|
|
) -> Result<()> {
|
|
match (username, password) {
|
|
(None, None) => {
|
|
stream.write_all(ok_response).await?;
|
|
stream.flush().await?;
|
|
Ok(())
|
|
}
|
|
(_, Some(pass)) => {
|
|
stream.write_all(&AUTH_REQUIRED).await?;
|
|
let challenge = generate_challenge();
|
|
stream.write_all(&challenge).await?;
|
|
stream.flush().await?;
|
|
|
|
let mut response = [0u8; 48];
|
|
stream.read_exact(&mut response).await?;
|
|
|
|
let received_hash = &response[0..16];
|
|
let received_user = &response[16..48];
|
|
|
|
let user_end = received_user
|
|
.iter()
|
|
.position(|&b| b == 0)
|
|
.unwrap_or(32);
|
|
let received_username = std::str::from_utf8(&received_user[..user_end])
|
|
.unwrap_or("");
|
|
|
|
if let Some(expected_user) = username {
|
|
if received_username != expected_user {
|
|
tracing::warn!("Auth failed: username mismatch (got '{}')", received_username);
|
|
stream.write_all(&AUTH_FAILED).await?;
|
|
stream.flush().await?;
|
|
return Err(BtestError::AuthFailed);
|
|
}
|
|
}
|
|
|
|
let expected_hash = compute_auth_hash(pass, &challenge);
|
|
if received_hash != expected_hash {
|
|
tracing::warn!("Auth failed: hash mismatch for user '{}'", received_username);
|
|
stream.write_all(&AUTH_FAILED).await?;
|
|
stream.flush().await?;
|
|
return Err(BtestError::AuthFailed);
|
|
}
|
|
|
|
tracing::info!("Auth successful for user '{}'", received_username);
|
|
stream.write_all(ok_response).await?;
|
|
stream.flush().await?;
|
|
Ok(())
|
|
}
|
|
(Some(_), None) => {
|
|
stream.write_all(ok_response).await?;
|
|
stream.flush().await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Client-side: respond to auth challenge if required.
|
|
pub async fn client_authenticate<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
|
stream: &mut S,
|
|
resp: [u8; 4],
|
|
username: &str,
|
|
password: &str,
|
|
) -> Result<()> {
|
|
if resp == AUTH_OK {
|
|
return Ok(());
|
|
}
|
|
|
|
if resp == AUTH_REQUIRED {
|
|
// Read 16-byte challenge
|
|
let mut challenge = [0u8; 16];
|
|
stream.read_exact(&mut challenge).await?;
|
|
|
|
// Compute response
|
|
let hash = compute_auth_hash(password, &challenge);
|
|
|
|
// Build 48-byte response: 16 hash + 32 username
|
|
let mut response = [0u8; 48];
|
|
response[0..16].copy_from_slice(&hash);
|
|
let user_bytes = username.as_bytes();
|
|
let copy_len = user_bytes.len().min(32);
|
|
response[16..16 + copy_len].copy_from_slice(&user_bytes[..copy_len]);
|
|
|
|
stream.write_all(&response).await?;
|
|
stream.flush().await?;
|
|
|
|
// Read auth result
|
|
let result = protocol::recv_response(stream).await?;
|
|
if result == AUTH_OK {
|
|
tracing::info!("Authentication successful");
|
|
Ok(())
|
|
} else {
|
|
Err(BtestError::AuthFailed)
|
|
}
|
|
} else if resp == [0x03, 0x00, 0x00, 0x00] {
|
|
Err(BtestError::Protocol(
|
|
"EC-SRP5 authentication (RouterOS >= 6.43) is not supported".into(),
|
|
))
|
|
} else {
|
|
Err(BtestError::Protocol(format!(
|
|
"Unexpected auth response: {:02x?}",
|
|
resp
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_auth_hash_known_vector() {
|
|
// From the Perl reference: password "test", challenge as hex "ad32d6f94d28161625f2f390bb895637"
|
|
let challenge: [u8; 16] = [
|
|
0xad, 0x32, 0xd6, 0xf9, 0x4d, 0x28, 0x16, 0x16, 0x25, 0xf2, 0xf3, 0x90, 0xbb, 0x89,
|
|
0x56, 0x37,
|
|
];
|
|
let hash = compute_auth_hash("test", &challenge);
|
|
let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
|
assert_eq!(hex, "3c968565bc0314f281a6da1571cf7255");
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_password() {
|
|
let challenge = generate_challenge();
|
|
let hash = compute_auth_hash("", &challenge);
|
|
assert_eq!(hash.len(), 16);
|
|
}
|
|
}
|