Skip to main content

sip_core/
auth.rs

1//! SIP Digest Authentication (RFC 2617 / RFC 7616).
2//!
3//! Handles 401 Unauthorized and 407 Proxy Authentication Required challenges.
4
5use std::fmt;
6
7/// Parsed challenge from WWW-Authenticate or Proxy-Authenticate header.
8#[derive(Debug, Clone)]
9pub struct DigestChallenge {
10    pub realm: String,
11    pub nonce: String,
12    pub opaque: Option<String>,
13    pub algorithm: DigestAlgorithm,
14    pub qop: Option<String>,
15    pub stale: bool,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DigestAlgorithm {
20    Md5,
21    Md5Sess,
22}
23
24impl fmt::Display for DigestAlgorithm {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            DigestAlgorithm::Md5 => write!(f, "MD5"),
28            DigestAlgorithm::Md5Sess => write!(f, "MD5-sess"),
29        }
30    }
31}
32
33/// Credentials for authentication.
34#[derive(Debug, Clone)]
35pub struct Credentials {
36    pub username: String,
37    pub password: String,
38}
39
40/// Computed digest authentication response.
41#[derive(Debug, Clone)]
42pub struct DigestResponse {
43    pub username: String,
44    pub realm: String,
45    pub nonce: String,
46    pub uri: String,
47    pub response: String,
48    pub algorithm: DigestAlgorithm,
49    pub opaque: Option<String>,
50    pub qop: Option<String>,
51    pub nc: Option<String>,
52    pub cnonce: Option<String>,
53}
54
55impl fmt::Display for DigestResponse {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", response=\"{}\", algorithm={}",
58            self.username, self.realm, self.nonce, self.uri, self.response, self.algorithm)?;
59        if let Some(ref opaque) = self.opaque {
60            write!(f, ", opaque=\"{}\"", opaque)?;
61        }
62        if let Some(ref qop) = self.qop {
63            write!(f, ", qop={}", qop)?;
64            if let Some(ref nc) = self.nc {
65                write!(f, ", nc={}", nc)?;
66            }
67            if let Some(ref cnonce) = self.cnonce {
68                write!(f, ", cnonce=\"{}\"", cnonce)?;
69            }
70        }
71        Ok(())
72    }
73}
74
75/// Parse a Digest challenge from a WWW-Authenticate or Proxy-Authenticate header value.
76///
77/// Example input: `Digest realm="asterisk", nonce="abc123", algorithm=MD5, qop="auth"`
78pub fn parse_challenge(header_value: &str) -> Option<DigestChallenge> {
79    let value = header_value.strip_prefix("Digest ")
80        .or_else(|| header_value.strip_prefix("digest "))?;
81
82    let mut realm = None;
83    let mut nonce = None;
84    let mut opaque = None;
85    let mut algorithm = DigestAlgorithm::Md5;
86    let mut qop = None;
87    let mut stale = false;
88
89    // Parse comma-separated key=value pairs (values may be quoted)
90    for param in split_params(value) {
91        let param = param.trim();
92        if let Some((key, val)) = param.split_once('=') {
93            let key = key.trim().to_lowercase();
94            let val = val.trim().trim_matches('"');
95            match key.as_str() {
96                "realm" => realm = Some(val.to_string()),
97                "nonce" => nonce = Some(val.to_string()),
98                "opaque" => opaque = Some(val.to_string()),
99                "algorithm" => {
100                    algorithm = match val.to_lowercase().as_str() {
101                        "md5-sess" => DigestAlgorithm::Md5Sess,
102                        _ => DigestAlgorithm::Md5,
103                    };
104                }
105                "qop" => qop = Some(val.to_string()),
106                "stale" => stale = val.eq_ignore_ascii_case("true"),
107                _ => {}
108            }
109        }
110    }
111
112    Some(DigestChallenge {
113        realm: realm?,
114        nonce: nonce?,
115        opaque,
116        algorithm,
117        qop,
118        stale,
119    })
120}
121
122/// Compute the digest authentication response.
123///
124/// Per RFC 2617:
125/// - HA1 = MD5(username:realm:password)
126/// - HA2 = MD5(method:uri)
127/// - response = MD5(HA1:nonce:HA2) -- without qop
128/// - response = MD5(HA1:nonce:nc:cnonce:qop:HA2) -- with qop=auth
129pub fn compute_digest(
130    challenge: &DigestChallenge,
131    creds: &Credentials,
132    method: &str,
133    uri: &str,
134) -> DigestResponse {
135    let ha1 = md5_hex(&format!("{}:{}:{}", creds.username, challenge.realm, creds.password));
136    let ha2 = md5_hex(&format!("{}:{}", method, uri));
137
138    let (response, qop, nc, cnonce) = if let Some(ref qop_val) = challenge.qop {
139        if qop_val.contains("auth") {
140            let cnonce = generate_cnonce();
141            let nc = "00000001".to_string();
142            let response = md5_hex(&format!("{}:{}:{}:{}:auth:{}", ha1, challenge.nonce, nc, cnonce, ha2));
143            (response, Some("auth".to_string()), Some(nc), Some(cnonce))
144        } else {
145            let response = md5_hex(&format!("{}:{}:{}", ha1, challenge.nonce, ha2));
146            (response, None, None, None)
147        }
148    } else {
149        let response = md5_hex(&format!("{}:{}:{}", ha1, challenge.nonce, ha2));
150        (response, None, None, None)
151    };
152
153    DigestResponse {
154        username: creds.username.clone(),
155        realm: challenge.realm.clone(),
156        nonce: challenge.nonce.clone(),
157        uri: uri.to_string(),
158        response,
159        algorithm: challenge.algorithm,
160        opaque: challenge.opaque.clone(),
161        qop,
162        nc,
163        cnonce,
164    }
165}
166
167/// Split parameters respecting quoted strings.
168fn split_params(s: &str) -> Vec<&str> {
169    let mut result = Vec::new();
170    let mut start = 0;
171    let mut in_quotes = false;
172    for (i, ch) in s.char_indices() {
173        match ch {
174            '"' => in_quotes = !in_quotes,
175            ',' if !in_quotes => {
176                result.push(&s[start..i]);
177                start = i + 1;
178            }
179            _ => {}
180        }
181    }
182    if start < s.len() {
183        result.push(&s[start..]);
184    }
185    result
186}
187
188/// Compute MD5 hex digest of a string.
189fn md5_hex(input: &str) -> String {
190    // Implement MD5 directly since we don't want to add a dependency.
191    // Use a simple pure-Rust MD5 implementation.
192    let digest = md5_compute(input.as_bytes());
193    hex_encode(&digest)
194}
195
196fn hex_encode(bytes: &[u8]) -> String {
197    let mut s = String::with_capacity(bytes.len() * 2);
198    for &b in bytes {
199        s.push_str(&format!("{:02x}", b));
200    }
201    s
202}
203
204fn generate_cnonce() -> String {
205    use rand::Rng;
206    let mut rng = rand::thread_rng();
207    let bytes: [u8; 8] = rng.gen();
208    hex_encode(&bytes)
209}
210
211// ── Pure-Rust MD5 implementation (RFC 1321) ──────────────────────────
212
213const S: [u32; 64] = [
214    7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
215    5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
216    4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
217    6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
218];
219
220const K: [u32; 64] = [
221    0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,
222    0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
223    0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,
224    0x6b901122,0xfd987193,0xa679438e,0x49b40821,
225    0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,
226    0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
227    0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,
228    0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
229    0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,
230    0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
231    0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,
232    0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
233    0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,
234    0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
235    0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,
236    0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391,
237];
238
239fn md5_compute(data: &[u8]) -> [u8; 16] {
240    let mut a0: u32 = 0x67452301;
241    let mut b0: u32 = 0xefcdab89;
242    let mut c0: u32 = 0x98badcfe;
243    let mut d0: u32 = 0x10325476;
244
245    // Pre-processing: add padding
246    let orig_len_bits = (data.len() as u64) * 8;
247    let mut msg = data.to_vec();
248    msg.push(0x80);
249    while msg.len() % 64 != 56 {
250        msg.push(0);
251    }
252    msg.extend_from_slice(&orig_len_bits.to_le_bytes());
253
254    // Process each 512-bit (64-byte) chunk
255    for chunk in msg.chunks_exact(64) {
256        let mut m = [0u32; 16];
257        for (i, word) in chunk.chunks_exact(4).enumerate() {
258            m[i] = u32::from_le_bytes([word[0], word[1], word[2], word[3]]);
259        }
260
261        let mut a = a0;
262        let mut b = b0;
263        let mut c = c0;
264        let mut d = d0;
265
266        for i in 0..64 {
267            let (f, g) = match i {
268                0..=15 => ((b & c) | ((!b) & d), i),
269                16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
270                32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
271                _ => (c ^ (b | (!d)), (7 * i) % 16),
272            };
273
274            let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
275            a = d;
276            d = c;
277            c = b;
278            b = b.wrapping_add(f.rotate_left(S[i]));
279        }
280
281        a0 = a0.wrapping_add(a);
282        b0 = b0.wrapping_add(b);
283        c0 = c0.wrapping_add(c);
284        d0 = d0.wrapping_add(d);
285    }
286
287    let mut result = [0u8; 16];
288    result[0..4].copy_from_slice(&a0.to_le_bytes());
289    result[4..8].copy_from_slice(&b0.to_le_bytes());
290    result[8..12].copy_from_slice(&c0.to_le_bytes());
291    result[12..16].copy_from_slice(&d0.to_le_bytes());
292    result
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_md5_known_values() {
301        // RFC 1321 test vectors
302        assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e");
303        assert_eq!(md5_hex("a"), "0cc175b9c0f1b6a831c399e269772661");
304        assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72");
305        assert_eq!(md5_hex("message digest"), "f96b697d7cb7938d525a2f31aaf161d0");
306    }
307
308    #[test]
309    fn test_parse_challenge_basic() {
310        let header = r#"Digest realm="asterisk", nonce="abc123def""#;
311        let challenge = parse_challenge(header).unwrap();
312        assert_eq!(challenge.realm, "asterisk");
313        assert_eq!(challenge.nonce, "abc123def");
314        assert_eq!(challenge.algorithm, DigestAlgorithm::Md5);
315        assert!(challenge.opaque.is_none());
316        assert!(challenge.qop.is_none());
317    }
318
319    #[test]
320    fn test_parse_challenge_full() {
321        let header = r#"Digest realm="biloxi.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41", qop="auth", algorithm=MD5"#;
322        let challenge = parse_challenge(header).unwrap();
323        assert_eq!(challenge.realm, "biloxi.com");
324        assert_eq!(challenge.nonce, "dcd98b7102dd2f0e8b11d0f600bfb0c093");
325        assert_eq!(challenge.opaque.as_deref(), Some("5ccc069c403ebaf9f0171e9517f40e41"));
326        assert_eq!(challenge.qop.as_deref(), Some("auth"));
327        assert_eq!(challenge.algorithm, DigestAlgorithm::Md5);
328    }
329
330    #[test]
331    fn test_compute_digest_rfc2617_example() {
332        // Based on RFC 2617 Section 3.5 example
333        let challenge = DigestChallenge {
334            realm: "testrealm@host.com".to_string(),
335            nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093".to_string(),
336            opaque: Some("5ccc069c403ebaf9f0171e9517f40e41".to_string()),
337            algorithm: DigestAlgorithm::Md5,
338            qop: Some("auth".to_string()),
339            stale: false,
340        };
341        let creds = Credentials {
342            username: "Mufasa".to_string(),
343            password: "Circle Of Life".to_string(),
344        };
345        let resp = compute_digest(&challenge, &creds, "GET", "/dir/index.html");
346        assert_eq!(resp.realm, "testrealm@host.com");
347        assert_eq!(resp.username, "Mufasa");
348        // Can't check exact response since cnonce is random, but verify it's 32 hex chars
349        assert_eq!(resp.response.len(), 32);
350        assert!(resp.response.chars().all(|c| c.is_ascii_hexdigit()));
351        assert_eq!(resp.qop.as_deref(), Some("auth"));
352    }
353
354    #[test]
355    fn test_compute_digest_no_qop() {
356        let challenge = DigestChallenge {
357            realm: "asterisk".to_string(),
358            nonce: "1234567890".to_string(),
359            opaque: None,
360            algorithm: DigestAlgorithm::Md5,
361            qop: None,
362            stale: false,
363        };
364        let creds = Credentials {
365            username: "alice".to_string(),
366            password: "secret".to_string(),
367        };
368        let resp = compute_digest(&challenge, &creds, "REGISTER", "sip:asterisk");
369
370        // Manually compute expected:
371        // HA1 = MD5("alice:asterisk:secret")
372        // HA2 = MD5("REGISTER:sip:asterisk")
373        // response = MD5(HA1:1234567890:HA2)
374        let ha1 = md5_hex("alice:asterisk:secret");
375        let ha2 = md5_hex("REGISTER:sip:asterisk");
376        let expected = md5_hex(&format!("{}:1234567890:{}", ha1, ha2));
377        assert_eq!(resp.response, expected);
378        assert!(resp.qop.is_none());
379    }
380
381    #[test]
382    fn test_digest_response_display() {
383        let resp = DigestResponse {
384            username: "alice".to_string(),
385            realm: "asterisk".to_string(),
386            nonce: "abc123".to_string(),
387            uri: "sip:asterisk".to_string(),
388            response: "deadbeef01234567890abcdef0123456".to_string(),
389            algorithm: DigestAlgorithm::Md5,
390            opaque: Some("xyz".to_string()),
391            qop: None,
392            nc: None,
393            cnonce: None,
394        };
395        let s = resp.to_string();
396        assert!(s.starts_with("Digest "));
397        assert!(s.contains("username=\"alice\""));
398        assert!(s.contains("opaque=\"xyz\""));
399        assert!(!s.contains("qop="));
400    }
401
402    #[test]
403    fn test_parse_challenge_stale() {
404        let header = r#"Digest realm="test", nonce="new_nonce", stale=true"#;
405        let challenge = parse_challenge(header).unwrap();
406        assert!(challenge.stale);
407    }
408
409    #[test]
410    fn test_split_params_with_quotes() {
411        let input = r#"realm="a,b", nonce="c""#;
412        let params = split_params(input);
413        assert_eq!(params.len(), 2);
414        assert!(params[0].contains("a,b"));
415    }
416}