1use hmac::{Hmac, KeyInit, Mac};
16use sha2::{Digest, Sha256};
17use subtle::ConstantTimeEq;
18use tracing::debug;
19
20use crate::{
21 canonical::build_canonical_request, credentials::CredentialProvider, error::AuthError,
22};
23
24const SUPPORTED_ALGORITHM: &str = "AWS4-HMAC-SHA256";
26
27type HmacSha256 = Hmac<Sha256>;
28
29#[derive(Debug, Clone)]
31pub struct AuthResult {
32 pub access_key_id: String,
34 pub region: String,
36 pub service: String,
38 pub signed_headers: Vec<String>,
40}
41
42#[derive(Debug, Clone)]
51pub struct ParsedAuth {
52 pub algorithm: String,
54 pub access_key_id: String,
56 pub date: String,
58 pub region: String,
60 pub service: String,
62 pub signed_headers: Vec<String>,
64 pub signature: String,
66}
67
68pub fn parse_authorization_header(header: &str) -> Result<ParsedAuth, AuthError> {
75 let (algorithm, rest) = header.split_once(' ').ok_or(AuthError::InvalidAuthHeader)?;
78
79 if algorithm != SUPPORTED_ALGORITHM {
80 return Err(AuthError::UnsupportedAlgorithm(algorithm.to_owned()));
81 }
82
83 let mut credential = None;
85 let mut signed_headers = None;
86 let mut signature = None;
87
88 for part in rest.split(',') {
89 let part = part.trim();
90 if let Some(value) = part.strip_prefix("Credential=") {
91 credential = Some(value);
92 } else if let Some(value) = part.strip_prefix("SignedHeaders=") {
93 signed_headers = Some(value);
94 } else if let Some(value) = part.strip_prefix("Signature=") {
95 signature = Some(value);
96 }
97 }
98
99 let credential = credential.ok_or(AuthError::InvalidAuthHeader)?;
100 let signed_headers = signed_headers.ok_or(AuthError::InvalidAuthHeader)?;
101 let signature = signature.ok_or(AuthError::InvalidAuthHeader)?;
102
103 let cred_parts: Vec<&str> = credential.splitn(5, '/').collect();
105 if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" {
106 return Err(AuthError::InvalidCredential);
107 }
108
109 let parsed_signed_headers: Vec<String> =
110 signed_headers.split(';').map(ToOwned::to_owned).collect();
111
112 Ok(ParsedAuth {
113 algorithm: algorithm.to_owned(),
114 access_key_id: cred_parts[0].to_owned(),
115 date: cred_parts[1].to_owned(),
116 region: cred_parts[2].to_owned(),
117 service: cred_parts[3].to_owned(),
118 signed_headers: parsed_signed_headers,
119 signature: signature.to_owned(),
120 })
121}
122
123#[must_use]
146pub fn build_string_to_sign(
147 timestamp: &str,
148 credential_scope: &str,
149 canonical_request_hash: &str,
150) -> String {
151 format!("{SUPPORTED_ALGORITHM}\n{timestamp}\n{credential_scope}\n{canonical_request_hash}")
152}
153
154#[must_use]
177pub fn derive_signing_key(secret_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
178 let date_key = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
179 let date_region_key = hmac_sha256(&date_key, region.as_bytes());
180 let date_region_service_key = hmac_sha256(&date_region_key, service.as_bytes());
181 hmac_sha256(&date_region_service_key, b"aws4_request")
182}
183
184#[must_use]
188pub fn compute_signature(signing_key: &[u8], data: &str) -> String {
189 let sig = hmac_sha256(signing_key, data.as_bytes());
190 hex::encode(sig)
191}
192
193pub fn verify_sigv4(
210 parts: &http::request::Parts,
211 body_hash: &str,
212 credential_provider: &dyn CredentialProvider,
213) -> Result<AuthResult, AuthError> {
214 let auth_header = parts
216 .headers
217 .get(http::header::AUTHORIZATION)
218 .ok_or(AuthError::MissingAuthHeader)?
219 .to_str()
220 .map_err(|_| AuthError::InvalidAuthHeader)?;
221
222 debug!(auth_header, "Parsing SigV4 authorization header");
223
224 let parsed = parse_authorization_header(auth_header)?;
225
226 let secret_key = credential_provider.get_secret_key(&parsed.access_key_id)?;
228
229 let timestamp = extract_header_value(parts, "x-amz-date")?;
231
232 debug!(
233 access_key_id = %parsed.access_key_id,
234 date = %parsed.date,
235 region = %parsed.region,
236 service = %parsed.service,
237 "Verifying SigV4 signature"
238 );
239
240 let method = parts.method.as_str();
242 let uri = parts.uri.path();
243 let query = parts.uri.query().unwrap_or("");
244
245 let signed_header_refs: Vec<&str> = parsed.signed_headers.iter().map(String::as_str).collect();
247 let header_pairs: Vec<(&str, &str)> = collect_signed_headers(parts, &signed_header_refs)?;
248
249 let payload_hash = parts
253 .headers
254 .get("x-amz-content-sha256")
255 .and_then(|v| v.to_str().ok())
256 .unwrap_or(body_hash);
257
258 let canonical_request = build_canonical_request(
259 method,
260 uri,
261 query,
262 &header_pairs,
263 &signed_header_refs,
264 payload_hash,
265 );
266
267 let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
269
270 let credential_scope = format!(
272 "{}/{}/{}/aws4_request",
273 parsed.date, parsed.region, parsed.service
274 );
275 let string_to_sign = build_string_to_sign(×tamp, &credential_scope, &canonical_hash);
276
277 let signing_key =
279 derive_signing_key(&secret_key, &parsed.date, &parsed.region, &parsed.service);
280 let expected_signature = compute_signature(&signing_key, &string_to_sign);
281
282 let provided_bytes = parsed.signature.as_bytes();
284 let expected_bytes = expected_signature.as_bytes();
285
286 if provided_bytes.ct_eq(expected_bytes).into() {
287 debug!(access_key_id = %parsed.access_key_id, "Signature verification succeeded");
288 Ok(AuthResult {
289 access_key_id: parsed.access_key_id,
290 region: parsed.region,
291 service: parsed.service,
292 signed_headers: parsed.signed_headers,
293 })
294 } else {
295 debug!(
296 expected = %expected_signature,
297 provided = %parsed.signature,
298 "Signature mismatch"
299 );
300 Err(AuthError::SignatureDoesNotMatch)
301 }
302}
303
304fn extract_header_value(parts: &http::request::Parts, name: &str) -> Result<String, AuthError> {
306 parts
307 .headers
308 .get(name)
309 .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
310 .to_str()
311 .map(ToOwned::to_owned)
312 .map_err(|_| AuthError::MissingHeader(name.to_owned()))
313}
314
315fn collect_signed_headers<'a>(
317 parts: &'a http::request::Parts,
318 signed_headers: &[&'a str],
319) -> Result<Vec<(&'a str, &'a str)>, AuthError> {
320 let mut result = Vec::with_capacity(signed_headers.len());
321
322 for &name in signed_headers {
323 let value = parts
324 .headers
325 .get(name)
326 .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
327 .to_str()
328 .map_err(|_| AuthError::MissingHeader(name.to_owned()))?;
329 result.push((name, value));
330 }
331
332 Ok(result)
333}
334
335fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
337 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can accept keys of any length");
338 mac.update(data);
339 mac.finalize().into_bytes().to_vec()
340}
341
342#[must_use]
358pub fn hash_payload(payload: &[u8]) -> String {
359 hex::encode(Sha256::digest(payload))
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::{canonical::build_signed_headers_string, credentials::StaticCredentialProvider};
366
367 const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
368 const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
369 const TEST_DATE: &str = "20130524";
370 const TEST_REGION: &str = "us-east-1";
371 const TEST_SERVICE: &str = "s3";
372
373 fn test_credential_provider() -> StaticCredentialProvider {
374 StaticCredentialProvider::new(vec![(
375 TEST_ACCESS_KEY.to_owned(),
376 TEST_SECRET_KEY.to_owned(),
377 )])
378 }
379
380 #[test]
381 fn test_should_derive_signing_key_matching_aws_test_vector() {
382 let key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
383 assert_eq!(key.len(), 32); }
387
388 #[test]
389 fn test_should_parse_authorization_header() {
390 let header = "AWS4-HMAC-SHA256 \
391 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,\
392 SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,\
393 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
394
395 let parsed = parse_authorization_header(header).unwrap();
396 assert_eq!(parsed.algorithm, "AWS4-HMAC-SHA256");
397 assert_eq!(parsed.access_key_id, "AKIAIOSFODNN7EXAMPLE");
398 assert_eq!(parsed.date, "20130524");
399 assert_eq!(parsed.region, "us-east-1");
400 assert_eq!(parsed.service, "s3");
401 assert_eq!(
402 parsed.signed_headers,
403 vec!["host", "range", "x-amz-content-sha256", "x-amz-date"]
404 );
405 assert_eq!(
406 parsed.signature,
407 "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
408 );
409 }
410
411 #[test]
412 fn test_should_reject_unsupported_algorithm() {
413 let header = "AWS4-HMAC-SHA512 \
414 Credential=AKID/20130524/us-east-1/s3/aws4_request,SignedHeaders=host,\
415 Signature=abc";
416 let result = parse_authorization_header(header);
417 assert!(matches!(result, Err(AuthError::UnsupportedAlgorithm(_))));
418 }
419
420 #[test]
421 fn test_should_reject_invalid_credential_format() {
422 let header =
423 "AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1,SignedHeaders=host,Signature=abc";
424 let result = parse_authorization_header(header);
425 assert!(matches!(result, Err(AuthError::InvalidCredential)));
426 }
427
428 #[test]
429 fn test_should_build_string_to_sign_matching_aws_example() {
430 let canonical_hash = "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
431 let sts = build_string_to_sign(
432 "20130524T000000Z",
433 "20130524/us-east-1/s3/aws4_request",
434 canonical_hash,
435 );
436 #[rustfmt::skip]
437 let expected = "AWS4-HMAC-SHA256\n\
438 20130524T000000Z\n\
439 20130524/us-east-1/s3/aws4_request\n\
440 7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
441 assert_eq!(sts, expected);
442 }
443
444 #[test]
445 fn test_should_compute_correct_signature_for_aws_get_object_example() {
446 let signing_key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
448
449 #[rustfmt::skip]
450 let string_to_sign = "AWS4-HMAC-SHA256\n\
451 20130524T000000Z\n\
452 20130524/us-east-1/s3/aws4_request\n\
453 7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
454
455 let signature = compute_signature(&signing_key, string_to_sign);
456 assert_eq!(
457 signature,
458 "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
459 );
460 }
461
462 #[test]
463 fn test_should_verify_sigv4_success() {
464 let provider = test_credential_provider();
465 let empty_hash = hash_payload(b"");
466
467 let mut builder = http::Request::builder()
469 .method("GET")
470 .uri("http://examplebucket.s3.amazonaws.com/test.txt")
471 .header("host", "examplebucket.s3.amazonaws.com")
472 .header("range", "bytes=0-9")
473 .header("x-amz-content-sha256", &empty_hash)
474 .header("x-amz-date", "20130524T000000Z");
475
476 let auth_value = format!(
478 "AWS4-HMAC-SHA256 \
479 Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
480 range;x-amz-content-sha256;x-amz-date,\
481 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
482 );
483 builder = builder.header(http::header::AUTHORIZATION, &auth_value);
484
485 let (parts, _body) = builder.body(()).unwrap().into_parts();
486 let result = verify_sigv4(&parts, &empty_hash, &provider);
487 assert!(result.is_ok());
488
489 let auth_result = result.unwrap();
490 assert_eq!(auth_result.access_key_id, TEST_ACCESS_KEY);
491 assert_eq!(auth_result.region, "us-east-1");
492 assert_eq!(auth_result.service, "s3");
493 }
494
495 #[test]
496 fn test_should_fail_sigv4_with_wrong_key() {
497 let provider = StaticCredentialProvider::new(vec![(
498 TEST_ACCESS_KEY.to_owned(),
499 "WRONG_SECRET_KEY".to_owned(),
500 )]);
501 let empty_hash = hash_payload(b"");
502
503 let auth_value = format!(
504 "AWS4-HMAC-SHA256 \
505 Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
506 range;x-amz-content-sha256;x-amz-date,\
507 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
508 );
509
510 let (parts, _body) = http::Request::builder()
511 .method("GET")
512 .uri("http://examplebucket.s3.amazonaws.com/test.txt")
513 .header("host", "examplebucket.s3.amazonaws.com")
514 .header("range", "bytes=0-9")
515 .header("x-amz-content-sha256", &empty_hash)
516 .header("x-amz-date", "20130524T000000Z")
517 .header(http::header::AUTHORIZATION, &auth_value)
518 .body(())
519 .unwrap()
520 .into_parts();
521
522 let result = verify_sigv4(&parts, &empty_hash, &provider);
523 assert!(matches!(result, Err(AuthError::SignatureDoesNotMatch)));
524 }
525
526 #[test]
527 fn test_should_fail_sigv4_with_missing_auth_header() {
528 let provider = test_credential_provider();
529 let empty_hash = hash_payload(b"");
530
531 let (parts, _body) = http::Request::builder()
532 .method("GET")
533 .uri("http://example.com/")
534 .header("host", "example.com")
535 .body(())
536 .unwrap()
537 .into_parts();
538
539 let result = verify_sigv4(&parts, &empty_hash, &provider);
540 assert!(matches!(result, Err(AuthError::MissingAuthHeader)));
541 }
542
543 #[test]
544 fn test_should_fail_sigv4_with_unknown_access_key() {
545 let provider = StaticCredentialProvider::new(vec![]);
546 let empty_hash = hash_payload(b"");
547
548 let auth_value = "AWS4-HMAC-SHA256 \
549 Credential=UNKNOWN_KEY/20130524/us-east-1/s3/aws4_request,\
550 SignedHeaders=host;x-amz-date,Signature=abc123"
551 .to_owned();
552
553 let (parts, _body) = http::Request::builder()
554 .method("GET")
555 .uri("http://example.com/")
556 .header("host", "example.com")
557 .header("x-amz-date", "20130524T000000Z")
558 .header(http::header::AUTHORIZATION, &auth_value)
559 .body(())
560 .unwrap()
561 .into_parts();
562
563 let result = verify_sigv4(&parts, &empty_hash, &provider);
564 assert!(matches!(result, Err(AuthError::AccessKeyNotFound(_))));
565 }
566
567 #[test]
568 fn test_should_hash_empty_payload() {
569 assert_eq!(
570 hash_payload(b""),
571 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
572 );
573 }
574
575 #[test]
576 fn test_should_hash_nonempty_payload() {
577 let hash = hash_payload(b"Hello, World!");
578 assert_eq!(hash.len(), 64); assert_ne!(
580 hash,
581 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
582 );
583 }
584
585 #[test]
586 fn test_should_build_signed_headers_string_from_parsed() {
587 let headers = [
588 "host".to_owned(),
589 "range".to_owned(),
590 "x-amz-content-sha256".to_owned(),
591 "x-amz-date".to_owned(),
592 ];
593 let refs: Vec<&str> = headers.iter().map(String::as_str).collect();
594 let result = build_signed_headers_string(&refs);
595 assert_eq!(result, "host;range;x-amz-content-sha256;x-amz-date");
596 }
597}