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