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 ch.fill(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
120impl Challenge {
121 fn fill(&mut self, pair: Pair<'_>) -> Result<()> {
122 let Pair { key, val } = pair;
123 match key.trim().to_ascii_lowercase().as_str() {
124 "realm" => self.realm = val,
125 "nonce" => self.nonce = val,
126 "qop" => self.qop = Some(val),
127 "opaque" => self.opaque = Some(val),
128 "stale" => self.stale = val.eq_ignore_ascii_case("true"),
129 "algorithm" => self.algorithm = algorithm(val.trim())?,
130 "domain" => self.domain = val.split_whitespace().map(str::to_owned).collect(),
131 _ => {}
132 }
133 Ok(())
134 }
135}
136
137fn algorithm(val: &str) -> Result<Algorithm> {
138 match val {
139 "MD5" | "md5" => Ok(Algorithm::Md5),
140 "MD5-sess" | "md5-sess" => Ok(Algorithm::Md5Sess),
141 "SHA-256" | "sha-256" => Ok(Algorithm::Sha256),
142 "SHA-256-sess" | "sha-256-sess" => Ok(Algorithm::Sha256Sess),
143 "SHA-512-256" | "sha-512-256" => Ok(Algorithm::Sha512_256),
144 "SHA-512-256-sess" | "sha-512-256-sess" => Ok(Algorithm::Sha512_256Sess),
145 other => Err(Error::new(Kind::Request)
146 .with_message(format!("digest: unsupported algorithm {other}"))),
147 }
148}
149
150impl Challenge {
151 pub(crate) fn build_auth_header(
152 &self,
153 auth: &DigestAuth,
154 method: &str,
155 uri: &str,
156 nc: u32,
157 cnonce: &str,
158 ) -> Option<String> {
159 let qop = match self.qop.as_deref() {
160 Some(offered) => pick_supported_qop(offered)?,
161 None => "",
162 };
163
164 let alg = self.algorithm;
165 let ha1_base =
166 alg.hash_hex(format!("{}:{}:{}", auth.username, self.realm, auth.password).as_bytes());
167 let ha1 = if alg.is_sess() {
168 alg.hash_hex(format!("{}:{}:{}", ha1_base, self.nonce, cnonce).as_bytes())
169 } else {
170 ha1_base
171 };
172 let ha2 = alg.hash_hex(format!("{}:{}", method, uri).as_bytes());
173
174 let nc_hex = format!("{:08x}", nc);
175
176 let response = if !qop.is_empty() {
177 alg.hash_hex(
178 format!(
179 "{}:{}:{}:{}:{}:{}",
180 ha1, self.nonce, nc_hex, cnonce, qop, ha2
181 )
182 .as_bytes(),
183 )
184 } else {
185 alg.hash_hex(format!("{}:{}:{}", ha1, self.nonce, ha2).as_bytes())
186 };
187
188 let quoted = |s: &str| {
189 let mut esc = String::with_capacity(s.len() + 2);
190 esc.push('"');
191 for c in s.chars() {
192 if c == '"' || c == '\\' {
193 esc.push('\\');
194 }
195 esc.push(c);
196 }
197 esc.push('"');
198 esc
199 };
200
201 let mut out = format!(
202 "Digest username={u}, realm={r}, nonce={n}, uri={uri}, algorithm={alg}, response={resp}",
203 u = quoted(&auth.username),
204 r = quoted(&self.realm),
205 n = quoted(&self.nonce),
206 uri = quoted(uri),
207 alg = alg.wire_name(),
208 resp = quoted(&response),
209 );
210 if !qop.is_empty() {
211 out.push_str(&format!(
212 ", qop={qop}, nc={nc_hex}, cnonce={}",
213 quoted(cnonce)
214 ));
215 }
216 if let Some(opaque) = &self.opaque {
217 out.push_str(&format!(", opaque={}", quoted(opaque)));
218 }
219 Some(out)
220 }
221
222 pub(crate) fn covers(&self, url: &url::Url) -> bool {
223 self.domain.is_empty()
224 || self.domain.iter().any(|space| match url.join(space) {
225 Ok(space) => space.origin() == url.origin() && url.path().starts_with(space.path()),
226 Err(_) => false,
227 })
228 }
229}
230
231pub(crate) fn pick_supported_qop(qop: &str) -> Option<&'static str> {
232 for token in qop.split(',') {
233 if token.trim().eq_ignore_ascii_case("auth") {
234 return Some("auth");
235 }
236 }
237 None
238}
239
240pub(crate) fn generate_cnonce() -> String {
241 crate::util::random_hex_token(8)
242}
243
244pub(crate) const DIGEST_NONCE_CACHE_CAP: usize = 4096;
245
246fn nonce_cache() -> &'static std::sync::Mutex<lru::LruCache<String, u32>> {
247 use lru::LruCache;
248 use std::num::NonZeroUsize;
249 use std::sync::{Mutex, OnceLock};
250 static CACHE: OnceLock<Mutex<LruCache<String, u32>>> = OnceLock::new();
251 CACHE.get_or_init(|| {
252 Mutex::new(LruCache::new(
253 NonZeroUsize::new(DIGEST_NONCE_CACHE_CAP).expect("cap is non-zero"),
254 ))
255 })
256}
257
258pub(crate) fn next_nc_for_nonce(nonce: &str) -> u32 {
259 let mut guard = crate::util::lock(nonce_cache());
260 if let Some(existing) = guard.get(nonce)
261 && *existing >= u32::MAX - 1
262 {
263 guard.pop(nonce);
264 }
265 match guard.get_mut(nonce) {
266 Some(counter) => {
267 *counter = counter.saturating_add(1);
268 *counter
269 }
270 None => {
271 guard.put(nonce.to_string(), 1);
272 1
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests;