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