diff options
Diffstat (limited to 'vendor/http-auth/src/digest.rs')
-rw-r--r-- | vendor/http-auth/src/digest.rs | 909 |
1 files changed, 909 insertions, 0 deletions
diff --git a/vendor/http-auth/src/digest.rs b/vendor/http-auth/src/digest.rs new file mode 100644 index 0000000..b322780 --- /dev/null +++ b/vendor/http-auth/src/digest.rs @@ -0,0 +1,909 @@ +// Copyright (C) 2021 Scott Lamb <slamb@slamb.org> +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Digest` authentication scheme, as in +//! [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616). + +use std::{convert::TryFrom, fmt::Write as _, io::Write as _}; + +use digest::Digest; + +use crate::{ + char_classes, ChallengeRef, ParamValue, PasswordParams, C_ATTR, C_ESCAPABLE, C_QDTEXT, +}; + +/// "Quality of protection" value. +/// +/// The values here can be used in a bitmask as in [`DigestClient::qop`]. +#[derive(Copy, Clone, Debug)] +#[repr(u8)] +#[non_exhaustive] +pub enum Qop { + /// Authentication. + Auth = 1, + + /// Authentication with integrity protection. + /// + /// "Integrity protection" means protection of the request entity body. + AuthInt = 2, +} + +impl Qop { + /// Returns a string form as expected over the wire. + fn as_str(self) -> &'static str { + match self { + Qop::Auth => "auth", + Qop::AuthInt => "auth-int", + } + } +} + +/// A set of zero or more [`Qop`]s. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct QopSet(u8); + +impl std::fmt::Debug for QopSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut l = f.debug_set(); + if (self.0 & Qop::Auth as u8) != 0 { + l.entry(&"auth"); + } + if (self.0 & Qop::AuthInt as u8) != 0 { + l.entry(&"auth-int"); + } + l.finish() + } +} + +impl std::ops::BitAnd<Qop> for QopSet { + type Output = bool; + + fn bitand(self, rhs: Qop) -> Self::Output { + (self.0 & (rhs as u8)) != 0 + } +} + +/// Client for a `Digest` challenge, as in [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616). +/// +/// Most of the information here is taken from the `WWW-Authenticate` or +/// `Proxy-Authenticate` header. This also internally maintains a nonce counter. +/// +/// ## Implementation notes +/// +/// * Recalculates `H(A1)` on each [`DigestClient::respond`] call. It'd be +/// more CPU-efficient to calculate `H(A1)` only once by supplying the +/// username and password at construction time or by caching (username, +/// password) -> `H(A1)` mappings internally. `DigestClient` prioritizes +/// simplicity instead. +/// * There's no support yet for parsing the `Authentication-Info` and +/// `Proxy-Authentication-Info` header fields described by [RFC 7616 section +/// 3.5](https://datatracker.ietf.org/doc/html/rfc7616#section-3.5). +/// PRs welcome! +/// * Always responds using `UTF-8`, and thus doesn't use or keep around the `charset` +/// parameter. The RFC only allows that parameter to be set to `UTF-8` anyway. +/// * Supports [RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) compatibility as in +/// [RFC 2617 section 3.2.2.1](https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1), +/// even though RFC 7616 drops it. There are still RTSP cameras being sold +/// in 2021 that use the RFC 2069-style calculations. +/// * Supports RFC 7616 `userhash`, even though it seems impractical and only +/// marginally useful. The server must index the userhash for each supported +/// algorithm or calculate it on-the-fly for all users in the database. +/// * The `-sess` algorithm variants haven't been tested; there's no example +/// in the RFCs. +/// +/// ## Security considerations +/// +/// We strongly advise *servers* against implementing `Digest`: +/// +/// * It's actively harmful in that it prevents the server from securing their +/// password storage via salted password hashes. See [RFC 7616 Section +/// 5.2](https://datatracker.ietf.org/doc/html/rfc7616#section-5.2). +/// When your server offers `Digest` authentication, it is advertising that +/// it stores plaintext passwords! +/// * It's no replacement for TLS in terms of protecting confidentiality of +/// the password, much less confidentiality of any other information. +/// +/// For *clients*, when a server supports both `Digest` and `Basic`, we advise +/// using `Digest`. It provides (slightly) more confidentiality of passwords +/// over the wire. +/// +/// Some servers *only* support `Digest`. E.g., +/// [ONVIF](https://www.onvif.org/profiles/specifications/) mandates the +/// `Digest` scheme. It doesn't prohibit implementing other schemes, but some +/// cameras meet the specification's requirement and do no more. +#[derive(Eq, PartialEq)] +pub struct DigestClient { + /// Holds unescaped versions of all string fields. + /// + /// Using a single `String` minimizes the size of the `DigestClient` + /// itself and/or any option/enum it may be wrapped in. It also minimizes + /// padding bytes after each allocation. The fields as stored as follows: + /// + /// 1. `realm`: `[0, domain_start)` + /// 2. `domain`: `[domain_start, opaque_start)` + /// 3. `opaque`: `[opaque_start, nonce_start)` + /// 4. `nonce`: `[nonce_start, buf.len())` + buf: Box<str>, + + // Positions described in `buf` comment above. See respective methods' doc + // comments for more information. These are stored as `u16` to save space, + // and because it's unreasonable for them to be large. + domain_start: u16, + opaque_start: u16, + nonce_start: u16, + + // Non-string fields. See respective methods' doc comments for more information. + algorithm: Algorithm, + session: bool, + stale: bool, + rfc2069_compat: bool, + userhash: bool, + qop: QopSet, + nc: u32, +} + +impl DigestClient { + /// Returns a string to be displayed to users so they know which username + /// and password to use. + /// + /// This string should contain at least the name of + /// the host performing the authentication and might additionally + /// indicate the collection of users who might have access. An + /// example is `registered_users@example.com`. (See [Section 2.2 of + /// RFC 7235](https://datatracker.ietf.org/doc/html/rfc7235#section-2.2) for + /// more details.) + #[inline] + pub fn realm(&self) -> &str { + &self.buf[..self.domain_start as usize] + } + + /// Returns the domain, a space-separated list of URIs, as specified in RFC + /// 3986, that define the protection space. + /// + /// If the domain parameter is absent, returns an empty string, which is semantically + /// identical according to the RFC. + #[inline] + pub fn domain(&self) -> &str { + &self.buf[self.domain_start as usize..self.opaque_start as usize] + } + + /// Returns the nonce, a server-specified string which should be uniquely + /// generated each time a 401 response is made. + #[inline] + pub fn nonce(&self) -> &str { + &self.buf[self.nonce_start as usize..] + } + + /// Returns string of data, specified by the server, that SHOULD be returned + /// by the client unchanged in the Authorization header field of subsequent + /// requests with URIs in the same protection space. + /// + /// Currently an empty `opaque` is treated as an absent one. + #[inline] + pub fn opaque(&self) -> Option<&str> { + if self.opaque_start == self.nonce_start { + None + } else { + Some(&self.buf[self.opaque_start as usize..self.nonce_start as usize]) + } + } + + /// Returns a flag indicating that the previous request from the client was + /// rejected because the nonce value was stale. + #[inline] + pub fn stale(&self) -> bool { + self.stale + } + + /// Returns true if using [RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) + /// compatibility mode as in [RFC 2617 section + /// 3.2.2.1](https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1). + /// + /// If so, `request-digest` is calculated without the nonce count, conce, or qop. + #[inline] + pub fn rfc2069_compat(&self) -> bool { + self.rfc2069_compat + } + + /// Returns the algorithm used to produce the digest and an unkeyed digest. + #[inline] + pub fn algorithm(&self) -> Algorithm { + self.algorithm + } + + /// Returns if the session style `A1` will be used. + #[inline] + pub fn session(&self) -> bool { + self.session + } + + /// Returns the acceptable `qop` (quality of protection) values. + #[inline] + pub fn qop(&self) -> QopSet { + self.qop + } + + /// Returns the number of times the server-supplied nonce has been used by + /// [`DigestClient::respond`]. + #[inline] + pub fn nonce_count(&self) -> u32 { + self.nc + } + + /// Responds to the challenge with the supplied parameters. + /// + /// The caller should use the returned string as an `Authorization` or + /// `Proxy-Authorization` header value. + #[inline] + pub fn respond(&mut self, p: &PasswordParams) -> Result<String, String> { + self.respond_inner(p, &new_random_cnonce()) + } + + /// Responds using a fixed cnonce **for testing only**. + /// + /// In production code, use [`DigestClient::respond`] instead, which generates a new + /// random cnonce value. + #[inline] + pub fn respond_with_testing_cnonce( + &mut self, + p: &PasswordParams, + cnonce: &str, + ) -> Result<String, String> { + self.respond_inner(p, cnonce) + } + + /// Helper for respond methods. + /// + /// We don't simply implement this as `respond_with_testing_cnonce` and have + /// `respond` delegate to that method because it'd be confusing/alarming if + /// that method name ever shows up in production stack traces. + /// and have `respond` delegate to the testing version. We don't do that because + fn respond_inner(&mut self, p: &PasswordParams, cnonce: &str) -> Result<String, String> { + let realm = self.realm(); + let mut h_a1 = self.algorithm.h(&[ + p.username.as_bytes(), + b":", + realm.as_bytes(), + b":", + p.password.as_bytes(), + ]); + if self.session { + h_a1 = self.algorithm.h(&[ + h_a1.as_bytes(), + b":", + self.nonce().as_bytes(), + b":", + cnonce.as_bytes(), + ]); + } + + // Select the best available qop and calculate H(A2) as in + // [https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.3]. + let (h_a2, qop); + if let (Some(body), true) = (p.body, self.qop & Qop::AuthInt) { + h_a2 = self + .algorithm + .h(&[p.method.as_bytes(), b":", p.uri.as_bytes(), b":", body]); + qop = Qop::AuthInt; + } else if self.qop & Qop::Auth { + h_a2 = self + .algorithm + .h(&[p.method.as_bytes(), b":", p.uri.as_bytes()]); + qop = Qop::Auth; + } else { + return Err("no supported/available qop".into()); + } + + let nc = self.nc.checked_add(1).ok_or("nonce count exhausted")?; + let mut hex_nc = [0u8; 8]; + let _ = write!(&mut hex_nc[..], "{:08x}", nc); + let str_hex_nc = match std::str::from_utf8(&hex_nc[..]) { + Ok(h) => h, + Err(_) => unreachable!(), + }; + + // https://datatracker.ietf.org/doc/html/rfc2617#section-3.2.2.1 + let response = if self.rfc2069_compat { + self.algorithm.h(&[ + h_a1.as_bytes(), + b":", + self.nonce().as_bytes(), + b":", + h_a2.as_bytes(), + ]) + } else { + self.algorithm.h(&[ + h_a1.as_bytes(), + b":", + self.nonce().as_bytes(), + b":", + &hex_nc[..], + b":", + cnonce.as_bytes(), + b":", + qop.as_str().as_bytes(), + b":", + h_a2.as_bytes(), + ]) + }; + + let mut out = String::with_capacity(128); + out.push_str("Digest "); + if self.userhash { + let hashed = self + .algorithm + .h(&[p.username.as_bytes(), b":", realm.as_bytes()]); + append_quoted_key_value(&mut out, "username", &hashed)?; + append_unquoted_key_value(&mut out, "userhash", "true"); + } else if is_valid_quoted_value(p.username) { + append_quoted_key_value(&mut out, "username", p.username)?; + } else { + append_extended_key_value(&mut out, "username", p.username); + } + append_quoted_key_value(&mut out, "realm", self.realm())?; + append_quoted_key_value(&mut out, "uri", p.uri)?; + append_quoted_key_value(&mut out, "nonce", self.nonce())?; + if !self.rfc2069_compat { + append_unquoted_key_value(&mut out, "algorithm", self.algorithm.as_str(self.session)); + append_unquoted_key_value(&mut out, "nc", str_hex_nc); + append_quoted_key_value(&mut out, "cnonce", cnonce)?; + append_unquoted_key_value(&mut out, "qop", qop.as_str()); + } + append_quoted_key_value(&mut out, "response", &response)?; + if let Some(o) = self.opaque() { + append_quoted_key_value(&mut out, "opaque", o)?; + } + out.truncate(out.len() - 2); // remove final ", " + self.nc = nc; + Ok(out) + } +} + +impl TryFrom<&ChallengeRef<'_>> for DigestClient { + type Error = String; + + fn try_from(value: &ChallengeRef<'_>) -> Result<Self, Self::Error> { + if !value.scheme.eq_ignore_ascii_case("Digest") { + return Err(format!( + "DigestClientContext doesn't support challenge scheme {:?}", + value.scheme + )); + } + let mut buf_len = 0; + let mut unused_len = 0; + let mut realm = None; + let mut domain = None; + let mut nonce = None; + let mut opaque = None; + let mut stale = false; + let mut algorithm_and_session = None; + let mut qop_str = None; + let mut userhash_str = None; + + // Parse response header field parameters as in + // [https://datatracker.ietf.org/doc/html/rfc7616#section-3.3]. + for (k, v) in &value.params { + // Note that "stale" and "algorithm" can be directly compared + // without unescaping because RFC 7616 section 3.3 says "For + // historical reasons, a sender MUST NOT generate the quoted string + // syntax values for the following parameters: stale and algorithm." + if store_param(k, v, "realm", &mut realm, &mut buf_len)? + || store_param(k, v, "domain", &mut domain, &mut buf_len)? + || store_param(k, v, "nonce", &mut nonce, &mut buf_len)? + || store_param(k, v, "opaque", &mut opaque, &mut buf_len)? + || store_param(k, v, "qop", &mut qop_str, &mut unused_len)? + || store_param(k, v, "userhash", &mut userhash_str, &mut unused_len)? + { + // Do nothing here. + } else if k.eq_ignore_ascii_case("stale") { + stale = v.escaped.eq_ignore_ascii_case("true"); + } else if k.eq_ignore_ascii_case("algorithm") { + algorithm_and_session = Some(Algorithm::parse(v.escaped)?); + } + } + let realm = realm.ok_or("missing required parameter realm")?; + let nonce = nonce.ok_or("missing required parameter nonce")?; + if buf_len > u16::MAX as usize { + // Incredibly unlikely, but just for completeness. + return Err(format!( + "Unescaped parameters' length {} exceeds u16::MAX!", + buf_len + )); + } + + let algorithm_and_session = algorithm_and_session.unwrap_or((Algorithm::Md5, false)); + + let mut buf = String::with_capacity(buf_len); + let mut qop = QopSet(0); + let rfc2069_compat = if let Some(qop_str) = qop_str { + let qop_str = qop_str.unescaped_with_scratch(&mut buf); + for v in qop_str.split(',') { + let v = v.trim(); + if v.eq_ignore_ascii_case("auth") { + qop.0 |= Qop::Auth as u8; + } else if v.eq_ignore_ascii_case("auth-int") { + qop.0 |= Qop::AuthInt as u8; + } + } + if qop.0 == 0 { + return Err(format!("no supported qop in {:?}", qop_str)); + } + buf.clear(); + false + } else { + // An absent qop is treated as "auth", according to + // https://datatracker.ietf.org/doc/html/rfc7616#section-3.4.3 + qop.0 |= Qop::Auth as u8; + true + }; + let userhash; + if let Some(userhash_str) = userhash_str { + let userhash_str = userhash_str.unescaped_with_scratch(&mut buf); + userhash = userhash_str.eq_ignore_ascii_case("true"); + buf.clear(); + } else { + userhash = false; + }; + realm.append_unescaped(&mut buf); + let domain_start = buf.len(); + if let Some(d) = domain { + d.append_unescaped(&mut buf); + } + let opaque_start = buf.len(); + if let Some(o) = opaque { + o.append_unescaped(&mut buf); + } + let nonce_start = buf.len(); + nonce.append_unescaped(&mut buf); + Ok(DigestClient { + buf: buf.into_boxed_str(), + domain_start: domain_start as u16, + opaque_start: opaque_start as u16, + nonce_start: nonce_start as u16, + algorithm: algorithm_and_session.0, + session: algorithm_and_session.1, + stale, + rfc2069_compat, + userhash, + qop, + nc: 0, + }) + } +} + +impl std::fmt::Debug for DigestClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DigestClient") + .field("realm", &self.realm()) + .field("domain", &self.domain()) + .field("opaque", &self.opaque()) + .field("nonce", &self.nonce()) + .field("algorithm", &self.algorithm.as_str(self.session)) + .field("stale", &self.stale) + .field("qop", &self.qop) + .field("rfc2069_compat", &self.rfc2069_compat) + .field("userhash", &self.userhash) + .field("nc", &self.nc) + .finish() + } +} + +/// Helper for `DigestClient::try_from` which stashes away a `&ParamValue`. +fn store_param<'v, 'tmp>( + k: &'tmp str, + v: &'v ParamValue<'v>, + expected_k: &'tmp str, + set_v: &'tmp mut Option<&'v ParamValue<'v>>, + add_len: &'tmp mut usize, +) -> Result<bool, String> { + if !k.eq_ignore_ascii_case(expected_k) { + return Ok(false); + } + if set_v.is_some() { + return Err(format!("duplicate parameter {:?}", k)); + } + *add_len += v.unescaped_len(); + *set_v = Some(v); + Ok(true) +} + +fn is_valid_quoted_value(s: &str) -> bool { + for &b in s.as_bytes() { + if char_classes(b) & (C_QDTEXT | C_ESCAPABLE) == 0 { + return false; + } + } + true +} + +fn append_extended_key_value(out: &mut String, key: &str, value: &str) { + out.push_str(key); + out.push_str("*=UTF-8''"); + for &b in value.as_bytes() { + if (char_classes(b) & C_ATTR) != 0 { + out.push(char::from(b)); + } else { + let _ = write!(out, "%{:02X}", b); + } + } + out.push_str(", "); +} + +fn append_unquoted_key_value(out: &mut String, key: &str, value: &str) { + out.push_str(key); + out.push('='); + out.push_str(value); + out.push_str(", "); +} + +fn append_quoted_key_value(out: &mut String, key: &str, value: &str) -> Result<(), String> { + out.push_str(key); + out.push_str("=\""); + let mut first_unwritten = 0; + let bytes = value.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + // Note that bytes >= 128 are in neither C_QDTEXT nor C_ESCAPABLE, so every allowed byte + // is a full character. + let class = char_classes(b); + if (class & C_QDTEXT) != 0 { + // Just advance. + } else if (class & C_ESCAPABLE) != 0 { + out.push_str(&value[first_unwritten..i]); + out.push('\\'); + out.push(char::from(b)); + first_unwritten = i + 1; + } else { + return Err(format!("invalid {} value {:?}", key, value)); + } + } + out.push_str(&value[first_unwritten..]); + out.push_str("\", "); + Ok(()) +} + +/// Supported algorithm from the [HTTP Digest Algorithm Values +/// registry](https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml). +/// +/// This doesn't store whether the session variant (`<Algorithm>-sess`) was +/// requested; see [`DigestClient::session`] for that. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Algorithm { + Md5, + Sha256, + Sha512Trunc256, +} + +impl Algorithm { + /// Parses a string into a tuple of `Algorithm` and a bool representing + /// whether the `-sess` suffix is present. + fn parse(s: &str) -> Result<(Self, bool), String> { + Ok(match s { + "MD5" => (Algorithm::Md5, false), + "MD5-sess" => (Algorithm::Md5, true), + "SHA-256" => (Algorithm::Sha256, false), + "SHA-256-sess" => (Algorithm::Sha256, true), + "SHA-512-256" => (Algorithm::Sha512Trunc256, false), + "SHA-512-256-sess" => (Algorithm::Sha512Trunc256, true), + _ => return Err(format!("unknown algorithm {:?}", s)), + }) + } + + fn as_str(&self, session: bool) -> &'static str { + match (self, session) { + (Algorithm::Md5, false) => "MD5", + (Algorithm::Md5, true) => "MD5-sess", + (Algorithm::Sha256, false) => "SHA-256", + (Algorithm::Sha256, true) => "SHA-256-sess", + (Algorithm::Sha512Trunc256, false) => "SHA-512-256", + (Algorithm::Sha512Trunc256, true) => "SHA-512-256-sess", + } + } + + fn h(&self, items: &[&[u8]]) -> String { + match self { + Algorithm::Md5 => h(md5::Md5::new(), items), + Algorithm::Sha256 => h(sha2::Sha256::new(), items), + Algorithm::Sha512Trunc256 => h(sha2::Sha512_256::new(), items), + } + } +} + +fn h<D: Digest>(mut d: D, items: &[&[u8]]) -> String { + for i in items { + d.update(i); + } + hex::encode(d.finalize()) +} + +fn new_random_cnonce() -> String { + let raw: [u8; 16] = rand::random(); + hex::encode(&raw[..]) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// Tests the example from [RFC 7616 section 3.9.1: SHA-256 and + /// MD5](https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1). + #[test] + fn sha256_and_md5() { + let www_authenticate = "\ + Digest \ + realm=\"http-auth@example.org\", \ + qop=\"auth, auth-int\", \ + algorithm=SHA-256, \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", \ + Digest \ + realm=\"http-auth@example.org\", \ + qop=\"auth, auth-int\", \ + algorithm=MD5, \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\""; + let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap()); + assert_eq!(challenges.len(), 2); + let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect(); + let mut ctxs = dbg!(ctxs.unwrap()); + assert_eq!(ctxs[1].realm(), "http-auth@example.org"); + assert_eq!(ctxs[1].domain(), ""); + assert_eq!( + ctxs[1].nonce(), + "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" + ); + assert_eq!( + ctxs[1].opaque(), + Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS") + ); + assert_eq!(ctxs[1].stale(), false); + assert_eq!(ctxs[1].algorithm(), Algorithm::Md5); + assert_eq!(ctxs[1].qop().0, (Qop::Auth as u8) | (Qop::AuthInt as u8)); + assert_eq!(ctxs[1].nonce_count(), 0); + let params = crate::PasswordParams { + username: "Mufasa", + password: "Circle of Life", + uri: "/dir/index.html", + body: None, + method: "GET", + }; + assert_eq!( + &mut ctxs[0] + .respond_with_testing_cnonce( + ¶ms, + "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + ) + .unwrap(), + "Digest username=\"Mufasa\", \ + realm=\"http-auth@example.org\", \ + uri=\"/dir/index.html\", \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + algorithm=SHA-256, \ + nc=00000001, \ + cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \ + qop=auth, \ + response=\"753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"" + ); + assert_eq!(ctxs[0].nc, 1); + assert_eq!( + &mut ctxs[1] + .respond_with_testing_cnonce( + ¶ms, + "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + ) + .unwrap(), + "Digest username=\"Mufasa\", \ + realm=\"http-auth@example.org\", \ + uri=\"/dir/index.html\", \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + algorithm=MD5, \ + nc=00000001, \ + cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \ + qop=auth, \ + response=\"8ca523f5e9506fed4657c9700eebdbec\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"" + ); + assert_eq!(ctxs[1].nc, 1); + } + + /// Tests a made-up example with `MD5-sess`. There's no example in the RFC, + /// and these values haven't been tested against any other implementation. + /// But having the test here ensures we don't accidentally change the + /// algorithm. + #[test] + fn md5_sess() { + let www_authenticate = "\ + Digest \ + realm=\"http-auth@example.org\", \ + qop=\"auth, auth-int\", \ + algorithm=MD5-sess, \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\""; + let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap()); + assert_eq!(challenges.len(), 1); + let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect(); + let mut ctxs = dbg!(ctxs.unwrap()); + assert_eq!(ctxs[0].realm(), "http-auth@example.org"); + assert_eq!(ctxs[0].domain(), ""); + assert_eq!( + ctxs[0].nonce(), + "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" + ); + assert_eq!( + ctxs[0].opaque(), + Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS") + ); + assert_eq!(ctxs[0].stale(), false); + assert_eq!(ctxs[0].algorithm(), Algorithm::Md5); + assert_eq!(ctxs[0].session(), true); + assert_eq!(ctxs[0].qop().0, (Qop::Auth as u8) | (Qop::AuthInt as u8)); + assert_eq!(ctxs[0].nonce_count(), 0); + let params = crate::PasswordParams { + username: "Mufasa", + password: "Circle of Life", + uri: "/dir/index.html", + body: None, + method: "GET", + }; + assert_eq!( + &mut ctxs[0] + .respond_with_testing_cnonce( + ¶ms, + "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + ) + .unwrap(), + "Digest username=\"Mufasa\", \ + realm=\"http-auth@example.org\", \ + uri=\"/dir/index.html\", \ + nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", \ + algorithm=MD5-sess, \ + nc=00000001, \ + cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", \ + qop=auth, \ + response=\"e783283f46242139c486a698fec7211d\", \ + opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"" + ); + assert_eq!(ctxs[0].nc, 1); + } + + /// Tests the example from [RFC 7616 section 3.9.2: SHA-512-256, Charset, and + /// Userhash](https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.2). + #[test] + fn sha512_256_charset() { + let www_authenticate = "\ + Digest \ + realm=\"api@example.org\", \ + qop=\"auth\", \ + algorithm=SHA-512-256, \ + nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \ + opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\", \ + charset=UTF-8, \ + userhash=true"; + let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap()); + assert_eq!(challenges.len(), 1); + let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect(); + let mut ctxs = dbg!(ctxs.unwrap()); + assert_eq!(ctxs.len(), 1); + assert_eq!(ctxs[0].realm(), "api@example.org"); + assert_eq!(ctxs[0].domain(), ""); + assert_eq!( + ctxs[0].nonce(), + "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK" + ); + assert_eq!( + ctxs[0].opaque(), + Some("HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS") + ); + assert_eq!(ctxs[0].stale, false); + assert_eq!(ctxs[0].userhash, true); + assert_eq!(ctxs[0].algorithm, Algorithm::Sha512Trunc256); + assert_eq!(ctxs[0].qop.0, Qop::Auth as u8); + assert_eq!(ctxs[0].nc, 0); + let params = crate::PasswordParams { + username: "J\u{E4}s\u{F8}n Doe", + password: "Secret, or not?", + uri: "/doe.json", + body: None, + method: "GET", + }; + + // Note the username and response values in the RFC are *wrong*! + // https://www.rfc-editor.org/errata/eid4897 + assert_eq!( + &mut ctxs[0] + .respond_with_testing_cnonce( + ¶ms, + "NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v" + ) + .unwrap(), + "\ + Digest \ + username=\"793263caabb707a56211940d90411ea4a575adeccb7e360aeb624ed06ece9b0b\", \ + userhash=true, \ + realm=\"api@example.org\", \ + uri=\"/doe.json\", \ + nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \ + algorithm=SHA-512-256, \ + nc=00000001, \ + cnonce=\"NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v\", \ + qop=auth, \ + response=\"3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5\", \ + opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\"" + ); + assert_eq!(ctxs[0].nc, 1); + ctxs[0].userhash = false; + ctxs[0].nc = 0; + assert_eq!( + &mut ctxs[0] + .respond_with_testing_cnonce( + ¶ms, + "NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v" + ) + .unwrap(), + "\ + Digest \ + username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, \ + realm=\"api@example.org\", \ + uri=\"/doe.json\", \ + nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", \ + algorithm=SHA-512-256, \ + nc=00000001, \ + cnonce=\"NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v\", \ + qop=auth, \ + response=\"3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5\", \ + opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\"" + ); + assert_eq!(ctxs[0].nc, 1); + } + + #[test] + fn rfc2069() { + // https://datatracker.ietf.org/doc/html/rfc2069#section-2.4 + // The response there is wrong! See https://www.rfc-editor.org/errata/eid749 + let www_authenticate = "\ + Digest \ + realm=\"testrealm@host.com\", \ + nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", \ + opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""; + let challenges = dbg!(crate::parse_challenges(www_authenticate).unwrap()); + assert_eq!(challenges.len(), 1); + let ctxs: Result<Vec<_>, _> = challenges.iter().map(DigestClient::try_from).collect(); + let mut ctxs = dbg!(ctxs.unwrap()); + assert_eq!(ctxs.len(), 1); + assert_eq!(ctxs[0].qop.0, Qop::Auth as u8); + assert_eq!(ctxs[0].rfc2069_compat, true); + let params = crate::PasswordParams { + username: "Mufasa", + password: "CircleOfLife", + uri: "/dir/index.html", + body: None, + method: "GET", + }; + assert_eq!( + &mut ctxs[0] + .respond_with_testing_cnonce(¶ms, "unused") + .unwrap(), + "\ + Digest \ + username=\"Mufasa\", \ + realm=\"testrealm@host.com\", \ + uri=\"/dir/index.html\", \ + nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", \ + response=\"1949323746fe6a43ef61f9606e7febea\", \ + opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"", + ); + assert_eq!(ctxs[0].nc, 1); + } + + // See sizes with: cargo test -- --nocapture digest::tests::size + #[test] + fn size() { + // This type should have a niche. + assert_eq!( + dbg!(std::mem::size_of::<DigestClient>()), + dbg!(std::mem::size_of::<Option<DigestClient>>()), + ) + } +} |