use crate::error::Error;
#[derive(Debug, Clone)]
pub struct DigestChallenge {
pub realm: String,
pub nonce: String,
pub qop: Option<String>,
pub algorithm: DigestAlgorithm,
pub opaque: Option<String>,
pub stale: bool,
pub algorithm_specified: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DigestAlgorithm {
Md5,
Sha256,
}
impl DigestAlgorithm {
fn hash(self, input: &[u8]) -> String {
use md5::Digest as _;
match self {
Self::Md5 => hex::encode(md5::Md5::digest(input)),
Self::Sha256 => hex::encode(sha2::Sha256::digest(input)),
}
}
}
impl DigestChallenge {
pub fn parse(header_value: &str) -> Result<Self, Error> {
let stripped = header_value
.strip_prefix("Digest")
.or_else(|| header_value.strip_prefix("digest"))
.ok_or_else(|| Error::Http("not a Digest challenge".to_string()))?
.trim();
let mut realm = None;
let mut nonce = None;
let mut qop = None;
let mut algorithm = DigestAlgorithm::Md5; let mut algorithm_specified = false;
let mut opaque = None;
let mut stale = false;
for param in split_params(stripped) {
let (key, value) = split_kv(param);
let value = unquote(value);
match key.to_lowercase().as_str() {
"realm" => realm = Some(value),
"nonce" => nonce = Some(value),
"qop" => {
let selected = value
.split(',')
.map(str::trim)
.find(|q| q.eq_ignore_ascii_case("auth"))
.map(ToString::to_string)
.unwrap_or(value);
qop = Some(selected);
}
"algorithm" => {
algorithm_specified = true;
algorithm = match value.to_uppercase().as_str() {
"SHA-256" => DigestAlgorithm::Sha256,
_ => DigestAlgorithm::Md5,
};
}
"opaque" => opaque = Some(value),
"stale" => stale = value.eq_ignore_ascii_case("true"),
_ => {} }
}
let realm =
realm.ok_or_else(|| Error::Http("Digest challenge missing realm".to_string()))?;
let nonce =
nonce.ok_or_else(|| Error::Http("Digest challenge missing nonce".to_string()))?;
Ok(Self { realm, nonce, qop, algorithm, opaque, stale, algorithm_specified })
}
#[must_use]
pub fn respond(
&self,
username: &str,
password: &str,
method: &str,
uri: &str,
nc: u32,
cnonce: &str,
) -> String {
use std::fmt::Write as _;
let ha1 = self
.algorithm
.hash(format!("{username}:{realm}:{password}", realm = self.realm).as_bytes());
let ha2 = self.algorithm.hash(format!("{method}:{uri}").as_bytes());
let response = if self.qop.is_some() {
let nc_str = format!("{nc:08x}");
self.algorithm.hash(
format!("{ha1}:{nonce}:{nc_str}:{cnonce}:auth:{ha2}", nonce = self.nonce)
.as_bytes(),
)
} else {
self.algorithm.hash(format!("{ha1}:{nonce}:{ha2}", nonce = self.nonce).as_bytes())
};
let username_escaped = username.replace('\\', "\\\\").replace('"', "\\\"");
let realm_escaped = self.realm.replace('\\', "\\\\").replace('"', "\\\"");
let uri_escaped = uri.replace('"', "\\\"");
let mut header = format!(
"Digest username=\"{username_escaped}\", realm=\"{realm_escaped}\", nonce=\"{nonce}\", uri=\"{uri_escaped}\", response=\"{response}\"",
nonce = self.nonce,
);
if self.qop.is_some() {
let nc_str = format!("{nc:08x}");
let _ = write!(header, ", qop=auth, nc={nc_str}, cnonce=\"{cnonce}\"");
}
if let Some(ref opaque) = self.opaque {
let _ = write!(header, ", opaque=\"{opaque}\"");
}
if self.algorithm_specified {
match self.algorithm {
DigestAlgorithm::Sha256 => header.push_str(", algorithm=SHA-256"),
DigestAlgorithm::Md5 => header.push_str(", algorithm=MD5"),
}
}
header
}
}
#[must_use]
pub fn generate_cnonce() -> String {
use rand::Rng as _;
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
hex::encode(bytes)
}
fn split_params(s: &str) -> Vec<&str> {
let mut params = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if in_quotes => {
i += 2; continue;
}
b'"' => in_quotes = !in_quotes,
b',' if !in_quotes => {
let param = s[start..i].trim();
if !param.is_empty() {
params.push(param);
}
start = i + 1;
}
_ => {}
}
i += 1;
}
let last = s[start..].trim();
if !last.is_empty() {
params.push(last);
}
params
}
fn split_kv(s: &str) -> (&str, &str) {
if let Some((key, value)) = s.split_once('=') {
(key.trim(), value.trim())
} else {
(s.trim(), "")
}
}
fn unquote(s: &str) -> String {
let inner = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(s);
let mut result = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
result.push(next);
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_basic_digest_challenge() {
let header = r#"Digest realm="test@example.com", nonce="abc123", qop="auth""#;
let challenge = DigestChallenge::parse(header).unwrap();
assert_eq!(challenge.realm, "test@example.com");
assert_eq!(challenge.nonce, "abc123");
assert_eq!(challenge.qop.as_deref(), Some("auth"));
assert_eq!(challenge.algorithm, DigestAlgorithm::Md5);
assert!(challenge.opaque.is_none());
assert!(!challenge.stale);
}
#[test]
fn parse_digest_challenge_with_sha256() {
let header =
r#"Digest realm="example", nonce="xyz", algorithm=SHA-256, opaque="opq", stale=true"#;
let challenge = DigestChallenge::parse(header).unwrap();
assert_eq!(challenge.realm, "example");
assert_eq!(challenge.algorithm, DigestAlgorithm::Sha256);
assert_eq!(challenge.opaque.as_deref(), Some("opq"));
assert!(challenge.stale);
}
#[test]
fn parse_digest_challenge_missing_realm() {
let header = r#"Digest nonce="abc""#;
assert!(DigestChallenge::parse(header).is_err());
}
#[test]
fn parse_digest_challenge_missing_nonce() {
let header = r#"Digest realm="test""#;
assert!(DigestChallenge::parse(header).is_err());
}
#[test]
fn parse_not_digest() {
let header = "Basic realm=\"test\"";
assert!(DigestChallenge::parse(header).is_err());
}
#[test]
fn digest_response_md5_with_qop() {
let challenge = DigestChallenge {
realm: "testrealm@host.com".to_string(),
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093".to_string(),
qop: Some("auth".to_string()),
algorithm: DigestAlgorithm::Md5,
opaque: Some("5ccc069c403ebaf9f0171e9517f40e41".to_string()),
stale: false,
algorithm_specified: true,
};
let response =
challenge.respond("Mufasa", "Circle Of Life", "GET", "/dir/index.html", 1, "0a4f113b");
assert!(response.starts_with("Digest username=\"Mufasa\""));
assert!(response.contains("realm=\"testrealm@host.com\""));
assert!(response.contains("qop=auth"));
assert!(response.contains("nc=00000001"));
assert!(response.contains("cnonce=\"0a4f113b\""));
assert!(response.contains("algorithm=MD5"));
assert!(response.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""));
assert!(response.contains("response=\""));
}
#[test]
fn digest_response_md5_rfc2617_example() {
let challenge = DigestChallenge {
realm: "testrealm@host.com".to_string(),
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093".to_string(),
qop: Some("auth".to_string()),
algorithm: DigestAlgorithm::Md5,
opaque: None,
stale: false,
algorithm_specified: false,
};
let response =
challenge.respond("Mufasa", "Circle Of Life", "GET", "/dir/index.html", 1, "0a4f113b");
assert!(response.contains("response=\"6629fae49393a05397450978507c4ef1\""));
}
#[test]
fn digest_response_without_qop() {
let challenge = DigestChallenge {
realm: "test".to_string(),
nonce: "nonce123".to_string(),
qop: None,
algorithm: DigestAlgorithm::Md5,
opaque: None,
stale: false,
algorithm_specified: false,
};
let response = challenge.respond("user", "pass", "GET", "/", 1, "cnonce");
assert!(!response.contains("qop="));
assert!(!response.contains("nc="));
assert!(!response.contains("cnonce="));
assert!(response.contains("response=\""));
}
#[test]
fn digest_response_sha256() {
let challenge = DigestChallenge {
realm: "test".to_string(),
nonce: "nonce".to_string(),
qop: Some("auth".to_string()),
algorithm: DigestAlgorithm::Sha256,
opaque: None,
stale: false,
algorithm_specified: true,
};
let response = challenge.respond("user", "pass", "GET", "/", 1, "cnonce");
assert!(response.contains("algorithm=SHA-256"));
}
#[test]
fn generate_cnonce_not_empty() {
let cnonce = generate_cnonce();
assert!(!cnonce.is_empty());
assert!(cnonce.len() >= 16);
}
#[test]
fn split_params_basic() {
let params = split_params(r#"realm="test", nonce="abc""#);
assert_eq!(params.len(), 2);
assert_eq!(params[0], r#"realm="test""#);
assert_eq!(params[1], r#"nonce="abc""#);
}
#[test]
fn split_params_with_commas_in_quotes() {
let params = split_params(r#"realm="a,b", nonce="c""#);
assert_eq!(params.len(), 2);
assert_eq!(params[0], r#"realm="a,b""#);
}
#[test]
fn unquote_removes_quotes() {
assert_eq!(unquote(r#""hello""#), "hello");
assert_eq!(unquote("hello"), "hello");
assert_eq!(unquote("\""), "\"");
}
#[test]
fn unquote_escaped_quotes() {
assert_eq!(unquote(r#""test \"this\" realm!!""#), r#"test "this" realm!!"#);
}
#[test]
fn parse_multiple_qop_values() {
let header = r#"Digest realm="test", nonce="abc", qop=" crazy, auth""#;
let challenge = DigestChallenge::parse(header).unwrap();
assert_eq!(challenge.qop.as_deref(), Some("auth"));
}
}