1use std::collections::HashMap;
104use std::sync::Mutex;
105use std::time::{Duration, SystemTime, UNIX_EPOCH};
106
107use headers::authorization::{Basic, Bearer, Credentials as HeaderCredentials};
108use headers::{Authorization, Header, HeaderValue};
109use hmac::digest::Key;
110use hmac::{Hmac, KeyInit, Mac};
111use http_auth::{ChallengeParser, ChallengeRef};
112use lru::LruCache;
113use md5::{Digest as _, Md5};
114use percent_encoding::percent_decode_str;
115use sha2::Sha256;
116use url::{Position, Url};
117
118use crate::credentials::Credentials;
119use crate::request::RequestContext;
120use crate::signed_url::{self, SignedUrlKeySet};
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum AuthResult {
126 Ok,
129 Unauthorized,
132}
133
134enum VerifierScheme {
138 Basic {
139 username: String,
140 password: String,
141 realm: String,
142 },
143 Digest {
144 username: String,
145 password: String,
146 realm: String,
147 nonces: DigestNonces,
148 },
149 Bearer {
150 token: String,
151 },
152 Forwarded {
156 user_header: String,
157 forwarded_for_header: Option<String>,
158 },
159 SignedUrl {
163 keys: SignedUrlKeySet,
164 },
165}
166
167pub struct Verifier {
170 scheme: VerifierScheme,
171 clock: Clock,
172}
173
174type Clock = Box<dyn Fn() -> SystemTime + Send + Sync>;
176
177pub const DIGEST_NONCE_LIFETIME: Duration = Duration::from_secs(3600);
182
183pub const DIGEST_NC_TRACK_CAP: usize = 65_536;
188
189pub const NC_WINDOW: u32 = u64::BITS;
192
193const NONCE_SECRET_LEN: usize = 32;
195const _: () = assert!(NONCE_SECRET_LEN <= 64);
197const NONCE_TIME_LEN: usize = 8;
199const NONCE_SEQ_LEN: usize = 8;
202const NONCE_MAC_LEN: usize = 32;
204const NONCE_LEN: usize = NONCE_TIME_LEN + NONCE_SEQ_LEN + NONCE_MAC_LEN;
206const NC_HEX_LEN: usize = 8;
208
209struct DigestNonces {
211 secret: [u8; NONCE_SECRET_LEN],
212 seen: Mutex<NcTable>,
213}
214
215type PairKey = [u8; 32];
218
219#[derive(Clone, Copy)]
221struct NonceStamp {
222 time: u64,
223 seq: u64,
224}
225
226struct NcEntry {
227 stamp: NonceStamp,
228 highest: u32,
230 window: u64,
232 }
234
235impl NcEntry {
236 fn accept(&mut self, nc: u32) -> bool {
238 if nc > self.highest {
239 let shift = nc - self.highest;
240 let old_highest = 1u64.checked_shl(shift - 1).unwrap_or(0);
241 self.window = self.window.checked_shl(shift).unwrap_or(0) | old_highest;
242 self.highest = nc;
243 return true;
244 }
245 let below = self.highest - nc;
246 if below == 0 || below > NC_WINDOW {
247 return false;
248 }
249 let bit = 1u64 << (below - 1);
250 if self.window & bit != 0 {
251 return false;
252 }
253 self.window |= bit;
254 true
255 }
256}
257
258struct NcTable {
259 entries: LruCache<PairKey, NcEntry>,
263 capacity: usize,
264 floor_seq: u64,
267 next_seq: u64,
268 latest_issue_time: u64,
269}
270
271enum DigestCheck {
274 Accept {
276 key: PairKey,
277 stamp: NonceStamp,
278 nc: u32,
279 },
280 Stale,
283 Reject,
285}
286
287fn pair_key(nonce: &str, cnonce: &str) -> PairKey {
288 let mut hash = Sha256::new();
289 hash.update(nonce.as_bytes());
290 hash.update(cnonce.as_bytes());
291 hash.finalize().into()
292}
293
294impl DigestNonces {
295 fn new() -> Self {
296 DigestNonces {
297 secret: rand::random(),
298 seen: Mutex::new(NcTable {
299 entries: LruCache::unbounded(),
300 capacity: DIGEST_NC_TRACK_CAP,
301 floor_seq: 0,
302 next_seq: 0,
303 latest_issue_time: 0,
304 }),
305 }
306 }
307
308 fn table(&self) -> std::sync::MutexGuard<'_, NcTable> {
309 self.seen.lock().unwrap_or_else(|e| e.into_inner())
310 }
311
312 fn set_capacity(&self, capacity: usize) {
313 self.table().capacity = capacity.max(1);
314 }
315
316 fn now(&self, clock_secs: u64) -> u64 {
318 clock_secs.max(self.table().latest_issue_time)
319 }
320
321 fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
322 let mut block = Key::<Hmac<Sha256>>::default();
327 block[..NONCE_SECRET_LEN].copy_from_slice(&self.secret);
328 let mut mac = <Hmac<Sha256> as KeyInit>::new(&block);
329 mac.update(signed);
330 mac
331 }
332
333 fn issue(&self, clock_secs: u64) -> String {
335 let (time, seq) = {
336 let mut table = self.table();
337 let time = clock_secs.max(table.latest_issue_time);
338 table.latest_issue_time = time;
339 let seq = table.next_seq;
340 table.next_seq = seq.wrapping_add(1);
341 (time, seq)
342 };
343 let mut raw = [0u8; NONCE_LEN];
344 raw[..NONCE_TIME_LEN].copy_from_slice(&time.to_be_bytes());
345 raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN].copy_from_slice(&seq.to_be_bytes());
346 let tag = self
347 .mac(&raw[..NONCE_TIME_LEN + NONCE_SEQ_LEN])
348 .finalize()
349 .into_bytes();
350 raw[NONCE_TIME_LEN + NONCE_SEQ_LEN..].copy_from_slice(&tag);
351 hex::encode(raw)
352 }
353
354 fn issued_at(&self, nonce: &str) -> Option<NonceStamp> {
356 let raw = unhex::<NONCE_LEN>(nonce)?;
357 let (signed, tag) = raw.split_at(NONCE_TIME_LEN + NONCE_SEQ_LEN);
358 self.mac(signed).verify_slice(tag).ok()?;
359 let mut time = [0u8; NONCE_TIME_LEN];
360 time.copy_from_slice(&raw[..NONCE_TIME_LEN]);
361 let mut seq = [0u8; NONCE_SEQ_LEN];
362 seq.copy_from_slice(&raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN]);
363 Some(NonceStamp {
364 time: u64::from_be_bytes(time),
365 seq: u64::from_be_bytes(seq),
366 })
367 }
368
369 fn is_expired(issued: u64, now_secs: u64) -> bool {
370 now_secs.saturating_sub(issued) >= DIGEST_NONCE_LIFETIME.as_secs()
371 }
372
373 fn record(&self, key: PairKey, stamp: NonceStamp, nc: u32, now_secs: u64) -> bool {
376 let mut guard = self.table();
377 let table = &mut *guard;
378 if let Some(entry) = table.entries.peek_mut(&key) {
381 if !entry.accept(nc) {
382 return false;
383 }
384 table.entries.promote(&key);
385 return true;
386 }
387 while table.entries.len() >= table.capacity {
388 let Some((_, dropped)) = table.entries.pop_lru() else {
389 break;
390 };
391 if !Self::is_expired(dropped.stamp.time, now_secs) {
392 table.floor_seq = table.floor_seq.max(dropped.stamp.seq.saturating_add(1));
393 }
394 }
395 if stamp.seq < table.floor_seq {
396 return false;
397 }
398 table.entries.put(
399 key,
400 NcEntry {
401 stamp,
402 highest: nc,
403 window: 0,
404 },
405 );
406 true
407 }
408
409 fn below_floor(&self, key: &PairKey, stamp: NonceStamp) -> bool {
412 let table = self.table();
413 stamp.seq < table.floor_seq && !table.entries.contains(key)
414 }
415}
416
417fn unix_secs(t: SystemTime) -> u64 {
418 t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
419}
420
421fn default_clock() -> Clock {
422 Box::new(SystemTime::now)
423}
424
425impl Verifier {
426 pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
435 let realm = sanitize_realm(&realm.into());
438 let scheme = match credentials {
439 Credentials::Basic { username, password } => VerifierScheme::Basic {
440 username,
441 password,
442 realm,
443 },
444 Credentials::Digest { username, password } => VerifierScheme::Digest {
445 username,
446 password,
447 realm,
448 nonces: DigestNonces::new(),
449 },
450 Credentials::Bearer { token } => VerifierScheme::Bearer { token },
451 };
452 Verifier {
453 scheme,
454 clock: default_clock(),
455 }
456 }
457
458 pub fn with_clock(mut self, clock: impl Fn() -> SystemTime + Send + Sync + 'static) -> Self {
461 self.clock = Box::new(clock);
462 self
463 }
464
465 fn now_secs(&self) -> u64 {
466 unix_secs((self.clock)())
467 }
468
469 pub fn with_digest_nc_capacity(self, capacity: usize) -> Self {
473 if let VerifierScheme::Digest { nonces, .. } = &self.scheme {
474 nonces.set_capacity(capacity);
475 }
476 self
477 }
478
479 pub fn forwarded(user_header: impl Into<String>, forwarded_for_header: Option<String>) -> Self {
489 Verifier {
490 scheme: VerifierScheme::Forwarded {
491 user_header: user_header.into(),
492 forwarded_for_header,
493 },
494 clock: default_clock(),
495 }
496 }
497
498 pub fn signed_url(keys: SignedUrlKeySet) -> Self {
502 Verifier {
503 scheme: VerifierScheme::SignedUrl { keys },
504 clock: default_clock(),
505 }
506 }
507
508 pub fn challenge(&self) -> String {
519 self.render_challenge(false)
520 }
521
522 pub fn challenge_for(&self, ctx: &RequestContext<'_>) -> String {
529 let stale = match &self.scheme {
530 VerifierScheme::Digest {
531 username,
532 password,
533 realm,
534 nonces,
535 } => ctx.header("authorization").is_some_and(|header| {
536 matches!(
537 check_digest(
538 header,
539 username,
540 password,
541 realm,
542 nonces,
543 ctx.method,
544 ctx.uri,
545 nonces.now(self.now_secs()),
546 ),
547 DigestCheck::Stale
548 )
549 }),
550 _ => false,
551 };
552 self.render_challenge(stale)
553 }
554
555 fn render_challenge(&self, stale: bool) -> String {
556 match &self.scheme {
557 VerifierScheme::Basic { realm, .. } => format!("Basic realm={}", quoted(realm)),
558 VerifierScheme::Digest { realm, nonces, .. } => {
559 let nonce = nonces.issue(self.now_secs());
560 let stale = if stale { ", stale=true" } else { "" };
561 format!(
562 "Digest realm={}, nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5{stale}",
563 quoted(realm)
564 )
565 }
566 VerifierScheme::Bearer { .. } => "Bearer".to_string(),
567 VerifierScheme::Forwarded { .. } => "Forwarded".to_string(),
568 VerifierScheme::SignedUrl { .. } => "SignedUrl".to_string(),
569 }
570 }
571
572 pub fn verify(&self, ctx: &RequestContext<'_>) -> AuthResult {
592 let ok = match &self.scheme {
593 VerifierScheme::Basic {
594 username, password, ..
595 } => ctx
596 .header("authorization")
597 .is_some_and(|header| verify_basic(header, username, password)),
598 VerifierScheme::Bearer { token } => ctx
599 .header("authorization")
600 .is_some_and(|header| verify_bearer(header, token)),
601 VerifierScheme::Digest {
602 username,
603 password,
604 realm,
605 nonces,
606 } => ctx.header("authorization").is_some_and(|header| {
607 let now = nonces.now(self.now_secs());
608 match check_digest(
609 header, username, password, realm, nonces, ctx.method, ctx.uri, now,
610 ) {
611 DigestCheck::Accept { key, stamp, nc } => nonces.record(key, stamp, nc, now),
612 DigestCheck::Stale | DigestCheck::Reject => false,
613 }
614 }),
615 VerifierScheme::Forwarded { user_header, .. } => verify_forwarded(ctx, user_header),
616 VerifierScheme::SignedUrl { keys } => signed_url::verify(ctx, keys),
617 };
618 if ok {
619 AuthResult::Ok
620 } else {
621 AuthResult::Unauthorized
622 }
623 }
624
625 pub fn forwarded_for<'a>(&self, ctx: &RequestContext<'a>) -> Option<&'a str> {
632 match &self.scheme {
633 VerifierScheme::Forwarded {
634 forwarded_for_header: Some(header_name),
635 ..
636 } => ctx.header(header_name),
637 _ => None,
638 }
639 }
640}
641
642impl core::fmt::Debug for Verifier {
645 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
646 let scheme = match &self.scheme {
647 VerifierScheme::Basic { .. } => "Basic",
648 VerifierScheme::Digest { .. } => "Digest",
649 VerifierScheme::Bearer { .. } => "Bearer",
650 VerifierScheme::Forwarded { .. } => "Forwarded",
651 VerifierScheme::SignedUrl { .. } => "SignedUrl",
652 };
653 f.debug_struct("Verifier")
654 .field("scheme", &scheme)
655 .finish_non_exhaustive()
656 }
657}
658
659fn sanitize_realm(realm: &str) -> String {
664 realm
665 .chars()
666 .filter(|c| !c.is_ascii_control() || *c == '\t')
667 .collect()
668}
669
670fn quoted(value: &str) -> String {
677 let mut out = String::with_capacity(value.len() + 2);
678 out.push('"');
679 for c in value
680 .chars()
681 .filter(|c| !c.is_ascii_control() || *c == '\t')
682 {
683 if matches!(c, '"' | '\\') {
684 out.push('\\');
685 }
686 out.push(c);
687 }
688 out.push('"');
689 out
690}
691
692fn is_ext_value_syntax(value: &str) -> bool {
694 let b = value.as_bytes();
695 let mut i = 0;
696 while i < b.len() {
697 match b[i] {
698 b'%' => {
699 if !(i + 2 < b.len()
700 && b[i + 1].is_ascii_hexdigit()
701 && b[i + 2].is_ascii_hexdigit())
702 {
703 return false;
704 }
705 i += 3;
706 }
707 c if c.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&c) => i += 1,
708 _ => return false,
709 }
710 }
711 true
712}
713
714fn decode_ext_value(v: &str) -> Option<String> {
719 let (charset, rest) = v.split_once('\'')?;
720 let (_language, value) = rest.split_once('\'')?;
721 if !charset.eq_ignore_ascii_case("UTF-8") || !is_ext_value_syntax(value) {
722 return None;
723 }
724 percent_decode_str(value)
725 .decode_utf8()
726 .ok()
727 .map(std::borrow::Cow::into_owned)
728}
729
730fn parse_digest_fields(header: &str) -> Option<HashMap<String, String>> {
738 let mut parser = ChallengeParser::new(header);
739 let challenge: ChallengeRef<'_> = parser.next()?.ok()?;
740 if parser.next().is_some()
741 || !challenge.scheme.eq_ignore_ascii_case("Digest")
742 || challenge.params.len() > MAX_DIGEST_FIELDS
743 {
744 return None;
745 }
746 let mut fields = HashMap::with_capacity(challenge.params.len());
747 for (name, value) in &challenge.params {
748 if fields
749 .insert(name.to_ascii_lowercase(), value.to_unescaped())
750 .is_some()
751 {
752 return None;
753 }
754 }
755 Some(fields)
756}
757
758fn decode<C: HeaderCredentials>(header: &str) -> Option<Authorization<C>> {
762 let value = HeaderValue::from_str(header).ok()?;
763 Authorization::<C>::decode(&mut std::iter::once(&value)).ok()
764}
765
766fn verify_basic(header: &str, username: &str, password: &str) -> bool {
771 let Some(auth) = decode::<Basic>(header) else {
772 return false;
773 };
774 let user_ok = constant_time_eq(auth.username().as_bytes(), username.as_bytes());
776 let pass_ok = constant_time_eq(auth.password().as_bytes(), password.as_bytes());
777 user_ok & pass_ok
778}
779
780fn verify_bearer(header: &str, token: &str) -> bool {
782 let Some(auth) = decode::<Bearer>(header) else {
783 return false;
784 };
785 constant_time_eq(auth.token().trim().as_bytes(), token.trim().as_bytes())
786}
787
788const MAX_DIGEST_FIELDS: usize = 64;
796
797#[allow(clippy::too_many_arguments)]
816fn check_digest(
817 header: &str,
818 username: &str,
819 password: &str,
820 realm: &str,
821 nonces: &DigestNonces,
822 method: &str,
823 request_uri: &str,
824 now_secs: u64,
825) -> DigestCheck {
826 let Some(fields) = parse_digest_fields(header) else {
827 return DigestCheck::Reject;
828 };
829 let get = |k: &str| fields.get(k).map(String::as_str).unwrap_or_default();
830
831 if get("userhash").eq_ignore_ascii_case("true") {
832 return DigestCheck::Reject;
833 }
834 let client_user = match (fields.get("username"), fields.get("username*")) {
835 (Some(_), Some(_)) => return DigestCheck::Reject, (Some(u), None) => u.clone(),
837 (None, Some(ext)) => match decode_ext_value(ext) {
838 Some(u) => u,
839 None => return DigestCheck::Reject,
840 },
841 (None, None) => return DigestCheck::Reject,
842 };
843 if client_user != username || get("realm") != realm {
844 return DigestCheck::Reject;
845 }
846 let nonce = get("nonce");
847 let Some(stamp) = nonces.issued_at(nonce) else {
848 return DigestCheck::Reject;
849 };
850 let client_uri = get("uri");
851 if !digest_uri_matches(client_uri, request_uri) {
852 return DigestCheck::Reject;
853 }
854 let nc_text = get("nc");
855 let cnonce = get("cnonce");
856 let qop = get("qop");
857 let client_response = get("response");
858 if cnonce.is_empty() || client_response.is_empty() {
859 return DigestCheck::Reject;
860 }
861 if qop != "auth" {
869 return DigestCheck::Reject;
870 }
871 let algorithm = get("algorithm");
872 if !algorithm.is_empty() && !algorithm.eq_ignore_ascii_case("MD5") {
873 return DigestCheck::Reject;
874 }
875 let Some(nc) = parse_nc(nc_text) else {
876 return DigestCheck::Reject;
877 };
878
879 let ha1 = md5_hex(format!("{username}:{realm}:{password}"));
880 let ha2 = md5_hex(format!("{method}:{client_uri}"));
881 let expected_response = md5_hex(format!("{ha1}:{nonce}:{nc_text}:{cnonce}:{qop}:{ha2}"));
882 if !constant_time_eq(expected_response.as_bytes(), client_response.as_bytes()) {
883 return DigestCheck::Reject;
884 }
885 let key = pair_key(nonce, cnonce);
886 if DigestNonces::is_expired(stamp.time, now_secs) || nonces.below_floor(&key, stamp) {
887 return DigestCheck::Stale;
888 }
889 DigestCheck::Accept { key, stamp, nc }
890}
891
892fn parse_nc(nc: &str) -> Option<u32> {
894 if nc.len() != NC_HEX_LEN || !nc.bytes().all(|b| b.is_ascii_hexdigit()) {
895 return None;
896 }
897 u32::from_str_radix(nc, 16).ok()
898}
899
900fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
923 if client_uri == request_uri {
924 return true;
925 }
926 let Ok(parsed) = Url::parse(client_uri) else {
927 return false;
928 };
929 parsed.as_str() == client_uri && &parsed[Position::BeforePath..] == request_uri
930}
931
932fn verify_forwarded(ctx: &RequestContext<'_>, user_header: &str) -> bool {
937 ctx.header(user_header)
938 .is_some_and(|v| !v.trim().is_empty())
939}
940
941fn md5_hex(input: String) -> String {
943 let mut hasher = Md5::new();
944 hasher.update(input.as_bytes());
945 hex::encode(hasher.finalize())
946}
947
948fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
953 if a.len() != b.len() {
954 return false;
955 }
956 a.iter()
957 .zip(b.iter())
958 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
959 == 0
960}
961
962fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
964 let mut out = [0u8; N];
965 hex::decode_to_slice(text, &mut out).ok()?; Some(out)
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use crate::{Credentials, RequestContext, respond};
973
974 const REALM: &str = "cameras";
975
976 #[test]
979 fn recently_used_pair_survives_eviction() {
980 let nonces = DigestNonces::new();
981 nonces.set_capacity(2);
982 let stamp = |seq| NonceStamp { time: 100, seq };
983 let (a, b, c) = (
984 pair_key("n1", "c"),
985 pair_key("n2", "c"),
986 pair_key("n3", "c"),
987 );
988 assert!(nonces.record(a, stamp(1), 1, 100));
989 assert!(nonces.record(b, stamp(2), 1, 100));
990 assert!(nonces.record(a, stamp(1), 2, 100));
992 assert!(nonces.record(c, stamp(3), 1, 100));
994 assert!(
995 !nonces.record(a, stamp(1), 2, 100),
996 "a still tracked: nc 2 is a replay"
997 );
998 assert_eq!(nonces.table().entries.len(), 2);
999 assert!(!nonces.record(b, stamp(2), 1, 100));
1002 }
1003
1004 #[test]
1005 fn nonce_is_96_lowercase_hex_and_round_trips() {
1006 let nonces = DigestNonces::new();
1007 let nonce = nonces.issue(1_000);
1008 assert_eq!(nonce.len(), NONCE_LEN * 2);
1009 assert!(
1010 nonce
1011 .bytes()
1012 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1013 );
1014 let stamp = nonces.issued_at(&nonce).expect("our own nonce verifies");
1015 assert_eq!(stamp.time, 1_000);
1016 assert!(
1017 nonces.issued_at(&nonce.to_uppercase()).is_some(),
1018 "hex is case-insensitive on read"
1019 );
1020 assert!(nonces.issued_at(&nonce[..nonce.len() - 2]).is_none());
1021 assert!(
1022 nonces
1023 .issued_at(&format!("{}zz", &nonce[..nonce.len() - 2]))
1024 .is_none()
1025 );
1026 }
1027
1028 #[test]
1033 fn nonce_mac_matches_variable_length_keying() {
1034 let nonces = DigestNonces::new();
1035 let signed = b"issue-time||issue-seq";
1036 let reference = {
1037 let mut m = <Hmac<Sha256> as KeyInit>::new_from_slice(&nonces.secret).unwrap();
1038 m.update(signed);
1039 m.finalize().into_bytes()
1040 };
1041 assert_eq!(nonces.mac(signed).finalize().into_bytes(), reference);
1042 }
1043
1044 fn verify_auth(
1049 v: &Verifier,
1050 authorization: Option<&str>,
1051 method: &str,
1052 uri: &str,
1053 ) -> AuthResult {
1054 let auth_header = authorization.map(|h| [("authorization", h)]);
1055 let headers: &[(&str, &str)] = match &auth_header {
1056 Some(arr) => arr,
1057 None => &[],
1058 };
1059 let ctx = RequestContext::new(method, uri).with_headers(headers);
1060 v.verify(&ctx)
1061 }
1062
1063 #[test]
1066 fn basic_challenge_names_the_realm() {
1067 let v = Verifier::new(
1068 Credentials::Basic {
1069 username: "admin".into(),
1070 password: "12345".into(),
1071 },
1072 REALM,
1073 );
1074 assert_eq!(v.challenge(), "Basic realm=\"cameras\"");
1075 }
1076
1077 #[test]
1078 fn digest_challenge_carries_realm_nonce_qop_algorithm() {
1079 let v = Verifier::new(
1080 Credentials::Digest {
1081 username: "admin".into(),
1082 password: "12345".into(),
1083 },
1084 REALM,
1085 );
1086 let challenge = v.challenge();
1087 assert!(challenge.starts_with("Digest "), "got: {challenge}");
1088 for needle in [
1089 "realm=\"cameras\"",
1090 "nonce=",
1091 "qop=\"auth\"",
1092 "algorithm=MD5",
1093 ] {
1094 assert!(
1095 challenge.contains(needle),
1096 "missing {needle} in {challenge}"
1097 );
1098 }
1099 }
1100
1101 #[test]
1102 fn bearer_challenge_is_bare_scheme_name() {
1103 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1104 assert_eq!(v.challenge(), "Bearer");
1105 }
1106
1107 #[test]
1108 fn digest_challenges_issue_distinct_nonces() {
1109 let v = Verifier::new(
1110 Credentials::Digest {
1111 username: "admin".into(),
1112 password: "12345".into(),
1113 },
1114 REALM,
1115 );
1116 assert_ne!(v.challenge(), v.challenge());
1117 }
1118
1119 #[test]
1122 fn basic_respond_to_challenge_verifies_ok() {
1123 let v = Verifier::new(
1124 Credentials::Basic {
1125 username: "admin".into(),
1126 password: "12345".into(),
1127 },
1128 REALM,
1129 );
1130 let header = respond(
1131 &v.challenge(),
1132 &RequestContext::new("GET", "/stream"),
1133 Credentials::new("admin", "12345"),
1134 )
1135 .unwrap();
1136 assert_eq!(
1137 verify_auth(&v, Some(&header), "GET", "/stream"),
1138 AuthResult::Ok
1139 );
1140 }
1141
1142 #[test]
1143 fn digest_respond_to_challenge_verifies_ok() {
1144 let v = Verifier::new(
1145 Credentials::Digest {
1146 username: "admin".into(),
1147 password: "12345".into(),
1148 },
1149 REALM,
1150 );
1151 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1152 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1153 assert_eq!(
1154 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1155 AuthResult::Ok
1156 );
1157 }
1158
1159 #[test]
1160 fn bearer_respond_to_challenge_verifies_ok() {
1161 let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1162 let header = respond(
1163 &v.challenge(),
1164 &RequestContext::new("GET", "/stream"),
1165 Credentials::bearer("mytoken123"),
1166 )
1167 .unwrap();
1168 assert_eq!(
1169 verify_auth(&v, Some(&header), "GET", "/stream"),
1170 AuthResult::Ok
1171 );
1172 }
1173
1174 #[test]
1177 fn basic_wrong_password_is_unauthorized() {
1178 let v = Verifier::new(
1179 Credentials::Basic {
1180 username: "admin".into(),
1181 password: "12345".into(),
1182 },
1183 REALM,
1184 );
1185 let header = respond(
1186 &v.challenge(),
1187 &RequestContext::new("GET", "/stream"),
1188 Credentials::new("admin", "WRONG"),
1189 )
1190 .unwrap();
1191 assert_eq!(
1192 verify_auth(&v, Some(&header), "GET", "/stream"),
1193 AuthResult::Unauthorized
1194 );
1195 }
1196
1197 #[test]
1198 fn digest_wrong_password_is_unauthorized() {
1199 let v = Verifier::new(
1200 Credentials::Digest {
1201 username: "admin".into(),
1202 password: "12345".into(),
1203 },
1204 REALM,
1205 );
1206 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1207 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "WRONG")).unwrap();
1208 assert_eq!(
1209 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1210 AuthResult::Unauthorized
1211 );
1212 }
1213
1214 #[test]
1215 fn digest_mismatched_request_uri_is_unauthorized() {
1216 let v = Verifier::new(
1220 Credentials::Digest {
1221 username: "admin".into(),
1222 password: "12345".into(),
1223 },
1224 REALM,
1225 );
1226 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1227 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1228 assert_eq!(
1229 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/OTHER"),
1230 AuthResult::Unauthorized
1231 );
1232 }
1233
1234 #[test]
1245 fn digest_accepts_absolute_form_client_uri_matching_request_path() {
1246 let v = Verifier::new(
1247 Credentials::Digest {
1248 username: "admin".into(),
1249 password: "12345".into(),
1250 },
1251 REALM,
1252 );
1253 let client_ctx = RequestContext::new("GET", "http://cam.local/stream/media.m3u8");
1254 let header = respond(
1255 &v.challenge(),
1256 &client_ctx,
1257 Credentials::new("admin", "12345"),
1258 )
1259 .unwrap();
1260 assert!(
1261 header.contains("uri=\"http://cam.local/stream/media.m3u8\""),
1262 "expected the client to hash the absolute-form uri, got: {header}"
1263 );
1264 assert_eq!(
1265 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1266 AuthResult::Ok
1267 );
1268 }
1269
1270 #[test]
1280 fn digest_rejects_absolute_form_uri_with_wrong_path() {
1281 let v = Verifier::new(
1282 Credentials::Digest {
1283 username: "admin".into(),
1284 password: "12345".into(),
1285 },
1286 REALM,
1287 );
1288 let client_ctx = RequestContext::new("GET", "http://cam.local/other/path");
1289 let header = respond(
1290 &v.challenge(),
1291 &client_ctx,
1292 Credentials::new("admin", "12345"),
1293 )
1294 .unwrap();
1295 assert_eq!(
1296 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1297 AuthResult::Unauthorized
1298 );
1299 }
1300
1301 #[test]
1304 fn digest_rejects_origin_form_uri_with_wrong_path() {
1305 let v = Verifier::new(
1306 Credentials::Digest {
1307 username: "admin".into(),
1308 password: "12345".into(),
1309 },
1310 REALM,
1311 );
1312 let client_ctx = RequestContext::new("GET", "/other/path");
1313 let header = respond(
1314 &v.challenge(),
1315 &client_ctx,
1316 Credentials::new("admin", "12345"),
1317 )
1318 .unwrap();
1319 assert_eq!(
1320 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1321 AuthResult::Unauthorized
1322 );
1323 }
1324
1325 #[test]
1326 fn digest_uri_matches_unit_cases() {
1327 assert!(digest_uri_matches("/a/b", "/a/b"));
1329 assert!(digest_uri_matches("http://host/a/b", "/a/b"));
1331 assert!(digest_uri_matches("https://host:8080/a/b?q=1", "/a/b?q=1"));
1332 assert!(!digest_uri_matches("/a/c", "/a/b"));
1334 assert!(!digest_uri_matches("http://host/a/c", "/a/b"));
1335 assert!(!digest_uri_matches("http://host/x/a/b", "/a/b"));
1337 assert!(!digest_uri_matches("/a/b/extra", "/a/b"));
1338 assert!(!digest_uri_matches("http://host", "/a/b"));
1340 }
1341
1342 #[test]
1343 fn digest_uri_match_is_not_loosened_by_url_normalisation() {
1344 assert!(!digest_uri_matches("http://h/a/../b", "/b"));
1347 assert!(!digest_uri_matches("http://h/%2e%2e/b", "/b"));
1348 assert!(!digest_uri_matches("http://h/./b", "/b"));
1349 assert!(!digest_uri_matches("HTTP://H/b", "/b"));
1350 assert!(!digest_uri_matches("http://h:80/b", "/b"));
1351 assert!(!digest_uri_matches("http://h", "/"));
1352 assert!(!digest_uri_matches("http://h/b#frag", "/b"));
1353 assert!(!digest_uri_matches("http://[::1/b", "/b"));
1354 assert!(digest_uri_matches("http://h/b?x=1&y=2", "/b?x=1&y=2"));
1356 assert!(digest_uri_matches("http://[2001:db8::1]:8080/b", "/b"));
1357 assert!(digest_uri_matches("rtsp://cam:554/live/ch1", "/live/ch1"));
1358 assert!(digest_uri_matches(
1359 "rtsp://cam/live/ch1",
1360 "rtsp://cam/live/ch1"
1361 ));
1362 }
1363
1364 #[test]
1365 fn bearer_wrong_token_is_unauthorized() {
1366 let v = Verifier::new(Credentials::bearer("right-token"), REALM);
1367 let header = respond(
1368 &v.challenge(),
1369 &RequestContext::new("GET", "/stream"),
1370 Credentials::bearer("wrong-token"),
1371 )
1372 .unwrap();
1373 assert_eq!(
1374 verify_auth(&v, Some(&header), "GET", "/stream"),
1375 AuthResult::Unauthorized
1376 );
1377 }
1378
1379 #[test]
1380 fn missing_authorization_header_is_unauthorized() {
1381 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1382 assert_eq!(
1383 verify_auth(&v, None, "GET", "/stream"),
1384 AuthResult::Unauthorized
1385 );
1386 }
1387
1388 #[test]
1389 fn wrong_scheme_header_is_unauthorized() {
1390 let v = Verifier::new(
1393 Credentials::Basic {
1394 username: "admin".into(),
1395 password: "12345".into(),
1396 },
1397 REALM,
1398 );
1399 assert_eq!(
1400 verify_auth(&v, Some("Bearer sometoken"), "GET", "/stream"),
1401 AuthResult::Unauthorized
1402 );
1403 }
1404
1405 #[test]
1409 fn forwarded_challenge_is_bare_scheme_name() {
1410 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1411 assert_eq!(v.challenge(), "Forwarded");
1412 }
1413
1414 #[test]
1418 fn forwarded_with_user_header_present_is_ok() {
1419 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1420 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
1421 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1422 assert_eq!(v.verify(&ctx), AuthResult::Ok);
1423 }
1424
1425 #[test]
1429 fn forwarded_without_user_header_is_unauthorized() {
1430 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1431 let ctx = RequestContext::new("GET", "/stream");
1432 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1433 }
1434
1435 #[test]
1439 fn forwarded_with_empty_user_header_is_unauthorized() {
1440 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1441 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "")];
1442 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1443 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1444 }
1445
1446 #[test]
1449 fn forwarded_user_header_lookup_is_case_insensitive() {
1450 let v = Verifier::forwarded("X-Forwarded-User", None);
1451 let headers: &[(&str, &str)] = &[("x-forwarded-user", "alice")];
1452 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1453 assert_eq!(v.verify(&ctx), AuthResult::Ok);
1454 }
1455
1456 #[test]
1460 fn forwarded_for_reads_configured_header() {
1461 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1462 let headers: &[(&str, &str)] = &[
1463 ("X-Forwarded-User", "alice"),
1464 ("X-Forwarded-For", "203.0.113.7"),
1465 ];
1466 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1467 assert_eq!(v.forwarded_for(&ctx), Some("203.0.113.7"));
1468 }
1469
1470 #[test]
1473 fn forwarded_for_is_none_when_not_configured() {
1474 let v = Verifier::forwarded("X-Forwarded-User", None);
1475 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1476 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1477 assert_eq!(v.forwarded_for(&ctx), None);
1478 }
1479
1480 #[test]
1483 fn forwarded_for_is_none_for_non_forwarded_verifier() {
1484 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1485 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1486 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1487 assert_eq!(v.forwarded_for(&ctx), None);
1488 }
1489
1490 #[test]
1493 fn forwarded_debug_names_scheme() {
1494 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1495 let debug = format!("{v:?}");
1496 assert!(debug.contains("Forwarded"), "debug: {debug}");
1497 }
1498
1499 #[test]
1505 fn signed_url_challenge_is_bare_scheme_name() {
1506 let keys = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
1507 let v = Verifier::signed_url(keys);
1508 assert_eq!(v.challenge(), "SignedUrl");
1509 }
1510
1511 #[test]
1512 fn signed_url_debug_names_scheme_and_never_leaks_secret() {
1513 let secret = b"super-secret-32-byte-hmac-key!!!".to_vec();
1514 let keys = SignedUrlKeySet::new([("k".to_string(), secret.clone())]).unwrap();
1515 let v = Verifier::signed_url(keys);
1516 let debug = format!("{v:?}");
1517 assert!(debug.contains("SignedUrl"), "debug: {debug}");
1518 assert!(
1519 !debug.contains(std::str::from_utf8(&secret).unwrap()),
1520 "debug: {debug}"
1521 );
1522 }
1523
1524 #[test]
1525 fn constant_time_eq_matches_naive_equality() {
1526 assert!(constant_time_eq(b"same", b"same"));
1527 assert!(!constant_time_eq(b"same", b"diff"));
1528 assert!(!constant_time_eq(b"short", b"longer-string"));
1529 assert!(constant_time_eq(b"", b""));
1530 }
1531
1532 #[test]
1538 fn oversized_digest_header_is_rejected_not_parsed() {
1539 let v = Verifier::new(
1540 Credentials::Digest {
1541 username: "admin".into(),
1542 password: "12345".into(),
1543 },
1544 REALM,
1545 );
1546 let mut huge = String::from("Digest ");
1547 for i in 0..(MAX_DIGEST_FIELDS + 1) {
1548 if i > 0 {
1549 huge.push(',');
1550 }
1551 huge.push_str(&format!("k{i}=\"v{i}\""));
1552 }
1553 assert_eq!(
1554 verify_auth(&v, Some(&huge), "DESCRIBE", "rtsp://cam/live"),
1555 AuthResult::Unauthorized,
1556 "oversized Digest header must not be accepted"
1557 );
1558 }
1559
1560 fn digest_verifier_at(now: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Verifier {
1561 Verifier::new(
1562 Credentials::Digest {
1563 username: "admin".into(),
1564 password: "12345".into(),
1565 },
1566 REALM,
1567 )
1568 .with_clock(move || {
1569 UNIX_EPOCH + Duration::from_secs(now.load(std::sync::atomic::Ordering::SeqCst))
1570 })
1571 }
1572
1573 fn digest_header(challenge: &str, uri: &str) -> String {
1574 respond(
1575 challenge,
1576 &RequestContext::new("GET", uri),
1577 Credentials::new("admin", "12345"),
1578 )
1579 .unwrap()
1580 }
1581
1582 fn with_nc(header: &str, nc: u32, uri: &str) -> String {
1585 let field = |name: &str| {
1586 let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1587 let rest = &header[start..];
1588 let rest = rest.trim_start_matches('"');
1589 let end = rest.find(['"', ',']).unwrap_or(rest.len());
1590 rest[..end].to_string()
1591 };
1592 let (nonce, cnonce, old_nc, old_resp) = (
1593 field("nonce"),
1594 field("cnonce"),
1595 field("nc"),
1596 field("response"),
1597 );
1598 let nc = format!("{nc:08x}");
1599 let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1600 let ha2 = md5_hex(format!("GET:{uri}"));
1601 let resp = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
1602 header
1603 .replace(&format!("nc={old_nc}"), &format!("nc={nc}"))
1604 .replace(&old_resp, &resp)
1605 }
1606
1607 #[test]
1611 fn basic_scheme_is_matched_case_insensitively() {
1612 let v = Verifier::new(
1613 Credentials::Basic {
1614 username: "admin".into(),
1615 password: "12345".into(),
1616 },
1617 REALM,
1618 );
1619 let header = respond(
1620 &v.challenge(),
1621 &RequestContext::new("GET", "/stream"),
1622 Credentials::new("admin", "12345"),
1623 )
1624 .unwrap();
1625 assert!(header.starts_with("Basic "));
1626 let lower = header.replacen("Basic ", "basic ", 1);
1627 assert_eq!(
1628 verify_auth(&v, Some(&lower), "GET", "/stream"),
1629 AuthResult::Ok,
1630 "lower-case scheme token must verify identically to the canonical case"
1631 );
1632 }
1633
1634 #[test]
1635 fn bearer_scheme_is_matched_case_insensitively() {
1636 let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1637 let header = respond(
1638 &v.challenge(),
1639 &RequestContext::new("GET", "/stream"),
1640 Credentials::bearer("mytoken123"),
1641 )
1642 .unwrap();
1643 assert!(header.starts_with("Bearer "));
1644 let mixed = header.replacen("Bearer ", "BEARER ", 1);
1645 assert_eq!(
1646 verify_auth(&v, Some(&mixed), "GET", "/stream"),
1647 AuthResult::Ok,
1648 "mixed-case scheme token must verify identically to the canonical case"
1649 );
1650 }
1651
1652 #[test]
1653 fn digest_scheme_is_matched_case_insensitively() {
1654 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1655 let v = digest_verifier_at(now);
1656 let uri = "/stream/index.m3u8";
1657 let header = digest_header(&v.challenge(), uri);
1658 assert!(header.starts_with("Digest "));
1659 let lower = header.replacen("Digest ", "digest ", 1);
1660 assert_eq!(
1661 verify_auth(&v, Some(&lower), "GET", uri),
1662 AuthResult::Ok,
1663 "lower-case scheme token must verify identically to the canonical case"
1664 );
1665 }
1666
1667 #[test]
1676 fn digest_field_parser_respects_quoted_commas() {
1677 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1678 let v = digest_verifier_at(now);
1679 let uri = "/stream/a,b/index.m3u8";
1680 let header = digest_header(&v.challenge(), uri);
1681 assert!(header.contains(&format!("uri=\"{uri}\"")));
1682 assert_eq!(
1683 verify_auth(&v, Some(&header), "GET", uri),
1684 AuthResult::Ok,
1685 "a comma inside the quoted uri field must not fragment the field list"
1686 );
1687 }
1688
1689 #[test]
1698 fn digest_rejects_unsupported_algorithm_label() {
1699 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1700 let v = digest_verifier_at(now);
1701 let uri = "/stream/index.m3u8";
1702 let header = digest_header(&v.challenge(), uri);
1703 assert!(header.contains("algorithm=MD5"));
1704 let relabeled = header.replacen("algorithm=MD5", "algorithm=SHA-256", 1);
1705 assert_eq!(
1706 verify_auth(&v, Some(&relabeled), "GET", uri),
1707 AuthResult::Unauthorized,
1708 "a header claiming an unimplemented algorithm must not verify"
1709 );
1710 }
1711
1712 #[test]
1722 fn digest_rejects_non_auth_qop() {
1723 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1724 let v = digest_verifier_at(now);
1725 let uri = "/stream/index.m3u8";
1726 let header = digest_header(&v.challenge(), uri);
1727 let field = |name: &str| {
1728 let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1729 let rest = &header[start..];
1730 let rest = rest.trim_start_matches('"');
1731 let end = rest.find(['"', ',']).unwrap_or(rest.len());
1732 rest[..end].to_string()
1733 };
1734 let (nonce, cnonce, nc, response) = (
1735 field("nonce"),
1736 field("cnonce"),
1737 field("nc"),
1738 field("response"),
1739 );
1740 let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1741 let ha2 = md5_hex(format!("GET:{uri}"));
1742 let recomputed = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}"));
1743 let relabeled = header
1744 .replacen("qop=auth", "qop=auth-int", 1)
1745 .replace(&response, &recomputed);
1746 assert_eq!(
1747 verify_auth(&v, Some(&relabeled), "GET", uri),
1748 AuthResult::Unauthorized,
1749 "a header claiming qop=auth-int must not verify even when response \
1750 was (mis)computed with the qop=auth formula"
1751 );
1752 }
1753
1754 #[test]
1755 fn digest_increasing_nc_is_accepted_and_repeated_nc_rejected() {
1756 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1757 let v = digest_verifier_at(now);
1758 let uri = "/stream/index.m3u8";
1759 let first = digest_header(&v.challenge(), uri);
1760 assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1761 let second = with_nc(&first, 2, uri);
1762 assert_eq!(verify_auth(&v, Some(&second), "GET", uri), AuthResult::Ok);
1763 let third = with_nc(&first, 3, uri);
1764 assert_eq!(verify_auth(&v, Some(&third), "GET", uri), AuthResult::Ok);
1765 assert_eq!(
1766 verify_auth(&v, Some(&second), "GET", uri),
1767 AuthResult::Unauthorized,
1768 "an nc already accepted must be rejected"
1769 );
1770 }
1771
1772 #[test]
1773 fn digest_expired_nonce_is_rejected_with_stale_challenge() {
1774 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1775 let v = digest_verifier_at(now.clone());
1776 let uri = "/stream/index.m3u8";
1777 let header = digest_header(&v.challenge(), uri);
1778 now.fetch_add(
1779 DIGEST_NONCE_LIFETIME.as_secs(),
1780 std::sync::atomic::Ordering::SeqCst,
1781 );
1782 assert_eq!(
1783 verify_auth(&v, Some(&header), "GET", uri),
1784 AuthResult::Unauthorized
1785 );
1786 let headers = [("authorization", header.as_str())];
1787 let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1788 let challenge = v.challenge_for(&ctx);
1789 assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1790 let retry = digest_header(&challenge, uri);
1792 assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1793 let wrong = respond(
1795 &v.challenge(),
1796 &RequestContext::new("GET", uri),
1797 Credentials::new("admin", "WRONG"),
1798 )
1799 .unwrap();
1800 now.fetch_add(
1801 DIGEST_NONCE_LIFETIME.as_secs(),
1802 std::sync::atomic::Ordering::SeqCst,
1803 );
1804 let headers = [("authorization", wrong.as_str())];
1805 let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1806 assert!(!v.challenge_for(&ctx).contains("stale"));
1807 }
1808
1809 #[test]
1810 fn digest_nonce_with_wrong_mac_is_rejected() {
1811 let v = Verifier::new(
1812 Credentials::Digest {
1813 username: "admin".into(),
1814 password: "12345".into(),
1815 },
1816 REALM,
1817 );
1818 let uri = "/stream/index.m3u8";
1819 let challenge = v.challenge();
1820 let start = challenge.find("nonce=\"").unwrap() + "nonce=\"".len();
1821 let nonce = &challenge[start..start + NONCE_LEN * 2];
1822 let last = nonce.as_bytes()[nonce.len() - 1];
1824 let flipped = if last == b'0' { '1' } else { '0' };
1825 let forged_nonce = format!("{}{flipped}", &nonce[..nonce.len() - 1]);
1826 let forged = challenge.replace(nonce, &forged_nonce);
1827 let header = digest_header(&forged, uri);
1828 assert!(header.contains(&forged_nonce));
1829 assert_eq!(
1830 verify_auth(&v, Some(&header), "GET", uri),
1831 AuthResult::Unauthorized
1832 );
1833 let other = Verifier::new(
1835 Credentials::Digest {
1836 username: "admin".into(),
1837 password: "12345".into(),
1838 },
1839 REALM,
1840 );
1841 let header = digest_header(&other.challenge(), uri);
1842 assert_eq!(
1843 verify_auth(&v, Some(&header), "GET", uri),
1844 AuthResult::Unauthorized
1845 );
1846 }
1847
1848 #[test]
1849 fn digest_out_of_order_nc_within_window_is_accepted_once() {
1850 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1851 let v = digest_verifier_at(now);
1852 let uri = "/stream/part.m4s";
1853 let first = digest_header(&v.challenge(), uri);
1854 assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1855 let nc = |n| with_nc(&first, n, uri);
1856 assert_eq!(verify_auth(&v, Some(&nc(6)), "GET", uri), AuthResult::Ok);
1858 assert_eq!(verify_auth(&v, Some(&nc(5)), "GET", uri), AuthResult::Ok);
1859 assert_eq!(
1860 verify_auth(&v, Some(&nc(5)), "GET", uri),
1861 AuthResult::Unauthorized,
1862 "nc 5 a second time is a replay"
1863 );
1864 assert_eq!(
1865 verify_auth(&v, Some(&nc(6)), "GET", uri),
1866 AuthResult::Unauthorized
1867 );
1868 let top = 200;
1871 assert_eq!(verify_auth(&v, Some(&nc(top)), "GET", uri), AuthResult::Ok);
1872 assert_eq!(
1873 verify_auth(&v, Some(&nc(top - NC_WINDOW - 1)), "GET", uri),
1874 AuthResult::Unauthorized,
1875 "an nc 65 below the highest is outside the window"
1876 );
1877 assert_eq!(
1878 verify_auth(&v, Some(&nc(top - NC_WINDOW)), "GET", uri),
1879 AuthResult::Ok
1880 );
1881 }
1882
1883 #[test]
1884 fn digest_nc_table_evicts_least_recently_used_pair() {
1885 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1886 let v = digest_verifier_at(now).with_digest_nc_capacity(4);
1887 let uri = "/s";
1888 let headers: Vec<String> = (0..4).map(|_| digest_header(&v.challenge(), uri)).collect();
1889 for h in &headers {
1890 assert_eq!(verify_auth(&v, Some(h), "GET", uri), AuthResult::Ok);
1891 }
1892 assert_eq!(
1894 verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1895 AuthResult::Ok
1896 );
1897 let newcomer = digest_header(&v.challenge(), uri);
1898 assert_eq!(verify_auth(&v, Some(&newcomer), "GET", uri), AuthResult::Ok);
1899 let VerifierScheme::Digest { nonces, .. } = &v.scheme else {
1900 unreachable!()
1901 };
1902 assert_eq!(nonces.table().entries.len(), 4);
1903 assert_eq!(
1905 verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1906 AuthResult::Unauthorized
1907 );
1908 assert_eq!(
1909 verify_auth(&v, Some(&with_nc(&headers[0], 3, uri)), "GET", uri),
1910 AuthResult::Ok
1911 );
1912 let replay = with_nc(&headers[1], 2, uri);
1915 assert_eq!(
1916 verify_auth(&v, Some(&replay), "GET", uri),
1917 AuthResult::Unauthorized
1918 );
1919 let hdrs = [("authorization", replay.as_str())];
1920 let ctx = RequestContext::new("GET", uri).with_headers(&hdrs);
1921 let challenge = v.challenge_for(&ctx);
1922 assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1923 let retry = digest_header(&challenge, uri);
1924 assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1925 }
1926
1927 #[test]
1928 fn digest_clock_stepping_back_does_not_backdate_nonces() {
1929 let start = 1_000_000;
1930 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(start));
1931 let v = digest_verifier_at(now.clone());
1932 let uri = "/s";
1933 let before = digest_header(&v.challenge(), uri);
1934 now.store(
1936 start - 2 * DIGEST_NONCE_LIFETIME.as_secs(),
1937 std::sync::atomic::Ordering::SeqCst,
1938 );
1939 let during = digest_header(&v.challenge(), uri);
1940 assert_eq!(verify_auth(&v, Some(&during), "GET", uri), AuthResult::Ok);
1941 assert_eq!(verify_auth(&v, Some(&before), "GET", uri), AuthResult::Ok);
1942 now.store(
1944 start + DIGEST_NONCE_LIFETIME.as_secs() - 1,
1945 std::sync::atomic::Ordering::SeqCst,
1946 );
1947 let again = with_nc(&during, 2, uri);
1948 assert_eq!(verify_auth(&v, Some(&again), "GET", uri), AuthResult::Ok);
1949 }
1950
1951 #[test]
1952 fn digest_replayed_request_is_rejected() {
1953 let v = Verifier::new(
1954 Credentials::Digest {
1955 username: "admin".into(),
1956 password: "12345".into(),
1957 },
1958 REALM,
1959 );
1960 let ctx = RequestContext::new("GET", "/stream/index.m3u8");
1961 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1962 assert_eq!(
1963 verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1964 AuthResult::Ok
1965 );
1966 assert_eq!(
1967 verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1968 AuthResult::Unauthorized,
1969 "same nonce + nc + cnonce must not verify twice"
1970 );
1971 }
1972
1973 #[test]
1974 fn debug_never_leaks_password_or_token() {
1975 let v = Verifier::new(
1976 Credentials::Digest {
1977 username: "admin".into(),
1978 password: "supersecret".into(),
1979 },
1980 REALM,
1981 );
1982 let debug = format!("{v:?}");
1983 assert!(!debug.contains("supersecret"), "debug: {debug}");
1984
1985 let v = Verifier::new(Credentials::bearer("topsecrettoken"), REALM);
1986 let debug = format!("{v:?}");
1987 assert!(!debug.contains("topsecrettoken"), "debug: {debug}");
1988 }
1989
1990 #[test]
1992 fn digest_md5_matches_rfc2617_worked_example() {
1993 use md5::{Digest as _, Md5};
1994 let ha1 = hex_of(Md5::digest(b"Mufasa:testrealm@host.com:Circle Of Life"));
1995 let ha2 = hex_of(Md5::digest(b"GET:/dir/index.html"));
1996 let resp = hex_of(Md5::digest(
1997 format!("{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}")
1998 .as_bytes(),
1999 ));
2000 assert_eq!(resp, "6629fae49393a05397450978507c4ef1");
2001 }
2002
2003 fn hex_of(bytes: impl AsRef<[u8]>) -> String {
2004 bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
2005 }
2006
2007 #[test]
2009 fn basic_credentials_base64_matches_rfc7617_example() {
2010 use base64::Engine as _;
2011 let enc = base64::engine::general_purpose::STANDARD.encode(b"Aladdin:open sesame");
2012 assert_eq!(enc, "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
2013 let dec = base64::engine::general_purpose::STANDARD
2014 .decode("QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
2015 .unwrap();
2016 assert_eq!(dec, b"Aladdin:open sesame");
2017 }
2018
2019 #[test]
2026 fn production_digest_chain_matches_rfc2617_and_rejects_altered() {
2027 let ha1 = md5_hex("Mufasa:testrealm@host.com:Circle Of Life".to_string());
2028 let ha2 = md5_hex("GET:/dir/index.html".to_string());
2029 let expected = md5_hex(format!(
2030 "{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}"
2031 ));
2032 let rfc = "6629fae49393a05397450978507c4ef1";
2033 assert!(constant_time_eq(expected.as_bytes(), rfc.as_bytes()));
2034 let altered = "6629fae49393a05397450978507c4ef2";
2036 assert!(!constant_time_eq(expected.as_bytes(), altered.as_bytes()));
2037 }
2038
2039 #[test]
2042 fn production_basic_verify_matches_rfc7617_and_rejects_altered() {
2043 let ok = "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2044 assert!(verify_basic(ok, "Aladdin", "open sesame"));
2045 let altered = "Basic RWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2047 assert!(!verify_basic(altered, "Aladdin", "open sesame"));
2048 assert!(!verify_basic(ok, "Aladdin", "open sesamf"));
2049 }
2050}