1use std::collections::BTreeMap;
22
23use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
24use hmac::{Hmac, KeyInit, Mac};
25use sha1::Sha1;
26use subtle::ConstantTimeEq;
27use tracing::debug;
28
29use crate::{credentials::CredentialProvider, error::AuthError, sigv4::AuthResult};
30
31type HmacSha1 = Hmac<Sha1>;
32
33#[must_use]
35pub fn is_sigv2(auth_header: &str) -> bool {
36 auth_header.starts_with("AWS ") && !auth_header.starts_with("AWS4-")
37}
38
39pub fn verify_sigv2(
46 parts: &http::request::Parts,
47 credential_provider: &dyn CredentialProvider,
48) -> Result<AuthResult, AuthError> {
49 let auth_header = parts
50 .headers
51 .get(http::header::AUTHORIZATION)
52 .ok_or(AuthError::MissingAuthHeader)?
53 .to_str()
54 .map_err(|_| AuthError::InvalidAuthHeader)?;
55
56 let (access_key_id, provided_signature) = parse_sigv2_header(auth_header)?;
57
58 debug!(access_key_id = %access_key_id, "Verifying SigV2 signature");
59
60 let secret_key = credential_provider.get_secret_key(&access_key_id)?;
61
62 let string_to_sign = build_string_to_sign(parts);
63
64 debug!(string_to_sign = ?string_to_sign, "Built SigV2 string to sign");
65
66 let expected_signature = compute_sigv2_signature(&secret_key, &string_to_sign);
67
68 if provided_signature
69 .as_bytes()
70 .ct_eq(expected_signature.as_bytes())
71 .into()
72 {
73 debug!(access_key_id = %access_key_id, "SigV2 verification succeeded");
74 Ok(AuthResult {
75 access_key_id,
76 region: String::new(),
77 service: "s3".to_owned(),
78 signed_headers: Vec::new(),
79 })
80 } else {
81 debug!(
82 expected = %expected_signature,
83 provided = %provided_signature,
84 "SigV2 signature mismatch"
85 );
86 Err(AuthError::SignatureDoesNotMatch)
87 }
88}
89
90fn parse_sigv2_header(header: &str) -> Result<(String, String), AuthError> {
92 let rest = header
93 .strip_prefix("AWS ")
94 .ok_or(AuthError::InvalidAuthHeader)?;
95
96 let (access_key_id, signature) = rest.split_once(':').ok_or(AuthError::InvalidAuthHeader)?;
97
98 if access_key_id.is_empty() || signature.is_empty() {
99 return Err(AuthError::InvalidAuthHeader);
100 }
101
102 Ok((access_key_id.to_owned(), signature.to_owned()))
103}
104
105fn build_string_to_sign(parts: &http::request::Parts) -> String {
116 let method = parts.method.as_str();
117 let content_md5 = header_value(parts, "content-md5");
118 let content_type = header_value(parts, "content-type");
119
120 let date = if parts.headers.contains_key("x-amz-date") {
122 "" } else {
124 &header_value(parts, "date")
125 };
126
127 let amz_headers = build_canonicalized_amz_headers(parts);
128 let resource = build_canonicalized_resource(parts);
129
130 format!("{method}\n{content_md5}\n{content_type}\n{date}\n{amz_headers}{resource}")
131}
132
133fn build_canonicalized_amz_headers(parts: &http::request::Parts) -> String {
138 let mut amz_headers: BTreeMap<String, Vec<String>> = BTreeMap::new();
139
140 for (name, value) in &parts.headers {
141 let name_str = name.as_str();
142 if name_str.starts_with("x-amz-") {
143 let val = value.to_str().unwrap_or("").trim().to_owned();
144 amz_headers
145 .entry(name_str.to_owned())
146 .or_default()
147 .push(val);
148 }
149 }
150
151 let mut result = String::new();
152 for (name, values) in &amz_headers {
153 result.push_str(name);
154 result.push(':');
155 result.push_str(&values.join(","));
156 result.push('\n');
157 }
158
159 result
160}
161
162fn build_canonicalized_resource(parts: &http::request::Parts) -> String {
166 const SUB_RESOURCES: &[&str] = &[
168 "acl",
169 "cors",
170 "delete",
171 "lifecycle",
172 "location",
173 "logging",
174 "notification",
175 "partNumber",
176 "policy",
177 "requestPayment",
178 "response-cache-control",
179 "response-content-disposition",
180 "response-content-encoding",
181 "response-content-language",
182 "response-content-type",
183 "response-expires",
184 "restore",
185 "tagging",
186 "torrent",
187 "uploadId",
188 "uploads",
189 "versionId",
190 "versioning",
191 "versions",
192 "website",
193 ];
194
195 let path = parts.uri.path();
196 let query = parts.uri.query().unwrap_or("");
197 let mut sub_params: Vec<(String, Option<String>)> = Vec::new();
198
199 if !query.is_empty() {
200 for param in query.split('&') {
201 let (key, value) = param.split_once('=').map_or((param, None), |(k, v)| {
202 let decoded = percent_encoding::percent_decode_str(v)
203 .decode_utf8_lossy()
204 .into_owned();
205 let value = if decoded.is_empty() {
207 None
208 } else {
209 Some(decoded)
210 };
211 (k, value)
212 });
213 if SUB_RESOURCES.contains(&key) {
214 sub_params.push((key.to_owned(), value));
215 }
216 }
217 }
218
219 sub_params.sort_by(|a, b| a.0.cmp(&b.0));
220
221 if sub_params.is_empty() {
222 path.to_owned()
223 } else {
224 let params_str: Vec<String> = sub_params
225 .iter()
226 .map(|(k, v)| match v {
227 Some(val) => format!("{k}={val}"),
228 None => k.clone(),
229 })
230 .collect();
231 format!("{path}?{}", params_str.join("&"))
232 }
233}
234
235fn compute_sigv2_signature(secret_key: &str, string_to_sign: &str) -> String {
237 let mut mac =
238 HmacSha1::new_from_slice(secret_key.as_bytes()).expect("HMAC can accept any key length");
239 mac.update(string_to_sign.as_bytes());
240 let result = mac.finalize().into_bytes();
241 BASE64.encode(result)
242}
243
244fn header_value(parts: &http::request::Parts, name: &str) -> String {
246 parts
247 .headers
248 .get(name)
249 .and_then(|v| v.to_str().ok())
250 .unwrap_or("")
251 .to_owned()
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::credentials::StaticCredentialProvider;
258
259 const TEST_ACCESS_KEY: &str = "minioadmin";
260 const TEST_SECRET_KEY: &str = "minioadmin";
261
262 fn test_credential_provider() -> StaticCredentialProvider {
263 StaticCredentialProvider::new(vec![(
264 TEST_ACCESS_KEY.to_owned(),
265 TEST_SECRET_KEY.to_owned(),
266 )])
267 }
268
269 #[test]
270 fn test_should_detect_sigv2_header() {
271 assert!(is_sigv2("AWS AKID:signature"));
272 assert!(!is_sigv2("AWS4-HMAC-SHA256 Credential=..."));
273 assert!(!is_sigv2("Bearer token"));
274 }
275
276 #[test]
277 fn test_should_parse_sigv2_header() {
278 let (akid, sig) = parse_sigv2_header("AWS mykey:mysignature").unwrap();
279 assert_eq!(akid, "mykey");
280 assert_eq!(sig, "mysignature");
281 }
282
283 #[test]
284 fn test_should_reject_invalid_sigv2_header() {
285 assert!(parse_sigv2_header("AWS :sig").is_err());
286 assert!(parse_sigv2_header("AWS key:").is_err());
287 assert!(parse_sigv2_header("AWS noseparator").is_err());
288 assert!(parse_sigv2_header("NOTAWS key:sig").is_err());
289 }
290
291 #[test]
292 fn test_should_compute_sigv2_signature() {
293 let sig = compute_sigv2_signature("secret", "data");
294 assert!(!sig.is_empty());
295 let sig2 = compute_sigv2_signature("secret", "data");
297 assert_eq!(sig, sig2);
298 }
299
300 #[test]
301 fn test_should_verify_sigv2_roundtrip() {
302 let provider = test_credential_provider();
303
304 let date = "Sat, 28 Feb 2026 12:00:00 GMT";
306 let string_to_sign = format!("GET\n\n\n{date}\n/test-bucket/");
307 let signature = compute_sigv2_signature(TEST_SECRET_KEY, &string_to_sign);
308
309 let auth_header = format!("AWS {TEST_ACCESS_KEY}:{signature}");
310
311 let (parts, ()) = http::Request::builder()
312 .method("GET")
313 .uri("http://localhost:4566/test-bucket/")
314 .header("host", "localhost:4566")
315 .header("date", date)
316 .header(http::header::AUTHORIZATION, &auth_header)
317 .body(())
318 .unwrap()
319 .into_parts();
320
321 let result = verify_sigv2(&parts, &provider);
322 assert!(result.is_ok(), "verify_sigv2 failed: {result:?}");
323 assert_eq!(result.unwrap().access_key_id, TEST_ACCESS_KEY);
324 }
325}