1pub(crate) use chrono::{DateTime, NaiveDateTime, Utc};
13use dashmap::DashMap;
14use http::header::HeaderMap;
15use once_cell::sync::Lazy;
16use pingora::{http::RequestHeader, proxy::Session};
17use ring::hmac;
18use sha256::digest;
19use std::{
20 collections::{HashMap, HashSet},
21 fmt, io,
22};
23use tracing::{debug, error};
24use url::Url;
25
26use bytes::{BufMut, Bytes, BytesMut};
27
28static SIGNING_KEY_CACHE: Lazy<DashMap<String, Vec<u8>>> = Lazy::new(DashMap::new);
49
50use crate::parsers::{
51 cos_map::CosMapItem,
52 credentials::{parse_credential_scope, parse_token_from_header},
53};
54
55const SHORT_DATE: &str = "%Y%m%d";
56const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ";
57
58const SHA256_EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
61
62pub struct AwsSign<'a, T: 'a>
70where
71 &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
72 T: std::fmt::Debug,
73{
74 method: &'a str,
75 url: Url,
76 datetime: &'a DateTime<Utc>,
77 region: &'a str,
78 access_key: &'a str,
79 secret_key: &'a str,
80 headers: T,
81 payload_override: Option<String>,
82
83 service: &'a str,
112
113 body: &'a [u8],
115}
116
117impl<'a> AwsSign<'a, HashMap<String, String>> {
137 #[allow(clippy::too_many_arguments)]
138 pub fn new<B: AsRef<[u8]> + ?Sized>(
139 method: &'a str,
140 url: &'a str,
141 datetime: &'a DateTime<Utc>,
142 headers: &'a HeaderMap,
143 region: &'a str,
144 access_key: &'a str,
145 secret_key: &'a str,
146 service: &'a str,
147 body: &'a B,
148 _signed_headers: Option<&'a Vec<String>>,
149 ) -> Self {
150 let signed_allow: Option<HashSet<&str>> =
151 _signed_headers.map(|v| v.iter().map(String::as_str).collect());
152
153 let headers: HashMap<String, String> = headers
154 .iter()
155 .filter_map(|(key, value)| {
156 let name = key.as_str().to_lowercase();
157
158 let keep = if let Some(ref set) = signed_allow {
160 set.contains(name.as_str())
162 } else {
163 name == "host"
165 || name.starts_with("x-amz-")
166 || matches!(
167 name.as_str(),
168 "content-length"
169 | "content-encoding"
170 | "transfer-encoding"
171 | "range"
172 | "expect"
173 | "x-amz-decoded-content-length"
174 )
175 };
176 if !keep {
177 return None;
178 }
179 value.to_str().ok().map(|v| (name, v.trim().to_owned()))
180 })
181 .collect();
182
183 debug!(url, "parsing presigned URL");
201 let url: Url = url.parse().expect("invalid URL passed to AwsSign::new");
202 Self {
216 method,
217 url,
218 datetime,
219 region,
220 access_key,
221 secret_key,
222 headers,
223 service,
224 body: body.as_ref(),
225 payload_override: None,
226 }
227 }
228}
229
230impl<'a, T> fmt::Debug for AwsSign<'a, T>
232where
233 &'a T: IntoIterator<Item = (&'a String, &'a String)>,
234 T: std::fmt::Debug,
235{
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 f.debug_struct("AwsSign")
238 .field("method", &self.method)
239 .field("url", &self.url)
240 .field("datetime", &self.datetime)
241 .field("region", &self.region)
242 .field("access_key", &self.access_key)
243 .field("secret_key", &"<REDACTED>")
244 .field("service", &self.service)
245 .field("body", &self.body)
246 .field("headers", &self.headers)
247 .field("payload_override", &self.payload_override)
248 .finish()
249 }
250}
251
252impl<'a, T> AwsSign<'a, T>
253where
254 &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
255 T: std::fmt::Debug,
256{
257 pub fn set_payload_override(&mut self, h: String) {
266 self.payload_override = Some(h);
267 }
268
269 pub fn canonical_header_string(&'a self) -> String {
278 let mut keyvalues: Vec<(&str, &str)> = (&self.headers)
280 .into_iter()
281 .map(|(key, value)| (key.as_str(), value.as_str()))
282 .collect();
283 keyvalues.sort_by_key(|(k, _)| *k);
284 keyvalues
285 .iter()
286 .map(|(key, value)| format!("{}:{}", key, value.trim()))
287 .collect::<Vec<_>>()
288 .join("\n")
289 }
290
291 pub fn signed_header_string(&'a self) -> String {
293 let mut keys: Vec<&str> = (&self.headers)
295 .into_iter()
296 .map(|(k, _)| k.as_str())
297 .collect();
298 keys.sort();
299 keys.join(";")
300 }
301
302 pub fn canonical_request(&'a self) -> String {
306 let url: &str = self.url.path();
307 let payload_line = if let Some(ov) = &self.payload_override {
308 ov.clone()
309 } else if self.body == b"UNSIGNED-PAYLOAD" {
310 "UNSIGNED-PAYLOAD".into()
311 } else if self.body == b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
312 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".into()
314 } else {
315 digest(self.body)
316 };
317
318 format!(
319 "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{payload}",
320 method = self.method,
321 uri = url,
322 query_string = canonical_query_string(&self.url),
323 headers = self.canonical_header_string(),
324 signed = self.signed_header_string(),
325 payload = payload_line,
326 )
327 }
328 pub fn sign(&'a self) -> String {
338 let scope = scope_string(self.datetime, self.region, self.service);
340
341 let mut kv: Vec<(&str, &str)> = (&self.headers)
344 .into_iter()
345 .map(|(k, v)| (k.as_str(), v.as_str()))
346 .collect();
347 kv.sort_by_key(|(k, _)| *k);
348 let canonical_headers = kv
349 .iter()
350 .map(|(k, v)| format!("{}:{}", k, v.trim()))
351 .collect::<Vec<_>>()
352 .join("\n");
353 let signed_headers = kv.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(";");
354
355 let payload_owned;
357 let payload_line: &str = if let Some(ov) = &self.payload_override {
358 ov.as_str()
359 } else if self.body == b"UNSIGNED-PAYLOAD" {
360 "UNSIGNED-PAYLOAD"
361 } else if self.body == b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
362 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
363 } else {
364 payload_owned = digest(self.body);
365 &payload_owned
366 };
367
368 let canonical = format!(
369 "{}\n{}\n{}\n{}\n\n{}\n{}",
370 self.method,
371 self.url.path(),
372 canonical_query_string(&self.url),
373 canonical_headers,
374 signed_headers,
375 payload_line,
376 );
377
378 let canonical_hash =
379 hex::encode(ring::digest::digest(&ring::digest::SHA256, canonical.as_bytes()).as_ref());
380 let sts = format!(
381 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
382 self.datetime.format(LONG_DATETIME),
383 scope,
384 canonical_hash,
385 );
386
387 let sk = signing_key(self.datetime, self.secret_key, self.region, self.service)
388 .expect("signing key derivation failed");
389 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &sk);
390 let signature = hex::encode(ring::hmac::sign(&key, sts.as_bytes()).as_ref());
391
392 let sign_string = format!(
393 "AWS4-HMAC-SHA256 Credential={}/{},SignedHeaders={},Signature={}",
394 self.access_key, scope, signed_headers, signature
395 );
396 debug!("sign_string: {}", sign_string);
397 sign_string
398 }
399}
400
401pub fn uri_encode(string: &str, encode_slash: bool) -> String {
402 let mut result = String::with_capacity(string.len() * 2);
403 for c in string.chars() {
404 match c {
405 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~' | '.' => result.push(c),
406 '/' if encode_slash => result.push_str("%2F"),
407 '/' if !encode_slash => result.push('/'),
408 _ => {
409 let mut buf = [0u8; 4];
411 for b in c.encode_utf8(&mut buf).bytes() {
412 use std::fmt::Write;
413 let _ = write!(result, "%{b:02X}");
414 }
415 }
416 }
417 }
418 result
419}
420
421pub fn canonical_query_string(uri: &Url) -> String {
422 let mut keyvalues = uri
423 .query_pairs()
424 .map(|(key, value)| uri_encode(&key, true) + "=" + &uri_encode(&value, true))
425 .collect::<Vec<String>>();
426 keyvalues.sort();
427 keyvalues.join("&")
428}
429
430pub fn scope_string(datetime: &DateTime<Utc>, region: &str, service: &str) -> String {
432 format!(
433 "{date}/{region}/{service}/aws4_request",
434 date = datetime.format(SHORT_DATE),
435 region = region,
436 service = service
437 )
438}
439
440pub fn string_to_sign(
444 datetime: &DateTime<Utc>,
445 region: &str,
446 canonical_req: &str,
447 service: &str,
448) -> String {
449 let hash = ring::digest::digest(&ring::digest::SHA256, canonical_req.as_bytes());
450 format!(
451 "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
452 timestamp = datetime.format(LONG_DATETIME),
453 scope = scope_string(datetime, region, service),
454 hash = hex::encode(hash.as_ref())
455 )
456}
457
458pub fn signing_key(
465 datetime: &DateTime<Utc>,
466 secret_key: &str,
467 region: &str,
468 service: &str,
469) -> Result<Vec<u8>, String> {
470 let date_str = datetime.format(SHORT_DATE).to_string();
471 let cache_key = format!("{date_str}:{region}:{service}:{secret_key}");
474
475 if let Some(cached) = SIGNING_KEY_CACHE.get(&cache_key) {
476 debug!("signing_key cache hit");
477 return Ok(cached.clone());
478 }
479
480 debug!("signing_key cache miss — deriving key");
481 let secret = String::from("AWS4") + secret_key;
482
483 let date_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
484 let date_tag = ring::hmac::sign(&date_key, date_str.as_bytes());
485
486 let region_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, date_tag.as_ref());
487 let region_tag = ring::hmac::sign(®ion_key, region.as_bytes());
488
489 let service_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, region_tag.as_ref());
490 let service_tag = ring::hmac::sign(&service_key, service.as_bytes());
491
492 let signing_key_val = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, service_tag.as_ref());
493 let signing_tag = ring::hmac::sign(&signing_key_val, b"aws4_request");
494 let derived = signing_tag.as_ref().to_vec();
495
496 SIGNING_KEY_CACHE.retain(|k, _| k.starts_with(&date_str));
500 SIGNING_KEY_CACHE.insert(cache_key, derived.clone());
501
502 Ok(derived)
503}
504
505pub(crate) async fn sign_request(
513 request: &mut RequestHeader,
514 cos_map: &CosMapItem,
515) -> Result<(), Box<dyn std::error::Error>> {
516 if cos_map.region.is_none() || cos_map.access_key.is_none() || cos_map.secret_key.is_none() {
518 return Err("Missing region, access_key or secret_key".into());
519 }
520
521 request.remove_header("authorization");
522
523 let datetime = chrono::Utc::now();
524 let method = request.method.to_string();
525 let url = request.uri.to_string();
526 let access_key = cos_map
527 .access_key
528 .as_ref()
529 .ok_or_else(|| pingora::Error::new_str("bucket config missing access_key"))?;
530 let secret_key = cos_map
531 .secret_key
532 .as_ref()
533 .ok_or_else(|| pingora::Error::new_str("bucket config missing secret_key"))?;
534 let region = cos_map
535 .region
536 .as_ref()
537 .ok_or_else(|| pingora::Error::new_str("bucket config missing region"))?;
538
539 request.insert_header(
540 "X-Amz-Date",
541 datetime
542 .format("%Y%m%dT%H%M%SZ")
543 .to_string()
544 .parse::<http::header::HeaderValue>()
545 .unwrap(),
546 )?;
547 let payload_hdr = request
555 .headers
556 .get("x-amz-content-sha256")
557 .and_then(|v| v.to_str().ok());
558
559 let payload_hash = match payload_hdr {
560 Some(h) => h,
562
563 None if matches!(method.as_str(), "GET" | "HEAD" | "DELETE") => SHA256_EMPTY,
565
566 _ => "UNSIGNED-PAYLOAD",
568 };
569
570 let payload_hash_value = payload_hash.to_string();
571 request.insert_header("x-amz-content-sha256", payload_hash_value.as_str())?;
572
573 let body_bytes: &[u8] = match payload_hash_value.as_str() {
574 SHA256_EMPTY => &[],
576 "UNSIGNED-PAYLOAD" => b"UNSIGNED-PAYLOAD",
577 "STREAMING-UNSIGNED-PAYLOAD-TRAILER" => b"STREAMING-UNSIGNED-PAYLOAD-TRAILER",
578 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" => b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
579 _ => &[],
581 };
582
583 let auth_header = AwsSign::new(
584 &method,
585 &url,
586 &datetime,
587 &request.headers,
588 region,
589 access_key,
590 secret_key,
591 "s3",
592 body_bytes,
593 None,
594 );
595 debug!("{:#?}", &auth_header);
596
597 let mut signer = auth_header;
598 signer.set_payload_override(payload_hash_value); let signature = signer.sign();
601 debug!("{:#?}", signature);
602
603 request.insert_header(
604 "Authorization",
605 http::header::HeaderValue::from_str(&signature)?,
606 )?;
607
608 Ok(())
609}
610
611#[allow(clippy::too_many_arguments)]
613async fn signature_is_valid_core(
614 method: &str,
615 provided_signature: &str,
616 region: &str,
617 service: &str,
618 datetime: DateTime<Utc>,
619 full_url: &str,
620 headers: &HeaderMap,
621 payload_override: Option<String>,
622 access_key: &str,
623 secret_key: &str,
624 signed_headers: &Vec<String>,
625 body_bytes: &[u8],
626) -> Result<bool, Box<dyn std::error::Error>> {
627 debug!("{:#?}", &headers);
629 let mut signer = AwsSign::new(
630 method,
631 full_url,
632 &datetime,
633 headers,
634 region,
635 access_key,
636 secret_key,
637 service,
638 body_bytes,
639 Some(signed_headers),
640 );
641
642 if let Some(ov) = payload_override {
648 debug!(payload_override = ov, "applying payload hash override");
649 signer.set_payload_override(ov);
650 }
651
652 let canonical = signer.canonical_request();
653 debug!("Canonical request:\n{}", canonical);
654 let signature = signer.sign();
655 let computed = signature.split("Signature=").nth(1).unwrap_or_default();
656 debug!("Provided signature: {}", provided_signature);
657 debug!("Computed signature: {}", computed);
658 Ok(computed == provided_signature)
659}
660
661pub async fn signature_is_valid_for_request(
663 auth_header: &str,
664 session: &Session,
665 secret_key: &str,
666) -> Result<bool, Box<dyn std::error::Error>> {
667 let (_, local_access_key) = parse_token_from_header(auth_header)
668 .map_err(|_| pingora::Error::new_str("Failed to parse token"))?;
669 let local_access_key = local_access_key.to_string();
670 if local_access_key.is_empty() {
671 error!("Missing access key");
672 return Ok(false);
673 }
674 let provided_signature = auth_header
675 .split("Signature=")
676 .nth(1)
677 .ok_or("Missing Signature")?;
678
679 let (_, (region, service)) = parse_credential_scope(auth_header)
680 .map_err(|_| pingora::Error::new_str("Invalid Credential scope"))?;
681
682 let method = session.req_header().method.to_string();
683 let dt_header = session
685 .req_header()
686 .headers
687 .get("x-amz-date")
688 .ok_or_else(|| pingora::Error::new_str("missing x-amz-date header"))?
689 .to_str()?;
690 let datetime = NaiveDateTime::parse_from_str(dt_header, LONG_DATETIME)?.and_utc();
691
692 let content_sha256 = session
693 .req_header()
694 .headers
695 .get("x-amz-content-sha256")
696 .and_then(|h| h.to_str().ok())
697 .ok_or_else(|| pingora::Error::new_str("Missing x-amz-content-sha256 header"))?;
698
699 let (body_bytes, payload_override) = if content_sha256 == "UNSIGNED-PAYLOAD" {
700 (b"UNSIGNED-PAYLOAD" as &[u8], None)
701 } else if content_sha256 == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
702 (
703 b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD".as_ref(),
704 Some("STREAMING-AWS4-HMAC-SHA256-PAYLOAD".to_string()),
705 )
706 } else {
707 (&[] as &[u8], Some(content_sha256.to_owned()))
710 };
711
712 let original_uri = session.req_header().uri.to_string();
714 let full_url = if original_uri.starts_with('/') {
715 let host = session
716 .req_header()
717 .headers
718 .get("host")
719 .ok_or_else(|| pingora::Error::new_str("Missing host header"))?
720 .to_str()?;
721 format!("https://{}{}", host, original_uri)
722 } else {
723 original_uri
724 };
725
726 let signed_headers_str = auth_header
728 .split("SignedHeaders=")
729 .nth(1)
730 .ok_or_else(|| pingora::Error::new_str("missing SignedHeaders in Authorization"))?
731 .split(',')
732 .next()
733 .ok_or_else(|| pingora::Error::new_str("malformed SignedHeaders value"))?;
734
735 let signed_headers: Vec<String> = signed_headers_str.split(';').map(str::to_string).collect();
736
737 signature_is_valid_core(
738 &method,
739 provided_signature,
740 region,
741 service,
742 datetime,
743 &full_url,
744 &session.req_header().headers,
745 payload_override,
746 &local_access_key,
747 secret_key,
748 &signed_headers,
749 body_bytes,
750 )
751 .await
752}
753
754pub async fn signature_is_valid_for_presigned(
756 session: &Session,
757 secret_key: &str,
758) -> Result<bool, Box<dyn std::error::Error>> {
759 let uri = session.req_header().uri.to_string();
762 let full_uri = if uri.starts_with('/') {
763 let host = session
765 .req_header()
766 .headers
767 .get("host")
768 .ok_or("Missing host header")?
769 .to_str()?;
770 format!("https://{}{}", host, uri)
771 } else {
772 uri
773 };
774
775 let mut url = Url::parse(&full_uri)?;
776 debug!("full_url: {}", url);
777 let mut provided_signature = None;
778 let mut qp: Vec<(String, String)> = vec![];
779 for (k, v) in url.query_pairs() {
780 if k == "X-Amz-Signature" {
781 provided_signature = Some(v.into_owned());
782 } else {
783 qp.push((k.into_owned(), v.into_owned()));
784 }
785 }
786 let provided_signature = provided_signature.ok_or("Missing X-Amz-Signature")?;
787
788 qp.sort();
790 let new_query = qp
791 .iter()
792 .map(|(k, v)| format!("{k}={v}"))
793 .collect::<Vec<_>>()
794 .join("&");
795 url.set_query(Some(&new_query));
796
797 let params: HashMap<_, _> = qp.into_iter().collect();
799 debug!("params: {:?}", params);
800 debug!("url: {:?}", url);
801
802 debug!("provided signature: {}", provided_signature);
803 let credential = params
804 .get("X-Amz-Credential")
805 .ok_or("Missing X-Amz-Credential")?;
806
807 debug!("credential: {}", credential);
808
809 let mut parts = credential.split('/');
811 let access_key = parts.next().ok_or("Malformed Credential")?;
812 let _credential_date = parts.next().ok_or("Malformed Credential")?;
813 let region = parts.next().ok_or("Malformed Credential")?;
814 let service = parts.next().ok_or("Malformed Credential")?;
815
816 debug!("access_key: {}", access_key);
817 debug!("region: {}", region);
818 debug!("service: {}", service);
819
820 let date_str = params.get("X-Amz-Date").ok_or("Missing X-Amz-Date")?;
822 let datetime = NaiveDateTime::parse_from_str(date_str, LONG_DATETIME)?.and_utc();
823
824 debug!("datetime: {}", datetime);
825
826 if let Some(expires_str) = params.get("X-Amz-Expires") {
828 let expires_secs: i64 = expires_str.parse().map_err(|_| "Invalid X-Amz-Expires")?;
829 let expiry = datetime + chrono::Duration::seconds(expires_secs);
830 let now = chrono::Utc::now();
831 if now > expiry {
832 debug!("presigned URL expired at {}, now is {}", expiry, now);
833 return Err("Presigned URL has expired".into());
834 }
835 }
836
837 let body_bytes: &[u8] = b"UNSIGNED-PAYLOAD";
838 let payload_override = None;
839
840 debug!("body_bytes: {:?}", body_bytes);
841
842 let signed_headers = params
844 .get("X-Amz-SignedHeaders")
845 .ok_or_else(|| pingora::Error::new_str("missing X-Amz-SignedHeaders in presigned URL"))?
846 .split(';')
847 .map(str::to_string)
848 .collect::<Vec<_>>();
849
850 let mut signed_hdrs = HeaderMap::new();
851
852 let host = url
853 .host_str()
854 .ok_or_else(|| pingora::Error::new_str("presigned URL has no host"))?;
855 let host_header = match url.port_or_known_default() {
856 Some(443) | Some(80) | None => host.to_owned(),
857 Some(p) => format!("{}:{}", host, p),
858 };
859
860 signed_hdrs.insert("host", host_header.parse()?);
861
862 for h in &[
864 "x-amz-date",
865 "x-amz-content-sha256",
866 "range",
867 "x-amz-security-token",
868 ] {
869 if signed_headers.contains(&h.to_string())
870 && let Some(v) = session.req_header().headers.get(*h)
871 {
872 signed_hdrs.insert(*h, v.clone());
873 }
874 }
875
876 debug!("signed_headers: {:?}", signed_headers);
877 signature_is_valid_core(
879 session.req_header().method.as_str(),
880 &provided_signature,
881 region,
882 service,
883 datetime,
884 url.as_str(),
885 &signed_hdrs,
886 payload_override,
887 access_key,
888 secret_key,
889 &signed_headers,
890 body_bytes,
891 )
892 .await
893}
894
895pub async fn wrap_streaming_body(
915 session: &mut Session,
916 upstream_request: &mut RequestHeader,
917 region: &str,
918 access_key: &str,
919 secret_key: &str,
920) -> Result<(), Box<dyn std::error::Error>> {
921 let body: Bytes = session
923 .read_request_body()
924 .await
925 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
926 .ok_or_else(|| Box::<dyn std::error::Error>::from("empty request body"))?;
927
928 upstream_request.insert_header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")?;
930 upstream_request.remove_header("x-amz-decoded-content-length");
931 upstream_request.insert_header("content-length", body.len().to_string())?;
932
933 let ts = chrono::Utc::now();
935 let url = upstream_request.uri.to_string();
936 upstream_request.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
937 let signer = AwsSign::new(
938 upstream_request.method.as_str(),
939 &url,
940 &ts,
941 &upstream_request.headers,
942 region,
943 access_key,
944 secret_key,
945 "s3",
946 b"UNSIGNED-PAYLOAD",
947 None,
948 );
949 let auth = signer.sign();
950 upstream_request.insert_header("authorization", auth)?;
951
952 let end_of_stream: bool = session.is_body_done();
953
954 session
956 .write_response_body(Some(body), end_of_stream)
957 .await?;
958
959 Ok(())
960}
961
962const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
963
964pub struct ChunkSigner {
970 signing_key: Vec<u8>,
971 scope: String,
972 ts: String,
973 prev_sig: String,
974}
975
976impl ChunkSigner {
977 pub fn new(signing_key: Vec<u8>, scope: String, ts: String, seed_signature: String) -> Self {
982 Self {
983 signing_key,
984 scope,
985 ts,
986 prev_sig: seed_signature,
987 }
988 }
989
990 pub fn sign_chunk(
992 &mut self,
993 chunk: Bytes,
994 ) -> Result<Bytes, Box<dyn std::error::Error + Send + Sync>> {
995 let chunk_hash = hex::encode(sha256::digest(chunk.as_ref()));
997
998 let string_to_sign = format!(
1000 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1001 self.ts,
1002 self.scope,
1003 self.prev_sig,
1004 EMPTY_HASH, chunk_hash
1006 );
1007
1008 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
1010 let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
1011
1012 let mut buf = BytesMut::with_capacity(chunk.len() + 128);
1014 buf.put(format!("{:x};chunk-signature={}\r\n", chunk.len(), sig).as_bytes());
1015 buf.put(chunk);
1016 buf.put_slice(b"\r\n");
1017
1018 self.prev_sig = sig;
1020
1021 Ok(buf.freeze())
1022 }
1023
1024 pub fn final_chunk(&mut self) -> Bytes {
1027 let string_to_sign = format!(
1029 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1030 self.ts, self.scope, self.prev_sig, EMPTY_HASH, EMPTY_HASH
1031 );
1032 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
1033 let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
1034
1035 Bytes::from(format!("0;chunk-signature={}\r\n\r\n", sig))
1036 }
1037}
1038
1039pub fn resign_streaming_request(
1044 req: &mut RequestHeader,
1045 region: &str,
1046 access_key: &str,
1047 secret_key: &str,
1048 ts: DateTime<Utc>,
1049) -> Result<(), Box<dyn std::error::Error>> {
1050 req.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
1052
1053 let url = req.uri.to_string();
1054 let signer = AwsSign::new(
1055 req.method.as_str(),
1056 &url,
1057 &ts,
1058 &req.headers,
1059 region,
1060 access_key,
1061 secret_key,
1062 "s3",
1063 b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
1064 None,
1065 );
1066
1067 let auth = signer.sign();
1068 req.insert_header("authorization", auth)?;
1069
1070 Ok(())
1071}
1072
1073#[derive(Debug)]
1227pub struct StreamingState {
1233 region: String,
1234 _access_key: String,
1235 _secret_key: String,
1236 ts: chrono::DateTime<chrono::Utc>,
1237 prior_sig: String,
1238 signing_key: Vec<u8>,
1239 pub decode_buf: BytesMut,
1241}
1242
1243impl StreamingState {
1244 pub fn new(
1247 region: String,
1248 access_key: String,
1249 secret_key: String,
1250 ts: chrono::DateTime<chrono::Utc>,
1251 seed_signature: String,
1252 ) -> Self {
1253 let signing_key = signing_key(&ts, &secret_key, ®ion, "s3").expect("signing key");
1254 Self {
1255 region,
1256 _access_key: access_key,
1257 _secret_key: secret_key,
1258 ts,
1259 prior_sig: seed_signature,
1260 signing_key,
1261 decode_buf: BytesMut::new(),
1262 }
1263 }
1264
1265 pub fn sign_chunk(&mut self, payload: &[u8]) -> io::Result<Bytes> {
1267 let sig = compute_chunk_signature(
1268 payload,
1269 &self.signing_key,
1270 &self.prior_sig,
1271 &self.ts,
1272 &self.region,
1273 )?;
1274 self.prior_sig = sig.clone();
1275 Ok(build_chunk_frame(payload, &sig))
1276 }
1277
1278 pub fn final_chunk(&mut self) -> io::Result<Bytes> {
1280 let sig = compute_chunk_signature(
1281 &[],
1282 &self.signing_key,
1283 &self.prior_sig,
1284 &self.ts,
1285 &self.region,
1286 )?;
1287 Ok(build_chunk_frame(&[], &sig))
1288 }
1289}
1290
1291fn sha256_hex(data: &[u8]) -> String {
1298 sha256::digest(data)
1299}
1300
1301const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1303
1304fn compute_chunk_signature(
1309 payload: &[u8],
1310 signing_key: &[u8],
1311 prior_sig: &str,
1312 ts: &DateTime<Utc>,
1313 region: &str,
1314) -> io::Result<String> {
1315 let time = ts.format("%Y%m%dT%H%M%SZ").to_string();
1317 let scope = format!("{}/{}/s3/aws4_request", ts.format("%Y%m%d"), region);
1318
1319 let string_to_sign = format!(
1320 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1321 time,
1322 scope,
1323 prior_sig,
1324 EMPTY_SHA256, sha256_hex(payload),
1326 );
1327
1328 let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
1330 let sig = hmac::sign(&key, string_to_sign.as_bytes());
1331 Ok(hex::encode(sig.as_ref()))
1332}
1333
1334pub fn parse_aws_chunk_header(buf: &[u8]) -> Option<(usize, usize)> {
1348 let crlf = buf.windows(2).position(|w| w == b"\r\n")?;
1350 let header = std::str::from_utf8(&buf[..crlf]).ok()?;
1351 let hex_size = header.split(';').next()?;
1353 let payload_len = usize::from_str_radix(hex_size.trim(), 16).ok()?;
1354 Some((crlf + 2, payload_len))
1355}
1356
1357fn build_chunk_frame(payload: &[u8], sig: &str) -> Bytes {
1359 let mut buf = BytesMut::with_capacity(payload.len() + sig.len() + 64);
1360 use std::fmt::Write;
1362 write!(&mut buf, "{:x};chunk-signature={}\r\n", payload.len(), sig).unwrap();
1363 buf.extend_from_slice(payload);
1364 buf.extend_from_slice(b"\r\n");
1365 buf.freeze()
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371 use crate::parsers::cos_map::CosMapItem;
1372 use http::{HeaderMap, Method};
1373 use pingora::http::RequestHeader;
1374 use regex::Regex;
1375 use sha256::digest;
1376
1377 #[test]
1378 fn sample_canonical_request() {
1379 let datetime = chrono::Utc::now();
1380 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1381 let map: HeaderMap = HeaderMap::new();
1382 let aws_sign = AwsSign::new(
1383 "GET",
1384 url,
1385 &datetime,
1386 &map,
1387 "us-east-1",
1388 "a",
1389 "b",
1390 "s3",
1391 "",
1392 None,
1393 );
1394 let s = aws_sign.canonical_request();
1395 assert_eq!(
1396 s,
1397 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1398 );
1399 }
1400
1401 #[test]
1402 fn sample_canonical_request_using_u8_body() {
1403 let datetime = chrono::Utc::now();
1404 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1405 let map: HeaderMap = HeaderMap::new();
1406 let aws_sign = AwsSign::new(
1407 "GET",
1408 url,
1409 &datetime,
1410 &map,
1411 "us-east-1",
1412 "a",
1413 "b",
1414 "s3",
1415 "".as_bytes(),
1416 None,
1417 );
1418 let s = aws_sign.canonical_request();
1419 assert_eq!(
1420 s,
1421 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1422 );
1423 }
1424
1425 #[test]
1426 fn sample_canonical_request_using_vec_body() {
1427 let datetime = chrono::Utc::now();
1428 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1429 let map: HeaderMap = HeaderMap::new();
1430 let body = Vec::new();
1431 let aws_sign = AwsSign::new(
1432 "GET",
1433 url,
1434 &datetime,
1435 &map,
1436 "us-east-1",
1437 "a",
1438 "b",
1439 "s3",
1440 &body,
1441 None,
1442 );
1443 let s = aws_sign.canonical_request();
1444 assert_eq!(
1445 s,
1446 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1447 );
1448 }
1449
1450 fn make_cos_map_item() -> CosMapItem {
1451 CosMapItem {
1452 region: Some("us-east-1".into()),
1453 access_key: Some("AKIDEXAMPLE".into()),
1454 secret_key: Some("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into()),
1455 host: "bucket.s3.us-east-1.amazonaws.com".into(),
1456 port: 443,
1457 api_key: None,
1458 ttl: None,
1459 tls: Some(true),
1460 addressing_style: Some("path".to_string()),
1461 }
1462 }
1463
1464 #[tokio::test]
1466 async fn post_request_uses_unsigned_payload() {
1467 let mut req = RequestHeader::build(
1469 Method::GET,
1470 b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1471 None
1472 ).unwrap();
1473 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1474 .unwrap();
1475 assert!(req.headers.get("x-amz-content-sha256").is_none());
1476
1477 let cos = make_cos_map_item();
1479 sign_request(&mut req, &cos).await.unwrap();
1480
1481 let payload_header = req
1483 .headers
1484 .get("x-amz-content-sha256")
1485 .unwrap()
1486 .to_str()
1487 .unwrap();
1488 assert_eq!(
1489 payload_header,
1490 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1491 );
1492
1493 let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1495 assert!(auth.contains("Credential=AKIDEXAMPLE/"));
1496 assert!(auth.contains("/us-east-1/s3/aws4_request,"));
1497 }
1498
1499 #[tokio::test]
1501 async fn get_request_sets_empty_body_hash_and_signature_format() {
1502 let mut req = RequestHeader::build(
1503 Method::GET, b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1504 None
1505 ).unwrap();
1506 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1507 .unwrap();
1508 let cos = make_cos_map_item();
1509 sign_request(&mut req, &cos).await.unwrap();
1510
1511 let empty_hash = digest(b"");
1513 let header_hash = req
1514 .headers
1515 .get("x-amz-content-sha256")
1516 .unwrap()
1517 .to_str()
1518 .unwrap();
1519 assert_eq!(header_hash, empty_hash);
1520
1521 let x_amz_date = req.headers.get("x-amz-date").unwrap().to_str().unwrap();
1523 let re_date = Regex::new(r"^\d{8}T\d{6}Z$").unwrap();
1524 assert!(
1525 re_date.is_match(x_amz_date),
1526 "x-amz-date wrong format: {}",
1527 x_amz_date
1528 );
1529
1530 let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1532 assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/"));
1533 assert!(auth.contains("SignedHeaders="));
1535 assert!(auth.contains("host;"));
1536 assert!(auth.contains("x-amz-content-sha256;"));
1537 assert!(auth.contains("x-amz-date"));
1538 }
1539
1540 #[tokio::test]
1542 async fn error_when_missing_credentials() {
1543 let mut req = RequestHeader::build(
1544 Method::GET,
1545 b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1546 None
1547 ).unwrap();
1548 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1549 .unwrap();
1550 let mut cos = make_cos_map_item();
1551 cos.region = None; let err = sign_request(&mut req, &cos).await.unwrap_err();
1553 let msg = format!("{}", err);
1554 assert!(msg.contains("Missing region, access_key or secret_key"));
1555 }
1556
1557 #[test]
1558 fn uri_encode_edge_cases() {
1559 assert_eq!(uri_encode("simple", true), "simple");
1560 assert_eq!(uri_encode("a b", true), "a%20b");
1561 assert_eq!(
1562 uri_encode("/path/with/slash", true),
1563 "%2Fpath%2Fwith%2Fslash"
1564 );
1565 assert_eq!(uri_encode("/path/with/slash", false), "/path/with/slash");
1566 assert!(uri_encode("unicode✓", true).contains("%E2%9C%93"));
1567 }
1568
1569 #[test]
1570 fn canonical_query_string_sorts_and_encodes() {
1571 let url = "https://example.com/?b=2&a=1 space";
1572 let parsed = url.parse().unwrap();
1573 let qs = canonical_query_string(&parsed);
1574 assert_eq!(qs, "a=1%20space&b=2");
1575 }
1576
1577 #[test]
1578 fn canonical_query_string_empty() {
1579 let url: Url = "https://example.com/path".parse().unwrap();
1580 assert_eq!(canonical_query_string(&url), "");
1581 }
1582
1583 #[test]
1584 fn scope_string_format() {
1585 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1586 .unwrap()
1587 .with_timezone(&chrono::Utc);
1588 assert_eq!(
1589 scope_string(&dt, "us-east-1", "s3"),
1590 "20130524/us-east-1/s3/aws4_request"
1591 );
1592 }
1593
1594 #[test]
1595 fn string_to_sign_format() {
1596 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1597 .unwrap()
1598 .with_timezone(&chrono::Utc);
1599 let sts = string_to_sign(&dt, "us-east-1", "canonical-request-here", "s3");
1600 assert!(sts.starts_with("AWS4-HMAC-SHA256\n"));
1601 assert!(sts.contains("20130524T000000Z"));
1602 assert!(sts.contains("20130524/us-east-1/s3/aws4_request"));
1603 let last = sts.lines().last().unwrap();
1605 assert_eq!(last.len(), 64);
1606 assert!(last.chars().all(|c| c.is_ascii_hexdigit()));
1607 }
1608
1609 #[test]
1610 fn signing_key_returns_32_bytes() {
1611 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1612 .unwrap()
1613 .with_timezone(&chrono::Utc);
1614 let key = signing_key(
1615 &dt,
1616 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1617 "us-east-1",
1618 "s3",
1619 )
1620 .unwrap();
1621 assert_eq!(key.len(), 32);
1622 }
1623
1624 #[test]
1625 fn canonical_header_and_signed_header_strings() {
1626 let datetime = chrono::Utc::now();
1627 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1628 let mut headers = HeaderMap::new();
1629 headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1630 headers.insert("x-amz-date", "20130524T000000Z".parse().unwrap());
1631
1632 let signer = AwsSign::new(
1633 "GET",
1634 url,
1635 &datetime,
1636 &headers,
1637 "us-east-1",
1638 "AK",
1639 "SK",
1640 "s3",
1641 "",
1642 None,
1643 );
1644
1645 let signed = signer.signed_header_string();
1646 assert_eq!(signed, "host;x-amz-date");
1648
1649 let canonical = signer.canonical_header_string();
1650 assert!(canonical.contains("host:bucket.s3.us-east-1.amazonaws.com"));
1651 assert!(canonical.contains("x-amz-date:20130524T000000Z"));
1652 }
1653
1654 #[test]
1655 fn sign_output_format() {
1656 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1657 .unwrap()
1658 .with_timezone(&chrono::Utc);
1659 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1660 let mut headers = HeaderMap::new();
1661 headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1662 let signer = AwsSign::new(
1663 "GET",
1664 url,
1665 &dt,
1666 &headers,
1667 "us-east-1",
1668 "AKID",
1669 "SECRET",
1670 "s3",
1671 "",
1672 None,
1673 );
1674 let auth = signer.sign();
1675 assert!(
1676 auth.starts_with(
1677 "AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1/s3/aws4_request,"
1678 )
1679 );
1680 assert!(auth.contains("SignedHeaders="));
1681 assert!(auth.contains("Signature="));
1682 let sig = auth.split("Signature=").nth(1).unwrap();
1684 assert_eq!(sig.len(), 64);
1685 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1686 }
1687
1688 #[test]
1689 fn canonical_request_uses_unsigned_payload_marker() {
1690 let dt = chrono::Utc::now();
1691 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1692 let headers = HeaderMap::new();
1693 let mut signer = AwsSign::new(
1694 "PUT",
1695 url,
1696 &dt,
1697 &headers,
1698 "us-east-1",
1699 "AK",
1700 "SK",
1701 "s3",
1702 "UNSIGNED-PAYLOAD",
1703 None,
1704 );
1705 signer.set_payload_override("UNSIGNED-PAYLOAD".into());
1706 assert!(signer.canonical_request().ends_with("UNSIGNED-PAYLOAD"));
1707 }
1708
1709 #[test]
1710 fn chunk_signer_produces_valid_frames() {
1711 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1712 .unwrap()
1713 .with_timezone(&chrono::Utc);
1714 let key = signing_key(&dt, "SECRET", "us-east-1", "s3").unwrap();
1715 let scope = scope_string(&dt, "us-east-1", "s3");
1716 let ts = dt.format("%Y%m%dT%H%M%SZ").to_string();
1717 let mut cs = ChunkSigner::new(key, scope, ts, "seed-sig-hex".into());
1718
1719 let payload: &[u8] = b"hello world";
1720 let frame = cs
1721 .sign_chunk(bytes::Bytes::copy_from_slice(payload))
1722 .unwrap();
1723 let frame_str = std::str::from_utf8(&frame).unwrap();
1725 assert!(frame_str.contains("chunk-signature="));
1726 assert!(frame_str.contains("\r\n"));
1727
1728 let final_frame = cs.final_chunk();
1729 let final_str = std::str::from_utf8(&final_frame).unwrap();
1730 assert!(final_str.contains("chunk-signature="));
1731 }
1732}