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    let signed_headers = signed_headers.join(";");
225    let signed_headers = utf8_percent_encode(&signed_headers, FRAGMENT_SLASH);
226
227    let mut query_params = format!(
228        "?X-Amz-Algorithm=AWS4-HMAC-SHA256\
229            &X-Amz-Credential={credentials}\
230            &X-Amz-Date={long_date}\
231            &X-Amz-Expires={expires}\
232            &X-Amz-SignedHeaders={signed_headers}",
233        credentials = credentials,
234        long_date = datetime.format(LONG_DATETIME)?,
235        expires = expires,
236        signed_headers = signed_headers,
237    );
238
239    if let Some(token) = token {
240        write!(
241            query_params,
242            "&X-Amz-Security-Token={}",
243            utf8_percent_encode(token, FRAGMENT_SLASH)
244        )
245        .expect("Could not write token");
246    }
247
248    Ok(query_params)
249}
250
251pub fn flatten_queries(queries: Option<&HashMap<String, String>>) -> Result<String, S3Error> {
252    match queries {
253        None => Ok(String::new()),
254        Some(queries) => {
255            let mut query_str = String::new();
256            for (k, v) in queries {
257                write!(
258                    query_str,
259                    "&{}={}",
260                    utf8_percent_encode(k, FRAGMENT_SLASH),
261                    utf8_percent_encode(v, FRAGMENT_SLASH),
262                )?;
263            }
264            Ok(query_str)
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use std::convert::TryInto;
272    use std::str;
273
274    use http::header::{HeaderName, HOST, RANGE};
275    use http::HeaderMap;
276    use time::Date;
277    use url::Url;
278
279    use crate::serde_types::ListBucketResult;
280
281    use super::*;
282
283    #[test]
284    fn test_base_url_encode() {
285        // Make sure parsing doesn't remove extra slashes, as normalization
286        // will mess up the path lookup.
287        let url = Url::parse("http://s3.amazonaws.com/examplebucket///foo//bar//baz").unwrap();
288        let canonical = canonical_uri_string(&url);
289        assert_eq!("/examplebucket///foo//bar//baz", canonical);
290    }
291
292    #[test]
293    fn test_path_encode() {
294        let url = Url::parse("http://s3.amazonaws.com/bucket/Filename (xx)%=").unwrap();
295        let canonical = canonical_uri_string(&url);
296        assert_eq!("/bucket/Filename%20%28xx%29%25%3D", canonical);
297    }
298
299    #[test]
300    fn test_path_slash_encode() {
301        let url =
302            Url::parse("http://s3.amazonaws.com/bucket/Folder (xx)%=/Filename (xx)%=").unwrap();
303        let canonical = canonical_uri_string(&url);
304        assert_eq!(
305            "/bucket/Folder%20%28xx%29%25%3D/Filename%20%28xx%29%25%3D",
306            canonical
307        );
308    }
309
310    #[test]
311    fn test_query_string_encode() {
312        let url = Url::parse(
313            "http://s3.amazonaws.com/examplebucket?\
314                              prefix=somePrefix&marker=someMarker&max-keys=20",
315        )
316        .unwrap();
317        let canonical = canonical_query_string(&url);
318        assert_eq!("marker=someMarker&max-keys=20&prefix=somePrefix", canonical);
319
320        let url = Url::parse("http://s3.amazonaws.com/examplebucket?acl").unwrap();
321        let canonical = canonical_query_string(&url);
322        assert_eq!("acl=", canonical);
323
324        let url = Url::parse(
325            "http://s3.amazonaws.com/examplebucket?\
326                              key=with%20space&also+space=with+plus",
327        )
328        .unwrap();
329        let canonical = canonical_query_string(&url);
330        assert_eq!("also%20space=with%20plus&key=with%20space", canonical);
331
332        let url =
333            Url::parse("http://s3.amazonaws.com/examplebucket?key-with-postfix=something&key=")
334                .unwrap();
335        let canonical = canonical_query_string(&url);
336        assert_eq!("key=&key-with-postfix=something", canonical);
337
338        let url = Url::parse("http://s3.amazonaws.com/examplebucket?key=c&key=a&key=b").unwrap();
339        let canonical = canonical_query_string(&url);
340        assert_eq!("key=a&key=b&key=c", canonical);
341    }
342
343    #[test]
344    fn test_headers_encode() {
345        let mut headers = HeaderMap::new();
346        headers.insert(
347            HeaderName::from_static("x-amz-date"),
348            "20130708T220855Z".parse().unwrap(),
349        );
350        headers.insert(HeaderName::from_static("foo"), "bAr".parse().unwrap());
351        headers.insert(HOST, "s3.amazonaws.com".parse().unwrap());
352        let canonical = canonical_header_string(&headers).unwrap();
353        let expected = "foo:bAr\nhost:s3.amazonaws.com\nx-amz-date:20130708T220855Z";
354        assert_eq!(expected, canonical);
355
356        let signed = signed_header_string(&headers);
357        assert_eq!("foo;host;x-amz-date", signed);
358    }
359
360    #[test]
361    fn test_aws_signing_key() {
362        let key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
363        let expected = "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9";
364        let datetime = Date::from_calendar_date(2015, 8.try_into().unwrap(), 30)
365            .unwrap()
366            .with_hms(0, 0, 0)
367            .unwrap()
368            .assume_utc();
369        let signature = signing_key(&datetime, key, &"us-east-1".parse().unwrap(), "iam").unwrap();
370        assert_eq!(expected, hex::encode(signature));
371    }
372
373    const EXPECTED_SHA: &str = "e3b0c44298fc1c149afbf4c8996fb924\
374                                        27ae41e4649b934ca495991b7852b855";
375
376    #[rustfmt::skip]
377    const EXPECTED_CANONICAL_REQUEST: &str =
378        "GET\n\
379         /test.txt\n\
380         \n\
381         host:examplebucket.s3.amazonaws.com\n\
382         range:bytes=0-9\n\
383         x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
384         x-amz-date:20130524T000000Z\n\
385         \n\
386         host;range;x-amz-content-sha256;x-amz-date\n\
387         e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
388
389    #[rustfmt::skip]
390    const EXPECTED_STRING_TO_SIGN: &str =
391        "AWS4-HMAC-SHA256\n\
392         20130524T000000Z\n\
393         20130524/us-east-1/s3/aws4_request\n\
394         7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
395
396    #[test]
397    fn test_signing() {
398        let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
399        let mut headers = HeaderMap::new();
400        headers.insert(
401            HeaderName::from_static("x-amz-date"),
402            "20130524T000000Z".parse().unwrap(),
403        );
404        headers.insert(RANGE, "bytes=0-9".parse().unwrap());
405        headers.insert(HOST, "examplebucket.s3.amazonaws.com".parse().unwrap());
406        headers.insert(
407            HeaderName::from_static("x-amz-content-sha256"),
408            EXPECTED_SHA.parse().unwrap(),
409        );
410        let canonical = canonical_request("GET", &url, &headers, EXPECTED_SHA).unwrap();
411        assert_eq!(EXPECTED_CANONICAL_REQUEST, canonical);
412
413        let datetime = Date::from_calendar_date(2013, 5.try_into().unwrap(), 24)
414            .unwrap()
415            .with_hms(0, 0, 0)
416            .unwrap()
417            .assume_utc();
418        let string_to_sign =
419            string_to_sign(&datetime, &"us-east-1".parse().unwrap(), &canonical).unwrap();
420        assert_eq!(EXPECTED_STRING_TO_SIGN, string_to_sign);
421
422        let expected = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
423        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
424        let signing_key = signing_key(&datetime, secret, &"us-east-1".parse().unwrap(), "s3");
425        let mut hmac = Hmac::<Sha256>::new_from_slice(&signing_key.unwrap()).unwrap();
426        hmac.update(string_to_sign.as_bytes());
427        assert_eq!(expected, hex::encode(hmac.finalize().into_bytes()));
428    }
429
430    #[test]
431    fn test_parse_list_bucket_result() {
432        let result_string = r###"
433            <?xml version="1.0" encoding="UTF-8"?>
434            <ListBucketResult
435                xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
436                <Name>RelationalAI</Name>
437                <Prefix>/</Prefix>
438                <KeyCount>0</KeyCount>
439                <MaxKeys>1000</MaxKeys>
440                <IsTruncated>true</IsTruncated>
441            </ListBucketResult>
442        "###;
443        let deserialized: ListBucketResult =
444            quick_xml::de::from_reader(result_string.as_bytes()).expect("Parse error!");
445        assert!(deserialized.is_truncated);
446    }
447
448    #[test]
449    fn test_uri_encode() {
450        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");
451    }
452}