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
63#[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
101pub 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 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 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}