Skip to main content

rvoip_auth_core/
sip_digest.rs

1//! SIP Digest Authentication per RFC 2617, RFC 7616, and RFC 8760.
2//!
3//! Supports `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess`,
4//! `SHA-512-256`, and `SHA-512-256-sess`.
5//!
6//! ## Sprint 3 nonce-count hardening (RFC 7616 §3.4.5)
7//!
8//! Per-(realm, nonce) `nc` is owned by the *caller* (typically
9//! `session-core::SessionState::digest_nc`). The legacy
10//! [`DigestClient::compute_response`] entry point is preserved as a thin
11//! wrapper that always passes `nc=1, body=None`; new code should call
12//! [`DigestClient::compute_response_with_state`] and pass an
13//! incrementing counter plus the request body (for `auth-int`).
14
15use crate::error::{AuthError, Result};
16use crate::providers::DigestSecret;
17use hex;
18use rand::Rng;
19use sha2::{Digest as Sha2Digest, Sha256, Sha512_256};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// Digest authentication algorithm.
23///
24/// `*-sess` variants follow RFC 7616 §3.4.2: the basic `H(user:realm:pwd)`
25/// HA1 is rehashed with `nonce:cnonce` to derive a session-bound key.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DigestAlgorithm {
28    MD5,
29    MD5Sess,
30    SHA256,
31    SHA256Sess,
32    SHA512256,
33    SHA512256Sess,
34}
35
36impl DigestAlgorithm {
37    pub fn as_str(&self) -> &'static str {
38        match self {
39            Self::MD5 => "MD5",
40            Self::MD5Sess => "MD5-sess",
41            Self::SHA256 => "SHA-256",
42            Self::SHA256Sess => "SHA-256-sess",
43            Self::SHA512256 => "SHA-512-256",
44            Self::SHA512256Sess => "SHA-512-256-sess",
45        }
46    }
47
48    /// Whether the algorithm is a `-sess` variant requiring HA1 to fold
49    /// in `nonce:cnonce` (RFC 7616 §3.4.2).
50    pub fn is_sess(&self) -> bool {
51        matches!(self, Self::MD5Sess | Self::SHA256Sess | Self::SHA512256Sess)
52    }
53
54    /// Hash an input slice with the algorithm's underlying hash function.
55    /// MD5 / MD5-sess → MD5; SHA-256 / SHA-256-sess → SHA-256;
56    /// SHA-512-256 / SHA-512-256-sess → SHA-512/256.
57    fn hash(&self, input: &[u8]) -> String {
58        match self {
59            Self::MD5 | Self::MD5Sess => hex::encode(md5::compute(input).0),
60            Self::SHA256 | Self::SHA256Sess => hex::encode(Sha256::digest(input)),
61            Self::SHA512256 | Self::SHA512256Sess => hex::encode(Sha512_256::digest(input)),
62        }
63    }
64}
65
66impl std::fmt::Display for DigestAlgorithm {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(f, "{}", self.as_str())
69    }
70}
71
72fn parse_algorithm(value: &str) -> Result<DigestAlgorithm> {
73    let value = value.trim();
74    if value.eq_ignore_ascii_case("MD5") {
75        Ok(DigestAlgorithm::MD5)
76    } else if value.eq_ignore_ascii_case("MD5-sess") {
77        Ok(DigestAlgorithm::MD5Sess)
78    } else if value.eq_ignore_ascii_case("SHA-256") {
79        Ok(DigestAlgorithm::SHA256)
80    } else if value.eq_ignore_ascii_case("SHA-256-sess") {
81        Ok(DigestAlgorithm::SHA256Sess)
82    } else if value.eq_ignore_ascii_case("SHA-512-256") {
83        Ok(DigestAlgorithm::SHA512256)
84    } else if value.eq_ignore_ascii_case("SHA-512-256-sess") {
85        Ok(DigestAlgorithm::SHA512256Sess)
86    } else {
87        Err(AuthError::InvalidChallenge(format!(
88            "Unsupported digest algorithm '{}'",
89            value
90        )))
91    }
92}
93
94fn parse_bool(value: &str) -> bool {
95    matches!(value.trim(), "true" | "TRUE" | "True" | "1")
96}
97
98fn split_auth_params(params: &str) -> Vec<&str> {
99    let mut parts = Vec::new();
100    let mut start = 0;
101    let mut in_quotes = false;
102    let mut escaped = false;
103
104    for (idx, ch) in params.char_indices() {
105        if escaped {
106            escaped = false;
107            continue;
108        }
109
110        match ch {
111            '\\' if in_quotes => escaped = true,
112            '"' => in_quotes = !in_quotes,
113            ',' if !in_quotes => {
114                parts.push(params[start..idx].trim());
115                start = idx + ch.len_utf8();
116            }
117            _ => {}
118        }
119    }
120
121    parts.push(params[start..].trim());
122    parts.into_iter().filter(|part| !part.is_empty()).collect()
123}
124
125fn unquote_auth_value(value: &str) -> String {
126    let value = value.trim();
127    let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else {
128        return value.to_string();
129    };
130
131    let mut unescaped = String::with_capacity(inner.len());
132    let mut chars = inner.chars();
133    while let Some(ch) = chars.next() {
134        if ch == '\\' {
135            if let Some(next) = chars.next() {
136                unescaped.push(next);
137            }
138        } else {
139            unescaped.push(ch);
140        }
141    }
142    unescaped
143}
144
145fn parse_qop_options(value: &str) -> Vec<String> {
146    value
147        .split(',')
148        .map(|s| s.trim().to_ascii_lowercase())
149        .filter(|s| !s.is_empty())
150        .collect()
151}
152
153/// Digest challenge issued by server (401/407 response).
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct DigestChallenge {
156    pub realm: String,
157    pub nonce: String,
158    pub algorithm: DigestAlgorithm,
159    pub qop: Option<Vec<String>>, // "auth", "auth-int"
160    pub opaque: Option<String>,
161}
162
163/// Parsed digest challenge plus metadata that is useful for negotiation.
164///
165/// This is additive to keep [`DigestChallenge`] source-compatible for
166/// callers that construct challenge literals.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct DigestChallengeDetails {
169    pub challenge: DigestChallenge,
170    pub stale: bool,
171}
172
173/// Parsed Authorization header on the server side.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct DigestResponse {
176    pub username: String,
177    pub realm: String,
178    pub nonce: String,
179    pub uri: String,
180    pub response: String,
181    pub algorithm: DigestAlgorithm,
182    pub cnonce: Option<String>,
183    pub qop: Option<String>,
184    pub nc: Option<String>,
185    pub opaque: Option<String>,
186}
187
188/// Result of computing a digest response with explicit state.
189///
190/// Returned by [`DigestClient::compute_response_with_state`] so the
191/// caller can fold the same `nc` and `qop` values into the
192/// Authorization header without duplicating the qop-selection logic.
193#[derive(Debug, Clone)]
194pub struct DigestComputed {
195    pub response: String,
196    pub cnonce: Option<String>,
197    /// Hex-formatted nonce-count (e.g. `"00000002"`). `None` when no
198    /// qop was negotiated (legacy mode).
199    pub nc: Option<String>,
200    /// Negotiated qop value (`"auth"` or `"auth-int"`), or `None` when
201    /// the server didn't offer qop.
202    pub qop: Option<String>,
203}
204
205/// SIP Digest authenticator for generating challenges and validating responses.
206#[derive(Debug, Clone)]
207pub struct DigestAuthenticator {
208    realm: String,
209    algorithm: DigestAlgorithm,
210}
211
212impl DigestAuthenticator {
213    pub fn new(realm: impl Into<String>) -> Self {
214        Self {
215            realm: realm.into(),
216            algorithm: DigestAlgorithm::MD5,
217        }
218    }
219
220    /// Select the algorithm used in newly generated challenges.
221    pub fn with_algorithm(mut self, algorithm: DigestAlgorithm) -> Self {
222        self.algorithm = algorithm;
223        self
224    }
225
226    pub fn generate_challenge(&self) -> DigestChallenge {
227        DigestChallenge {
228            realm: self.realm.clone(),
229            nonce: Self::generate_nonce(),
230            algorithm: self.algorithm,
231            qop: Some(vec!["auth".to_string()]),
232            opaque: Some(Self::generate_opaque()),
233        }
234    }
235
236    pub fn format_www_authenticate(&self, challenge: &DigestChallenge) -> String {
237        self.format_www_authenticate_with_stale(challenge, false)
238    }
239
240    /// Format a digest challenge with an optional RFC 7616 `stale=true` flag.
241    pub fn format_www_authenticate_with_stale(
242        &self,
243        challenge: &DigestChallenge,
244        stale: bool,
245    ) -> String {
246        let mut parts = vec![
247            format!(r#"realm="{}""#, challenge.realm),
248            format!(r#"nonce="{}""#, challenge.nonce),
249            format!(r#"algorithm={}"#, challenge.algorithm),
250        ];
251
252        if let Some(ref qop) = challenge.qop {
253            parts.push(format!(r#"qop="{}""#, qop.join(",")));
254        }
255
256        if let Some(ref opaque) = challenge.opaque {
257            parts.push(format!(r#"opaque="{}""#, opaque));
258        }
259
260        if stale {
261            parts.push("stale=true".to_string());
262        }
263
264        format!("Digest {}", parts.join(", "))
265    }
266
267    /// Validate a digest response against the stored password.
268    ///
269    /// Honors the algorithm carried on the response (`MD5` /
270    /// `MD5-sess` / `SHA-256` / `SHA-256-sess` / `SHA-512-256` /
271    /// `SHA-512-256-sess`) — clients sending SHA-based digests are
272    /// validated with the advertised hash, not silently downgraded.
273    pub fn validate_response(
274        &self,
275        response: &DigestResponse,
276        method: &str,
277        password: &str,
278    ) -> Result<bool> {
279        self.validate_response_with_body(response, method, password, None)
280    }
281
282    /// Like [`Self::validate_response`] but also accepts the request
283    /// body so `qop=auth-int` validation can include it in HA2.
284    pub fn validate_response_with_body(
285        &self,
286        response: &DigestResponse,
287        method: &str,
288        password: &str,
289        body: Option<&[u8]>,
290    ) -> Result<bool> {
291        self.validate_response_with_secret_and_body(
292            response,
293            method,
294            &DigestSecret::PlaintextPassword(password.to_string()),
295            body,
296        )
297    }
298
299    /// Validate a digest response against provider-supplied secret material.
300    ///
301    /// [`DigestSecret::Ha1`] lets a server validate SIP Digest without storing
302    /// plaintext SIP passwords. The HA1 value must be the base
303    /// `H(username:realm:password)` value; `-sess` algorithms fold nonce and
304    /// cnonce in this method.
305    pub fn validate_response_with_secret(
306        &self,
307        response: &DigestResponse,
308        method: &str,
309        secret: &DigestSecret,
310    ) -> Result<bool> {
311        self.validate_response_with_secret_and_body(response, method, secret, None)
312    }
313
314    /// Like [`Self::validate_response_with_secret`] but also accepts the
315    /// request body for `qop=auth-int` validation.
316    pub fn validate_response_with_secret_and_body(
317        &self,
318        response: &DigestResponse,
319        method: &str,
320        secret: &DigestSecret,
321        body: Option<&[u8]>,
322    ) -> Result<bool> {
323        let algorithm = response.algorithm;
324        // HA1
325        let basic_ha1 = match secret {
326            DigestSecret::PlaintextPassword(password) => algorithm
327                .hash(format!("{}:{}:{}", response.username, response.realm, password).as_bytes()),
328            DigestSecret::Ha1(ha1) => ha1.clone(),
329        };
330        let ha1 = if algorithm.is_sess() {
331            let cnonce = response.cnonce.as_deref().ok_or_else(|| {
332                AuthError::InvalidResponse("Missing cnonce for -sess algorithm".into())
333            })?;
334            algorithm.hash(format!("{}:{}:{}", basic_ha1, response.nonce, cnonce).as_bytes())
335        } else {
336            basic_ha1
337        };
338
339        // HA2
340        let ha2 = match response.qop.as_deref() {
341            Some("auth-int") => {
342                let body_bytes = body.unwrap_or(&[]);
343                let body_hash = algorithm.hash(body_bytes);
344                algorithm.hash(format!("{}:{}:{}", method, response.uri, body_hash).as_bytes())
345            }
346            Some("auth") | None => {
347                algorithm.hash(format!("{}:{}", method, response.uri).as_bytes())
348            }
349            Some(other) => {
350                return Err(AuthError::InvalidResponse(format!(
351                    "Unsupported digest qop '{}'",
352                    other
353                )))
354            }
355        };
356
357        // Expected response
358        let expected = if let Some(qop) = response.qop.as_ref() {
359            let nc = response
360                .nc
361                .as_ref()
362                .ok_or_else(|| AuthError::InvalidResponse("Missing nc for qop".into()))?;
363            let cnonce = response
364                .cnonce
365                .as_ref()
366                .ok_or_else(|| AuthError::InvalidResponse("Missing cnonce for qop".into()))?;
367            algorithm.hash(
368                format!(
369                    "{}:{}:{}:{}:{}:{}",
370                    ha1, response.nonce, nc, cnonce, qop, ha2
371                )
372                .as_bytes(),
373            )
374        } else {
375            algorithm.hash(format!("{}:{}:{}", ha1, response.nonce, ha2).as_bytes())
376        };
377
378        Ok(expected == response.response)
379    }
380
381    /// Parse WWW-Authenticate header to extract challenge.
382    pub fn parse_challenge(header: &str) -> Result<DigestChallenge> {
383        Ok(Self::parse_challenge_details(header)?.challenge)
384    }
385
386    /// Parse WWW-Authenticate / Proxy-Authenticate header to extract the
387    /// challenge plus metadata such as `stale=true`.
388    pub fn parse_challenge_details(header: &str) -> Result<DigestChallengeDetails> {
389        let header = header.trim();
390
391        let params_str = if header.starts_with("Digest ") || header.starts_with("digest ") {
392            &header[7..]
393        } else {
394            return Err(AuthError::InvalidChallenge(
395                "Missing 'Digest' prefix".into(),
396            ));
397        };
398
399        let mut realm = None;
400        let mut nonce = None;
401        let mut algorithm = DigestAlgorithm::MD5;
402        let mut qop = None;
403        let mut opaque = None;
404        let mut stale = false;
405
406        for param in split_auth_params(params_str) {
407            let param = param.trim();
408            if let Some((key, value)) = param.split_once('=') {
409                let key = key.trim().to_ascii_lowercase();
410                let value = unquote_auth_value(value);
411
412                match key.as_str() {
413                    "realm" => realm = Some(value),
414                    "nonce" => nonce = Some(value),
415                    "algorithm" => algorithm = parse_algorithm(&value)?,
416                    "qop" => {
417                        qop = Some(parse_qop_options(&value));
418                    }
419                    "opaque" => opaque = Some(value),
420                    "stale" => stale = parse_bool(&value),
421                    _ => {}
422                }
423            }
424        }
425
426        Ok(DigestChallengeDetails {
427            challenge: DigestChallenge {
428                realm: realm.ok_or_else(|| AuthError::InvalidChallenge("Missing realm".into()))?,
429                nonce: nonce.ok_or_else(|| AuthError::InvalidChallenge("Missing nonce".into()))?,
430                algorithm,
431                qop,
432                opaque,
433            },
434            stale,
435        })
436    }
437
438    /// Parse Authorization header to extract response.
439    pub fn parse_authorization(header: &str) -> Result<DigestResponse> {
440        let header = header.trim();
441
442        let params_str = if header.starts_with("Digest ") || header.starts_with("digest ") {
443            &header[7..]
444        } else {
445            return Err(AuthError::InvalidResponse("Missing 'Digest' prefix".into()));
446        };
447
448        let mut username = None;
449        let mut realm = None;
450        let mut nonce = None;
451        let mut uri = None;
452        let mut response = None;
453        let mut algorithm = DigestAlgorithm::MD5;
454        let mut cnonce = None;
455        let mut qop = None;
456        let mut nc = None;
457        let mut opaque = None;
458
459        for param in split_auth_params(params_str) {
460            let param = param.trim();
461            if let Some((key, value)) = param.split_once('=') {
462                let key = key.trim().to_ascii_lowercase();
463                let value = unquote_auth_value(value);
464
465                match key.as_str() {
466                    "username" => username = Some(value),
467                    "realm" => realm = Some(value),
468                    "nonce" => nonce = Some(value),
469                    "uri" => uri = Some(value),
470                    "response" => response = Some(value),
471                    "algorithm" => {
472                        algorithm = parse_algorithm(&value)
473                            .map_err(|e| AuthError::InvalidResponse(e.to_string()))?
474                    }
475                    "cnonce" => cnonce = Some(value),
476                    "qop" => qop = Some(value.to_ascii_lowercase()),
477                    "nc" => nc = Some(value),
478                    "opaque" => opaque = Some(value),
479                    _ => {}
480                }
481            }
482        }
483
484        Ok(DigestResponse {
485            username: username
486                .ok_or_else(|| AuthError::InvalidResponse("Missing username".into()))?,
487            realm: realm.ok_or_else(|| AuthError::InvalidResponse("Missing realm".into()))?,
488            nonce: nonce.ok_or_else(|| AuthError::InvalidResponse("Missing nonce".into()))?,
489            uri: uri.ok_or_else(|| AuthError::InvalidResponse("Missing uri".into()))?,
490            response: response
491                .ok_or_else(|| AuthError::InvalidResponse("Missing response".into()))?,
492            algorithm,
493            cnonce,
494            qop,
495            nc,
496            opaque,
497        })
498    }
499
500    fn generate_nonce() -> String {
501        let mut rng = rand::thread_rng();
502        let random_bytes: [u8; 16] = rng.gen();
503        let timestamp = SystemTime::now()
504            .duration_since(UNIX_EPOCH)
505            .unwrap()
506            .as_secs();
507        let data = format!("{}{}", timestamp, hex::encode(random_bytes));
508        hex::encode(md5::compute(data.as_bytes()).0)
509    }
510
511    fn generate_opaque() -> String {
512        let mut rng = rand::thread_rng();
513        let random_bytes: [u8; 16] = rng.gen();
514        hex::encode(random_bytes)
515    }
516}
517
518/// Client-side digest authentication helper.
519pub struct DigestClient;
520
521impl DigestClient {
522    /// Legacy entry point. Always uses `nc=1` and never `auth-int`.
523    /// New callers should prefer
524    /// [`DigestClient::compute_response_with_state`] so per-(realm,
525    /// nonce) counter increments survive across requests.
526    pub fn compute_response(
527        username: &str,
528        password: &str,
529        challenge: &DigestChallenge,
530        method: &str,
531        uri: &str,
532    ) -> Result<(String, Option<String>)> {
533        let computed =
534            Self::compute_response_with_state(username, password, challenge, method, uri, 1, None)?;
535        Ok((computed.response, computed.cnonce))
536    }
537
538    /// Compute a digest response with caller-managed state.
539    ///
540    /// `nc` is the per-(realm, nonce) request counter (RFC 7616
541    /// §3.4.5); the caller is responsible for incrementing it on
542    /// each request that reuses the same nonce, and resetting to 1
543    /// when a fresh challenge with a different nonce arrives.
544    ///
545    /// `body` is the request body (e.g. INVITE SDP). When `Some(_)`
546    /// AND the challenge offers `qop=auth-int`, `auth-int` is
547    /// selected and the body is folded into HA2 per RFC 7616 §3.4.3.
548    /// Pass `None` (or an empty slice) for bodyless requests like
549    /// REGISTER.
550    pub fn compute_response_with_state(
551        username: &str,
552        password: &str,
553        challenge: &DigestChallenge,
554        method: &str,
555        uri: &str,
556        nc: u32,
557        body: Option<&[u8]>,
558    ) -> Result<DigestComputed> {
559        let algorithm = challenge.algorithm;
560        let cnonce_value = Self::generate_cnonce();
561        let nc_str = format!("{:08x}", nc);
562
563        // HA1 (RFC 7616 §3.4.2). `-sess` wraps the basic HA1 in a
564        // session-bound construction with nonce + cnonce.
565        let basic_ha1 =
566            algorithm.hash(format!("{}:{}:{}", username, challenge.realm, password).as_bytes());
567        let ha1 = if algorithm.is_sess() {
568            algorithm.hash(format!("{}:{}:{}", basic_ha1, challenge.nonce, cnonce_value).as_bytes())
569        } else {
570            basic_ha1
571        };
572
573        // qop selection (RFC 7616 §3.4): prefer auth-int when the
574        // server offers it AND the caller supplied a body; else auth;
575        // else legacy (no qop).
576        let chosen_qop = match challenge.qop.as_ref() {
577            Some(opts) if body.is_some() && opts.iter().any(|q| q == "auth-int") => {
578                Some("auth-int".to_string())
579            }
580            Some(opts) if opts.iter().any(|q| q == "auth") => Some("auth".to_string()),
581            Some(_) => {
582                return Err(AuthError::InvalidChallenge(
583                    "Digest challenge did not offer supported qop".into(),
584                ))
585            }
586            None => None,
587        };
588
589        // HA2 (RFC 7616 §3.4.3).
590        let ha2 = match chosen_qop.as_deref() {
591            Some("auth-int") => {
592                let body_bytes = body.unwrap_or(&[]);
593                let body_hash = algorithm.hash(body_bytes);
594                algorithm.hash(format!("{}:{}:{}", method, uri, body_hash).as_bytes())
595            }
596            _ => algorithm.hash(format!("{}:{}", method, uri).as_bytes()),
597        };
598
599        // Response (RFC 7616 §3.4.1).
600        let response = if let Some(ref qop) = chosen_qop {
601            algorithm.hash(
602                format!(
603                    "{}:{}:{}:{}:{}:{}",
604                    ha1, challenge.nonce, nc_str, cnonce_value, qop, ha2
605                )
606                .as_bytes(),
607            )
608        } else {
609            algorithm.hash(format!("{}:{}:{}", ha1, challenge.nonce, ha2).as_bytes())
610        };
611
612        let (cnonce_out, nc_out) = if chosen_qop.is_some() {
613            (Some(cnonce_value), Some(nc_str))
614        } else {
615            (None, None)
616        };
617
618        Ok(DigestComputed {
619            response,
620            cnonce: cnonce_out,
621            nc: nc_out,
622            qop: chosen_qop,
623        })
624    }
625
626    /// Legacy Authorization header formatter. Always emits
627    /// `nc=00000001` when qop is in play. Preserved for callers that
628    /// don't yet thread state; new callers should use
629    /// [`DigestClient::format_authorization_with_state`].
630    pub fn format_authorization(
631        username: &str,
632        challenge: &DigestChallenge,
633        uri: &str,
634        response: &str,
635        cnonce: Option<&str>,
636    ) -> String {
637        let mut parts = vec![
638            format!(r#"username="{}""#, username),
639            format!(r#"realm="{}""#, challenge.realm),
640            format!(r#"nonce="{}""#, challenge.nonce),
641            format!(r#"uri="{}""#, uri),
642            format!(r#"response="{}""#, response),
643            format!(r#"algorithm={}"#, challenge.algorithm),
644        ];
645
646        if let Some(ref qop_options) = challenge.qop {
647            if qop_options.iter().any(|q| q == "auth") {
648                parts.push("qop=auth".to_string());
649                parts.push("nc=00000001".to_string());
650                let cn_owned;
651                let cn = match cnonce {
652                    Some(c) => c,
653                    None => {
654                        cn_owned = Self::generate_cnonce();
655                        cn_owned.as_str()
656                    }
657                };
658                parts.push(format!(r#"cnonce="{}""#, cn));
659            }
660        }
661
662        if let Some(ref opaque) = challenge.opaque {
663            parts.push(format!(r#"opaque="{}""#, opaque));
664        }
665
666        format!("Digest {}", parts.join(", "))
667    }
668
669    /// Format the Authorization header using a precomputed
670    /// [`DigestComputed`]. The `nc`, `cnonce`, and `qop` fields are
671    /// emitted exactly as they were used in the response computation
672    /// — no recomputation, no risk of drift.
673    pub fn format_authorization_with_state(
674        username: &str,
675        challenge: &DigestChallenge,
676        uri: &str,
677        computed: &DigestComputed,
678    ) -> String {
679        let mut parts = vec![
680            format!(r#"username="{}""#, username),
681            format!(r#"realm="{}""#, challenge.realm),
682            format!(r#"nonce="{}""#, challenge.nonce),
683            format!(r#"uri="{}""#, uri),
684            format!(r#"response="{}""#, computed.response),
685            format!(r#"algorithm={}"#, challenge.algorithm),
686        ];
687
688        if let (Some(qop), Some(nc), Some(cnonce)) = (
689            computed.qop.as_ref(),
690            computed.nc.as_ref(),
691            computed.cnonce.as_ref(),
692        ) {
693            parts.push(format!("qop={}", qop));
694            parts.push(format!("nc={}", nc));
695            parts.push(format!(r#"cnonce="{}""#, cnonce));
696        }
697
698        if let Some(ref opaque) = challenge.opaque {
699            parts.push(format!(r#"opaque="{}""#, opaque));
700        }
701
702        format!("Digest {}", parts.join(", "))
703    }
704
705    fn generate_cnonce() -> String {
706        let mut rng = rand::thread_rng();
707        let random_bytes: [u8; 8] = rng.gen();
708        hex::encode(random_bytes)
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    #[test]
717    fn algorithm_parser_recognises_known_tokens() {
718        assert_eq!(parse_algorithm("MD5").unwrap(), DigestAlgorithm::MD5);
719        assert_eq!(parse_algorithm("md5").unwrap(), DigestAlgorithm::MD5);
720        assert_eq!(
721            parse_algorithm("MD5-sess").unwrap(),
722            DigestAlgorithm::MD5Sess
723        );
724        assert_eq!(
725            parse_algorithm("md5-sess").unwrap(),
726            DigestAlgorithm::MD5Sess
727        );
728        assert_eq!(parse_algorithm("SHA-256").unwrap(), DigestAlgorithm::SHA256);
729        assert_eq!(
730            parse_algorithm("SHA-256-sess").unwrap(),
731            DigestAlgorithm::SHA256Sess
732        );
733        assert_eq!(
734            parse_algorithm("SHA-512-256").unwrap(),
735            DigestAlgorithm::SHA512256
736        );
737        assert_eq!(
738            parse_algorithm("SHA-512-256-sess").unwrap(),
739            DigestAlgorithm::SHA512256Sess
740        );
741    }
742
743    #[test]
744    fn algorithm_parser_rejects_unknown_tokens() {
745        assert!(parse_algorithm("garbage").is_err());
746        assert!(DigestAuthenticator::parse_challenge(
747            r#"Digest realm="example.com", nonce="fixed", algorithm=SHA-999"#
748        )
749        .is_err());
750        assert!(
751            DigestAuthenticator::parse_authorization(
752                r#"Digest username="alice", realm="example.com", nonce="fixed", uri="sip:example.com", response="abcd", algorithm=SHA-999"#
753            )
754            .is_err()
755        );
756    }
757
758    #[test]
759    fn omitted_algorithm_defaults_to_md5() {
760        let challenge =
761            DigestAuthenticator::parse_challenge(r#"Digest realm="example.com", nonce="fixed""#)
762                .unwrap();
763        assert_eq!(challenge.algorithm, DigestAlgorithm::MD5);
764    }
765
766    #[test]
767    fn challenge_parser_preserves_quoted_qop_list() {
768        let challenge = DigestAuthenticator::parse_challenge(
769            r#"Digest realm="example.com", nonce="fixed", algorithm=md5, qop="auth,auth-int""#,
770        )
771        .unwrap();
772
773        assert_eq!(challenge.algorithm, DigestAlgorithm::MD5);
774        assert_eq!(
775            challenge.qop,
776            Some(vec!["auth".to_string(), "auth-int".to_string()])
777        );
778    }
779
780    #[test]
781    fn challenge_details_parser_exposes_stale_flag() {
782        let details = DigestAuthenticator::parse_challenge_details(
783            r#"Digest realm="example.com", nonce="fixed", algorithm=MD5, stale=true"#,
784        )
785        .unwrap();
786
787        assert!(details.stale);
788        assert_eq!(details.challenge.nonce, "fixed");
789
790        let auth = DigestAuthenticator::new("example.com");
791        let header = auth.format_www_authenticate_with_stale(&details.challenge, true);
792        assert!(
793            header.contains("stale=true"),
794            "formatted challenge was: {header}"
795        );
796    }
797
798    #[test]
799    fn authorization_parser_ignores_commas_inside_quotes() {
800        let response = DigestAuthenticator::parse_authorization(
801            r#"Digest username="alice,ua", realm="example.com", nonce="fixed", uri="sip:example.com", response="abcd", algorithm=MD5, qop=auth, nc=00000001, cnonce="cn,once", opaque="op,aque""#,
802        )
803        .unwrap();
804
805        assert_eq!(response.username, "alice,ua");
806        assert_eq!(response.cnonce.as_deref(), Some("cn,once"));
807        assert_eq!(response.opaque.as_deref(), Some("op,aque"));
808        assert_eq!(response.qop.as_deref(), Some("auth"));
809    }
810
811    #[test]
812    fn algorithm_is_sess_only_for_sess_variants() {
813        assert!(!DigestAlgorithm::MD5.is_sess());
814        assert!(DigestAlgorithm::MD5Sess.is_sess());
815        assert!(!DigestAlgorithm::SHA256.is_sess());
816        assert!(DigestAlgorithm::SHA256Sess.is_sess());
817    }
818
819    #[test]
820    fn nc_increments_across_calls_with_same_nonce() {
821        let challenge = DigestChallenge {
822            realm: "example.com".to_string(),
823            nonce: "shared-nonce".to_string(),
824            algorithm: DigestAlgorithm::MD5,
825            qop: Some(vec!["auth".to_string()]),
826            opaque: None,
827        };
828
829        let r1 = DigestClient::compute_response_with_state(
830            "alice",
831            "secret",
832            &challenge,
833            "INVITE",
834            "sip:bob@example.com",
835            1,
836            None,
837        )
838        .unwrap();
839        let r2 = DigestClient::compute_response_with_state(
840            "alice",
841            "secret",
842            &challenge,
843            "INVITE",
844            "sip:bob@example.com",
845            2,
846            None,
847        )
848        .unwrap();
849
850        assert_eq!(r1.nc.as_deref(), Some("00000001"));
851        assert_eq!(r2.nc.as_deref(), Some("00000002"));
852        // Different nc + different cnonce → different response hashes.
853        assert_ne!(r1.response, r2.response);
854    }
855
856    #[test]
857    fn nc_resets_implicitly_on_new_nonce() {
858        // Two distinct nonces both starting at nc=1 — they're
859        // independent counter spaces. The caller is responsible for
860        // resetting; here we just verify the API permits it.
861        let mk = |nonce: &str| DigestChallenge {
862            realm: "example.com".to_string(),
863            nonce: nonce.to_string(),
864            algorithm: DigestAlgorithm::MD5,
865            qop: Some(vec!["auth".to_string()]),
866            opaque: None,
867        };
868
869        let r1 = DigestClient::compute_response_with_state(
870            "alice",
871            "secret",
872            &mk("nonce-A"),
873            "REGISTER",
874            "sip:reg.example.com",
875            1,
876            None,
877        )
878        .unwrap();
879        let r2 = DigestClient::compute_response_with_state(
880            "alice",
881            "secret",
882            &mk("nonce-B"),
883            "REGISTER",
884            "sip:reg.example.com",
885            1,
886            None,
887        )
888        .unwrap();
889
890        assert_eq!(r1.nc.as_deref(), Some("00000001"));
891        assert_eq!(r2.nc.as_deref(), Some("00000001"));
892        assert_ne!(
893            r1.response, r2.response,
894            "different nonces must produce different responses"
895        );
896    }
897
898    #[test]
899    fn sha256_round_trip_with_authenticator() {
900        let auth = DigestAuthenticator::new("example.com");
901        let challenge = DigestChallenge {
902            realm: "example.com".to_string(),
903            nonce: "fixed-nonce".to_string(),
904            algorithm: DigestAlgorithm::SHA256,
905            qop: Some(vec!["auth".to_string()]),
906            opaque: None,
907        };
908
909        let computed = DigestClient::compute_response_with_state(
910            "alice",
911            "secret",
912            &challenge,
913            "INVITE",
914            "sip:bob@example.com",
915            1,
916            None,
917        )
918        .unwrap();
919
920        let response = DigestResponse {
921            username: "alice".to_string(),
922            realm: "example.com".to_string(),
923            nonce: "fixed-nonce".to_string(),
924            uri: "sip:bob@example.com".to_string(),
925            response: computed.response.clone(),
926            algorithm: DigestAlgorithm::SHA256,
927            cnonce: computed.cnonce.clone(),
928            qop: computed.qop.clone(),
929            nc: computed.nc.clone(),
930            opaque: None,
931        };
932
933        assert!(auth
934            .validate_response(&response, "INVITE", "secret")
935            .unwrap());
936        // Wrong password rejected.
937        assert!(!auth
938            .validate_response(&response, "INVITE", "WRONG")
939            .unwrap());
940    }
941
942    #[test]
943    fn digest_secret_ha1_validates_without_plaintext_password() {
944        let auth = DigestAuthenticator::new("example.com");
945        let challenge = DigestChallenge {
946            realm: "example.com".to_string(),
947            nonce: "fixed-nonce".to_string(),
948            algorithm: DigestAlgorithm::SHA512256,
949            qop: Some(vec!["auth".to_string()]),
950            opaque: None,
951        };
952        let computed = DigestClient::compute_response_with_state(
953            "alice",
954            "secret",
955            &challenge,
956            "REGISTER",
957            "sip:example.com",
958            1,
959            None,
960        )
961        .unwrap();
962        let response = DigestResponse {
963            username: "alice".to_string(),
964            realm: "example.com".to_string(),
965            nonce: "fixed-nonce".to_string(),
966            uri: "sip:example.com".to_string(),
967            response: computed.response,
968            algorithm: DigestAlgorithm::SHA512256,
969            cnonce: computed.cnonce,
970            qop: computed.qop,
971            nc: computed.nc,
972            opaque: None,
973        };
974        let ha1 = DigestAlgorithm::SHA512256.hash(b"alice:example.com:secret");
975
976        assert!(auth
977            .validate_response_with_secret(&response, "REGISTER", &crate::DigestSecret::Ha1(ha1))
978            .unwrap());
979        assert!(!auth
980            .validate_response_with_secret(
981                &response,
982                "REGISTER",
983                &crate::DigestSecret::Ha1("wrong".to_string())
984            )
985            .unwrap());
986    }
987
988    #[test]
989    fn sess_algorithms_use_session_key_ha1() {
990        // Same inputs except algorithm. -sess HA1 folds in nonce +
991        // cnonce, so the response must differ.
992        let mk = |alg| DigestChallenge {
993            realm: "example.com".to_string(),
994            nonce: "fixed-nonce".to_string(),
995            algorithm: alg,
996            qop: Some(vec!["auth".to_string()]),
997            opaque: None,
998        };
999
1000        // We can't compare hashes directly because cnonce is random.
1001        // Instead, exercise the validate path on each.
1002        let auth_plain = DigestAuthenticator::new("example.com");
1003
1004        for alg in [
1005            DigestAlgorithm::SHA256,
1006            DigestAlgorithm::SHA256Sess,
1007            DigestAlgorithm::SHA512256,
1008            DigestAlgorithm::SHA512256Sess,
1009            DigestAlgorithm::MD5,
1010            DigestAlgorithm::MD5Sess,
1011        ] {
1012            let ch = mk(alg);
1013            let computed = DigestClient::compute_response_with_state(
1014                "alice",
1015                "secret",
1016                &ch,
1017                "INVITE",
1018                "sip:bob@example.com",
1019                1,
1020                None,
1021            )
1022            .unwrap();
1023            let resp = DigestResponse {
1024                username: "alice".to_string(),
1025                realm: "example.com".to_string(),
1026                nonce: "fixed-nonce".to_string(),
1027                uri: "sip:bob@example.com".to_string(),
1028                response: computed.response,
1029                algorithm: alg,
1030                cnonce: computed.cnonce,
1031                qop: computed.qop,
1032                nc: computed.nc,
1033                opaque: None,
1034            };
1035            assert!(
1036                auth_plain
1037                    .validate_response(&resp, "INVITE", "secret")
1038                    .unwrap(),
1039                "algorithm {:?} failed self-validation",
1040                alg
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn auth_int_includes_body_in_ha2() {
1047        // Same inputs except body bytes. With auth-int negotiated,
1048        // the response must differ between bodies.
1049        let challenge = DigestChallenge {
1050            realm: "example.com".to_string(),
1051            nonce: "fixed-nonce".to_string(),
1052            algorithm: DigestAlgorithm::MD5,
1053            qop: Some(vec!["auth".to_string(), "auth-int".to_string()]),
1054            opaque: None,
1055        };
1056
1057        let body_a = b"v=0\r\no=alice 1 1 IN IP4 1.2.3.4\r\n";
1058        let body_b = b"v=0\r\no=alice 2 2 IN IP4 5.6.7.8\r\n";
1059
1060        let r_a = DigestClient::compute_response_with_state(
1061            "alice",
1062            "secret",
1063            &challenge,
1064            "INVITE",
1065            "sip:bob@example.com",
1066            1,
1067            Some(body_a),
1068        )
1069        .unwrap();
1070        let r_b = DigestClient::compute_response_with_state(
1071            "alice",
1072            "secret",
1073            &challenge,
1074            "INVITE",
1075            "sip:bob@example.com",
1076            1,
1077            Some(body_b),
1078        )
1079        .unwrap();
1080
1081        assert_eq!(r_a.qop.as_deref(), Some("auth-int"));
1082        assert_eq!(r_b.qop.as_deref(), Some("auth-int"));
1083        assert_ne!(
1084            r_a.response, r_b.response,
1085            "auth-int must fold the body into HA2"
1086        );
1087    }
1088
1089    #[test]
1090    fn qop_selector_prefers_auth_int_when_offered_with_body() {
1091        let challenge = DigestChallenge {
1092            realm: "example.com".to_string(),
1093            nonce: "fixed-nonce".to_string(),
1094            algorithm: DigestAlgorithm::MD5,
1095            qop: Some(vec!["auth".to_string(), "auth-int".to_string()]),
1096            opaque: None,
1097        };
1098
1099        // With body present, auth-int wins.
1100        let r = DigestClient::compute_response_with_state(
1101            "alice",
1102            "secret",
1103            &challenge,
1104            "INVITE",
1105            "sip:bob@example.com",
1106            1,
1107            Some(b"sdp"),
1108        )
1109        .unwrap();
1110        assert_eq!(r.qop.as_deref(), Some("auth-int"));
1111
1112        // Without body, auth wins (we don't pad an empty body for an
1113        // option the caller didn't ask for).
1114        let r2 = DigestClient::compute_response_with_state(
1115            "alice",
1116            "secret",
1117            &challenge,
1118            "INVITE",
1119            "sip:bob@example.com",
1120            1,
1121            None,
1122        )
1123        .unwrap();
1124        assert_eq!(r2.qop.as_deref(), Some("auth"));
1125    }
1126
1127    #[test]
1128    fn qop_selector_rejects_unsupported_qop_only_challenge() {
1129        let challenge = DigestChallenge {
1130            realm: "example.com".to_string(),
1131            nonce: "fixed-nonce".to_string(),
1132            algorithm: DigestAlgorithm::MD5,
1133            qop: Some(vec!["auth-conf".to_string()]),
1134            opaque: None,
1135        };
1136
1137        let err = DigestClient::compute_response_with_state(
1138            "alice",
1139            "secret",
1140            &challenge,
1141            "INVITE",
1142            "sip:bob@example.com",
1143            1,
1144            None,
1145        )
1146        .expect_err("unsupported qop must fail");
1147
1148        assert!(
1149            err.to_string().contains("supported qop"),
1150            "unexpected error: {err}"
1151        );
1152    }
1153
1154    #[test]
1155    fn validation_rejects_qop_without_nonce_count_or_cnonce() {
1156        let auth = DigestAuthenticator::new("example.com");
1157        let response = DigestResponse {
1158            username: "alice".to_string(),
1159            realm: "example.com".to_string(),
1160            nonce: "fixed".to_string(),
1161            uri: "sip:bob@example.com".to_string(),
1162            response: "abcd".to_string(),
1163            algorithm: DigestAlgorithm::MD5,
1164            cnonce: None,
1165            qop: Some("auth".to_string()),
1166            nc: Some("00000001".to_string()),
1167            opaque: None,
1168        };
1169
1170        assert!(auth
1171            .validate_response(&response, "INVITE", "secret")
1172            .is_err());
1173    }
1174
1175    #[test]
1176    fn format_authorization_with_state_emits_nc_from_computed() {
1177        let challenge = DigestChallenge {
1178            realm: "example.com".to_string(),
1179            nonce: "fixed-nonce".to_string(),
1180            algorithm: DigestAlgorithm::MD5,
1181            qop: Some(vec!["auth".to_string()]),
1182            opaque: None,
1183        };
1184
1185        let computed = DigestClient::compute_response_with_state(
1186            "alice",
1187            "secret",
1188            &challenge,
1189            "REGISTER",
1190            "sip:reg.example.com",
1191            42,
1192            None,
1193        )
1194        .unwrap();
1195
1196        let header = DigestClient::format_authorization_with_state(
1197            "alice",
1198            &challenge,
1199            "sip:reg.example.com",
1200            &computed,
1201        );
1202        assert!(header.contains("nc=0000002a"), "header was: {}", header);
1203        assert!(header.contains("qop=auth"));
1204        assert!(header.contains(r#"cnonce=""#));
1205    }
1206
1207    #[test]
1208    fn legacy_compute_response_still_works() {
1209        let challenge = DigestChallenge {
1210            realm: "realm".to_string(),
1211            nonce: "nonce".to_string(),
1212            algorithm: DigestAlgorithm::MD5,
1213            qop: None,
1214            opaque: None,
1215        };
1216
1217        let response = DigestClient::compute_response(
1218            "user",
1219            "password",
1220            &challenge,
1221            "REGISTER",
1222            "sip:registrar.example.com",
1223        )
1224        .unwrap();
1225
1226        assert_eq!(response.0.len(), 32); // MD5 hex
1227        assert!(response.1.is_none()); // No qop, no cnonce.
1228    }
1229
1230    #[test]
1231    fn parse_challenge_recognises_sha256_sess() {
1232        let header = r#"Digest realm="test", nonce="abc", algorithm=SHA-256-sess, qop="auth""#;
1233        let ch = DigestAuthenticator::parse_challenge(header).unwrap();
1234        assert_eq!(ch.algorithm, DigestAlgorithm::SHA256Sess);
1235    }
1236
1237    #[test]
1238    fn test_generate_nonce() {
1239        let nonce1 = DigestAuthenticator::generate_nonce();
1240        let nonce2 = DigestAuthenticator::generate_nonce();
1241        assert_eq!(nonce1.len(), 32);
1242        assert_ne!(nonce1, nonce2);
1243    }
1244
1245    #[test]
1246    fn test_format_www_authenticate() {
1247        let auth = DigestAuthenticator::new("testrealm");
1248        let challenge = DigestChallenge {
1249            realm: "testrealm".to_string(),
1250            nonce: "nonce123".to_string(),
1251            algorithm: DigestAlgorithm::MD5,
1252            qop: Some(vec!["auth".to_string()]),
1253            opaque: Some("opaque456".to_string()),
1254        };
1255        let header = auth.format_www_authenticate(&challenge);
1256        assert!(header.contains("Digest"));
1257        assert!(header.contains(r#"realm="testrealm""#));
1258        assert!(header.contains(r#"nonce="nonce123""#));
1259    }
1260}