heldar_kernel/services/camera_config/
digest.rs1use std::collections::HashMap;
9
10use argon2::password_hash::rand_core::OsRng;
11use md5::{Digest, Md5};
12use rand_core::RngCore;
13
14fn 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
21fn 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
63fn 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#[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 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
124pub 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 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 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 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 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 assert_eq!(quote_escape("a\"b\\c"), "a\\\"b\\\\c");
253 assert_eq!(quote_escape("plain"), "plain");
254 }
255}