1use md5::{Digest as Md5Digest, Md5};
2use sha2::{Sha256, Sha512_256};
3
4use crate::core::error::{Error, Kind, Result};
5
6mod scan;
7
8use scan::{Pair, pairs};
9
10#[derive(Clone)]
11pub struct DigestAuth {
12 pub(crate) username: String,
13 pub(crate) password: String,
14}
15
16impl std::fmt::Debug for DigestAuth {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 f.debug_struct("DigestAuth")
19 .field("username", &self.username)
20 .finish_non_exhaustive()
21 }
22}
23
24impl DigestAuth {
25 pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
26 Self {
27 username: username.into(),
28 password: password.into(),
29 }
30 }
31}
32
33#[derive(Debug, Default, Clone)]
34pub(crate) struct Challenge {
35 pub realm: String,
36 pub nonce: String,
37 pub qop: Option<String>,
38 pub algorithm: Algorithm,
39 pub opaque: Option<String>,
40 pub stale: bool,
41 pub domain: Vec<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub(crate) enum Algorithm {
46 #[default]
47 Md5,
48 Md5Sess,
49 Sha256,
50 Sha256Sess,
51 Sha512_256,
52 Sha512_256Sess,
53}
54
55impl Algorithm {
56 fn is_sess(self) -> bool {
57 matches!(
58 self,
59 Algorithm::Md5Sess | Algorithm::Sha256Sess | Algorithm::Sha512_256Sess
60 )
61 }
62
63 fn wire_name(self) -> &'static str {
64 match self {
65 Algorithm::Md5 => "MD5",
66 Algorithm::Md5Sess => "MD5-sess",
67 Algorithm::Sha256 => "SHA-256",
68 Algorithm::Sha256Sess => "SHA-256-sess",
69 Algorithm::Sha512_256 => "SHA-512-256",
70 Algorithm::Sha512_256Sess => "SHA-512-256-sess",
71 }
72 }
73
74 fn hash_hex(self, input: &[u8]) -> String {
75 match self {
76 Algorithm::Md5 | Algorithm::Md5Sess => {
77 let mut h = Md5::new();
78 h.update(input);
79 hex(&h.finalize())
80 }
81 Algorithm::Sha256 | Algorithm::Sha256Sess => {
82 let mut h = Sha256::new();
83 h.update(input);
84 hex(&h.finalize())
85 }
86 Algorithm::Sha512_256 | Algorithm::Sha512_256Sess => {
87 let mut h = Sha512_256::new();
88 h.update(input);
89 hex(&h.finalize())
90 }
91 }
92 }
93}
94
95fn hex(bytes: &[u8]) -> String {
96 hex::encode(bytes)
97}
98
99pub(crate) fn parse_challenge(header: &str) -> Result<Challenge> {
100 let trimmed = header.trim();
101 let body = trimmed
102 .strip_prefix("Digest ")
103 .or_else(|| trimmed.strip_prefix("digest "))
104 .ok_or_else(|| Error::new(Kind::Request).with_message("digest: not a Digest challenge"))?;
105
106 let mut ch = Challenge::default();
107 for pair in pairs(body) {
108 fill(&mut ch, pair)?;
109 }
110
111 if ch.nonce.is_empty() {
112 return Err(
113 Error::new(Kind::Request).with_message("digest: challenge missing required `nonce`")
114 );
115 }
116
117 Ok(ch)
118}
119
120fn fill(ch: &mut Challenge, pair: Pair<'_>) -> Result<()> {
121 let Pair { key, val } = pair;
122 match key.trim().to_ascii_lowercase().as_str() {
123 "realm" => ch.realm = val,
124 "nonce" => ch.nonce = val,
125 "qop" => ch.qop = Some(val),
126 "opaque" => ch.opaque = Some(val),
127 "stale" => ch.stale = val.eq_ignore_ascii_case("true"),
128 "algorithm" => ch.algorithm = algorithm(val.trim())?,
129 "domain" => ch.domain = val.split_whitespace().map(str::to_owned).collect(),
130 _ => {}
131 }
132 Ok(())
133}
134
135fn algorithm(val: &str) -> Result<Algorithm> {
136 match val {
137 "MD5" | "md5" => Ok(Algorithm::Md5),
138 "MD5-sess" | "md5-sess" => Ok(Algorithm::Md5Sess),
139 "SHA-256" | "sha-256" => Ok(Algorithm::Sha256),
140 "SHA-256-sess" | "sha-256-sess" => Ok(Algorithm::Sha256Sess),
141 "SHA-512-256" | "sha-512-256" => Ok(Algorithm::Sha512_256),
142 "SHA-512-256-sess" | "sha-512-256-sess" => Ok(Algorithm::Sha512_256Sess),
143 other => Err(Error::new(Kind::Request)
144 .with_message(format!("digest: unsupported algorithm {other}"))),
145 }
146}
147
148pub(crate) fn build_auth_header(
149 challenge: &Challenge,
150 auth: &DigestAuth,
151 method: &str,
152 uri: &str,
153 nc: u32,
154 cnonce: &str,
155) -> Option<String> {
156 let qop = match challenge.qop.as_deref() {
157 Some(offered) => pick_supported_qop(offered)?,
158 None => "",
159 };
160
161 let alg = challenge.algorithm;
162 let ha1_base =
163 alg.hash_hex(format!("{}:{}:{}", auth.username, challenge.realm, auth.password).as_bytes());
164 let ha1 = if alg.is_sess() {
165 alg.hash_hex(format!("{}:{}:{}", ha1_base, challenge.nonce, cnonce).as_bytes())
166 } else {
167 ha1_base
168 };
169 let ha2 = alg.hash_hex(format!("{}:{}", method, uri).as_bytes());
170
171 let nc_hex = format!("{:08x}", nc);
172
173 let response = if !qop.is_empty() {
174 alg.hash_hex(
175 format!(
176 "{}:{}:{}:{}:{}:{}",
177 ha1, challenge.nonce, nc_hex, cnonce, qop, ha2
178 )
179 .as_bytes(),
180 )
181 } else {
182 alg.hash_hex(format!("{}:{}:{}", ha1, challenge.nonce, ha2).as_bytes())
183 };
184
185 let quoted = |s: &str| {
186 let mut esc = String::with_capacity(s.len() + 2);
187 esc.push('"');
188 for c in s.chars() {
189 if c == '"' || c == '\\' {
190 esc.push('\\');
191 }
192 esc.push(c);
193 }
194 esc.push('"');
195 esc
196 };
197
198 let mut out = format!(
199 "Digest username={u}, realm={r}, nonce={n}, uri={uri}, algorithm={alg}, response={resp}",
200 u = quoted(&auth.username),
201 r = quoted(&challenge.realm),
202 n = quoted(&challenge.nonce),
203 uri = quoted(uri),
204 alg = alg.wire_name(),
205 resp = quoted(&response),
206 );
207 if !qop.is_empty() {
208 out.push_str(&format!(
209 ", qop={qop}, nc={nc_hex}, cnonce={}",
210 quoted(cnonce)
211 ));
212 }
213 if let Some(opaque) = &challenge.opaque {
214 out.push_str(&format!(", opaque={}", quoted(opaque)));
215 }
216 Some(out)
217}
218
219pub(crate) fn covers(challenge: &Challenge, url: &url::Url) -> bool {
220 challenge.domain.is_empty()
221 || challenge.domain.iter().any(|space| match url.join(space) {
222 Ok(space) => space.origin() == url.origin() && url.path().starts_with(space.path()),
223 Err(_) => false,
224 })
225}
226
227pub(crate) fn pick_supported_qop(qop: &str) -> Option<&'static str> {
228 for token in qop.split(',') {
229 if token.trim().eq_ignore_ascii_case("auth") {
230 return Some("auth");
231 }
232 }
233 None
234}
235
236pub(crate) fn generate_cnonce() -> String {
237 crate::util::random_hex_token(8)
238}
239
240pub(crate) const DIGEST_NONCE_CACHE_CAP: usize = 4096;
241
242fn nonce_cache() -> &'static std::sync::Mutex<lru::LruCache<String, u32>> {
243 use lru::LruCache;
244 use std::num::NonZeroUsize;
245 use std::sync::{Mutex, OnceLock};
246 static CACHE: OnceLock<Mutex<LruCache<String, u32>>> = OnceLock::new();
247 CACHE.get_or_init(|| {
248 Mutex::new(LruCache::new(
249 NonZeroUsize::new(DIGEST_NONCE_CACHE_CAP).expect("cap is non-zero"),
250 ))
251 })
252}
253
254pub(crate) fn next_nc_for_nonce(nonce: &str) -> u32 {
255 let mut guard = crate::util::lock(nonce_cache());
256 if let Some(existing) = guard.get(nonce)
257 && *existing >= u32::MAX - 1
258 {
259 guard.pop(nonce);
260 }
261 match guard.get_mut(nonce) {
262 Some(counter) => {
263 *counter = counter.saturating_add(1);
264 *counter
265 }
266 None => {
267 guard.put(nonce.to_string(), 1);
268 1
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests;