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 dashmap::DashMap;
14use http::header::HeaderMap;
15use once_cell::sync::Lazy;
16use pingora::{http::RequestHeader, proxy::Session};
17use ring::hmac;
18use sha256::digest;
19use std::{
20    collections::{HashMap, HashSet},
21    fmt, io,
22};
23use tracing::{debug, error};
24use url::Url;
25
26use bytes::{BufMut, Bytes, BytesMut};
27
28// ── Performance TODOs ──────────────────────────────────────────────────────────
29// TODO(perf-2): Cache the resolved CosMapItem in MyCtx so upstream_peer does
30//   not acquire the cos_mapping RwLock a second time on every request.
31// TODO(perf-3): Replace the global Mutex<HashMap<key→Mutex>> in AuthCache with
32//   a DashMap so concurrent cache misses are not serialised on a single lock.
33// TODO(perf-4): Periodically sweep expired entries from AuthCache::inner to
34//   bound memory growth under long-running, high-cardinality workloads.
35// TODO(perf-5): Merge the three Python::with_gil clone_ref calls in new_ctx
36//   into a single GIL acquisition to reduce per-connection GIL contention.
37// ─────────────────────────────────────────────────────────────────────────────
38
39/// Process-wide cache for derived SigV4 signing keys.
40///
41/// The signing key only changes once per calendar day (it is keyed on
42/// `date + region + service + secret`).  Caching it eliminates the four
43/// chained HMAC-SHA256 derivations that `signing_key()` would otherwise run
44/// on **every outgoing request**.
45///
46/// Key format: `"YYYYMMDD:{region}:{service}:{secret_key}"`
47/// Value: the 32-byte derived signing key.
48static SIGNING_KEY_CACHE: Lazy<DashMap<String, Vec<u8>>> = Lazy::new(DashMap::new);
49
50use crate::parsers::{
51    cos_map::CosMapItem,
52    credentials::{parse_credential_scope, parse_token_from_header},
53};
54
55const SHORT_DATE: &str = "%Y%m%d";
56const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ";
57
58/// SHA-256 digest of an empty byte string — pre-computed constant to avoid
59/// hashing an empty body on every GET/HEAD/DELETE re-sign.
60const SHA256_EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
61
62// AwsSign copied and modified from https://github.com/psnszsn/aws-sign-v4
63
64/// Low-level AWS Signature Version 4 signing primitive.
65///
66/// Holds all inputs needed to produce the canonical request, string-to-sign,
67/// and final `Authorization` header value.  Construct via [`AwsSign::new`] and
68/// then call [`AwsSign::sign`] to obtain the header value.
69pub struct AwsSign<'a, T: 'a>
70where
71    &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
72    T: std::fmt::Debug,
73{
74    method: &'a str,
75    url: Url,
76    datetime: &'a DateTime<Utc>,
77    region: &'a str,
78    access_key: &'a str,
79    secret_key: &'a str,
80    headers: T,
81    payload_override: Option<String>,
82
83    /*
84    service is the <aws-service-code> that can be found in the service-quotas api.
85
86    For example, use the value `ServiceCode` for this `service` property.
87    Thus, for "Amazon Simple Storage Service (Amazon S3)", you would use value "s3"
88
89    ```
90    > aws service-quotas list-services
91    {
92        "Services": [
93            ...
94            {
95                "ServiceCode": "a4b",
96                "ServiceName": "Alexa for Business"
97            },
98            ...
99            {
100                "ServiceCode": "s3",
101                "ServiceName": "Amazon Simple Storage Service (Amazon S3)"
102            },
103            ...
104    ```
105    This is not absolute, so you might need to poke around at the service you're interesed in.
106    See:
107    [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
108
109    added in 0.2.0
110    */
111    service: &'a str,
112
113    /// body, such as in an http POST
114    body: &'a [u8],
115}
116
117/// Create a new AwsSign instance
118///
119/// # Arguments
120///
121/// * `method` - HTTP method (GET, POST, etc.)
122/// * `url` - URL to sign
123/// * `datetime` - Date and time of the request
124/// * `headers` - HTTP headers
125/// * `region` - AWS region
126/// * `access_key` - AWS access key
127/// * `secret_key` - AWS secret key
128/// * `service` - AWS service code
129/// * `body` - Request body
130/// * `signed_headers` - Optional list of signed headers, used to check inbound request signature
131///
132/// # Returns
133///
134/// A new instance of `AwsSign`
135///
136impl<'a> AwsSign<'a, HashMap<String, String>> {
137    #[allow(clippy::too_many_arguments)]
138    pub fn new<B: AsRef<[u8]> + ?Sized>(
139        method: &'a str,
140        url: &'a str,
141        datetime: &'a DateTime<Utc>,
142        headers: &'a HeaderMap,
143        region: &'a str,
144        access_key: &'a str,
145        secret_key: &'a str,
146        service: &'a str,
147        body: &'a B,
148        _signed_headers: Option<&'a Vec<String>>,
149    ) -> Self {
150        let signed_allow: Option<HashSet<&str>> =
151            _signed_headers.map(|v| v.iter().map(String::as_str).collect());
152
153        let headers: HashMap<String, String> = headers
154            .iter()
155            .filter_map(|(key, value)| {
156                let name = key.as_str().to_lowercase();
157
158                // ─── decide whether to keep `name` ──────────────────────────
159                let keep = if let Some(ref set) = signed_allow {
160                    // verifier path -> keep exactly what the client signed
161                    set.contains(name.as_str())
162                } else {
163                    // re-signing path -> keep the full streaming whitelist
164                    name == "host"
165                        || name.starts_with("x-amz-")
166                        || matches!(
167                            name.as_str(),
168                            "content-length"
169                                | "content-encoding"
170                                | "transfer-encoding"
171                                | "range"
172                                | "expect"
173                                | "x-amz-decoded-content-length"
174                        )
175                };
176                if !keep {
177                    return None;
178                }
179                value.to_str().ok().map(|v| (name, v.trim().to_owned()))
180            })
181            .collect();
182
183        // let headers: HashMap<String, String> = headers
184        //     .iter()
185        //     .filter_map(|(key, value)| {
186        //         let name = key.as_str().to_lowercase();
187        //         let keep = name == "host"
188        //             || name.starts_with("x-amz-")
189        //             || name == "content-length"
190        //             || name == "content-encoding"
191        //             || name == "transfer-encoding"
192        //             || name == "range";
193        //         if !keep {
194        //             return None;
195        //         }
196        //         value.to_str().ok().map(|v| (name, v.trim().to_owned()))
197        //     })
198        //     .collect();
199
200        debug!(url, "parsing presigned URL");
201        let url: Url = url.parse().expect("invalid URL passed to AwsSign::new");
202        // let headers: HashMap<String, String> = headers
203        //     .iter()
204        //     .filter_map(|(key, value)| {
205        //         let name = key.as_str().to_lowercase();
206        //         if !allowed.contains(&name.as_str()) {
207        //             return None;
208        //         }
209        //         value
210        //             .to_str()
211        //             .ok()
212        //             .map(|v| (name, v.trim().to_owned()))
213        //     })
214        //     .collect();
215        Self {
216            method,
217            url,
218            datetime,
219            region,
220            access_key,
221            secret_key,
222            headers,
223            service,
224            body: body.as_ref(),
225            payload_override: None,
226        }
227    }
228}
229
230/// custom debug implementation to redact secret_key
231impl<'a, T> fmt::Debug for AwsSign<'a, T>
232where
233    &'a T: IntoIterator<Item = (&'a String, &'a String)>,
234    T: std::fmt::Debug,
235{
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.debug_struct("AwsSign")
238            .field("method", &self.method)
239            .field("url", &self.url)
240            .field("datetime", &self.datetime)
241            .field("region", &self.region)
242            .field("access_key", &self.access_key)
243            .field("secret_key", &"<REDACTED>")
244            .field("service", &self.service)
245            .field("body", &self.body)
246            .field("headers", &self.headers)
247            .field("payload_override", &self.payload_override)
248            .finish()
249    }
250}
251
252impl<'a, T> AwsSign<'a, T>
253where
254    &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>,
255    T: std::fmt::Debug,
256{
257    /// for streaming uploads, we need to override the payload hash
258    /// with the actual payload hash
259    /// this is used for the `UNSIGNED-PAYLOAD` case
260    /// and for the `payload_override` case
261    /// Override the payload hash used in the canonical request.
262    ///
263    /// Use `"UNSIGNED-PAYLOAD"` for presigned URLs or streaming uploads where
264    /// the body hash is not computed up front.
265    pub fn set_payload_override(&mut self, h: String) {
266        self.payload_override = Some(h);
267    }
268
269    /// Return the canonicalized header string for inclusion in the canonical request.
270    ///
271    /// Headers are sorted lexicographically by name and each entry is formatted as
272    /// `lowercase-name:trimmed-value\n`.
273    ///
274    /// IMPORTANT: Sort must be by key name only, not by the full `key:value` string.
275    /// Otherwise `x-amz-copy-source-if-match` would sort before `x-amz-copy-source`
276    /// because `-` (ASCII 45) < `:` (ASCII 58).
277    pub fn canonical_header_string(&'a self) -> String {
278        // Keys are already lowercased in AwsSign::new; borrow them directly.
279        let mut keyvalues: Vec<(&str, &str)> = (&self.headers)
280            .into_iter()
281            .map(|(key, value)| (key.as_str(), value.as_str()))
282            .collect();
283        keyvalues.sort_by_key(|(k, _)| *k);
284        keyvalues
285            .iter()
286            .map(|(key, value)| format!("{}:{}", key, value.trim()))
287            .collect::<Vec<_>>()
288            .join("\n")
289    }
290
291    /// Return the semicolon-separated list of signed header names (lowercase, sorted).
292    pub fn signed_header_string(&'a self) -> String {
293        // Keys are already lowercased in AwsSign::new; borrow them directly.
294        let mut keys: Vec<&str> = (&self.headers)
295            .into_iter()
296            .map(|(k, _)| k.as_str())
297            .collect();
298        keys.sort();
299        keys.join(";")
300    }
301
302    /// Build the canonical request string as defined in the AWS SigV4 spec.
303    ///
304    /// Format: `METHOD\nURI\nQUERY\nHEADERS\nSIGNED_HEADERS\nPAYLOAD_HASH`
305    pub fn canonical_request(&'a self) -> String {
306        let url: &str = self.url.path();
307        let payload_line = if let Some(ov) = &self.payload_override {
308            ov.clone()
309        } else if self.body == b"UNSIGNED-PAYLOAD" {
310            "UNSIGNED-PAYLOAD".into()
311        } else if self.body == b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
312            // NEW: forward the literal marker for chunk-signed streams
313            "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".into()
314        } else {
315            digest(self.body)
316        };
317
318        format!(
319            "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{payload}",
320            method = self.method,
321            uri = url,
322            query_string = canonical_query_string(&self.url),
323            headers = self.canonical_header_string(),
324            signed = self.signed_header_string(),
325            payload = payload_line,
326        )
327    }
328    /// Compute and return the complete `Authorization` header value.
329    ///
330    /// The returned string can be set directly on the outgoing request with
331    /// `request.insert_header("authorization", sign_result)`.
332    ///
333    /// Optimised to sort the header map **once** and compute the credential
334    /// scope **once**, avoiding the redundant work that would occur if
335    /// `canonical_request()` and `signed_header_string()` were called
336    /// separately.
337    pub fn sign(&'a self) -> String {
338        // Scope is embedded in both the string-to-sign and the Credential= field.
339        let scope = scope_string(self.datetime, self.region, self.service);
340
341        // Sort header pairs once; derive canonical-headers and signed-headers
342        // strings from the same sorted slice instead of sorting twice.
343        let mut kv: Vec<(&str, &str)> = (&self.headers)
344            .into_iter()
345            .map(|(k, v)| (k.as_str(), v.as_str()))
346            .collect();
347        kv.sort_by_key(|(k, _)| *k);
348        let canonical_headers = kv
349            .iter()
350            .map(|(k, v)| format!("{}:{}", k, v.trim()))
351            .collect::<Vec<_>>()
352            .join("\n");
353        let signed_headers = kv.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(";");
354
355        // Payload line — same logic as canonical_request() but without the clone().
356        let payload_owned;
357        let payload_line: &str = if let Some(ov) = &self.payload_override {
358            ov.as_str()
359        } else if self.body == b"UNSIGNED-PAYLOAD" {
360            "UNSIGNED-PAYLOAD"
361        } else if self.body == b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
362            "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
363        } else {
364            payload_owned = digest(self.body);
365            &payload_owned
366        };
367
368        let canonical = format!(
369            "{}\n{}\n{}\n{}\n\n{}\n{}",
370            self.method,
371            self.url.path(),
372            canonical_query_string(&self.url),
373            canonical_headers,
374            signed_headers,
375            payload_line,
376        );
377
378        let canonical_hash =
379            hex::encode(ring::digest::digest(&ring::digest::SHA256, canonical.as_bytes()).as_ref());
380        let sts = format!(
381            "AWS4-HMAC-SHA256\n{}\n{}\n{}",
382            self.datetime.format(LONG_DATETIME),
383            scope,
384            canonical_hash,
385        );
386
387        let sk = signing_key(self.datetime, self.secret_key, self.region, self.service)
388            .expect("signing key derivation failed");
389        let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &sk);
390        let signature = hex::encode(ring::hmac::sign(&key, sts.as_bytes()).as_ref());
391
392        let sign_string = format!(
393            "AWS4-HMAC-SHA256 Credential={}/{},SignedHeaders={},Signature={}",
394            self.access_key, scope, signed_headers, signature
395        );
396        debug!("sign_string: {}", sign_string);
397        sign_string
398    }
399}
400
401pub fn uri_encode(string: &str, encode_slash: bool) -> String {
402    let mut result = String::with_capacity(string.len() * 2);
403    for c in string.chars() {
404        match c {
405            'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~' | '.' => result.push(c),
406            '/' if encode_slash => result.push_str("%2F"),
407            '/' if !encode_slash => result.push('/'),
408            _ => {
409                // Encode each UTF-8 byte without extra heap allocations.
410                let mut buf = [0u8; 4];
411                for b in c.encode_utf8(&mut buf).bytes() {
412                    use std::fmt::Write;
413                    let _ = write!(result, "%{b:02X}");
414                }
415            }
416        }
417    }
418    result
419}
420
421pub fn canonical_query_string(uri: &Url) -> String {
422    let mut keyvalues = uri
423        .query_pairs()
424        .map(|(key, value)| uri_encode(&key, true) + "=" + &uri_encode(&value, true))
425        .collect::<Vec<String>>();
426    keyvalues.sort();
427    keyvalues.join("&")
428}
429
430/// Build the credential scope string: `YYYYMMDD/<region>/<service>/aws4_request`.
431pub fn scope_string(datetime: &DateTime<Utc>, region: &str, service: &str) -> String {
432    format!(
433        "{date}/{region}/{service}/aws4_request",
434        date = datetime.format(SHORT_DATE),
435        region = region,
436        service = service
437    )
438}
439
440/// Build the string-to-sign for AWS SigV4.
441///
442/// Format: `AWS4-HMAC-SHA256\n<ISO8601>\n<scope>\n<canonical_request_hash>`
443pub fn string_to_sign(
444    datetime: &DateTime<Utc>,
445    region: &str,
446    canonical_req: &str,
447    service: &str,
448) -> String {
449    let hash = ring::digest::digest(&ring::digest::SHA256, canonical_req.as_bytes());
450    format!(
451        "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
452        timestamp = datetime.format(LONG_DATETIME),
453        scope = scope_string(datetime, region, service),
454        hash = hex::encode(hash.as_ref())
455    )
456}
457
458/// Derive (or return a cached copy of) the SigV4 signing key.
459///
460/// Computes `HMAC(HMAC(HMAC(HMAC("AWS4" + secret, date), region), service), "aws4_request")`
461/// and stores the result in the process-wide [`SIGNING_KEY_CACHE`].  The cache
462/// entry is valid for the entire UTC calendar day; stale entries from previous
463/// days are evicted lazily on the first miss of the new day.
464pub fn signing_key(
465    datetime: &DateTime<Utc>,
466    secret_key: &str,
467    region: &str,
468    service: &str,
469) -> Result<Vec<u8>, String> {
470    let date_str = datetime.format(SHORT_DATE).to_string();
471    // Use a compact key that avoids exposing the raw secret in logs or heap
472    // dumps while still being unique per (date, region, service, secret).
473    let cache_key = format!("{date_str}:{region}:{service}:{secret_key}");
474
475    if let Some(cached) = SIGNING_KEY_CACHE.get(&cache_key) {
476        debug!("signing_key cache hit");
477        return Ok(cached.clone());
478    }
479
480    debug!("signing_key cache miss — deriving key");
481    let secret = String::from("AWS4") + secret_key;
482
483    let date_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
484    let date_tag = ring::hmac::sign(&date_key, date_str.as_bytes());
485
486    let region_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, date_tag.as_ref());
487    let region_tag = ring::hmac::sign(&region_key, region.as_bytes());
488
489    let service_key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, region_tag.as_ref());
490    let service_tag = ring::hmac::sign(&service_key, service.as_bytes());
491
492    let signing_key_val = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, service_tag.as_ref());
493    let signing_tag = ring::hmac::sign(&signing_key_val, b"aws4_request");
494    let derived = signing_tag.as_ref().to_vec();
495
496    // Evict any stale entries from previous days before inserting the new one.
497    // In practice there is at most one entry per (region, service, secret)
498    // combination, so this scan is O(number of unique backends) — tiny.
499    SIGNING_KEY_CACHE.retain(|k, _| k.starts_with(&date_str));
500    SIGNING_KEY_CACHE.insert(cache_key, derived.clone());
501
502    Ok(derived)
503}
504
505/// Sign the request with the AWS V4 signature
506/// # Arguments
507/// * `request` - The request to sign
508/// * `cos_map` - The COS map item containing the credentials
509/// # Returns
510/// * `Ok(())` if the request was signed successfully
511/// * `Err` if there was an error signing the request
512pub(crate) async fn sign_request(
513    request: &mut RequestHeader,
514    cos_map: &CosMapItem,
515) -> Result<(), Box<dyn std::error::Error>> {
516    // if no region, access_key or secret_key, return error
517    if cos_map.region.is_none() || cos_map.access_key.is_none() || cos_map.secret_key.is_none() {
518        return Err("Missing region, access_key or secret_key".into());
519    }
520
521    request.remove_header("authorization");
522
523    let datetime = chrono::Utc::now();
524    let method = request.method.to_string();
525    let url = request.uri.to_string();
526    let access_key = cos_map
527        .access_key
528        .as_ref()
529        .ok_or_else(|| pingora::Error::new_str("bucket config missing access_key"))?;
530    let secret_key = cos_map
531        .secret_key
532        .as_ref()
533        .ok_or_else(|| pingora::Error::new_str("bucket config missing secret_key"))?;
534    let region = cos_map
535        .region
536        .as_ref()
537        .ok_or_else(|| pingora::Error::new_str("bucket config missing region"))?;
538
539    request.insert_header(
540        "X-Amz-Date",
541        datetime
542            .format("%Y%m%dT%H%M%SZ")
543            .to_string()
544            .parse::<http::header::HeaderValue>()
545            .unwrap(),
546    )?;
547    // let payload_hash = if method == "GET" || method == "HEAD" || method == "DELETE" {
548    //     // spec uses empty‑body hash for reads
549    //     SHA256_EMPTY
550    // } else {
551    //     // for streaming uploads we sign UNSIGNED‑PAYLOAD
552    //     "UNSIGNED-PAYLOAD"
553    // };
554    let payload_hdr = request
555        .headers
556        .get("x-amz-content-sha256")
557        .and_then(|v| v.to_str().ok());
558
559    let payload_hash = match payload_hdr {
560        // client already supplied one -> keep it verbatim
561        Some(h) => h,
562
563        // empty-body requests (GET/HEAD/DELETE) -> spec hash of “”
564        None if matches!(method.as_str(), "GET" | "HEAD" | "DELETE") => SHA256_EMPTY,
565
566        // default for uploads over TLS
567        _ => "UNSIGNED-PAYLOAD",
568    };
569
570    let payload_hash_value = payload_hash.to_string();
571    request.insert_header("x-amz-content-sha256", payload_hash_value.as_str())?;
572
573    let body_bytes: &[u8] = match payload_hash_value.as_str() {
574        // empty body -> empty slice
575        SHA256_EMPTY => &[],
576        "UNSIGNED-PAYLOAD" => b"UNSIGNED-PAYLOAD",
577        "STREAMING-UNSIGNED-PAYLOAD-TRAILER" => b"STREAMING-UNSIGNED-PAYLOAD-TRAILER",
578        "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" => b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
579        // unreachable code
580        _ => &[],
581    };
582
583    let auth_header = AwsSign::new(
584        &method,
585        &url,
586        &datetime,
587        &request.headers,
588        region,
589        access_key,
590        secret_key,
591        "s3",
592        body_bytes,
593        None,
594    );
595    debug!("{:#?}", &auth_header);
596
597    let mut signer = auth_header;
598    signer.set_payload_override(payload_hash_value); // move, no clone
599
600    let signature = signer.sign();
601    debug!("{:#?}", signature);
602
603    request.insert_header(
604        "Authorization",
605        http::header::HeaderValue::from_str(&signature)?,
606    )?;
607
608    Ok(())
609}
610
611/// Core signature validation: compares provided vs computed
612#[allow(clippy::too_many_arguments)]
613async fn signature_is_valid_core(
614    method: &str,
615    provided_signature: &str,
616    region: &str,
617    service: &str,
618    datetime: DateTime<Utc>,
619    full_url: &str,
620    headers: &HeaderMap,
621    payload_override: Option<String>,
622    access_key: &str,
623    secret_key: &str,
624    signed_headers: &Vec<String>,
625    body_bytes: &[u8],
626) -> Result<bool, Box<dyn std::error::Error>> {
627    // Build AwsSign for authorization header style
628    debug!("{:#?}", &headers);
629    let mut signer = AwsSign::new(
630        method,
631        full_url,
632        &datetime,
633        headers,
634        region,
635        access_key,
636        secret_key,
637        service,
638        body_bytes,
639        Some(signed_headers),
640    );
641
642    // if payload_hash.starts_with("STREAMING-") {
643    //     // don’t hash the literal bytes – embed the magic string itself
644    //     signer.set_payload_override(payload_hash.to_string());
645    // }
646
647    if let Some(ov) = payload_override {
648        debug!(payload_override = ov, "applying payload hash override");
649        signer.set_payload_override(ov);
650    }
651
652    let canonical = signer.canonical_request();
653    debug!("Canonical request:\n{}", canonical);
654    let signature = signer.sign();
655    let computed = signature.split("Signature=").nth(1).unwrap_or_default();
656    debug!("Provided signature: {}", provided_signature);
657    debug!("Computed signature: {}", computed);
658    Ok(computed == provided_signature)
659}
660
661/// Validate standard S3 Authorization header
662pub async fn signature_is_valid_for_request(
663    auth_header: &str,
664    session: &Session,
665    secret_key: &str,
666) -> Result<bool, Box<dyn std::error::Error>> {
667    let (_, local_access_key) = parse_token_from_header(auth_header)
668        .map_err(|_| pingora::Error::new_str("Failed to parse token"))?;
669    let local_access_key = local_access_key.to_string();
670    if local_access_key.is_empty() {
671        error!("Missing access key");
672        return Ok(false);
673    }
674    let provided_signature = auth_header
675        .split("Signature=")
676        .nth(1)
677        .ok_or("Missing Signature")?;
678
679    let (_, (region, service)) = parse_credential_scope(auth_header)
680        .map_err(|_| pingora::Error::new_str("Invalid Credential scope"))?;
681
682    let method = session.req_header().method.to_string();
683    // Parse date header
684    let dt_header = session
685        .req_header()
686        .headers
687        .get("x-amz-date")
688        .ok_or_else(|| pingora::Error::new_str("missing x-amz-date header"))?
689        .to_str()?;
690    let datetime = NaiveDateTime::parse_from_str(dt_header, LONG_DATETIME)?.and_utc();
691
692    let content_sha256 = session
693        .req_header()
694        .headers
695        .get("x-amz-content-sha256")
696        .and_then(|h| h.to_str().ok())
697        .ok_or_else(|| pingora::Error::new_str("Missing x-amz-content-sha256 header"))?;
698
699    let (body_bytes, payload_override) = if content_sha256 == "UNSIGNED-PAYLOAD" {
700        (b"UNSIGNED-PAYLOAD" as &[u8], None)
701    } else if content_sha256 == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" {
702        (
703            b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD".as_ref(),
704            Some("STREAMING-AWS4-HMAC-SHA256-PAYLOAD".to_string()),
705        )
706    } else {
707        // we don't have the raw body here, but we do have its hash:
708        // tell AwsSign to use this string directly
709        (&[] as &[u8], Some(content_sha256.to_owned()))
710    };
711
712    // Build full URL
713    let original_uri = session.req_header().uri.to_string();
714    let full_url = if original_uri.starts_with('/') {
715        let host = session
716            .req_header()
717            .headers
718            .get("host")
719            .ok_or_else(|| pingora::Error::new_str("Missing host header"))?
720            .to_str()?;
721        format!("https://{}{}", host, original_uri)
722    } else {
723        original_uri
724    };
725
726    // Signed headers list
727    let signed_headers_str = auth_header
728        .split("SignedHeaders=")
729        .nth(1)
730        .ok_or_else(|| pingora::Error::new_str("missing SignedHeaders in Authorization"))?
731        .split(',')
732        .next()
733        .ok_or_else(|| pingora::Error::new_str("malformed SignedHeaders value"))?;
734
735    let signed_headers: Vec<String> = signed_headers_str.split(';').map(str::to_string).collect();
736
737    signature_is_valid_core(
738        &method,
739        provided_signature,
740        region,
741        service,
742        datetime,
743        &full_url,
744        &session.req_header().headers,
745        payload_override,
746        &local_access_key,
747        secret_key,
748        &signed_headers,
749        body_bytes,
750    )
751    .await
752}
753
754/// Validate presigned URL signature
755pub async fn signature_is_valid_for_presigned(
756    session: &Session,
757    secret_key: &str,
758) -> Result<bool, Box<dyn std::error::Error>> {
759    // Extract query params from the URL
760
761    let uri = session.req_header().uri.to_string();
762    let full_uri = if uri.starts_with('/') {
763        // build an absolute URL: scheme://host + path?query
764        let host = session
765            .req_header()
766            .headers
767            .get("host")
768            .ok_or("Missing host header")?
769            .to_str()?;
770        format!("https://{}{}", host, uri)
771    } else {
772        uri
773    };
774
775    let mut url = Url::parse(&full_uri)?;
776    debug!("full_url: {}", url);
777    let mut provided_signature = None;
778    let mut qp: Vec<(String, String)> = vec![];
779    for (k, v) in url.query_pairs() {
780        if k == "X-Amz-Signature" {
781            provided_signature = Some(v.into_owned());
782        } else {
783            qp.push((k.into_owned(), v.into_owned()));
784        }
785    }
786    let provided_signature = provided_signature.ok_or("Missing X-Amz-Signature")?;
787
788    // rebuild query string without the signature
789    qp.sort();
790    let new_query = qp
791        .iter()
792        .map(|(k, v)| format!("{k}={v}"))
793        .collect::<Vec<_>>()
794        .join("&");
795    url.set_query(Some(&new_query));
796
797    // params map (also without the signature)
798    let params: HashMap<_, _> = qp.into_iter().collect();
799    debug!("params: {:?}", params);
800    debug!("url: {:?}", url);
801
802    debug!("provided signature: {}", provided_signature);
803    let credential = params
804        .get("X-Amz-Credential")
805        .ok_or("Missing X-Amz-Credential")?;
806
807    debug!("credential: {}", credential);
808
809    // Parse credential: <access_key>/<date>/<region>/<service>/aws4_request
810    let mut parts = credential.split('/');
811    let access_key = parts.next().ok_or("Malformed Credential")?;
812    let _credential_date = parts.next().ok_or("Malformed Credential")?;
813    let region = parts.next().ok_or("Malformed Credential")?;
814    let service = parts.next().ok_or("Malformed Credential")?;
815
816    debug!("access_key: {}", access_key);
817    debug!("region: {}", region);
818    debug!("service: {}", service);
819
820    // Parse date from query
821    let date_str = params.get("X-Amz-Date").ok_or("Missing X-Amz-Date")?;
822    let datetime = NaiveDateTime::parse_from_str(date_str, LONG_DATETIME)?.and_utc();
823
824    debug!("datetime: {}", datetime);
825
826    // Check expiry: X-Amz-Expires is in seconds relative to X-Amz-Date
827    if let Some(expires_str) = params.get("X-Amz-Expires") {
828        let expires_secs: i64 = expires_str.parse().map_err(|_| "Invalid X-Amz-Expires")?;
829        let expiry = datetime + chrono::Duration::seconds(expires_secs);
830        let now = chrono::Utc::now();
831        if now > expiry {
832            debug!("presigned URL expired at {}, now is {}", expiry, now);
833            return Err("Presigned URL has expired".into());
834        }
835    }
836
837    let body_bytes: &[u8] = b"UNSIGNED-PAYLOAD";
838    let payload_override = None;
839
840    debug!("body_bytes: {:?}", body_bytes);
841
842    // collect signed headers list
843    let signed_headers = params
844        .get("X-Amz-SignedHeaders")
845        .ok_or_else(|| pingora::Error::new_str("missing X-Amz-SignedHeaders in presigned URL"))?
846        .split(';')
847        .map(str::to_string)
848        .collect::<Vec<_>>();
849
850    let mut signed_hdrs = HeaderMap::new();
851
852    let host = url
853        .host_str()
854        .ok_or_else(|| pingora::Error::new_str("presigned URL has no host"))?;
855    let host_header = match url.port_or_known_default() {
856        Some(443) | Some(80) | None => host.to_owned(),
857        Some(p) => format!("{}:{}", host, p),
858    };
859
860    signed_hdrs.insert("host", host_header.parse()?);
861
862    // copy any additional headers that appear in X-Amz-SignedHeaders (rare)
863    for h in &[
864        "x-amz-date",
865        "x-amz-content-sha256",
866        "range",
867        "x-amz-security-token",
868    ] {
869        if signed_headers.contains(&h.to_string())
870            && let Some(v) = session.req_header().headers.get(*h)
871        {
872            signed_hdrs.insert(*h, v.clone());
873        }
874    }
875
876    debug!("signed_headers: {:?}", signed_headers);
877    // delegate to core validator
878    signature_is_valid_core(
879        session.req_header().method.as_str(),
880        &provided_signature,
881        region,
882        service,
883        datetime,
884        url.as_str(),
885        &signed_hdrs,
886        payload_override,
887        access_key,
888        secret_key,
889        &signed_headers,
890        body_bytes,
891    )
892    .await
893}
894
895/// Build a stream whose items are *already* wrapped in
896/// "AWS-chunk-signed" envelopes.
897///
898/// * `body`        - raw payload implementing `AsyncRead`
899/// * `signing_key` - result of the usual `signing_key()` step
900/// * `scope`       - e.g. `"20250501/eu-west-3/s3/aws4_request"`
901/// * `ts`          - the `X-Amz-Date` you put in the header (`YYYYMMDDThhmmssZ`)
902/// * `seed_sig`    - the `Signature=` value you computed for the
903///   *headers* (the one that goes into `Authorization:`)
904///
905/// ```text
906/// ┌──── header chunk ────┐┌── data ─┐┌─ CRLF ─┐
907/// <hex-len>;chunk-signature=<sig>\r\n<bytes>\r\n
908/// ```
909///
910/// The very last frame is
911/// ```text
912/// 0;chunk-signature=<final-sig>\r\n\r\n
913/// ```
914pub async fn wrap_streaming_body(
915    session: &mut Session,
916    upstream_request: &mut RequestHeader,
917    region: &str,
918    access_key: &str,
919    secret_key: &str,
920) -> Result<(), Box<dyn std::error::Error>> {
921    // 1. pull the COMPLETE body from the client
922    let body: Bytes = session
923        .read_request_body()
924        .await
925        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
926        .ok_or_else(|| Box::<dyn std::error::Error>::from("empty request body"))?;
927
928    // 2. overwrite the x-amz-* headers so that we can sign UNSIGNED-PAYLOAD
929    upstream_request.insert_header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")?;
930    upstream_request.remove_header("x-amz-decoded-content-length");
931    upstream_request.insert_header("content-length", body.len().to_string())?;
932
933    // 3. resign
934    let ts = chrono::Utc::now();
935    let url = upstream_request.uri.to_string();
936    upstream_request.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
937    let signer = AwsSign::new(
938        upstream_request.method.as_str(),
939        &url,
940        &ts,
941        &upstream_request.headers,
942        region,
943        access_key,
944        secret_key,
945        "s3",
946        b"UNSIGNED-PAYLOAD",
947        None,
948    );
949    let auth = signer.sign();
950    upstream_request.insert_header("authorization", auth)?;
951
952    let end_of_stream: bool = session.is_body_done();
953
954    // 4. finally attach the body
955    session
956        .write_response_body(Some(body), end_of_stream)
957        .await?;
958
959    Ok(())
960}
961
962const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
963
964/// Stateful per-chunk signer for `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` uploads.
965///
966/// Each call to [`sign_chunk`](ChunkSigner::sign_chunk) produces the
967/// `chunk-signature` trailer required by AWS chunked encoding and advances
968/// the internal HMAC chain for the next chunk.
969pub struct ChunkSigner {
970    signing_key: Vec<u8>,
971    scope: String,
972    ts: String,
973    prev_sig: String,
974}
975
976impl ChunkSigner {
977    /// create a new signer.
978    /// *`seed_signature`* is the **signature you put in the `Authorization` header**
979    /// Create a new [`ChunkSigner`] from the seed values established during
980    /// the initial header signing step.
981    pub fn new(signing_key: Vec<u8>, scope: String, ts: String, seed_signature: String) -> Self {
982        Self {
983            signing_key,
984            scope,
985            ts,
986            prev_sig: seed_signature,
987        }
988    }
989
990    /// sign one payload chunk and return the wire bytes **and** update internal state.
991    pub fn sign_chunk(
992        &mut self,
993        chunk: Bytes,
994    ) -> Result<Bytes, Box<dyn std::error::Error + Send + Sync>> {
995        // 1. Hash of the chunk payload
996        let chunk_hash = hex::encode(sha256::digest(chunk.as_ref()));
997
998        // 2. String to sign for this chunk (AWS spec §4)
999        let string_to_sign = format!(
1000            "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1001            self.ts,
1002            self.scope,
1003            self.prev_sig,
1004            EMPTY_HASH, // hash of empty string
1005            chunk_hash
1006        );
1007
1008        // 3. HMAC with the derived signing key
1009        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
1010        let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
1011
1012        // 4. Build the chunk header:  "<hexlen>;chunk-signature=<sig>\r\n"
1013        let mut buf = BytesMut::with_capacity(chunk.len() + 128);
1014        buf.put(format!("{:x};chunk-signature={}\r\n", chunk.len(), sig).as_bytes());
1015        buf.put(chunk);
1016        buf.put_slice(b"\r\n");
1017
1018        // 5. advance running signature
1019        self.prev_sig = sig;
1020
1021        Ok(buf.freeze())
1022    }
1023
1024    /// Final 0-length chunk (must be sent after the body)
1025    /// Produce the zero-length terminal chunk that signals end-of-stream.
1026    pub fn final_chunk(&mut self) -> Bytes {
1027        // sign an empty payload chunk (same steps, len = 0)
1028        let string_to_sign = format!(
1029            "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1030            self.ts, self.scope, self.prev_sig, EMPTY_HASH, EMPTY_HASH
1031        );
1032        let key = hmac::Key::new(hmac::HMAC_SHA256, &self.signing_key);
1033        let sig = hex::encode(hmac::sign(&key, string_to_sign.as_bytes()));
1034
1035        Bytes::from(format!("0;chunk-signature={}\r\n\r\n", sig))
1036    }
1037}
1038
1039/// Re-sign an upstream request header for a `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` upload.
1040///
1041/// Updates the `Authorization`, `x-amz-date`, and `x-amz-content-sha256` headers
1042/// in-place.  Returns the seed signature that must be passed to [`StreamingState::new`].
1043pub fn resign_streaming_request(
1044    req: &mut RequestHeader,
1045    region: &str,
1046    access_key: &str,
1047    secret_key: &str,
1048    ts: DateTime<Utc>,
1049) -> Result<(), Box<dyn std::error::Error>> {
1050    // let ts = chrono::Utc::now();
1051    req.insert_header("x-amz-date", ts.format("%Y%m%dT%H%M%SZ").to_string())?;
1052
1053    let url = req.uri.to_string();
1054    let signer = AwsSign::new(
1055        req.method.as_str(),
1056        &url,
1057        &ts,
1058        &req.headers,
1059        region,
1060        access_key,
1061        secret_key,
1062        "s3",
1063        b"STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
1064        None,
1065    );
1066
1067    let auth = signer.sign();
1068    req.insert_header("authorization", auth)?;
1069
1070    Ok(())
1071}
1072
1073/// Maximum chunk payload we read from the client before signing.
1074/// (4 MiB is what the Java/AWS SDKs use, but any size works.)
1075// const DEFAULT_CHUNK: usize = 4 * 1024 * 1024;
1076
1077// pub fn wrap_streaming_body_reader<R>(
1078//     body_reader: R,
1079//     signing_key: Vec<u8>,
1080//     scope: String,
1081//     ts: String,
1082//     seed_signature: String,
1083// ) -> impl Stream<Item = Result<bytes::Bytes, std::io::Error>>
1084// where
1085//     R: Stream<Item = Result<bytes::Bytes, pingora::Error>> + Unpin + Send + 'static,
1086// {
1087//     // sign_chunk prepends the S3 chunk header & signature
1088//     body_reader
1089//         .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
1090//         .and_then(move |chunk| async move {
1091//             Ok(ChunkSigner::sign_chunk(
1092//                 chunk,
1093//                 &signing_key,
1094//                 &scope,
1095//                 &ts,
1096//                 &seed_signature,
1097//             )?)
1098//         })
1099// }
1100
1101// pin_project! {
1102//     pub struct StreamingSignerReader<R> {
1103//         #[pin] inner: R,
1104//         buf: BytesMut,
1105//         region: String,
1106//         access_key: String,
1107//         secret_key: String,
1108//         ts: chrono::DateTime<chrono::Utc>,
1109//         prior_signature: String,
1110//         eof: bool,
1111//     }
1112// }
1113
1114// impl<R> StreamingSignerReader<R>
1115// where
1116//     R: AsyncRead + Unpin,
1117// {
1118//     pub fn new(
1119//         inner: R,
1120//         region: String,
1121//         access_key: String,
1122//         secret_key: String,
1123//         ts: chrono::DateTime<chrono::Utc>,
1124//         seed_sig: String,
1125//     ) -> Self {
1126//         Self {
1127//             inner,
1128//             buf: BytesMut::new(),
1129//             region,
1130//             access_key,
1131//             secret_key,
1132//             ts,
1133//             prior_signature: seed_sig,
1134//             eof: false,
1135//         }
1136//     }
1137
1138//     /// Read *one* chunk payload from `inner`, sign it and stage it in `buf`.
1139//     async fn fill_buf(mut self: Pin<&mut Self>) -> std::io::Result<()> {
1140//         if self.eof {
1141//             return Ok(());
1142//         }
1143
1144//         let mut payload = vec![0u8; DEFAULT_CHUNK];
1145//         let n = self.inner.read(&mut payload).await?;
1146//         if n == 0 {
1147//             // Final 0-length chunk
1148//             self.stage_chunk(Bytes::new());
1149//             self.eof = true;
1150//             return Ok(());
1151//         }
1152//         payload.truncate(n);
1153//         self.stage_chunk(Bytes::from(payload));
1154//         Ok(())
1155//     }
1156
1157//     fn stage_chunk(&mut self, payload: Bytes) {
1158//         // 1/ compute SHA-256 hash of the chunk payload
1159//         let chunk_hash = sha256::digest(payload.as_ref());
1160
1161//         // 2/ build canonical string for the *chunk*
1162//         //    https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
1163//         let string_to_sign = format!(
1164//             "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}/s3/aws4_request\n{}\n{}\n{}",
1165//             self.ts.format("%Y%m%dT%H%M%SZ"),
1166//             self.ts.format("%Y%m%d"),
1167//             self.prior_signature,
1168//             hex::encode([0u8; 32]), // empty-string hash for headers
1169//             chunk_hash
1170//         );
1171
1172//         // 3/ get signing key for the day and region
1173//         let sig_key = signing_key(&self.ts, &self.secret_key, &self.region, "s3")
1174//             .expect("streaming key");
1175
1176//         let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &sig_key);
1177//         let tag = ring::hmac::sign(&key, string_to_sign.as_bytes());
1178//         let signature_hex = hex::encode(tag);
1179
1180//         // 4/ assemble chunk: <hexlen>;<sig>\r\n<payload>\r\n
1181//         let mut chunk = BytesMut::new();
1182//         let len_hex = format!("{:x}", payload.len());
1183//         chunk.extend_from_slice(len_hex.as_bytes());
1184//         chunk.extend_from_slice(b";chunk-signature=");
1185//         chunk.extend_from_slice(signature_hex.as_bytes());
1186//         chunk.extend_from_slice(b"\r\n");
1187//         chunk.extend_from_slice(&payload);
1188//         chunk.extend_from_slice(b"\r\n");
1189
1190//         self.prior_signature = signature_hex;
1191//         self.buf.extend_from_slice(&chunk);
1192//     }
1193// }
1194
1195// impl<R: AsyncRead + Unpin> AsyncRead for StreamingSignerReader<R> {
1196//     fn poll_read(
1197//         self: Pin<&mut Self>,
1198//         cx: &mut Context<'_>,
1199//         out: &mut tokio::io::ReadBuf<'_>,
1200//     ) -> Poll<std::io::Result<()>> {
1201//         // 1. fast-path: drain any buffered bytes
1202//         {
1203//             let this = self.as_ref().project();
1204//             if !this.buf.is_empty() {
1205//                 let n = std::cmp::min(out.remaining(), this.buf.len());
1206//                 out.put_slice(&this.buf.split_to(n));
1207//                 return Poll::Ready(Ok(()));
1208//             }
1209//         }
1210
1211//         // 2. refill the buffer
1212//         let mut fut = Box::pin(self.as_mut().fill_buf());      // <-- changed line
1213//         futures::ready!(fut.as_mut().poll(cx))?;
1214
1215//         // 3. project again to copy freshly-staged data to `out`
1216//         let this = self.project();
1217//         if this.buf.is_empty() {
1218//             return Poll::Ready(Ok(())); // EOF
1219//         }
1220//         let n = std::cmp::min(out.remaining(), this.buf.len());
1221//         out.put_slice(&this.buf.split_to(n));
1222//         Poll::Ready(Ok(()))
1223//     }
1224// }
1225
1226#[derive(Debug)]
1227/// Runtime state for an in-progress streaming upload.
1228///
1229/// Stored in [`MyCtx::stream_state`](crate::MyCtx) and consumed by
1230/// [`MyProxy::request_body_filter`](crate::MyProxy) to sign each body chunk
1231/// as it flows through the proxy.
1232pub struct StreamingState {
1233    region: String,
1234    _access_key: String,
1235    _secret_key: String,
1236    ts: chrono::DateTime<chrono::Utc>,
1237    prior_sig: String,
1238    signing_key: Vec<u8>,
1239    /// Buffer for incomplete incoming aws-chunked frames across body filter calls.
1240    pub decode_buf: BytesMut,
1241}
1242
1243impl StreamingState {
1244    /// Create a new [`StreamingState`] from the seed values produced by
1245    /// [`resign_streaming_request`].
1246    pub fn new(
1247        region: String,
1248        access_key: String,
1249        secret_key: String,
1250        ts: chrono::DateTime<chrono::Utc>,
1251        seed_signature: String,
1252    ) -> Self {
1253        let signing_key = signing_key(&ts, &secret_key, &region, "s3").expect("signing key");
1254        Self {
1255            region,
1256            _access_key: access_key,
1257            _secret_key: secret_key,
1258            ts,
1259            prior_sig: seed_signature,
1260            signing_key,
1261            decode_buf: BytesMut::new(),
1262        }
1263    }
1264
1265    /// Sign a body chunk and return the framed AWS-chunked bytes.
1266    pub fn sign_chunk(&mut self, payload: &[u8]) -> io::Result<Bytes> {
1267        let sig = compute_chunk_signature(
1268            payload,
1269            &self.signing_key,
1270            &self.prior_sig,
1271            &self.ts,
1272            &self.region,
1273        )?;
1274        self.prior_sig = sig.clone();
1275        Ok(build_chunk_frame(payload, &sig))
1276    }
1277
1278    /// Produce the terminal zero-length chunk that signals end-of-stream.
1279    pub fn final_chunk(&mut self) -> io::Result<Bytes> {
1280        let sig = compute_chunk_signature(
1281            &[],
1282            &self.signing_key,
1283            &self.prior_sig,
1284            &self.ts,
1285            &self.region,
1286        )?;
1287        Ok(build_chunk_frame(&[], &sig))
1288    }
1289}
1290
1291// fn sha256_hex(data: &[u8]) -> String {
1292//     let mut hasher = Sha256::new();
1293//     hasher.update(data);
1294//     hex::encode(hasher.finalize())
1295// }
1296
1297fn sha256_hex(data: &[u8]) -> String {
1298    sha256::digest(data)
1299}
1300
1301/// Pre‑computed because it is constant for every chunk.
1302const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1303
1304/// Calculate the per‑chunk signature (section *Defining the Chunk Body* of the
1305/// AWS doc).
1306/// * `signing_key` is the value you built once from the secret key
1307/// * `prior_sig`  is the *seed* (first chunk) or the previous chunk’s signature
1308fn compute_chunk_signature(
1309    payload: &[u8],
1310    signing_key: &[u8],
1311    prior_sig: &str,
1312    ts: &DateTime<Utc>,
1313    region: &str,
1314) -> io::Result<String> {
1315    // 1️⃣  Build the String‑To‑Sign
1316    let time = ts.format("%Y%m%dT%H%M%SZ").to_string();
1317    let scope = format!("{}/{}/s3/aws4_request", ts.format("%Y%m%d"), region);
1318
1319    let string_to_sign = format!(
1320        "AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{}\n{}\n{}",
1321        time,
1322        scope,
1323        prior_sig,
1324        EMPTY_SHA256, // SHA256("")
1325        sha256_hex(payload),
1326    );
1327
1328    // 2️⃣  HMAC it
1329    let key = hmac::Key::new(hmac::HMAC_SHA256, signing_key);
1330    let sig = hmac::sign(&key, string_to_sign.as_bytes());
1331    Ok(hex::encode(sig.as_ref()))
1332}
1333
1334/// Parse one aws-chunked frame header from `buf`.
1335///
1336/// The aws-chunked format is:
1337/// ```text
1338/// <hex-payload-size>;chunk-signature=<sig>\r\n<payload>\r\n
1339/// ```
1340///
1341/// Returns `Some((header_len, payload_len))` when a complete header is
1342/// available, or `None` if more data is needed.
1343///
1344/// `header_len` is the number of bytes occupied by the header line
1345/// (including the trailing `\r\n`).  `payload_len` is the number of
1346/// payload bytes that follow.
1347pub fn parse_aws_chunk_header(buf: &[u8]) -> Option<(usize, usize)> {
1348    // Find the end of the first line (\r\n).
1349    let crlf = buf.windows(2).position(|w| w == b"\r\n")?;
1350    let header = std::str::from_utf8(&buf[..crlf]).ok()?;
1351    // The first token before ';' is the hex payload size.
1352    let hex_size = header.split(';').next()?;
1353    let payload_len = usize::from_str_radix(hex_size.trim(), 16).ok()?;
1354    Some((crlf + 2, payload_len))
1355}
1356
1357/// Wrap a signed payload frame into the final on‑the‑wire representation.
1358fn build_chunk_frame(payload: &[u8], sig: &str) -> Bytes {
1359    let mut buf = BytesMut::with_capacity(payload.len() + sig.len() + 64);
1360    // <hexlen>;chunk-signature=<sig>\r\n
1361    use std::fmt::Write;
1362    write!(&mut buf, "{:x};chunk-signature={}\r\n", payload.len(), sig).unwrap();
1363    buf.extend_from_slice(payload);
1364    buf.extend_from_slice(b"\r\n");
1365    buf.freeze()
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371    use crate::parsers::cos_map::CosMapItem;
1372    use http::{HeaderMap, Method};
1373    use pingora::http::RequestHeader;
1374    use regex::Regex;
1375    use sha256::digest;
1376
1377    #[test]
1378    fn sample_canonical_request() {
1379        let datetime = chrono::Utc::now();
1380        let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1381        let map: HeaderMap = HeaderMap::new();
1382        let aws_sign = AwsSign::new(
1383            "GET",
1384            url,
1385            &datetime,
1386            &map,
1387            "us-east-1",
1388            "a",
1389            "b",
1390            "s3",
1391            "",
1392            None,
1393        );
1394        let s = aws_sign.canonical_request();
1395        assert_eq!(
1396            s,
1397            "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1398        );
1399    }
1400
1401    #[test]
1402    fn sample_canonical_request_using_u8_body() {
1403        let datetime = chrono::Utc::now();
1404        let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1405        let map: HeaderMap = HeaderMap::new();
1406        let aws_sign = AwsSign::new(
1407            "GET",
1408            url,
1409            &datetime,
1410            &map,
1411            "us-east-1",
1412            "a",
1413            "b",
1414            "s3",
1415            "".as_bytes(),
1416            None,
1417        );
1418        let s = aws_sign.canonical_request();
1419        assert_eq!(
1420            s,
1421            "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1422        );
1423    }
1424
1425    #[test]
1426    fn sample_canonical_request_using_vec_body() {
1427        let datetime = chrono::Utc::now();
1428        let url: &str = "https://hi.s3.us-east-1.amazonaws.com/Prod/graphql";
1429        let map: HeaderMap = HeaderMap::new();
1430        let body = Vec::new();
1431        let aws_sign = AwsSign::new(
1432            "GET",
1433            url,
1434            &datetime,
1435            &map,
1436            "us-east-1",
1437            "a",
1438            "b",
1439            "s3",
1440            &body,
1441            None,
1442        );
1443        let s = aws_sign.canonical_request();
1444        assert_eq!(
1445            s,
1446            "GET\n/Prod/graphql\n\n\n\n\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1447        );
1448    }
1449
1450    fn make_cos_map_item() -> CosMapItem {
1451        CosMapItem {
1452            region: Some("us-east-1".into()),
1453            access_key: Some("AKIDEXAMPLE".into()),
1454            secret_key: Some("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into()),
1455            host: "bucket.s3.us-east-1.amazonaws.com".into(),
1456            port: 443,
1457            api_key: None,
1458            ttl: None,
1459            tls: Some(true),
1460            addressing_style: Some("path".to_string()),
1461        }
1462    }
1463
1464    /// Any method other than GET/HEAD/DELETE should use UNSIGNED-PAYLOAD
1465    #[tokio::test]
1466    async fn post_request_uses_unsigned_payload() {
1467        // build a POST RequestHeader
1468        let mut req = RequestHeader::build(
1469            Method::GET,
1470            b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1471            None
1472        ).unwrap();
1473        req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1474            .unwrap();
1475        assert!(req.headers.get("x-amz-content-sha256").is_none());
1476
1477        // run sign_request
1478        let cos = make_cos_map_item();
1479        sign_request(&mut req, &cos).await.unwrap();
1480
1481        // x-amz-content-sha256 must be "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1482        let payload_header = req
1483            .headers
1484            .get("x-amz-content-sha256")
1485            .unwrap()
1486            .to_str()
1487            .unwrap();
1488        assert_eq!(
1489            payload_header,
1490            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1491        );
1492
1493        // Authorization header must include our access key and scope
1494        let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1495        assert!(auth.contains("Credential=AKIDEXAMPLE/"));
1496        assert!(auth.contains("/us-east-1/s3/aws4_request,"));
1497    }
1498
1499    /// GET/DELETE must use the empty-body hash, and sign correctly
1500    #[tokio::test]
1501    async fn get_request_sets_empty_body_hash_and_signature_format() {
1502        let mut req = RequestHeader::build(
1503            Method::GET, b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1504            None
1505        ).unwrap();
1506        req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1507            .unwrap();
1508        let cos = make_cos_map_item();
1509        sign_request(&mut req, &cos).await.unwrap();
1510
1511        // empty-body sha256
1512        let empty_hash = digest(b"");
1513        let header_hash = req
1514            .headers
1515            .get("x-amz-content-sha256")
1516            .unwrap()
1517            .to_str()
1518            .unwrap();
1519        assert_eq!(header_hash, empty_hash);
1520
1521        // X-Amz-Date must be a valid timestamp ending in Z
1522        let x_amz_date = req.headers.get("x-amz-date").unwrap().to_str().unwrap();
1523        let re_date = Regex::new(r"^\d{8}T\d{6}Z$").unwrap();
1524        assert!(
1525            re_date.is_match(x_amz_date),
1526            "x-amz-date wrong format: {}",
1527            x_amz_date
1528        );
1529
1530        // Authorization header format
1531        let auth = req.headers.get("authorization").unwrap().to_str().unwrap();
1532        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/"));
1533        // must have SignedHeaders including host;x-amz-content-sha256;x-amz-date
1534        assert!(auth.contains("SignedHeaders="));
1535        assert!(auth.contains("host;"));
1536        assert!(auth.contains("x-amz-content-sha256;"));
1537        assert!(auth.contains("x-amz-date"));
1538    }
1539
1540    /// Missing any of region/access_key/secret_key should error out
1541    #[tokio::test]
1542    async fn error_when_missing_credentials() {
1543        let mut req = RequestHeader::build(
1544            Method::GET,
1545            b"https://bucket.s3.us-east-1.amazonaws.com/?list-type=2&prefix=mandelbrot&encoding-type=url",
1546            None
1547        ).unwrap();
1548        req.insert_header("Host", "bucket.s3.us-east-1.amazonaws.com")
1549            .unwrap();
1550        let mut cos = make_cos_map_item();
1551        cos.region = None; // drop region
1552        let err = sign_request(&mut req, &cos).await.unwrap_err();
1553        let msg = format!("{}", err);
1554        assert!(msg.contains("Missing region, access_key or secret_key"));
1555    }
1556
1557    #[test]
1558    fn uri_encode_edge_cases() {
1559        assert_eq!(uri_encode("simple", true), "simple");
1560        assert_eq!(uri_encode("a b", true), "a%20b");
1561        assert_eq!(
1562            uri_encode("/path/with/slash", true),
1563            "%2Fpath%2Fwith%2Fslash"
1564        );
1565        assert_eq!(uri_encode("/path/with/slash", false), "/path/with/slash");
1566        assert!(uri_encode("unicode✓", true).contains("%E2%9C%93"));
1567    }
1568
1569    #[test]
1570    fn canonical_query_string_sorts_and_encodes() {
1571        let url = "https://example.com/?b=2&a=1 space";
1572        let parsed = url.parse().unwrap();
1573        let qs = canonical_query_string(&parsed);
1574        assert_eq!(qs, "a=1%20space&b=2");
1575    }
1576
1577    #[test]
1578    fn canonical_query_string_empty() {
1579        let url: Url = "https://example.com/path".parse().unwrap();
1580        assert_eq!(canonical_query_string(&url), "");
1581    }
1582
1583    #[test]
1584    fn scope_string_format() {
1585        let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1586            .unwrap()
1587            .with_timezone(&chrono::Utc);
1588        assert_eq!(
1589            scope_string(&dt, "us-east-1", "s3"),
1590            "20130524/us-east-1/s3/aws4_request"
1591        );
1592    }
1593
1594    #[test]
1595    fn string_to_sign_format() {
1596        let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1597            .unwrap()
1598            .with_timezone(&chrono::Utc);
1599        let sts = string_to_sign(&dt, "us-east-1", "canonical-request-here", "s3");
1600        assert!(sts.starts_with("AWS4-HMAC-SHA256\n"));
1601        assert!(sts.contains("20130524T000000Z"));
1602        assert!(sts.contains("20130524/us-east-1/s3/aws4_request"));
1603        // last line must be a 64-char hex SHA-256
1604        let last = sts.lines().last().unwrap();
1605        assert_eq!(last.len(), 64);
1606        assert!(last.chars().all(|c| c.is_ascii_hexdigit()));
1607    }
1608
1609    #[test]
1610    fn signing_key_returns_32_bytes() {
1611        let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1612            .unwrap()
1613            .with_timezone(&chrono::Utc);
1614        let key = signing_key(
1615            &dt,
1616            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
1617            "us-east-1",
1618            "s3",
1619        )
1620        .unwrap();
1621        assert_eq!(key.len(), 32);
1622    }
1623
1624    #[test]
1625    fn canonical_header_and_signed_header_strings() {
1626        let datetime = chrono::Utc::now();
1627        let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1628        let mut headers = HeaderMap::new();
1629        headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1630        headers.insert("x-amz-date", "20130524T000000Z".parse().unwrap());
1631
1632        let signer = AwsSign::new(
1633            "GET",
1634            url,
1635            &datetime,
1636            &headers,
1637            "us-east-1",
1638            "AK",
1639            "SK",
1640            "s3",
1641            "",
1642            None,
1643        );
1644
1645        let signed = signer.signed_header_string();
1646        // sorted alphabetically
1647        assert_eq!(signed, "host;x-amz-date");
1648
1649        let canonical = signer.canonical_header_string();
1650        assert!(canonical.contains("host:bucket.s3.us-east-1.amazonaws.com"));
1651        assert!(canonical.contains("x-amz-date:20130524T000000Z"));
1652    }
1653
1654    #[test]
1655    fn sign_output_format() {
1656        let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1657            .unwrap()
1658            .with_timezone(&chrono::Utc);
1659        let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1660        let mut headers = HeaderMap::new();
1661        headers.insert("host", "bucket.s3.us-east-1.amazonaws.com".parse().unwrap());
1662        let signer = AwsSign::new(
1663            "GET",
1664            url,
1665            &dt,
1666            &headers,
1667            "us-east-1",
1668            "AKID",
1669            "SECRET",
1670            "s3",
1671            "",
1672            None,
1673        );
1674        let auth = signer.sign();
1675        assert!(
1676            auth.starts_with(
1677                "AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1/s3/aws4_request,"
1678            )
1679        );
1680        assert!(auth.contains("SignedHeaders="));
1681        assert!(auth.contains("Signature="));
1682        // Signature must be a 64-char hex string
1683        let sig = auth.split("Signature=").nth(1).unwrap();
1684        assert_eq!(sig.len(), 64);
1685        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1686    }
1687
1688    #[test]
1689    fn canonical_request_uses_unsigned_payload_marker() {
1690        let dt = chrono::Utc::now();
1691        let url = "https://bucket.s3.us-east-1.amazonaws.com/key";
1692        let headers = HeaderMap::new();
1693        let mut signer = AwsSign::new(
1694            "PUT",
1695            url,
1696            &dt,
1697            &headers,
1698            "us-east-1",
1699            "AK",
1700            "SK",
1701            "s3",
1702            "UNSIGNED-PAYLOAD",
1703            None,
1704        );
1705        signer.set_payload_override("UNSIGNED-PAYLOAD".into());
1706        assert!(signer.canonical_request().ends_with("UNSIGNED-PAYLOAD"));
1707    }
1708
1709    #[test]
1710    fn chunk_signer_produces_valid_frames() {
1711        let dt = chrono::DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1712            .unwrap()
1713            .with_timezone(&chrono::Utc);
1714        let key = signing_key(&dt, "SECRET", "us-east-1", "s3").unwrap();
1715        let scope = scope_string(&dt, "us-east-1", "s3");
1716        let ts = dt.format("%Y%m%dT%H%M%SZ").to_string();
1717        let mut cs = ChunkSigner::new(key, scope, ts, "seed-sig-hex".into());
1718
1719        let payload: &[u8] = b"hello world";
1720        let frame = cs
1721            .sign_chunk(bytes::Bytes::copy_from_slice(payload))
1722            .unwrap();
1723        // Frame must start with hex chunk-size;chunk-ext\r\n
1724        let frame_str = std::str::from_utf8(&frame).unwrap();
1725        assert!(frame_str.contains("chunk-signature="));
1726        assert!(frame_str.contains("\r\n"));
1727
1728        let final_frame = cs.final_chunk();
1729        let final_str = std::str::from_utf8(&final_frame).unwrap();
1730        assert!(final_str.contains("chunk-signature="));
1731    }
1732}