Skip to main content

rustack_auth/
sigv4.rs

1//! AWS Signature Version 4 verification.
2//!
3//! This module implements the core SigV4 signature verification flow:
4//!
5//! 1. Parse the `Authorization` header to extract the algorithm, credential scope, signed headers,
6//!    and provided signature.
7//! 2. Reconstruct the canonical request from the HTTP request parts.
8//! 3. Build the string to sign from the timestamp, credential scope, and canonical request hash.
9//! 4. Derive the signing key using HMAC-SHA256 from the secret key and credential scope components.
10//! 5. Compute the expected signature and compare it to the provided signature using constant-time
11//!    comparison.
12//!
13//! The main entry point is [`verify_sigv4`].
14
15use hmac::{Hmac, KeyInit, Mac};
16use sha2::{Digest, Sha256};
17use subtle::ConstantTimeEq;
18use tracing::debug;
19
20use crate::{
21    canonical::build_canonical_request, credentials::CredentialProvider, error::AuthError,
22};
23
24/// The only algorithm supported by this implementation.
25const SUPPORTED_ALGORITHM: &str = "AWS4-HMAC-SHA256";
26
27type HmacSha256 = Hmac<Sha256>;
28
29/// The result of a successful SigV4 verification.
30#[derive(Debug, Clone)]
31pub struct AuthResult {
32    /// The access key ID that signed the request.
33    pub access_key_id: String,
34    /// The AWS region from the credential scope.
35    pub region: String,
36    /// The AWS service from the credential scope.
37    pub service: String,
38    /// The list of headers that were included in the signature.
39    pub signed_headers: Vec<String>,
40}
41
42/// Parsed components of an AWS SigV4 `Authorization` header.
43///
44/// Format:
45/// ```text
46/// AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1/s3/aws4_request,
47///   SignedHeaders=host;x-amz-content-sha256;x-amz-date,
48///   Signature=<hex-signature>
49/// ```
50#[derive(Debug, Clone)]
51pub struct ParsedAuth {
52    /// The signing algorithm (must be `AWS4-HMAC-SHA256`).
53    pub algorithm: String,
54    /// The access key ID.
55    pub access_key_id: String,
56    /// The date component of the credential scope (YYYYMMDD).
57    pub date: String,
58    /// The AWS region from the credential scope.
59    pub region: String,
60    /// The AWS service from the credential scope.
61    pub service: String,
62    /// The list of signed header names (lowercase).
63    pub signed_headers: Vec<String>,
64    /// The hex-encoded signature.
65    pub signature: String,
66}
67
68/// Parse an AWS SigV4 `Authorization` header value into its components.
69///
70/// # Errors
71///
72/// Returns [`AuthError::InvalidAuthHeader`] if the header format is invalid,
73/// or [`AuthError::UnsupportedAlgorithm`] if the algorithm is not `AWS4-HMAC-SHA256`.
74pub fn parse_authorization_header(header: &str) -> Result<ParsedAuth, AuthError> {
75    // Split algorithm from the rest: "AWS4-HMAC-SHA256
76    // Credential=...,SignedHeaders=...,Signature=..."
77    let (algorithm, rest) = header.split_once(' ').ok_or(AuthError::InvalidAuthHeader)?;
78
79    if algorithm != SUPPORTED_ALGORITHM {
80        return Err(AuthError::UnsupportedAlgorithm(algorithm.to_owned()));
81    }
82
83    // Parse the key=value pairs separated by ", " or ","
84    let mut credential = None;
85    let mut signed_headers = None;
86    let mut signature = None;
87
88    for part in rest.split(',') {
89        let part = part.trim();
90        if let Some(value) = part.strip_prefix("Credential=") {
91            credential = Some(value);
92        } else if let Some(value) = part.strip_prefix("SignedHeaders=") {
93            signed_headers = Some(value);
94        } else if let Some(value) = part.strip_prefix("Signature=") {
95            signature = Some(value);
96        }
97    }
98
99    let credential = credential.ok_or(AuthError::InvalidAuthHeader)?;
100    let signed_headers = signed_headers.ok_or(AuthError::InvalidAuthHeader)?;
101    let signature = signature.ok_or(AuthError::InvalidAuthHeader)?;
102
103    // Parse credential: AKID/date/region/service/aws4_request
104    let cred_parts: Vec<&str> = credential.splitn(5, '/').collect();
105    if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" {
106        return Err(AuthError::InvalidCredential);
107    }
108
109    let parsed_signed_headers: Vec<String> =
110        signed_headers.split(';').map(ToOwned::to_owned).collect();
111
112    Ok(ParsedAuth {
113        algorithm: algorithm.to_owned(),
114        access_key_id: cred_parts[0].to_owned(),
115        date: cred_parts[1].to_owned(),
116        region: cred_parts[2].to_owned(),
117        service: cred_parts[3].to_owned(),
118        signed_headers: parsed_signed_headers,
119        signature: signature.to_owned(),
120    })
121}
122
123/// Build the SigV4 string to sign.
124///
125/// Format:
126/// ```text
127/// AWS4-HMAC-SHA256\n
128/// <ISO8601 timestamp>\n
129/// <credential_scope>\n
130/// <hex(SHA256(canonical_request))>
131/// ```
132///
133/// # Examples
134///
135/// ```
136/// use rustack_auth::sigv4::build_string_to_sign;
137///
138/// let sts = build_string_to_sign(
139///     "20130524T000000Z",
140///     "20130524/us-east-1/s3/aws4_request",
141///     "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972",
142/// );
143/// assert!(sts.starts_with("AWS4-HMAC-SHA256\n20130524T000000Z\n"));
144/// ```
145#[must_use]
146pub fn build_string_to_sign(
147    timestamp: &str,
148    credential_scope: &str,
149    canonical_request_hash: &str,
150) -> String {
151    format!("{SUPPORTED_ALGORITHM}\n{timestamp}\n{credential_scope}\n{canonical_request_hash}")
152}
153
154/// Derive the SigV4 signing key using HMAC-SHA256 chain.
155///
156/// ```text
157/// DateKey              = HMAC-SHA256("AWS4" + secret_key, date)
158/// DateRegionKey        = HMAC-SHA256(DateKey, region)
159/// DateRegionServiceKey = HMAC-SHA256(DateRegionKey, service)
160/// SigningKey           = HMAC-SHA256(DateRegionServiceKey, "aws4_request")
161/// ```
162///
163/// # Examples
164///
165/// ```
166/// use rustack_auth::sigv4::derive_signing_key;
167///
168/// let key = derive_signing_key(
169///     "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
170///     "20130524",
171///     "us-east-1",
172///     "s3",
173/// );
174/// assert!(!key.is_empty());
175/// ```
176#[must_use]
177pub fn derive_signing_key(secret_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
178    let date_key = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
179    let date_region_key = hmac_sha256(&date_key, region.as_bytes());
180    let date_region_service_key = hmac_sha256(&date_region_key, service.as_bytes());
181    hmac_sha256(&date_region_service_key, b"aws4_request")
182}
183
184/// Compute the HMAC-SHA256 signature of `data` using the given `signing_key`.
185///
186/// Returns the hex-encoded signature.
187#[must_use]
188pub fn compute_signature(signing_key: &[u8], data: &str) -> String {
189    let sig = hmac_sha256(signing_key, data.as_bytes());
190    hex::encode(sig)
191}
192
193/// Verify an AWS SigV4-signed HTTP request.
194///
195/// This function:
196/// 1. Parses the `Authorization` header
197/// 2. Resolves the secret key via the credential provider
198/// 3. Reconstructs the canonical request
199/// 4. Computes the expected signature
200/// 5. Compares signatures using constant-time comparison
201///
202/// # Errors
203///
204/// Returns an [`AuthError`] if:
205/// - The `Authorization` header is missing or malformed
206/// - The access key is not found
207/// - Required signed headers are missing
208/// - The signature does not match
209pub fn verify_sigv4(
210    parts: &http::request::Parts,
211    body_hash: &str,
212    credential_provider: &dyn CredentialProvider,
213) -> Result<AuthResult, AuthError> {
214    // Extract and parse the Authorization header.
215    let auth_header = parts
216        .headers
217        .get(http::header::AUTHORIZATION)
218        .ok_or(AuthError::MissingAuthHeader)?
219        .to_str()
220        .map_err(|_| AuthError::InvalidAuthHeader)?;
221
222    debug!(auth_header, "Parsing SigV4 authorization header");
223
224    let parsed = parse_authorization_header(auth_header)?;
225
226    // Resolve the secret key.
227    let secret_key = credential_provider.get_secret_key(&parsed.access_key_id)?;
228
229    // Extract the timestamp from x-amz-date header.
230    let timestamp = extract_header_value(parts, "x-amz-date")?;
231
232    debug!(
233        access_key_id = %parsed.access_key_id,
234        date = %parsed.date,
235        region = %parsed.region,
236        service = %parsed.service,
237        "Verifying SigV4 signature"
238    );
239
240    // Build the canonical request.
241    let method = parts.method.as_str();
242    let uri = parts.uri.path();
243    let query = parts.uri.query().unwrap_or("");
244
245    // Collect headers that are in the signed headers list.
246    let signed_header_refs: Vec<&str> = parsed.signed_headers.iter().map(String::as_str).collect();
247    let header_pairs: Vec<(&str, &str)> = collect_signed_headers(parts, &signed_header_refs)?;
248
249    // Use the x-amz-content-sha256 header value (what the client signed with)
250    // rather than the recomputed body hash. This is critical because the client
251    // may use STREAMING-* placeholders or compute the hash before encoding.
252    let payload_hash = parts
253        .headers
254        .get("x-amz-content-sha256")
255        .and_then(|v| v.to_str().ok())
256        .unwrap_or(body_hash);
257
258    let canonical_request = build_canonical_request(
259        method,
260        uri,
261        query,
262        &header_pairs,
263        &signed_header_refs,
264        payload_hash,
265    );
266
267    // Hash the canonical request.
268    let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
269
270    // Build the credential scope and string to sign.
271    let credential_scope = format!(
272        "{}/{}/{}/aws4_request",
273        parsed.date, parsed.region, parsed.service
274    );
275    let string_to_sign = build_string_to_sign(&timestamp, &credential_scope, &canonical_hash);
276
277    // Derive the signing key and compute the expected signature.
278    let signing_key =
279        derive_signing_key(&secret_key, &parsed.date, &parsed.region, &parsed.service);
280    let expected_signature = compute_signature(&signing_key, &string_to_sign);
281
282    // Constant-time comparison to prevent timing attacks.
283    let provided_bytes = parsed.signature.as_bytes();
284    let expected_bytes = expected_signature.as_bytes();
285
286    if provided_bytes.ct_eq(expected_bytes).into() {
287        debug!(access_key_id = %parsed.access_key_id, "Signature verification succeeded");
288        Ok(AuthResult {
289            access_key_id: parsed.access_key_id,
290            region: parsed.region,
291            service: parsed.service,
292            signed_headers: parsed.signed_headers,
293        })
294    } else {
295        debug!(
296            expected = %expected_signature,
297            provided = %parsed.signature,
298            "Signature mismatch"
299        );
300        Err(AuthError::SignatureDoesNotMatch)
301    }
302}
303
304/// Extract a header value as a string from the request parts.
305fn extract_header_value(parts: &http::request::Parts, name: &str) -> Result<String, AuthError> {
306    parts
307        .headers
308        .get(name)
309        .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
310        .to_str()
311        .map(ToOwned::to_owned)
312        .map_err(|_| AuthError::MissingHeader(name.to_owned()))
313}
314
315/// Collect header name-value pairs for the specified signed headers.
316fn collect_signed_headers<'a>(
317    parts: &'a http::request::Parts,
318    signed_headers: &[&'a str],
319) -> Result<Vec<(&'a str, &'a str)>, AuthError> {
320    let mut result = Vec::with_capacity(signed_headers.len());
321
322    for &name in signed_headers {
323        let value = parts
324            .headers
325            .get(name)
326            .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
327            .to_str()
328            .map_err(|_| AuthError::MissingHeader(name.to_owned()))?;
329        result.push((name, value));
330    }
331
332    Ok(result)
333}
334
335/// Compute HMAC-SHA256 and return the raw bytes.
336fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
337    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can accept keys of any length");
338    mac.update(data);
339    mac.finalize().into_bytes().to_vec()
340}
341
342/// Compute the SHA-256 hash of the given payload and return it as a hex string.
343///
344/// This is a convenience function for computing the `x-amz-content-sha256` header value.
345///
346/// # Examples
347///
348/// ```
349/// use rustack_auth::sigv4::hash_payload;
350///
351/// // SHA-256 of empty payload
352/// assert_eq!(
353///     hash_payload(b""),
354///     "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
355/// );
356/// ```
357#[must_use]
358pub fn hash_payload(payload: &[u8]) -> String {
359    hex::encode(Sha256::digest(payload))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::{canonical::build_signed_headers_string, credentials::StaticCredentialProvider};
366
367    const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
368    const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
369    const TEST_DATE: &str = "20130524";
370    const TEST_REGION: &str = "us-east-1";
371    const TEST_SERVICE: &str = "s3";
372
373    fn test_credential_provider() -> StaticCredentialProvider {
374        StaticCredentialProvider::new(vec![(
375            TEST_ACCESS_KEY.to_owned(),
376            TEST_SECRET_KEY.to_owned(),
377        )])
378    }
379
380    #[test]
381    fn test_should_derive_signing_key_matching_aws_test_vector() {
382        let key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
383        // The signing key itself is not published as hex in the AWS docs,
384        // but we can verify it produces the correct signature when used.
385        assert_eq!(key.len(), 32); // SHA-256 produces 32 bytes
386    }
387
388    #[test]
389    fn test_should_parse_authorization_header() {
390        let header = "AWS4-HMAC-SHA256 \
391                      Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,\
392                      SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,\
393                      Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
394
395        let parsed = parse_authorization_header(header).unwrap();
396        assert_eq!(parsed.algorithm, "AWS4-HMAC-SHA256");
397        assert_eq!(parsed.access_key_id, "AKIAIOSFODNN7EXAMPLE");
398        assert_eq!(parsed.date, "20130524");
399        assert_eq!(parsed.region, "us-east-1");
400        assert_eq!(parsed.service, "s3");
401        assert_eq!(
402            parsed.signed_headers,
403            vec!["host", "range", "x-amz-content-sha256", "x-amz-date"]
404        );
405        assert_eq!(
406            parsed.signature,
407            "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
408        );
409    }
410
411    #[test]
412    fn test_should_reject_unsupported_algorithm() {
413        let header = "AWS4-HMAC-SHA512 \
414                      Credential=AKID/20130524/us-east-1/s3/aws4_request,SignedHeaders=host,\
415                      Signature=abc";
416        let result = parse_authorization_header(header);
417        assert!(matches!(result, Err(AuthError::UnsupportedAlgorithm(_))));
418    }
419
420    #[test]
421    fn test_should_reject_invalid_credential_format() {
422        let header =
423            "AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1,SignedHeaders=host,Signature=abc";
424        let result = parse_authorization_header(header);
425        assert!(matches!(result, Err(AuthError::InvalidCredential)));
426    }
427
428    #[test]
429    fn test_should_build_string_to_sign_matching_aws_example() {
430        let canonical_hash = "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
431        let sts = build_string_to_sign(
432            "20130524T000000Z",
433            "20130524/us-east-1/s3/aws4_request",
434            canonical_hash,
435        );
436        #[rustfmt::skip]
437        let expected = "AWS4-HMAC-SHA256\n\
438                        20130524T000000Z\n\
439                        20130524/us-east-1/s3/aws4_request\n\
440                        7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
441        assert_eq!(sts, expected);
442    }
443
444    #[test]
445    fn test_should_compute_correct_signature_for_aws_get_object_example() {
446        // Full end-to-end test using the AWS GET Object example.
447        let signing_key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
448
449        #[rustfmt::skip]
450        let string_to_sign = "AWS4-HMAC-SHA256\n\
451                              20130524T000000Z\n\
452                              20130524/us-east-1/s3/aws4_request\n\
453                              7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
454
455        let signature = compute_signature(&signing_key, string_to_sign);
456        assert_eq!(
457            signature,
458            "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
459        );
460    }
461
462    #[test]
463    fn test_should_verify_sigv4_success() {
464        let provider = test_credential_provider();
465        let empty_hash = hash_payload(b"");
466
467        // Build a request matching the AWS test vector.
468        let mut builder = http::Request::builder()
469            .method("GET")
470            .uri("http://examplebucket.s3.amazonaws.com/test.txt")
471            .header("host", "examplebucket.s3.amazonaws.com")
472            .header("range", "bytes=0-9")
473            .header("x-amz-content-sha256", &empty_hash)
474            .header("x-amz-date", "20130524T000000Z");
475
476        // Compute the expected signature to build the auth header.
477        let auth_value = format!(
478            "AWS4-HMAC-SHA256 \
479             Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
480             range;x-amz-content-sha256;x-amz-date,\
481             Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
482        );
483        builder = builder.header(http::header::AUTHORIZATION, &auth_value);
484
485        let (parts, _body) = builder.body(()).unwrap().into_parts();
486        let result = verify_sigv4(&parts, &empty_hash, &provider);
487        assert!(result.is_ok());
488
489        let auth_result = result.unwrap();
490        assert_eq!(auth_result.access_key_id, TEST_ACCESS_KEY);
491        assert_eq!(auth_result.region, "us-east-1");
492        assert_eq!(auth_result.service, "s3");
493    }
494
495    #[test]
496    fn test_should_fail_sigv4_with_wrong_key() {
497        let provider = StaticCredentialProvider::new(vec![(
498            TEST_ACCESS_KEY.to_owned(),
499            "WRONG_SECRET_KEY".to_owned(),
500        )]);
501        let empty_hash = hash_payload(b"");
502
503        let auth_value = format!(
504            "AWS4-HMAC-SHA256 \
505             Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
506             range;x-amz-content-sha256;x-amz-date,\
507             Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
508        );
509
510        let (parts, _body) = http::Request::builder()
511            .method("GET")
512            .uri("http://examplebucket.s3.amazonaws.com/test.txt")
513            .header("host", "examplebucket.s3.amazonaws.com")
514            .header("range", "bytes=0-9")
515            .header("x-amz-content-sha256", &empty_hash)
516            .header("x-amz-date", "20130524T000000Z")
517            .header(http::header::AUTHORIZATION, &auth_value)
518            .body(())
519            .unwrap()
520            .into_parts();
521
522        let result = verify_sigv4(&parts, &empty_hash, &provider);
523        assert!(matches!(result, Err(AuthError::SignatureDoesNotMatch)));
524    }
525
526    #[test]
527    fn test_should_fail_sigv4_with_missing_auth_header() {
528        let provider = test_credential_provider();
529        let empty_hash = hash_payload(b"");
530
531        let (parts, _body) = http::Request::builder()
532            .method("GET")
533            .uri("http://example.com/")
534            .header("host", "example.com")
535            .body(())
536            .unwrap()
537            .into_parts();
538
539        let result = verify_sigv4(&parts, &empty_hash, &provider);
540        assert!(matches!(result, Err(AuthError::MissingAuthHeader)));
541    }
542
543    #[test]
544    fn test_should_fail_sigv4_with_unknown_access_key() {
545        let provider = StaticCredentialProvider::new(vec![]);
546        let empty_hash = hash_payload(b"");
547
548        let auth_value = "AWS4-HMAC-SHA256 \
549                          Credential=UNKNOWN_KEY/20130524/us-east-1/s3/aws4_request,\
550                          SignedHeaders=host;x-amz-date,Signature=abc123"
551            .to_owned();
552
553        let (parts, _body) = http::Request::builder()
554            .method("GET")
555            .uri("http://example.com/")
556            .header("host", "example.com")
557            .header("x-amz-date", "20130524T000000Z")
558            .header(http::header::AUTHORIZATION, &auth_value)
559            .body(())
560            .unwrap()
561            .into_parts();
562
563        let result = verify_sigv4(&parts, &empty_hash, &provider);
564        assert!(matches!(result, Err(AuthError::AccessKeyNotFound(_))));
565    }
566
567    #[test]
568    fn test_should_hash_empty_payload() {
569        assert_eq!(
570            hash_payload(b""),
571            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
572        );
573    }
574
575    #[test]
576    fn test_should_hash_nonempty_payload() {
577        let hash = hash_payload(b"Hello, World!");
578        assert_eq!(hash.len(), 64); // 32 bytes hex-encoded
579        assert_ne!(
580            hash,
581            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
582        );
583    }
584
585    #[test]
586    fn test_should_build_signed_headers_string_from_parsed() {
587        let headers = [
588            "host".to_owned(),
589            "range".to_owned(),
590            "x-amz-content-sha256".to_owned(),
591            "x-amz-date".to_owned(),
592        ];
593        let refs: Vec<&str> = headers.iter().map(String::as_str).collect();
594        let result = build_signed_headers_string(&refs);
595        assert_eq!(result, "host;range;x-amz-content-sha256;x-amz-date");
596    }
597}