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        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;