Skip to main content

s3/
signing.rs

1//! Implementation of [AWS V4 Signing][link]
2//!
3//! [link]: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
4//! //! This module implements AWS Signature Version 4 signing, which is used to authenticate requests to Amazon S3 and other AWS services. AWS Signature V4 is a process for adding authentication information to AWS requests. The module provides functions to generate canonical requests, sign them using HMAC-SHA256, and construct the necessary headers or query parameters to authorize requests.
5//!
6//! ## Key Functions
7//!
8//! - **uri_encode**: Encodes a URI according to AWS-specific rules, handling characters that must be percent-encoded.
9//!
10//! - **canonical_uri_string**: Generates a canonical URI string from a given URL, decoding and re-encoding the URL path as required by AWS.
11//!
12//! - **canonical_query_string**: Creates a canonical query string from the query parameters in a URL, ensuring they are sorted and encoded correctly.
13//!
14//! - **canonical_header_string**: Constructs a canonical header string from provided HTTP headers, ensuring they are sorted and formatted as required by AWS.
15//!
16//! - **signed_header_string**: Generates a string of signed headers from the provided headers, which is required for creating the authorization header.
17//!
18//! - **canonical_request**: Assembles the complete canonical request, which is a combination of the HTTP method, URI, query string, headers, and payload hash. This is the core of the AWS signing process.
19//!
20//! - **scope_string**: Generates the AWS scope string, which is used in the signing process to specify the AWS region, service, and request type.
21//!
22//! - **string_to_sign**: Constructs the string to sign, which is a key component in the AWS V4 signing process. This string is hashed and signed with the AWS secret key.
23//!
24//! - **signing_key**: Derives the AWS signing key using the secret key, date, region, and service name. This key is used to sign the string-to-sign.
25//!
26//! - **authorization_header**: Generates the Authorization header value, which includes the credential scope, signed headers, and the request signature. This header must be included in the signed AWS requests.
27//!
28//! - **authorization_query_params_no_sig**: Constructs query parameters for presigned URLs, excluding the final signature. This is used when creating presigned URLs for temporary access to S3 objects.
29//!
30//! - **flatten_queries**: Flattens a map of query parameters into a single query string, ensuring proper encoding.
31
32use std::collections::HashMap;
33use std::str;
34
35use hmac::{Hmac, Mac};
36use http::HeaderMap;
37use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
38use sha2::{Digest, Sha256};
39use time::{OffsetDateTime, macros::format_description};
40use url::Url;
41
42use crate::LONG_DATETIME;
43use crate::error::S3Error;
44use crate::region::Region;
45
46use std::fmt::Write as _;
47
48const SHORT_DATE: &[time::format_description::FormatItem<'static>] =
49    format_description!("[year][month][day]");
50
51pub type HmacSha256 = Hmac<Sha256>;
52
53// https://perishablepress.com/stop-using-unsafe-characters-in-urls/
54pub const FRAGMENT: &AsciiSet = &CONTROLS
55    // URL_RESERVED
56    .add(b':')
57    .add(b'?')
58    .add(b'#')
59    .add(b'[')
60    .add(b']')
61    .add(b'@')
62    .add(b'!')
63    .add(b'$')
64    .add(b'&')
65    .add(b'\'')
66    .add(b'(')
67    .add(b')')
68    .add(b'*')
69    .add(b'+')
70    .add(b',')
71    .add(b';')
72    .add(b'=')
73    // URL_UNSAFE
74    .add(b'"')
75    .add(b' ')
76    .add(b'<')
77    .add(b'>')
78    .add(b'%')
79    .add(b'{')
80    .add(b'}')
81    .add(b'|')
82    .add(b'\\')
83    .add(b'^')
84    .add(b'`');
85
86pub const FRAGMENT_SLASH: &AsciiSet = &FRAGMENT.add(b'/');
87
88/// Encode a URI following the specific requirements of the AWS service.
89pub fn uri_encode(string: &str, encode_slash: bool) -> String {
90    if encode_slash {
91        utf8_percent_encode(string, FRAGMENT_SLASH).to_string()
92    } else {
93        utf8_percent_encode(string, FRAGMENT).to_string()
94    }
95}
96
97/// Generate a canonical URI string from the given URL.
98pub fn canonical_uri_string(uri: &Url) -> String {
99    // decode `Url`'s percent-encoding and then reencode it
100    // according to AWS's rules
101    let decoded = percent_encoding::percent_decode_str(uri.path()).decode_utf8_lossy();
102    uri_encode(&decoded, false)
103}
104
105/// Generate a canonical query string from the query pairs in the given URL.
106pub fn canonical_query_string(uri: &Url) -> String {
107    let mut keyvalues: Vec<(String, String)> = uri
108        .query_pairs()
109        .map(|(key, value)| {
110            (
111                utf8_percent_encode(&key, FRAGMENT_SLASH).to_string(),
112                utf8_percent_encode(&value, FRAGMENT_SLASH).to_string(),
113            )
114        })
115        .collect();
116    keyvalues.sort();
117    let keyvalues: Vec<String> = keyvalues.iter().map(|(k, v)| format!("{k}={v}")).collect();
118    keyvalues.join("&")
119}
120
121/// Generate a canonical header string from the provided headers.
122pub fn canonical_header_string(headers: &HeaderMap) -> Result<String, S3Error> {
123    let mut keyvalues = vec![];
124    for (key, value) in headers.iter() {
125        keyvalues.push(format!(
126            "{}:{}",
127            key.as_str().to_lowercase(),
128            value.to_str()?.trim()
129        ))
130    }
131    keyvalues.sort();
132    Ok(keyvalues.join("\n"))
133}
134
135/// Generate a signed header string from the provided headers.
136pub fn signed_header_string(headers: &HeaderMap) -> String {
137    let mut keys = headers
138        .keys()
139        .map(|key| key.as_str().to_lowercase())
140        .collect::<Vec<String>>();
141    keys.sort();
142    keys.join(";")
143}
144
145/// Generate a canonical request.
146pub fn canonical_request(
147    method: &str,
148    url: &Url,
149    headers: &HeaderMap,
150    sha256: &str,
151) -> Result<String, S3Error> {
152    Ok(format!(
153        "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{sha256}",
154        method = method,
155        uri = canonical_uri_string(url),
156        query_string = canonical_query_string(url),
157        headers = canonical_header_string(headers)?,
158        signed = signed_header_string(headers),
159        sha256 = sha256
160    ))
161}
162
163/// Generate an AWS scope string.
164pub fn scope_string(datetime: &OffsetDateTime, region: &Region) -> Result<String, S3Error> {
165    Ok(format!(
166        "{date}/{region}/s3/aws4_request",
167        date = datetime.format(SHORT_DATE)?,
168        region = region
169    ))
170}
171
172/// Generate the "string to sign" - the value to which the HMAC signing is
173/// applied to sign requests.
174pub fn string_to_sign(
175    datetime: &OffsetDateTime,
176    region: &Region,
177    canonical_req: &str,
178) -> Result<String, S3Error> {
179    let mut hasher = Sha256::default();
180    hasher.update(canonical_req.as_bytes());
181    let string_to = format!(
182        "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
183        timestamp = datetime.format(LONG_DATETIME)?,
184        scope = scope_string(datetime, region)?,
185        hash = hex::encode(hasher.finalize().as_slice())
186    );
187    Ok(string_to)
188}
189
190/// Generate the AWS signing key, derived from the secret key, date, region,
191/// and service name.
192pub fn signing_key(
193    datetime: &OffsetDateTime,
194    secret_key: &str,
195    region: &Region,
196    service: &str,
197) -> Result<Vec<u8>, S3Error> {
198    let secret = format!("AWS4{}", secret_key);
199    let mut date_hmac = HmacSha256::new_from_slice(secret.as_bytes())?;
200    date_hmac.update(datetime.format(SHORT_DATE)?.as_bytes());
201    let mut region_hmac = HmacSha256::new_from_slice(&date_hmac.finalize().into_bytes())?;
202    region_hmac.update(region.to_string().as_bytes());
203    let mut service_hmac = HmacSha256::new_from_slice(&region_hmac.finalize().into_bytes())?;
204    service_hmac.update(service.as_bytes());
205    let mut signing_hmac = HmacSha256::new_from_slice(&service_hmac.finalize().into_bytes())?;
206    signing_hmac.update(b"aws4_request");
207    Ok(signing_hmac.finalize().into_bytes().to_vec())
208}
209
210/// Generate the AWS authorization header.
211pub fn authorization_header(
212    access_key: &str,
213    datetime: &OffsetDateTime,
214    region: &Region,
215    signed_headers: &str,
216    signature: &str,
217) -> Result<String, S3Error> {
218    Ok(format!(
219        "AWS4-HMAC-SHA256 Credential={access_key}/{scope},\
220            SignedHeaders={signed_headers},Signature={signature}",
221        access_key = access_key,
222        scope = scope_string(datetime, region)?,
223        signed_headers = signed_headers,
224        signature = signature
225    ))
226}
227
228pub fn authorization_query_params_no_sig(
229    access_key: &str,
230    datetime: &OffsetDateTime,
231    region: &Region,
232    expires: u32,
233    custom_headers: Option<&HeaderMap>,
234    token: Option<&String>,
235) -> Result<String, S3Error> {
236    let credentials = format!("{}/{}", access_key, scope_string(datetime, region)?);
237    let credentials = utf8_percent_encode(&credentials, FRAGMENT_SLASH);
238
239    let mut signed_headers = vec!["host".to_string()];
240
241    if let Some(custom_headers) = &custom_headers {
242        for k in custom_headers.keys() {
243            signed_headers.push(k.to_string())
244        }
245    }
246
247    signed_headers.sort();
248    let signed_headers = signed_headers.join(";");
249    let signed_headers = utf8_percent_encode(&signed_headers, FRAGMENT_SLASH);
250
251    let mut query_params = format!(
252        "?X-Amz-Algorithm=AWS4-HMAC-SHA256\
253            &X-Amz-Credential={credentials}\
254            &X-Amz-Date={long_date}\
255            &X-Amz-Expires={expires}\
256            &X-Amz-SignedHeaders={signed_headers}",
257        credentials = credentials,
258        long_date = datetime.format(LONG_DATETIME)?,
259        expires = expires,
260        signed_headers = signed_headers,
261    );
262
263    if let Some(token) = token {
264        write!(
265            query_params,
266            "&X-Amz-Security-Token={}",
267            utf8_percent_encode(token, FRAGMENT_SLASH)
268        )
269        .expect("Could not write token");
270    }
271
272    Ok(query_params)
273}
274
275pub fn flatten_queries(queries: Option<&HashMap<String, String>>) -> Result<String, S3Error> {
276    match queries {
277        None => Ok(String::new()),
278        Some(queries) => {
279            let mut query_str = String::new();
280            for (k, v) in queries {
281                write!(
282                    query_str,
283                    "&{}={}",
284                    utf8_percent_encode(k, FRAGMENT_SLASH),
285                    utf8_percent_encode(v, FRAGMENT_SLASH),
286                )?;
287            }
288            Ok(query_str)
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use std::convert::TryInto;
296    use std::str;
297
298    use http::HeaderMap;
299    use http::header::{HOST, HeaderName, RANGE};
300    use time::Date;
301    use url::Url;
302
303    use crate::serde_types::ListBucketResult;
304
305    use super::*;
306
307    #[test]
308    fn test_base_url_encode() {
309        // Make sure parsing doesn't remove extra slashes, as normalization
310        // will mess up the path lookup.
311        let url = Url::parse("http://s3.amazonaws.com/examplebucket///foo//bar//baz").unwrap();
312        let canonical = canonical_uri_string(&url);
313        assert_eq!("/examplebucket///foo//bar//baz", canonical);
314    }
315
316    #[test]
317    fn test_path_encode() {
318        let url = Url::parse("http://s3.amazonaws.com/bucket/Filename (xx)%=").unwrap();
319        let canonical = canonical_uri_string(&url);
320        assert_eq!("/bucket/Filename%20%28xx%29%25%3D", canonical);
321    }
322
323    #[test]
324    fn test_path_slash_encode() {
325        let url =
326            Url::parse("http://s3.amazonaws.com/bucket/Folder (xx)%=/Filename (xx)%=").unwrap();
327        let canonical = canonical_uri_string(&url);
328        assert_eq!(
329            "/bucket/Folder%20%28xx%29%25%3D/Filename%20%28xx%29%25%3D",
330            canonical
331        );
332    }
333
334    #[test]
335    fn test_query_string_encode() {
336        let url = Url::parse(
337            "http://s3.amazonaws.com/examplebucket?\
338                              prefix=somePrefix&marker=someMarker&max-keys=20",
339        )
340        .unwrap();
341        let canonical = canonical_query_string(&url);
342        assert_eq!("marker=someMarker&max-keys=20&prefix=somePrefix", canonical);
343
344        let url = Url::parse("http://s3.amazonaws.com/examplebucket?acl").unwrap();
345        let canonical = canonical_query_string(&url);
346        assert_eq!("acl=", canonical);
347
348        let url = Url::parse(
349            "http://s3.amazonaws.com/examplebucket?\
350                              key=with%20space&also+space=with+plus",
351        )
352        .unwrap();
353        let canonical = canonical_query_string(&url);
354        assert_eq!("also%20space=with%20plus&key=with%20space", canonical);
355
356        let url =
357            Url::parse("http://s3.amazonaws.com/examplebucket?key-with-postfix=something&key=")
358                .unwrap();
359        let canonical = canonical_query_string(&url);
360        assert_eq!("key=&key-with-postfix=something", canonical);
361
362        let url = Url::parse("http://s3.amazonaws.com/examplebucket?key=c&key=a&key=b").unwrap();
363        let canonical = canonical_query_string(&url);
364        assert_eq!("key=a&key=b&key=c", canonical);
365    }
366
367    #[test]
368    fn test_query_string_sorts_encoded_pairs() {
369        let url = Url::parse(
370            "http://s3.amazonaws.com/examplebucket?z=last&%C3%A9=accent&-=dash&%2F=slash&same=/&same=-&same=%C3%A9",
371        )
372        .unwrap();
373        let original_pairs = url
374            .query_pairs()
375            .map(|(key, value)| (key.into_owned(), value.into_owned()))
376            .collect::<Vec<_>>();
377        let canonical = canonical_query_string(&url);
378
379        assert_eq!(
380            "%2F=slash&%C3%A9=accent&-=dash&same=%2F&same=%C3%A9&same=-&z=last",
381            canonical
382        );
383        let mut canonical_pairs = Url::parse(&format!("http://example.com/?{canonical}"))
384            .unwrap()
385            .query_pairs()
386            .map(|(key, value)| (key.into_owned(), value.into_owned()))
387            .collect::<Vec<_>>();
388        let mut sorted_original_pairs = original_pairs;
389        canonical_pairs.sort();
390        sorted_original_pairs.sort();
391        assert_eq!(sorted_original_pairs, canonical_pairs);
392        assert_eq!(
393            vec![
394                ("z".to_owned(), "last".to_owned()),
395                ("é".to_owned(), "accent".to_owned()),
396                ("-".to_owned(), "dash".to_owned()),
397                ("/".to_owned(), "slash".to_owned()),
398                ("same".to_owned(), "/".to_owned()),
399                ("same".to_owned(), "-".to_owned()),
400                ("same".to_owned(), "é".to_owned()),
401            ],
402            url.query_pairs()
403                .map(|(key, value)| (key.into_owned(), value.into_owned()))
404                .collect::<Vec<_>>()
405        );
406    }
407
408    #[test]
409    fn test_headers_encode() {
410        let mut headers = HeaderMap::new();
411        headers.insert(
412            HeaderName::from_static("x-amz-date"),
413            "20130708T220855Z".parse().unwrap(),
414        );
415        headers.insert(HeaderName::from_static("foo"), "bAr".parse().unwrap());
416        headers.insert(HOST, "s3.amazonaws.com".parse().unwrap());
417        let canonical = canonical_header_string(&headers).unwrap();
418        let expected = "foo:bAr\nhost:s3.amazonaws.com\nx-amz-date:20130708T220855Z";
419        assert_eq!(expected, canonical);
420
421        let signed = signed_header_string(&headers);
422        assert_eq!("foo;host;x-amz-date", signed);
423    }
424
425    #[test]
426    fn test_aws_signing_key() {
427        let key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
428        let expected = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9";
429        let datetime = Date::from_calendar_date(2015, 8.try_into().unwrap(), 30)
430            .unwrap()
431            .with_hms(0, 0, 0)
432            .unwrap()
433            .assume_utc();
434        let signature = signing_key(&datetime, key, &"us-east-1".parse().unwrap(), "iam").unwrap();
435        assert_eq!(expected, hex::encode(signature));
436    }
437
438    const EXPECTED_SHA: &str = "e3b0c44298fc1c149afbf4c8996fb924\
439                                        27ae41e4649b934ca495991b7852b855";
440
441    #[rustfmt::skip]
442    const EXPECTED_CANONICAL_REQUEST: &str =
443        "GET\n\
444         /test.txt\n\
445         \n\
446         host:examplebucket.s3.amazonaws.com\n\
447         range:bytes=0-9\n\
448         x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
449         x-amz-date:20130524T000000Z\n\
450         \n\
451         host;range;x-amz-content-sha256;x-amz-date\n\
452         e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
453
454    #[rustfmt::skip]
455    const EXPECTED_STRING_TO_SIGN: &str =
456        "AWS4-HMAC-SHA256\n\
457         20130524T000000Z\n\
458         20130524/us-east-1/s3/aws4_request\n\
459         7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
460
461    #[test]
462    fn test_signing() {
463        let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
464        let mut headers = HeaderMap::new();
465        headers.insert(
466            HeaderName::from_static("x-amz-date"),
467            "20130524T000000Z".parse().unwrap(),
468        );
469        headers.insert(RANGE, "bytes=0-9".parse().unwrap());
470        headers.insert(HOST, "examplebucket.s3.amazonaws.com".parse().unwrap());
471        headers.insert(
472            HeaderName::from_static("x-amz-content-sha256"),
473            EXPECTED_SHA.parse().unwrap(),
474        );
475        let canonical = canonical_request("GET", &url, &headers, EXPECTED_SHA).unwrap();
476        assert_eq!(EXPECTED_CANONICAL_REQUEST, canonical);
477
478        let datetime = Date::from_calendar_date(2013, 5.try_into().unwrap(), 24)
479            .unwrap()
480            .with_hms(0, 0, 0)
481            .unwrap()
482            .assume_utc();
483        let string_to_sign =
484            string_to_sign(&datetime, &"us-east-1".parse().unwrap(), &canonical).unwrap();
485        assert_eq!(EXPECTED_STRING_TO_SIGN, string_to_sign);
486
487        let expected = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
488        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
489        let signing_key = signing_key(&datetime, secret, &"us-east-1".parse().unwrap(), "s3");
490        let mut hmac = Hmac::<Sha256>::new_from_slice(&signing_key.unwrap()).unwrap();
491        hmac.update(string_to_sign.as_bytes());
492        assert_eq!(expected, hex::encode(hmac.finalize().into_bytes()));
493    }
494
495    #[test]
496    fn test_parse_list_bucket_result() {
497        let result_string = r###"
498            <?xml version="1.0" encoding="UTF-8"?>
499            <ListBucketResult
500                xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
501                <Name>RelationalAI</Name>
502                <Prefix>/</Prefix>
503                <KeyCount>0</KeyCount>
504                <MaxKeys>1000</MaxKeys>
505                <IsTruncated>true</IsTruncated>
506            </ListBucketResult>
507        "###;
508        let deserialized: ListBucketResult =
509            quick_xml::de::from_reader(result_string.as_bytes()).expect("Parse error!");
510        assert!(deserialized.is_truncated);
511    }
512
513    #[test]
514    fn test_uri_encode() {
515        assert_eq!(
516            uri_encode(r#"~!@#$%^&*()-_=+[]\{}|;:'",.<>? привет 你好"#, true),
517            "~%21%40%23%24%25%5E%26%2A%28%29-_%3D%2B%5B%5D%5C%7B%7D%7C%3B%3A%27%22%2C.%3C%3E%3F%20%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%E4%BD%A0%E5%A5%BD"
518        );
519    }
520}