1pub(crate) use chrono::{DateTime, NaiveDateTime, Utc};
13use http::header::HeaderMap;
14use pingora::{http::RequestHeader, proxy::Session};
15use ring::hmac;
16use sha256::digest;
17use std::{
18 collections::{HashMap, HashSet},
19 fmt, io,
20};
21use tracing::{debug, error};
22use url::Url;
23
24use bytes::{BufMut, Bytes, BytesMut};
25
26use crate::parsers::{
27 cos_map::CosMapItem,
28 credentials::{parse_credential_scope, parse_token_from_header},
29};
30
31const SHORT_DATE: &str = "%Y%m%d";
32const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ";
33
34pub struct AwsSign<'a, T: 'a>
42where
43 &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
44 T: std::fmt::Debug,
45{
46 method: &'a str,
47 url: Url,
48 datetime: &'a DateTime<Utc>,
49 region: &'a str,
50 access_key: &'a str,
51 secret_key: &'a str,
52 headers: T,
53 payload_override: Option<String>,
54
55 service: &'a str,
84
85 body: &'a [u8],
87}
88
89impl<'a> AwsSign<'a, HashMap<String, String>> {
109 #[allow(clippy::too_many_arguments)]
110 pub fn new<B: AsRef<[u8]> + ?Sized>(
111 method: &'a str,
112 url: &'a str,
113 datetime: &'a DateTime<Utc>,
114 headers: &'a HeaderMap,
115 region: &'a str,
116 access_key: &'a str,
117 secret_key: &'a str,
118 service: &'a str,
119 body: &'a B,
120 _signed_headers: Option<&'a Vec<String>>,
121 ) -> Self {
122 let signed_allow: Option<HashSet<&str>> =
123 _signed_headers.map(|v| v.iter().map(String::as_str).collect());
124
125 let headers: HashMap<String, String> = headers
126 .iter()
127 .filter_map(|(key, value)| {
128 let name = key.as_str().to_lowercase();
129
130 let keep = if let Some(ref set) = signed_allow {
132 set.contains(name.as_str())
134 } else {
135 name == "host"
137 || name.starts_with("x-amz-")
138 || matches!(
139 name.as_str(),
140 "content-length"
141 | "content-encoding"
142 | "transfer-encoding"
143 | "range"
144 | "expect"
145 | "x-amz-decoded-content-length"
146 )
147 };
148 if !keep {
149 return None;
150 }
151 value.to_str().ok().map(|v| (name, v.trim().to_owned()))
152 })
153 .collect();
154
155 debug!(url, "parsing presigned URL");
173 let url: Url = url.parse().expect("invalid URL passed to AwsSign::new");
174 Self {
188 method,
189 url,
190 datetime,
191 region,
192 access_key,
193 secret_key,
194 headers,
195 service,
196 body: body.as_ref(),
197 payload_override: None,
198 }
199 }
200}
201
202impl<'a, T> fmt::Debug for AwsSign<'a, T>
204where
205 &'a T: IntoIterator<Item = (&'a String, &'a String)>,
206 T: std::fmt::Debug,
207{
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 f.debug_struct("AwsSign")
210 .field("method", &self.method)
211 .field("url", &self.url)
212 .field("datetime", &self.datetime)
213 .field("region", &self.region)
214 .field("access_key", &self.access_key)
215 .field("secret_key", &"<REDACTED>")
216 .field("service", &self.service)
217 .field("body", &self.body)
218 .field("headers", &self.headers)
219 .field("payload_override", &self.payload_override)
220 .finish()
221 }
222}
223
224impl<'a, T> AwsSign<'a, T>
225where
226 &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
227 T: std::fmt::Debug,
228{
229 pub fn set_payload_override(&mut self, h: String) {
238 self.payload_override = Some(h);
239 }
240
241 pub fn canonical_header_string(&'a self) -> String {
246 let mut keyvalues = self
247 .headers
248 .into_iter()
249 .map(|(key, value)| key.to_lowercase() + ":" + value.trim())
250 .collect::<Vec<String>>();
251 keyvalues.sort();
252 keyvalues.join("\n")
253 }
254
255 pub fn signed_header_string(&'a self) -> String {
257 let mut keys = self
258 .headers
259 .into_iter()
260 .map(|(key, _)| key.to_lowercase())
261 .collect::<Vec<String>>();
262 keys.sort();
263 keys.join(";")
264 }
265
266 pub fn canonical_request(&'a self) -> String {
270 let url: &str = self.url.path();
271 let payload_line = if let Some(ov) = &self.payload_override {
272 ov.clone()
273 } else if self.body == b"UNSIGNED-PAYLOAD" {
274 "UNSIGNED-PAYLOAD".into()
275 } else if self.body == b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
276 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".into()
278 } else {
279 digest(self.body)
280 };
281
282 format!(
283 "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{payload}",
284 method = self.method,
285 uri = url,
286 query_string = canonical_query_string(&self.url),
287 headers = self.canonical_header_string(),
288 signed = self.signed_header_string(),
289 payload = payload_line,
290 )
291 }
292 pub fn sign(&'a self) -> String {
297 let canonical = self.canonical_request();
298 let string_to_sign = string_to_sign(self.datetime, self.region, &canonical, self.service);
299 let signing_key = signing_key(self.datetime, self.secret_key, self.region, self.service);
300 let key = ring::hmac::Key::new(
301 ring::hmac::HMAC_SHA256,
302 &signing_key.expect("signing key derivation failed"),
303 );
304 let tag = ring::hmac::sign(&key, string_to_sign.as_bytes());
305 let signature = hex::encode(tag.as_ref());
306 let signed_headers = self.signed_header_string();
307
308 let sign_string = format!(
309 "AWS4-HMAC-SHA256 Credential={access_key}/{scope},\
310 SignedHeaders={signed_headers},Signature={signature}",
311 access_key = self.access_key,
312 scope = scope_string(self.datetime, self.region, self.service),
313 signed_headers = signed_headers,
314 signature = signature
315 );
316 debug!("sign_string: {}", sign_string);
317 sign_string
318 }
319}
320
321pub fn uri_encode(string: &str, encode_slash: bool) -> String {
322 let mut result = String::with_capacity(string.len() * 2);
323 for c in string.chars() {
324 match c {
325 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~' | '.' => result.push(c),
326 '/' if encode_slash => result.push_str("%2F"),
327 '/' if !encode_slash => result.push('/'),
328 _ => {
329 result.push_str(
330 &format!("{}", c)
331 .bytes()
332 .map(|b| format!("%{:02X}", b))
333 .collect::<String>(),
334 );
335 }
336 }
337 }
338 result
339}
340
341pub fn canonical_query_string(uri: &Url) -> String {
342 let mut keyvalues = uri
343 .query_pairs()
344 .map(|(key, value)| uri_encode(&key, true) + "=" + &uri_encode(&value, true))
345 .collect::<Vec<String>>();
346 keyvalues.sort();
347 keyvalues.join("&")
348}
349
350pub fn scope_string(datetime: &DateTime<Utc>, region: &str, service: &str) -> String {
352 format!(
353 "{date}/{region}/{service}/aws4_request",
354 date = datetime.format(SHORT_DATE),
355 region = region,
356 service = service
357 )
358}
359
360pub fn string_to_sign(
364 datetime: &DateTime<Utc>,
365 region: &str,
366 canonical_req: &str,
367 service: &str,
368) -> String {
369 let hash = ring::digest::digest(&ring::digest::SHA256, canonical_req.as_bytes());
370 format!(
371 "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
372 timestamp = datetime.format(LONG_DATETIME),
373 scope = scope_string(datetime, region, service),
374 hash = hex::encode(hash.as_ref())
375 )
376}
377
378pub fn signing_key(
382 datetime: &DateTime<Utc>,
383 secret_key: &str,
384 region: &str,
385 service: &str,
386) -> Result<Vec<u8>, String> {
387 let secret = String::from("AWS4") + secret_key;
388
389 let date_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
390 let date_tag = ring::hmac::sign(
391 &date_key,
392 datetime.format(SHORT_DATE).to_string().as_bytes(),
393 );
394
395 let region_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, date_tag.as_ref());
396 let region_tag = ring::hmac::sign(®ion_key, region.to_string().as_bytes());
397
398 let service_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, region_tag.as_ref());
399 let service_tag = ring::hmac::sign(&service_key, service.as_bytes());
400
401 let signing_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, service_tag.as_ref());
402 let signing_tag = ring::hmac::sign(&signing_key, b"aws4_request");
403 Ok(signing_tag.as_ref().to_vec())
404}
405
406pub(crate) async fn sign_request(
414 request: &mut RequestHeader,
415 cos_map: &CosMapItem,
416) -> Result<(), Box<dyn std::error::Error>> {
417 if cos_map.region.is_none() || cos_map.access_key.is_none() || cos_map.secret_key.is_none() {
419 return Err("Missing region, access_key or secret_key".into());
420 }
421
422 request.remove_header("authorization");
423
424 let datetime = chrono::Utc::now();
425 let method = request.method.to_string();
426 let url = request.uri.to_string();
427 let access_key = cos_map
428 .access_key
429 .as_ref()
430 .ok_or_else(|| pingora::Error::new_str("bucket config missing access_key"))?;
431 let secret_key = cos_map
432 .secret_key
433 .as_ref()
434 .ok_or_else(|| pingora::Error::new_str("bucket config missing secret_key"))?;
435 let region = cos_map
436 .region
437 .as_ref()
438 .ok_or_else(|| pingora::Error::new_str("bucket config missing region"))?;
439
440 request.insert_header(
441 "X-Amz-Date",
442 datetime
443 .format("%Y%m%dT%H%M%SZ")
444 .to_string()
445 .parse::<http::header::HeaderValue>()
446 .unwrap(),
447 )?;
448 let payload_hdr = request
456 .headers
457 .get("x-amz-content-sha256")
458 .and_then(|v| v.to_str().ok());
459
460 let payload_hash = match payload_hdr {
461 Some(h) => h,
463
464 None if matches!(method.as_str(), "GET" | "HEAD" | "DELETE") => &sha256::digest(b""),
466
467 _ => "UNSIGNED-PAYLOAD",
469 };
470
471 let payload_hash_value = payload_hash.to_string();
472 request.insert_header("x-amz-content-sha256", payload_hash_value.clone())?;
473
474 let body_bytes: &[u8] = match payload_hash_value.clone().as_str() {
475 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" => &[], "UNSIGNED-PAYLOAD" => b"UNSIGNED-PAYLOAD",
478 "STREAMING-UNSIGNED-PAYLOAD-TRAILER" => b"STREAMING-UNSIGNED-PAYLOAD-TRAILER",
479 "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" => b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
480 _ => &[],
482 };
483
484 let auth_header = AwsSign::new(
485 &method,
486 &url,
487 &datetime,
488 &request.headers,
489 region,
490 access_key,
491 secret_key,
492 "s3",
493 body_bytes,
494 None,
495 );
496 debug!("{:#?}", &auth_header);
497
498 let mut signer = auth_header;
499
500 signer.set_payload_override(payload_hash_value.clone());
509
510 let signature = signer.sign();
511 debug!("{:#?}", signature);
512
513 request.insert_header(
514 "Authorization",
515 http::header::HeaderValue::from_str(&signature)?,
516 )?;
517
518 Ok(())
519}
520
521#[allow(clippy::too_many_arguments)]
523async fn signature_is_valid_core(
524 method: &str,
525 provided_signature: &str,
526 region: &str,
527 service: &str,
528 datetime: DateTime<Utc>,
529 full_url: &str,
530 headers: &HeaderMap,
531 payload_override: Option<String>,
532 access_key: &str,
533 secret_key: &str,
534 signed_headers: &Vec<String>,
535 body_bytes: &[u8],
536) -> Result<bool, Box<dyn std::error::Error>> {
537 debug!("{:#?}", &headers);
539 let mut signer = AwsSign::new(
540 method,
541 full_url,
542 &datetime,
543 headers,
544 region,
545 access_key,
546 secret_key,
547 service,
548 body_bytes,
549 Some(signed_headers),
550 );
551
552 if let Some(ov) = payload_override {
558 debug!(payload_override = ov, "applying payload hash override");
559 signer.set_payload_override(ov);
560 }
561
562 let signature = signer.sign();
563 let computed = signature.split("Signature=").nth(1).unwrap_or_default();
564 debug!("Provided signature: {}", provided_signature);
565 debug!("Computed signature: {}", computed);
566 Ok(computed == provided_signature)
567}
568
569pub async fn signature_is_valid_for_request(
571 auth_header: &str,
572 session: &Session,
573 secret_key: &str,
574) -> Result<bool, Box<dyn std::error::Error>> {
575 let (_, local_access_key) = parse_token_from_header(auth_header)
576 .map_err(|_| pingora::Error::new_str("Failed to parse token"))?;
577 let local_access_key = local_access_key.to_string();
578 if local_access_key.is_empty() {
579 error!("Missing access key");
580 return Ok(false);
581 }
582 let provided_signature = auth_header
583 .split("Signature=")
584 .nth(1)
585 .ok_or("Missing Signature")?;
586
587 let (_, (region, service)) = parse_credential_scope(auth_header)
588 .map_err(|_| pingora::Error::new_str("Invalid Credential scope"))?;
589
590 let method = session.req_header().method.to_string();
591 let dt_header = session
593 .req_header()
594 .headers
595 .get("x-amz-date")
596 .ok_or_else(|| pingora::Error::new_str("missing x-amz-date header"))?
597 .to_str()?;
598 let datetime = NaiveDateTime::parse_from_str(dt_header, LONG_DATETIME)?.and_utc();
599
600 let content_sha256 = session
601 .req_header()
602 .headers
603 .get("x-amz-content-sha256")
604 .and_then(|h| h.to_str().ok())
605 .ok_or_else(|| pingora::Error::new_str("Missing x-amz-content-sha256 header"))?;
606
607 let (body_bytes, payload_override) = if content_sha256 == "UNSIGNED-PAYLOAD" {
608 (b"UNSIGNED-PAYLOAD" as &[u8], None)
609 } else if content_sha256 == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
610 (
611 b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD".as_ref(),
612 Some("STREAMING-AWS4-HMAC-SHA256-PAYLOAD".to_string()),
613 )
614 } else {
615 (&[] as &[u8], Some(content_sha256.to_owned()))
618 };
619
620 let original_uri = session.req_header().uri.to_string();
622 let full_url = if original_uri.starts_with('/') {
623 let host = session
624 .req_header()
625 .headers
626 .get("host")
627 .ok_or_else(|| pingora::Error::new_str("Missing host header"))?
628 .to_str()?;
629 format!("https://{}{}", host, original_uri)
630 } else {
631 original_uri
632 };
633
634 let signed_headers_str = auth_header
636 .split("SignedHeaders=")
637 .nth(1)
638 .ok_or_else(|| pingora::Error::new_str("missing SignedHeaders in Authorization"))?
639 .split(',')
640 .next()
641 .ok_or_else(|| pingora::Error::new_str("malformed SignedHeaders value"))?;
642
643 let signed_headers: Vec<String> = signed_headers_str.split(';').map(str::to_string).collect();
644
645 signature_is_valid_core(
646 &method,
647 provided_signature,
648 region,
649 service,
650 datetime,
651 &full_url,
652 &session.req_header().headers,
653 payload_override,
654 &local_access_key,
655 secret_key,
656 &signed_headers,
657 body_bytes,
658 )
659 .await
660}
661
662pub async fn signature_is_valid_for_presigned(
664 session: &Session,
665 secret_key: &str,
666) -> Result<bool, Box<dyn std::error::Error>> {
667 let uri = session.req_header().uri.to_string();
670 let full_uri = if uri.starts_with('/') {
671 let host = session
673 .req_header()
674 .headers
675 .get("host")
676 .ok_or("Missing host header")?
677 .to_str()?;
678 format!("https://{}{}", host, uri)
679 } else {
680 uri
681 };
682
683 let mut url = Url::parse(&full_uri)?;
684 debug!("full_url: {}", url);
685 let mut provided_signature = None;
686 let mut qp: Vec<(String, String)> = vec![];
687 for (k, v) in url.query_pairs() {
688 if k == "X-Amz-Signature" {
689 provided_signature = Some(v.into_owned());
690 } else {
691 qp.push((k.into_owned(), v.into_owned()));
692 }
693 }
694 let provided_signature = provided_signature.ok_or("Missing X-Amz-Signature")?;
695
696 qp.sort();
698 let new_query = qp
699 .iter()
700 .map(|(k, v)| format!("{k}={v}"))
701 .collect::<Vec<_>>()
702 .join("&");
703 url.set_query(Some(&new_query));
704
705 let params: HashMap<_, _> = qp.into_iter().collect();
707 debug!("params: {:?}", params);
708 debug!("url: {:?}", url);
709
710 debug!("provided signature: {}", provided_signature);
711 let credential = params
712 .get("X-Amz-Credential")
713 .ok_or("Missing X-Amz-Credential")?;
714
715 debug!("credential: {}", credential);
716
717 let mut parts = credential.split('/');
719 let access_key = parts.next().ok_or("Malformed Credential")?;
720 let _credential_date = parts.next().ok_or("Malformed Credential")?;
721 let region = parts.next().ok_or("Malformed Credential")?;
722 let service = parts.next().ok_or("Malformed Credential")?;
723
724 debug!("access_key: {}", access_key);
725 debug!("region: {}", region);
726 debug!("service: {}", service);
727
728 let date_str = params.get("X-Amz-Date").ok_or("Missing X-Amz-Date")?;
730 let datetime = NaiveDateTime::parse_from_str(date_str, LONG_DATETIME)?.and_utc();
731
732 debug!("datetime: {}", datetime);
733
734 let body_bytes: &[u8] = b"UNSIGNED-PAYLOAD";
735 let payload_override = None;
736
737 debug!("body_bytes: {:?}", body_bytes);
738
739 let signed_headers = params
741 .get("X-Amz-SignedHeaders")
742 .ok_or_else(|| pingora::Error::new_str("missing X-Amz-SignedHeaders in presigned URL"))?
743 .split(';')
744 .map(str::to_string)
745 .collect::<Vec<_>>();
746
747 let mut signed_hdrs = HeaderMap::new();
748
749 let host = url
750 .host_str()
751 .ok_or_else(|| pingora::Error::new_str("presigned URL has no host"))?;
752 let host_header = match url.port_or_known_default() {
753 Some(443) | Some(80) | None => host.to_owned(),
754 Some(p) => format!("{}:{}", host, p),
755 };
756
757 signed_hdrs.insert("host", host_header.parse()?);
758
759 for h in &[
761 "x-amz-date",
762 "x-amz-content-sha256",
763 "range",
764 "x-amz-security-token",
765 ] {
766 if signed_headers.contains(&h.to_string())
767 && let Some(v) = session.req_header().headers.get(*h)
768 {
769 signed_hdrs.insert(*h, v.clone());
770 }
771 }
772
773 debug!("signed_headers: {:?}", signed_headers);
774 signature_is_valid_core(
776 session.req_header().method.as_str(),
777 &provided_signature,
778 region,
779 service,
780 datetime,
781 url.as_str(),
782 &signed_hdrs,
783 payload_override,
784 access_key,
785 secret_key,
786 &signed_headers,
787 body_bytes,
788 )
789 .await
790}
791
792pub async fn wrap_streaming_body(
812 session: &mut Session,
813 upstream_request: &mut RequestHeader,
814 region: &str,
815 access_key: &str,
816 secret_key: &str,
817) -> Result<(), Box<dyn std::error::Error>> {
818 let body: Bytes = session
820 .read_request_body()
821 .await
822 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
823 .ok_or_else(|| Box::<dyn std::error::Error>::from("empty request body"))?;
824
825 upstream_request.insert_header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")?;
827 upstream_request.remove_header("x-amz-decoded-content-length");
828 upstream_request.insert_header("content-length", body.len().to_string())?;
829
830 let ts = chrono::Utc::now();
832 let url = upstream_request.uri.to_string();
833 upstream_request.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
834 let signer = AwsSign::new(
835 upstream_request.method.as_str(),
836 &url,
837 &ts,
838 &upstream_request.headers,
839 region,
840 access_key,
841 secret_key,
842 "s3",
843 b"UNSIGNED-PAYLOAD",
844 None,
845 );
846 let auth = signer.sign();
847 upstream_request.insert_header("authorization", auth)?;
848
849 let end_of_stream: bool = session.is_body_done();
850
851 session
853 .write_response_body(Some(body), end_of_stream)
854 .await?;
855
856 Ok(())
857}
858
859const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
860
861pub struct ChunkSigner {
867 signing_key: Vec<u8>,
868 scope: String,
869 ts: String,
870 prev_sig: String,
871}
872
873impl ChunkSigner {
874 pub fn new(signing_key: Vec<u8>, scope: String, ts: String, seed_signature: String) -> Self {
879 Self {
880 signing_key,
881 scope,
882 ts,
883 prev_sig: seed_signature,
884 }
885 }
886
887 pub fn sign_chunk(
889 &mut self,
890 chunk: Bytes,
891 ) -> Result<Bytes, Box<dyn std::error::Error + Send + Sync>> {
892 let chunk_hash = hex::encode(sha256::digest(chunk.as_ref()));
894
895 let string_to_sign = format!(
897 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
898 self.ts,
899 self.scope,
900 self.prev_sig,
901 EMPTY_HASH, chunk_hash
903 );
904
905 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
907 let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
908
909 let mut buf = BytesMut::with_capacity(chunk.len() + 128);
911 buf.put(format!("{:x};chunk-signature={}\r\n", chunk.len(), sig).as_bytes());
912 buf.put(chunk);
913 buf.put_slice(b"\r\n");
914
915 self.prev_sig = sig;
917
918 Ok(buf.freeze())
919 }
920
921 pub fn final_chunk(&mut self) -> Bytes {
924 let string_to_sign = format!(
926 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
927 self.ts, self.scope, self.prev_sig, EMPTY_HASH, EMPTY_HASH
928 );
929 let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
930 let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
931
932 Bytes::from(format!("0;chunk-signature={}\r\n\r\n", sig))
933 }
934}
935
936pub fn resign_streaming_request(
941 req: &mut RequestHeader,
942 region: &str,
943 access_key: &str,
944 secret_key: &str,
945 ts: DateTime<Utc>,
946) -> Result<(), Box<dyn std::error::Error>> {
947 req.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
949
950 let url = req.uri.to_string();
951 let signer = AwsSign::new(
952 req.method.as_str(),
953 &url,
954 &ts,
955 &req.headers,
956 region,
957 access_key,
958 secret_key,
959 "s3",
960 b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
961 None,
962 );
963
964 let auth = signer.sign();
965 req.insert_header("authorization", auth)?;
966
967 Ok(())
968}
969
970#[derive(Debug)]
1124pub struct StreamingState {
1130 region: String,
1131 _access_key: String,
1132 _secret_key: String,
1133 ts: chrono::DateTime<chrono::Utc>,
1134 prior_sig: String,
1135 signing_key: Vec<u8>,
1136}
1137
1138impl StreamingState {
1139 pub fn new(
1142 region: String,
1143 access_key: String,
1144 secret_key: String,
1145 ts: chrono::DateTime<chrono::Utc>,
1146 seed_signature: String,
1147 ) -> Self {
1148 let signing_key = signing_key(&ts, &secret_key, ®ion, "s3").expect("signing key");
1149 Self {
1150 region,
1151 _access_key: access_key,
1152 _secret_key: secret_key,
1153 ts,
1154 prior_sig: seed_signature,
1155 signing_key,
1156 }
1157 }
1158
1159 pub fn sign_chunk(&mut self, payload: &[u8]) -> io::Result<Bytes> {
1161 let sig = compute_chunk_signature(
1162 payload,
1163 &self.signing_key,
1164 &self.prior_sig,
1165 &self.ts,
1166 &self.region,
1167 )?;
1168 self.prior_sig = sig.clone();
1169 Ok(build_chunk_frame(payload, &sig))
1170 }
1171
1172 pub fn final_chunk(&mut self) -> io::Result<Bytes> {
1174 let sig = compute_chunk_signature(
1175 &[],
1176 &self.signing_key,
1177 &self.prior_sig,
1178 &self.ts,
1179 &self.region,
1180 )?;
1181 Ok(build_chunk_frame(&[], &sig))
1182 }
1183}
1184
1185fn sha256_hex(data: &[u8]) -> String {
1192 sha256::digest(data)
1193}
1194
1195const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1197
1198fn compute_chunk_signature(
1203 payload: &[u8],
1204 signing_key: &[u8],
1205 prior_sig: &str,
1206 ts: &DateTime<Utc>,
1207 region: &str,
1208) -> io::Result<String> {
1209 let time = ts.format("%Y%m%dT%H%M%SZ").to_string();
1211 let scope = format!("{}/{}/s3/aws4_request", ts.format("%Y%m%d"), region);
1212
1213 let string_to_sign = format!(
1214 "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1215 time,
1216 scope,
1217 prior_sig,
1218 EMPTY_SHA256, sha256_hex(payload),
1220 );
1221
1222 let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
1224 let sig = hmac::sign(&key, string_to_sign.as_bytes());
1225 Ok(hex::encode(sig.as_ref()))
1226}
1227
1228fn build_chunk_frame(payload: &[u8], sig: &str) -> Bytes {
1230 let mut buf = BytesMut::with_capacity(payload.len() + sig.len() + 64);
1231 use std::fmt::Write;
1233 write!(&mut buf, "{:x};chunk-signature={}\r\n", payload.len(), sig).unwrap();
1234 buf.extend_from_slice(payload);
1235 buf.extend_from_slice(b"\r\n");
1236 buf.freeze()
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242 use crate::parsers::cos_map::CosMapItem;
1243 use http::{HeaderMap, Method};
1244 use pingora::http::RequestHeader;
1245 use regex::Regex;
1246 use sha256::digest;
1247
1248 #[test]
1249 fn sample_canonical_request() {
1250 let datetime = chrono::Utc::now();
1251 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1252 let map: HeaderMap = HeaderMap::new();
1253 let aws_sign = AwsSign::new(
1254 "GET",
1255 url,
1256 &datetime,
1257 &map,
1258 "us-east-1",
1259 "a",
1260 "b",
1261 "s3",
1262 "",
1263 None,
1264 );
1265 let s = aws_sign.canonical_request();
1266 assert_eq!(
1267 s,
1268 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1269 );
1270 }
1271
1272 #[test]
1273 fn sample_canonical_request_using_u8_body() {
1274 let datetime = chrono::Utc::now();
1275 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1276 let map: HeaderMap = HeaderMap::new();
1277 let aws_sign = AwsSign::new(
1278 "GET",
1279 url,
1280 &datetime,
1281 &map,
1282 "us-east-1",
1283 "a",
1284 "b",
1285 "s3",
1286 "".as_bytes(),
1287 None,
1288 );
1289 let s = aws_sign.canonical_request();
1290 assert_eq!(
1291 s,
1292 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1293 );
1294 }
1295
1296 #[test]
1297 fn sample_canonical_request_using_vec_body() {
1298 let datetime = chrono::Utc::now();
1299 let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1300 let map: HeaderMap = HeaderMap::new();
1301 let body = Vec::new();
1302 let aws_sign = AwsSign::new(
1303 "GET",
1304 url,
1305 &datetime,
1306 &map,
1307 "us-east-1",
1308 "a",
1309 "b",
1310 "s3",
1311 &body,
1312 None,
1313 );
1314 let s = aws_sign.canonical_request();
1315 assert_eq!(
1316 s,
1317 "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1318 );
1319 }
1320
1321 fn make_cos_map_item() -> CosMapItem {
1322 CosMapItem {
1323 region: Some("us-east-1".into()),
1324 access_key: Some("AKIDEXAMPLE".into()),
1325 secret_key: Some("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into()),
1326 host: "bucket.s3.us-east-1.amazonaws.com".into(),
1327 port: 443,
1328 api_key: None,
1329 ttl: None,
1330 tls: Some(true),
1331 addressing_style: Some("path".to_string()),
1332 }
1333 }
1334
1335 #[tokio::test]
1337 async fn post_request_uses_unsigned_payload() {
1338 let mut req = RequestHeader::build(
1340 Method::GET,
1341 b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1342 None
1343 ).unwrap();
1344 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1345 .unwrap();
1346 assert!(req.headers.get("x-amz-content-sha256").is_none());
1347
1348 let cos = make_cos_map_item();
1350 sign_request(&mut req, &cos).await.unwrap();
1351
1352 let payload_header = req
1354 .headers
1355 .get("x-amz-content-sha256")
1356 .unwrap()
1357 .to_str()
1358 .unwrap();
1359 assert_eq!(
1360 payload_header,
1361 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1362 );
1363
1364 let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1366 assert!(auth.contains("Credential=AKIDEXAMPLE/"));
1367 assert!(auth.contains("/us-east-1/s3/aws4_request,"));
1368 }
1369
1370 #[tokio::test]
1372 async fn get_request_sets_empty_body_hash_and_signature_format() {
1373 let mut req = RequestHeader::build(
1374 Method::GET, b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1375 None
1376 ).unwrap();
1377 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1378 .unwrap();
1379 let cos = make_cos_map_item();
1380 sign_request(&mut req, &cos).await.unwrap();
1381
1382 let empty_hash = digest(b"");
1384 let header_hash = req
1385 .headers
1386 .get("x-amz-content-sha256")
1387 .unwrap()
1388 .to_str()
1389 .unwrap();
1390 assert_eq!(header_hash, empty_hash);
1391
1392 let x_amz_date = req.headers.get("x-amz-date").unwrap().to_str().unwrap();
1394 let re_date = Regex::new(r"^\d{8}T\d{6}Z$").unwrap();
1395 assert!(
1396 re_date.is_match(x_amz_date),
1397 "x-amz-date wrong format: {}",
1398 x_amz_date
1399 );
1400
1401 let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1403 assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/"));
1404 assert!(auth.contains("SignedHeaders="));
1406 assert!(auth.contains("host;"));
1407 assert!(auth.contains("x-amz-content-sha256;"));
1408 assert!(auth.contains("x-amz-date"));
1409 }
1410
1411 #[tokio::test]
1413 async fn error_when_missing_credentials() {
1414 let mut req = RequestHeader::build(
1415 Method::GET,
1416 b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1417 None
1418 ).unwrap();
1419 req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1420 .unwrap();
1421 let mut cos = make_cos_map_item();
1422 cos.region = None; let err = sign_request(&mut req, &cos).await.unwrap_err();
1424 let msg = format!("{}", err);
1425 assert!(msg.contains("Missing region, access_key or secret_key"));
1426 }
1427
1428 #[test]
1429 fn uri_encode_edge_cases() {
1430 assert_eq!(uri_encode("simple", true), "simple");
1431 assert_eq!(uri_encode("a b", true), "a%20b");
1432 assert_eq!(
1433 uri_encode("/path/with/slash", true),
1434 "%2Fpath%2Fwith%2Fslash"
1435 );
1436 assert_eq!(uri_encode("/path/with/slash", false), "/path/with/slash");
1437 assert_eq!(uri_encode("unicode✓", true).contains("%E2%9C%93"), true);
1438 }
1439
1440 #[test]
1441 fn canonical_query_string_sorts_and_encodes() {
1442 let url = "https://example.com/?b=2&a=1 space";
1443 let parsed = url.parse().unwrap();
1444 let qs = canonical_query_string(&parsed);
1445 assert_eq!(qs, "a=1%20space&b=2");
1446 }
1447
1448 #[test]
1449 fn canonical_query_string_empty() {
1450 let url: Url = "https://example.com/path".parse().unwrap();
1451 assert_eq!(canonical_query_string(&url), "");
1452 }
1453
1454 #[test]
1455 fn scope_string_format() {
1456 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1457 .unwrap()
1458 .with_timezone(&chrono::Utc);
1459 assert_eq!(
1460 scope_string(&dt, "us-east-1", "s3"),
1461 "20130524/us-east-1/s3/aws4_request"
1462 );
1463 }
1464
1465 #[test]
1466 fn string_to_sign_format() {
1467 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1468 .unwrap()
1469 .with_timezone(&chrono::Utc);
1470 let sts = string_to_sign(&dt, "us-east-1", "canonical-request-here", "s3");
1471 assert!(sts.starts_with("AWS4-HMAC-SHA256\n"));
1472 assert!(sts.contains("20130524T000000Z"));
1473 assert!(sts.contains("20130524/us-east-1/s3/aws4_request"));
1474 let last = sts.lines().last().unwrap();
1476 assert_eq!(last.len(), 64);
1477 assert!(last.chars().all(|c| c.is_ascii_hexdigit()));
1478 }
1479
1480 #[test]
1481 fn signing_key_returns_32_bytes() {
1482 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1483 .unwrap()
1484 .with_timezone(&chrono::Utc);
1485 let key = signing_key(
1486 &dt,
1487 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1488 "us-east-1",
1489 "s3",
1490 )
1491 .unwrap();
1492 assert_eq!(key.len(), 32);
1493 }
1494
1495 #[test]
1496 fn canonical_header_and_signed_header_strings() {
1497 let datetime = chrono::Utc::now();
1498 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1499 let mut headers = HeaderMap::new();
1500 headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1501 headers.insert("x-amz-date", "20130524T000000Z".parse().unwrap());
1502
1503 let signer = AwsSign::new(
1504 "GET",
1505 url,
1506 &datetime,
1507 &headers,
1508 "us-east-1",
1509 "AK",
1510 "SK",
1511 "s3",
1512 "",
1513 None,
1514 );
1515
1516 let signed = signer.signed_header_string();
1517 assert_eq!(signed, "host;x-amz-date");
1519
1520 let canonical = signer.canonical_header_string();
1521 assert!(canonical.contains("host:bucket.s3.us-east-1.amazonaws.com"));
1522 assert!(canonical.contains("x-amz-date:20130524T000000Z"));
1523 }
1524
1525 #[test]
1526 fn sign_output_format() {
1527 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1528 .unwrap()
1529 .with_timezone(&chrono::Utc);
1530 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1531 let mut headers = HeaderMap::new();
1532 headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1533 let signer = AwsSign::new(
1534 "GET",
1535 url,
1536 &dt,
1537 &headers,
1538 "us-east-1",
1539 "AKID",
1540 "SECRET",
1541 "s3",
1542 "",
1543 None,
1544 );
1545 let auth = signer.sign();
1546 assert!(
1547 auth.starts_with(
1548 "AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1/s3/aws4_request,"
1549 )
1550 );
1551 assert!(auth.contains("SignedHeaders="));
1552 assert!(auth.contains("Signature="));
1553 let sig = auth.split("Signature=").nth(1).unwrap();
1555 assert_eq!(sig.len(), 64);
1556 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1557 }
1558
1559 #[test]
1560 fn canonical_request_uses_unsigned_payload_marker() {
1561 let dt = chrono::Utc::now();
1562 let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1563 let headers = HeaderMap::new();
1564 let mut signer = AwsSign::new(
1565 "PUT",
1566 url,
1567 &dt,
1568 &headers,
1569 "us-east-1",
1570 "AK",
1571 "SK",
1572 "s3",
1573 "UNSIGNED-PAYLOAD",
1574 None,
1575 );
1576 signer.set_payload_override("UNSIGNED-PAYLOAD".into());
1577 assert!(signer.canonical_request().ends_with("UNSIGNED-PAYLOAD"));
1578 }
1579
1580 #[test]
1581 fn chunk_signer_produces_valid_frames() {
1582 let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1583 .unwrap()
1584 .with_timezone(&chrono::Utc);
1585 let key = signing_key(&dt, "SECRET", "us-east-1", "s3").unwrap();
1586 let scope = scope_string(&dt, "us-east-1", "s3");
1587 let ts = dt.format("%Y%m%dT%H%M%SZ").to_string();
1588 let mut cs = ChunkSigner::new(key, scope, ts, "seed-sig-hex".into());
1589
1590 let payload: &[u8] = b"hello world";
1591 let frame = cs
1592 .sign_chunk(bytes::Bytes::copy_from_slice(payload))
1593 .unwrap();
1594 let frame_str = std::str::from_utf8(&frame).unwrap();
1596 assert!(frame_str.contains("chunk-signature="));
1597 assert!(frame_str.contains("\r\n"));
1598
1599 let final_frame = cs.final_chunk();
1600 let final_str = std::str::from_utf8(&final_frame).unwrap();
1601 assert!(final_str.contains("chunk-signature="));
1602 }
1603}