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}
95
96/// Maximum allowed drift between the request's `X-Amz-Date` and the server's
97/// wall clock. Matches the 15-minute window AWS uses.
98pub const CLOCK_SKEW_SECONDS: i64 = 15 * 60;
99
100/// Legacy routing-only parse. Extracts access key, region, and service from
101/// either an `Authorization: AWS4-HMAC-SHA256 …` header or a presigned URL's
102/// `X-Amz-Credential` component.
103///
104/// Returns `None` when the input isn't a recognizable SigV4 credential.
105pub fn parse_sigv4(auth_header: &str) -> Option<SigV4Info> {
106    parse_header_credential(auth_header)
107}
108
109fn parse_header_credential(auth_header: &str) -> Option<SigV4Info> {
110    let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
111    let credential_start = auth.find("Credential=")?;
112    let credential_value = &auth[credential_start + "Credential=".len()..];
113    let credential_end = credential_value.find(',')?;
114    let credential = &credential_value[..credential_end];
115    parse_credential_scope(credential)
116}
117
118fn parse_credential_scope(credential: &str) -> Option<SigV4Info> {
119    let parts: Vec<&str> = credential.split('/').collect();
120    if parts.len() != 5 || parts[4] != "aws4_request" {
121        return None;
122    }
123    Some(SigV4Info {
124        access_key: parts[0].to_string(),
125        region: parts[2].to_string(),
126        service: parts[3].to_string(),
127    })
128}
129
130/// Full parse of an `Authorization: AWS4-HMAC-SHA256 …` header.
131///
132/// Returns `None` if the header is missing required components. The returned
133/// value is suitable for both routing (`as_info`) and signature verification
134/// (`verify`). The caller must also supply the request's `X-Amz-Date` header
135/// because it isn't embedded in the credential — it's carried separately.
136pub fn parse_sigv4_header(auth_header: &str, amz_date: Option<&str>) -> Option<ParsedSigV4> {
137    let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
138
139    // Each field is comma-separated and may or may not be preceded by a space.
140    let mut credential: Option<&str> = None;
141    let mut signed_headers: Option<&str> = None;
142    let mut signature: Option<&str> = None;
143    for part in auth.split(',') {
144        let part = part.trim();
145        if let Some(v) = part.strip_prefix("Credential=") {
146            credential = Some(v);
147        } else if let Some(v) = part.strip_prefix("SignedHeaders=") {
148            signed_headers = Some(v);
149        } else if let Some(v) = part.strip_prefix("Signature=") {
150            signature = Some(v);
151        }
152    }
153
154    let credential = credential?;
155    let signed_headers = signed_headers?;
156    let signature = signature?;
157    let scope = parse_credential_scope(credential)?;
158    let date_stamp = credential.split('/').nth(1)?.to_string();
159    let signed_headers: Vec<String> = signed_headers
160        .split(';')
161        .map(|s| s.to_ascii_lowercase())
162        .collect();
163    if signed_headers.is_empty() {
164        return None;
165    }
166
167    Some(ParsedSigV4 {
168        access_key: scope.access_key,
169        date_stamp,
170        region: scope.region,
171        service: scope.service,
172        signed_headers,
173        signature: signature.to_string(),
174        amz_date: amz_date?.to_string(),
175        is_presigned: false,
176    })
177}
178
179/// Full parse of a presigned URL's SigV4 query parameters.
180///
181/// Expects to be given the full query-parameter map. Returns `None` when the
182/// required `X-Amz-*` parameters are missing or malformed.
183pub fn parse_sigv4_presigned(
184    query: &std::collections::HashMap<String, String>,
185) -> Option<ParsedSigV4> {
186    if query.get("X-Amz-Algorithm").map(|s| s.as_str()) != Some("AWS4-HMAC-SHA256") {
187        return None;
188    }
189    let credential = query.get("X-Amz-Credential")?;
190    let scope = parse_credential_scope(credential)?;
191    let date_stamp = credential.split('/').nth(1)?.to_string();
192    let signed_headers = query.get("X-Amz-SignedHeaders")?;
193    let signed_headers: Vec<String> = signed_headers
194        .split(';')
195        .map(|s| s.to_ascii_lowercase())
196        .collect();
197    let signature = query.get("X-Amz-Signature")?.clone();
198    let amz_date = query.get("X-Amz-Date")?.clone();
199
200    Some(ParsedSigV4 {
201        access_key: scope.access_key,
202        date_stamp,
203        region: scope.region,
204        service: scope.service,
205        signed_headers,
206        signature,
207        amz_date,
208        is_presigned: true,
209    })
210}
211
212/// The fields of an incoming HTTP request relevant to SigV4 verification.
213///
214/// Held separately from the full HTTP request so tests can build synthetic
215/// requests without constructing an axum/hyper payload.
216#[derive(Debug, Clone)]
217pub struct VerifyRequest<'a> {
218    pub method: &'a str,
219    /// URI path as-received (already URL-decoded once by the HTTP framework).
220    pub path: &'a str,
221    /// Query string without the leading `?`. For presigned URLs the
222    /// `X-Amz-Signature` parameter is removed before signing.
223    pub query: &'a str,
224    /// Lowercased header name → header value. Multi-valued headers should be
225    /// joined by `", "`. Built by [`headers_from_http`] for real requests.
226    pub headers: &'a [(String, String)],
227    /// Full request body. Required unless `X-Amz-Content-Sha256` is set.
228    pub body: &'a [u8],
229}
230
231/// Flatten a [`http::HeaderMap`] into the lowercase key/value slice
232/// [`VerifyRequest`] expects. Multi-valued headers are joined with `", "`.
233pub fn headers_from_http(headers: &http::HeaderMap) -> Vec<(String, String)> {
234    let mut out: std::collections::BTreeMap<String, Vec<String>> = Default::default();
235    for (name, value) in headers.iter() {
236        let key = name.as_str().to_ascii_lowercase();
237        if let Ok(v) = value.to_str() {
238            out.entry(key).or_default().push(v.to_string());
239        }
240    }
241    out.into_iter().map(|(k, vs)| (k, vs.join(", "))).collect()
242}
243
244/// Cryptographically verify that the parsed SigV4 signature matches the
245/// incoming request under the given secret access key.
246///
247/// The verification procedure is the canonical AWS SigV4 flow:
248///
249/// 1. Parse the `X-Amz-Date` and check it's within `CLOCK_SKEW_SECONDS` of
250///    `now`.
251/// 2. Build the canonical request (method, canonical URI, canonical query
252///    string, canonical headers, signed headers, hashed payload).
253/// 3. Derive the string-to-sign.
254/// 4. Derive the signing key from the secret access key via the four-step
255///    HMAC chain (`date → region → service → aws4_request`).
256/// 5. Compute the expected HMAC-SHA256 signature and compare it against
257///    `parsed.signature` in constant time.
258pub fn verify(
259    parsed: &ParsedSigV4,
260    req: &VerifyRequest<'_>,
261    secret_access_key: &str,
262    now: DateTime<Utc>,
263) -> Result<(), SigV4Error> {
264    // 1. Clock-skew check.
265    let signed_at = parse_amz_date(&parsed.amz_date)?;
266    let drift = (now - signed_at).num_seconds().abs();
267    if drift > CLOCK_SKEW_SECONDS {
268        return Err(SigV4Error::RequestTimeTooSkewed {
269            signed: signed_at,
270            server: now,
271        });
272    }
273
274    // 2. Canonical request.
275    let canonical_request = build_canonical_request(parsed, req)?;
276    let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
277
278    // 3. String to sign.
279    let credential_scope = format!(
280        "{}/{}/{}/aws4_request",
281        parsed.date_stamp, parsed.region, parsed.service
282    );
283    let string_to_sign = format!(
284        "AWS4-HMAC-SHA256\n{}\n{}\n{}",
285        parsed.amz_date, credential_scope, hashed_canonical
286    );
287
288    // 4. Signing key.
289    let signing_key = derive_signing_key(
290        secret_access_key,
291        &parsed.date_stamp,
292        &parsed.region,
293        &parsed.service,
294    );
295
296    // 5. Expected signature.
297    let expected = hmac_sha256(&signing_key, string_to_sign.as_bytes());
298    let expected_hex = hex::encode(expected);
299
300    if constant_time_eq(expected_hex.as_bytes(), parsed.signature.as_bytes()) {
301        Ok(())
302    } else {
303        Err(SigV4Error::SignatureMismatch)
304    }
305}
306
307/// Parse the `YYYYMMDDTHHMMSSZ` basic-ISO-8601 form AWS uses.
308fn parse_amz_date(s: &str) -> Result<DateTime<Utc>, SigV4Error> {
309    let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ")
310        .map_err(|_| SigV4Error::InvalidDate("expected YYYYMMDDTHHMMSSZ"))?;
311    Utc.from_local_datetime(&naive)
312        .single()
313        .ok_or(SigV4Error::InvalidDate("ambiguous datetime"))
314}
315
316/// The SigV4 URI-encoding character set: everything except unreserved
317/// characters (`A-Za-z0-9-_.~`). Same set for all AWS services.
318const SIGV4_URI_ENCODE: &AsciiSet = &CONTROLS
319    .add(b' ')
320    .add(b'!')
321    .add(b'"')
322    .add(b'#')
323    .add(b'$')
324    .add(b'%')
325    .add(b'&')
326    .add(b'\'')
327    .add(b'(')
328    .add(b')')
329    .add(b'*')
330    .add(b'+')
331    .add(b',')
332    .add(b'/')
333    .add(b':')
334    .add(b';')
335    .add(b'<')
336    .add(b'=')
337    .add(b'>')
338    .add(b'?')
339    .add(b'@')
340    .add(b'[')
341    .add(b'\\')
342    .add(b']')
343    .add(b'^')
344    .add(b'`')
345    .add(b'{')
346    .add(b'|')
347    .add(b'}');
348
349fn sigv4_encode(s: &str) -> String {
350    utf8_percent_encode(s, SIGV4_URI_ENCODE).to_string()
351}
352
353/// Canonicalize the URI path. S3 encodes each segment once; all other
354/// services encode twice.
355fn canonical_uri(path: &str, service: &str) -> String {
356    if path.is_empty() {
357        return "/".to_string();
358    }
359    // Split on '/' so the separators themselves aren't encoded.
360    let encoded: Vec<String> = path
361        .split('/')
362        .map(|seg| {
363            let once = sigv4_encode(seg);
364            if service == "s3" {
365                once
366            } else {
367                sigv4_encode(&once)
368            }
369        })
370        .collect();
371    encoded.join("/")
372}
373
374/// Canonicalize the query string per SigV4: URL-decode + re-encode each
375/// key/value, sort by key then value, exclude `X-Amz-Signature` for
376/// presigned URLs.
377fn canonical_query(query: &str, is_presigned: bool) -> String {
378    if query.is_empty() {
379        return String::new();
380    }
381    let mut pairs: Vec<(String, String)> = query
382        .split('&')
383        .filter_map(|kv| {
384            if kv.is_empty() {
385                return None;
386            }
387            let (k, v) = match kv.split_once('=') {
388                Some((k, v)) => (k, v),
389                None => (kv, ""),
390            };
391            // Decode then re-encode to normalize.
392            let k_dec = percent_decode(k);
393            let v_dec = percent_decode(v);
394            if is_presigned && k_dec == "X-Amz-Signature" {
395                return None;
396            }
397            Some((sigv4_encode(&k_dec), sigv4_encode(&v_dec)))
398        })
399        .collect();
400    pairs.sort();
401    pairs
402        .into_iter()
403        .map(|(k, v)| format!("{}={}", k, v))
404        .collect::<Vec<_>>()
405        .join("&")
406}
407
408fn percent_decode(s: &str) -> String {
409    percent_encoding::percent_decode_str(s)
410        .decode_utf8_lossy()
411        .into_owned()
412}
413
414fn build_canonical_request(
415    parsed: &ParsedSigV4,
416    req: &VerifyRequest<'_>,
417) -> Result<String, SigV4Error> {
418    let method = req.method.to_ascii_uppercase();
419    let canonical_path = canonical_uri(req.path, &parsed.service);
420    let canonical_qs = canonical_query(req.query, parsed.is_presigned);
421
422    // Canonical headers: lowercased name, trimmed ASCII-collapsed value,
423    // sorted by name, only those listed in `signed_headers`.
424    let header_map: BTreeMap<String, String> = req
425        .headers
426        .iter()
427        .map(|(k, v)| (k.to_ascii_lowercase(), collapse_ws(v)))
428        .collect();
429    let mut canonical_headers = String::new();
430    for name in &parsed.signed_headers {
431        let value = header_map.get(name).ok_or(SigV4Error::Malformed(
432            "signed header not present in request",
433        ))?;
434        canonical_headers.push_str(name);
435        canonical_headers.push(':');
436        canonical_headers.push_str(value);
437        canonical_headers.push('\n');
438    }
439    let signed_headers_list = parsed.signed_headers.join(";");
440
441    // Payload hash: prefer `x-amz-content-sha256` when present. S3's special
442    // values (`UNSIGNED-PAYLOAD`, `STREAMING-*`) flow through as-is and are
443    // matched against the client's signature without rehashing.
444    let payload_hash = if parsed.is_presigned {
445        // Presigned URLs always sign the empty payload hash marker on GET,
446        // or `UNSIGNED-PAYLOAD` on PUT. AWS sets `x-amz-content-sha256` to
447        // `UNSIGNED-PAYLOAD` for presigned PUT; match that.
448        header_map
449            .get("x-amz-content-sha256")
450            .cloned()
451            .unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string())
452    } else if let Some(h) = header_map.get("x-amz-content-sha256") {
453        h.clone()
454    } else {
455        hex::encode(Sha256::digest(req.body))
456    };
457
458    Ok(format!(
459        "{}\n{}\n{}\n{}\n{}\n{}",
460        method, canonical_path, canonical_qs, canonical_headers, signed_headers_list, payload_hash
461    ))
462}
463
464/// Trim and collapse internal runs of whitespace per the SigV4 spec.
465fn collapse_ws(v: &str) -> String {
466    let mut out = String::with_capacity(v.len());
467    let mut in_ws = false;
468    for ch in v.trim().chars() {
469        if ch == ' ' || ch == '\t' {
470            if !in_ws {
471                out.push(' ');
472                in_ws = true;
473            }
474        } else {
475            out.push(ch);
476            in_ws = false;
477        }
478    }
479    out
480}
481
482fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
483    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
484    mac.update(data);
485    mac.finalize().into_bytes().to_vec()
486}
487
488fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
489    let k_secret = format!("AWS4{}", secret);
490    let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
491    let k_region = hmac_sha256(&k_date, region.as_bytes());
492    let k_service = hmac_sha256(&k_region, service.as_bytes());
493    hmac_sha256(&k_service, b"aws4_request")
494}
495
496fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
497    use subtle::ConstantTimeEq;
498    a.ct_eq(b).into()
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    // AWS documentation's canonical example from
506    // https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
507    // GetSessionToken request, us-east-1, iam service.
508    // Obviously-synthetic secret — real AWS example strings trip GitHub's
509    // secret-scanning push protection.
510    const AWS_EXAMPLE_SECRET: &str = "testtesttesttesttesttesttesttesttesttest";
511    const AWS_EXAMPLE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
512
513    #[test]
514    fn parse_valid_header() {
515        let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123";
516        let info = parse_sigv4(header).unwrap();
517        assert_eq!(info.access_key, "AKIAIOSFODNN7EXAMPLE");
518        assert_eq!(info.region, "us-east-1");
519        assert_eq!(info.service, "sqs");
520    }
521
522    #[test]
523    fn parse_full_header_extracts_all_fields() {
524        let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
525        let parsed = parse_sigv4_header(header, Some("20260101T000000Z")).unwrap();
526        assert_eq!(parsed.access_key, "AKIAIOSFODNN7EXAMPLE");
527        assert_eq!(parsed.date_stamp, "20260101");
528        assert_eq!(parsed.signed_headers, vec!["host", "x-amz-date"]);
529        assert_eq!(parsed.signature, "deadbeef");
530        assert!(!parsed.is_presigned);
531    }
532
533    #[test]
534    fn parse_presigned_query_extracts_all_fields() {
535        let mut q = std::collections::HashMap::new();
536        q.insert(
537            "X-Amz-Algorithm".to_string(),
538            "AWS4-HMAC-SHA256".to_string(),
539        );
540        q.insert(
541            "X-Amz-Credential".to_string(),
542            "AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/s3/aws4_request".to_string(),
543        );
544        q.insert("X-Amz-Date".to_string(), "20260101T000000Z".to_string());
545        q.insert("X-Amz-SignedHeaders".to_string(), "host".to_string());
546        q.insert("X-Amz-Signature".to_string(), "cafe".to_string());
547        let parsed = parse_sigv4_presigned(&q).unwrap();
548        assert_eq!(parsed.service, "s3");
549        assert!(parsed.is_presigned);
550        assert_eq!(parsed.signature, "cafe");
551    }
552
553    #[test]
554    fn returns_none_for_invalid() {
555        assert!(parse_sigv4("Bearer token123").is_none());
556        assert!(parse_sigv4("").is_none());
557    }
558
559    #[test]
560    fn canonical_uri_non_s3_double_encodes() {
561        assert_eq!(canonical_uri("/foo bar", "iam"), "/foo%2520bar");
562        // path with slash stays as-is
563        assert_eq!(canonical_uri("/a/b", "iam"), "/a/b");
564    }
565
566    #[test]
567    fn canonical_uri_s3_single_encodes() {
568        assert_eq!(canonical_uri("/foo bar", "s3"), "/foo%20bar");
569    }
570
571    #[test]
572    fn canonical_query_sorts_and_drops_presigned_signature() {
573        let q = "X-Amz-Signature=ignored&B=2&A=1";
574        assert_eq!(canonical_query(q, true), "A=1&B=2");
575        assert_eq!(canonical_query(q, false), "A=1&B=2&X-Amz-Signature=ignored");
576    }
577
578    #[test]
579    fn derive_signing_key_is_deterministic_and_stable() {
580        // Regression guard: fix the derivation output for a known set of
581        // inputs so any future refactor that changes the HMAC chain is
582        // caught. The value is the hex HMAC-SHA256 of `aws4_request` under
583        // the four-step AWS4 → date → region → service → aws4_request chain,
584        // computed by this same function — the goal is stability over time,
585        // not agreement with an external reference.
586        let key = derive_signing_key(AWS_EXAMPLE_SECRET, "20150830", "us-east-1", "iam");
587        assert_eq!(
588            hex::encode(&key),
589            "0d041c02f01817181204845091e3445c37d6f6b200833f52d34d682b2005918a"
590        );
591        // Sanity: swapping any input changes the output.
592        let diff = derive_signing_key(AWS_EXAMPLE_SECRET, "20150831", "us-east-1", "iam");
593        assert_ne!(key, diff);
594    }
595
596    #[test]
597    fn verify_rejects_skewed_clock() {
598        // Signature content is irrelevant here; clock check runs first.
599        let parsed = ParsedSigV4 {
600            access_key: "X".into(),
601            date_stamp: "20260101".into(),
602            region: "us-east-1".into(),
603            service: "iam".into(),
604            signed_headers: vec!["host".into()],
605            signature: "00".into(),
606            amz_date: "20260101T000000Z".into(),
607            is_presigned: false,
608        };
609        let req = VerifyRequest {
610            method: "GET",
611            path: "/",
612            query: "",
613            headers: &[("host".into(), "iam.amazonaws.com".into())],
614            body: b"",
615        };
616        let server_now = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
617        let result = verify(&parsed, &req, AWS_EXAMPLE_SECRET, server_now);
618        assert!(matches!(
619            result,
620            Err(SigV4Error::RequestTimeTooSkewed { .. })
621        ));
622    }
623
624    #[test]
625    fn verify_round_trip_matches_self_computed_signature() {
626        // Build a request, compute the signature using the same derivation
627        // `verify` uses, then assert `verify` accepts it.
628        let secret = AWS_EXAMPLE_SECRET;
629        let date_stamp = "20260101";
630        let amz_date = "20260101T120000Z";
631        let region = "us-east-1";
632        let service = "iam";
633        let method = "GET";
634        let path = "/";
635        let query = "Action=GetUser&Version=2010-05-08";
636        let headers = vec![
637            ("host".to_string(), "iam.amazonaws.com".to_string()),
638            ("x-amz-date".to_string(), amz_date.to_string()),
639        ];
640        let body: &[u8] = b"";
641
642        // Build canonical request manually, matching `build_canonical_request`.
643        let canonical_request = {
644            let mut parsed = ParsedSigV4 {
645                access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
646                date_stamp: date_stamp.into(),
647                region: region.into(),
648                service: service.into(),
649                signed_headers: vec!["host".into(), "x-amz-date".into()],
650                signature: String::new(),
651                amz_date: amz_date.into(),
652                is_presigned: false,
653            };
654            let req = VerifyRequest {
655                method,
656                path,
657                query,
658                headers: &headers,
659                body,
660            };
661            let cr = build_canonical_request(&parsed, &req).unwrap();
662            parsed.signature = {
663                let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
664                let sts = format!(
665                    "AWS4-HMAC-SHA256\n{}\n{}\n{}",
666                    amz_date,
667                    scope,
668                    hex::encode(Sha256::digest(cr.as_bytes()))
669                );
670                let sk = derive_signing_key(secret, date_stamp, region, service);
671                hex::encode(hmac_sha256(&sk, sts.as_bytes()))
672            };
673            parsed
674        };
675
676        let req = VerifyRequest {
677            method,
678            path,
679            query,
680            headers: &headers,
681            body,
682        };
683        let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
684        verify(&canonical_request, &req, secret, now).unwrap();
685    }
686
687    #[test]
688    fn verify_rejects_tampered_body() {
689        let secret = AWS_EXAMPLE_SECRET;
690        let date_stamp = "20260101";
691        let amz_date = "20260101T120000Z";
692        let region = "us-east-1";
693        let service = "iam";
694        let method = "POST";
695        let path = "/";
696        let query = "";
697        let headers = vec![
698            ("host".to_string(), "iam.amazonaws.com".to_string()),
699            ("x-amz-date".to_string(), amz_date.to_string()),
700        ];
701        let original_body: &[u8] = b"Action=ListUsers&Version=2010-05-08";
702
703        // Sign the original body.
704        let mut parsed = ParsedSigV4 {
705            access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
706            date_stamp: date_stamp.into(),
707            region: region.into(),
708            service: service.into(),
709            signed_headers: vec!["host".into(), "x-amz-date".into()],
710            signature: String::new(),
711            amz_date: amz_date.into(),
712            is_presigned: false,
713        };
714        let signing_req = VerifyRequest {
715            method,
716            path,
717            query,
718            headers: &headers,
719            body: original_body,
720        };
721        let cr = build_canonical_request(&parsed, &signing_req).unwrap();
722        let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
723        let sts = format!(
724            "AWS4-HMAC-SHA256\n{}\n{}\n{}",
725            amz_date,
726            scope,
727            hex::encode(Sha256::digest(cr.as_bytes()))
728        );
729        let sk = derive_signing_key(secret, date_stamp, region, service);
730        parsed.signature = hex::encode(hmac_sha256(&sk, sts.as_bytes()));
731
732        // Verify against a tampered body.
733        let tampered = VerifyRequest {
734            method,
735            path,
736            query,
737            headers: &headers,
738            body: b"Action=DeleteUser&Version=2010-05-08",
739        };
740        let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
741        assert!(matches!(
742            verify(&parsed, &tampered, secret, now),
743            Err(SigV4Error::SignatureMismatch)
744        ));
745    }
746
747    #[test]
748    fn collapse_ws_normalizes_runs() {
749        assert_eq!(collapse_ws("  foo   bar  "), "foo bar");
750        assert_eq!(collapse_ws("foo\tbar"), "foo bar");
751    }
752}