1use chrono::{DateTime, TimeZone, Utc};
22use hmac::{Hmac, Mac};
23use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
24use sha2::{Digest, Sha256};
25use std::collections::BTreeMap;
26
27type HmacSha256 = Hmac<Sha256>;
28
29#[derive(Debug, Clone)]
33pub struct SigV4Info {
34 pub access_key: String,
35 pub region: String,
36 pub service: String,
37}
38
39#[derive(Debug, Clone)]
42pub struct ParsedSigV4 {
43 pub access_key: String,
44 pub date_stamp: String,
46 pub region: String,
47 pub service: String,
48 pub signed_headers: Vec<String>,
51 pub signature: String,
53 pub amz_date: String,
55 pub is_presigned: bool,
58}
59
60impl ParsedSigV4 {
61 pub fn as_info(&self) -> SigV4Info {
63 SigV4Info {
64 access_key: self.access_key.clone(),
65 region: self.region.clone(),
66 service: self.service.clone(),
67 }
68 }
69}
70
71#[derive(Debug, thiserror::Error)]
74pub enum SigV4Error {
75 #[error("request time {signed} is outside the allowed 15-minute window from {server}")]
78 RequestTimeTooSkewed {
79 signed: DateTime<Utc>,
80 server: DateTime<Utc>,
81 },
82 #[error("malformed SigV4 signature: {0}")]
86 Malformed(&'static str),
87 #[error("signature does not match")]
90 SignatureMismatch,
91 #[error("invalid x-amz-date: {0}")]
93 InvalidDate(&'static str),
94 #[error("presigned URL signed at {signed} has expired (X-Amz-Expires={expires_secs}s) relative to {server}")]
97 PresignedUrlExpired {
98 signed: DateTime<Utc>,
99 server: DateTime<Utc>,
100 expires_secs: i64,
101 },
102 #[error("invalid X-Amz-Expires value {0} (must be 1..=604800 seconds)")]
104 InvalidPresignExpires(i64),
105}
106
107pub const CLOCK_SKEW_SECONDS: i64 = 15 * 60;
110
111pub const MAX_PRESIGN_EXPIRES_SECONDS: i64 = 7 * 24 * 60 * 60;
113
114fn presigned_expires_secs(query: &str) -> Result<i64, SigV4Error> {
119 query
120 .split('&')
121 .find_map(|pair| {
122 let (k, v) = pair.split_once('=')?;
123 (k == "X-Amz-Expires").then(|| v.to_string())
124 })
125 .and_then(|v| v.parse::<i64>().ok())
126 .ok_or(SigV4Error::Malformed("missing or invalid X-Amz-Expires"))
127}
128
129pub fn parse_sigv4(auth_header: &str) -> Option<SigV4Info> {
135 parse_header_credential(auth_header)
136}
137
138fn parse_header_credential(auth_header: &str) -> Option<SigV4Info> {
139 let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
140 let credential_start = auth.find("Credential=")?;
141 let credential_value = &auth[credential_start + "Credential=".len()..];
142 let credential = match credential_value.find(',') {
148 Some(end) => &credential_value[..end],
149 None => credential_value,
150 };
151 parse_credential_scope(credential)
152}
153
154fn parse_credential_scope(credential: &str) -> Option<SigV4Info> {
155 let parts: Vec<&str> = credential.split('/').collect();
156 if parts.len() != 5 || parts[4] != "aws4_request" {
157 return None;
158 }
159 Some(SigV4Info {
160 access_key: parts[0].to_string(),
161 region: parts[2].to_string(),
162 service: parts[3].to_string(),
163 })
164}
165
166pub fn parse_sigv4_header(auth_header: &str, amz_date: Option<&str>) -> Option<ParsedSigV4> {
173 let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
174
175 let mut credential: Option<&str> = None;
177 let mut signed_headers: Option<&str> = None;
178 let mut signature: Option<&str> = None;
179 for part in auth.split(',') {
180 let part = part.trim();
181 if let Some(v) = part.strip_prefix("Credential=") {
182 credential = Some(v);
183 } else if let Some(v) = part.strip_prefix("SignedHeaders=") {
184 signed_headers = Some(v);
185 } else if let Some(v) = part.strip_prefix("Signature=") {
186 signature = Some(v);
187 }
188 }
189
190 let credential = credential?;
191 let signed_headers = signed_headers?;
192 let signature = signature?;
193 let scope = parse_credential_scope(credential)?;
194 let date_stamp = credential.split('/').nth(1)?.to_string();
195 let signed_headers: Vec<String> = signed_headers
196 .split(';')
197 .map(|s| s.to_ascii_lowercase())
198 .collect();
199 if signed_headers.is_empty() {
200 return None;
201 }
202
203 Some(ParsedSigV4 {
204 access_key: scope.access_key,
205 date_stamp,
206 region: scope.region,
207 service: scope.service,
208 signed_headers,
209 signature: signature.to_string(),
210 amz_date: amz_date?.to_string(),
211 is_presigned: false,
212 })
213}
214
215pub fn parse_sigv4_presigned(
220 query: &std::collections::HashMap<String, String>,
221) -> Option<ParsedSigV4> {
222 if query.get("X-Amz-Algorithm").map(|s| s.as_str()) != Some("AWS4-HMAC-SHA256") {
223 return None;
224 }
225 let credential = query.get("X-Amz-Credential")?;
226 let scope = parse_credential_scope(credential)?;
227 let date_stamp = credential.split('/').nth(1)?.to_string();
228 let signed_headers = query.get("X-Amz-SignedHeaders")?;
229 let signed_headers: Vec<String> = signed_headers
230 .split(';')
231 .map(|s| s.to_ascii_lowercase())
232 .collect();
233 let signature = query.get("X-Amz-Signature")?.clone();
234 let amz_date = query.get("X-Amz-Date")?.clone();
235
236 Some(ParsedSigV4 {
237 access_key: scope.access_key,
238 date_stamp,
239 region: scope.region,
240 service: scope.service,
241 signed_headers,
242 signature,
243 amz_date,
244 is_presigned: true,
245 })
246}
247
248#[derive(Debug, Clone)]
253pub struct VerifyRequest<'a> {
254 pub method: &'a str,
255 pub path: &'a str,
257 pub query: &'a str,
260 pub headers: &'a [(String, String)],
263 pub body: &'a [u8],
265}
266
267pub fn headers_from_http(headers: &http::HeaderMap) -> Vec<(String, String)> {
270 let mut out: std::collections::BTreeMap<String, Vec<String>> = Default::default();
271 for (name, value) in headers.iter() {
272 let key = name.as_str().to_ascii_lowercase();
273 if let Ok(v) = value.to_str() {
274 out.entry(key).or_default().push(v.to_string());
275 }
276 }
277 out.into_iter().map(|(k, vs)| (k, vs.join(", "))).collect()
278}
279
280pub fn verify(
295 parsed: &ParsedSigV4,
296 req: &VerifyRequest<'_>,
297 secret_access_key: &str,
298 now: DateTime<Utc>,
299) -> Result<(), SigV4Error> {
300 let signed_at = parse_amz_date(&parsed.amz_date)?;
307 if parsed.is_presigned {
308 let expires = presigned_expires_secs(req.query)?;
309 if !(1..=MAX_PRESIGN_EXPIRES_SECONDS).contains(&expires) {
310 return Err(SigV4Error::InvalidPresignExpires(expires));
311 }
312 let age = (now - signed_at).num_seconds();
316 if age < -CLOCK_SKEW_SECONDS || age > expires {
317 return Err(SigV4Error::PresignedUrlExpired {
318 signed: signed_at,
319 server: now,
320 expires_secs: expires,
321 });
322 }
323 } else {
324 let drift = (now - signed_at).num_seconds().abs();
325 if drift > CLOCK_SKEW_SECONDS {
326 return Err(SigV4Error::RequestTimeTooSkewed {
327 signed: signed_at,
328 server: now,
329 });
330 }
331 }
332
333 let canonical_request = build_canonical_request(parsed, req)?;
335 let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
336
337 let credential_scope = format!(
339 "{}/{}/{}/aws4_request",
340 parsed.date_stamp, parsed.region, parsed.service
341 );
342 let string_to_sign = format!(
343 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
344 parsed.amz_date, credential_scope, hashed_canonical
345 );
346
347 let signing_key = derive_signing_key(
349 secret_access_key,
350 &parsed.date_stamp,
351 &parsed.region,
352 &parsed.service,
353 );
354
355 let expected = hmac_sha256(&signing_key, string_to_sign.as_bytes());
357 let expected_hex = hex::encode(expected);
358
359 if constant_time_eq(expected_hex.as_bytes(), parsed.signature.as_bytes()) {
360 Ok(())
361 } else {
362 Err(SigV4Error::SignatureMismatch)
363 }
364}
365
366fn parse_amz_date(s: &str) -> Result<DateTime<Utc>, SigV4Error> {
368 let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ")
369 .map_err(|_| SigV4Error::InvalidDate("expected YYYYMMDDTHHMMSSZ"))?;
370 Utc.from_local_datetime(&naive)
371 .single()
372 .ok_or(SigV4Error::InvalidDate("ambiguous datetime"))
373}
374
375const SIGV4_URI_ENCODE: &AsciiSet = &CONTROLS
378 .add(b' ')
379 .add(b'!')
380 .add(b'"')
381 .add(b'#')
382 .add(b'$')
383 .add(b'%')
384 .add(b'&')
385 .add(b'\'')
386 .add(b'(')
387 .add(b')')
388 .add(b'*')
389 .add(b'+')
390 .add(b',')
391 .add(b'/')
392 .add(b':')
393 .add(b';')
394 .add(b'<')
395 .add(b'=')
396 .add(b'>')
397 .add(b'?')
398 .add(b'@')
399 .add(b'[')
400 .add(b'\\')
401 .add(b']')
402 .add(b'^')
403 .add(b'`')
404 .add(b'{')
405 .add(b'|')
406 .add(b'}');
407
408fn sigv4_encode(s: &str) -> String {
409 utf8_percent_encode(s, SIGV4_URI_ENCODE).to_string()
410}
411
412fn canonical_uri(path: &str, service: &str) -> String {
415 if path.is_empty() {
416 return "/".to_string();
417 }
418 let encoded: Vec<String> = path
420 .split('/')
421 .map(|seg| {
422 let once = sigv4_encode(seg);
423 if service == "s3" {
424 once
425 } else {
426 sigv4_encode(&once)
427 }
428 })
429 .collect();
430 encoded.join("/")
431}
432
433fn canonical_query(query: &str, is_presigned: bool) -> String {
437 if query.is_empty() {
438 return String::new();
439 }
440 let mut pairs: Vec<(String, String)> = query
441 .split('&')
442 .filter_map(|kv| {
443 if kv.is_empty() {
444 return None;
445 }
446 let (k, v) = match kv.split_once('=') {
447 Some((k, v)) => (k, v),
448 None => (kv, ""),
449 };
450 let k_dec = percent_decode(k);
452 let v_dec = percent_decode(v);
453 if is_presigned && k_dec == "X-Amz-Signature" {
454 return None;
455 }
456 Some((sigv4_encode(&k_dec), sigv4_encode(&v_dec)))
457 })
458 .collect();
459 pairs.sort();
460 pairs
461 .into_iter()
462 .map(|(k, v)| format!("{}={}", k, v))
463 .collect::<Vec<_>>()
464 .join("&")
465}
466
467fn percent_decode(s: &str) -> String {
468 percent_encoding::percent_decode_str(s)
469 .decode_utf8_lossy()
470 .into_owned()
471}
472
473fn build_canonical_request(
474 parsed: &ParsedSigV4,
475 req: &VerifyRequest<'_>,
476) -> Result<String, SigV4Error> {
477 let method = req.method.to_ascii_uppercase();
478 let canonical_path = canonical_uri(req.path, &parsed.service);
479 let canonical_qs = canonical_query(req.query, parsed.is_presigned);
480
481 let header_map: BTreeMap<String, String> = req
484 .headers
485 .iter()
486 .map(|(k, v)| (k.to_ascii_lowercase(), collapse_ws(v)))
487 .collect();
488 let mut canonical_headers = String::new();
489 for name in &parsed.signed_headers {
490 let value = header_map.get(name).ok_or(SigV4Error::Malformed(
491 "signed header not present in request",
492 ))?;
493 canonical_headers.push_str(name);
494 canonical_headers.push(':');
495 canonical_headers.push_str(value);
496 canonical_headers.push('\n');
497 }
498 let signed_headers_list = parsed.signed_headers.join(";");
499
500 let payload_hash = if parsed.is_presigned {
504 header_map
508 .get("x-amz-content-sha256")
509 .cloned()
510 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string())
511 } else if let Some(h) = header_map.get("x-amz-content-sha256") {
512 h.clone()
513 } else {
514 hex::encode(Sha256::digest(req.body))
515 };
516
517 Ok(format!(
518 "{}\n{}\n{}\n{}\n{}\n{}",
519 method, canonical_path, canonical_qs, canonical_headers, signed_headers_list, payload_hash
520 ))
521}
522
523fn collapse_ws(v: &str) -> String {
525 let mut out = String::with_capacity(v.len());
526 let mut in_ws = false;
527 for ch in v.trim().chars() {
528 if ch == ' ' || ch == '\t' {
529 if !in_ws {
530 out.push(' ');
531 in_ws = true;
532 }
533 } else {
534 out.push(ch);
535 in_ws = false;
536 }
537 }
538 out
539}
540
541fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
542 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
543 mac.update(data);
544 mac.finalize().into_bytes().to_vec()
545}
546
547fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
548 let k_secret = format!("AWS4{}", secret);
549 let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
550 let k_region = hmac_sha256(&k_date, region.as_bytes());
551 let k_service = hmac_sha256(&k_region, service.as_bytes());
552 hmac_sha256(&k_service, b"aws4_request")
553}
554
555fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
556 use subtle::ConstantTimeEq;
557 a.ct_eq(b).into()
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 const AWS_EXAMPLE_SECRET: &str = "testtesttesttesttesttesttesttesttesttest";
570 const AWS_EXAMPLE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
571
572 #[test]
573 fn parse_valid_header() {
574 let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123";
575 let info = parse_sigv4(header).unwrap();
576 assert_eq!(info.access_key, "AKIAIOSFODNN7EXAMPLE");
577 assert_eq!(info.region, "us-east-1");
578 assert_eq!(info.service, "sqs");
579 }
580
581 #[test]
582 fn parse_credential_only_no_trailing_parts() {
583 let header =
584 "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/rds/aws4_request";
585 let info = parse_sigv4(header).unwrap();
586 assert_eq!(info.service, "rds");
587 assert_eq!(info.region, "us-east-1");
588 }
589
590 #[test]
591 fn parse_full_header_extracts_all_fields() {
592 let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
593 let parsed = parse_sigv4_header(header, Some("20260101T000000Z")).unwrap();
594 assert_eq!(parsed.access_key, "AKIAIOSFODNN7EXAMPLE");
595 assert_eq!(parsed.date_stamp, "20260101");
596 assert_eq!(parsed.signed_headers, vec!["host", "x-amz-date"]);
597 assert_eq!(parsed.signature, "deadbeef");
598 assert!(!parsed.is_presigned);
599 }
600
601 #[test]
602 fn parse_presigned_query_extracts_all_fields() {
603 let mut q = std::collections::HashMap::new();
604 q.insert(
605 "X-Amz-Algorithm".to_string(),
606 "AWS4-HMAC-SHA256".to_string(),
607 );
608 q.insert(
609 "X-Amz-Credential".to_string(),
610 "AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/s3/aws4_request".to_string(),
611 );
612 q.insert("X-Amz-Date".to_string(), "20260101T000000Z".to_string());
613 q.insert("X-Amz-SignedHeaders".to_string(), "host".to_string());
614 q.insert("X-Amz-Signature".to_string(), "cafe".to_string());
615 let parsed = parse_sigv4_presigned(&q).unwrap();
616 assert_eq!(parsed.service, "s3");
617 assert!(parsed.is_presigned);
618 assert_eq!(parsed.signature, "cafe");
619 }
620
621 #[test]
622 fn returns_none_for_invalid() {
623 assert!(parse_sigv4("Bearer token123").is_none());
624 assert!(parse_sigv4("").is_none());
625 }
626
627 #[test]
628 fn canonical_uri_non_s3_double_encodes() {
629 assert_eq!(canonical_uri("/foo bar", "iam"), "/foo%2520bar");
630 assert_eq!(canonical_uri("/a/b", "iam"), "/a/b");
632 }
633
634 #[test]
635 fn canonical_uri_s3_single_encodes() {
636 assert_eq!(canonical_uri("/foo bar", "s3"), "/foo%20bar");
637 }
638
639 #[test]
640 fn canonical_query_sorts_and_drops_presigned_signature() {
641 let q = "X-Amz-Signature=ignored&B=2&A=1";
642 assert_eq!(canonical_query(q, true), "A=1&B=2");
643 assert_eq!(canonical_query(q, false), "A=1&B=2&X-Amz-Signature=ignored");
644 }
645
646 #[test]
647 fn derive_signing_key_is_deterministic_and_stable() {
648 let key = derive_signing_key(AWS_EXAMPLE_SECRET, "20150830", "us-east-1", "iam");
655 assert_eq!(
656 hex::encode(&key),
657 "0d041c02f01817181204845091e3445c37d6f6b200833f52d34d682b2005918a"
658 );
659 let diff = derive_signing_key(AWS_EXAMPLE_SECRET, "20150831", "us-east-1", "iam");
661 assert_ne!(key, diff);
662 }
663
664 #[test]
665 fn verify_rejects_skewed_clock() {
666 let parsed = ParsedSigV4 {
668 access_key: "X".into(),
669 date_stamp: "20260101".into(),
670 region: "us-east-1".into(),
671 service: "iam".into(),
672 signed_headers: vec!["host".into()],
673 signature: "00".into(),
674 amz_date: "20260101T000000Z".into(),
675 is_presigned: false,
676 };
677 let req = VerifyRequest {
678 method: "GET",
679 path: "/",
680 query: "",
681 headers: &[("host".into(), "iam.amazonaws.com".into())],
682 body: b"",
683 };
684 let server_now = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
685 let result = verify(&parsed, &req, AWS_EXAMPLE_SECRET, server_now);
686 assert!(matches!(
687 result,
688 Err(SigV4Error::RequestTimeTooSkewed { .. })
689 ));
690 }
691
692 #[test]
693 fn verify_round_trip_matches_self_computed_signature() {
694 let secret = AWS_EXAMPLE_SECRET;
697 let date_stamp = "20260101";
698 let amz_date = "20260101T120000Z";
699 let region = "us-east-1";
700 let service = "iam";
701 let method = "GET";
702 let path = "/";
703 let query = "Action=GetUser&Version=2010-05-08";
704 let headers = vec![
705 ("host".to_string(), "iam.amazonaws.com".to_string()),
706 ("x-amz-date".to_string(), amz_date.to_string()),
707 ];
708 let body: &[u8] = b"";
709
710 let canonical_request = {
712 let mut parsed = ParsedSigV4 {
713 access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
714 date_stamp: date_stamp.into(),
715 region: region.into(),
716 service: service.into(),
717 signed_headers: vec!["host".into(), "x-amz-date".into()],
718 signature: String::new(),
719 amz_date: amz_date.into(),
720 is_presigned: false,
721 };
722 let req = VerifyRequest {
723 method,
724 path,
725 query,
726 headers: &headers,
727 body,
728 };
729 let cr = build_canonical_request(&parsed, &req).unwrap();
730 parsed.signature = {
731 let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
732 let sts = format!(
733 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
734 amz_date,
735 scope,
736 hex::encode(Sha256::digest(cr.as_bytes()))
737 );
738 let sk = derive_signing_key(secret, date_stamp, region, service);
739 hex::encode(hmac_sha256(&sk, sts.as_bytes()))
740 };
741 parsed
742 };
743
744 let req = VerifyRequest {
745 method,
746 path,
747 query,
748 headers: &headers,
749 body,
750 };
751 let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
752 verify(&canonical_request, &req, secret, now).unwrap();
753 }
754
755 #[test]
756 fn verify_rejects_tampered_body() {
757 let secret = AWS_EXAMPLE_SECRET;
758 let date_stamp = "20260101";
759 let amz_date = "20260101T120000Z";
760 let region = "us-east-1";
761 let service = "iam";
762 let method = "POST";
763 let path = "/";
764 let query = "";
765 let headers = vec![
766 ("host".to_string(), "iam.amazonaws.com".to_string()),
767 ("x-amz-date".to_string(), amz_date.to_string()),
768 ];
769 let original_body: &[u8] = b"Action=ListUsers&Version=2010-05-08";
770
771 let mut parsed = ParsedSigV4 {
773 access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
774 date_stamp: date_stamp.into(),
775 region: region.into(),
776 service: service.into(),
777 signed_headers: vec!["host".into(), "x-amz-date".into()],
778 signature: String::new(),
779 amz_date: amz_date.into(),
780 is_presigned: false,
781 };
782 let signing_req = VerifyRequest {
783 method,
784 path,
785 query,
786 headers: &headers,
787 body: original_body,
788 };
789 let cr = build_canonical_request(&parsed, &signing_req).unwrap();
790 let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
791 let sts = format!(
792 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
793 amz_date,
794 scope,
795 hex::encode(Sha256::digest(cr.as_bytes()))
796 );
797 let sk = derive_signing_key(secret, date_stamp, region, service);
798 parsed.signature = hex::encode(hmac_sha256(&sk, sts.as_bytes()));
799
800 let tampered = VerifyRequest {
802 method,
803 path,
804 query,
805 headers: &headers,
806 body: b"Action=DeleteUser&Version=2010-05-08",
807 };
808 let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
809 assert!(matches!(
810 verify(&parsed, &tampered, secret, now),
811 Err(SigV4Error::SignatureMismatch)
812 ));
813 }
814
815 #[test]
816 fn collapse_ws_normalizes_runs() {
817 assert_eq!(collapse_ws(" foo bar "), "foo bar");
818 assert_eq!(collapse_ws("foo\tbar"), "foo bar");
819 }
820}