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