1use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use sipx_sip::{HeaderName, Request};
14
15use crate::auth::{Algorithm, Credentials, respond};
16
17pub const DEFAULT_LIFETIME: Duration = Duration::from_secs(300);
23
24const REPLAY_CAPACITY: usize = 4096;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Presented {
37 pub username: String,
39 pub realm: String,
41 pub nonce: String,
43 pub uri: String,
45 pub response: String,
47 pub algorithm: Algorithm,
49 pub nonce_count: Option<u32>,
51 pub cnonce: Option<String>,
53 pub qop_auth: bool,
55}
56
57impl Presented {
58 #[must_use]
65 pub fn from_request(request: &Request, proxy: bool) -> Option<Self> {
66 let header = if proxy {
67 HeaderName::ProxyAuthorization
68 } else {
69 HeaderName::Authorization
70 };
71 let value = request.headers.value(&header)?;
72 Self::parse(&value)
73 }
74
75 #[must_use]
77 pub fn parse(value: &[u8]) -> Option<Self> {
78 let text = String::from_utf8_lossy(value);
79 let rest = text.trim().strip_prefix("Digest")?.trim_start();
80 let param = |name: &str| parameter(rest, name);
81
82 let qop = param("qop");
83 Some(Self {
84 username: param("username")?,
85 realm: param("realm").unwrap_or_default(),
86 nonce: param("nonce")?,
87 uri: param("uri").unwrap_or_default(),
88 response: param("response")?,
89 algorithm: param("algorithm")
90 .as_deref()
91 .and_then(Algorithm::parse)
92 .unwrap_or(Algorithm::Md5),
94 nonce_count: param("nc").and_then(|nc| u32::from_str_radix(&nc, 16).ok()),
95 cnonce: param("cnonce"),
96 qop_auth: qop.as_deref() == Some("auth"),
97 })
98 }
99}
100
101fn parameter(input: &str, name: &str) -> Option<String> {
103 let mut rest = input;
104 while !rest.is_empty() {
105 let rest_trimmed = rest.trim_start_matches([' ', '\t', ',']);
106 let (key, after) = rest_trimmed.split_once('=')?;
107 let key = key.trim();
108 let after = after.trim_start();
109 let (value, remainder) = if let Some(quoted) = after.strip_prefix('"') {
110 let mut value = String::new();
113 let mut chars = quoted.char_indices();
114 let mut end = None;
115 while let Some((index, character)) = chars.next() {
116 match character {
117 '\\' => {
118 if let Some((_, escaped)) = chars.next() {
119 value.push(escaped);
120 }
121 }
122 '"' => {
123 end = Some(index + 1);
124 break;
125 }
126 other => value.push(other),
127 }
128 }
129 (value, quoted.get(end?..).unwrap_or_default())
130 } else {
131 let end = after.find(',').unwrap_or(after.len());
132 (
133 after.get(..end).unwrap_or_default().trim().to_owned(),
134 after.get(end..).unwrap_or_default(),
135 )
136 };
137 if key.eq_ignore_ascii_case(name) {
138 return Some(value);
139 }
140 rest = remainder;
141 }
142 None
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum Verdict {
148 Authenticated,
150 Stale,
156 Rejected(Reason),
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Reason {
163 Mismatch,
168 ForeignNonce,
170 Replay,
172 QopMismatch,
174 Algorithm,
176}
177
178#[derive(Debug)]
184pub struct Authenticator {
185 realm: String,
186 secret: [u8; 32],
187 lifetime: Duration,
188 algorithm: Algorithm,
189 seen: std::collections::VecDeque<(String, u32, String)>,
195}
196
197impl Authenticator {
198 #[must_use]
204 pub fn new(realm: impl Into<String>, secret: [u8; 32]) -> Self {
205 Self {
206 realm: realm.into(),
207 secret,
208 lifetime: DEFAULT_LIFETIME,
209 algorithm: Algorithm::Sha256,
210 seen: std::collections::VecDeque::new(),
211 }
212 }
213
214 #[must_use]
219 pub fn with_random_secret(realm: impl Into<String>) -> Self {
220 use rand::Rng as _;
221 let mut secret = [0u8; 32];
222 rand::rng().fill(&mut secret);
223 Self::new(realm, secret)
224 }
225
226 #[must_use]
232 pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
233 self.algorithm = algorithm;
234 self
235 }
236
237 #[must_use]
239 pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
240 self.lifetime = lifetime;
241 self
242 }
243
244 #[must_use]
246 pub fn realm(&self) -> &str {
247 &self.realm
248 }
249
250 #[must_use]
252 pub fn challenge_header(proxy: bool) -> HeaderName {
253 if proxy {
254 HeaderName::ProxyAuthenticate
255 } else {
256 HeaderName::WwwAuthenticate
257 }
258 }
259
260 #[must_use]
264 pub fn challenge(&self, stale: bool) -> String {
265 self.challenge_at(stale, now())
266 }
267
268 #[must_use]
270 pub fn challenge_at(&self, stale: bool, now: u64) -> String {
271 let nonce = self.mint(now);
272 let mut header = format!(
273 r#"Digest realm="{}", nonce="{nonce}", qop="auth", algorithm={}"#,
274 self.realm.replace('\\', r"\\").replace('"', "\\\""),
275 self.algorithm.as_str()
276 );
277 if stale {
278 header.push_str(", stale=true");
279 }
280 header
281 }
282
283 fn mint(&self, now: u64) -> String {
288 let issued = format!("{now:016x}");
289 format!("{issued}.{}", self.mac(&issued))
290 }
291
292 fn mac(&self, issued: &str) -> String {
295 use hmac::{Hmac, Mac};
296 use sha2::Sha256;
297 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&self.secret)
298 .unwrap_or_else(|_| unreachable!("HMAC accepts a key of any length"));
299 mac.update(issued.as_bytes());
300 mac.update(b":");
301 mac.update(self.realm.as_bytes());
302 let bytes = mac.finalize().into_bytes();
303 bytes.iter().fold(String::new(), |mut out, byte| {
304 use std::fmt::Write as _;
305 let _ = write!(out, "{byte:02x}");
306 out
307 })
308 }
309
310 fn issued_at(&self, nonce: &str) -> Option<u64> {
316 use subtle::ConstantTimeEq as _;
317 let (issued, mac) = nonce.split_once('.')?;
318 let expected = self.mac(issued);
319 let matches: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into();
320 matches.then(|| u64::from_str_radix(issued, 16).ok())?
321 }
322
323 pub fn verify(&mut self, presented: &Presented, method: &str, password: &str) -> Verdict {
329 self.verify_at(presented, method, password, now())
330 }
331
332 pub fn verify_at(
334 &mut self,
335 presented: &Presented,
336 method: &str,
337 password: &str,
338 now: u64,
339 ) -> Verdict {
340 if presented.algorithm != self.algorithm {
341 return Verdict::Rejected(Reason::Algorithm);
342 }
343 if !presented.qop_auth || presented.nonce_count.is_none() || presented.cnonce.is_none() {
347 return Verdict::Rejected(Reason::QopMismatch);
348 }
349 let Some(issued) = self.issued_at(&presented.nonce) else {
350 return Verdict::Rejected(Reason::ForeignNonce);
351 };
352
353 let expected = respond(
357 &self.as_challenge(&presented.nonce),
358 &Credentials::new(presented.username.clone(), password.to_owned()),
359 method,
360 &presented.uri,
361 presented.nonce_count.unwrap_or(1),
362 presented.cnonce.as_deref().unwrap_or_default(),
363 );
364 let Some(computed) = parameter(&expected, "response") else {
365 return Verdict::Rejected(Reason::Mismatch);
366 };
367 if !constant_time_eq(computed.as_bytes(), presented.response.as_bytes()) {
368 return Verdict::Rejected(Reason::Mismatch);
369 }
370
371 if now.saturating_sub(issued) > self.lifetime.as_secs() {
372 return Verdict::Stale;
373 }
374
375 match self.record(presented) {
376 Ok(()) => Verdict::Authenticated,
377 Err(reason) => Verdict::Rejected(reason),
378 }
379 }
380
381 fn record(&mut self, presented: &Presented) -> Result<(), Reason> {
389 let count = presented.nonce_count.unwrap_or(1);
390 if let Some(entry) = self
391 .seen
392 .iter_mut()
393 .find(|(nonce, _, _)| nonce == &presented.nonce)
394 {
395 if count > entry.1 {
396 entry.1 = count;
397 entry.2.clone_from(&presented.response);
398 return Ok(());
399 }
400 if count == entry.1 && entry.2 == presented.response {
401 return Ok(());
402 }
403 return Err(Reason::Replay);
404 }
405 if self.seen.len() >= REPLAY_CAPACITY {
409 self.seen.pop_front();
410 }
411 self.seen
412 .push_back((presented.nonce.clone(), count, presented.response.clone()));
413 Ok(())
414 }
415
416 fn as_challenge(&self, nonce: &str) -> crate::auth::Challenge {
422 crate::auth::Challenge {
423 realm: self.realm.clone(),
424 nonce: nonce.to_owned(),
425 opaque: None,
426 algorithm: self.algorithm,
427 qop_auth: true,
428 stale: false,
429 from_proxy: false,
430 }
431 }
432}
433
434fn constant_time_eq(one: &[u8], other: &[u8]) -> bool {
435 use subtle::ConstantTimeEq as _;
436 one.len() == other.len() && one.ct_eq(other).into()
437}
438
439fn now() -> u64 {
441 SystemTime::now()
442 .duration_since(UNIX_EPOCH)
443 .map_or(0, |since| since.as_secs())
444}
445
446#[cfg(test)]
447#[allow(
448 clippy::unwrap_used,
449 clippy::expect_used,
450 clippy::panic,
451 clippy::indexing_slicing
452)]
453mod tests {
454 use super::*;
455
456 const NOW: u64 = 1_700_000_000;
457 const PASSWORD: &str = "Circle Of Life";
458
459 fn server() -> Authenticator {
460 Authenticator::new("sipx.test", [7u8; 32])
461 }
462
463 fn answer(
465 server: &Authenticator,
466 challenge: &str,
467 method: &str,
468 nc: u32,
469 cnonce: &str,
470 ) -> Presented {
471 let parsed = crate::auth::Challenge::parse(challenge.as_bytes(), false).expect("parses");
472 let header = respond(
473 &parsed,
474 &Credentials::new("alice", PASSWORD),
475 method,
476 "sip:sipx.test",
477 nc,
478 cnonce,
479 );
480 let _ = server;
481 Presented::parse(header.as_bytes()).expect("parses")
482 }
483
484 #[test]
485 fn a_challenge_carries_a_realm_a_nonce_and_qop() {
486 let value = server().challenge_at(false, NOW);
487 assert!(value.starts_with("Digest "), "{value}");
488 assert!(value.contains(r#"realm="sipx.test""#), "{value}");
489 assert!(value.contains(r#"qop="auth""#), "{value}");
490 assert!(value.contains("algorithm=SHA-256"), "{value}");
491 assert!(!value.contains("stale"), "a fresh challenge is not stale");
492 assert!(
493 server().challenge_at(true, NOW).contains("stale=true"),
494 "a stale challenge says so"
495 );
496 }
497
498 #[test]
499 fn a_server_recognises_its_own_nonce_and_not_anyone_elses() {
500 let server = server();
501 let nonce = server.mint(NOW);
502 assert_eq!(server.issued_at(&nonce), Some(NOW));
503
504 let other = Authenticator::new("sipx.test", [9u8; 32]);
506 assert_eq!(other.issued_at(&nonce), None, "a foreign nonce");
507
508 let other_realm = Authenticator::new("elsewhere.test", [7u8; 32]);
511 assert_eq!(other_realm.issued_at(&nonce), None);
512
513 let mut tampered = nonce.clone();
515 tampered.replace_range(0..1, "f");
516 assert_eq!(server.issued_at(&tampered), None);
517 assert_eq!(server.issued_at("not-a-nonce"), None);
518 assert_eq!(server.issued_at(""), None);
519 }
520
521 #[test]
522 fn correct_credentials_authenticate() {
523 let mut server = server();
524 let challenge = server.challenge_at(false, NOW);
525 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
526 assert_eq!(
527 server.verify_at(&presented, "REGISTER", PASSWORD, NOW),
528 Verdict::Authenticated
529 );
530 }
531
532 #[test]
533 fn a_wrong_password_is_rejected() {
534 let mut server = server();
535 let challenge = server.challenge_at(false, NOW);
536 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
537 assert_eq!(
538 server.verify_at(&presented, "REGISTER", "the wrong one", NOW),
539 Verdict::Rejected(Reason::Mismatch)
540 );
541 }
542
543 #[test]
546 fn credentials_for_one_method_do_not_authenticate_another() {
547 let mut server = server();
548 let challenge = server.challenge_at(false, NOW);
549 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
550 assert_eq!(
551 server.verify_at(&presented, "INVITE", PASSWORD, NOW),
552 Verdict::Rejected(Reason::Mismatch)
553 );
554 }
555
556 #[test]
562 fn a_replayed_nonce_count_is_rejected_but_a_retransmission_is_not() {
563 let mut server = server();
564 let challenge = server.challenge_at(false, NOW);
565 let first = answer(&server, &challenge, "REGISTER", 1, "abc");
566
567 assert_eq!(
568 server.verify_at(&first, "REGISTER", PASSWORD, NOW),
569 Verdict::Authenticated
570 );
571
572 assert_eq!(
574 server.verify_at(&first, "REGISTER", PASSWORD, NOW),
575 Verdict::Authenticated,
576 "a retransmission must still authenticate, or a duplicated packet fails a login"
577 );
578
579 let replayed = answer(&server, &challenge, "INVITE", 1, "abc");
581 assert_eq!(
582 server.verify_at(&replayed, "INVITE", PASSWORD, NOW),
583 Verdict::Rejected(Reason::Replay),
584 "the same count with a different request is somebody reusing a captured credential"
585 );
586
587 let next = answer(&server, &challenge, "REGISTER", 2, "abc");
589 assert_eq!(
590 server.verify_at(&next, "REGISTER", PASSWORD, NOW),
591 Verdict::Authenticated
592 );
593
594 let backwards = answer(&server, &challenge, "REGISTER", 1, "abc");
596 assert_eq!(
597 server.verify_at(&backwards, "REGISTER", PASSWORD, NOW),
598 Verdict::Rejected(Reason::Replay)
599 );
600 }
601
602 #[test]
605 fn an_expired_nonce_with_correct_credentials_is_stale_rather_than_rejected() {
606 let mut server = server().with_lifetime(Duration::from_secs(60));
607 let challenge = server.challenge_at(false, NOW);
608 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
609 assert_eq!(
610 server.verify_at(&presented, "REGISTER", PASSWORD, NOW + 61),
611 Verdict::Stale
612 );
613 }
614
615 #[test]
618 fn an_expired_nonce_with_wrong_credentials_is_a_rejection_not_a_stale() {
619 let mut server = server().with_lifetime(Duration::from_secs(60));
620 let challenge = server.challenge_at(false, NOW);
621 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
622 assert_eq!(
623 server.verify_at(&presented, "REGISTER", "wrong", NOW + 61),
624 Verdict::Rejected(Reason::Mismatch)
625 );
626 }
627
628 #[test]
629 fn a_nonce_this_server_did_not_issue_is_refused() {
630 let mut server = server();
631 let elsewhere = Authenticator::new("sipx.test", [1u8; 32]);
632 let challenge = elsewhere.challenge_at(false, NOW);
633 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
634 assert_eq!(
635 server.verify_at(&presented, "REGISTER", PASSWORD, NOW),
636 Verdict::Rejected(Reason::ForeignNonce)
637 );
638 }
639
640 #[test]
641 fn credentials_without_qop_are_refused_because_the_challenge_required_it() {
642 let mut server = server();
643 let nonce = server.mint(NOW);
644 let presented = Presented {
645 username: "alice".to_owned(),
646 realm: "sipx.test".to_owned(),
647 nonce,
648 uri: "sip:sipx.test".to_owned(),
649 response: "0".repeat(64),
650 algorithm: Algorithm::Sha256,
651 nonce_count: None,
652 cnonce: None,
653 qop_auth: false,
654 };
655 assert_eq!(
656 server.verify_at(&presented, "REGISTER", PASSWORD, NOW),
657 Verdict::Rejected(Reason::QopMismatch),
658 "the RFC 2069 formula has no client nonce in it, which is the replay protection"
659 );
660 }
661
662 #[test]
663 fn an_algorithm_this_server_did_not_offer_is_refused() {
664 let mut server = server(); let nonce = server.mint(NOW);
666 let presented = Presented {
667 username: "alice".to_owned(),
668 realm: "sipx.test".to_owned(),
669 nonce,
670 uri: "sip:sipx.test".to_owned(),
671 response: "0".repeat(32),
672 algorithm: Algorithm::Md5,
673 nonce_count: Some(1),
674 cnonce: Some("abc".to_owned()),
675 qop_auth: true,
676 };
677 assert_eq!(
678 server.verify_at(&presented, "REGISTER", PASSWORD, NOW),
679 Verdict::Rejected(Reason::Algorithm)
680 );
681 }
682
683 #[test]
684 fn the_replay_window_does_not_grow_without_bound() {
685 let mut server = server();
686 for index in 0..(REPLAY_CAPACITY + 100) {
687 let challenge = server.challenge_at(false, NOW + index as u64);
689 let presented = answer(&server, &challenge, "REGISTER", 1, "abc");
690 let _ = server.verify_at(&presented, "REGISTER", PASSWORD, NOW + index as u64);
691 }
692 assert!(
693 server.seen.len() <= REPLAY_CAPACITY,
694 "the window held {} entries",
695 server.seen.len()
696 );
697 }
698
699 #[test]
702 fn a_quoted_value_containing_a_comma_or_a_quote_does_not_shift_the_rest() {
703 let value = br#"Digest username="al,ice", realm="a\"b", nonce="n", uri="sip:x", response="deadbeef", qop=auth, nc=00000002, cnonce="c""#;
704 let presented = Presented::parse(value).expect("parses");
705 assert_eq!(presented.username, "al,ice");
706 assert_eq!(presented.realm, "a\"b");
707 assert_eq!(presented.response, "deadbeef");
708 assert_eq!(presented.nonce_count, Some(2));
709 assert_eq!(presented.cnonce.as_deref(), Some("c"));
710 assert!(presented.qop_auth);
711 }
712
713 #[test]
714 fn credentials_without_a_username_or_a_response_are_not_credentials() {
715 assert!(Presented::parse(b"Digest realm=\"a\", nonce=\"n\"").is_none());
716 assert!(Presented::parse(b"Basic dXNlcjpwYXNz").is_none());
717 assert!(Presented::parse(b"").is_none());
718 }
719}