Skip to main content

object_storage_proxy/credentials/
signer.rs

1//! AWS Signature Version 4 request signing and verification.
2//!
3//! Provides:
4//! * [`AwsSign`] — low-level SigV4 signing primitive (adapted from
5//!   [aws-sign-v4](https://github.com/psnszsn/aws-sign-v4)).
6//! * `sign_request` — sign an outgoing Pingora [`RequestHeader`] in-place.
7//! * [`signature_is_valid_for_request`] — verify a standard `Authorization` header.
8//! * [`signature_is_valid_for_presigned`] — verify presigned URL query parameters.
9//! * [`ChunkSigner`] / [`StreamingState`] — per-chunk signing for
10//!   `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` uploads.
11
12pub(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
34// AwsSign copied and modified from https://github.com/psnszsn/aws-sign-v4
35
36/// Low-level AWS Signature Version 4 signing primitive.
37///
38/// Holds all inputs needed to produce the canonical request, string-to-sign,
39/// and final `Authorization` header value.  Construct via [`AwsSign::new`] and
40/// then call [`AwsSign::sign`] to obtain the header value.
41pub 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    /*
56    service is the <aws-service-code> that can be found in the service-quotas api.
57
58    For example, use the value `ServiceCode` for this `service` property.
59    Thus, for "Amazon Simple Storage Service (Amazon S3)", you would use value "s3"
60
61    ```
62    > aws service-quotas list-services
63    {
64        "Services": [
65            ...
66            {
67                "ServiceCode": "a4b",
68                "ServiceName": "Alexa for Business"
69            },
70            ...
71            {
72                "ServiceCode": "s3",
73                "ServiceName": "Amazon Simple Storage Service (Amazon S3)"
74            },
75            ...
76    ```
77    This is not absolute, so you might need to poke around at the service you're interesed in.
78    See:
79    [AWS General Reference -> Service endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/aws-service-information.html) - to look up "service" names and codes
80
81    added in 0.2.0
82    */
83    service: &'a str,
84
85    /// body, such as in an http POST
86    body: &'a [u8],
87}
88
89/// Create a new AwsSign instance
90///
91/// # Arguments
92///
93/// * `method` - HTTP method (GET, POST, etc.)
94/// * `url` - URL to sign
95/// * `datetime` - Date and time of the request
96/// * `headers` - HTTP headers
97/// * `region` - AWS region
98/// * `access_key` - AWS access key
99/// * `secret_key` - AWS secret key
100/// * `service` - AWS service code
101/// * `body` - Request body
102/// * `signed_headers` - Optional list of signed headers, used to check inbound request signature
103///
104/// # Returns
105///
106/// A new instance of `AwsSign`
107///
108impl<'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                // ─── decide whether to keep `name` ──────────────────────────
131                let keep = if let Some(ref set) = signed_allow {
132                    // verifier path → keep exactly what the client signed
133                    set.contains(name.as_str())
134                } else {
135                    // re-signing path → keep the full streaming whitelist
136                    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        // let headers: HashMap<String, String> = headers
156        //     .iter()
157        //     .filter_map(|(key, value)| {
158        //         let name = key.as_str().to_lowercase();
159        //         let keep = name == "host"
160        //             || name.starts_with("x-amz-")
161        //             || name == "content-length"
162        //             || name == "content-encoding"
163        //             || name == "transfer-encoding"
164        //             || name == "range";
165        //         if !keep {
166        //             return None;
167        //         }
168        //         value.to_str().ok().map(|v| (name, v.trim().to_owned()))
169        //     })
170        //     .collect();
171
172        debug!(url, "parsing presigned URL");
173        let url: Url = url.parse().expect("invalid URL passed to AwsSign::new");
174        // let headers: HashMap<String, String> = headers
175        //     .iter()
176        //     .filter_map(|(key, value)| {
177        //         let name = key.as_str().to_lowercase();
178        //         if !allowed.contains(&name.as_str()) {
179        //             return None;
180        //         }
181        //         value
182        //             .to_str()
183        //             .ok()
184        //             .map(|v| (name, v.trim().to_owned()))
185        //     })
186        //     .collect();
187        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
202/// custom debug implementation to redact secret_key
203impl<'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    /// for streaming uploads, we need to override the payload hash
230    /// with the actual payload hash
231    /// this is used for the `UNSIGNED-PAYLOAD` case
232    /// and for the `payload_override` case
233    /// Override the payload hash used in the canonical request.
234    ///
235    /// Use `"UNSIGNED-PAYLOAD"` for presigned URLs or streaming uploads where
236    /// the body hash is not computed up front.
237    pub fn set_payload_override(&mut self, h: String) {
238        self.payload_override = Some(h);
239    }
240
241    /// Return the canonicalized header string for inclusion in the canonical request.
242    ///
243    /// Headers are sorted lexicographically and each entry is formatted as
244    /// `lowercase-name:trimmed-value\n`.
245    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    /// Return the semicolon-separated list of signed header names (lowercase, sorted).
256    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    /// Build the canonical request string as defined in the AWS SigV4 spec.
267    ///
268    /// Format: `METHOD\nURI\nQUERY\nHEADERS\nSIGNED_HEADERS\nPAYLOAD_HASH`
269    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            // NEW: forward the literal marker for chunk-signed streams
277            "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    /// Compute and return the complete `Authorization` header value.
293    ///
294    /// The returned string can be set directly on the outgoing request with
295    /// `request.insert_header("authorization", sign_result)`.
296    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
350/// Build the credential scope string: `YYYYMMDD/<region>/<service>/aws4_request`.
351pub 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
360/// Build the string-to-sign for AWS SigV4.
361///
362/// Format: `AWS4-HMAC-SHA256\n<ISO8601>\n<scope>\n<canonical_request_hash>`
363pub 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
378/// Derive the SigV4 signing key.
379///
380/// Computes `HMAC(HMAC(HMAC(HMAC("AWS4" + secret, date), region), service), "aws4_request")`.
381pub 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(&region_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
406/// Sign the request with the AWS V4 signature
407/// # Arguments
408/// * `request` - The request to sign
409/// * `cos_map` - The COS map item containing the credentials
410/// # Returns
411/// * `Ok(())` if the request was signed successfully
412/// * `Err` if there was an error signing the request
413pub(crate) async fn sign_request(
414    request: &mut RequestHeader,
415    cos_map: &CosMapItem,
416) -> Result<(), Box<dyn std::error::Error>> {
417    // if no region, access_key or secret_key, return error
418    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_hash = if method == "GET" || method == "HEAD" || method == "DELETE" {
449    //     // spec uses empty‑body hash for reads
450    //     &sha256::digest(b"")
451    // } else {
452    //     // for streaming uploads we sign UNSIGNED‑PAYLOAD
453    //     "UNSIGNED-PAYLOAD"
454    // };
455    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        // client already supplied one → keep it verbatim
462        Some(h) => h,
463
464        // empty-body requests (GET/HEAD/DELETE) → spec hash of “”
465        None if matches!(method.as_str(), "GET" | "HEAD" | "DELETE") => &sha256::digest(b""),
466
467        // default for uploads over TLS
468        _ => "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        // empty body → empty slice
476        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" => &[], // sha256 hash of empty string
477        "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        // unreachable code
481        _ => &[],
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    // if payload_hash_value.starts_with("STREAMING-") {
501    //     // don’t hash the literal bytes – embed the magic string itself
502    //     signer.set_payload_override(payload_hash_value.to_string());
503    // }
504    // if payload_hash_value != "UNSIGNED-PAYLOAD" {
505    //     signer.set_payload_override(payload_hash_value.to_string());
506    // }
507
508    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/// Core signature validation: compares provided vs computed
522#[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    // Build AwsSign for authorization header style
538    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 payload_hash.starts_with("STREAMING-") {
553    //     // don’t hash the literal bytes – embed the magic string itself
554    //     signer.set_payload_override(payload_hash.to_string());
555    // }
556
557    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
569/// Validate standard S3 Authorization header
570pub 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    // Parse date header
592    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        // we don't have the raw body here, but we do have its hash:
616        // tell AwsSign to use this string directly
617        (&[] as &[u8], Some(content_sha256.to_owned()))
618    };
619
620    // Build full URL
621    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    // Signed headers list
635    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
662/// Validate presigned URL signature
663pub async fn signature_is_valid_for_presigned(
664    session: &Session,
665    secret_key: &str,
666) -> Result<bool, Box<dyn std::error::Error>> {
667    // Extract query params from the URL
668
669    let uri = session.req_header().uri.to_string();
670    let full_uri = if uri.starts_with('/') {
671        // build an absolute URL: scheme://host + path?query
672        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    // rebuild query string without the signature
697    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    // params map (also without the signature)
706    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    // Parse credential: <access_key>/<date>/<region>/<service>/aws4_request
718    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    // Parse date from query
729    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    // collect signed headers list
740    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    // copy any additional headers that appear in X-Amz-SignedHeaders (rare)
760    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    // delegate to core validator
775    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
792/// Build a stream whose items are *already* wrapped in
793/// "AWS-chunk-signed" envelopes.
794///
795/// * `body`        - raw payload implementing `AsyncRead`  
796/// * `signing_key` - result of the usual `signing_key()` step  
797/// * `scope`       - e.g. `"20250501/eu-west-3/s3/aws4_request"`  
798/// * `ts`          - the `X-Amz-Date` you put in the header (`YYYYMMDDThhmmssZ`)  
799/// * `seed_sig`    - the `Signature=` value you computed for the
800///   *headers* (the one that goes into `Authorization:`)
801///
802/// ```text
803/// ┌──── header chunk ────┐┌── data ─┐┌─ CRLF ─┐
804/// <hex-len>;chunk-signature=<sig>\r\n<bytes>\r\n
805/// ```
806///
807/// The very last frame is
808/// ```text
809/// 0;chunk-signature=<final-sig>\r\n\r\n
810/// ```
811pub 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    // 1. pull the COMPLETE body from the client
819    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    // 2. overwrite the x-amz-* headers so that we can sign UNSIGNED-PAYLOAD
826    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    // 3. resign
831    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    // 4. finally attach the body
852    session
853        .write_response_body(Some(body), end_of_stream)
854        .await?;
855
856    Ok(())
857}
858
859const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
860
861/// Stateful per-chunk signer for `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` uploads.
862///
863/// Each call to [`sign_chunk`](ChunkSigner::sign_chunk) produces the
864/// `chunk-signature` trailer required by AWS chunked encoding and advances
865/// the internal HMAC chain for the next chunk.
866pub struct ChunkSigner {
867    signing_key: Vec<u8>,
868    scope: String,
869    ts: String,
870    prev_sig: String,
871}
872
873impl ChunkSigner {
874    /// create a new signer.  
875    /// *`seed_signature`* is the **signature you put in the `Authorization` header**
876    /// Create a new [`ChunkSigner`] from the seed values established during
877    /// the initial header signing step.
878    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    /// sign one payload chunk and return the wire bytes **and** update internal state.
888    pub fn sign_chunk(
889        &mut self,
890        chunk: Bytes,
891    ) -> Result<Bytes, Box<dyn std::error::Error + Send + Sync>> {
892        // 1. Hash of the chunk payload
893        let chunk_hash = hex::encode(sha256::digest(chunk.as_ref()));
894
895        // 2. String to sign for this chunk (AWS spec §4)
896        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, // hash of empty string
902            chunk_hash
903        );
904
905        // 3. HMAC with the derived signing key
906        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        // 4. Build the chunk header:  "<hexlen>;chunk-signature=<sig>\r\n"
910        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        // 5. advance running signature
916        self.prev_sig = sig;
917
918        Ok(buf.freeze())
919    }
920
921    /// Final 0-length chunk (must be sent after the body)
922    /// Produce the zero-length terminal chunk that signals end-of-stream.
923    pub fn final_chunk(&mut self) -> Bytes {
924        // sign an empty payload chunk (same steps, len = 0)
925        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
936/// Re-sign an upstream request header for a `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` upload.
937///
938/// Updates the `Authorization`, `x-amz-date`, and `x-amz-content-sha256` headers
939/// in-place.  Returns the seed signature that must be passed to [`StreamingState::new`].
940pub 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    // let ts = chrono::Utc::now();
948    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/// Maximum chunk payload we read from the client before signing.
971/// (4 MiB is what the Java/AWS SDKs use, but any size works.)
972// const DEFAULT_CHUNK: usize = 4 * 1024 * 1024;
973
974// pub fn wrap_streaming_body_reader<R>(
975//     body_reader: R,
976//     signing_key: Vec<u8>,
977//     scope: String,
978//     ts: String,
979//     seed_signature: String,
980// ) -> impl Stream<Item = Result<bytes::Bytes, std::io::Error>>
981// where
982//     R: Stream<Item = Result<bytes::Bytes, pingora::Error>> + Unpin + Send + 'static,
983// {
984//     // sign_chunk prepends the S3 chunk header & signature
985//     body_reader
986//         .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
987//         .and_then(move |chunk| async move {
988//             Ok(ChunkSigner::sign_chunk(
989//                 chunk,
990//                 &signing_key,
991//                 &scope,
992//                 &ts,
993//                 &seed_signature,
994//             )?)
995//         })
996// }
997
998// pin_project! {
999//     pub struct StreamingSignerReader<R> {
1000//         #[pin] inner: R,
1001//         buf: BytesMut,
1002//         region: String,
1003//         access_key: String,
1004//         secret_key: String,
1005//         ts: chrono::DateTime<chrono::Utc>,
1006//         prior_signature: String,
1007//         eof: bool,
1008//     }
1009// }
1010
1011// impl<R> StreamingSignerReader<R>
1012// where
1013//     R: AsyncRead + Unpin,
1014// {
1015//     pub fn new(
1016//         inner: R,
1017//         region: String,
1018//         access_key: String,
1019//         secret_key: String,
1020//         ts: chrono::DateTime<chrono::Utc>,
1021//         seed_sig: String,
1022//     ) -> Self {
1023//         Self {
1024//             inner,
1025//             buf: BytesMut::new(),
1026//             region,
1027//             access_key,
1028//             secret_key,
1029//             ts,
1030//             prior_signature: seed_sig,
1031//             eof: false,
1032//         }
1033//     }
1034
1035//     /// Read *one* chunk payload from `inner`, sign it and stage it in `buf`.
1036//     async fn fill_buf(mut self: Pin<&mut Self>) -> std::io::Result<()> {
1037//         if self.eof {
1038//             return Ok(());
1039//         }
1040
1041//         let mut payload = vec![0u8; DEFAULT_CHUNK];
1042//         let n = self.inner.read(&mut payload).await?;
1043//         if n == 0 {
1044//             // Final 0-length chunk
1045//             self.stage_chunk(Bytes::new());
1046//             self.eof = true;
1047//             return Ok(());
1048//         }
1049//         payload.truncate(n);
1050//         self.stage_chunk(Bytes::from(payload));
1051//         Ok(())
1052//     }
1053
1054//     fn stage_chunk(&mut self, payload: Bytes) {
1055//         // 1/ compute SHA-256 hash of the chunk payload
1056//         let chunk_hash = sha256::digest(payload.as_ref());
1057
1058//         // 2/ build canonical string for the *chunk*
1059//         //    https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
1060//         let string_to_sign = format!(
1061//             "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}/s3/aws4_request\n{}\n{}\n{}",
1062//             self.ts.format("%Y%m%dT%H%M%SZ"),
1063//             self.ts.format("%Y%m%d"),
1064//             self.prior_signature,
1065//             hex::encode([0u8; 32]), // empty-string hash for headers
1066//             chunk_hash
1067//         );
1068
1069//         // 3/ get signing key for the day and region
1070//         let sig_key = signing_key(&self.ts, &self.secret_key, &self.region, "s3")
1071//             .expect("streaming key");
1072
1073//         let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &sig_key);
1074//         let tag = ring::hmac::sign(&key, string_to_sign.as_bytes());
1075//         let signature_hex = hex::encode(tag);
1076
1077//         // 4/ assemble chunk: <hexlen>;<sig>\r\n<payload>\r\n
1078//         let mut chunk = BytesMut::new();
1079//         let len_hex = format!("{:x}", payload.len());
1080//         chunk.extend_from_slice(len_hex.as_bytes());
1081//         chunk.extend_from_slice(b";chunk-signature=");
1082//         chunk.extend_from_slice(signature_hex.as_bytes());
1083//         chunk.extend_from_slice(b"\r\n");
1084//         chunk.extend_from_slice(&payload);
1085//         chunk.extend_from_slice(b"\r\n");
1086
1087//         self.prior_signature = signature_hex;
1088//         self.buf.extend_from_slice(&chunk);
1089//     }
1090// }
1091
1092// impl<R: AsyncRead + Unpin> AsyncRead for StreamingSignerReader<R> {
1093//     fn poll_read(
1094//         self: Pin<&mut Self>,
1095//         cx: &mut Context<'_>,
1096//         out: &mut tokio::io::ReadBuf<'_>,
1097//     ) -> Poll<std::io::Result<()>> {
1098//         // 1. fast-path: drain any buffered bytes
1099//         {
1100//             let this = self.as_ref().project();
1101//             if !this.buf.is_empty() {
1102//                 let n = std::cmp::min(out.remaining(), this.buf.len());
1103//                 out.put_slice(&this.buf.split_to(n));
1104//                 return Poll::Ready(Ok(()));
1105//             }
1106//         }
1107
1108//         // 2. refill the buffer
1109//         let mut fut = Box::pin(self.as_mut().fill_buf());      // <-- changed line
1110//         futures::ready!(fut.as_mut().poll(cx))?;
1111
1112//         // 3. project again to copy freshly-staged data to `out`
1113//         let this = self.project();
1114//         if this.buf.is_empty() {
1115//             return Poll::Ready(Ok(())); // EOF
1116//         }
1117//         let n = std::cmp::min(out.remaining(), this.buf.len());
1118//         out.put_slice(&this.buf.split_to(n));
1119//         Poll::Ready(Ok(()))
1120//     }
1121// }
1122
1123#[derive(Debug)]
1124/// Runtime state for an in-progress streaming upload.
1125///
1126/// Stored in [`MyCtx::stream_state`](crate::MyCtx) and consumed by
1127/// [`MyProxy::request_body_filter`](crate::MyProxy) to sign each body chunk
1128/// as it flows through the proxy.
1129pub 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    /// Create a new [`StreamingState`] from the seed values produced by
1140    /// [`resign_streaming_request`].
1141    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, &region, "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    /// Sign a body chunk and return the framed AWS-chunked bytes.
1160    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    /// Produce the terminal zero-length chunk that signals end-of-stream.
1173    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
1185// fn sha256_hex(data: &[u8]) -> String {
1186//     let mut hasher = Sha256::new();
1187//     hasher.update(data);
1188//     hex::encode(hasher.finalize())
1189// }
1190
1191fn sha256_hex(data: &[u8]) -> String {
1192    sha256::digest(data)
1193}
1194
1195/// Pre‑computed because it is constant for every chunk.
1196const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1197
1198/// Calculate the per‑chunk signature (section *Defining the Chunk Body* of the
1199/// AWS doc).  
1200/// * `signing_key` is the value you built once from the secret key  
1201/// * `prior_sig`  is the *seed* (first chunk) or the previous chunk’s signature
1202fn 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    // 1️⃣  Build the String‑To‑Sign
1210    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("")
1219        sha256_hex(payload),
1220    );
1221
1222    // 2️⃣  HMAC it
1223    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
1228/// Wrap a signed payload frame into the final on‑the‑wire representation.
1229fn build_chunk_frame(payload: &[u8], sig: &str) -> Bytes {
1230    let mut buf = BytesMut::with_capacity(payload.len() + sig.len() + 64);
1231    // <hexlen>;chunk-signature=<sig>\r\n
1232    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    /// Any method other than GET/HEAD/DELETE should use UNSIGNED-PAYLOAD
1336    #[tokio::test]
1337    async fn post_request_uses_unsigned_payload() {
1338        // build a POST RequestHeader
1339        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        // run sign_request
1349        let cos = make_cos_map_item();
1350        sign_request(&mut req, &cos).await.unwrap();
1351
1352        // x-amz-content-sha256 must be "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1353        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        // Authorization header must include our access key and scope
1365        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    /// GET/DELETE must use the empty-body hash, and sign correctly
1371    #[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        // empty-body sha256
1383        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        // X-Amz-Date must be a valid timestamp ending in Z
1393        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        // Authorization header format
1402        let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1403        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/"));
1404        // must have SignedHeaders including host;x-amz-content-sha256;x-amz-date
1405        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    /// Missing any of region/access_key/secret_key should error out
1412    #[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; // drop region
1423        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        // last line must be a 64-char hex SHA-256
1475        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        // sorted alphabetically
1518        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        // Signature must be a 64-char hex string
1554        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        // Frame must start with hex chunk-size;chunk-ext\r\n
1595        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}