ezk_sip_auth/
digest.rs

1use crate::{ClientAuthenticator, RequestParts, ResponseParts};
2use bytesstr::BytesStr;
3use sha2::Digest;
4use sip_types::header::typed::{
5    Algorithm, AlgorithmValue, AuthChallenge, DigestChallenge, DigestResponse, QopOption,
6    QopResponse, Username,
7};
8use sip_types::header::HeaderError;
9use sip_types::print::{AppendCtx, PrintCtx, UriContext};
10use sip_types::{Headers, Name};
11use std::collections::HashMap;
12
13#[derive(Debug, thiserror::Error)]
14pub enum DigestError {
15    #[error("failed to authenticate realms: {0:?}")]
16    FailedToAuthenticate(Vec<BytesStr>),
17    #[error("encountered unsupported algorithm {0}")]
18    UnsupportedAlgorithm(BytesStr),
19    #[error("missing credentials for realm {0}")]
20    MissingCredentials(BytesStr),
21    #[error("unsupported qop")]
22    UnsupportedQop,
23    #[error(transparent)]
24    Header(HeaderError),
25}
26
27/// A HashMap wrapper that holds credentials mapped to their respective realm
28///
29/// Default credentials can be set to attempt authentication for unknown realms
30#[derive(Default, Clone)]
31pub struct DigestCredentials {
32    default: Option<DigestUser>,
33    map: HashMap<String, DigestUser>,
34}
35
36impl DigestCredentials {
37    pub fn new() -> Self {
38        Self {
39            default: None,
40            map: HashMap::new(),
41        }
42    }
43
44    /// Set default `credentials` to authenticate on unknown realms
45    pub fn set_default(&mut self, credentials: DigestUser) {
46        self.default = Some(credentials)
47    }
48
49    /// Add `credentials` that will be used when authenticating for `realm`
50    pub fn add_for_realm<R>(&mut self, realm: R, credentials: DigestUser)
51    where
52        R: Into<String>,
53    {
54        self.map.insert(realm.into(), credentials);
55    }
56
57    /// Get credentials for the specified `realm`
58    ///
59    /// Returns the default credentials when no credentials where set for the
60    /// requested `realm`
61    pub fn get_for_realm(&self, realm: &str) -> Option<&DigestUser> {
62        self.map.get(realm).or(self.default.as_ref())
63    }
64
65    /// Remove credentials for the specified `realm`
66    pub fn remove_for_realm(&mut self, realm: &str) {
67        self.map.remove(realm);
68    }
69}
70
71#[derive(Clone)]
72pub struct DigestUser {
73    user: String,
74    password: Vec<u8>,
75}
76
77impl DigestUser {
78    pub fn new<U, P>(user: U, password: P) -> Self
79    where
80        U: Into<String>,
81        P: Into<Vec<u8>>,
82    {
83        Self {
84            user: user.into(),
85            password: password.into(),
86        }
87    }
88}
89
90/// Used to solve Digest authenticate challenges in 401 / 407 SIP responses
91pub struct DigestAuthenticator {
92    pub credentials: DigestCredentials,
93    qop_responses: Vec<(BytesStr, QopEntry)>,
94    responses: Vec<ResponseEntry>,
95
96    /// Respond with qop `Auth` when a challenge does not contain qop field (RFC8760 Section 2.6). Is false by default
97    pub enforce_qop: bool,
98    /// Reject challenges with MD5 algorithm. Is false by default
99    pub reject_md5: bool,
100}
101
102struct QopEntry {
103    ha1: String,
104    ha2: String,
105    hash: HashFn,
106}
107
108/// Contains a list of authentication challenges that want to authenticate the same realm.
109///
110/// As each realm may only be authenticated once per request, only the topmost supported challenge will
111/// be used for authentication. (See RFC8760 Section 2.4)
112struct ChallengedRealm {
113    realm: BytesStr,
114    challenges: Vec<(bool, AuthChallenge)>,
115}
116
117/// A cached authorization response that will be used/reused to authorize a request
118pub struct ResponseEntry {
119    pub realm: BytesStr,
120    pub header: DigestResponse,
121
122    /// Number of times the response has been used in a request.
123    ///
124    /// Will be initialized at 0 and incremented each time after calling
125    /// `UacAuthenticator::on_authorize_request`.
126    pub use_count: u32,
127
128    is_proxy: bool,
129}
130
131impl ClientAuthenticator for DigestAuthenticator {
132    type Error = DigestError;
133
134    fn authorize_request(&mut self, request_headers: &mut Headers) {
135        for response in &mut self.responses {
136            let name = if response.is_proxy {
137                Name::PROXY_AUTHORIZATION
138            } else {
139                Name::AUTHORIZATION
140            };
141
142            // nc is already correct
143            if response.use_count > 0 {
144                let digest_realm = &response.header.realm;
145
146                // qop response needs its nonce-count incremented and response re-calculated
147                if let Some(qop_response) = &mut response.header.qop_response {
148                    qop_response.nc += 1;
149
150                    let (_, qop_entry) = self
151                        .qop_responses
152                        .iter_mut()
153                        .find(|(realm, _)| realm == digest_realm)
154                        .expect("qop_entry must be some");
155
156                    match qop_response.qop {
157                        QopOption::Auth | QopOption::AuthInt => {
158                            let hash = (qop_entry.hash)(
159                                format!(
160                                    "{}:{}:{:08X}:{}:auth:{}",
161                                    qop_entry.ha1,
162                                    response.header.nonce,
163                                    qop_response.nc,
164                                    qop_response.cnonce,
165                                    qop_entry.ha2
166                                )
167                                .as_bytes(),
168                            );
169
170                            response.header.response = hash.into();
171                        }
172                        QopOption::Other(_) => unreachable!(),
173                    };
174                }
175            }
176
177            response.use_count += 1;
178
179            request_headers.insert_type(name, &response.header);
180        }
181    }
182
183    fn handle_rejection(
184        &mut self,
185        rejected_request: RequestParts<'_>,
186        reject_response: ResponseParts<'_>,
187    ) -> Result<(), DigestError> {
188        let mut challenged_realms = vec![];
189
190        self.read_challenges(false, reject_response.headers, &mut challenged_realms)?;
191        self.read_challenges(true, reject_response.headers, &mut challenged_realms)?;
192
193        let mut failed_realms = vec![];
194
195        'outer: for challenged_realm in challenged_realms {
196            for (is_proxy, challenge) in challenged_realm.challenges {
197                let AuthChallenge::Digest(challenge) = challenge else {
198                    continue;
199                };
200
201                let result = self.handle_challenge(rejected_request, challenge);
202
203                let response = match result {
204                    Ok(response) => response,
205                    Err(e) => {
206                        log::warn!("failed to handle challenge {}", e);
207                        continue;
208                    }
209                };
210
211                let realm = challenged_realm.realm;
212
213                // Remove old response for the realm
214                if let Some(i) = self
215                    .responses
216                    .iter()
217                    .position(|response| response.realm == realm)
218                {
219                    self.responses.remove(i);
220                }
221
222                let entry = ResponseEntry {
223                    realm,
224                    header: response,
225                    use_count: 0,
226                    is_proxy,
227                };
228
229                self.responses.push(entry);
230
231                continue 'outer;
232            }
233
234            failed_realms.push(challenged_realm.realm);
235        }
236
237        if !failed_realms.is_empty() {
238            return Err(DigestError::FailedToAuthenticate(failed_realms));
239        }
240
241        Ok(())
242    }
243}
244
245impl DigestAuthenticator {
246    pub fn new(credentials: DigestCredentials) -> Self {
247        Self {
248            credentials,
249            qop_responses: vec![],
250            responses: vec![],
251            enforce_qop: false,
252            reject_md5: false,
253        }
254    }
255
256    /// Read all authentication headers and group them by realm
257    fn read_challenges(
258        &mut self,
259        is_proxy: bool,
260        headers: &Headers,
261        dst: &mut Vec<ChallengedRealm>,
262    ) -> Result<(), DigestError> {
263        let challenge_name = if is_proxy {
264            Name::PROXY_AUTHENTICATE
265        } else {
266            Name::WWW_AUTHENTICATE
267        };
268
269        let challenges = headers
270            .try_get::<Vec<AuthChallenge>>(challenge_name)
271            .map(|val| val.map_err(DigestError::Header))
272            .transpose()?
273            .unwrap_or_default();
274
275        for challenge in challenges {
276            let realm = match &challenge {
277                AuthChallenge::Digest(digest_challenge) => &digest_challenge.realm,
278                AuthChallenge::Other(..) => {
279                    continue;
280                }
281            };
282
283            if let Some(challenged_realm) = dst
284                .iter_mut()
285                .find(|challenged_realm| &challenged_realm.realm == realm)
286            {
287                challenged_realm.challenges.push((is_proxy, challenge));
288            } else {
289                dst.push(ChallengedRealm {
290                    realm: realm.clone(),
291                    challenges: vec![(is_proxy, challenge)],
292                });
293            }
294        }
295
296        Ok(())
297    }
298
299    fn handle_challenge(
300        &mut self,
301        request_parts: RequestParts<'_>,
302        challenge: DigestChallenge,
303    ) -> Result<DigestResponse, DigestError> {
304        // Following things can happen:
305        // - We didn't respond to this challenge yet -> authenticate
306        // - We did respond, but
307        //     - The previous response has an outdated nonce, sets stale to `true` -> authenticate with new nonce
308        //     - The new challenge has set a new nonce but hasn't set stale=true,
309        //       this is a observed behavior from other implementations and happens
310        //       when using any qop. To solve this issue, stale is ignored and the
311        //       the nonce is compared directly.
312        let previous_response = self
313            .responses
314            .iter()
315            .find(|response| response.realm == challenge.realm);
316
317        let authenticate = if let Some(previous_response) = previous_response {
318            previous_response.header.nonce != challenge.nonce
319        } else {
320            true
321        };
322
323        if authenticate {
324            self.handle_digest_challenge(challenge, request_parts)
325        } else {
326            Err(DigestError::FailedToAuthenticate(vec![challenge.realm]))
327        }
328    }
329
330    fn handle_digest_challenge(
331        &mut self,
332        digest_challenge: DigestChallenge,
333        request_parts: RequestParts<'_>,
334    ) -> Result<DigestResponse, DigestError> {
335        let algorithm_value = match digest_challenge.algorithm.clone() {
336            Algorithm::AkaNamespace((_, av)) => av,
337            Algorithm::AlgorithmValue(av) => av,
338        };
339
340        let (hash, is_session): (HashFn, bool) = match algorithm_value {
341            AlgorithmValue::MD5 => {
342                if self.reject_md5 {
343                    return Err(DigestError::UnsupportedAlgorithm(BytesStr::from_static(
344                        "MD5",
345                    )));
346                } else {
347                    (hash_md5, false)
348                }
349            }
350            AlgorithmValue::MD5Sess => {
351                if self.reject_md5 {
352                    return Err(DigestError::UnsupportedAlgorithm(BytesStr::from_static(
353                        "MD5",
354                    )));
355                } else {
356                    (hash_md5, true)
357                }
358            }
359            AlgorithmValue::SHA256 => (hash_sha256, false),
360            AlgorithmValue::SHA256Sess => (hash_sha256, true),
361            AlgorithmValue::SHA512256 => (hash_sha512_trunc256, false),
362            AlgorithmValue::SHA512256Sess => (hash_sha512_trunc256, true),
363            AlgorithmValue::Other(other) => return Err(DigestError::UnsupportedAlgorithm(other)),
364        };
365
366        let response = self.digest_respond(digest_challenge, request_parts, is_session, hash)?;
367
368        Ok(response)
369    }
370
371    fn digest_respond(
372        &mut self,
373        mut challenge: DigestChallenge,
374        request_parts: RequestParts<'_>,
375        is_session: bool,
376        hash: HashFn,
377    ) -> Result<DigestResponse, DigestError> {
378        let digest_user = self
379            .credentials
380            .get_for_realm(&challenge.realm)
381            .ok_or_else(|| DigestError::MissingCredentials(challenge.realm.clone()))?
382            .clone();
383
384        let cnonce = BytesStr::from(uuid::Uuid::new_v4().simple().to_string());
385
386        let mut ha1 = hash(
387            [
388                format!("{}:{}:", digest_user.user, challenge.realm).as_bytes(),
389                &digest_user.password,
390            ]
391            .concat()
392            .as_slice(),
393        );
394
395        if is_session {
396            ha1 = format!("{}:{}:{}", ha1, challenge.nonce, cnonce);
397        }
398
399        let ctx = PrintCtx {
400            method: Some(&request_parts.line.method),
401            uri: Some(UriContext::ReqUri),
402        };
403
404        let uri = request_parts.line.uri.print_ctx(ctx).to_string();
405
406        // enforce qop when enabled (See RFC8760 Section 2.6)
407        if challenge.qop.is_empty() && self.enforce_qop {
408            challenge.qop.push(QopOption::Auth)
409        }
410
411        let (response, qop_response) = if !challenge.qop.is_empty() {
412            if challenge.qop.contains(&QopOption::AuthInt) {
413                let ha2 = hash(
414                    format!(
415                        "{}:{}:{}",
416                        &request_parts.line.method,
417                        uri,
418                        hash(request_parts.body)
419                    )
420                    .as_bytes(),
421                );
422
423                let nc = 1;
424
425                let response = hash(
426                    format!(
427                        "{}:{}:{:08X}:{}:auth-int:{}",
428                        ha1, challenge.nonce, nc, cnonce, ha2
429                    )
430                    .as_bytes(),
431                );
432
433                self.save_qop_response(challenge.realm.clone(), ha1, ha2, hash);
434
435                let qop_response = QopResponse {
436                    qop: QopOption::AuthInt,
437                    cnonce,
438                    nc,
439                };
440
441                (response, Some(qop_response))
442            } else if challenge.qop.contains(&QopOption::Auth) {
443                let a2 = format!("{}:{}", &request_parts.line.method, uri);
444                let ha2 = hash(a2.as_bytes());
445
446                let nc = 1;
447
448                let response = hash(
449                    format!(
450                        "{}:{}:{:08X}:{}:auth:{}",
451                        ha1, challenge.nonce, nc, cnonce, ha2
452                    )
453                    .as_bytes(),
454                );
455
456                self.save_qop_response(challenge.realm.clone(), ha1, ha2, hash);
457
458                let qop_response = QopResponse {
459                    qop: QopOption::Auth,
460                    cnonce,
461                    nc,
462                };
463
464                (response, Some(qop_response))
465            } else {
466                return Err(DigestError::UnsupportedQop);
467            }
468        } else {
469            let a2 = format!("{}:{}", &request_parts.line.method, uri);
470
471            (
472                hash(format!("{}:{}:{}", ha1, challenge.nonce, hash(a2.as_bytes())).as_bytes()),
473                None,
474            )
475        };
476
477        let username = if challenge.userhash {
478            // Hash the username when the challenge sets `userhash` (RFC7616 Section 3.4.4)
479            let username_hash =
480                hash(format!("{}:{}", digest_user.user, challenge.realm).as_bytes())
481                    .as_str()
482                    .into();
483
484            Username::Username(username_hash)
485        } else {
486            Username::new(digest_user.user.as_str().into())
487        };
488
489        Ok(DigestResponse {
490            username,
491            realm: challenge.realm,
492            nonce: challenge.nonce,
493            uri: uri.into(),
494            response: response.into(),
495            algorithm: challenge.algorithm,
496            opaque: challenge.opaque,
497            qop_response,
498            userhash: challenge.userhash,
499            other: vec![],
500        })
501    }
502
503    fn save_qop_response(
504        &mut self,
505        challenge_realm: BytesStr,
506        ha1: String,
507        ha2: String,
508        hash: HashFn,
509    ) {
510        let qop_entry = QopEntry { ha1, ha2, hash };
511
512        if let Some((_, old_qop_entry)) = self
513            .qop_responses
514            .iter_mut()
515            .find(|(realm, _)| *realm == challenge_realm)
516        {
517            *old_qop_entry = qop_entry;
518        } else {
519            self.qop_responses
520                .push((challenge_realm.clone(), qop_entry))
521        }
522    }
523}
524
525fn hash_md5(i: &[u8]) -> String {
526    format!("{:x}", md5::compute(i))
527}
528
529fn hash_sha256(i: &[u8]) -> String {
530    let mut hasher = sha2::Sha256::new();
531    hasher.update(i);
532    format!("{:x}", hasher.finalize())
533}
534
535fn hash_sha512_trunc256(i: &[u8]) -> String {
536    let mut hasher = sha2::Sha512_256::new();
537    hasher.update(i);
538    format!("{:x}", hasher.finalize())
539}
540
541type HashFn = fn(&[u8]) -> String;
542
543#[cfg(test)]
544mod test {
545    use super::*;
546    use sip_types::{
547        header::typed::AuthResponse,
548        msg::{RequestLine, StatusLine},
549        uri::SipUri,
550        Headers, Method, Name, StatusCode,
551    };
552
553    fn test_authenticator() -> DigestAuthenticator {
554        let mut credentials = DigestCredentials::new();
555
556        credentials.add_for_realm("example.org", DigestUser::new("user123", "password123"));
557
558        DigestAuthenticator::new(credentials)
559    }
560
561    #[test]
562    fn digest_challenge() {
563        let mut authenticator = test_authenticator();
564
565        let mut headers = Headers::new();
566
567        headers.insert_type(
568            Name::WWW_AUTHENTICATE,
569            &AuthChallenge::Digest(DigestChallenge {
570                realm: "example.org".into(),
571                domain: None,
572                nonce: "YWmh5GFpoLjiTDCA1hTSSygkgdj99aHE".into(),
573                opaque: None,
574                stale: false,
575                algorithm: Algorithm::AlgorithmValue(AlgorithmValue::MD5),
576                qop: vec![],
577                userhash: false,
578                other: vec![],
579            }),
580        );
581
582        let line = RequestLine {
583            method: Method::REGISTER,
584            uri: "sip:example.org".parse::<SipUri>().unwrap(),
585        };
586
587        authenticator
588            .handle_rejection(
589                RequestParts {
590                    line: &line,
591                    headers: &Headers::new(),
592                    body: &[],
593                },
594                ResponseParts {
595                    line: &StatusLine {
596                        code: StatusCode::UNAUTHORIZED,
597                        reason: None,
598                    },
599                    headers: &headers,
600                    body: &[],
601                },
602            )
603            .unwrap();
604
605        let mut response_headers = Headers::new();
606        authenticator.authorize_request(&mut response_headers);
607
608        let authorization = response_headers
609            .get::<AuthResponse>(Name::AUTHORIZATION)
610            .unwrap();
611
612        match authorization {
613            AuthResponse::Digest(DigestResponse {
614                username,
615                realm,
616                nonce,
617                uri,
618                response,
619                algorithm,
620                opaque,
621                qop_response,
622                userhash,
623                other,
624            }) => {
625                assert_eq!(username, Username::Username("user123".into()));
626                assert_eq!(realm, "example.org");
627                assert_eq!(nonce, "YWmh5GFpoLjiTDCA1hTSSygkgdj99aHE");
628                assert_eq!(uri, "sip:example.org");
629                assert_eq!(response, "bc185e4893f17f12dc53153d2a62e6a6");
630                assert_eq!(algorithm, Algorithm::AlgorithmValue(AlgorithmValue::MD5));
631                assert_eq!(opaque, None);
632                assert_eq!(qop_response, None);
633                assert!(!userhash);
634                assert_eq!(other, vec![]);
635            }
636            _ => panic!(),
637        }
638    }
639
640    #[test]
641    fn digest_challenge_and_response() {
642        let mut authenticator = test_authenticator();
643
644        let mut headers = Headers::new();
645
646        headers.insert_type(
647            Name::WWW_AUTHENTICATE,
648            &AuthChallenge::Digest(DigestChallenge {
649                realm: "example.org".into(),
650                domain: None,
651                nonce: "YWmh5GFpoLjiTDCA1hTSSygkgdj99aHE".into(),
652                opaque: None,
653                stale: false,
654                algorithm: Algorithm::AlgorithmValue(AlgorithmValue::MD5),
655                qop: vec![QopOption::AuthInt],
656                userhash: false,
657                other: vec![],
658            }),
659        );
660
661        let uri: SipUri = "sip:example.org".parse().unwrap();
662
663        let line = RequestLine {
664            method: Method::REGISTER,
665            uri,
666        };
667
668        authenticator
669            .handle_rejection(
670                RequestParts {
671                    line: &line,
672                    headers: &Headers::new(),
673                    body: &[],
674                },
675                ResponseParts {
676                    line: &StatusLine {
677                        code: StatusCode::UNAUTHORIZED,
678                        reason: None,
679                    },
680                    headers: &headers,
681                    body: &[],
682                },
683            )
684            .unwrap();
685
686        let mut response_headers = Headers::new();
687        authenticator.authorize_request(&mut response_headers);
688
689        let response = response_headers
690            .get::<AuthResponse>(Name::AUTHORIZATION)
691            .unwrap();
692
693        let resp_value = match response {
694            AuthResponse::Digest(DigestResponse {
695                username,
696                realm,
697                nonce,
698                uri,
699                response, // cannot check, cnonce is random
700                algorithm,
701                opaque,
702                qop_response,
703                userhash,
704                other,
705            }) => {
706                assert_eq!(username, Username::Username("user123".into()));
707                assert_eq!(realm, "example.org");
708                assert_eq!(nonce, "YWmh5GFpoLjiTDCA1hTSSygkgdj99aHE");
709                assert_eq!(uri, "sip:example.org");
710                assert_eq!(algorithm, Algorithm::AlgorithmValue(AlgorithmValue::MD5));
711                assert_eq!(opaque, None);
712                let qop_response = qop_response.unwrap();
713                assert_eq!(qop_response.qop, QopOption::AuthInt);
714                assert_eq!(qop_response.nc, 1);
715                assert!(!userhash);
716                assert_eq!(other, vec![]);
717                response
718            }
719            _ => panic!("Expected digest"),
720        };
721
722        let mut response_headers = Headers::new();
723        authenticator.authorize_request(&mut response_headers);
724
725        let response = response_headers
726            .get::<AuthResponse>(Name::AUTHORIZATION)
727            .unwrap();
728
729        match response {
730            AuthResponse::Digest(DigestResponse {
731                username,
732                realm,
733                nonce,
734                uri,
735                response, // cannot check, cnonce is random
736                algorithm,
737                opaque,
738                qop_response,
739                userhash,
740                other,
741            }) => {
742                assert_eq!(username, Username::Username("user123".into()));
743                assert_eq!(realm, "example.org");
744                assert_eq!(nonce, "YWmh5GFpoLjiTDCA1hTSSygkgdj99aHE");
745                assert_eq!(uri, "sip:example.org");
746
747                assert_eq!(algorithm, Algorithm::AlgorithmValue(AlgorithmValue::MD5));
748                assert_eq!(opaque, None);
749                let qop_response = qop_response.unwrap();
750                assert_eq!(qop_response.qop, QopOption::AuthInt);
751                assert_eq!(qop_response.nc, 2);
752                assert!(!userhash);
753                assert_eq!(other, vec![]);
754                assert_ne!(resp_value, response)
755            }
756            _ => panic!("Expected digest"),
757        }
758    }
759}