use std::collections::HashMap;
use std::fmt;
use crate::error::Aria2Error;
#[derive(Debug, Clone)]
pub struct DigestAuthChallenge {
pub realm: String,
pub nonce: String,
pub qop: Option<String>,
pub algorithm: String,
pub opaque: Option<String>,
pub stale: bool,
}
impl fmt::Display for DigestAuthChallenge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Digest realm=\"{}\", nonce=\"{}\", algorithm=\"{}\"",
self.realm, self.nonce, self.algorithm
)?;
if let Some(ref qop) = self.qop {
write!(f, ", qop=\"{}\"", qop)?;
}
if let Some(ref opaque) = self.opaque {
write!(f, ", opaque=\"{}\"", opaque)?;
}
if self.stale {
write!(f, ", stale=true")?;
}
Ok(())
}
}
impl DigestAuthChallenge {
pub fn parse(header_value: &str) -> Result<Self, Aria2Error> {
let digest_part = header_value.strip_prefix("Digest ").ok_or_else(|| {
Aria2Error::Parse("Not a Digest challenge: missing 'Digest ' prefix".to_string())
})?;
let mut params = HashMap::new();
for pair in digest_part.split(',') {
let pair = pair.trim();
if let Some((key, value)) = pair.split_once('=') {
let key = key.trim().to_lowercase();
let mut value = value.trim().to_string();
if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
value = value[1..value.len() - 1].to_string();
}
params.insert(key, value);
}
}
let nonce = params.get("nonce").cloned().ok_or_else(|| {
Aria2Error::Parse("Missing required 'nonce' parameter in Digest challenge".to_string())
})?;
Ok(DigestAuthChallenge {
realm: params.get("realm").cloned().unwrap_or_default(),
nonce,
qop: params.get("qop").cloned(),
algorithm: params
.get("algorithm")
.cloned()
.unwrap_or_else(|| "MD5".to_string()),
opaque: params.get("opaque").cloned(),
stale: params
.get("stale")
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false),
})
}
}
#[derive(Debug, Clone)]
pub struct DigestAuthResponse {
pub username: String,
pub realm: String,
pub nonce: String,
pub uri: String,
pub qop: Option<String>,
pub nc: u32,
pub cnonce: String,
pub response: String,
pub algorithm: String,
pub opaque: Option<String>,
}
impl DigestAuthResponse {
pub fn to_header_value(&self) -> String {
format!(
r#"Digest username="{}", realm="{}", nonce="{}", uri="{}", nc={:08x}, cnonce="{}", qop="{}", response="{}", algorithm="{}", opaque="{}""#,
self.username,
self.realm,
self.nonce,
self.uri,
self.nc,
self.cnonce,
self.qop.as_deref().unwrap_or(""),
self.response,
self.algorithm,
self.opaque.as_deref().unwrap_or("")
)
}
pub fn compute(
username: &str,
password: &str,
method: &str,
uri: &str,
challenge: &DigestAuthChallenge,
nc: u32,
) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn hash_hex(input: &str) -> String {
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
let ha1 = hash_hex(&format!("{}:{}:{}", username, challenge.realm, password));
let ha2 = hash_hex(&format!("{}:{}", method, uri));
let cnonce = format!("{:016x}", rand_u64());
let nc_str = format!("{:08x}", nc);
let response = if let Some(qop) = &challenge.qop {
hash_hex(&format!(
"{}:{}:{}:{}:{}:{}",
ha1, challenge.nonce, nc_str, cnonce, qop, ha2
))
} else {
hash_hex(&format!("{}:{}:{}", ha1, challenge.nonce, ha2))
};
DigestAuthResponse {
username: username.to_string(),
realm: challenge.realm.clone(),
nonce: challenge.nonce.clone(),
uri: uri.to_string(),
qop: challenge.qop.clone(),
nc,
cnonce,
response,
algorithm: challenge.algorithm.clone(),
opaque: challenge.opaque.clone(),
}
}
}
fn rand_u64() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
dur.as_nanos() as u64 ^ (dur.as_secs()).wrapping_mul(0x5851F42D4C957F2D)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_digest_challenge_parse_basic() {
let header = r#"Digest realm="test realm", nonce="abc123def456""#;
let challenge = DigestAuthChallenge::parse(header).unwrap();
assert_eq!(challenge.realm, "test realm");
assert_eq!(challenge.nonce, "abc123def456");
assert_eq!(challenge.algorithm, "MD5"); assert!(challenge.qop.is_none());
assert!(challenge.opaque.is_none());
assert!(!challenge.stale);
}
#[test]
fn test_digest_challenge_parse_all_fields() {
let header = r#"Digest realm="aria2 download", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", algorithm="MD5", opaque="5ccc069c403ebaf9f0171e9517bf40c9", stale=true"#;
let challenge = DigestAuthChallenge::parse(header).unwrap();
assert_eq!(challenge.realm, "aria2 download");
assert_eq!(challenge.nonce, "dcd98b7102dd2f0e8b11d0f600bfb0c093");
assert_eq!(challenge.qop.as_deref(), Some("auth"));
assert_eq!(challenge.algorithm, "MD5");
assert_eq!(
challenge.opaque.as_deref(),
Some("5ccc069c403ebaf9f0171e9517bf40c9")
);
assert!(challenge.stale);
}
#[test]
fn test_digest_challenge_missing_nonce_returns_error() {
let header = r#"Digest realm="only realm""#;
let result = DigestAuthChallenge::parse(header);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("nonce"));
}
#[test]
fn test_digest_challenge_not_digest_prefix_returns_error() {
let header = r#"Basic realm="test""#;
let result = DigestAuthChallenge::parse(header);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Digest "));
}
#[test]
fn test_digest_challenge_case_insensitive_keys() {
let header = r#"Digest REALM="TestRealm", NONCE="myNonce", ALGORITHM="SHA-256""#;
let challenge = DigestAuthChallenge::parse(header).unwrap();
assert_eq!(challenge.realm, "TestRealm");
assert_eq!(challenge.nonce, "myNonce");
assert_eq!(challenge.algorithm, "SHA-256");
}
#[test]
fn test_digest_response_compute_and_format() {
let challenge = DigestAuthChallenge::parse(
r#"Digest realm="test@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", opaque="someopaque""#
).unwrap();
let response = DigestAuthResponse::compute(
"Mufasa",
"Circle Of Life",
"GET",
"/dir/index.html",
&challenge,
1,
);
assert_eq!(response.username, "Mufasa");
assert_eq!(response.realm, "test@host.com");
assert_eq!(response.uri, "/dir/index.html");
assert_eq!(response.nc, 1);
assert_eq!(response.qop.as_deref(), Some("auth"));
assert_eq!(response.algorithm, "MD5");
assert_eq!(response.opaque.as_deref(), Some("someopaque"));
let header_val = response.to_header_value();
assert!(header_val.starts_with("Digest "));
assert!(header_val.contains(r#"username="Mufasa""#));
assert!(header_val.contains(r#"realm="test@host.com""#));
assert!(header_val.contains("nc=00000001"));
assert!(header_val.contains(r#"response=""#));
assert!(!response.cnonce.is_empty());
}
#[test]
fn test_digest_response_without_qop() {
let challenge =
DigestAuthChallenge::parse(r#"Digest realm="simple", nonce="simpleNonce123""#).unwrap();
let response =
DigestAuthResponse::compute("user", "pass", "POST", "/api/data", &challenge, 1);
assert!(response.qop.is_none());
let header_val = response.to_header_value();
assert!(header_val.contains("qop=")); assert!(header_val.contains(r#"algorithm="MD5""#));
}
#[test]
fn test_digest_full_flow_roundtrip() {
let www_authenticate = r#"Digest realm="WallyWorld", nonce="OA=MPOPQKX/RI=SOXPVDFKB,URI=/download/file.torrent", qop="auth", algorithm="MD5", opaque="FQwERTYuiop123""#;
let challenge = DigestAuthChallenge::parse(www_authenticate).unwrap();
assert_eq!(challenge.realm, "WallyWorld");
let auth_response = DigestAuthResponse::compute(
"admin",
"secret123",
"GET",
"/download/file.torrent",
&challenge,
1,
);
let authorization = auth_response.to_header_value();
assert!(authorization.starts_with("Digest "));
assert!(authorization.contains(r#"username="admin""#));
assert!(authorization.contains(r#"realm="WallyWorld""#));
assert!(authorization.contains(&format!("nonce=\"{}\"", challenge.nonce)));
assert!(authorization.contains(r#"uri="/download/file.torrent""#));
assert!(authorization.contains("nc=00000001"));
assert!(authorization.contains(r#"qop="auth""#));
assert!(authorization.contains(r#"opaque="FQwERTYuiop123""#));
assert!(authorization.len() > 50); assert!(!authorization.contains("\n")); }
#[test]
fn test_digest_challenge_display_format() {
let challenge = DigestAuthChallenge {
realm: "MyRealm".into(),
nonce: "abc123".into(),
qop: Some("auth".into()),
algorithm: "MD5".into(),
opaque: Some("opaqueVal".into()),
stale: false,
};
let display = format!("{}", challenge);
assert!(display.contains("Digest "));
assert!(display.contains(r#"realm="MyRealm""#));
assert!(display.contains(r#"nonce="abc123""#));
assert!(display.contains(r#"qop="auth""#));
assert!(display.contains(r#"opaque="opaqueVal""#));
}
#[test]
fn test_digest_nonce_count_increments_correctly() {
let challenge =
DigestAuthChallenge::parse(r#"Digest realm="test", nonce="nonce123", qop="auth""#)
.unwrap();
let resp1 = DigestAuthResponse::compute("u", "p", "GET", "/", &challenge, 1);
let resp2 = DigestAuthResponse::compute("u", "p", "GET", "/", &challenge, 2);
let h1 = resp1.to_header_value();
let h2 = resp2.to_header_value();
assert!(h1.contains("nc=00000001"));
assert!(h2.contains("nc=00000002"));
assert_ne!(resp1.cnonce, resp2.cnonce);
assert_ne!(resp1.response, resp2.response);
}
}