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::constant_time_eq;
119use crate::credentials::Credentials;
120use crate::request::RequestContext;
121use crate::signed_url::{self, SignedUrlKeySet};
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum AuthResult {
127 Ok,
130 Unauthorized,
133}
134
135enum VerifierScheme {
139 Basic {
140 username: String,
141 password: String,
142 realm: String,
143 },
144 Digest {
145 username: String,
146 password: String,
147 realm: String,
148 nonces: DigestNonces,
149 },
150 Bearer {
151 token: String,
152 },
153 Forwarded {
157 user_header: String,
158 forwarded_for_header: Option<String>,
159 },
160 SignedUrl {
164 keys: SignedUrlKeySet,
165 },
166}
167
168impl Drop for VerifierScheme {
171 fn drop(&mut self) {
172 use zeroize::Zeroize;
173 match self {
174 VerifierScheme::Basic { password, .. } | VerifierScheme::Digest { password, .. } => {
175 password.zeroize();
176 }
177 VerifierScheme::Bearer { token } => token.zeroize(),
178 VerifierScheme::Forwarded { .. } | VerifierScheme::SignedUrl { .. } => {}
179 }
180 }
181}
182
183pub struct Verifier {
186 scheme: VerifierScheme,
187 clock: Clock,
188}
189
190type Clock = Box<dyn Fn() -> SystemTime + Send + Sync>;
192
193pub const DIGEST_NONCE_LIFETIME: Duration = Duration::from_secs(3600);
198
199pub const DIGEST_NC_TRACK_CAP: usize = 65_536;
204
205pub const NC_WINDOW: u32 = u64::BITS;
208
209const NONCE_SECRET_LEN: usize = 32;
211const _: () = assert!(NONCE_SECRET_LEN <= 64);
213const NONCE_TIME_LEN: usize = 8;
215const NONCE_SEQ_LEN: usize = 8;
218const NONCE_MAC_LEN: usize = 32;
220const NONCE_LEN: usize = NONCE_TIME_LEN + NONCE_SEQ_LEN + NONCE_MAC_LEN;
222const NC_HEX_LEN: usize = 8;
224
225struct DigestNonces {
227 secret: [u8; NONCE_SECRET_LEN],
228 seen: Mutex<NcTable>,
229}
230
231impl Drop for DigestNonces {
232 fn drop(&mut self) {
233 use zeroize::Zeroize;
234 self.secret.zeroize();
235 }
236}
237
238type PairKey = [u8; 32];
241
242#[derive(Clone, Copy)]
244struct NonceStamp {
245 time: u64,
246 seq: u64,
247}
248
249struct NcEntry {
250 stamp: NonceStamp,
251 highest: u32,
253 window: u64,
255 }
257
258impl NcEntry {
259 fn accept(&mut self, nc: u32) -> bool {
261 if nc > self.highest {
262 let shift = nc - self.highest;
263 let old_highest = 1u64.checked_shl(shift - 1).unwrap_or(0);
264 self.window = self.window.checked_shl(shift).unwrap_or(0) | old_highest;
265 self.highest = nc;
266 return true;
267 }
268 let below = self.highest - nc;
269 if below == 0 || below > NC_WINDOW {
270 return false;
271 }
272 let bit = 1u64 << (below - 1);
273 if self.window & bit != 0 {
274 return false;
275 }
276 self.window |= bit;
277 true
278 }
279}
280
281struct NcTable {
282 entries: LruCache<PairKey, NcEntry>,
286 capacity: usize,
287 floor_seq: u64,
290 next_seq: u64,
291 latest_issue_time: u64,
292}
293
294enum DigestCheck {
297 Accept {
299 key: PairKey,
300 stamp: NonceStamp,
301 nc: u32,
302 },
303 Stale,
306 Reject,
308}
309
310fn pair_key(nonce: &str, cnonce: &str) -> PairKey {
311 let mut hash = Sha256::new();
312 hash.update(nonce.as_bytes());
313 hash.update(cnonce.as_bytes());
314 hash.finalize().into()
315}
316
317impl DigestNonces {
318 fn new() -> Self {
319 let mut secret = [0u8; NONCE_SECRET_LEN];
320 getrandom::fill(&mut secret).expect("OS random source unavailable");
321 DigestNonces {
322 secret,
323 seen: Mutex::new(NcTable {
324 entries: LruCache::unbounded(),
325 capacity: DIGEST_NC_TRACK_CAP,
326 floor_seq: 0,
327 next_seq: 0,
328 latest_issue_time: 0,
329 }),
330 }
331 }
332
333 fn table(&self) -> std::sync::MutexGuard<'_, NcTable> {
334 self.seen.lock().unwrap_or_else(|e| e.into_inner())
335 }
336
337 fn set_capacity(&self, capacity: usize) {
338 self.table().capacity = capacity.max(1);
339 }
340
341 fn now(&self, clock_secs: u64) -> u64 {
343 clock_secs.max(self.table().latest_issue_time)
344 }
345
346 fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
347 let mut block = Key::<Hmac<Sha256>>::default();
352 block[..NONCE_SECRET_LEN].copy_from_slice(&self.secret);
353 let mut mac = <Hmac<Sha256> as KeyInit>::new(&block);
354 mac.update(signed);
355 mac
356 }
357
358 fn issue(&self, clock_secs: u64) -> String {
360 let (time, seq) = {
361 let mut table = self.table();
362 let time = clock_secs.max(table.latest_issue_time);
363 table.latest_issue_time = time;
364 let seq = table.next_seq;
365 table.next_seq = seq.wrapping_add(1);
366 (time, seq)
367 };
368 let mut raw = [0u8; NONCE_LEN];
369 raw[..NONCE_TIME_LEN].copy_from_slice(&time.to_be_bytes());
370 raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN].copy_from_slice(&seq.to_be_bytes());
371 let tag = self
372 .mac(&raw[..NONCE_TIME_LEN + NONCE_SEQ_LEN])
373 .finalize()
374 .into_bytes();
375 raw[NONCE_TIME_LEN + NONCE_SEQ_LEN..].copy_from_slice(&tag);
376 hex::encode(raw)
377 }
378
379 fn issued_at(&self, nonce: &str) -> Option<NonceStamp> {
381 let raw = unhex::<NONCE_LEN>(nonce)?;
382 let (signed, tag) = raw.split_at(NONCE_TIME_LEN + NONCE_SEQ_LEN);
383 self.mac(signed).verify_slice(tag).ok()?;
384 let mut time = [0u8; NONCE_TIME_LEN];
385 time.copy_from_slice(&raw[..NONCE_TIME_LEN]);
386 let mut seq = [0u8; NONCE_SEQ_LEN];
387 seq.copy_from_slice(&raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN]);
388 Some(NonceStamp {
389 time: u64::from_be_bytes(time),
390 seq: u64::from_be_bytes(seq),
391 })
392 }
393
394 fn is_expired(issued: u64, now_secs: u64) -> bool {
395 now_secs.saturating_sub(issued) >= DIGEST_NONCE_LIFETIME.as_secs()
396 }
397
398 fn record(&self, key: PairKey, stamp: NonceStamp, nc: u32, now_secs: u64) -> bool {
401 let mut guard = self.table();
402 let table = &mut *guard;
403 if let Some(entry) = table.entries.peek_mut(&key) {
406 if !entry.accept(nc) {
407 return false;
408 }
409 table.entries.promote(&key);
410 return true;
411 }
412 while table.entries.len() >= table.capacity {
413 let Some((_, dropped)) = table.entries.pop_lru() else {
414 break;
415 };
416 if !Self::is_expired(dropped.stamp.time, now_secs) {
417 table.floor_seq = table.floor_seq.max(dropped.stamp.seq.saturating_add(1));
418 }
419 }
420 if stamp.seq < table.floor_seq {
421 return false;
422 }
423 table.entries.put(
424 key,
425 NcEntry {
426 stamp,
427 highest: nc,
428 window: 0,
429 },
430 );
431 true
432 }
433
434 fn below_floor(&self, key: &PairKey, stamp: NonceStamp) -> bool {
437 let table = self.table();
438 stamp.seq < table.floor_seq && !table.entries.contains(key)
439 }
440}
441
442fn unix_secs(t: SystemTime) -> u64 {
443 t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
444}
445
446fn default_clock() -> Clock {
447 Box::new(SystemTime::now)
448}
449
450impl Verifier {
451 pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
460 let realm = sanitize_realm(&realm.into());
463 let mut credentials = credentials;
467 let scheme = match &mut credentials {
468 Credentials::Basic { username, password } => VerifierScheme::Basic {
469 username: core::mem::take(username),
470 password: core::mem::take(password),
471 realm,
472 },
473 Credentials::Digest { username, password } => VerifierScheme::Digest {
474 username: core::mem::take(username),
475 password: core::mem::take(password),
476 realm,
477 nonces: DigestNonces::new(),
478 },
479 Credentials::Bearer { token } => VerifierScheme::Bearer {
480 token: core::mem::take(token),
481 },
482 };
483 Verifier {
484 scheme,
485 clock: default_clock(),
486 }
487 }
488
489 pub fn with_clock(mut self, clock: impl Fn() -> SystemTime + Send + Sync + 'static) -> Self {
492 self.clock = Box::new(clock);
493 self
494 }
495
496 fn now_secs(&self) -> u64 {
497 unix_secs((self.clock)())
498 }
499
500 pub fn with_digest_nc_capacity(self, capacity: usize) -> Self {
504 if let VerifierScheme::Digest { nonces, .. } = &self.scheme {
505 nonces.set_capacity(capacity);
506 }
507 self
508 }
509
510 pub fn forwarded(user_header: impl Into<String>, forwarded_for_header: Option<String>) -> Self {
520 Verifier {
521 scheme: VerifierScheme::Forwarded {
522 user_header: user_header.into(),
523 forwarded_for_header,
524 },
525 clock: default_clock(),
526 }
527 }
528
529 pub fn signed_url(keys: SignedUrlKeySet) -> Self {
533 Verifier {
534 scheme: VerifierScheme::SignedUrl { keys },
535 clock: default_clock(),
536 }
537 }
538
539 pub fn challenge(&self) -> String {
550 self.render_challenge(false)
551 }
552
553 pub fn challenge_for(&self, ctx: &RequestContext<'_>) -> String {
560 let stale = match &self.scheme {
561 VerifierScheme::Digest {
562 username,
563 password,
564 realm,
565 nonces,
566 } => ctx.header("authorization").is_some_and(|header| {
567 matches!(
568 check_digest(
569 header,
570 username,
571 password,
572 realm,
573 nonces,
574 ctx.method,
575 ctx.uri,
576 nonces.now(self.now_secs()),
577 ),
578 DigestCheck::Stale
579 )
580 }),
581 _ => false,
582 };
583 self.render_challenge(stale)
584 }
585
586 fn render_challenge(&self, stale: bool) -> String {
587 match &self.scheme {
588 VerifierScheme::Basic { realm, .. } => format!("Basic realm={}", quoted(realm)),
589 VerifierScheme::Digest { realm, nonces, .. } => {
590 let nonce = nonces.issue(self.now_secs());
591 let stale = if stale { ", stale=true" } else { "" };
592 format!(
593 "Digest realm={}, nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5{stale}",
594 quoted(realm)
595 )
596 }
597 VerifierScheme::Bearer { .. } => "Bearer".to_string(),
598 VerifierScheme::Forwarded { .. } => "Forwarded".to_string(),
599 VerifierScheme::SignedUrl { .. } => "SignedUrl".to_string(),
600 }
601 }
602
603 pub fn verify(&self, ctx: &RequestContext<'_>) -> AuthResult {
623 let ok = match &self.scheme {
624 VerifierScheme::Basic {
625 username, password, ..
626 } => ctx
627 .header("authorization")
628 .is_some_and(|header| verify_basic(header, username, password)),
629 VerifierScheme::Bearer { token } => ctx
630 .header("authorization")
631 .is_some_and(|header| verify_bearer(header, token)),
632 VerifierScheme::Digest {
633 username,
634 password,
635 realm,
636 nonces,
637 } => ctx.header("authorization").is_some_and(|header| {
638 let now = nonces.now(self.now_secs());
639 match check_digest(
640 header, username, password, realm, nonces, ctx.method, ctx.uri, now,
641 ) {
642 DigestCheck::Accept { key, stamp, nc } => nonces.record(key, stamp, nc, now),
643 DigestCheck::Stale | DigestCheck::Reject => false,
644 }
645 }),
646 VerifierScheme::Forwarded { user_header, .. } => verify_forwarded(ctx, user_header),
647 VerifierScheme::SignedUrl { keys } => signed_url::verify(ctx, keys),
648 };
649 if ok {
650 AuthResult::Ok
651 } else {
652 AuthResult::Unauthorized
653 }
654 }
655
656 pub fn forwarded_for<'a>(&self, ctx: &RequestContext<'a>) -> Option<&'a str> {
663 match &self.scheme {
664 VerifierScheme::Forwarded {
665 forwarded_for_header: Some(header_name),
666 ..
667 } => ctx.header(header_name),
668 _ => None,
669 }
670 }
671}
672
673impl core::fmt::Debug for Verifier {
676 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
677 let scheme = match &self.scheme {
678 VerifierScheme::Basic { .. } => "Basic",
679 VerifierScheme::Digest { .. } => "Digest",
680 VerifierScheme::Bearer { .. } => "Bearer",
681 VerifierScheme::Forwarded { .. } => "Forwarded",
682 VerifierScheme::SignedUrl { .. } => "SignedUrl",
683 };
684 f.debug_struct("Verifier")
685 .field("scheme", &scheme)
686 .finish_non_exhaustive()
687 }
688}
689
690fn sanitize_realm(realm: &str) -> String {
695 realm
696 .chars()
697 .filter(|c| !c.is_ascii_control() || *c == '\t')
698 .collect()
699}
700
701fn quoted(value: &str) -> String {
708 let mut out = String::with_capacity(value.len() + 2);
709 out.push('"');
710 for c in value
711 .chars()
712 .filter(|c| !c.is_ascii_control() || *c == '\t')
713 {
714 if matches!(c, '"' | '\\') {
715 out.push('\\');
716 }
717 out.push(c);
718 }
719 out.push('"');
720 out
721}
722
723fn is_ext_value_syntax(value: &str) -> bool {
725 let b = value.as_bytes();
726 let mut i = 0;
727 while i < b.len() {
728 match b[i] {
729 b'%' => {
730 if !(i + 2 < b.len()
731 && b[i + 1].is_ascii_hexdigit()
732 && b[i + 2].is_ascii_hexdigit())
733 {
734 return false;
735 }
736 i += 3;
737 }
738 c if c.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&c) => i += 1,
739 _ => return false,
740 }
741 }
742 true
743}
744
745fn decode_ext_value(v: &str) -> Option<String> {
750 let (charset, rest) = v.split_once('\'')?;
751 let (_language, value) = rest.split_once('\'')?;
752 if !charset.eq_ignore_ascii_case("UTF-8") || !is_ext_value_syntax(value) {
753 return None;
754 }
755 percent_decode_str(value)
756 .decode_utf8()
757 .ok()
758 .map(std::borrow::Cow::into_owned)
759}
760
761fn parse_digest_fields(header: &str) -> Option<HashMap<String, String>> {
769 let mut parser = ChallengeParser::new(header);
770 let challenge: ChallengeRef<'_> = parser.next()?.ok()?;
771 if parser.next().is_some()
772 || !challenge.scheme.eq_ignore_ascii_case("Digest")
773 || challenge.params.len() > MAX_DIGEST_FIELDS
774 {
775 return None;
776 }
777 let mut fields = HashMap::with_capacity(challenge.params.len());
778 for (name, value) in &challenge.params {
779 if fields
780 .insert(name.to_ascii_lowercase(), value.to_unescaped())
781 .is_some()
782 {
783 return None;
784 }
785 }
786 Some(fields)
787}
788
789fn decode<C: HeaderCredentials>(header: &str) -> Option<Authorization<C>> {
793 let value = HeaderValue::from_str(header).ok()?;
794 Authorization::<C>::decode(&mut std::iter::once(&value)).ok()
795}
796
797fn verify_basic(header: &str, username: &str, password: &str) -> bool {
802 let Some(auth) = decode::<Basic>(header) else {
803 return false;
804 };
805 let user_ok = constant_time_eq(auth.username().as_bytes(), username.as_bytes());
807 let pass_ok = constant_time_eq(auth.password().as_bytes(), password.as_bytes());
808 user_ok & pass_ok
809}
810
811fn verify_bearer(header: &str, token: &str) -> bool {
813 let Some(auth) = decode::<Bearer>(header) else {
814 return false;
815 };
816 constant_time_eq(auth.token().trim().as_bytes(), token.trim().as_bytes())
817}
818
819const MAX_DIGEST_FIELDS: usize = 64;
827
828#[allow(clippy::too_many_arguments)]
847fn check_digest(
848 header: &str,
849 username: &str,
850 password: &str,
851 realm: &str,
852 nonces: &DigestNonces,
853 method: &str,
854 request_uri: &str,
855 now_secs: u64,
856) -> DigestCheck {
857 let Some(fields) = parse_digest_fields(header) else {
858 return DigestCheck::Reject;
859 };
860 let get = |k: &str| fields.get(k).map(String::as_str).unwrap_or_default();
861
862 if get("userhash").eq_ignore_ascii_case("true") {
863 return DigestCheck::Reject;
864 }
865 let client_user = match (fields.get("username"), fields.get("username*")) {
866 (Some(_), Some(_)) => return DigestCheck::Reject, (Some(u), None) => u.clone(),
868 (None, Some(ext)) => match decode_ext_value(ext) {
869 Some(u) => u,
870 None => return DigestCheck::Reject,
871 },
872 (None, None) => return DigestCheck::Reject,
873 };
874 if client_user != username || get("realm") != realm {
875 return DigestCheck::Reject;
876 }
877 let nonce = get("nonce");
878 let Some(stamp) = nonces.issued_at(nonce) else {
879 return DigestCheck::Reject;
880 };
881 let client_uri = get("uri");
882 if !digest_uri_matches(client_uri, request_uri) {
883 return DigestCheck::Reject;
884 }
885 let nc_text = get("nc");
886 let cnonce = get("cnonce");
887 let qop = get("qop");
888 let client_response = get("response");
889 if cnonce.is_empty() || client_response.is_empty() {
890 return DigestCheck::Reject;
891 }
892 if qop != "auth" {
900 return DigestCheck::Reject;
901 }
902 let algorithm = get("algorithm");
903 if !algorithm.is_empty() && !algorithm.eq_ignore_ascii_case("MD5") {
904 return DigestCheck::Reject;
905 }
906 let Some(nc) = parse_nc(nc_text) else {
907 return DigestCheck::Reject;
908 };
909
910 let ha1 = md5_hex(format!("{username}:{realm}:{password}"));
911 let ha2 = md5_hex(format!("{method}:{client_uri}"));
912 let expected_response = md5_hex(format!("{ha1}:{nonce}:{nc_text}:{cnonce}:{qop}:{ha2}"));
913 if !constant_time_eq(expected_response.as_bytes(), client_response.as_bytes()) {
914 return DigestCheck::Reject;
915 }
916 let key = pair_key(nonce, cnonce);
917 if DigestNonces::is_expired(stamp.time, now_secs) || nonces.below_floor(&key, stamp) {
918 return DigestCheck::Stale;
919 }
920 DigestCheck::Accept { key, stamp, nc }
921}
922
923fn parse_nc(nc: &str) -> Option<u32> {
925 if nc.len() != NC_HEX_LEN || !nc.bytes().all(|b| b.is_ascii_hexdigit()) {
926 return None;
927 }
928 u32::from_str_radix(nc, 16).ok()
929}
930
931fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
954 if client_uri == request_uri {
955 return true;
956 }
957 let Ok(parsed) = Url::parse(client_uri) else {
958 return false;
959 };
960 parsed.as_str() == client_uri && &parsed[Position::BeforePath..] == request_uri
961}
962
963fn verify_forwarded(ctx: &RequestContext<'_>, user_header: &str) -> bool {
968 ctx.header(user_header)
969 .is_some_and(|v| !v.trim().is_empty())
970}
971
972fn md5_hex(input: String) -> String {
974 let mut hasher = Md5::new();
975 hasher.update(input.as_bytes());
976 hex::encode(hasher.finalize())
977}
978
979fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
981 let mut out = [0u8; N];
982 hex::decode_to_slice(text, &mut out).ok()?; Some(out)
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use crate::{Credentials, RequestContext, respond};
990
991 const REALM: &str = "cameras";
992
993 #[test]
996 fn recently_used_pair_survives_eviction() {
997 let nonces = DigestNonces::new();
998 nonces.set_capacity(2);
999 let stamp = |seq| NonceStamp { time: 100, seq };
1000 let (a, b, c) = (
1001 pair_key("n1", "c"),
1002 pair_key("n2", "c"),
1003 pair_key("n3", "c"),
1004 );
1005 assert!(nonces.record(a, stamp(1), 1, 100));
1006 assert!(nonces.record(b, stamp(2), 1, 100));
1007 assert!(nonces.record(a, stamp(1), 2, 100));
1009 assert!(nonces.record(c, stamp(3), 1, 100));
1011 assert!(
1012 !nonces.record(a, stamp(1), 2, 100),
1013 "a still tracked: nc 2 is a replay"
1014 );
1015 assert_eq!(nonces.table().entries.len(), 2);
1016 assert!(!nonces.record(b, stamp(2), 1, 100));
1019 }
1020
1021 #[test]
1022 fn nonce_is_96_lowercase_hex_and_round_trips() {
1023 let nonces = DigestNonces::new();
1024 let nonce = nonces.issue(1_000);
1025 assert_eq!(nonce.len(), NONCE_LEN * 2);
1026 assert!(
1027 nonce
1028 .bytes()
1029 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1030 );
1031 let stamp = nonces.issued_at(&nonce).expect("our own nonce verifies");
1032 assert_eq!(stamp.time, 1_000);
1033 assert!(
1034 nonces.issued_at(&nonce.to_uppercase()).is_some(),
1035 "hex is case-insensitive on read"
1036 );
1037 assert!(nonces.issued_at(&nonce[..nonce.len() - 2]).is_none());
1038 assert!(
1039 nonces
1040 .issued_at(&format!("{}zz", &nonce[..nonce.len() - 2]))
1041 .is_none()
1042 );
1043 }
1044
1045 #[test]
1050 fn nonce_mac_matches_variable_length_keying() {
1051 let nonces = DigestNonces::new();
1052 let signed = b"issue-time||issue-seq";
1053 let reference = {
1054 let mut m = <Hmac<Sha256> as KeyInit>::new_from_slice(&nonces.secret).unwrap();
1055 m.update(signed);
1056 m.finalize().into_bytes()
1057 };
1058 assert_eq!(nonces.mac(signed).finalize().into_bytes(), reference);
1059 }
1060
1061 fn verify_auth(
1066 v: &Verifier,
1067 authorization: Option<&str>,
1068 method: &str,
1069 uri: &str,
1070 ) -> AuthResult {
1071 let auth_header = authorization.map(|h| [("authorization", h)]);
1072 let headers: &[(&str, &str)] = match &auth_header {
1073 Some(arr) => arr,
1074 None => &[],
1075 };
1076 let ctx = RequestContext::new(method, uri).with_headers(headers);
1077 v.verify(&ctx)
1078 }
1079
1080 #[test]
1083 fn basic_challenge_names_the_realm() {
1084 let v = Verifier::new(
1085 Credentials::Basic {
1086 username: "admin".into(),
1087 password: "12345".into(),
1088 },
1089 REALM,
1090 );
1091 assert_eq!(v.challenge(), "Basic realm=\"cameras\"");
1092 }
1093
1094 #[test]
1095 fn digest_challenge_carries_realm_nonce_qop_algorithm() {
1096 let v = Verifier::new(
1097 Credentials::Digest {
1098 username: "admin".into(),
1099 password: "12345".into(),
1100 },
1101 REALM,
1102 );
1103 let challenge = v.challenge();
1104 assert!(challenge.starts_with("Digest "), "got: {challenge}");
1105 for needle in [
1106 "realm=\"cameras\"",
1107 "nonce=",
1108 "qop=\"auth\"",
1109 "algorithm=MD5",
1110 ] {
1111 assert!(
1112 challenge.contains(needle),
1113 "missing {needle} in {challenge}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1119 fn bearer_challenge_is_bare_scheme_name() {
1120 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1121 assert_eq!(v.challenge(), "Bearer");
1122 }
1123
1124 #[test]
1125 fn digest_challenges_issue_distinct_nonces() {
1126 let v = Verifier::new(
1127 Credentials::Digest {
1128 username: "admin".into(),
1129 password: "12345".into(),
1130 },
1131 REALM,
1132 );
1133 assert_ne!(v.challenge(), v.challenge());
1134 }
1135
1136 #[test]
1139 fn basic_respond_to_challenge_verifies_ok() {
1140 let v = Verifier::new(
1141 Credentials::Basic {
1142 username: "admin".into(),
1143 password: "12345".into(),
1144 },
1145 REALM,
1146 );
1147 let header = respond(
1148 &v.challenge(),
1149 &RequestContext::new("GET", "/stream"),
1150 Credentials::new("admin", "12345"),
1151 )
1152 .unwrap();
1153 assert_eq!(
1154 verify_auth(&v, Some(&header), "GET", "/stream"),
1155 AuthResult::Ok
1156 );
1157 }
1158
1159 #[test]
1160 fn digest_respond_to_challenge_verifies_ok() {
1161 let v = Verifier::new(
1162 Credentials::Digest {
1163 username: "admin".into(),
1164 password: "12345".into(),
1165 },
1166 REALM,
1167 );
1168 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1169 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1170 assert_eq!(
1171 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1172 AuthResult::Ok
1173 );
1174 }
1175
1176 #[test]
1177 fn bearer_respond_to_challenge_verifies_ok() {
1178 let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1179 let header = respond(
1180 &v.challenge(),
1181 &RequestContext::new("GET", "/stream"),
1182 Credentials::bearer("mytoken123"),
1183 )
1184 .unwrap();
1185 assert_eq!(
1186 verify_auth(&v, Some(&header), "GET", "/stream"),
1187 AuthResult::Ok
1188 );
1189 }
1190
1191 #[test]
1194 fn basic_wrong_password_is_unauthorized() {
1195 let v = Verifier::new(
1196 Credentials::Basic {
1197 username: "admin".into(),
1198 password: "12345".into(),
1199 },
1200 REALM,
1201 );
1202 let header = respond(
1203 &v.challenge(),
1204 &RequestContext::new("GET", "/stream"),
1205 Credentials::new("admin", "WRONG"),
1206 )
1207 .unwrap();
1208 assert_eq!(
1209 verify_auth(&v, Some(&header), "GET", "/stream"),
1210 AuthResult::Unauthorized
1211 );
1212 }
1213
1214 #[test]
1215 fn digest_wrong_password_is_unauthorized() {
1216 let v = Verifier::new(
1217 Credentials::Digest {
1218 username: "admin".into(),
1219 password: "12345".into(),
1220 },
1221 REALM,
1222 );
1223 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1224 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "WRONG")).unwrap();
1225 assert_eq!(
1226 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
1227 AuthResult::Unauthorized
1228 );
1229 }
1230
1231 #[test]
1232 fn digest_mismatched_request_uri_is_unauthorized() {
1233 let v = Verifier::new(
1237 Credentials::Digest {
1238 username: "admin".into(),
1239 password: "12345".into(),
1240 },
1241 REALM,
1242 );
1243 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
1244 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1245 assert_eq!(
1246 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/OTHER"),
1247 AuthResult::Unauthorized
1248 );
1249 }
1250
1251 #[test]
1262 fn digest_accepts_absolute_form_client_uri_matching_request_path() {
1263 let v = Verifier::new(
1264 Credentials::Digest {
1265 username: "admin".into(),
1266 password: "12345".into(),
1267 },
1268 REALM,
1269 );
1270 let client_ctx = RequestContext::new("GET", "http://cam.local/stream/media.m3u8");
1271 let header = respond(
1272 &v.challenge(),
1273 &client_ctx,
1274 Credentials::new("admin", "12345"),
1275 )
1276 .unwrap();
1277 assert!(
1278 header.contains("uri=\"http://cam.local/stream/media.m3u8\""),
1279 "expected the client to hash the absolute-form uri, got: {header}"
1280 );
1281 assert_eq!(
1282 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1283 AuthResult::Ok
1284 );
1285 }
1286
1287 #[test]
1297 fn digest_rejects_absolute_form_uri_with_wrong_path() {
1298 let v = Verifier::new(
1299 Credentials::Digest {
1300 username: "admin".into(),
1301 password: "12345".into(),
1302 },
1303 REALM,
1304 );
1305 let client_ctx = RequestContext::new("GET", "http://cam.local/other/path");
1306 let header = respond(
1307 &v.challenge(),
1308 &client_ctx,
1309 Credentials::new("admin", "12345"),
1310 )
1311 .unwrap();
1312 assert_eq!(
1313 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1314 AuthResult::Unauthorized
1315 );
1316 }
1317
1318 #[test]
1321 fn digest_rejects_origin_form_uri_with_wrong_path() {
1322 let v = Verifier::new(
1323 Credentials::Digest {
1324 username: "admin".into(),
1325 password: "12345".into(),
1326 },
1327 REALM,
1328 );
1329 let client_ctx = RequestContext::new("GET", "/other/path");
1330 let header = respond(
1331 &v.challenge(),
1332 &client_ctx,
1333 Credentials::new("admin", "12345"),
1334 )
1335 .unwrap();
1336 assert_eq!(
1337 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
1338 AuthResult::Unauthorized
1339 );
1340 }
1341
1342 #[test]
1343 fn digest_uri_matches_unit_cases() {
1344 assert!(digest_uri_matches("/a/b", "/a/b"));
1346 assert!(digest_uri_matches("http://host/a/b", "/a/b"));
1348 assert!(digest_uri_matches("https://host:8080/a/b?q=1", "/a/b?q=1"));
1349 assert!(!digest_uri_matches("/a/c", "/a/b"));
1351 assert!(!digest_uri_matches("http://host/a/c", "/a/b"));
1352 assert!(!digest_uri_matches("http://host/x/a/b", "/a/b"));
1354 assert!(!digest_uri_matches("/a/b/extra", "/a/b"));
1355 assert!(!digest_uri_matches("http://host", "/a/b"));
1357 }
1358
1359 #[test]
1360 fn digest_uri_match_is_not_loosened_by_url_normalisation() {
1361 assert!(!digest_uri_matches("http://h/a/../b", "/b"));
1364 assert!(!digest_uri_matches("http://h/%2e%2e/b", "/b"));
1365 assert!(!digest_uri_matches("http://h/./b", "/b"));
1366 assert!(!digest_uri_matches("HTTP://H/b", "/b"));
1367 assert!(!digest_uri_matches("http://h:80/b", "/b"));
1368 assert!(!digest_uri_matches("http://h", "/"));
1369 assert!(!digest_uri_matches("http://h/b#frag", "/b"));
1370 assert!(!digest_uri_matches("http://[::1/b", "/b"));
1371 assert!(digest_uri_matches("http://h/b?x=1&y=2", "/b?x=1&y=2"));
1373 assert!(digest_uri_matches("http://[2001:db8::1]:8080/b", "/b"));
1374 assert!(digest_uri_matches("rtsp://cam:554/live/ch1", "/live/ch1"));
1375 assert!(digest_uri_matches(
1376 "rtsp://cam/live/ch1",
1377 "rtsp://cam/live/ch1"
1378 ));
1379 }
1380
1381 #[test]
1382 fn bearer_wrong_token_is_unauthorized() {
1383 let v = Verifier::new(Credentials::bearer("right-token"), REALM);
1384 let header = respond(
1385 &v.challenge(),
1386 &RequestContext::new("GET", "/stream"),
1387 Credentials::bearer("wrong-token"),
1388 )
1389 .unwrap();
1390 assert_eq!(
1391 verify_auth(&v, Some(&header), "GET", "/stream"),
1392 AuthResult::Unauthorized
1393 );
1394 }
1395
1396 #[test]
1397 fn missing_authorization_header_is_unauthorized() {
1398 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1399 assert_eq!(
1400 verify_auth(&v, None, "GET", "/stream"),
1401 AuthResult::Unauthorized
1402 );
1403 }
1404
1405 #[test]
1406 fn wrong_scheme_header_is_unauthorized() {
1407 let v = Verifier::new(
1410 Credentials::Basic {
1411 username: "admin".into(),
1412 password: "12345".into(),
1413 },
1414 REALM,
1415 );
1416 assert_eq!(
1417 verify_auth(&v, Some("Bearer sometoken"), "GET", "/stream"),
1418 AuthResult::Unauthorized
1419 );
1420 }
1421
1422 #[test]
1426 fn forwarded_challenge_is_bare_scheme_name() {
1427 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1428 assert_eq!(v.challenge(), "Forwarded");
1429 }
1430
1431 #[test]
1435 fn forwarded_with_user_header_present_is_ok() {
1436 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1437 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
1438 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1439 assert_eq!(v.verify(&ctx), AuthResult::Ok);
1440 }
1441
1442 #[test]
1446 fn forwarded_without_user_header_is_unauthorized() {
1447 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1448 let ctx = RequestContext::new("GET", "/stream");
1449 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1450 }
1451
1452 #[test]
1456 fn forwarded_with_empty_user_header_is_unauthorized() {
1457 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1458 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "")];
1459 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1460 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
1461 }
1462
1463 #[test]
1466 fn forwarded_user_header_lookup_is_case_insensitive() {
1467 let v = Verifier::forwarded("X-Forwarded-User", None);
1468 let headers: &[(&str, &str)] = &[("x-forwarded-user", "alice")];
1469 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1470 assert_eq!(v.verify(&ctx), AuthResult::Ok);
1471 }
1472
1473 #[test]
1477 fn forwarded_for_reads_configured_header() {
1478 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1479 let headers: &[(&str, &str)] = &[
1480 ("X-Forwarded-User", "alice"),
1481 ("X-Forwarded-For", "203.0.113.7"),
1482 ];
1483 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1484 assert_eq!(v.forwarded_for(&ctx), Some("203.0.113.7"));
1485 }
1486
1487 #[test]
1490 fn forwarded_for_is_none_when_not_configured() {
1491 let v = Verifier::forwarded("X-Forwarded-User", None);
1492 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1493 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1494 assert_eq!(v.forwarded_for(&ctx), None);
1495 }
1496
1497 #[test]
1500 fn forwarded_for_is_none_for_non_forwarded_verifier() {
1501 let v = Verifier::new(Credentials::bearer("tok"), REALM);
1502 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
1503 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
1504 assert_eq!(v.forwarded_for(&ctx), None);
1505 }
1506
1507 #[test]
1510 fn forwarded_debug_names_scheme() {
1511 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
1512 let debug = format!("{v:?}");
1513 assert!(debug.contains("Forwarded"), "debug: {debug}");
1514 }
1515
1516 #[test]
1522 fn signed_url_challenge_is_bare_scheme_name() {
1523 let keys = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
1524 let v = Verifier::signed_url(keys);
1525 assert_eq!(v.challenge(), "SignedUrl");
1526 }
1527
1528 #[test]
1529 fn signed_url_debug_names_scheme_and_never_leaks_secret() {
1530 let secret = b"super-secret-32-byte-hmac-key!!!".to_vec();
1531 let keys = SignedUrlKeySet::new([("k".to_string(), secret.clone())]).unwrap();
1532 let v = Verifier::signed_url(keys);
1533 let debug = format!("{v:?}");
1534 assert!(debug.contains("SignedUrl"), "debug: {debug}");
1535 assert!(
1536 !debug.contains(std::str::from_utf8(&secret).unwrap()),
1537 "debug: {debug}"
1538 );
1539 }
1540
1541 #[test]
1547 fn oversized_digest_header_is_rejected_not_parsed() {
1548 let v = Verifier::new(
1549 Credentials::Digest {
1550 username: "admin".into(),
1551 password: "12345".into(),
1552 },
1553 REALM,
1554 );
1555 let mut huge = String::from("Digest ");
1556 for i in 0..(MAX_DIGEST_FIELDS + 1) {
1557 if i > 0 {
1558 huge.push(',');
1559 }
1560 huge.push_str(&format!("k{i}=\"v{i}\""));
1561 }
1562 assert_eq!(
1563 verify_auth(&v, Some(&huge), "DESCRIBE", "rtsp://cam/live"),
1564 AuthResult::Unauthorized,
1565 "oversized Digest header must not be accepted"
1566 );
1567 }
1568
1569 fn digest_verifier_at(now: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Verifier {
1570 Verifier::new(
1571 Credentials::Digest {
1572 username: "admin".into(),
1573 password: "12345".into(),
1574 },
1575 REALM,
1576 )
1577 .with_clock(move || {
1578 UNIX_EPOCH + Duration::from_secs(now.load(std::sync::atomic::Ordering::SeqCst))
1579 })
1580 }
1581
1582 fn digest_header(challenge: &str, uri: &str) -> String {
1583 respond(
1584 challenge,
1585 &RequestContext::new("GET", uri),
1586 Credentials::new("admin", "12345"),
1587 )
1588 .unwrap()
1589 }
1590
1591 fn with_nc(header: &str, nc: u32, uri: &str) -> String {
1594 let field = |name: &str| {
1595 let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1596 let rest = &header[start..];
1597 let rest = rest.trim_start_matches('"');
1598 let end = rest.find(['"', ',']).unwrap_or(rest.len());
1599 rest[..end].to_string()
1600 };
1601 let (nonce, cnonce, old_nc, old_resp) = (
1602 field("nonce"),
1603 field("cnonce"),
1604 field("nc"),
1605 field("response"),
1606 );
1607 let nc = format!("{nc:08x}");
1608 let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1609 let ha2 = md5_hex(format!("GET:{uri}"));
1610 let resp = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
1611 header
1612 .replace(&format!("nc={old_nc}"), &format!("nc={nc}"))
1613 .replace(&old_resp, &resp)
1614 }
1615
1616 #[test]
1620 fn basic_scheme_is_matched_case_insensitively() {
1621 let v = Verifier::new(
1622 Credentials::Basic {
1623 username: "admin".into(),
1624 password: "12345".into(),
1625 },
1626 REALM,
1627 );
1628 let header = respond(
1629 &v.challenge(),
1630 &RequestContext::new("GET", "/stream"),
1631 Credentials::new("admin", "12345"),
1632 )
1633 .unwrap();
1634 assert!(header.starts_with("Basic "));
1635 let lower = header.replacen("Basic ", "basic ", 1);
1636 assert_eq!(
1637 verify_auth(&v, Some(&lower), "GET", "/stream"),
1638 AuthResult::Ok,
1639 "lower-case scheme token must verify identically to the canonical case"
1640 );
1641 }
1642
1643 #[test]
1644 fn bearer_scheme_is_matched_case_insensitively() {
1645 let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
1646 let header = respond(
1647 &v.challenge(),
1648 &RequestContext::new("GET", "/stream"),
1649 Credentials::bearer("mytoken123"),
1650 )
1651 .unwrap();
1652 assert!(header.starts_with("Bearer "));
1653 let mixed = header.replacen("Bearer ", "BEARER ", 1);
1654 assert_eq!(
1655 verify_auth(&v, Some(&mixed), "GET", "/stream"),
1656 AuthResult::Ok,
1657 "mixed-case scheme token must verify identically to the canonical case"
1658 );
1659 }
1660
1661 #[test]
1662 fn digest_scheme_is_matched_case_insensitively() {
1663 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1664 let v = digest_verifier_at(now);
1665 let uri = "/stream/index.m3u8";
1666 let header = digest_header(&v.challenge(), uri);
1667 assert!(header.starts_with("Digest "));
1668 let lower = header.replacen("Digest ", "digest ", 1);
1669 assert_eq!(
1670 verify_auth(&v, Some(&lower), "GET", uri),
1671 AuthResult::Ok,
1672 "lower-case scheme token must verify identically to the canonical case"
1673 );
1674 }
1675
1676 #[test]
1685 fn digest_field_parser_respects_quoted_commas() {
1686 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1687 let v = digest_verifier_at(now);
1688 let uri = "/stream/a,b/index.m3u8";
1689 let header = digest_header(&v.challenge(), uri);
1690 assert!(header.contains(&format!("uri=\"{uri}\"")));
1691 assert_eq!(
1692 verify_auth(&v, Some(&header), "GET", uri),
1693 AuthResult::Ok,
1694 "a comma inside the quoted uri field must not fragment the field list"
1695 );
1696 }
1697
1698 #[test]
1707 fn digest_rejects_unsupported_algorithm_label() {
1708 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1709 let v = digest_verifier_at(now);
1710 let uri = "/stream/index.m3u8";
1711 let header = digest_header(&v.challenge(), uri);
1712 assert!(header.contains("algorithm=MD5"));
1713 let relabeled = header.replacen("algorithm=MD5", "algorithm=SHA-256", 1);
1714 assert_eq!(
1715 verify_auth(&v, Some(&relabeled), "GET", uri),
1716 AuthResult::Unauthorized,
1717 "a header claiming an unimplemented algorithm must not verify"
1718 );
1719 }
1720
1721 #[test]
1731 fn digest_rejects_non_auth_qop() {
1732 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1733 let v = digest_verifier_at(now);
1734 let uri = "/stream/index.m3u8";
1735 let header = digest_header(&v.challenge(), uri);
1736 let field = |name: &str| {
1737 let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
1738 let rest = &header[start..];
1739 let rest = rest.trim_start_matches('"');
1740 let end = rest.find(['"', ',']).unwrap_or(rest.len());
1741 rest[..end].to_string()
1742 };
1743 let (nonce, cnonce, nc, response) = (
1744 field("nonce"),
1745 field("cnonce"),
1746 field("nc"),
1747 field("response"),
1748 );
1749 let ha1 = md5_hex(format!("admin:{REALM}:12345"));
1750 let ha2 = md5_hex(format!("GET:{uri}"));
1751 let recomputed = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}"));
1752 let relabeled = header
1753 .replacen("qop=auth", "qop=auth-int", 1)
1754 .replace(&response, &recomputed);
1755 assert_eq!(
1756 verify_auth(&v, Some(&relabeled), "GET", uri),
1757 AuthResult::Unauthorized,
1758 "a header claiming qop=auth-int must not verify even when response \
1759 was (mis)computed with the qop=auth formula"
1760 );
1761 }
1762
1763 #[test]
1764 fn digest_increasing_nc_is_accepted_and_repeated_nc_rejected() {
1765 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1766 let v = digest_verifier_at(now);
1767 let uri = "/stream/index.m3u8";
1768 let first = digest_header(&v.challenge(), uri);
1769 assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1770 let second = with_nc(&first, 2, uri);
1771 assert_eq!(verify_auth(&v, Some(&second), "GET", uri), AuthResult::Ok);
1772 let third = with_nc(&first, 3, uri);
1773 assert_eq!(verify_auth(&v, Some(&third), "GET", uri), AuthResult::Ok);
1774 assert_eq!(
1775 verify_auth(&v, Some(&second), "GET", uri),
1776 AuthResult::Unauthorized,
1777 "an nc already accepted must be rejected"
1778 );
1779 }
1780
1781 #[test]
1782 fn digest_expired_nonce_is_rejected_with_stale_challenge() {
1783 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1784 let v = digest_verifier_at(now.clone());
1785 let uri = "/stream/index.m3u8";
1786 let header = digest_header(&v.challenge(), uri);
1787 now.fetch_add(
1788 DIGEST_NONCE_LIFETIME.as_secs(),
1789 std::sync::atomic::Ordering::SeqCst,
1790 );
1791 assert_eq!(
1792 verify_auth(&v, Some(&header), "GET", uri),
1793 AuthResult::Unauthorized
1794 );
1795 let headers = [("authorization", header.as_str())];
1796 let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1797 let challenge = v.challenge_for(&ctx);
1798 assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1799 let retry = digest_header(&challenge, uri);
1801 assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1802 let wrong = respond(
1804 &v.challenge(),
1805 &RequestContext::new("GET", uri),
1806 Credentials::new("admin", "WRONG"),
1807 )
1808 .unwrap();
1809 now.fetch_add(
1810 DIGEST_NONCE_LIFETIME.as_secs(),
1811 std::sync::atomic::Ordering::SeqCst,
1812 );
1813 let headers = [("authorization", wrong.as_str())];
1814 let ctx = RequestContext::new("GET", uri).with_headers(&headers);
1815 assert!(!v.challenge_for(&ctx).contains("stale"));
1816 }
1817
1818 #[test]
1819 fn digest_nonce_with_wrong_mac_is_rejected() {
1820 let v = Verifier::new(
1821 Credentials::Digest {
1822 username: "admin".into(),
1823 password: "12345".into(),
1824 },
1825 REALM,
1826 );
1827 let uri = "/stream/index.m3u8";
1828 let challenge = v.challenge();
1829 let start = challenge.find("nonce=\"").unwrap() + "nonce=\"".len();
1830 let nonce = &challenge[start..start + NONCE_LEN * 2];
1831 let last = nonce.as_bytes()[nonce.len() - 1];
1833 let flipped = if last == b'0' { '1' } else { '0' };
1834 let forged_nonce = format!("{}{flipped}", &nonce[..nonce.len() - 1]);
1835 let forged = challenge.replace(nonce, &forged_nonce);
1836 let header = digest_header(&forged, uri);
1837 assert!(header.contains(&forged_nonce));
1838 assert_eq!(
1839 verify_auth(&v, Some(&header), "GET", uri),
1840 AuthResult::Unauthorized
1841 );
1842 let other = Verifier::new(
1844 Credentials::Digest {
1845 username: "admin".into(),
1846 password: "12345".into(),
1847 },
1848 REALM,
1849 );
1850 let header = digest_header(&other.challenge(), uri);
1851 assert_eq!(
1852 verify_auth(&v, Some(&header), "GET", uri),
1853 AuthResult::Unauthorized
1854 );
1855 }
1856
1857 #[test]
1858 fn digest_out_of_order_nc_within_window_is_accepted_once() {
1859 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1860 let v = digest_verifier_at(now);
1861 let uri = "/stream/part.m4s";
1862 let first = digest_header(&v.challenge(), uri);
1863 assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
1864 let nc = |n| with_nc(&first, n, uri);
1865 assert_eq!(verify_auth(&v, Some(&nc(6)), "GET", uri), AuthResult::Ok);
1867 assert_eq!(verify_auth(&v, Some(&nc(5)), "GET", uri), AuthResult::Ok);
1868 assert_eq!(
1869 verify_auth(&v, Some(&nc(5)), "GET", uri),
1870 AuthResult::Unauthorized,
1871 "nc 5 a second time is a replay"
1872 );
1873 assert_eq!(
1874 verify_auth(&v, Some(&nc(6)), "GET", uri),
1875 AuthResult::Unauthorized
1876 );
1877 let top = 200;
1880 assert_eq!(verify_auth(&v, Some(&nc(top)), "GET", uri), AuthResult::Ok);
1881 assert_eq!(
1882 verify_auth(&v, Some(&nc(top - NC_WINDOW - 1)), "GET", uri),
1883 AuthResult::Unauthorized,
1884 "an nc 65 below the highest is outside the window"
1885 );
1886 assert_eq!(
1887 verify_auth(&v, Some(&nc(top - NC_WINDOW)), "GET", uri),
1888 AuthResult::Ok
1889 );
1890 }
1891
1892 #[test]
1893 fn digest_nc_table_evicts_least_recently_used_pair() {
1894 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
1895 let v = digest_verifier_at(now).with_digest_nc_capacity(4);
1896 let uri = "/s";
1897 let headers: Vec<String> = (0..4).map(|_| digest_header(&v.challenge(), uri)).collect();
1898 for h in &headers {
1899 assert_eq!(verify_auth(&v, Some(h), "GET", uri), AuthResult::Ok);
1900 }
1901 assert_eq!(
1903 verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1904 AuthResult::Ok
1905 );
1906 let newcomer = digest_header(&v.challenge(), uri);
1907 assert_eq!(verify_auth(&v, Some(&newcomer), "GET", uri), AuthResult::Ok);
1908 let VerifierScheme::Digest { nonces, .. } = &v.scheme else {
1909 unreachable!()
1910 };
1911 assert_eq!(nonces.table().entries.len(), 4);
1912 assert_eq!(
1914 verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
1915 AuthResult::Unauthorized
1916 );
1917 assert_eq!(
1918 verify_auth(&v, Some(&with_nc(&headers[0], 3, uri)), "GET", uri),
1919 AuthResult::Ok
1920 );
1921 let replay = with_nc(&headers[1], 2, uri);
1924 assert_eq!(
1925 verify_auth(&v, Some(&replay), "GET", uri),
1926 AuthResult::Unauthorized
1927 );
1928 let hdrs = [("authorization", replay.as_str())];
1929 let ctx = RequestContext::new("GET", uri).with_headers(&hdrs);
1930 let challenge = v.challenge_for(&ctx);
1931 assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
1932 let retry = digest_header(&challenge, uri);
1933 assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
1934 }
1935
1936 #[test]
1937 fn digest_clock_stepping_back_does_not_backdate_nonces() {
1938 let start = 1_000_000;
1939 let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(start));
1940 let v = digest_verifier_at(now.clone());
1941 let uri = "/s";
1942 let before = digest_header(&v.challenge(), uri);
1943 now.store(
1945 start - 2 * DIGEST_NONCE_LIFETIME.as_secs(),
1946 std::sync::atomic::Ordering::SeqCst,
1947 );
1948 let during = digest_header(&v.challenge(), uri);
1949 assert_eq!(verify_auth(&v, Some(&during), "GET", uri), AuthResult::Ok);
1950 assert_eq!(verify_auth(&v, Some(&before), "GET", uri), AuthResult::Ok);
1951 now.store(
1953 start + DIGEST_NONCE_LIFETIME.as_secs() - 1,
1954 std::sync::atomic::Ordering::SeqCst,
1955 );
1956 let again = with_nc(&during, 2, uri);
1957 assert_eq!(verify_auth(&v, Some(&again), "GET", uri), AuthResult::Ok);
1958 }
1959
1960 #[test]
1961 fn digest_replayed_request_is_rejected() {
1962 let v = Verifier::new(
1963 Credentials::Digest {
1964 username: "admin".into(),
1965 password: "12345".into(),
1966 },
1967 REALM,
1968 );
1969 let ctx = RequestContext::new("GET", "/stream/index.m3u8");
1970 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
1971 assert_eq!(
1972 verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1973 AuthResult::Ok
1974 );
1975 assert_eq!(
1976 verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
1977 AuthResult::Unauthorized,
1978 "same nonce + nc + cnonce must not verify twice"
1979 );
1980 }
1981
1982 #[test]
1983 fn debug_never_leaks_password_or_token() {
1984 let v = Verifier::new(
1985 Credentials::Digest {
1986 username: "admin".into(),
1987 password: "supersecret".into(),
1988 },
1989 REALM,
1990 );
1991 let debug = format!("{v:?}");
1992 assert!(!debug.contains("supersecret"), "debug: {debug}");
1993
1994 let v = Verifier::new(Credentials::bearer("topsecrettoken"), REALM);
1995 let debug = format!("{v:?}");
1996 assert!(!debug.contains("topsecrettoken"), "debug: {debug}");
1997 }
1998
1999 #[test]
2001 fn digest_md5_matches_rfc2617_worked_example() {
2002 use md5::{Digest as _, Md5};
2003 let ha1 = hex_of(Md5::digest(b"Mufasa:testrealm@host.com:Circle Of Life"));
2004 let ha2 = hex_of(Md5::digest(b"GET:/dir/index.html"));
2005 let resp = hex_of(Md5::digest(
2006 format!("{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}")
2007 .as_bytes(),
2008 ));
2009 assert_eq!(resp, "6629fae49393a05397450978507c4ef1");
2010 }
2011
2012 fn hex_of(bytes: impl AsRef<[u8]>) -> String {
2013 bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
2014 }
2015
2016 #[test]
2018 fn basic_credentials_base64_matches_rfc7617_example() {
2019 use base64::Engine as _;
2020 let enc = base64::engine::general_purpose::STANDARD.encode(b"Aladdin:open sesame");
2021 assert_eq!(enc, "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
2022 let dec = base64::engine::general_purpose::STANDARD
2023 .decode("QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
2024 .unwrap();
2025 assert_eq!(dec, b"Aladdin:open sesame");
2026 }
2027
2028 #[test]
2035 fn production_digest_chain_matches_rfc2617_and_rejects_altered() {
2036 let ha1 = md5_hex("Mufasa:testrealm@host.com:Circle Of Life".to_string());
2037 let ha2 = md5_hex("GET:/dir/index.html".to_string());
2038 let expected = md5_hex(format!(
2039 "{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}"
2040 ));
2041 let rfc = "6629fae49393a05397450978507c4ef1";
2042 assert!(constant_time_eq(expected.as_bytes(), rfc.as_bytes()));
2043 let altered = "6629fae49393a05397450978507c4ef2";
2045 assert!(!constant_time_eq(expected.as_bytes(), altered.as_bytes()));
2046 }
2047
2048 #[test]
2051 fn production_basic_verify_matches_rfc7617_and_rejects_altered() {
2052 let ok = "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2053 assert!(verify_basic(ok, "Aladdin", "open sesame"));
2054 let altered = "Basic RWxhZGRpbjpvcGVuIHNlc2FtZQ==";
2056 assert!(!verify_basic(altered, "Aladdin", "open sesame"));
2057 assert!(!verify_basic(ok, "Aladdin", "open sesamf"));
2058 }
2059}