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)| (key.to_string(), value.to_string()))
110        .collect();
111    keyvalues.sort();
112    let keyvalues: Vec<String> = keyvalues
113        .iter()
114        .map(|(k, v)| {
115            format!(
116                "{}={}",
117                utf8_percent_encode(k, FRAGMENT_SLASH),
118                utf8_percent_encode(v, FRAGMENT_SLASH)
119            )
120        })
121        .collect();
122    keyvalues.join("&")
123}
124
125/// Generate a canonical header string from the provided headers.
126pub fn canonical_header_string(headers: &HeaderMap) -> Result<String, S3Error> {
127    let mut keyvalues = vec![];
128    for (key, value) in headers.iter() {
129        keyvalues.push(format!(
130            "{}:{}",
131            key.as_str().to_lowercase(),
132            value.to_str()?.trim()
133        ))
134    }
135    keyvalues.sort();
136    Ok(keyvalues.join("\n"))
137}
138
139/// Generate a signed header string from the provided headers.
140pub fn signed_header_string(headers: &HeaderMap) -> String {
141    let mut keys = headers
142        .keys()
143        .map(|key| key.as_str().to_lowercase())
144        .collect::<Vec<String>>();
145    keys.sort();
146    keys.join(";")
147}
148
149/// Generate a canonical request.
150pub fn canonical_request(
151    method: &str,
152    url: &Url,
153    headers: &HeaderMap,
154    sha256: &str,
155) -> Result<String, S3Error> {
156    Ok(format!(
157        "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{sha256}",
158        method = method,
159        uri = canonical_uri_string(url),
160        query_string = canonical_query_string(url),
161        headers = canonical_header_string(headers)?,
162        signed = signed_header_string(headers),
163        sha256 = sha256
164    ))
165}
166
167/// Generate an AWS scope string.
168pub fn scope_string(datetime: &OffsetDateTime, region: &Region) -> Result<String, S3Error> {
169    Ok(format!(
170        "{date}/{region}/s3/aws4_request",
171        date = datetime.format(SHORT_DATE)?,
172        region = region
173    ))
174}
175
176/// Generate the "string to sign" - the value to which the HMAC signing is
177/// applied to sign requests.
178pub fn string_to_sign(
179    datetime: &OffsetDateTime,
180    region: &Region,
181    canonical_req: &str,
182) -> Result<String, S3Error> {
183    let mut hasher = Sha256::default();
184    hasher.update(canonical_req.as_bytes());
185    let string_to = format!(
186        "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
187        timestamp = datetime.format(LONG_DATETIME)?,
188        scope = scope_string(datetime, region)?,
189        hash = hex::encode(hasher.finalize().as_slice())
190    );
191    Ok(string_to)
192}
193
194/// Generate the AWS signing key, derived from the secret key, date, region,
195/// and service name.
196pub fn signing_key(
197    datetime: &OffsetDateTime,
198    secret_key: &str,
199    region: &Region,
200    service: &str,
201) -> Result<Vec<u8>, S3Error> {
202    let secret = format!("AWS4{}", secret_key);
203    let mut date_hmac = HmacSha256::new_from_slice(secret.as_bytes())?;
204    date_hmac.update(datetime.format(SHORT_DATE)?.as_bytes());
205    let mut region_hmac = HmacSha256::new_from_slice(&date_hmac.finalize().into_bytes())?;
206    region_hmac.update(region.to_string().as_bytes());
207    let mut service_hmac = HmacSha256::new_from_slice(&region_hmac.finalize().into_bytes())?;
208    service_hmac.update(service.as_bytes());
209    let mut signing_hmac = HmacSha256::new_from_slice(&service_hmac.finalize().into_bytes())?;
210    signing_hmac.update(b"aws4_request");
211    Ok(signing_hmac.finalize().into_bytes().to_vec())
212}
213
214/// Generate the AWS authorization header.
215pub fn authorization_header(
216    access_key: &str,
217    datetime: &OffsetDateTime,
218    region: &Region,
219    signed_headers: &str,
220    signature: &str,
221) -> Result<String, S3Error> {
222    Ok(format!(
223        "AWS4-HMAC-SHA256 Credential={access_key}/{scope},\
224            SignedHeaders={signed_headers},Signature={signature}",
225        access_key = access_key,
226        scope = scope_string(datetime, region)?,
227        signed_headers = signed_headers,
228        signature = signature
229    ))
230}
231
232pub fn authorization_query_params_no_sig(
233    access_key: &str,
234    datetime: &OffsetDateTime,
235    region: &Region,
236    expires: u32,
237    custom_headers: Option<&HeaderMap>,
238    token: Option<&String>,
239) -> Result<String, S3Error> {
240    let credentials = format!("{}/{}", access_key, scope_string(datetime, region)?);
241    let credentials = utf8_percent_encode(&credentials, FRAGMENT_SLASH);
242
243    let mut signed_headers = vec!["host".to_string()];
244
245    if let Some(custom_headers) = &custom_headers {
246        for k in custom_headers.keys() {
247            signed_headers.push(k.to_string())
248        }
249    }
250
251    signed_headers.sort();
252    let signed_headers = signed_headers.join(";");
253    let signed_headers = utf8_percent_encode(&signed_headers, FRAGMENT_SLASH);
254
255    let mut query_params = format!(
256        "?X-Amz-Algorithm=AWS4-HMAC-SHA256\
257            &X-Amz-Credential={credentials}\
258            &X-Amz-Date={long_date}\
259            &X-Amz-Expires={expires}\
260            &X-Amz-SignedHeaders={signed_headers}",
261        credentials = credentials,
262        long_date = datetime.format(LONG_DATETIME)?,
263        expires = expires,
264        signed_headers = signed_headers,
265    );
266
267    if let Some(token) = token {
268        write!(
269            query_params,
270            "&X-Amz-Security-Token={}",
271            utf8_percent_encode(token, FRAGMENT_SLASH)
272        )
273        .expect("Could not write token");
274    }
275
276    Ok(query_params)
277}
278
279pub fn flatten_queries(queries: Option<&HashMap<String, String>>) -> Result<String, S3Error> {
280    match queries {
281        None => Ok(String::new()),
282        Some(queries) => {
283            let mut query_str = String::new();
284            for (k, v) in queries {
285                write!(
286                    query_str,
287                    "&{}={}",
288                    utf8_percent_encode(k, FRAGMENT_SLASH),
289                    utf8_percent_encode(v, FRAGMENT_SLASH),
290                )?;
291            }
292            Ok(query_str)
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use std::convert::TryInto;
300    use std::str;
301
302    use http::HeaderMap;
303    use http::header::{HOST, HeaderName, RANGE};
304    use time::Date;
305    use url::Url;
306
307    use crate::serde_types::ListBucketResult;
308
309    use super::*;
310
311    #[test]
312    fn test_base_url_encode() {
313        // Make sure parsing doesn't remove extra slashes, as normalization
314        // will mess up the path lookup.
315        let url = Url::parse("http://s3.amazonaws.com/examplebucket///foo//bar//baz").unwrap();
316        let canonical = canonical_uri_string(&url);
317        assert_eq!("/examplebucket///foo//bar//baz", canonical);
318    }
319
320    #[test]
321    fn test_path_encode() {
322        let url = Url::parse("http://s3.amazonaws.com/bucket/Filename (xx)%=").unwrap();
323        let canonical = canonical_uri_string(&url);
324        assert_eq!("/bucket/Filename%20%28xx%29%25%3D", canonical);
325    }
326
327    #[test]
328    fn test_path_slash_encode() {
329        let url =
330            Url::parse("http://s3.amazonaws.com/bucket/Folder (xx)%=/Filename (xx)%=").unwrap();
331        let canonical = canonical_uri_string(&url);
332        assert_eq!(
333            "/bucket/Folder%20%28xx%29%25%3D/Filename%20%28xx%29%25%3D",
334            canonical
335        );
336    }
337
338    #[test]
339    fn test_query_string_encode() {
340        let url = Url::parse(
341            "http://s3.amazonaws.com/examplebucket?\
342                              prefix=somePrefix&marker=someMarker&max-keys=20",
343        )
344        .unwrap();
345        let canonical = canonical_query_string(&url);
346        assert_eq!("marker=someMarker&max-keys=20&prefix=somePrefix", canonical);
347
348        let url = Url::parse("http://s3.amazonaws.com/examplebucket?acl").unwrap();
349        let canonical = canonical_query_string(&url);
350        assert_eq!("acl=", canonical);
351
352        let url = Url::parse(
353            "http://s3.amazonaws.com/examplebucket?\
354                              key=with%20space&also+space=with+plus",
355        )
356        .unwrap();
357        let canonical = canonical_query_string(&url);
358        assert_eq!("also%20space=with%20plus&key=with%20space", canonical);
359
360        let url =
361            Url::parse("http://s3.amazonaws.com/examplebucket?key-with-postfix=something&key=")
362                .unwrap();
363        let canonical = canonical_query_string(&url);
364        assert_eq!("key=&key-with-postfix=something", canonical);
365
366        let url = Url::parse("http://s3.amazonaws.com/examplebucket?key=c&key=a&key=b").unwrap();
367        let canonical = canonical_query_string(&url);
368        assert_eq!("key=a&key=b&key=c", canonical);
369    }
370
371    #[test]
372    fn test_headers_encode() {
373        let mut headers = HeaderMap::new();
374        headers.insert(
375            HeaderName::from_static("x-amz-date"),
376            "20130708T220855Z".parse().unwrap(),
377        );
378        headers.insert(HeaderName::from_static("foo"), "bAr".parse().unwrap());
379        headers.insert(HOST, "s3.amazonaws.com".parse().unwrap());
380        let canonical = canonical_header_string(&headers).unwrap();
381        let expected = "foo:bAr\nhost:s3.amazonaws.com\nx-amz-date:20130708T220855Z";
382        assert_eq!(expected, canonical);
383
384        let signed = signed_header_string(&headers);
385        assert_eq!("foo;host;x-amz-date", signed);
386    }
387
388    #[test]
389    fn test_aws_signing_key() {
390        let key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
391        let expected = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9";
392        let datetime = Date::from_calendar_date(2015, 8.try_into().unwrap(), 30)
393            .unwrap()
394            .with_hms(0, 0, 0)
395            .unwrap()
396            .assume_utc();
397        let signature = signing_key(&datetime, key, &"us-east-1".parse().unwrap(), "iam").unwrap();
398        assert_eq!(expected, hex::encode(signature));
399    }
400
401    const EXPECTED_SHA: &str = "e3b0c44298fc1c149afbf4c8996fb924\
402                                        27ae41e4649b934ca495991b7852b855";
403
404    #[rustfmt::skip]
405    const EXPECTED_CANONICAL_REQUEST: &str =
406        "GET\n\
407         /test.txt\n\
408         \n\
409         host:examplebucket.s3.amazonaws.com\n\
410         range:bytes=0-9\n\
411         x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
412         x-amz-date:20130524T000000Z\n\
413         \n\
414         host;range;x-amz-content-sha256;x-amz-date\n\
415         e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
416
417    #[rustfmt::skip]
418    const EXPECTED_STRING_TO_SIGN: &str =
419        "AWS4-HMAC-SHA256\n\
420         20130524T000000Z\n\
421         20130524/us-east-1/s3/aws4_request\n\
422         7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
423
424    #[test]
425    fn test_signing() {
426        let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
427        let mut headers = HeaderMap::new();
428        headers.insert(
429            HeaderName::from_static("x-amz-date"),
430            "20130524T000000Z".parse().unwrap(),
431        );
432        headers.insert(RANGE, "bytes=0-9".parse().unwrap());
433        headers.insert(HOST, "examplebucket.s3.amazonaws.com".parse().unwrap());
434        headers.insert(
435            HeaderName::from_static("x-amz-content-sha256"),
436            EXPECTED_SHA.parse().unwrap(),
437        );
438        let canonical = canonical_request("GET", &url, &headers, EXPECTED_SHA).unwrap();
439        assert_eq!(EXPECTED_CANONICAL_REQUEST, canonical);
440
441        let datetime = Date::from_calendar_date(2013, 5.try_into().unwrap(), 24)
442            .unwrap()
443            .with_hms(0, 0, 0)
444            .unwrap()
445            .assume_utc();
446        let string_to_sign =
447            string_to_sign(&datetime, &"us-east-1".parse().unwrap(), &canonical).unwrap();
448        assert_eq!(EXPECTED_STRING_TO_SIGN, string_to_sign);
449
450        let expected = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
451        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
452        let signing_key = signing_key(&datetime, secret, &"us-east-1".parse().unwrap(), "s3");
453        let mut hmac = Hmac::<Sha256>::new_from_slice(&signing_key.unwrap()).unwrap();
454        hmac.update(string_to_sign.as_bytes());
455        assert_eq!(expected, hex::encode(hmac.finalize().into_bytes()));
456    }
457
458    #[test]
459    fn test_parse_list_bucket_result() {
460        let result_string = r###"
461            <?xml version="1.0" encoding="UTF-8"?>
462            <ListBucketResult
463                xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
464                <Name>RelationalAI</Name>
465                <Prefix>/</Prefix>
466                <KeyCount>0</KeyCount>
467                <MaxKeys>1000</MaxKeys>
468                <IsTruncated>true</IsTruncated>
469            </ListBucketResult>
470        "###;
471        let deserialized: ListBucketResult =
472            quick_xml::de::from_reader(result_string.as_bytes()).expect("Parse error!");
473        assert!(deserialized.is_truncated);
474    }
475
476    #[test]
477    fn test_uri_encode() {
478        assert_eq!(
479            uri_encode(r#"~!@#$%^&*()-_=+[]\{}|;:'",.<>? привет 你好"#, true),
480            "~%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"
481        );
482    }
483}