1use std::collections::HashMap;
16
17use chrono::{NaiveDateTime, Utc};
18use sha2::{Digest, Sha256};
19use subtle::ConstantTimeEq;
20use tracing::debug;
21
22use crate::{
23 canonical::{
24 build_canonical_headers, build_canonical_query_string, build_canonical_uri,
25 build_signed_headers_string,
26 },
27 credentials::CredentialProvider,
28 error::AuthError,
29 sigv4::{AuthResult, build_string_to_sign, compute_signature, derive_signing_key},
30};
31
32const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
34
35#[derive(Debug, Clone)]
37pub struct ParsedPresignedParams {
38 pub algorithm: String,
40 pub access_key_id: String,
42 pub date: String,
44 pub region: String,
46 pub service: String,
48 pub timestamp: String,
50 pub expires: u64,
52 pub signed_headers: Vec<String>,
54 pub signature: String,
56}
57
58pub fn parse_presigned_params(query: &str) -> Result<ParsedPresignedParams, AuthError> {
66 let params: HashMap<String, String> = query
67 .split('&')
68 .filter(|s| !s.is_empty())
69 .filter_map(|param| {
70 let (key, value) = param.split_once('=')?;
71 Some((key.to_owned(), url_decode(value)))
72 })
73 .collect();
74
75 let algorithm = get_required_param(¶ms, "X-Amz-Algorithm")?;
76 if algorithm != "AWS4-HMAC-SHA256" {
77 return Err(AuthError::UnsupportedAlgorithm(algorithm));
78 }
79
80 let credential = get_required_param(¶ms, "X-Amz-Credential")?;
81 let timestamp = get_required_param(¶ms, "X-Amz-Date")?;
82 let expires_str = get_required_param(¶ms, "X-Amz-Expires")?;
83 let signed_headers_str = get_required_param(¶ms, "X-Amz-SignedHeaders")?;
84 let signature = get_required_param(¶ms, "X-Amz-Signature")?;
85
86 let cred_parts: Vec<&str> = credential.splitn(5, '/').collect();
88 if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" {
89 return Err(AuthError::InvalidCredential);
90 }
91
92 let expires: u64 = expires_str
93 .parse()
94 .map_err(|_| AuthError::MissingQueryParam("X-Amz-Expires (invalid integer)".to_owned()))?;
95
96 let signed_headers: Vec<String> = signed_headers_str
97 .split(';')
98 .map(ToOwned::to_owned)
99 .collect();
100
101 Ok(ParsedPresignedParams {
102 algorithm,
103 access_key_id: cred_parts[0].to_owned(),
104 date: cred_parts[1].to_owned(),
105 region: cred_parts[2].to_owned(),
106 service: cred_parts[3].to_owned(),
107 timestamp,
108 expires,
109 signed_headers,
110 signature,
111 })
112}
113
114pub fn verify_presigned(
133 parts: &http::request::Parts,
134 credential_provider: &dyn CredentialProvider,
135) -> Result<AuthResult, AuthError> {
136 let query = parts.uri.query().unwrap_or("");
137 let parsed = parse_presigned_params(query)?;
138
139 debug!(
140 access_key_id = %parsed.access_key_id,
141 date = %parsed.date,
142 region = %parsed.region,
143 service = %parsed.service,
144 expires = parsed.expires,
145 "Verifying presigned URL"
146 );
147
148 check_expiration(&parsed.timestamp, parsed.expires)?;
150
151 let secret_key = credential_provider.get_secret_key(&parsed.access_key_id)?;
153
154 let method = parts.method.as_str();
156 let uri = parts.uri.path();
157 let canonical_uri = build_canonical_uri(uri);
158
159 let canonical_query = build_canonical_query_string_without_signature(query);
161
162 let signed_header_refs: Vec<&str> = parsed.signed_headers.iter().map(String::as_str).collect();
164 let header_pairs: Vec<(&str, &str)> =
165 collect_signed_headers_for_presigned(parts, &signed_header_refs)?;
166
167 let canonical_headers = build_canonical_headers(&header_pairs, &signed_header_refs);
168 let signed_headers_str = build_signed_headers_string(&signed_header_refs);
169
170 #[rustfmt::skip]
172 let canonical_request = format!(
173 "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n\n{signed_headers_str}\n{UNSIGNED_PAYLOAD}"
174 );
175
176 debug!(canonical_request, "Built presigned canonical request");
177
178 let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
180
181 let credential_scope = format!(
183 "{}/{}/{}/aws4_request",
184 parsed.date, parsed.region, parsed.service
185 );
186 let string_to_sign =
187 build_string_to_sign(&parsed.timestamp, &credential_scope, &canonical_hash);
188
189 debug!(string_to_sign, "Built presigned string to sign");
190
191 let signing_key =
193 derive_signing_key(&secret_key, &parsed.date, &parsed.region, &parsed.service);
194 let expected_signature = compute_signature(&signing_key, &string_to_sign);
195
196 let provided_bytes = parsed.signature.as_bytes();
198 let expected_bytes = expected_signature.as_bytes();
199
200 if provided_bytes.ct_eq(expected_bytes).into() {
201 debug!(access_key_id = %parsed.access_key_id, "Presigned URL verification succeeded");
202 Ok(AuthResult {
203 access_key_id: parsed.access_key_id,
204 region: parsed.region,
205 service: parsed.service,
206 signed_headers: parsed.signed_headers,
207 })
208 } else {
209 debug!(
210 expected = %expected_signature,
211 provided = %parsed.signature,
212 "Presigned URL signature mismatch"
213 );
214 Err(AuthError::SignatureDoesNotMatch)
215 }
216}
217
218fn build_canonical_query_string_without_signature(query: &str) -> String {
222 let filtered: String = query
223 .split('&')
224 .filter(|param| !param.starts_with("X-Amz-Signature="))
225 .collect::<Vec<_>>()
226 .join("&");
227 build_canonical_query_string(&filtered)
228}
229
230fn check_expiration(timestamp: &str, expires: u64) -> Result<(), AuthError> {
232 let request_time = NaiveDateTime::parse_from_str(timestamp, "%Y%m%dT%H%M%SZ")
233 .map_err(|_| AuthError::MissingQueryParam("X-Amz-Date (invalid format)".to_owned()))?;
234
235 let expiry_time = request_time
236 + chrono::Duration::seconds(i64::try_from(expires).map_err(|_| AuthError::RequestExpired)?);
237
238 let now = Utc::now().naive_utc();
239 if now > expiry_time {
240 return Err(AuthError::RequestExpired);
241 }
242
243 Ok(())
244}
245
246fn collect_signed_headers_for_presigned<'a>(
248 parts: &'a http::request::Parts,
249 signed_headers: &[&'a str],
250) -> Result<Vec<(&'a str, &'a str)>, AuthError> {
251 let mut result = Vec::with_capacity(signed_headers.len());
252
253 for &name in signed_headers {
254 let value = parts
255 .headers
256 .get(name)
257 .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
258 .to_str()
259 .map_err(|_| AuthError::MissingHeader(name.to_owned()))?;
260 result.push((name, value));
261 }
262
263 Ok(result)
264}
265
266fn url_decode(input: &str) -> String {
268 percent_encoding::percent_decode_str(input)
269 .decode_utf8_lossy()
270 .into_owned()
271}
272
273fn get_required_param(params: &HashMap<String, String>, name: &str) -> Result<String, AuthError> {
275 params
276 .get(name)
277 .cloned()
278 .ok_or_else(|| AuthError::MissingQueryParam(name.to_owned()))
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::credentials::StaticCredentialProvider;
285
286 const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
287 const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
288
289 fn test_credential_provider() -> StaticCredentialProvider {
290 StaticCredentialProvider::new(vec![(
291 TEST_ACCESS_KEY.to_owned(),
292 TEST_SECRET_KEY.to_owned(),
293 )])
294 }
295
296 #[test]
297 fn test_should_parse_presigned_params() {
298 #[rustfmt::skip]
299 let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404";
300
301 let parsed = parse_presigned_params(query).unwrap();
302 assert_eq!(parsed.algorithm, "AWS4-HMAC-SHA256");
303 assert_eq!(parsed.access_key_id, "AKIAIOSFODNN7EXAMPLE");
304 assert_eq!(parsed.date, "20130524");
305 assert_eq!(parsed.region, "us-east-1");
306 assert_eq!(parsed.service, "s3");
307 assert_eq!(parsed.timestamp, "20130524T000000Z");
308 assert_eq!(parsed.expires, 86400);
309 assert_eq!(parsed.signed_headers, vec!["host"]);
310 assert_eq!(
311 parsed.signature,
312 "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
313 );
314 }
315
316 #[test]
317 fn test_should_reject_missing_algorithm_param() {
318 let query = "X-Amz-Credential=AKID%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
319 X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&\
320 X-Amz-Signature=abc";
321
322 let result = parse_presigned_params(query);
323 assert!(matches!(result, Err(AuthError::MissingQueryParam(_))));
324 }
325
326 #[test]
327 fn test_should_reject_expired_presigned_url() {
328 let result = check_expiration("20130524T000000Z", 86400);
330 assert!(matches!(result, Err(AuthError::RequestExpired)));
331 }
332
333 #[test]
334 fn test_should_accept_non_expired_presigned_url() {
335 let now = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
337 let result = check_expiration(&now, 86400);
338 assert!(result.is_ok());
339 }
340
341 #[test]
342 fn test_should_build_query_string_without_signature() {
343 let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKID%2F20130524%\
344 2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&\
345 X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123";
346
347 let result = build_canonical_query_string_without_signature(query);
348 assert!(!result.contains("X-Amz-Signature"));
349 assert!(result.contains("X-Amz-Algorithm"));
350 assert!(result.contains("X-Amz-Expires"));
351 }
352
353 #[test]
354 fn test_should_verify_presigned_url_matching_aws_example() {
355 let signing_key = derive_signing_key(TEST_SECRET_KEY, "20130524", "us-east-1", "s3");
362
363 let canonical_request = "GET\n/test.txt\nX-Amz-Algorithm=AWS4-HMAC-SHA256&\
365 X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%\
366 2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&\
367 X-Amz-Expires=86400&X-Amz-SignedHeaders=host\nhost:examplebucket.\
368 s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD";
369
370 let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
371 assert_eq!(
372 canonical_hash,
373 "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04"
374 );
375
376 let string_to_sign = build_string_to_sign(
377 "20130524T000000Z",
378 "20130524/us-east-1/s3/aws4_request",
379 &canonical_hash,
380 );
381
382 let signature = compute_signature(&signing_key, &string_to_sign);
383 assert_eq!(
384 signature,
385 "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
386 );
387 }
388
389 #[test]
390 fn test_should_verify_presigned_url_with_live_timestamp() {
391 let provider = test_credential_provider();
393 let now = Utc::now();
394 let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
395 let date = now.format("%Y%m%d").to_string();
396
397 let credential = format!("{TEST_ACCESS_KEY}/{date}/us-east-1/s3/aws4_request");
398
399 let canonical_uri = "/test.txt";
401 let query_without_sig = format!(
402 "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={}&X-Amz-Date={timestamp}&\
403 X-Amz-Expires=86400&X-Amz-SignedHeaders=host",
404 percent_encoding::utf8_percent_encode(&credential, percent_encoding::NON_ALPHANUMERIC)
405 );
406
407 let canonical_query = build_canonical_query_string(&query_without_sig);
408
409 #[rustfmt::skip]
410 let canonical_request = format!(
411 "GET\n{canonical_uri}\n{canonical_query}\nhost:examplebucket.s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
412 );
413
414 let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
415 let credential_scope = format!("{date}/us-east-1/s3/aws4_request");
416 let string_to_sign = build_string_to_sign(×tamp, &credential_scope, &canonical_hash);
417
418 let signing_key = derive_signing_key(TEST_SECRET_KEY, &date, "us-east-1", "s3");
419 let signature = compute_signature(&signing_key, &string_to_sign);
420
421 let full_query = format!("{query_without_sig}&X-Amz-Signature={signature}");
423 let uri = format!("http://examplebucket.s3.amazonaws.com/test.txt?{full_query}");
424
425 let (parts, _body) = http::Request::builder()
426 .method("GET")
427 .uri(&uri)
428 .header("host", "examplebucket.s3.amazonaws.com")
429 .body(())
430 .unwrap()
431 .into_parts();
432
433 let result = verify_presigned(&parts, &provider);
434 assert!(result.is_ok());
435
436 let auth_result = result.unwrap();
437 assert_eq!(auth_result.access_key_id, TEST_ACCESS_KEY);
438 assert_eq!(auth_result.region, "us-east-1");
439 assert_eq!(auth_result.service, "s3");
440 }
441}