Skip to main content

leyline/core/
digest.rs

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;