use crate::scram::{
CLIENT_NONCE_LEN, MAX_PBKDF2_ITERATIONS, MIN_PBKDF2_ITERATIONS, build_client_first,
compute_client_final, generate_client_nonce, parse_server_first, verify_server_final,
};
#[test]
fn generate_client_nonce_returns_nonempty_string() {
let n1 = generate_client_nonce().expect("CSPRNG");
assert!(!n1.is_empty());
}
#[test]
fn generate_client_nonce_is_random() {
let n1 = generate_client_nonce().expect("CSPRNG");
let n2 = generate_client_nonce().expect("CSPRNG");
assert_ne!(n1, n2);
}
#[test]
fn generate_client_nonce_is_base64_encoded() {
let n = generate_client_nonce().expect("CSPRNG");
assert_eq!(n.len(), 32);
assert!(
n.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'),
"nonce should be valid base64: {n}"
);
}
#[test]
fn client_nonce_len_constant_is_24() {
assert_eq!(CLIENT_NONCE_LEN, 24);
}
#[test]
fn client_first_format_matches_rfc() {
let msg = build_client_first("user", "fyko+d2lbbFgONRv9qkxdawL");
assert_eq!(msg, "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL");
}
#[test]
fn client_first_escapes_comma_and_equals_in_username() {
assert_eq!(build_client_first("a,b", "NONCE"), "n,,n=a=2Cb,r=NONCE");
assert_eq!(build_client_first("a=b", "NONCE"), "n,,n=a=3Db,r=NONCE");
assert_eq!(
build_client_first("a,b=c", "NONCE"),
"n,,n=a=2Cb=3Dc,r=NONCE"
);
}
#[test]
fn parse_server_first_accepts_well_formed() {
let raw = "r=clientNoncesERVERnonce,s=c2FsdHkgYnl0ZXM=,i=4096";
let parsed = parse_server_first(raw, "clientNonce").expect("well-formed");
assert_eq!(parsed.nonce, "clientNoncesERVERnonce");
assert_eq!(parsed.iterations, 4096);
assert_eq!(parsed.salt, b"salty bytes");
}
#[test]
fn parse_server_first_rejects_nonce_without_client_prefix() {
let raw = "r=ATTACKERnonce,s=c2FsdA==,i=4096";
assert!(parse_server_first(raw, "clientNonce").is_err());
}
#[test]
fn parse_server_first_rejects_low_iteration_count() {
let raw = format!("r=clientNonceX,s=c2FsdA==,i={}", MIN_PBKDF2_ITERATIONS - 1);
assert!(parse_server_first(&raw, "clientNonce").is_err());
}
#[test]
fn parse_server_first_rejects_high_iteration_count() {
let raw = format!("r=clientNonceX,s=c2FsdA==,i={}", MAX_PBKDF2_ITERATIONS + 1);
assert!(parse_server_first(&raw, "clientNonce").is_err());
}
#[test]
fn parse_server_first_rejects_unsupported_extension() {
let raw = "m=mandatoryExt,r=clientNonceX,s=c2FsdA==,i=4096";
assert!(parse_server_first(raw, "clientNonce").is_err());
}
#[test]
fn parse_server_first_rejects_missing_required_attrs() {
assert!(parse_server_first("s=c2FsdA==,i=4096", "x").is_err());
assert!(parse_server_first("r=xY,i=4096", "x").is_err());
assert!(parse_server_first("r=xY,s=c2FsdA==", "x").is_err());
}
#[test]
fn parse_server_first_rejects_non_numeric_iterations() {
let raw = "r=clientNonceX,s=c2FsdA==,i=lots";
assert!(parse_server_first(raw, "clientNonce").is_err());
}
#[test]
fn parse_server_first_rejects_invalid_base64_salt() {
let raw = "r=clientNonceX,s=!!!,i=4096";
assert!(parse_server_first(raw, "clientNonce").is_err());
}
#[test]
fn rfc7677_test_vector_round_trip() {
let username = "user";
let password = "pencil";
let client_nonce = "rOprNGfwEbeRWgbNEkqO";
let server_first_raw =
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
let server_first = parse_server_first(server_first_raw, client_nonce)
.expect("RFC 7677 server-first must parse");
let cf = compute_client_final(
username,
password,
client_nonce,
&server_first,
server_first_raw,
);
let expected_client_final = "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,\
p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=";
assert_eq!(
cf.message, expected_client_final,
"client-final must match RFC 7677"
);
let server_final = "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=";
verify_server_final(server_final, &cf.expected_server_signature)
.expect("RFC 7677 server-final must verify");
}
#[test]
fn verify_server_final_rejects_wrong_signature() {
let mut wrong = [0u8; 32];
let _ = parse_server_first(
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
"rOprNGfwEbeRWgbNEkqO",
)
.unwrap();
wrong[31] = 0xFF;
assert!(verify_server_final("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", &wrong).is_err());
}
#[test]
fn verify_server_final_rejects_error_attribute() {
let dummy = [0u8; 32];
assert!(verify_server_final("e=invalid-proof", &dummy).is_err());
}
#[test]
fn verify_server_final_rejects_missing_v() {
let dummy = [0u8; 32];
assert!(verify_server_final("x=foo", &dummy).is_err());
}
#[test]
fn smtp_auth_scram_sha256_end_to_end_succeeds() {
use super::harness::{MockTransport, block_on, flatten};
use crate::client::SmtpClient;
use crate::error::SmtpError;
use crate::protocol::base64_encode;
let username = "user";
let password = "pencil";
let server_first_raw = "r=00000000000000000000000000000000%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
let server_first_b64 = base64_encode(server_first_raw.as_bytes());
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH SCRAM-SHA-256\r\n",
format!("334 {server_first_b64}\r\n").as_bytes(),
]);
let (transport, written, _closed) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_with(
crate::protocol::AuthMechanism::ScramSha256,
username,
password,
))
.expect_err("nonce-prefix mismatch must fail");
assert!(
matches!(err, SmtpError::Auth(_)),
"expected Auth error, got {err:?}"
);
let written = written.borrow();
let written_str = std::str::from_utf8(&written).expect("UTF-8");
assert!(
written_str.contains("AUTH SCRAM-SHA-256 "),
"expected AUTH SCRAM-SHA-256 verb: {written_str:?}"
);
let scram_lines: Vec<&str> = written_str
.lines()
.filter(|l| !l.starts_with("EHLO") && !l.is_empty())
.collect();
assert_eq!(
scram_lines.len(),
1,
"client should have sent only AUTH SCRAM-SHA-256, not client-final: {scram_lines:?}"
);
let _ = client;
}
#[test]
fn smtp_auto_select_prefers_scram_when_advertised() {
use crate::protocol::{AuthMechanism, select_auth_mechanism};
let caps: Vec<String> = vec!["AUTH PLAIN LOGIN SCRAM-SHA-256".into()];
assert_eq!(
select_auth_mechanism(&caps),
Some(AuthMechanism::ScramSha256)
);
}
#[test]
fn smtp_auto_select_falls_back_to_plain_when_no_scram() {
use crate::protocol::{AuthMechanism, select_auth_mechanism};
let caps: Vec<String> = vec!["AUTH PLAIN LOGIN".into()];
assert_eq!(select_auth_mechanism(&caps), Some(AuthMechanism::Plain));
}