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