Skip to main content

heldar_kernel/services/camera_config/
digest.rs

1//! HTTP Digest access authentication (RFC 2617) — hand-rolled for HikVision ISAPI.
2//!
3//! The flow lives in the service layer: send a request unauthenticated, and on `401` read the
4//! `WWW-Authenticate` header, build an `Authorization: Digest ...` header with
5//! [`digest_auth_header`], and retry once. The MD5 hashing uses the `md-5` crate; the client nonce
6//! is drawn from the OS CSPRNG (mirroring `auth.rs` / `services/onvif.rs`).
7
8use std::collections::HashMap;
9
10use argon2::password_hash::rand_core::OsRng;
11use md5::{Digest, Md5};
12use rand_core::RngCore;
13
14/// Lowercase hex MD5 of `data`.
15fn md5_hex(data: &str) -> String {
16    let mut h = Md5::new();
17    h.update(data.as_bytes());
18    crate::auth::hex_encode(&h.finalize())
19}
20
21/// Parse a `WWW-Authenticate: Digest ...` challenge into its `key="value"` (or `key=value`) params.
22/// Keys are lowercased; surrounding quotes are stripped. Commas inside quoted values (e.g.
23/// `qop="auth,auth-int"`) are respected.
24fn parse_challenge(header: &str) -> HashMap<String, String> {
25    let s = header.trim();
26    let s = s
27        .strip_prefix("Digest")
28        .or_else(|| s.strip_prefix("digest"))
29        .unwrap_or(s)
30        .trim_start();
31
32    let mut params = HashMap::new();
33    let mut start = 0;
34    let mut in_quotes = false;
35    let bytes = s.as_bytes();
36    let push = |chunk: &str, params: &mut HashMap<String, String>| {
37        let chunk = chunk.trim();
38        if let Some(eq) = chunk.find('=') {
39            let key = chunk[..eq].trim().to_ascii_lowercase();
40            let mut val = chunk[eq + 1..].trim();
41            if val.len() >= 2 && val.starts_with('"') && val.ends_with('"') {
42                val = &val[1..val.len() - 1];
43            }
44            if !key.is_empty() {
45                params.insert(key, val.to_string());
46            }
47        }
48    };
49    for (i, &b) in bytes.iter().enumerate() {
50        match b {
51            b'"' => in_quotes = !in_quotes,
52            b',' if !in_quotes => {
53                push(&s[start..i], &mut params);
54                start = i + 1;
55            }
56            _ => {}
57        }
58    }
59    push(&s[start..], &mut params);
60    params
61}
62
63/// Build the `Authorization` header value from the parsed challenge fields and a chosen client nonce.
64#[allow(clippy::too_many_arguments)]
65fn build_header(
66    method: &str,
67    uri: &str,
68    username: &str,
69    password: &str,
70    realm: &str,
71    nonce: &str,
72    qop: Option<&str>,
73    opaque: Option<&str>,
74    cnonce: &str,
75    nc: &str,
76) -> String {
77    let ha1 = md5_hex(&format!("{username}:{realm}:{password}"));
78    let ha2 = md5_hex(&format!("{method}:{uri}"));
79    let mut header = match qop {
80        Some(qop) => {
81            let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}"));
82            format!(
83                "Digest username=\"{username}\", realm=\"{realm}\", nonce=\"{nonce}\", \
84uri=\"{uri}\", response=\"{response}\", qop={qop}, nc={nc}, cnonce=\"{cnonce}\""
85            )
86        }
87        None => {
88            let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}"));
89            format!(
90                "Digest username=\"{username}\", realm=\"{realm}\", nonce=\"{nonce}\", \
91uri=\"{uri}\", response=\"{response}\""
92            )
93        }
94    };
95    if let Some(opaque) = opaque {
96        header.push_str(&format!(", opaque=\"{opaque}\""));
97    }
98    header
99}
100
101/// Compute an HTTP Digest `Authorization` header for `method`+`uri` given a `WWW-Authenticate`
102/// challenge. Returns `None` when the challenge lacks the required `realm`/`nonce`. When the server
103/// offers `qop=auth`, a fresh client nonce (`cnonce`) is generated and `nc` is `00000001`.
104pub fn digest_auth_header(
105    method: &str,
106    uri: &str,
107    username: &str,
108    password: &str,
109    www_auth: &str,
110) -> Option<String> {
111    let challenge = parse_challenge(www_auth);
112    let realm = challenge.get("realm")?;
113    let nonce = challenge.get("nonce")?;
114    let opaque = challenge.get("opaque").map(String::as_str);
115    // Select the `auth` qop if the server offers it (the list may also include `auth-int`).
116    let qop = challenge
117        .get("qop")
118        .and_then(|q| q.split(',').map(str::trim).find(|t| *t == "auth"));
119
120    let (cnonce, nc) = if qop.is_some() {
121        let mut buf = [0u8; 8];
122        OsRng.fill_bytes(&mut buf);
123        (crate::auth::hex_encode(&buf), "00000001")
124    } else {
125        (String::new(), "")
126    };
127
128    Some(build_header(
129        method, uri, username, password, realm, nonce, qop, opaque, &cnonce, nc,
130    ))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn computes_rfc2617_qop_auth_response() {
139        // Canonical RFC 2617 §3.5 example (fixed cnonce/nc so the response is deterministic).
140        let header = build_header(
141            "GET",
142            "/dir/index.html",
143            "Mufasa",
144            "Circle Of Life",
145            "testrealm@host.com",
146            "dcd98b7102dd2f0e8b11d0f600bfb0c093",
147            Some("auth"),
148            None,
149            "0a4f113b",
150            "00000001",
151        );
152        assert!(header.contains("response=\"6629fae49393a05397450978507c4ef1\""));
153        assert!(header.contains("qop=auth"));
154        assert!(header.contains("cnonce=\"0a4f113b\""));
155        assert!(header.contains("uri=\"/dir/index.html\""));
156    }
157
158    #[test]
159    fn parses_challenge_with_quoted_qop_list() {
160        let c = parse_challenge(
161            "Digest realm=\"DS-2CD\", qop=\"auth,auth-int\", nonce=\"abc123\", opaque=\"xyz\"",
162        );
163        assert_eq!(c.get("realm").map(String::as_str), Some("DS-2CD"));
164        assert_eq!(c.get("nonce").map(String::as_str), Some("abc123"));
165        assert_eq!(c.get("qop").map(String::as_str), Some("auth,auth-int"));
166        assert_eq!(c.get("opaque").map(String::as_str), Some("xyz"));
167    }
168
169    #[test]
170    fn selects_auth_qop_and_emits_client_nonce() {
171        let h = digest_auth_header(
172            "GET",
173            "/ISAPI/System/deviceInfo",
174            "admin",
175            "secret",
176            "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"",
177        )
178        .expect("header");
179        assert!(h.contains("qop=auth"));
180        assert!(h.contains("nc=00000001"));
181        assert!(h.contains("cnonce="));
182        assert!(h.contains("uri=\"/ISAPI/System/deviceInfo\""));
183    }
184
185    #[test]
186    fn legacy_no_qop_response() {
187        let h =
188            digest_auth_header("GET", "/x", "u", "p", "Digest realm=\"r\", nonce=\"n\"").unwrap();
189        assert!(h.contains("response=\""));
190        assert!(!h.contains("qop="));
191        assert!(!h.contains("cnonce="));
192    }
193
194    #[test]
195    fn missing_realm_or_nonce_yields_none() {
196        assert!(digest_auth_header("GET", "/x", "u", "p", "Digest nonce=\"n\"").is_none());
197        assert!(digest_auth_header("GET", "/x", "u", "p", "Digest realm=\"r\"").is_none());
198    }
199}