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