Skip to main content

fastapi_core/
digest.rs

1//! HTTP Digest authentication (RFC 7616 / RFC 2617).
2//!
3//! Scope (bd-gl3v):
4//! - Parse `Authorization: Digest ...`
5//! - Provide response computation + verification helpers
6//! - Keep dependencies minimal (no external crypto crates)
7
8use crate::extract::FromRequest;
9use crate::password::constant_time_eq;
10use crate::response::IntoResponse;
11use crate::{Method, Request, RequestContext};
12use core::fmt;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DigestAlgorithm {
16    Md5,
17    Md5Sess,
18    Sha256,
19    Sha256Sess,
20}
21
22impl DigestAlgorithm {
23    #[must_use]
24    pub fn parse(s: &str) -> Option<Self> {
25        if s.eq_ignore_ascii_case("md5") {
26            Some(Self::Md5)
27        } else if s.eq_ignore_ascii_case("md5-sess") {
28            Some(Self::Md5Sess)
29        } else if s.eq_ignore_ascii_case("sha-256") {
30            Some(Self::Sha256)
31        } else if s.eq_ignore_ascii_case("sha-256-sess") {
32            Some(Self::Sha256Sess)
33        } else {
34            None
35        }
36    }
37
38    #[must_use]
39    pub fn is_sess(self) -> bool {
40        matches!(self, Self::Md5Sess | Self::Sha256Sess)
41    }
42
43    #[must_use]
44    fn response_hex_len(self) -> usize {
45        match self {
46            Self::Md5 | Self::Md5Sess => 32,
47            Self::Sha256 | Self::Sha256Sess => 64,
48        }
49    }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DigestQop {
54    Auth,
55    AuthInt,
56}
57
58impl DigestQop {
59    #[must_use]
60    pub fn parse(s: &str) -> Option<Self> {
61        if s.eq_ignore_ascii_case("auth") {
62            Some(Self::Auth)
63        } else if s.eq_ignore_ascii_case("auth-int") {
64            Some(Self::AuthInt)
65        } else {
66            None
67        }
68    }
69
70    #[must_use]
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::Auth => "auth",
74            Self::AuthInt => "auth-int",
75        }
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct DigestAuth {
81    pub username: String,
82    pub realm: Option<String>,
83    pub nonce: String,
84    pub uri: String,
85    pub response: String,
86    pub opaque: Option<String>,
87    pub algorithm: DigestAlgorithm,
88    pub qop: Option<DigestQop>,
89    pub nc: Option<String>,
90    pub cnonce: Option<String>,
91}
92
93#[derive(Debug, Clone)]
94pub struct DigestAuthError {
95    pub kind: DigestAuthErrorKind,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum DigestAuthErrorKind {
100    MissingHeader,
101    InvalidUtf8,
102    InvalidScheme,
103    InvalidFormat(&'static str),
104    MissingField(&'static str),
105    UnsupportedQop,
106    UnsupportedAlgorithm,
107    InvalidNc,
108    InvalidResponseHex,
109}
110
111impl fmt::Display for DigestAuthError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match &self.kind {
114            DigestAuthErrorKind::MissingHeader => write!(f, "Missing Authorization header"),
115            DigestAuthErrorKind::InvalidUtf8 => write!(f, "Invalid Authorization header encoding"),
116            DigestAuthErrorKind::InvalidScheme => {
117                write!(f, "Authorization header must use Digest scheme")
118            }
119            DigestAuthErrorKind::InvalidFormat(m) => write!(f, "Invalid Digest header: {m}"),
120            DigestAuthErrorKind::MissingField(k) => write!(f, "Digest header missing field: {k}"),
121            DigestAuthErrorKind::UnsupportedQop => write!(f, "Unsupported Digest qop"),
122            DigestAuthErrorKind::UnsupportedAlgorithm => write!(f, "Unsupported Digest algorithm"),
123            DigestAuthErrorKind::InvalidNc => write!(f, "Invalid Digest nc value"),
124            DigestAuthErrorKind::InvalidResponseHex => write!(f, "Invalid Digest response value"),
125        }
126    }
127}
128
129impl std::error::Error for DigestAuthError {}
130
131impl IntoResponse for DigestAuthError {
132    fn into_response(self) -> crate::response::Response {
133        use crate::response::{Response, ResponseBody, StatusCode};
134
135        let detail = match self.kind {
136            DigestAuthErrorKind::MissingHeader => "Not authenticated",
137            DigestAuthErrorKind::InvalidUtf8 => "Invalid authentication credentials",
138            DigestAuthErrorKind::InvalidScheme => "Invalid authentication credentials",
139            DigestAuthErrorKind::InvalidFormat(_) => "Invalid authentication credentials",
140            DigestAuthErrorKind::MissingField(_) => "Invalid authentication credentials",
141            DigestAuthErrorKind::UnsupportedQop => "Invalid authentication credentials",
142            DigestAuthErrorKind::UnsupportedAlgorithm => "Invalid authentication credentials",
143            DigestAuthErrorKind::InvalidNc => "Invalid authentication credentials",
144            DigestAuthErrorKind::InvalidResponseHex => "Invalid authentication credentials",
145        };
146
147        let body = serde_json::json!({ "detail": detail });
148        Response::with_status(StatusCode::UNAUTHORIZED)
149            .header(
150                "www-authenticate",
151                b"Digest realm=\"api\", qop=\"auth\", algorithm=MD5".to_vec(),
152            )
153            .header("content-type", b"application/json".to_vec())
154            .body(ResponseBody::Bytes(body.to_string().into_bytes()))
155    }
156}
157
158impl FromRequest for DigestAuth {
159    type Error = DigestAuthError;
160
161    async fn from_request(_ctx: &RequestContext, req: &mut Request) -> Result<Self, Self::Error> {
162        let auth_header = req.headers().get("authorization").ok_or(DigestAuthError {
163            kind: DigestAuthErrorKind::MissingHeader,
164        })?;
165        let auth_str = std::str::from_utf8(auth_header).map_err(|_| DigestAuthError {
166            kind: DigestAuthErrorKind::InvalidUtf8,
167        })?;
168        Self::parse(auth_str)
169    }
170}
171
172impl DigestAuth {
173    /// Parse an `Authorization` header value of the form `Digest ...`.
174    pub fn parse(header_value: &str) -> Result<Self, DigestAuthError> {
175        let mut it = header_value.splitn(2, char::is_whitespace);
176        let scheme = it.next().unwrap_or("");
177        if !scheme.eq_ignore_ascii_case("digest") {
178            return Err(DigestAuthError {
179                kind: DigestAuthErrorKind::InvalidScheme,
180            });
181        }
182        let rest = it.next().unwrap_or("").trim();
183        if rest.is_empty() {
184            return Err(DigestAuthError {
185                kind: DigestAuthErrorKind::InvalidFormat("missing parameters"),
186            });
187        }
188
189        let params = parse_kv_list(rest).map_err(|m| DigestAuthError {
190            kind: DigestAuthErrorKind::InvalidFormat(m),
191        })?;
192
193        let username = params
194            .get("username")
195            .ok_or(DigestAuthError {
196                kind: DigestAuthErrorKind::MissingField("username"),
197            })?
198            .clone();
199
200        let nonce = params
201            .get("nonce")
202            .ok_or(DigestAuthError {
203                kind: DigestAuthErrorKind::MissingField("nonce"),
204            })?
205            .clone();
206
207        let uri = params
208            .get("uri")
209            .ok_or(DigestAuthError {
210                kind: DigestAuthErrorKind::MissingField("uri"),
211            })?
212            .clone();
213
214        let response = params
215            .get("response")
216            .ok_or(DigestAuthError {
217                kind: DigestAuthErrorKind::MissingField("response"),
218            })?
219            .clone();
220
221        let realm = params.get("realm").map(ToString::to_string);
222        let opaque = params.get("opaque").map(ToString::to_string);
223
224        let algorithm = match params.get("algorithm") {
225            Some(v) => DigestAlgorithm::parse(v).ok_or(DigestAuthError {
226                kind: DigestAuthErrorKind::UnsupportedAlgorithm,
227            })?,
228            None => DigestAlgorithm::Md5,
229        };
230
231        if response.len() != algorithm.response_hex_len() || !is_hex(&response) {
232            return Err(DigestAuthError {
233                kind: DigestAuthErrorKind::InvalidResponseHex,
234            });
235        }
236
237        let qop = match params.get("qop") {
238            Some(v) => Some(DigestQop::parse(v).ok_or(DigestAuthError {
239                kind: DigestAuthErrorKind::UnsupportedQop,
240            })?),
241            None => None,
242        };
243
244        let nc = params.get("nc").map(|v| v.to_ascii_lowercase());
245        if let Some(nc) = &nc {
246            if nc.len() != 8 || !nc.as_bytes().iter().all(u8::is_ascii_hexdigit) {
247                return Err(DigestAuthError {
248                    kind: DigestAuthErrorKind::InvalidNc,
249                });
250            }
251        }
252
253        let cnonce = params.get("cnonce").map(ToString::to_string);
254        if qop.is_some() {
255            if nc.is_none() {
256                return Err(DigestAuthError {
257                    kind: DigestAuthErrorKind::MissingField("nc"),
258                });
259            }
260            if cnonce.is_none() {
261                return Err(DigestAuthError {
262                    kind: DigestAuthErrorKind::MissingField("cnonce"),
263                });
264            }
265        }
266
267        Ok(Self {
268            username,
269            realm,
270            nonce,
271            uri,
272            response: response.to_ascii_lowercase(),
273            opaque,
274            algorithm,
275            qop,
276            nc,
277            cnonce,
278        })
279    }
280
281    /// Compute the expected `response=` value for this challenge (lower hex).
282    ///
283    /// Supports:
284    /// - algorithms: MD5, MD5-sess, SHA-256, SHA-256-sess
285    /// - qop: auth (auth-int is rejected)
286    pub fn compute_expected_response(
287        &self,
288        method: Method,
289        realm: &str,
290        password: &str,
291    ) -> Result<String, DigestAuthError> {
292        let qop = match self.qop {
293            Some(DigestQop::Auth) => Some("auth"),
294            Some(DigestQop::AuthInt) => {
295                return Err(DigestAuthError {
296                    kind: DigestAuthErrorKind::UnsupportedQop,
297                });
298            }
299            None => None,
300        };
301
302        let ha1_0 = hash_hex(
303            self.algorithm,
304            format_args!("{}:{}:{}", self.username, realm, password),
305        );
306        let ha1 = if self.algorithm.is_sess() {
307            let Some(cnonce) = self.cnonce.as_deref() else {
308                return Err(DigestAuthError {
309                    kind: DigestAuthErrorKind::MissingField("cnonce"),
310                });
311            };
312            hash_hex(
313                self.algorithm,
314                format_args!("{}:{}:{}", ha1_0, self.nonce, cnonce),
315            )
316        } else {
317            ha1_0
318        };
319
320        let ha2 = hash_hex(
321            self.algorithm,
322            format_args!("{}:{}", method.as_str(), self.uri),
323        );
324
325        let response = if let Some(qop) = qop {
326            let Some(nc) = self.nc.as_deref() else {
327                return Err(DigestAuthError {
328                    kind: DigestAuthErrorKind::MissingField("nc"),
329                });
330            };
331            let Some(cnonce) = self.cnonce.as_deref() else {
332                return Err(DigestAuthError {
333                    kind: DigestAuthErrorKind::MissingField("cnonce"),
334                });
335            };
336            hash_hex(
337                self.algorithm,
338                format_args!("{}:{}:{}:{}:{}:{}", ha1, self.nonce, nc, cnonce, qop, ha2),
339            )
340        } else {
341            // RFC 2069 compatibility (no qop).
342            hash_hex(
343                self.algorithm,
344                format_args!("{}:{}:{}", ha1, self.nonce, ha2),
345            )
346        };
347
348        Ok(response)
349    }
350
351    /// Verify `response=` against the expected value (timing-safe).
352    pub fn verify(
353        &self,
354        method: Method,
355        realm: &str,
356        password: &str,
357    ) -> Result<bool, DigestAuthError> {
358        let expected = self.compute_expected_response(method, realm, password)?;
359        Ok(constant_time_eq(
360            expected.as_bytes(),
361            self.response.as_bytes(),
362        ))
363    }
364
365    /// Verify with challenge constraints, including nonce/realm matching.
366    pub fn verify_for_challenge(
367        &self,
368        method: Method,
369        realm: &str,
370        nonce: &str,
371        password: &str,
372    ) -> Result<bool, DigestAuthError> {
373        if self.nonce != nonce {
374            return Ok(false);
375        }
376        if let Some(header_realm) = self.realm.as_deref() {
377            if header_realm != realm {
378                return Ok(false);
379            }
380        }
381        self.verify(method, realm, password)
382    }
383}
384
385fn is_hex(s: &str) -> bool {
386    !s.is_empty() && s.as_bytes().iter().all(u8::is_ascii_hexdigit)
387}
388
389fn parse_kv_list(input: &str) -> Result<std::collections::HashMap<String, String>, &'static str> {
390    let mut out = std::collections::HashMap::new();
391    let bytes = input.as_bytes();
392    let mut i = 0usize;
393
394    while i < bytes.len() {
395        // Skip whitespace + commas.
396        while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') {
397            i += 1;
398        }
399        if i >= bytes.len() {
400            break;
401        }
402
403        // Key token.
404        let key_start = i;
405        while i < bytes.len()
406            && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
407        {
408            i += 1;
409        }
410        if i == key_start {
411            return Err("expected key");
412        }
413        let key = std::str::from_utf8(&bytes[key_start..i]).map_err(|_| "non-utf8 key")?;
414        let key = key.to_ascii_lowercase();
415
416        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
417            i += 1;
418        }
419        if i >= bytes.len() || bytes[i] != b'=' {
420            return Err("expected '='");
421        }
422        i += 1;
423        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
424            i += 1;
425        }
426        if i >= bytes.len() {
427            return Err("expected value");
428        }
429
430        let value = if bytes[i] == b'"' {
431            i += 1;
432            let mut buf = String::new();
433            let mut closed = false;
434            while i < bytes.len() {
435                let b = bytes[i];
436                i += 1;
437                match b {
438                    b'\\' => {
439                        if i >= bytes.len() {
440                            return Err("invalid escape");
441                        }
442                        let esc = bytes[i];
443                        i += 1;
444                        buf.push(esc as char);
445                    }
446                    b'"' => {
447                        closed = true;
448                        break;
449                    }
450                    _ => buf.push(b as char),
451                }
452            }
453            if !closed {
454                return Err("unterminated quoted value");
455            }
456            buf
457        } else {
458            let v_start = i;
459            while i < bytes.len() && bytes[i] != b',' {
460                i += 1;
461            }
462            let raw = std::str::from_utf8(&bytes[v_start..i]).map_err(|_| "non-utf8 value")?;
463            raw.trim().to_string()
464        };
465
466        out.insert(key, value);
467    }
468
469    Ok(out)
470}
471
472fn hash_hex(alg: DigestAlgorithm, args: fmt::Arguments<'_>) -> String {
473    let s = args.to_string();
474    match alg {
475        DigestAlgorithm::Md5 | DigestAlgorithm::Md5Sess => {
476            let d = md5(s.as_bytes());
477            hex_lower(&d)
478        }
479        DigestAlgorithm::Sha256 | DigestAlgorithm::Sha256Sess => {
480            let d = sha256(s.as_bytes());
481            hex_lower(&d)
482        }
483    }
484}
485
486fn hex_lower<const N: usize>(bytes: &[u8; N]) -> String {
487    const HEX: &[u8; 16] = b"0123456789abcdef";
488    let mut out = Vec::with_capacity(N * 2);
489    for &b in bytes {
490        out.push(HEX[(b >> 4) as usize]);
491        out.push(HEX[(b & 0x0f) as usize]);
492    }
493    String::from_utf8(out).expect("hex is ascii")
494}
495
496// =============================================================================
497// MD5 (minimal, pure Rust)
498// =============================================================================
499
500#[allow(clippy::many_single_char_names)]
501fn md5(data: &[u8]) -> [u8; 16] {
502    // RFC 1321.
503    let mut a0: u32 = 0x67452301;
504    let mut b0: u32 = 0xefcdab89;
505    let mut c0: u32 = 0x98badcfe;
506    let mut d0: u32 = 0x10325476;
507
508    let bit_len = (data.len() as u64) * 8;
509    let mut msg = Vec::with_capacity((data.len() + 9).div_ceil(64) * 64);
510    msg.extend_from_slice(data);
511    msg.push(0x80);
512    while (msg.len() % 64) != 56 {
513        msg.push(0);
514    }
515    msg.extend_from_slice(&bit_len.to_le_bytes());
516
517    for chunk in msg.chunks_exact(64) {
518        let mut m = [0u32; 16];
519        for (i, word) in m.iter_mut().enumerate() {
520            let j = i * 4;
521            *word = u32::from_le_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
522        }
523
524        let mut a = a0;
525        let mut b = b0;
526        let mut c = c0;
527        let mut d = d0;
528
529        for i in 0..64 {
530            let (f, g) = match i {
531                0..=15 => ((b & c) | ((!b) & d), i),
532                16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
533                32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
534                _ => (c ^ (b | (!d)), (7 * i) % 16),
535            };
536
537            let tmp = d;
538            d = c;
539            c = b;
540            b = b.wrapping_add(
541                (a.wrapping_add(f).wrapping_add(MD5_K[i]).wrapping_add(m[g])).rotate_left(MD5_S[i]),
542            );
543            a = tmp;
544        }
545
546        a0 = a0.wrapping_add(a);
547        b0 = b0.wrapping_add(b);
548        c0 = c0.wrapping_add(c);
549        d0 = d0.wrapping_add(d);
550    }
551
552    let mut out = [0u8; 16];
553    out[0..4].copy_from_slice(&a0.to_le_bytes());
554    out[4..8].copy_from_slice(&b0.to_le_bytes());
555    out[8..12].copy_from_slice(&c0.to_le_bytes());
556    out[12..16].copy_from_slice(&d0.to_le_bytes());
557    out
558}
559
560const MD5_S: [u32; 64] = [
561    7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9,
562    14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15,
563    21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
564];
565
566const MD5_K: [u32; 64] = [
567    0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
568    0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
569    0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
570    0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
571    0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
572    0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
573    0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
574    0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
575];
576
577#[allow(clippy::many_single_char_names)]
578fn sha256(data: &[u8]) -> [u8; 32] {
579    let mut state: [u32; 8] = [
580        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
581        0x5be0cd19,
582    ];
583
584    let bit_len = (data.len() as u64) * 8;
585    let mut padded = data.to_vec();
586    padded.push(0x80);
587    while (padded.len() % 64) != 56 {
588        padded.push(0);
589    }
590    padded.extend_from_slice(&bit_len.to_be_bytes());
591
592    for chunk in padded.chunks(64) {
593        let mut words = [0u32; 64];
594        for (i, word) in words.iter_mut().enumerate().take(16) {
595            let offset = i * 4;
596            *word = u32::from_be_bytes([
597                chunk[offset],
598                chunk[offset + 1],
599                chunk[offset + 2],
600                chunk[offset + 3],
601            ]);
602        }
603        for i in 16..64 {
604            let sigma0 = words[i - 15].rotate_right(7)
605                ^ words[i - 15].rotate_right(18)
606                ^ (words[i - 15] >> 3);
607            let sigma1 = words[i - 2].rotate_right(17)
608                ^ words[i - 2].rotate_right(19)
609                ^ (words[i - 2] >> 10);
610            words[i] = words[i - 16]
611                .wrapping_add(sigma0)
612                .wrapping_add(words[i - 7])
613                .wrapping_add(sigma1);
614        }
615
616        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state;
617        for i in 0..64 {
618            let sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
619            let choose = (e & f) ^ ((!e) & g);
620            let temp1 = h
621                .wrapping_add(sigma1)
622                .wrapping_add(choose)
623                .wrapping_add(SHA256_K[i])
624                .wrapping_add(words[i]);
625            let sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
626            let majority = (a & b) ^ (a & c) ^ (b & c);
627            let temp2 = sigma0.wrapping_add(majority);
628
629            h = g;
630            g = f;
631            f = e;
632            e = d.wrapping_add(temp1);
633            d = c;
634            c = b;
635            b = a;
636            a = temp1.wrapping_add(temp2);
637        }
638
639        state[0] = state[0].wrapping_add(a);
640        state[1] = state[1].wrapping_add(b);
641        state[2] = state[2].wrapping_add(c);
642        state[3] = state[3].wrapping_add(d);
643        state[4] = state[4].wrapping_add(e);
644        state[5] = state[5].wrapping_add(f);
645        state[6] = state[6].wrapping_add(g);
646        state[7] = state[7].wrapping_add(h);
647    }
648
649    let mut out = [0u8; 32];
650    for (i, value) in state.iter().enumerate() {
651        out[i * 4..i * 4 + 4].copy_from_slice(&value.to_be_bytes());
652    }
653    out
654}
655
656const SHA256_K: [u32; 64] = [
657    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
658    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
659    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
660    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
661    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
662    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
663    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
664    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
665];
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::response::IntoResponse;
671
672    #[test]
673    fn rfc_2617_mufasa_vector_md5_auth() {
674        // RFC 2617 example.
675        let hdr = concat!(
676            "Digest username=\"Mufasa\",",
677            " realm=\"testrealm@host.com\",",
678            " nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",",
679            " uri=\"/dir/index.html\",",
680            " qop=auth,",
681            " nc=00000001,",
682            " cnonce=\"0a4f113b\",",
683            " response=\"6629fae49393a05397450978507c4ef1\",",
684            " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""
685        );
686
687        let d = DigestAuth::parse(hdr).expect("parse");
688        assert_eq!(d.algorithm, DigestAlgorithm::Md5);
689        assert_eq!(d.qop, Some(DigestQop::Auth));
690
691        let ok = d
692            .verify(Method::Get, "testrealm@host.com", "Circle Of Life")
693            .expect("verify");
694        assert!(ok);
695    }
696
697    #[test]
698    fn md5_known_vector_empty() {
699        // MD5("") = d41d8cd98f00b204e9800998ecf8427e
700        let d = md5(b"");
701        assert_eq!(hex_lower(&d), "d41d8cd98f00b204e9800998ecf8427e");
702    }
703
704    #[test]
705    fn parse_uppercase_response_is_accepted_and_normalized() {
706        let hdr = concat!(
707            "Digest username=\"Mufasa\",",
708            " realm=\"testrealm@host.com\",",
709            " nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",",
710            " uri=\"/dir/index.html\",",
711            " qop=auth,",
712            " nc=00000001,",
713            " cnonce=\"0a4f113b\",",
714            " response=\"6629FAE49393A05397450978507C4EF1\""
715        );
716        let d = DigestAuth::parse(hdr).expect("parse");
717        assert_eq!(d.response, "6629fae49393a05397450978507c4ef1");
718    }
719
720    #[test]
721    fn verify_for_challenge_rejects_nonce_mismatch() {
722        let hdr = concat!(
723            "Digest username=\"Mufasa\",",
724            " realm=\"testrealm@host.com\",",
725            " nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",",
726            " uri=\"/dir/index.html\",",
727            " qop=auth,",
728            " nc=00000001,",
729            " cnonce=\"0a4f113b\",",
730            " response=\"6629fae49393a05397450978507c4ef1\""
731        );
732        let d = DigestAuth::parse(hdr).expect("parse");
733        let ok = d
734            .verify_for_challenge(
735                Method::Get,
736                "testrealm@host.com",
737                "different_nonce",
738                "Circle Of Life",
739            )
740            .expect("verify");
741        assert!(!ok);
742    }
743
744    #[test]
745    fn from_request_missing_header_produces_401() {
746        let cx = asupersync::Cx::for_testing();
747        let ctx = RequestContext::new(cx, 17);
748        let mut req = Request::new(Method::Get, "/");
749        let err = futures_executor::block_on(DigestAuth::from_request(&ctx, &mut req)).unwrap_err();
750        assert_eq!(err.kind, DigestAuthErrorKind::MissingHeader);
751        assert_eq!(err.into_response().status().as_u16(), 401);
752    }
753
754    #[test]
755    fn parse_rejects_unterminated_quoted_value() {
756        let hdr = "Digest username=\"Mufasa, nonce=\"abc\", uri=\"/\", response=\"0123456789abcdef0123456789abcdef\"";
757        let err = DigestAuth::parse(hdr).expect_err("unterminated quoted values must be rejected");
758        assert!(matches!(err.kind, DigestAuthErrorKind::InvalidFormat(_)));
759    }
760}