Skip to main content

fakecloud_aws/
sigv4.rs

1//! AWS Signature Version 4 parsing and verification.
2//!
3//! Used in two modes:
4//!
5//! 1. **Routing** — lightweight parse of the Authorization header or presigned
6//!    query string to extract the access key ID, region, and service. Always
7//!    on, used by dispatch to route requests regardless of whether signatures
8//!    are being verified.
9//!
10//! 2. **Verification** — reconstructs the canonical request, derives the
11//!    signing key from the access key's secret, and compares the computed
12//!    signature against the one the client sent. Opt-in via
13//!    `--verify-sigv4` / `FAKECLOUD_VERIFY_SIGV4`.
14//!
15//! The canonical request + string-to-sign + signing-key derivation follows
16//! the AWS SigV4 specification at
17//! <https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html>.
18//! S3 is the only service that single-encodes the path; all others
19//! double-encode.
20
21use chrono::{DateTime, TimeZone, Utc};
22use hmac::{Hmac, Mac};
23use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
24use sha2::{Digest, Sha256};
25use std::collections::BTreeMap;
26
27type HmacSha256 = Hmac<Sha256>;
28
29/// Lightweight view of a parsed SigV4 Authorization header or presigned URL.
30/// Used for request routing (access key → principal, region, service) without
31/// requiring cryptographic verification.
32#[derive(Debug, Clone)]
33pub struct SigV4Info {
34    pub access_key: String,
35    pub region: String,
36    pub service: String,
37}
38
39/// Full parse of a SigV4-signed request. Carries everything needed to
40/// reconstruct the canonical request and re-derive the signature.
41#[derive(Debug, Clone)]
42pub struct ParsedSigV4 {
43    pub access_key: String,
44    /// 8-char `YYYYMMDD` date part of the credential scope.
45    pub date_stamp: String,
46    pub region: String,
47    pub service: String,
48    /// Lowercased, semicolon-separated list of signed headers
49    /// (e.g. `host;x-amz-content-sha256;x-amz-date`).
50    pub signed_headers: Vec<String>,
51    /// Hex-encoded signature the client sent.
52    pub signature: String,
53    /// `X-Amz-Date` / `x-amz-date` value in `YYYYMMDDTHHMMSSZ` form.
54    pub amz_date: String,
55    /// True if the request was signed via presigned URL query parameters
56    /// rather than the `Authorization` header.
57    pub is_presigned: bool,
58}
59
60impl ParsedSigV4 {
61    /// Borrow-view the routing subset of a full parse.
62    pub fn as_info(&self) -> SigV4Info {
63        SigV4Info {
64            access_key: self.access_key.clone(),
65            region: self.region.clone(),
66            service: self.service.clone(),
67        }
68    }
69}
70
71/// Reasons a SigV4 verification can fail. Each variant maps onto the
72/// AWS-shape error the caller should return.
73#[derive(Debug, thiserror::Error)]
74pub enum SigV4Error {
75    /// Request was signed more than 15 minutes from the server's clock.
76    /// Maps to AWS `RequestTimeTooSkewed`.
77    #[error("request time {signed} is outside the allowed 15-minute window from {server}")]
78    RequestTimeTooSkewed {
79        signed: DateTime<Utc>,
80        server: DateTime<Utc>,
81    },
82    /// `Authorization` header or presigned URL was not a well-formed
83    /// AWS4-HMAC-SHA256 signature. Maps to AWS `IncompleteSignature` or
84    /// `InvalidSignatureException`.
85    #[error("malformed SigV4 signature: {0}")]
86    Malformed(&'static str),
87    /// The computed signature did not match the signature the client sent.
88    /// Maps to AWS `SignatureDoesNotMatch`.
89    #[error("signature does not match")]
90    SignatureMismatch,
91    /// `X-Amz-Date` / credential-scope date could not be parsed.
92    #[error("invalid x-amz-date: {0}")]
93    InvalidDate(&'static str),
94    /// Presigned URL is past its `X-Amz-Expires` lifetime. Maps to AWS
95    /// `AccessDenied` ("Request has expired").
96    #[error("presigned URL signed at {signed} has expired (X-Amz-Expires={expires_secs}s) relative to {server}")]
97    PresignedUrlExpired {
98        signed: DateTime<Utc>,
99        server: DateTime<Utc>,
100        expires_secs: i64,
101    },
102    /// Presigned `X-Amz-Expires` was absent or outside the 1..=604800 range.
103    #[error("invalid X-Amz-Expires value {0} (must be 1..=604800 seconds)")]
104    InvalidPresignExpires(i64),
105}
106
107/// Maximum allowed drift between the request's `X-Amz-Date` and the server's
108/// wall clock. Matches the 15-minute window AWS uses.
109pub const CLOCK_SKEW_SECONDS: i64 = 15 * 60;
110
111/// Maximum `X-Amz-Expires` AWS accepts for a presigned URL: 7 days.
112pub const MAX_PRESIGN_EXPIRES_SECONDS: i64 = 7 * 24 * 60 * 60;
113
114/// Extract the `X-Amz-Expires` value (seconds) from a presigned request's
115/// query string. Returns `DateParseError` when absent or not an integer, so a
116/// presigned request that omits it is rejected rather than treated as
117/// never-expiring (bug-audit 2026-05-28, 5.2).
118fn presigned_expires_secs(query: &str) -> Result<i64, SigV4Error> {
119    query
120        .split('&')
121        .find_map(|pair| {
122            let (k, v) = pair.split_once('=')?;
123            (k == "X-Amz-Expires").then(|| v.to_string())
124        })
125        .and_then(|v| v.parse::<i64>().ok())
126        .ok_or(SigV4Error::Malformed("missing or invalid X-Amz-Expires"))
127}
128
129/// Legacy routing-only parse. Extracts access key, region, and service from
130/// either an `Authorization: AWS4-HMAC-SHA256 …` header or a presigned URL's
131/// `X-Amz-Credential` component.
132///
133/// Returns `None` when the input isn't a recognizable SigV4 credential.
134pub fn parse_sigv4(auth_header: &str) -> Option<SigV4Info> {
135    parse_header_credential(auth_header)
136}
137
138fn parse_header_credential(auth_header: &str) -> Option<SigV4Info> {
139    let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
140    let credential_start = auth.find("Credential=")?;
141    let credential_value = &auth[credential_start + "Credential=".len()..];
142    // Real SigV4 headers always carry `SignedHeaders` and `Signature` after
143    // the credential, separated by commas. Some clients (and our conformance
144    // probe historically) send only the credential scope — accept that too
145    // so service routing still works rather than falling through to the
146    // catch-all API Gateway handler.
147    let credential = match credential_value.find(',') {
148        Some(end) => &credential_value[..end],
149        None => credential_value,
150    };
151    parse_credential_scope(credential)
152}
153
154fn parse_credential_scope(credential: &str) -> Option<SigV4Info> {
155    let parts: Vec<&str> = credential.split('/').collect();
156    if parts.len() != 5 || parts[4] != "aws4_request" {
157        return None;
158    }
159    Some(SigV4Info {
160        access_key: parts[0].to_string(),
161        region: parts[2].to_string(),
162        service: parts[3].to_string(),
163    })
164}
165
166/// Full parse of an `Authorization: AWS4-HMAC-SHA256 …` header.
167///
168/// Returns `None` if the header is missing required components. The returned
169/// value is suitable for both routing (`as_info`) and signature verification
170/// (`verify`). The caller must also supply the request's `X-Amz-Date` header
171/// because it isn't embedded in the credential — it's carried separately.
172pub fn parse_sigv4_header(auth_header: &str, amz_date: Option<&str>) -> Option<ParsedSigV4> {
173    let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
174
175    // Each field is comma-separated and may or may not be preceded by a space.
176    let mut credential: Option<&str> = None;
177    let mut signed_headers: Option<&str> = None;
178    let mut signature: Option<&str> = None;
179    for part in auth.split(',') {
180        let part = part.trim();
181        if let Some(v) = part.strip_prefix("Credential=") {
182            credential = Some(v);
183        } else if let Some(v) = part.strip_prefix("SignedHeaders=") {
184            signed_headers = Some(v);
185        } else if let Some(v) = part.strip_prefix("Signature=") {
186            signature = Some(v);
187        }
188    }
189
190    let credential = credential?;
191    let signed_headers = signed_headers?;
192    let signature = signature?;
193    let scope = parse_credential_scope(credential)?;
194    let date_stamp = credential.split('/').nth(1)?.to_string();
195    let signed_headers: Vec<String> = signed_headers
196        .split(';')
197        .map(|s| s.to_ascii_lowercase())
198        .collect();
199    if signed_headers.is_empty() {
200        return None;
201    }
202
203    Some(ParsedSigV4 {
204        access_key: scope.access_key,
205        date_stamp,
206        region: scope.region,
207        service: scope.service,
208        signed_headers,
209        signature: signature.to_string(),
210        amz_date: amz_date?.to_string(),
211        is_presigned: false,
212    })
213}
214
215/// Full parse of a presigned URL's SigV4 query parameters.
216///
217/// Expects to be given the full query-parameter map. Returns `None` when the
218/// required `X-Amz-*` parameters are missing or malformed.
219pub fn parse_sigv4_presigned(
220    query: &std::collections::HashMap<String, String>,
221) -> Option<ParsedSigV4> {
222    if query.get("X-Amz-Algorithm").map(|s| s.as_str()) != Some("AWS4-HMAC-SHA256") {
223        return None;
224    }
225    let credential = query.get("X-Amz-Credential")?;
226    let scope = parse_credential_scope(credential)?;
227    let date_stamp = credential.split('/').nth(1)?.to_string();
228    let signed_headers = query.get("X-Amz-SignedHeaders")?;
229    let signed_headers: Vec<String> = signed_headers
230        .split(';')
231        .map(|s| s.to_ascii_lowercase())
232        .collect();
233    let signature = query.get("X-Amz-Signature")?.clone();
234    let amz_date = query.get("X-Amz-Date")?.clone();
235
236    Some(ParsedSigV4 {
237        access_key: scope.access_key,
238        date_stamp,
239        region: scope.region,
240        service: scope.service,
241        signed_headers,
242        signature,
243        amz_date,
244        is_presigned: true,
245    })
246}
247
248/// The fields of an incoming HTTP request relevant to SigV4 verification.
249///
250/// Held separately from the full HTTP request so tests can build synthetic
251/// requests without constructing an axum/hyper payload.
252#[derive(Debug, Clone)]
253pub struct VerifyRequest<'a> {
254    pub method: &'a str,
255    /// URI path as-received (already URL-decoded once by the HTTP framework).
256    pub path: &'a str,
257    /// Query string without the leading `?`. For presigned URLs the
258    /// `X-Amz-Signature` parameter is removed before signing.
259    pub query: &'a str,
260    /// Lowercased header name → header value. Multi-valued headers should be
261    /// joined by `", "`. Built by [`headers_from_http`] for real requests.
262    pub headers: &'a [(String, String)],
263    /// Full request body. Required unless `X-Amz-Content-Sha256` is set.
264    pub body: &'a [u8],
265}
266
267/// Flatten a [`http::HeaderMap`] into the lowercase key/value slice
268/// [`VerifyRequest`] expects. Multi-valued headers are joined with `", "`.
269pub fn headers_from_http(headers: &http::HeaderMap) -> Vec<(String, String)> {
270    let mut out: std::collections::BTreeMap<String, Vec<String>> = Default::default();
271    for (name, value) in headers.iter() {
272        let key = name.as_str().to_ascii_lowercase();
273        if let Ok(v) = value.to_str() {
274            out.entry(key).or_default().push(v.to_string());
275        }
276    }
277    out.into_iter().map(|(k, vs)| (k, vs.join(", "))).collect()
278}
279
280/// Cryptographically verify that the parsed SigV4 signature matches the
281/// incoming request under the given secret access key.
282///
283/// The verification procedure is the canonical AWS SigV4 flow:
284///
285/// 1. Parse the `X-Amz-Date` and check it's within `CLOCK_SKEW_SECONDS` of
286///    `now`.
287/// 2. Build the canonical request (method, canonical URI, canonical query
288///    string, canonical headers, signed headers, hashed payload).
289/// 3. Derive the string-to-sign.
290/// 4. Derive the signing key from the secret access key via the four-step
291///    HMAC chain (`date → region → service → aws4_request`).
292/// 5. Compute the expected HMAC-SHA256 signature and compare it against
293///    `parsed.signature` in constant time.
294pub fn verify(
295    parsed: &ParsedSigV4,
296    req: &VerifyRequest<'_>,
297    secret_access_key: &str,
298    now: DateTime<Utc>,
299) -> Result<(), SigV4Error> {
300    // 1. Time-validity check. Header-signed requests must be within the fixed
301    // clock-skew window; presigned URLs instead live for their own
302    // `X-Amz-Expires` lifetime measured from the signing time (bug-audit
303    // 2026-05-28, 5.2: X-Amz-Expires was never enforced, so a 1-second URL
304    // lived the full 15-minute skew window and a legitimate 7-day URL died
305    // after 15 minutes).
306    let signed_at = parse_amz_date(&parsed.amz_date)?;
307    if parsed.is_presigned {
308        let expires = presigned_expires_secs(req.query)?;
309        if !(1..=MAX_PRESIGN_EXPIRES_SECONDS).contains(&expires) {
310            return Err(SigV4Error::InvalidPresignExpires(expires));
311        }
312        // The URL is valid from signing time until signing time + expires.
313        // A small negative drift (clock skew before signing) is tolerated via
314        // the same window AWS allows; anything past the lifetime is rejected.
315        let age = (now - signed_at).num_seconds();
316        if age < -CLOCK_SKEW_SECONDS || age > expires {
317            return Err(SigV4Error::PresignedUrlExpired {
318                signed: signed_at,
319                server: now,
320                expires_secs: expires,
321            });
322        }
323    } else {
324        let drift = (now - signed_at).num_seconds().abs();
325        if drift > CLOCK_SKEW_SECONDS {
326            return Err(SigV4Error::RequestTimeTooSkewed {
327                signed: signed_at,
328                server: now,
329            });
330        }
331    }
332
333    // 2. Canonical request.
334    let canonical_request = build_canonical_request(parsed, req)?;
335    let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
336
337    // 3. String to sign.
338    let credential_scope = format!(
339        "{}/{}/{}/aws4_request",
340        parsed.date_stamp, parsed.region, parsed.service
341    );
342    let string_to_sign = format!(
343        "AWS4-HMAC-SHA256\n{}\n{}\n{}",
344        parsed.amz_date, credential_scope, hashed_canonical
345    );
346
347    // 4. Signing key.
348    let signing_key = derive_signing_key(
349        secret_access_key,
350        &parsed.date_stamp,
351        &parsed.region,
352        &parsed.service,
353    );
354
355    // 5. Expected signature.
356    let expected = hmac_sha256(&signing_key, string_to_sign.as_bytes());
357    let expected_hex = hex::encode(expected);
358
359    if constant_time_eq(expected_hex.as_bytes(), parsed.signature.as_bytes()) {
360        Ok(())
361    } else {
362        Err(SigV4Error::SignatureMismatch)
363    }
364}
365
366/// Parse the `YYYYMMDDTHHMMSSZ` basic-ISO-8601 form AWS uses.
367fn parse_amz_date(s: &str) -> Result<DateTime<Utc>, SigV4Error> {
368    let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ")
369        .map_err(|_| SigV4Error::InvalidDate("expected YYYYMMDDTHHMMSSZ"))?;
370    Utc.from_local_datetime(&naive)
371        .single()
372        .ok_or(SigV4Error::InvalidDate("ambiguous datetime"))
373}
374
375/// The SigV4 URI-encoding character set: everything except unreserved
376/// characters (`A-Za-z0-9-_.~`). Same set for all AWS services.
377const SIGV4_URI_ENCODE: &AsciiSet = &CONTROLS
378    .add(b' ')
379    .add(b'!')
380    .add(b'"')
381    .add(b'#')
382    .add(b'$')
383    .add(b'%')
384    .add(b'&')
385    .add(b'\'')
386    .add(b'(')
387    .add(b')')
388    .add(b'*')
389    .add(b'+')
390    .add(b',')
391    .add(b'/')
392    .add(b':')
393    .add(b';')
394    .add(b'<')
395    .add(b'=')
396    .add(b'>')
397    .add(b'?')
398    .add(b'@')
399    .add(b'[')
400    .add(b'\\')
401    .add(b']')
402    .add(b'^')
403    .add(b'`')
404    .add(b'{')
405    .add(b'|')
406    .add(b'}');
407
408fn sigv4_encode(s: &str) -> String {
409    utf8_percent_encode(s, SIGV4_URI_ENCODE).to_string()
410}
411
412/// Canonicalize the URI path. S3 encodes each segment once; all other
413/// services encode twice.
414fn canonical_uri(path: &str, service: &str) -> String {
415    if path.is_empty() {
416        return "/".to_string();
417    }
418    // Split on '/' so the separators themselves aren't encoded.
419    let encoded: Vec<String> = path
420        .split('/')
421        .map(|seg| {
422            let once = sigv4_encode(seg);
423            if service == "s3" {
424                once
425            } else {
426                sigv4_encode(&once)
427            }
428        })
429        .collect();
430    encoded.join("/")
431}
432
433/// Canonicalize the query string per SigV4: URL-decode + re-encode each
434/// key/value, sort by key then value, exclude `X-Amz-Signature` for
435/// presigned URLs.
436fn canonical_query(query: &str, is_presigned: bool) -> String {
437    if query.is_empty() {
438        return String::new();
439    }
440    let mut pairs: Vec<(String, String)> = query
441        .split('&')
442        .filter_map(|kv| {
443            if kv.is_empty() {
444                return None;
445            }
446            let (k, v) = match kv.split_once('=') {
447                Some((k, v)) => (k, v),
448                None => (kv, ""),
449            };
450            // Decode then re-encode to normalize.
451            let k_dec = percent_decode(k);
452            let v_dec = percent_decode(v);
453            if is_presigned && k_dec == "X-Amz-Signature" {
454                return None;
455            }
456            Some((sigv4_encode(&k_dec), sigv4_encode(&v_dec)))
457        })
458        .collect();
459    pairs.sort();
460    pairs
461        .into_iter()
462        .map(|(k, v)| format!("{}={}", k, v))
463        .collect::<Vec<_>>()
464        .join("&")
465}
466
467fn percent_decode(s: &str) -> String {
468    percent_encoding::percent_decode_str(s)
469        .decode_utf8_lossy()
470        .into_owned()
471}
472
473fn build_canonical_request(
474    parsed: &ParsedSigV4,
475    req: &VerifyRequest<'_>,
476) -> Result<String, SigV4Error> {
477    let method = req.method.to_ascii_uppercase();
478    let canonical_path = canonical_uri(req.path, &parsed.service);
479    let canonical_qs = canonical_query(req.query, parsed.is_presigned);
480
481    // Canonical headers: lowercased name, trimmed ASCII-collapsed value,
482    // sorted by name, only those listed in `signed_headers`.
483    let header_map: BTreeMap<String, String> = req
484        .headers
485        .iter()
486        .map(|(k, v)| (k.to_ascii_lowercase(), collapse_ws(v)))
487        .collect();
488    let mut canonical_headers = String::new();
489    for name in &parsed.signed_headers {
490        let value = header_map.get(name).ok_or(SigV4Error::Malformed(
491            "signed header not present in request",
492        ))?;
493        canonical_headers.push_str(name);
494        canonical_headers.push(':');
495        canonical_headers.push_str(value);
496        canonical_headers.push('\n');
497    }
498    let signed_headers_list = parsed.signed_headers.join(";");
499
500    // Payload hash: prefer `x-amz-content-sha256` when present. S3's special
501    // values (`UNSIGNED-PAYLOAD`, `STREAMING-*`) flow through as-is and are
502    // matched against the client's signature without rehashing.
503    let payload_hash = if parsed.is_presigned {
504        // Presigned URLs always sign the empty payload hash marker on GET,
505        // or `UNSIGNED-PAYLOAD` on PUT. AWS sets `x-amz-content-sha256` to
506        // `UNSIGNED-PAYLOAD` for presigned PUT; match that.
507        header_map
508            .get("x-amz-content-sha256")
509            .cloned()
510            .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string())
511    } else if let Some(h) = header_map.get("x-amz-content-sha256") {
512        h.clone()
513    } else {
514        hex::encode(Sha256::digest(req.body))
515    };
516
517    Ok(format!(
518        "{}\n{}\n{}\n{}\n{}\n{}",
519        method, canonical_path, canonical_qs, canonical_headers, signed_headers_list, payload_hash
520    ))
521}
522
523/// Trim and collapse internal runs of whitespace per the SigV4 spec.
524fn collapse_ws(v: &str) -> String {
525    let mut out = String::with_capacity(v.len());
526    let mut in_ws = false;
527    for ch in v.trim().chars() {
528        if ch == ' ' || ch == '\t' {
529            if !in_ws {
530                out.push(' ');
531                in_ws = true;
532            }
533        } else {
534            out.push(ch);
535            in_ws = false;
536        }
537    }
538    out
539}
540
541fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
542    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
543    mac.update(data);
544    mac.finalize().into_bytes().to_vec()
545}
546
547fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
548    let k_secret = format!("AWS4{}", secret);
549    let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
550    let k_region = hmac_sha256(&k_date, region.as_bytes());
551    let k_service = hmac_sha256(&k_region, service.as_bytes());
552    hmac_sha256(&k_service, b"aws4_request")
553}
554
555fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
556    use subtle::ConstantTimeEq;
557    a.ct_eq(b).into()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    // AWS documentation's canonical example from
565    // https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
566    // GetSessionToken request, us-east-1, iam service.
567    // Obviously-synthetic secret — real AWS example strings trip GitHub's
568    // secret-scanning push protection.
569    const AWS_EXAMPLE_SECRET: &str = "testtesttesttesttesttesttesttesttesttest";
570    const AWS_EXAMPLE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
571
572    #[test]
573    fn parse_valid_header() {
574        let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123";
575        let info = parse_sigv4(header).unwrap();
576        assert_eq!(info.access_key, "AKIAIOSFODNN7EXAMPLE");
577        assert_eq!(info.region, "us-east-1");
578        assert_eq!(info.service, "sqs");
579    }
580
581    #[test]
582    fn parse_credential_only_no_trailing_parts() {
583        let header =
584            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/rds/aws4_request";
585        let info = parse_sigv4(header).unwrap();
586        assert_eq!(info.service, "rds");
587        assert_eq!(info.region, "us-east-1");
588    }
589
590    #[test]
591    fn parse_full_header_extracts_all_fields() {
592        let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
593        let parsed = parse_sigv4_header(header, Some("20260101T000000Z")).unwrap();
594        assert_eq!(parsed.access_key, "AKIAIOSFODNN7EXAMPLE");
595        assert_eq!(parsed.date_stamp, "20260101");
596        assert_eq!(parsed.signed_headers, vec!["host", "x-amz-date"]);
597        assert_eq!(parsed.signature, "deadbeef");
598        assert!(!parsed.is_presigned);
599    }
600
601    #[test]
602    fn parse_presigned_query_extracts_all_fields() {
603        let mut q = std::collections::HashMap::new();
604        q.insert(
605            "X-Amz-Algorithm".to_string(),
606            "AWS4-HMAC-SHA256".to_string(),
607        );
608        q.insert(
609            "X-Amz-Credential".to_string(),
610            "AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/s3/aws4_request".to_string(),
611        );
612        q.insert("X-Amz-Date".to_string(), "20260101T000000Z".to_string());
613        q.insert("X-Amz-SignedHeaders".to_string(), "host".to_string());
614        q.insert("X-Amz-Signature".to_string(), "cafe".to_string());
615        let parsed = parse_sigv4_presigned(&q).unwrap();
616        assert_eq!(parsed.service, "s3");
617        assert!(parsed.is_presigned);
618        assert_eq!(parsed.signature, "cafe");
619    }
620
621    #[test]
622    fn returns_none_for_invalid() {
623        assert!(parse_sigv4("Bearer token123").is_none());
624        assert!(parse_sigv4("").is_none());
625    }
626
627    #[test]
628    fn canonical_uri_non_s3_double_encodes() {
629        assert_eq!(canonical_uri("/foo bar", "iam"), "/foo%2520bar");
630        // path with slash stays as-is
631        assert_eq!(canonical_uri("/a/b", "iam"), "/a/b");
632    }
633
634    #[test]
635    fn canonical_uri_s3_single_encodes() {
636        assert_eq!(canonical_uri("/foo bar", "s3"), "/foo%20bar");
637    }
638
639    #[test]
640    fn canonical_query_sorts_and_drops_presigned_signature() {
641        let q = "X-Amz-Signature=ignored&B=2&A=1";
642        assert_eq!(canonical_query(q, true), "A=1&B=2");
643        assert_eq!(canonical_query(q, false), "A=1&B=2&X-Amz-Signature=ignored");
644    }
645
646    #[test]
647    fn derive_signing_key_is_deterministic_and_stable() {
648        // Regression guard: fix the derivation output for a known set of
649        // inputs so any future refactor that changes the HMAC chain is
650        // caught. The value is the hex HMAC-SHA256 of `aws4_request` under
651        // the four-step AWS4 → date → region → service → aws4_request chain,
652        // computed by this same function — the goal is stability over time,
653        // not agreement with an external reference.
654        let key = derive_signing_key(AWS_EXAMPLE_SECRET, "20150830", "us-east-1", "iam");
655        assert_eq!(
656            hex::encode(&key),
657            "0d041c02f01817181204845091e3445c37d6f6b200833f52d34d682b2005918a"
658        );
659        // Sanity: swapping any input changes the output.
660        let diff = derive_signing_key(AWS_EXAMPLE_SECRET, "20150831", "us-east-1", "iam");
661        assert_ne!(key, diff);
662    }
663
664    #[test]
665    fn verify_rejects_skewed_clock() {
666        // Signature content is irrelevant here; clock check runs first.
667        let parsed = ParsedSigV4 {
668            access_key: "X".into(),
669            date_stamp: "20260101".into(),
670            region: "us-east-1".into(),
671            service: "iam".into(),
672            signed_headers: vec!["host".into()],
673            signature: "00".into(),
674            amz_date: "20260101T000000Z".into(),
675            is_presigned: false,
676        };
677        let req = VerifyRequest {
678            method: "GET",
679            path: "/",
680            query: "",
681            headers: &[("host".into(), "iam.amazonaws.com".into())],
682            body: b"",
683        };
684        let server_now = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
685        let result = verify(&parsed, &req, AWS_EXAMPLE_SECRET, server_now);
686        assert!(matches!(
687            result,
688            Err(SigV4Error::RequestTimeTooSkewed { .. })
689        ));
690    }
691
692    #[test]
693    fn verify_round_trip_matches_self_computed_signature() {
694        // Build a request, compute the signature using the same derivation
695        // `verify` uses, then assert `verify` accepts it.
696        let secret = AWS_EXAMPLE_SECRET;
697        let date_stamp = "20260101";
698        let amz_date = "20260101T120000Z";
699        let region = "us-east-1";
700        let service = "iam";
701        let method = "GET";
702        let path = "/";
703        let query = "Action=GetUser&Version=2010-05-08";
704        let headers = vec![
705            ("host".to_string(), "iam.amazonaws.com".to_string()),
706            ("x-amz-date".to_string(), amz_date.to_string()),
707        ];
708        let body: &[u8] = b"";
709
710        // Build canonical request manually, matching `build_canonical_request`.
711        let canonical_request = {
712            let mut parsed = ParsedSigV4 {
713                access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
714                date_stamp: date_stamp.into(),
715                region: region.into(),
716                service: service.into(),
717                signed_headers: vec!["host".into(), "x-amz-date".into()],
718                signature: String::new(),
719                amz_date: amz_date.into(),
720                is_presigned: false,
721            };
722            let req = VerifyRequest {
723                method,
724                path,
725                query,
726                headers: &headers,
727                body,
728            };
729            let cr = build_canonical_request(&parsed, &req).unwrap();
730            parsed.signature = {
731                let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
732                let sts = format!(
733                    "AWS4-HMAC-SHA256\n{}\n{}\n{}",
734                    amz_date,
735                    scope,
736                    hex::encode(Sha256::digest(cr.as_bytes()))
737                );
738                let sk = derive_signing_key(secret, date_stamp, region, service);
739                hex::encode(hmac_sha256(&sk, sts.as_bytes()))
740            };
741            parsed
742        };
743
744        let req = VerifyRequest {
745            method,
746            path,
747            query,
748            headers: &headers,
749            body,
750        };
751        let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
752        verify(&canonical_request, &req, secret, now).unwrap();
753    }
754
755    #[test]
756    fn verify_rejects_tampered_body() {
757        let secret = AWS_EXAMPLE_SECRET;
758        let date_stamp = "20260101";
759        let amz_date = "20260101T120000Z";
760        let region = "us-east-1";
761        let service = "iam";
762        let method = "POST";
763        let path = "/";
764        let query = "";
765        let headers = vec![
766            ("host".to_string(), "iam.amazonaws.com".to_string()),
767            ("x-amz-date".to_string(), amz_date.to_string()),
768        ];
769        let original_body: &[u8] = b"Action=ListUsers&Version=2010-05-08";
770
771        // Sign the original body.
772        let mut parsed = ParsedSigV4 {
773            access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
774            date_stamp: date_stamp.into(),
775            region: region.into(),
776            service: service.into(),
777            signed_headers: vec!["host".into(), "x-amz-date".into()],
778            signature: String::new(),
779            amz_date: amz_date.into(),
780            is_presigned: false,
781        };
782        let signing_req = VerifyRequest {
783            method,
784            path,
785            query,
786            headers: &headers,
787            body: original_body,
788        };
789        let cr = build_canonical_request(&parsed, &signing_req).unwrap();
790        let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
791        let sts = format!(
792            "AWS4-HMAC-SHA256\n{}\n{}\n{}",
793            amz_date,
794            scope,
795            hex::encode(Sha256::digest(cr.as_bytes()))
796        );
797        let sk = derive_signing_key(secret, date_stamp, region, service);
798        parsed.signature = hex::encode(hmac_sha256(&sk, sts.as_bytes()));
799
800        // Verify against a tampered body.
801        let tampered = VerifyRequest {
802            method,
803            path,
804            query,
805            headers: &headers,
806            body: b"Action=DeleteUser&Version=2010-05-08",
807        };
808        let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
809        assert!(matches!(
810            verify(&parsed, &tampered, secret, now),
811            Err(SigV4Error::SignatureMismatch)
812        ));
813    }
814
815    #[test]
816    fn collapse_ws_normalizes_runs() {
817        assert_eq!(collapse_ws("  foo   bar  "), "foo bar");
818        assert_eq!(collapse_ws("foo\tbar"), "foo bar");
819    }
820}