This document outlines the MS-CHAPv2 (Microsoft Challenge Handshake Authentication Protocol version 2) features that are currently missing from the OpenRDX RADIUS implementation and provides guidance on how to implement them for future development.
The OpenRDX RADIUS server currently supports the following MS-CHAPv2 features:
The following MS-CHAPv2 features are not yet implemented and should be considered for future development:
The MS-CHAP-Error attribute (Vendor-Specific Attribute 2) provides detailed error information to the client when authentication fails. This is crucial for proper error handling and user experience.
When authentication fails, the server returns a generic Access-Reject packet without detailed error information.
E=eeeeeeeeee R=r C=cccccccccccccccccccccccccccccccc V=vvvvvvvvvv M=<msg>
Where:
E = Error code (decimal number)R = Retry flag (0 = donβt retry, 1 = may retry)C = Challenge (32 hex digits, new challenge for retry)V = Password change protocol version (0 = not supported, 3 = supported)M = Human-readable error messageconst ERROR_RESTRICTED_LOGON_HOURS: u32 = 646;
const ERROR_ACCT_DISABLED: u32 = 647;
const ERROR_PASSWD_EXPIRED: u32 = 648;
const ERROR_NO_DIALIN_PERMISSION: u32 = 649;
const ERROR_AUTHENTICATION_FAILURE: u32 = 691;
const ERROR_CHANGING_PASSWORD: u32 = 709;
const VENDOR_ATTR_MS_CHAP_ERROR: u8 = 2;
fn create_mschapv2_error_response(
request: &RadiusPacket,
secret: &str,
error_code: u32,
retry_allowed: bool,
password_change_supported: bool,
message: &str,
) -> Vec<u8> {
// Generate new challenge for retry
let mut challenge = vec![0u8; 16];
use rand::Rng;
rand::thread_rng().fill(&mut challenge[..]);
// Format challenge as hex string
let challenge_hex: String = challenge.iter()
.map(|b| format!("{:02X}", b))
.collect();
// Build error message
let error_msg = format!(
"E={} R={} C={} V={} M={}",
error_code,
if retry_allowed { "1" } else { "0" },
challenge_hex,
if password_change_supported { "3" } else { "0" },
message
);
// Create Access-Reject with MS-CHAP-Error
let mut response = RadiusPacket {
code: 3, // Access-Reject
identifier: request.identifier,
length: 20,
authenticator: request.authenticator,
attributes: vec![
RadiusAttribute {
typ: ATTR_VENDOR_SPECIFIC,
value: [
&VENDOR_MICROSOFT.to_be_bytes()[..],
&[VENDOR_ATTR_MS_CHAP_ERROR, (error_msg.len() + 2) as u8],
error_msg.as_bytes(),
].concat(),
},
RadiusAttribute {
typ: ATTR_REPLY_MESSAGE,
value: message.as_bytes().to_vec(),
},
],
};
response.encode_with_response_auth(secret)
}
async fn authenticate_mschap2(&self, username: &str, peer_challenge: &[u8], nt_response: &[u8], authenticator: &[u8], secret: &str) -> Result<Mschapv2Result, sqlx::Error> {
// ... existing code ...
match result {
Some(record) => {
if !record.is_enabled {
return Ok(Mschapv2Result {
result: AuthResult::AccountDisabled,
authenticator_response: None,
password_hash: None,
error_code: Some(ERROR_ACCT_DISABLED),
error_message: Some("Account is disabled".to_string()),
});
}
// Check password expiry
if let Some(expiry) = record.password_expiry {
if expiry < chrono::Utc::now() {
return Ok(Mschapv2Result {
result: AuthResult::PasswordExpired,
authenticator_response: None,
password_hash: None,
error_code: Some(ERROR_PASSWD_EXPIRED),
error_message: Some("Password has expired".to_string()),
});
}
}
// ... rest of authentication ...
}
None => {
Ok(Mschapv2Result {
result: AuthResult::InvalidPassword,
authenticator_response: None,
password_hash: None,
error_code: Some(ERROR_AUTHENTICATION_FAILURE),
error_message: Some("Invalid username or password".to_string()),
})
}
}
}
MS-CHAP2-CPW (Change Password) allows users to change their expired or about-to-expire passwords during the authentication process without requiring administrator intervention.
Password changes are not supported. Users with expired passwords cannot authenticate.
const VENDOR_ATTR_MS_CHAP2_CPW: u8 = 27; // Change Password v2
const VENDOR_ATTR_MS_CHAP_NT_ENC_PW: u8 = 6; // NT-Encrypted-Password
const VENDOR_ATTR_MS_CHAP2_PASSWORD: u8 = 27; // New Password
-- Add to user_identifiers table
ALTER TABLE user_identifiers ADD COLUMN password_expiry TIMESTAMP;
ALTER TABLE user_identifiers ADD COLUMN password_must_change BOOLEAN DEFAULT FALSE;
ALTER TABLE user_identifiers ADD COLUMN password_last_changed TIMESTAMP;
ALTER TABLE user_identifiers ADD COLUMN password_history JSONB DEFAULT '[]';
-- Password policy table
CREATE TABLE password_policies (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
min_length INTEGER DEFAULT 8,
max_length INTEGER DEFAULT 128,
require_uppercase BOOLEAN DEFAULT TRUE,
require_lowercase BOOLEAN DEFAULT TRUE,
require_digit BOOLEAN DEFAULT TRUE,
require_special BOOLEAN DEFAULT TRUE,
expiry_days INTEGER DEFAULT 90,
history_count INTEGER DEFAULT 5,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
fn encrypt_password_with_old_hash(
new_password: &str,
old_password_hash: &[u8],
) -> Vec<u8> {
use rc4::{Rc4, KeyInit, StreamCipher};
// Convert new password to UTF-16LE
let password_utf16: Vec<u8> = new_password
.encode_utf16()
.flat_map(|c| c.to_le_bytes().to_vec())
.collect();
// Pad to 512 bytes (256 UTF-16 characters)
let mut padded_password = vec![0u8; 512];
let password_len = password_utf16.len().min(512);
padded_password[..password_len].copy_from_slice(&password_utf16[..password_len]);
// Encrypt with RC4 using old password hash as key
let mut cipher = Rc4::new_from_slice(old_password_hash)
.expect("Failed to create RC4 cipher");
cipher.apply_keystream(&mut padded_password);
padded_password
}
async fn handle_password_change(
&self,
packet: &RadiusPacket,
secret: &str,
) -> Vec<u8> {
// Extract MS-CHAP2-CPW attributes
let mut encrypted_password = None;
let mut encrypted_hash = None;
let mut peer_challenge = None;
let mut nt_response = None;
let mut flags = None;
for attr in &packet.attributes {
if attr.typ == ATTR_VENDOR_SPECIFIC && attr.value.len() > 6 {
let vendor_id = u32::from_be_bytes([
attr.value[0], attr.value[1],
attr.value[2], attr.value[3]
]);
if vendor_id == VENDOR_MICROSOFT {
let vendor_type = attr.value[4];
let vendor_data = &attr.value[6..];
match vendor_type {
VENDOR_ATTR_MS_CHAP2_CPW => {
// Parse CPW attribute
if vendor_data.len() >= 516 {
encrypted_password = Some(&vendor_data[0..512]);
encrypted_hash = Some(&vendor_data[512..528]);
peer_challenge = Some(&vendor_data[528..544]);
nt_response = Some(&vendor_data[544..568]);
if vendor_data.len() >= 570 {
flags = Some(u16::from_be_bytes([
vendor_data[568],
vendor_data[569]
]));
}
}
}
_ => {}
}
}
}
}
// Validate we have all required data
let (enc_pw, enc_hash, peer_ch, nt_resp) = match (
encrypted_password, encrypted_hash, peer_challenge, nt_response
) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => {
return self.create_mschapv2_error_response(
packet, secret,
ERROR_CHANGING_PASSWORD,
false, false,
"Invalid password change request"
);
}
};
// Extract username
let username = packet.attributes.iter()
.find(|attr| attr.typ == ATTR_USER_NAME)
.map(|attr| String::from_utf8_lossy(&attr.value).to_string())
.unwrap_or_default();
// Authenticate with old password
let auth_result = self.authenticate_mschap2(
&username, peer_ch, nt_resp,
&packet.authenticator, secret
).await;
match auth_result {
Ok(result) if result.result == AuthResult::Success => {
// Decrypt new password
let old_hash = result.password_hash.as_ref().unwrap();
let new_password = self.decrypt_password(enc_pw, old_hash);
// Validate new password
if let Err(e) = self.validate_password(&new_password, &username).await {
return self.create_mschapv2_error_response(
packet, secret,
ERROR_CHANGING_PASSWORD,
false, false,
&format!("Password validation failed: {}", e)
);
}
// Update password in database
match self.update_user_password(&username, &new_password).await {
Ok(_) => {
// Return success response
self.create_access_accept_mschapv2(
packet, secret,
&result.authenticator_response.unwrap(),
0, // MS-CHAP2 identifier
nt_resp,
&nt_hash(&new_password.encode_utf16()
.flat_map(|c| c.to_le_bytes().to_vec())
.collect::<Vec<u8>>())
)
}
Err(e) => {
self.create_mschapv2_error_response(
packet, secret,
ERROR_CHANGING_PASSWORD,
false, false,
&format!("Failed to update password: {}", e)
)
}
}
}
_ => {
self.create_mschapv2_error_response(
packet, secret,
ERROR_AUTHENTICATION_FAILURE,
false, false,
"Authentication failed during password change"
)
}
}
}
async fn validate_password(
&self,
password: &str,
username: &str,
) -> Result<(), String> {
// Get password policy
let policy = self.get_password_policy().await?;
// Check length
if password.len() < policy.min_length {
return Err(format!(
"Password must be at least {} characters",
policy.min_length
));
}
if password.len() > policy.max_length {
return Err(format!(
"Password must be at most {} characters",
policy.max_length
));
}
// Check complexity requirements
let has_uppercase = password.chars().any(|c| c.is_uppercase());
let has_lowercase = password.chars().any(|c| c.is_lowercase());
let has_digit = password.chars().any(|c| c.is_numeric());
let has_special = password.chars().any(|c| !c.is_alphanumeric());
if policy.require_uppercase && !has_uppercase {
return Err("Password must contain uppercase letters".to_string());
}
if policy.require_lowercase && !has_lowercase {
return Err("Password must contain lowercase letters".to_string());
}
if policy.require_digit && !has_digit {
return Err("Password must contain digits".to_string());
}
if policy.require_special && !has_special {
return Err("Password must contain special characters".to_string());
}
// Check password history
let password_hash = nt_hash(&password.encode_utf16()
.flat_map(|c| c.to_le_bytes().to_vec())
.collect::<Vec<u8>>());
if self.is_password_in_history(username, &password_hash).await? {
return Err(format!(
"Password must not match last {} passwords",
policy.history_count
));
}
Ok(())
}
Implement intelligent retry logic and differentiate between temporary and permanent authentication failures.
All authentication failures are treated the same way without retry guidance.
#[derive(Debug)]
pub struct AuthFailureInfo {
pub error_code: u32,
pub retry_allowed: bool,
pub retry_delay: Option<Duration>,
pub new_challenge: Option<Vec<u8>>,
pub message: String,
}
impl Mschapv2Result {
pub fn from_error(
error_type: AuthResult,
username: &str,
) -> (Self, AuthFailureInfo) {
match error_type {
AuthResult::AccountDisabled => (
Mschapv2Result {
result: error_type,
authenticator_response: None,
password_hash: None,
},
AuthFailureInfo {
error_code: ERROR_ACCT_DISABLED,
retry_allowed: false,
retry_delay: None,
new_challenge: None,
message: format!("Account {} is disabled", username),
}
),
AuthResult::InvalidPassword => (
Mschapv2Result {
result: error_type,
authenticator_response: None,
password_hash: None,
},
AuthFailureInfo {
error_code: ERROR_AUTHENTICATION_FAILURE,
retry_allowed: true,
retry_delay: Some(Duration::from_secs(1)),
new_challenge: Some(generate_random_challenge()),
message: "Invalid credentials".to_string(),
}
),
// ... other cases ...
}
}
}
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Instant, Duration};
struct RateLimiter {
attempts: Mutex<HashMap<String, Vec<Instant>>>,
max_attempts: usize,
time_window: Duration,
lockout_duration: Duration,
}
impl RateLimiter {
fn check_and_record(&self, username: &str) -> Result<(), AuthFailureInfo> {
let mut attempts = self.attempts.lock().unwrap();
let now = Instant::now();
// Clean old attempts
let user_attempts = attempts.entry(username.to_string())
.or_insert_with(Vec::new);
user_attempts.retain(|&t| now.duration_since(t) < self.time_window);
// Check if locked out
if user_attempts.len() >= self.max_attempts {
let oldest_attempt = user_attempts[0];
let unlock_time = oldest_attempt + self.lockout_duration;
if now < unlock_time {
let wait_time = unlock_time.duration_since(now);
return Err(AuthFailureInfo {
error_code: ERROR_RESTRICTED_LOGON_HOURS,
retry_allowed: false,
retry_delay: Some(wait_time),
new_challenge: None,
message: format!(
"Account temporarily locked. Try again in {} seconds",
wait_time.as_secs()
),
});
} else {
// Lockout expired, clear attempts
user_attempts.clear();
}
}
// Record this attempt
user_attempts.push(now);
Ok(())
}
}
Notify users when their passwords are about to expire, allowing them to change passwords proactively.
No warnings are provided for upcoming password expiration.
const VENDOR_ATTR_MS_CHAP2_SUCCESS_EXTENDED: u8 = 42; // Success with additional info
fn create_access_accept_with_expiry_warning(
&self,
request: &RadiusPacket,
secret: &str,
auth_response: &[u8],
days_until_expiry: i64,
password_hash: &[u8],
nt_response: &[u8],
) -> Vec<u8> {
// Create normal success response
let mut response = self.create_access_accept_mschapv2(
request, secret, auth_response,
0, nt_response, password_hash
);
// Add expiry warning as Reply-Message
let warning = format!(
"Your password will expire in {} days. Please change it soon.",
days_until_expiry
);
// Parse and add warning attribute
// Note: This would require modifying the response packet
// Implementation depends on packet structure
response
}
async fn authenticate_mschap2(&self, username: &str, peer_challenge: &[u8], nt_response: &[u8], authenticator: &[u8], secret: &str) -> Result<Mschapv2Result, sqlx::Error> {
// ... existing authentication logic ...
if authentication_successful {
// Check password age
if let Some(last_changed) = record.password_last_changed {
let policy = self.get_password_policy().await?;
let age = chrono::Utc::now().signed_duration_since(last_changed);
let days_old = age.num_days();
if days_old >= policy.expiry_days - policy.warning_days {
let days_until_expiry = policy.expiry_days - days_old;
return Ok(Mschapv2Result {
result: AuthResult::Success,
authenticator_response: Some(auth_response),
password_hash: Some(password_hash),
expiry_warning: Some(days_until_expiry),
});
}
}
}
// ... rest of logic ...
}
Implement proper verification of MS-CHAP2-Success messages by clients to prevent man-in-the-middle attacks.
Success messages are generated but mutual authentication is not enforced.
The current implementation generates the authenticator response but doesnβt include the proper format expected by clients:
fn create_mschapv2_success_message(
auth_response: &[u8],
ms_chap_ident: u8,
) -> Vec<u8> {
// Format: "S=<auth_response_hex>"
let auth_hex: String = auth_response.iter()
.map(|b| format!("{:02X}", b))
.collect();
let success_str = format!("S={}", auth_hex);
let mut message = vec![ms_chap_ident];
message.extend_from_slice(success_str.as_bytes());
message
}
Comprehensive logging of MS-CHAPv2 authentication events for security auditing and troubleshooting.
Limited logging exists but not comprehensive for security auditing.
#[derive(Debug, Serialize)]
struct MschapAuthEvent {
timestamp: DateTime<Utc>,
username: String,
source_ip: IpAddr,
nas_identifier: String,
auth_method: String,
result: String,
error_code: Option<u32>,
session_id: String,
reason: String,
}
async fn log_mschap_auth_event(
&self,
event: MschapAuthEvent,
) -> Result<(), Box<dyn std::error::Error>> {
// Log to database
sqlx::query!(
r#"
INSERT INTO auth_audit_log
(timestamp, username, source_ip, nas_identifier,
auth_method, result, error_code, session_id, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
event.timestamp,
event.username,
event.source_ip.to_string(),
event.nas_identifier,
event.auth_method,
event.result,
event.error_code.map(|c| c as i32),
event.session_id,
event.reason,
)
.execute(self.auth_server.get_pool())
.await?;
// Also log to syslog for real-time monitoring
info!(
"MS-CHAPv2 Auth: user={} result={} ip={} reason={}",
event.username, event.result, event.source_ip, event.reason
);
Ok(())
}
CREATE TABLE auth_audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
username VARCHAR(255) NOT NULL,
source_ip INET NOT NULL,
nas_identifier VARCHAR(255),
auth_method VARCHAR(50) NOT NULL,
result VARCHAR(50) NOT NULL,
error_code INTEGER,
session_id VARCHAR(255),
reason TEXT,
INDEX idx_username (username),
INDEX idx_timestamp (timestamp),
INDEX idx_result (result)
);
Various performance optimizations for high-volume MS-CHAPv2 authentication.
use rand::rngs::OsRng;
use rand::RngCore;
fn generate_secure_challenge() -> Vec<u8> {
let mut challenge = vec![0u8; 16];
OsRng.fill_bytes(&mut challenge);
challenge
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_message_format() {
let error_msg = format_mschapv2_error(
ERROR_AUTHENTICATION_FAILURE,
true,
&[0u8; 16],
false,
"Invalid password"
);
assert!(error_msg.starts_with("E=691"));
assert!(error_msg.contains("R=1"));
}
#[test]
fn test_password_encryption() {
let new_password = "NewP@ssw0rd123";
let old_hash = nt_hash(b"OldPassword");
let encrypted = encrypt_password_with_old_hash(
new_password,
&old_hash
);
assert_eq!(encrypted.len(), 512);
}
#[tokio::test]
async fn test_password_validation() {
let validator = PasswordValidator::new();
// Valid password
assert!(validator.validate("ValidP@ss123", "testuser").await.is_ok());
// Too short
assert!(validator.validate("P@ss1", "testuser").await.is_err());
// No special char
assert!(validator.validate("Password123", "testuser").await.is_err());
}
}
#[tokio::test]
async fn test_mschapv2_with_expired_password() {
// Setup test user with expired password
let user = create_test_user_with_expired_password().await;
// Attempt authentication
let result = radius_server.authenticate_mschap2(
&user.username,
&peer_challenge,
&nt_response,
&authenticator,
"secret"
).await;
// Should receive password expired error
assert!(matches!(result.result, AuthResult::PasswordExpired));
assert_eq!(result.error_code, Some(ERROR_PASSWD_EXPIRED));
}
#[tokio::test]
async fn test_password_change_flow() {
let user = create_test_user().await;
let old_password = "OldP@ssw0rd123";
let new_password = "NewP@ssw0rd456";
// Create password change request
let cpw_request = create_mschapv2_cpw_request(
&user.username,
old_password,
new_password
);
// Process request
let response = radius_server.handle_password_change(
&cpw_request,
"secret"
).await;
// Should receive Access-Accept
assert_eq!(response[0], 2); // Access-Accept code
// Verify new password works
let auth_result = authenticate_with_password(
&user.username,
new_password
).await;
assert!(auth_result.is_ok());
}
OsRng or similar CSPRNGThis document provides a comprehensive guide for implementing the missing MS-CHAPv2 features in OpenRDX. The features are prioritized based on their importance for security, user experience, and standards compliance.
The implementation should be done in phases, starting with high-priority features (error handling and password change support) and progressing to medium and low-priority enhancements.
Each feature includes detailed implementation guidance, code examples, and testing recommendations to ensure a robust and secure implementation.
For questions or clarifications, please refer to the RFC specifications or consult with the development team.