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}
95
96pub const CLOCK_SKEW_SECONDS: i64 = 15 * 60;
99
100pub fn parse_sigv4(auth_header: &str) -> Option<SigV4Info> {
106 parse_header_credential(auth_header)
107}
108
109fn parse_header_credential(auth_header: &str) -> Option<SigV4Info> {
110 let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
111 let credential_start = auth.find("Credential=")?;
112 let credential_value = &auth[credential_start + "Credential=".len()..];
113 let credential = match credential_value.find(',') {
119 Some(end) => &credential_value[..end],
120 None => credential_value,
121 };
122 parse_credential_scope(credential)
123}
124
125fn parse_credential_scope(credential: &str) -> Option<SigV4Info> {
126 let parts: Vec<&str> = credential.split('/').collect();
127 if parts.len() != 5 || parts[4] != "aws4_request" {
128 return None;
129 }
130 Some(SigV4Info {
131 access_key: parts[0].to_string(),
132 region: parts[2].to_string(),
133 service: parts[3].to_string(),
134 })
135}
136
137pub fn parse_sigv4_header(auth_header: &str, amz_date: Option<&str>) -> Option<ParsedSigV4> {
144 let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
145
146 let mut credential: Option<&str> = None;
148 let mut signed_headers: Option<&str> = None;
149 let mut signature: Option<&str> = None;
150 for part in auth.split(',') {
151 let part = part.trim();
152 if let Some(v) = part.strip_prefix("Credential=") {
153 credential = Some(v);
154 } else if let Some(v) = part.strip_prefix("SignedHeaders=") {
155 signed_headers = Some(v);
156 } else if let Some(v) = part.strip_prefix("Signature=") {
157 signature = Some(v);
158 }
159 }
160
161 let credential = credential?;
162 let signed_headers = signed_headers?;
163 let signature = signature?;
164 let scope = parse_credential_scope(credential)?;
165 let date_stamp = credential.split('/').nth(1)?.to_string();
166 let signed_headers: Vec<String> = signed_headers
167 .split(';')
168 .map(|s| s.to_ascii_lowercase())
169 .collect();
170 if signed_headers.is_empty() {
171 return None;
172 }
173
174 Some(ParsedSigV4 {
175 access_key: scope.access_key,
176 date_stamp,
177 region: scope.region,
178 service: scope.service,
179 signed_headers,
180 signature: signature.to_string(),
181 amz_date: amz_date?.to_string(),
182 is_presigned: false,
183 })
184}
185
186pub fn parse_sigv4_presigned(
191 query: &std::collections::HashMap<String, String>,
192) -> Option<ParsedSigV4> {
193 if query.get("X-Amz-Algorithm").map(|s| s.as_str()) != Some("AWS4-HMAC-SHA256") {
194 return None;
195 }
196 let credential = query.get("X-Amz-Credential")?;
197 let scope = parse_credential_scope(credential)?;
198 let date_stamp = credential.split('/').nth(1)?.to_string();
199 let signed_headers = query.get("X-Amz-SignedHeaders")?;
200 let signed_headers: Vec<String> = signed_headers
201 .split(';')
202 .map(|s| s.to_ascii_lowercase())
203 .collect();
204 let signature = query.get("X-Amz-Signature")?.clone();
205 let amz_date = query.get("X-Amz-Date")?.clone();
206
207 Some(ParsedSigV4 {
208 access_key: scope.access_key,
209 date_stamp,
210 region: scope.region,
211 service: scope.service,
212 signed_headers,
213 signature,
214 amz_date,
215 is_presigned: true,
216 })
217}
218
219#[derive(Debug, Clone)]
224pub struct VerifyRequest<'a> {
225 pub method: &'a str,
226 pub path: &'a str,
228 pub query: &'a str,
231 pub headers: &'a [(String, String)],
234 pub body: &'a [u8],
236}
237
238pub fn headers_from_http(headers: &http::HeaderMap) -> Vec<(String, String)> {
241 let mut out: std::collections::BTreeMap<String, Vec<String>> = Default::default();
242 for (name, value) in headers.iter() {
243 let key = name.as_str().to_ascii_lowercase();
244 if let Ok(v) = value.to_str() {
245 out.entry(key).or_default().push(v.to_string());
246 }
247 }
248 out.into_iter().map(|(k, vs)| (k, vs.join(", "))).collect()
249}
250
251pub fn verify(
266 parsed: &ParsedSigV4,
267 req: &VerifyRequest<'_>,
268 secret_access_key: &str,
269 now: DateTime<Utc>,
270) -> Result<(), SigV4Error> {
271 let signed_at = parse_amz_date(&parsed.amz_date)?;
273 let drift = (now - signed_at).num_seconds().abs();
274 if drift > CLOCK_SKEW_SECONDS {
275 return Err(SigV4Error::RequestTimeTooSkewed {
276 signed: signed_at,
277 server: now,
278 });
279 }
280
281 let canonical_request = build_canonical_request(parsed, req)?;
283 let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
284
285 let credential_scope = format!(
287 "{}/{}/{}/aws4_request",
288 parsed.date_stamp, parsed.region, parsed.service
289 );
290 let string_to_sign = format!(
291 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
292 parsed.amz_date, credential_scope, hashed_canonical
293 );
294
295 let signing_key = derive_signing_key(
297 secret_access_key,
298 &parsed.date_stamp,
299 &parsed.region,
300 &parsed.service,
301 );
302
303 let expected = hmac_sha256(&signing_key, string_to_sign.as_bytes());
305 let expected_hex = hex::encode(expected);
306
307 if constant_time_eq(expected_hex.as_bytes(), parsed.signature.as_bytes()) {
308 Ok(())
309 } else {
310 Err(SigV4Error::SignatureMismatch)
311 }
312}
313
314fn parse_amz_date(s: &str) -> Result<DateTime<Utc>, SigV4Error> {
316 let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ")
317 .map_err(|_| SigV4Error::InvalidDate("expected YYYYMMDDTHHMMSSZ"))?;
318 Utc.from_local_datetime(&naive)
319 .single()
320 .ok_or(SigV4Error::InvalidDate("ambiguous datetime"))
321}
322
323const SIGV4_URI_ENCODE: &AsciiSet = &CONTROLS
326 .add(b' ')
327 .add(b'!')
328 .add(b'"')
329 .add(b'#')
330 .add(b'$')
331 .add(b'%')
332 .add(b'&')
333 .add(b'\'')
334 .add(b'(')
335 .add(b')')
336 .add(b'*')
337 .add(b'+')
338 .add(b',')
339 .add(b'/')
340 .add(b':')
341 .add(b';')
342 .add(b'<')
343 .add(b'=')
344 .add(b'>')
345 .add(b'?')
346 .add(b'@')
347 .add(b'[')
348 .add(b'\\')
349 .add(b']')
350 .add(b'^')
351 .add(b'`')
352 .add(b'{')
353 .add(b'|')
354 .add(b'}');
355
356fn sigv4_encode(s: &str) -> String {
357 utf8_percent_encode(s, SIGV4_URI_ENCODE).to_string()
358}
359
360fn canonical_uri(path: &str, service: &str) -> String {
363 if path.is_empty() {
364 return "/".to_string();
365 }
366 let encoded: Vec<String> = path
368 .split('/')
369 .map(|seg| {
370 let once = sigv4_encode(seg);
371 if service == "s3" {
372 once
373 } else {
374 sigv4_encode(&once)
375 }
376 })
377 .collect();
378 encoded.join("/")
379}
380
381fn canonical_query(query: &str, is_presigned: bool) -> String {
385 if query.is_empty() {
386 return String::new();
387 }
388 let mut pairs: Vec<(String, String)> = query
389 .split('&')
390 .filter_map(|kv| {
391 if kv.is_empty() {
392 return None;
393 }
394 let (k, v) = match kv.split_once('=') {
395 Some((k, v)) => (k, v),
396 None => (kv, ""),
397 };
398 let k_dec = percent_decode(k);
400 let v_dec = percent_decode(v);
401 if is_presigned && k_dec == "X-Amz-Signature" {
402 return None;
403 }
404 Some((sigv4_encode(&k_dec), sigv4_encode(&v_dec)))
405 })
406 .collect();
407 pairs.sort();
408 pairs
409 .into_iter()
410 .map(|(k, v)| format!("{}={}", k, v))
411 .collect::<Vec<_>>()
412 .join("&")
413}
414
415fn percent_decode(s: &str) -> String {
416 percent_encoding::percent_decode_str(s)
417 .decode_utf8_lossy()
418 .into_owned()
419}
420
421fn build_canonical_request(
422 parsed: &ParsedSigV4,
423 req: &VerifyRequest<'_>,
424) -> Result<String, SigV4Error> {
425 let method = req.method.to_ascii_uppercase();
426 let canonical_path = canonical_uri(req.path, &parsed.service);
427 let canonical_qs = canonical_query(req.query, parsed.is_presigned);
428
429 let header_map: BTreeMap<String, String> = req
432 .headers
433 .iter()
434 .map(|(k, v)| (k.to_ascii_lowercase(), collapse_ws(v)))
435 .collect();
436 let mut canonical_headers = String::new();
437 for name in &parsed.signed_headers {
438 let value = header_map.get(name).ok_or(SigV4Error::Malformed(
439 "signed header not present in request",
440 ))?;
441 canonical_headers.push_str(name);
442 canonical_headers.push(':');
443 canonical_headers.push_str(value);
444 canonical_headers.push('\n');
445 }
446 let signed_headers_list = parsed.signed_headers.join(";");
447
448 let payload_hash = if parsed.is_presigned {
452 header_map
456 .get("x-amz-content-sha256")
457 .cloned()
458 .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string())
459 } else if let Some(h) = header_map.get("x-amz-content-sha256") {
460 h.clone()
461 } else {
462 hex::encode(Sha256::digest(req.body))
463 };
464
465 Ok(format!(
466 "{}\n{}\n{}\n{}\n{}\n{}",
467 method, canonical_path, canonical_qs, canonical_headers, signed_headers_list, payload_hash
468 ))
469}
470
471fn collapse_ws(v: &str) -> String {
473 let mut out = String::with_capacity(v.len());
474 let mut in_ws = false;
475 for ch in v.trim().chars() {
476 if ch == ' ' || ch == '\t' {
477 if !in_ws {
478 out.push(' ');
479 in_ws = true;
480 }
481 } else {
482 out.push(ch);
483 in_ws = false;
484 }
485 }
486 out
487}
488
489fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
490 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
491 mac.update(data);
492 mac.finalize().into_bytes().to_vec()
493}
494
495fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
496 let k_secret = format!("AWS4{}", secret);
497 let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
498 let k_region = hmac_sha256(&k_date, region.as_bytes());
499 let k_service = hmac_sha256(&k_region, service.as_bytes());
500 hmac_sha256(&k_service, b"aws4_request")
501}
502
503fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
504 use subtle::ConstantTimeEq;
505 a.ct_eq(b).into()
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 const AWS_EXAMPLE_SECRET: &str = "testtesttesttesttesttesttesttesttesttest";
518 const AWS_EXAMPLE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
519
520 #[test]
521 fn parse_valid_header() {
522 let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123";
523 let info = parse_sigv4(header).unwrap();
524 assert_eq!(info.access_key, "AKIAIOSFODNN7EXAMPLE");
525 assert_eq!(info.region, "us-east-1");
526 assert_eq!(info.service, "sqs");
527 }
528
529 #[test]
530 fn parse_credential_only_no_trailing_parts() {
531 let header =
532 "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/rds/aws4_request";
533 let info = parse_sigv4(header).unwrap();
534 assert_eq!(info.service, "rds");
535 assert_eq!(info.region, "us-east-1");
536 }
537
538 #[test]
539 fn parse_full_header_extracts_all_fields() {
540 let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
541 let parsed = parse_sigv4_header(header, Some("20260101T000000Z")).unwrap();
542 assert_eq!(parsed.access_key, "AKIAIOSFODNN7EXAMPLE");
543 assert_eq!(parsed.date_stamp, "20260101");
544 assert_eq!(parsed.signed_headers, vec!["host", "x-amz-date"]);
545 assert_eq!(parsed.signature, "deadbeef");
546 assert!(!parsed.is_presigned);
547 }
548
549 #[test]
550 fn parse_presigned_query_extracts_all_fields() {
551 let mut q = std::collections::HashMap::new();
552 q.insert(
553 "X-Amz-Algorithm".to_string(),
554 "AWS4-HMAC-SHA256".to_string(),
555 );
556 q.insert(
557 "X-Amz-Credential".to_string(),
558 "AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/s3/aws4_request".to_string(),
559 );
560 q.insert("X-Amz-Date".to_string(), "20260101T000000Z".to_string());
561 q.insert("X-Amz-SignedHeaders".to_string(), "host".to_string());
562 q.insert("X-Amz-Signature".to_string(), "cafe".to_string());
563 let parsed = parse_sigv4_presigned(&q).unwrap();
564 assert_eq!(parsed.service, "s3");
565 assert!(parsed.is_presigned);
566 assert_eq!(parsed.signature, "cafe");
567 }
568
569 #[test]
570 fn returns_none_for_invalid() {
571 assert!(parse_sigv4("Bearer token123").is_none());
572 assert!(parse_sigv4("").is_none());
573 }
574
575 #[test]
576 fn canonical_uri_non_s3_double_encodes() {
577 assert_eq!(canonical_uri("/foo bar", "iam"), "/foo%2520bar");
578 assert_eq!(canonical_uri("/a/b", "iam"), "/a/b");
580 }
581
582 #[test]
583 fn canonical_uri_s3_single_encodes() {
584 assert_eq!(canonical_uri("/foo bar", "s3"), "/foo%20bar");
585 }
586
587 #[test]
588 fn canonical_query_sorts_and_drops_presigned_signature() {
589 let q = "X-Amz-Signature=ignored&B=2&A=1";
590 assert_eq!(canonical_query(q, true), "A=1&B=2");
591 assert_eq!(canonical_query(q, false), "A=1&B=2&X-Amz-Signature=ignored");
592 }
593
594 #[test]
595 fn derive_signing_key_is_deterministic_and_stable() {
596 let key = derive_signing_key(AWS_EXAMPLE_SECRET, "20150830", "us-east-1", "iam");
603 assert_eq!(
604 hex::encode(&key),
605 "0d041c02f01817181204845091e3445c37d6f6b200833f52d34d682b2005918a"
606 );
607 let diff = derive_signing_key(AWS_EXAMPLE_SECRET, "20150831", "us-east-1", "iam");
609 assert_ne!(key, diff);
610 }
611
612 #[test]
613 fn verify_rejects_skewed_clock() {
614 let parsed = ParsedSigV4 {
616 access_key: "X".into(),
617 date_stamp: "20260101".into(),
618 region: "us-east-1".into(),
619 service: "iam".into(),
620 signed_headers: vec!["host".into()],
621 signature: "00".into(),
622 amz_date: "20260101T000000Z".into(),
623 is_presigned: false,
624 };
625 let req = VerifyRequest {
626 method: "GET",
627 path: "/",
628 query: "",
629 headers: &[("host".into(), "iam.amazonaws.com".into())],
630 body: b"",
631 };
632 let server_now = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
633 let result = verify(&parsed, &req, AWS_EXAMPLE_SECRET, server_now);
634 assert!(matches!(
635 result,
636 Err(SigV4Error::RequestTimeTooSkewed { .. })
637 ));
638 }
639
640 #[test]
641 fn verify_round_trip_matches_self_computed_signature() {
642 let secret = AWS_EXAMPLE_SECRET;
645 let date_stamp = "20260101";
646 let amz_date = "20260101T120000Z";
647 let region = "us-east-1";
648 let service = "iam";
649 let method = "GET";
650 let path = "/";
651 let query = "Action=GetUser&Version=2010-05-08";
652 let headers = vec![
653 ("host".to_string(), "iam.amazonaws.com".to_string()),
654 ("x-amz-date".to_string(), amz_date.to_string()),
655 ];
656 let body: &[u8] = b"";
657
658 let canonical_request = {
660 let mut parsed = ParsedSigV4 {
661 access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
662 date_stamp: date_stamp.into(),
663 region: region.into(),
664 service: service.into(),
665 signed_headers: vec!["host".into(), "x-amz-date".into()],
666 signature: String::new(),
667 amz_date: amz_date.into(),
668 is_presigned: false,
669 };
670 let req = VerifyRequest {
671 method,
672 path,
673 query,
674 headers: &headers,
675 body,
676 };
677 let cr = build_canonical_request(&parsed, &req).unwrap();
678 parsed.signature = {
679 let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
680 let sts = format!(
681 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
682 amz_date,
683 scope,
684 hex::encode(Sha256::digest(cr.as_bytes()))
685 );
686 let sk = derive_signing_key(secret, date_stamp, region, service);
687 hex::encode(hmac_sha256(&sk, sts.as_bytes()))
688 };
689 parsed
690 };
691
692 let req = VerifyRequest {
693 method,
694 path,
695 query,
696 headers: &headers,
697 body,
698 };
699 let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
700 verify(&canonical_request, &req, secret, now).unwrap();
701 }
702
703 #[test]
704 fn verify_rejects_tampered_body() {
705 let secret = AWS_EXAMPLE_SECRET;
706 let date_stamp = "20260101";
707 let amz_date = "20260101T120000Z";
708 let region = "us-east-1";
709 let service = "iam";
710 let method = "POST";
711 let path = "/";
712 let query = "";
713 let headers = vec![
714 ("host".to_string(), "iam.amazonaws.com".to_string()),
715 ("x-amz-date".to_string(), amz_date.to_string()),
716 ];
717 let original_body: &[u8] = b"Action=ListUsers&Version=2010-05-08";
718
719 let mut parsed = ParsedSigV4 {
721 access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
722 date_stamp: date_stamp.into(),
723 region: region.into(),
724 service: service.into(),
725 signed_headers: vec!["host".into(), "x-amz-date".into()],
726 signature: String::new(),
727 amz_date: amz_date.into(),
728 is_presigned: false,
729 };
730 let signing_req = VerifyRequest {
731 method,
732 path,
733 query,
734 headers: &headers,
735 body: original_body,
736 };
737 let cr = build_canonical_request(&parsed, &signing_req).unwrap();
738 let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
739 let sts = format!(
740 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
741 amz_date,
742 scope,
743 hex::encode(Sha256::digest(cr.as_bytes()))
744 );
745 let sk = derive_signing_key(secret, date_stamp, region, service);
746 parsed.signature = hex::encode(hmac_sha256(&sk, sts.as_bytes()));
747
748 let tampered = VerifyRequest {
750 method,
751 path,
752 query,
753 headers: &headers,
754 body: b"Action=DeleteUser&Version=2010-05-08",
755 };
756 let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
757 assert!(matches!(
758 verify(&parsed, &tampered, secret, now),
759 Err(SigV4Error::SignatureMismatch)
760 ));
761 }
762
763 #[test]
764 fn collapse_ws_normalizes_runs() {
765 assert_eq!(collapse_ws(" foo bar "), "foo bar");
766 assert_eq!(collapse_ws("foo\tbar"), "foo bar");
767 }
768}