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/// Escape a value for an RFC 7616 §3.4 `quoted-string` parameter: each backslash and double-quote
64/// is prefixed with a backslash (`\` -> `\\`, `"` -> `\"`). All other characters pass through.
65/// This keeps the header well-formed when a credential/server field contains `"` or `\`.
66fn quote_escape(value: &str) -> String {
67    let mut out = String::with_capacity(value.len());
68    for c in value.chars() {
69        if c == '"' || c == '\\' {
70            out.push('\\');
71        }
72        out.push(c);
73    }
74    out
75}
76
77/// Build the `Authorization` header value from the parsed challenge fields and a chosen client nonce.
78#[allow(clippy::too_many_arguments)]
79fn build_header(
80    method: &str,
81    uri: &str,
82    username: &str,
83    password: &str,
84    realm: &str,
85    nonce: &str,
86    qop: Option<&str>,
87    opaque: Option<&str>,
88    cnonce: &str,
89    nc: &str,
90) -> String {
91    let ha1 = md5_hex(&format!("{username}:{realm}:{password}"));
92    let ha2 = md5_hex(&format!("{method}:{uri}"));
93    // RFC 7616 §3.4: values emitted as `quoted-string` params must have `\` and `"` escaped. The
94    // digest above is computed over the *unescaped* values; only the rendered header is escaped.
95    // `response`/`nc`/`qop` are hex/tokens and are emitted verbatim.
96    let username_q = quote_escape(username);
97    let realm_q = quote_escape(realm);
98    let nonce_q = quote_escape(nonce);
99    let uri_q = quote_escape(uri);
100    let cnonce_q = quote_escape(cnonce);
101    let mut header = match qop {
102        Some(qop) => {
103            let response = md5_hex(&format!("{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}"));
104            format!(
105                "Digest username=\"{username_q}\", realm=\"{realm_q}\", nonce=\"{nonce_q}\", \
106uri=\"{uri_q}\", response=\"{response}\", qop={qop}, nc={nc}, cnonce=\"{cnonce_q}\""
107            )
108        }
109        None => {
110            let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}"));
111            format!(
112                "Digest username=\"{username_q}\", realm=\"{realm_q}\", nonce=\"{nonce_q}\", \
113uri=\"{uri_q}\", response=\"{response}\""
114            )
115        }
116    };
117    if let Some(opaque) = opaque {
118        let opaque_q = quote_escape(opaque);
119        header.push_str(&format!(", opaque=\"{opaque_q}\""));
120    }
121    header
122}
123
124/// Compute an HTTP Digest `Authorization` header for `method`+`uri` given a `WWW-Authenticate`
125/// challenge. Returns `None` when the challenge lacks the required `realm`/`nonce`. When the server
126/// offers `qop=auth`, a fresh client nonce (`cnonce`) is generated and `nc` is `00000001`.
127pub fn digest_auth_header(
128    method: &str,
129    uri: &str,
130    username: &str,
131    password: &str,
132    www_auth: &str,
133) -> Option<String> {
134    let challenge = parse_challenge(www_auth);
135    let realm = challenge.get("realm")?;
136    let nonce = challenge.get("nonce")?;
137    let opaque = challenge.get("opaque").map(String::as_str);
138    // Select the `auth` qop if the server offers it (the list may also include `auth-int`).
139    let qop = challenge
140        .get("qop")
141        .and_then(|q| q.split(',').map(str::trim).find(|t| *t == "auth"));
142
143    let (cnonce, nc) = if qop.is_some() {
144        let mut buf = [0u8; 8];
145        OsRng.fill_bytes(&mut buf);
146        (crate::auth::hex_encode(&buf), "00000001")
147    } else {
148        (String::new(), "")
149    };
150
151    Some(build_header(
152        method, uri, username, password, realm, nonce, qop, opaque, &cnonce, nc,
153    ))
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn computes_rfc2617_qop_auth_response() {
162        // Canonical RFC 2617 §3.5 example (fixed cnonce/nc so the response is deterministic).
163        let header = build_header(
164            "GET",
165            "/dir/index.html",
166            "Mufasa",
167            "Circle Of Life",
168            "testrealm@host.com",
169            "dcd98b7102dd2f0e8b11d0f600bfb0c093",
170            Some("auth"),
171            None,
172            "0a4f113b",
173            "00000001",
174        );
175        assert!(header.contains("response=\"6629fae49393a05397450978507c4ef1\""));
176        assert!(header.contains("qop=auth"));
177        assert!(header.contains("cnonce=\"0a4f113b\""));
178        assert!(header.contains("uri=\"/dir/index.html\""));
179    }
180
181    #[test]
182    fn parses_challenge_with_quoted_qop_list() {
183        let c = parse_challenge(
184            "Digest realm=\"DS-2CD\", qop=\"auth,auth-int\", nonce=\"abc123\", opaque=\"xyz\"",
185        );
186        assert_eq!(c.get("realm").map(String::as_str), Some("DS-2CD"));
187        assert_eq!(c.get("nonce").map(String::as_str), Some("abc123"));
188        assert_eq!(c.get("qop").map(String::as_str), Some("auth,auth-int"));
189        assert_eq!(c.get("opaque").map(String::as_str), Some("xyz"));
190    }
191
192    #[test]
193    fn selects_auth_qop_and_emits_client_nonce() {
194        let h = digest_auth_header(
195            "GET",
196            "/ISAPI/System/deviceInfo",
197            "admin",
198            "secret",
199            "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"",
200        )
201        .expect("header");
202        assert!(h.contains("qop=auth"));
203        assert!(h.contains("nc=00000001"));
204        assert!(h.contains("cnonce="));
205        assert!(h.contains("uri=\"/ISAPI/System/deviceInfo\""));
206    }
207
208    #[test]
209    fn legacy_no_qop_response() {
210        let h =
211            digest_auth_header("GET", "/x", "u", "p", "Digest realm=\"r\", nonce=\"n\"").unwrap();
212        assert!(h.contains("response=\""));
213        assert!(!h.contains("qop="));
214        assert!(!h.contains("cnonce="));
215    }
216
217    #[test]
218    fn missing_realm_or_nonce_yields_none() {
219        assert!(digest_auth_header("GET", "/x", "u", "p", "Digest nonce=\"n\"").is_none());
220        assert!(digest_auth_header("GET", "/x", "u", "p", "Digest realm=\"r\"").is_none());
221    }
222
223    #[test]
224    fn escapes_quote_and_backslash_in_quoted_string_params() {
225        // A username containing a double-quote must be backslash-escaped so the `quoted-string`
226        // stays well-formed (RFC 7616 §3.4); the bare `user"x` would prematurely close the value.
227        let header = build_header(
228            "GET",
229            "/x",
230            "user\"x",
231            "p",
232            "r",
233            "n",
234            Some("auth"),
235            None,
236            "0a4f113b",
237            "00000001",
238        );
239        assert!(
240            header.contains("username=\"user\\\"x\""),
241            "double-quote in username must be escaped: {header}"
242        );
243
244        // A backslash is doubled (legacy no-qop branch).
245        let header2 = build_header("GET", "/x", "ab\\cd", "p", "r", "n", None, None, "", "");
246        assert!(
247            header2.contains("username=\"ab\\\\cd\""),
248            "backslash in username must be doubled: {header2}"
249        );
250
251        // The helper itself escapes both specials and passes other characters through unchanged.
252        assert_eq!(quote_escape("a\"b\\c"), "a\\\"b\\\\c");
253        assert_eq!(quote_escape("plain"), "plain");
254    }
255}