http_auth/
digest.rs

1// Copyright (C) 2021 Scott Lamb <slamb@slamb.org>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `Digest` authentication scheme, as in
5//! [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616).
6
7use std::{convert::TryFrom, fmt::Write as _, io::Write as _};
8
9use digest::Digest;
10
11use crate::{
12    char_classes, ChallengeRef, ParamValue, PasswordParams, C_ATTR, C_ESCAPABLE, C_QDTEXT,
13};
14
15/// "Quality of protection" value.
16///
17/// The values here can be used in a bitmask as in [`DigestClient::qop`].
18#[derive(Copy, Clone, Debug)]
19#[repr(u8)]
20#[non_exhaustive]
21pub enum Qop {
22    /// Authentication.
23    Auth = 1,
24
25    /// Authentication with integrity protection.
26    ///
27    /// "Integrity protection" means protection of the request entity body.
28    AuthInt = 2,
29}
30
31impl Qop {
32    /// Returns a string form as expected over the wire.
33    fn as_str(self) -> &'static str {
34        match self {
35            Qop::Auth => "auth",
36            Qop::AuthInt => "auth-int",
37        }
38    }
39}
40
41/// A set of zero or more [`Qop`]s.
42#[derive(Copy, Clone, PartialEq, Eq)]
43pub struct QopSet(u8);
44
45impl std::fmt::Debug for QopSet {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        let mut l = f.debug_set();
48        if (self.0 & Qop::Auth as u8) != 0 {
49            l.entry(&"auth");
50        }
51        if (self.0 & Qop::AuthInt as u8) != 0 {
52            l.entry(&"auth-int");
53        }
54        l.finish()
55    }
56}
57
58impl std::ops::BitAnd<Qop> for QopSet {
59    type Output = bool;
60
61    fn bitand(self, rhs: Qop) -> Self::Output {
62        (self.0 & (rhs as u8)) != 0
63    }
64}
65
66/// Client for a `Digest` challenge, as in [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616).
67///
68/// This can be constructed by the `TryFrom<&ChallengeRef<'_>>` impl. However,
69/// in most cases this client should be used only indirectly through the more
70/// abstract [`crate::PasswordClient`].
71///
72/// Most of the information here is taken from the `WWW-Authenticate` or
73/// `Proxy-Authenticate` header. This also internally maintains a nonce counter.
74///
75/// ## Implementation notes
76///
77/// *   Recalculates `H(A1)` on each [`DigestClient::respond`] call. It'd be
78///     more CPU-efficient to calculate `H(A1)` only once by supplying the
79///     username and password at construction time or by caching (username,
80///     password) -> `H(A1)` mappings internally. `DigestClient` prioritizes
81///     simplicity instead.
82/// *   There's no support yet for parsing the `Authentication-Info` and
83///     `Proxy-Authentication-Info` header fields described by [RFC 7616 section
84///     3.5](https://datatracker.ietf.org/doc/html/rfc7616#section-3.5).
85///     PRs welcome!
86/// *   Always responds using `UTF-8`, and thus doesn't use or keep around the `charset`
87///     parameter. The RFC only allows that parameter to be set to `UTF-8` anyway.
88/// *   Supports [RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) compatibility as in
89///     [RFC 2617 section 3.2.2.1](https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1),
90///     even though RFC 7616 drops it. There are still RTSP cameras being sold
91///     in 2021 that use the RFC 2069-style calculations.
92/// *   Supports RFC 7616 `userhash`, even though it seems impractical and only
93///     marginally useful. The server must index the userhash for each supported
94///     algorithm or calculate it on-the-fly for all users in the database.
95/// *   The `-sess` algorithm variants haven't been tested; there's no example
96///     in the RFCs.
97///
98/// ## Security considerations
99///
100/// We strongly advise *servers* against implementing `Digest`:
101///
102/// *   It's actively harmful in that it prevents the server from securing their
103///     password storage via salted password hashes. See [RFC 7616 Section
104///     5.2](https://datatracker.ietf.org/doc/html/rfc7616#section-5.2).
105///     When your server offers `Digest` authentication, it is advertising that
106///     it stores plaintext passwords!
107/// *   It's no replacement for TLS in terms of protecting confidentiality of
108///     the password, much less confidentiality of any other information.
109///
110/// For *clients*, when a server supports both `Digest` and `Basic`, we advise
111/// using `Digest`. It provides (slightly) more confidentiality of passwords
112/// over the wire.
113///
114/// Some servers *only* support `Digest`. E.g.,
115/// [ONVIF](https://www.onvif.org/profiles/specifications/) mandates the
116/// `Digest` scheme. It doesn't prohibit implementing other schemes, but some
117/// cameras meet the specification's requirement and do no more.
118#[derive(Eq, PartialEq)]
119pub struct DigestClient {
120    /// Holds unescaped versions of all string fields.
121    ///
122    /// Using a single `String` minimizes the size of the `DigestClient`
123    /// itself and/or any option/enum it may be wrapped in. It also minimizes
124    /// padding bytes after each allocation. The fields as stored as follows:
125    ///
126    /// 1.  `realm`: `[0, domain_start)`
127    /// 2.  `domain`: `[domain_start, opaque_start)`
128    /// 3.  `opaque`: `[opaque_start, nonce_start)`
129    /// 4.  `nonce`: `[nonce_start, buf.len())`
130    buf: Box<str>,
131
132    // Positions described in `buf` comment above. See respective methods' doc
133    // comments for more information. These are stored as `u16` to save space,
134    // and because it's unreasonable for them to be large.
135    domain_start: u16,
136    opaque_start: u16,
137    nonce_start: u16,
138
139    // Non-string fields. See respective methods' doc comments for more information.
140    algorithm: Algorithm,
141    session: bool,
142    stale: bool,
143    rfc2069_compat: bool,
144    userhash: bool,
145    qop: QopSet,
146    nc: u32,
147}
148
149impl DigestClient {
150    /// Returns a string to be displayed to users so they know which username
151    /// and password to use.
152    ///
153    /// This string should contain at least the name of
154    /// the host performing the authentication and might additionally
155    /// indicate the collection of users who might have access.  An
156    /// example is `registered_users@example.com`.  (See [Section 2.2 of
157    /// RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235#section-2.2) for
158    /// more details.)
159    #[inline]
160    pub fn realm(&self) -> &str {
161        &self.buf[..self.domain_start as usize]
162    }
163
164    /// Returns the domain, a space-separated list of URIs, as specified in RFC
165    /// 3986, that define the protection space.
166    ///
167    /// If the domain parameter is absent, returns an empty string, which is semantically
168    /// identical according to the RFC.
169    #[inline]
170    pub fn domain(&self) -> &str {
171        &self.buf[self.domain_start as usize..self.opaque_start as usize]
172    }
173
174    /// Returns the nonce, a server-specified string which should be uniquely
175    /// generated each time a 401 response is made.
176    #[inline]
177    pub fn nonce(&self) -> &str {
178        &self.buf[self.nonce_start as usize..]
179    }
180
181    /// Returns string of data, specified by the server, that SHOULD be returned
182    /// by the client unchanged in the Authorization header field of subsequent
183    /// requests with URIs in the same protection space.
184    ///
185    /// Currently an empty `opaque` is treated as an absent one.
186    #[inline]
187    pub fn opaque(&self) -> Option<&str> {
188        if self.opaque_start == self.nonce_start {
189            None
190        } else {
191            Some(&self.buf[self.opaque_start as usize..self.nonce_start as usize])
192        }
193    }
194
195    /// Returns a flag indicating that the previous request from the client was
196    /// rejected because the nonce value was stale.
197    #[inline]
198    pub fn stale(&self) -> bool {
199        self.stale
200    }
201
202    /// Returns true if using [RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069)
203    /// compatibility mode as in [RFC 2617 section
204    /// 3.2.2.1](https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1).
205    ///
206    /// If so, `request-digest` is calculated without the nonce count, conce, or qop.
207    #[inline]
208    pub fn rfc2069_compat(&self) -> bool {
209        self.rfc2069_compat
210    }
211
212    /// Returns the algorithm used to produce the digest and an unkeyed digest.
213    #[inline]
214    pub fn algorithm(&self) -> Algorithm {
215        self.algorithm
216    }
217
218    /// Returns if the session style `A1` will be used.
219    #[inline]
220    pub fn session(&self) -> bool {
221        self.session
222    }
223
224    /// Returns the acceptable `qop` (quality of protection) values.
225    #[inline]
226    pub fn qop(&self) -> QopSet {
227        self.qop
228    }
229
230    /// Returns the number of times the server-supplied nonce has been used by
231    /// [`DigestClient::respond`].
232    #[inline]
233    pub fn nonce_count(&self) -> u32 {
234        self.nc
235    }
236
237    /// Responds to the challenge with the supplied parameters.
238    ///
239    /// The caller should use the returned string as an `Authorization` or
240    /// `Proxy-Authorization` header value.
241    #[inline]
242    pub fn respond(&mut self, p: &PasswordParams) -> Result<String, String> {
243        self.respond_inner(p, &new_random_cnonce())
244    }
245
246    /// Responds using a fixed cnonce **for testing only**.
247    ///
248    /// In production code, use [`DigestClient::respond`] instead, which generates a new
249    /// random cnonce value.
250    #[inline]
251    pub fn respond_with_testing_cnonce(
252        &mut self,
253        p: &PasswordParams,
254        cnonce: &str,
255    ) -> Result<String, String> {
256        self.respond_inner(p, cnonce)
257    }
258
259    /// Helper for respond methods.
260    ///
261    /// We don't simply implement this as `respond_with_testing_cnonce` and have
262    /// `respond` delegate to that method because it'd be confusing/alarming if
263    /// that method name ever shows up in production stack traces.
264    fn respond_inner(&mut self, p: &PasswordParams, cnonce: &str) -> Result<String, String> {
265        let realm = self.realm();
266        let mut h_a1 = self.algorithm.h(&[
267            p.username.as_bytes(),
268            b":",
269            realm.as_bytes(),
270            b":",
271            p.password.as_bytes(),
272        ]);
273        if self.session {
274            h_a1 = self.algorithm.h(&[
275                h_a1.as_bytes(),
276                b":",
277                self.nonce().as_bytes(),
278                b":",
279                cnonce.as_bytes(),
280            ]);
281        }
282
283        // Select the best available qop and calculate H(A2) as in
284        // [https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.3].
285        let (h_a2, qop);
286        if let (Some(body), true) = (p.body, self.qop & Qop::AuthInt) {
287            h_a2 = self
288                .algorithm
289                .h(&[p.method.as_bytes(), b":", p.uri.as_bytes(), b":", body]);
290            qop = Qop::AuthInt;
291        } else if self.qop & Qop::Auth {
292            h_a2 = self
293                .algorithm
294                .h(&[p.method.as_bytes(), b":", p.uri.as_bytes()]);
295            qop = Qop::Auth;
296        } else {
297            return Err("no supported/available qop".into());
298        }
299
300        let nc = self.nc.checked_add(1).ok_or("nonce count exhausted")?;
301        let mut hex_nc = [0u8; 8];
302        let _ = write!(&mut hex_nc[..], "{:08x}", nc);
303        let str_hex_nc = match std::str::from_utf8(&hex_nc[..]) {
304            Ok(h) => h,
305            Err(_) => unreachable!(),
306        };
307
308        // https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1
309        let response = if self.rfc2069_compat {
310            self.algorithm.h(&[
311                h_a1.as_bytes(),
312                b":",
313                self.nonce().as_bytes(),
314                b":",
315                h_a2.as_bytes(),
316            ])
317        } else {
318            self.algorithm.h(&[
319                h_a1.as_bytes(),
320                b":",
321                self.nonce().as_bytes(),
322                b":",
323                &hex_nc[..],
324                b":",
325                cnonce.as_bytes(),
326                b":",
327                qop.as_str().as_bytes(),
328                b":",
329                h_a2.as_bytes(),
330            ])
331        };
332
333        let mut out = String::with_capacity(128);
334        out.push_str("Digest ");
335        if self.userhash {
336            let hashed = self
337                .algorithm
338                .h(&[p.username.as_bytes(), b":", realm.as_bytes()]);
339            append_quoted_key_value(&mut out, "username", &hashed)?;
340            append_unquoted_key_value(&mut out, "userhash", "true");
341        } else if is_valid_quoted_value(p.username) {
342            append_quoted_key_value(&mut out, "username", p.username)?;
343        } else {
344            append_extended_key_value(&mut out, "username", p.username);
345        }
346        append_quoted_key_value(&mut out, "realm", self.realm())?;
347        append_quoted_key_value(&mut out, "uri", p.uri)?;
348        append_quoted_key_value(&mut out, "nonce", self.nonce())?;
349        if !self.rfc2069_compat {
350            append_unquoted_key_value(&mut out, "algorithm", self.algorithm.as_str(self.session));
351            append_unquoted_key_value(&mut out, "nc", str_hex_nc);
352            append_quoted_key_value(&mut out, "cnonce", cnonce)?;
353            append_unquoted_key_value(&mut out, "qop", qop.as_str());
354        }
355        append_quoted_key_value(&mut out, "response", &response)?;
356        if let Some(o) = self.opaque() {
357            append_quoted_key_value(&mut out, "opaque", o)?;
358        }
359        out.truncate(out.len() - 2); // remove final ", "
360        self.nc = nc;
361        Ok(out)
362    }
363}
364
365impl TryFrom<&ChallengeRef<'_>> for DigestClient {
366    type Error = String;
367
368    fn try_from(value: &ChallengeRef<'_>) -> Result<Self, Self::Error> {
369        if !value.scheme.eq_ignore_ascii_case("Digest") {
370            return Err(format!(
371                "DigestClientContext doesn't support challenge scheme {:?}",
372                value.scheme
373            ));
374        }
375        let mut buf_len = 0;
376        let mut unused_len = 0;
377        let mut realm = None;
378        let mut domain = None;
379        let mut nonce = None;
380        let mut opaque = None;
381        let mut stale = false;
382        let mut algorithm_and_session = None;
383        let mut qop_str = None;
384        let mut userhash_str = None;
385
386        // Parse response header field parameters as in
387        // [https://datatracker.ietf.org/doc/html/rfc7616#section-3.3].
388        for (k, v) in &value.params {
389            // Note that "stale" and "algorithm" can be directly compared
390            // without unescaping because RFC 7616 section 3.3 says "For
391            // historical reasons, a sender MUST NOT generate the quoted string
392            // syntax values for the following parameters: stale and algorithm."
393            if store_param(k, v, "realm", &mut realm, &mut buf_len)?
394                || store_param(k, v, "domain", &mut domain, &mut buf_len)?
395                || store_param(k, v, "nonce", &mut nonce, &mut buf_len)?
396                || store_param(k, v, "opaque", &mut opaque, &mut buf_len)?
397                || store_param(k, v, "qop", &mut qop_str, &mut unused_len)?
398                || store_param(k, v, "userhash", &mut userhash_str, &mut unused_len)?
399            {
400                // Do nothing here.
401            } else if k.eq_ignore_ascii_case("stale") {
402                stale = v.escaped.eq_ignore_ascii_case("true");
403            } else if k.eq_ignore_ascii_case("algorithm") {
404                algorithm_and_session = Some(Algorithm::parse(v.escaped)?);
405            }
406        }
407        let realm = realm.ok_or("missing required parameter realm")?;
408        let nonce = nonce.ok_or("missing required parameter nonce")?;
409        if buf_len > u16::MAX as usize {
410            // Incredibly unlikely, but just for completeness.
411            return Err(format!(
412                "Unescaped parameters' length {} exceeds u16::MAX!",
413                buf_len
414            ));
415        }
416
417        let algorithm_and_session = algorithm_and_session.unwrap_or((Algorithm::Md5, false));
418
419        let mut buf = String::with_capacity(buf_len);
420        let mut qop = QopSet(0);
421        let rfc2069_compat = if let Some(qop_str) = qop_str {
422            let qop_str = qop_str.unescaped_with_scratch(&mut buf);
423            for v in qop_str.split(',') {
424                let v = v.trim();
425                if v.eq_ignore_ascii_case("auth") {
426                    qop.0 |= Qop::Auth as u8;
427                } else if v.eq_ignore_ascii_case("auth-int") {
428                    qop.0 |= Qop::AuthInt as u8;
429                }
430            }
431            if qop.0 == 0 {
432                return Err(format!("no supported qop in {:?}", qop_str));
433            }
434            buf.clear();
435            false
436        } else {
437            // An absent qop is treated as "auth", according to
438            // https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.3
439            qop.0 |= Qop::Auth as u8;
440            true
441        };
442        let userhash;
443        if let Some(userhash_str) = userhash_str {
444            let userhash_str = userhash_str.unescaped_with_scratch(&mut buf);
445            userhash = userhash_str.eq_ignore_ascii_case("true");
446            buf.clear();
447        } else {
448            userhash = false;
449        };
450        realm.append_unescaped(&mut buf);
451        let domain_start = buf.len();
452        if let Some(d) = domain {
453            d.append_unescaped(&mut buf);
454        }
455        let opaque_start = buf.len();
456        if let Some(o) = opaque {
457            o.append_unescaped(&mut buf);
458        }
459        let nonce_start = buf.len();
460        nonce.append_unescaped(&mut buf);
461        Ok(DigestClient {
462            buf: buf.into_boxed_str(),
463            domain_start: domain_start as u16,
464            opaque_start: opaque_start as u16,
465            nonce_start: nonce_start as u16,
466            algorithm: algorithm_and_session.0,
467            session: algorithm_and_session.1,
468            stale,
469            rfc2069_compat,
470            userhash,
471            qop,
472            nc: 0,
473        })
474    }
475}
476
477impl std::fmt::Debug for DigestClient {
478    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479        f.debug_struct("DigestClient")
480            .field("realm", &self.realm())
481            .field("domain", &self.domain())
482            .field("opaque", &self.opaque())
483            .field("nonce", &self.nonce())
484            .field("algorithm", &self.algorithm.as_str(self.session))
485            .field("stale", &self.stale)
486            .field("qop", &self.qop)
487            .field("rfc2069_compat", &self.rfc2069_compat)
488            .field("userhash", &self.userhash)
489            .field("nc", &self.nc)
490            .finish()
491    }
492}
493
494/// Helper for `DigestClient::try_from` which stashes away a `&ParamValue`.
495#[inline(never)]
496fn store_param<'v, 'tmp>(
497    k: &'tmp str,
498    v: &'v ParamValue<'v>,
499    expected_k: &'tmp str,
500    set_v: &'tmp mut Option<&'v ParamValue<'v>>,
501    add_len: &'tmp mut usize,
502) -> Result<bool, String> {
503    if !k.eq_ignore_ascii_case(expected_k) {
504        return Ok(false);
505    }
506    if set_v.is_some() {
507        return Err(format!("duplicate parameter {:?}", k));
508    }
509    *add_len += v.unescaped_len();
510    *set_v = Some(v);
511    Ok(true)
512}
513
514fn is_valid_quoted_value(s: &str) -> bool {
515    for &b in s.as_bytes() {
516        if char_classes(b) & (C_QDTEXT | C_ESCAPABLE) == 0 {
517            return false;
518        }
519    }
520    true
521}
522
523fn append_extended_key_value(out: &mut String, key: &str, value: &str) {
524    out.push_str(key);
525    out.push_str("*=UTF-8''");
526    for &b in value.as_bytes() {
527        if (char_classes(b) & C_ATTR) != 0 {
528            out.push(char::from(b));
529        } else {
530            let _ = write!(out, "%{:02X}", b);
531        }
532    }
533    out.push_str(", ");
534}
535
536#[inline(never)]
537fn append_unquoted_key_value(out: &mut String, key: &str, value: &str) {
538    out.push_str(key);
539    out.push('=');
540    out.push_str(value);
541    out.push_str(", ");
542}
543
544#[inline(never)]
545fn append_quoted_key_value(out: &mut String, key: &str, value: &str) -> Result<(), String> {
546    out.push_str(key);
547    out.push_str("=\"");
548    let mut first_unwritten = 0;
549    let bytes = value.as_bytes();
550    for (i, &b) in bytes.iter().enumerate() {
551        // Note that bytes >= 128 are in neither C_QDTEXT nor C_ESCAPABLE, so every allowed byte
552        // is a full UTF-8 code point.
553        let class = char_classes(b);
554        if (class & C_QDTEXT) != 0 {
555            // Just advance.
556        } else if (class & C_ESCAPABLE) != 0 {
557            out.push_str(&value[first_unwritten..i]);
558            out.push('\\');
559            out.push(char::from(b));
560            first_unwritten = i + 1;
561        } else {
562            return Err(format!("invalid {} value {:?}", key, value));
563        }
564    }
565    out.push_str(&value[first_unwritten..]);
566    out.push_str("\", ");
567    Ok(())
568}
569
570/// Supported algorithm from the [HTTP Digest Algorithm Values
571/// registry](https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml).
572///
573/// This doesn't store whether the session variant (`<Algorithm>-sess`) was
574/// requested; see [`DigestClient::session`] for that.
575#[derive(Copy, Clone, Debug, Eq, PartialEq)]
576#[non_exhaustive]
577pub enum Algorithm {
578    Md5,
579    Sha256,
580    Sha512Trunc256,
581}
582
583impl Algorithm {
584    /// Parses a string into a tuple of `Algorithm` and a bool representing
585    /// whether the `-sess` suffix is present.
586    fn parse(s: &str) -> Result<(Self, bool), String> {
587        Ok(match s {
588            "MD5" => (Algorithm::Md5, false),
589            "MD5-sess" => (Algorithm::Md5, true),
590            "SHA-256" => (Algorithm::Sha256, false),
591            "SHA-256-sess" => (Algorithm::Sha256, true),
592            "SHA-512-256" => (Algorithm::Sha512Trunc256, false),
593            "SHA-512-256-sess" => (Algorithm::Sha512Trunc256, true),
594            _ => return Err(format!("unknown algorithm {:?}", s)),
595        })
596    }
597
598    #[inline(never)]
599    fn as_str(&self, session: bool) -> &'static str {
600        match (self, session) {
601            (Algorithm::Md5, false) => "MD5",
602            (Algorithm::Md5, true) => "MD5-sess",
603            (Algorithm::Sha256, false) => "SHA-256",
604            (Algorithm::Sha256, true) => "SHA-256-sess",
605            (Algorithm::Sha512Trunc256, false) => "SHA-512-256",
606            (Algorithm::Sha512Trunc256, true) => "SHA-512-256-sess",
607        }
608    }
609
610    #[inline(never)]
611    fn h(&self, items: &[&[u8]]) -> String {
612        match self {
613            Algorithm::Md5 => h(md5::Md5::new(), items),
614            Algorithm::Sha256 => h(sha2::Sha256::new(), items),
615            Algorithm::Sha512Trunc256 => h(sha2::Sha512_256::new(), items),
616        }
617    }
618}
619
620fn h<D: Digest>(mut d: D, items: &[&[u8]]) -> String {
621    for i in items {
622        d.update(i);
623    }
624    hex::encode(d.finalize())
625}
626
627fn new_random_cnonce() -> String {
628    let raw: [u8; 16] = rand::random();
629    hex::encode(&raw[..])
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use pretty_assertions::assert_eq;
636
637    /// Tests the example from [RFC 7616 section 3.9.1: SHA-256 and
638    /// MD5](https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1).
639    #[test]
640    fn sha256_and_md5() {
641        let www_authenticate = "\
642            Digest \
643            realm=\"http-auth@example.org\", \
644            qop=\"auth, auth-int\", \
645            algorithm=SHA-256, \
646            nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
647            opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", \
648            Digest \
649            realm=\"http-auth@example.org\", \
650            qop=\"auth, auth-int\", \
651            algorithm=MD5, \
652            nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
653            opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"";
654        let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap());
655        assert_eq!(challenges.len(), 2);
656        let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect();
657        let mut ctxs = dbg!(ctxs.unwrap());
658        assert_eq!(ctxs[1].realm(), "http-auth@example.org");
659        assert_eq!(ctxs[1].domain(), "");
660        assert_eq!(
661            ctxs[1].nonce(),
662            "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v"
663        );
664        assert_eq!(
665            ctxs[1].opaque(),
666            Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS")
667        );
668        assert_eq!(ctxs[1].stale(), false);
669        assert_eq!(ctxs[1].algorithm(), Algorithm::Md5);
670        assert_eq!(ctxs[1].qop().0, (Qop::Auth as u8) | (Qop::AuthInt as u8));
671        assert_eq!(ctxs[1].nonce_count(), 0);
672        let params = crate::PasswordParams {
673            username: "Mufasa",
674            password: "Circle of Life",
675            uri: "/dir/index.html",
676            body: None,
677            method: "GET",
678        };
679        assert_eq!(
680            &mut ctxs[0]
681                .respond_with_testing_cnonce(
682                    &params,
683                    "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"
684                )
685                .unwrap(),
686            "Digest username=\"Mufasa\", \
687                    realm=\"http-auth@example.org\", \
688                    uri=\"/dir/index.html\", \
689                    nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
690                    algorithm=SHA-256, \
691                    nc=00000001, \
692                    cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \
693                    qop=auth, \
694                    response=\"753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1\", \
695                    opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\""
696        );
697        assert_eq!(ctxs[0].nc, 1);
698        assert_eq!(
699            &mut ctxs[1]
700                .respond_with_testing_cnonce(
701                    &params,
702                    "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"
703                )
704                .unwrap(),
705            "Digest username=\"Mufasa\", \
706                    realm=\"http-auth@example.org\", \
707                    uri=\"/dir/index.html\", \
708                    nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
709                    algorithm=MD5, \
710                    nc=00000001, \
711                    cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \
712                    qop=auth, \
713                    response=\"8ca523f5e9506fed4657c9700eebdbec\", \
714                    opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\""
715        );
716        assert_eq!(ctxs[1].nc, 1);
717    }
718
719    /// Tests a made-up example with `MD5-sess`. There's no example in the RFC,
720    /// and these values haven't been tested against any other implementation.
721    /// But having the test here ensures we don't accidentally change the
722    /// algorithm.
723    #[test]
724    fn md5_sess() {
725        let www_authenticate = "\
726            Digest \
727            realm=\"http-auth@example.org\", \
728            qop=\"auth, auth-int\", \
729            algorithm=MD5-sess, \
730            nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
731            opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"";
732        let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap());
733        assert_eq!(challenges.len(), 1);
734        let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect();
735        let mut ctxs = dbg!(ctxs.unwrap());
736        assert_eq!(ctxs[0].realm(), "http-auth@example.org");
737        assert_eq!(ctxs[0].domain(), "");
738        assert_eq!(
739            ctxs[0].nonce(),
740            "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v"
741        );
742        assert_eq!(
743            ctxs[0].opaque(),
744            Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS")
745        );
746        assert_eq!(ctxs[0].stale(), false);
747        assert_eq!(ctxs[0].algorithm(), Algorithm::Md5);
748        assert_eq!(ctxs[0].session(), true);
749        assert_eq!(ctxs[0].qop().0, (Qop::Auth as u8) | (Qop::AuthInt as u8));
750        assert_eq!(ctxs[0].nonce_count(), 0);
751        let params = crate::PasswordParams {
752            username: "Mufasa",
753            password: "Circle of Life",
754            uri: "/dir/index.html",
755            body: None,
756            method: "GET",
757        };
758        assert_eq!(
759            &mut ctxs[0]
760                .respond_with_testing_cnonce(
761                    &params,
762                    "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"
763                )
764                .unwrap(),
765            "Digest username=\"Mufasa\", \
766                    realm=\"http-auth@example.org\", \
767                    uri=\"/dir/index.html\", \
768                    nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \
769                    algorithm=MD5-sess, \
770                    nc=00000001, \
771                    cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \
772                    qop=auth, \
773                    response=\"e783283f46242139c486a698fec7211d\", \
774                    opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\""
775        );
776        assert_eq!(ctxs[0].nc, 1);
777    }
778
779    /// Tests the example from [RFC 7616 section 3.9.2: SHA-512-256, Charset, and
780    /// Userhash](https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.2).
781    #[test]
782    fn sha512_256_charset() {
783        let www_authenticate = "\
784            Digest \
785            realm=\"api@example.org\", \
786            qop=\"auth\", \
787            algorithm=SHA-512-256, \
788            nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \
789            opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\", \
790            charset=UTF-8, \
791            userhash=true";
792        let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap());
793        assert_eq!(challenges.len(), 1);
794        let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect();
795        let mut ctxs = dbg!(ctxs.unwrap());
796        assert_eq!(ctxs.len(), 1);
797        assert_eq!(ctxs[0].realm(), "api@example.org");
798        assert_eq!(ctxs[0].domain(), "");
799        assert_eq!(
800            ctxs[0].nonce(),
801            "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK"
802        );
803        assert_eq!(
804            ctxs[0].opaque(),
805            Some("HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS")
806        );
807        assert_eq!(ctxs[0].stale, false);
808        assert_eq!(ctxs[0].userhash, true);
809        assert_eq!(ctxs[0].algorithm, Algorithm::Sha512Trunc256);
810        assert_eq!(ctxs[0].qop.0, Qop::Auth as u8);
811        assert_eq!(ctxs[0].nc, 0);
812        let params = crate::PasswordParams {
813            username: "J\u{E4}s\u{F8}n Doe",
814            password: "Secret, or not?",
815            uri: "/doe.json",
816            body: None,
817            method: "GET",
818        };
819
820        // Note the username and response values in the RFC are *wrong*!
821        // https://www.rfc-editor.org/errata/eid4897
822        assert_eq!(
823            &mut ctxs[0]
824                .respond_with_testing_cnonce(
825                    &params,
826                    "NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v"
827                )
828                .unwrap(),
829            "\
830            Digest \
831            username=\"793263caabb707a56211940d90411ea4a575adeccb7e360aeb624ed06ece9b0b\", \
832            userhash=true, \
833            realm=\"api@example.org\", \
834            uri=\"/doe.json\", \
835            nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \
836            algorithm=SHA-512-256, \
837            nc=00000001, \
838            cnonce=\"NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v\", \
839            qop=auth, \
840            response=\"3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5\", \
841            opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\""
842        );
843        assert_eq!(ctxs[0].nc, 1);
844        ctxs[0].userhash = false;
845        ctxs[0].nc = 0;
846        assert_eq!(
847            &mut ctxs[0]
848                .respond_with_testing_cnonce(
849                    &params,
850                    "NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v"
851                )
852                .unwrap(),
853            "\
854            Digest \
855            username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, \
856            realm=\"api@example.org\", \
857            uri=\"/doe.json\", \
858            nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \
859            algorithm=SHA-512-256, \
860            nc=00000001, \
861            cnonce=\"NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v\", \
862            qop=auth, \
863            response=\"3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5\", \
864            opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\""
865        );
866        assert_eq!(ctxs[0].nc, 1);
867    }
868
869    #[test]
870    fn rfc2069() {
871        // https://datatracker.ietf.org/doc/html/rfc2069#section-2.4
872        // The response there is wrong! See https://www.rfc-editor.org/errata/eid749
873        let www_authenticate = "\
874            Digest \
875            realm=\"testrealm@host.com\", \
876            nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", \
877            opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"";
878        let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap());
879        assert_eq!(challenges.len(), 1);
880        let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect();
881        let mut ctxs = dbg!(ctxs.unwrap());
882        assert_eq!(ctxs.len(), 1);
883        assert_eq!(ctxs[0].qop.0, Qop::Auth as u8);
884        assert_eq!(ctxs[0].rfc2069_compat, true);
885        let params = crate::PasswordParams {
886            username: "Mufasa",
887            password: "CircleOfLife",
888            uri: "/dir/index.html",
889            body: None,
890            method: "GET",
891        };
892        assert_eq!(
893            &mut ctxs[0]
894                .respond_with_testing_cnonce(&params, "unused")
895                .unwrap(),
896            "\
897            Digest \
898            username=\"Mufasa\", \
899            realm=\"testrealm@host.com\", \
900            uri=\"/dir/index.html\", \
901            nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", \
902            response=\"1949323746fe6a43ef61f9606e7febea\", \
903            opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
904        );
905        assert_eq!(ctxs[0].nc, 1);
906    }
907
908    // See sizes with: cargo test -- --nocapture digest::tests::size
909    #[test]
910    fn size() {
911        // This type should have a niche.
912        assert_eq!(
913            dbg!(std::mem::size_of::<DigestClient>()),
914            dbg!(std::mem::size_of::<Option<DigestClient>>()),
915        )
916    }
917}