Skip to main content

rustlavel_storage/
sigv4.rs

1//! AWS Signature Version 4.
2//!
3//! Every S3-compatible service speaks this, and getting it wrong produces a
4//! `SignatureDoesNotMatch` that says nothing about which of the dozen inputs
5//! was off — so each step is separated and tested against the AWS reference
6//! vectors.
7
8use hmac::{Hmac, Mac};
9use sha2::{Digest, Sha256};
10
11type HmacSha256 = Hmac<Sha256>;
12
13pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
14
15/// What identifies the request being signed.
16pub struct Signing<'a> {
17    pub access_key: &'a str,
18    pub secret_key: &'a str,
19    pub region: &'a str,
20    pub service: &'a str,
21    /// `YYYYMMDDTHHMMSSZ`.
22    pub timestamp: &'a str,
23}
24
25impl Signing<'_> {
26    fn date(&self) -> &str {
27        // The credential scope uses the date alone.
28        &self.timestamp[..8]
29    }
30
31    fn scope(&self) -> String {
32        format!("{}/{}/{}/aws4_request", self.date(), self.region, self.service)
33    }
34}
35
36pub fn sha256_hex(bytes: &[u8]) -> String {
37    hex(&Sha256::digest(bytes))
38}
39
40fn hex(bytes: &[u8]) -> String {
41    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
42}
43
44fn hmac(key: &[u8], message: &[u8]) -> Vec<u8> {
45    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
46    mac.update(message);
47    mac.finalize().into_bytes().to_vec()
48}
49
50/// The canonical request: the exact bytes AWS hashes.
51pub fn canonical_request(
52    method: &str,
53    path: &str,
54    query: &str,
55    headers: &[(String, String)],
56    payload_hash: &str,
57) -> (String, String) {
58    let mut sorted: Vec<(String, String)> = headers
59        .iter()
60        .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string()))
61        .collect();
62    sorted.sort_by(|a, b| a.0.cmp(&b.0));
63
64    let canonical_headers: String =
65        sorted.iter().map(|(name, value)| format!("{name}:{value}\n")).collect();
66    let signed_headers =
67        sorted.iter().map(|(name, _)| name.as_str()).collect::<Vec<_>>().join(";");
68
69    let request = format!(
70        "{method}\n{}\n{query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
71        encode_path(path)
72    );
73
74    (request, signed_headers)
75}
76
77/// Percent-encode a path, leaving `/` alone.
78///
79/// S3 keys routinely contain spaces and other characters that must be encoded
80/// in the signature exactly as they are on the wire.
81pub fn encode_path(path: &str) -> String {
82    path.split('/').map(encode_segment).collect::<Vec<_>>().join("/")
83}
84
85/// Percent-encode one segment, using the unreserved set AWS specifies.
86pub fn encode_segment(segment: &str) -> String {
87    let mut out = String::with_capacity(segment.len());
88    for byte in segment.bytes() {
89        match byte {
90            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
91                out.push(byte as char)
92            }
93            _ => out.push_str(&format!("%{byte:02X}")),
94        }
95    }
96    out
97}
98
99/// The signature over a canonical request, as lowercase hex.
100pub fn signature(signing: &Signing<'_>, canonical: &str) -> String {
101    let string_to_sign = format!(
102        "AWS4-HMAC-SHA256\n{}\n{}\n{}",
103        signing.timestamp,
104        signing.scope(),
105        sha256_hex(canonical.as_bytes())
106    );
107    hex(&hmac(&signing_key(signing), string_to_sign.as_bytes()))
108}
109
110/// The query string of a presigned URL: the request signed into the URL
111/// itself, so a browser can make it without ever holding a credential.
112///
113/// This is how a large upload should reach a bucket. A video that passes
114/// through the application server costs that server the whole file in memory
115/// and the whole transfer twice; a browser given a URL like this one PUTs
116/// straight to the bucket and the server is never in the path. `expires` is
117/// how long the URL stays valid — seconds, at most seven days, which is the
118/// limit AWS enforces.
119///
120/// The payload is `UNSIGNED-PAYLOAD`, which is the only option: the signer
121/// does not have the bytes, the browser does. Only `host` is signed, so the
122/// browser is free to add whatever headers it likes — with one exception a
123/// caller can opt into via `extra`, such as pinning `content-type`.
124pub fn presigned_query(
125    signing: &Signing<'_>,
126    method: &str,
127    host: &str,
128    path: &str,
129    expires_seconds: u64,
130    extra: &[(&str, &str)],
131) -> String {
132    let credential = format!("{}/{}", signing.access_key, signing.scope());
133
134    let mut pairs: Vec<(String, String)> = vec![
135        ("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()),
136        ("X-Amz-Credential".into(), credential),
137        ("X-Amz-Date".into(), signing.timestamp.into()),
138        ("X-Amz-Expires".into(), expires_seconds.to_string()),
139        ("X-Amz-SignedHeaders".into(), "host".into()),
140    ];
141    for (name, value) in extra {
142        pairs.push(((*name).into(), (*value).into()));
143    }
144    // Sorted by name, and every name and value encoded: the canonical query is
145    // what is signed, and it has to be byte-for-byte what is sent.
146    pairs.sort();
147    let query: String = pairs
148        .iter()
149        .map(|(name, value)| format!("{}={}", encode_segment(name), encode_segment(value)))
150        .collect::<Vec<_>>()
151        .join("&");
152
153    let headers = vec![("host".to_string(), host.to_string())];
154    let (canonical, _) = canonical_request(method, path, &query, &headers, UNSIGNED_PAYLOAD);
155
156    format!("{query}&X-Amz-Signature={}", signature(signing, &canonical))
157}
158
159/// The `Authorization` header value for a request.
160pub fn authorization(
161    signing: &Signing<'_>,
162    method: &str,
163    path: &str,
164    query: &str,
165    headers: &[(String, String)],
166    payload_hash: &str,
167) -> String {
168    let (canonical, signed_headers) =
169        canonical_request(method, path, query, headers, payload_hash);
170    let signature = signature(signing, &canonical);
171
172    format!(
173        "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={signed_headers}, Signature={signature}",
174        signing.access_key,
175        signing.scope()
176    )
177}
178
179/// The derived key: secret, then date, region, service, and the terminator.
180///
181/// Deriving per day and per region is what keeps a leaked signature from being
182/// replayable elsewhere.
183fn signing_key(signing: &Signing<'_>) -> Vec<u8> {
184    let initial = format!("AWS4{}", signing.secret_key);
185    let date = hmac(initial.as_bytes(), signing.date().as_bytes());
186    let region = hmac(&date, signing.region.as_bytes());
187    let service = hmac(&region, signing.service.as_bytes());
188    hmac(&service, b"aws4_request")
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// The AWS documentation's worked example, so the whole chain is pinned.
196    fn example() -> Signing<'static> {
197        Signing {
198            access_key: "AKIAIOSFODNN7EXAMPLE",
199            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
200            region: "us-east-1",
201            service: "s3",
202            timestamp: "20130524T000000Z",
203        }
204    }
205
206    /// The worked example in AWS's own documentation for query-string signing:
207    /// a GET of `test.txt` in `examplebucket`, valid for a day, on 24 May 2013.
208    /// The signature is the one the document prints, so a mistake in any of
209    /// the dozen inputs shows up here rather than as `SignatureDoesNotMatch`
210    /// against a real bucket.
211    #[test]
212    fn presigns_the_documented_example_to_the_documented_signature() {
213        let signing = Signing {
214            access_key: "AKIAIOSFODNN7EXAMPLE",
215            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
216            region: "us-east-1",
217            service: "s3",
218            timestamp: "20130524T000000Z",
219        };
220
221        let query =
222            presigned_query(&signing, "GET", "examplebucket.s3.amazonaws.com", "/test.txt", 86400, &[]);
223
224        assert!(
225            query.ends_with(
226                "&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
227            ),
228            "{query}"
229        );
230        assert!(query.starts_with("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential="), "{query}");
231        assert!(query.contains("X-Amz-Expires=86400"), "{query}");
232        assert!(query.contains("X-Amz-SignedHeaders=host"), "{query}");
233    }
234
235    #[test]
236    fn hashes_an_empty_payload_to_the_documented_value() {
237        assert_eq!(
238            sha256_hex(b""),
239            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
240        );
241    }
242
243    #[test]
244    fn derives_the_documented_signing_key() {
245        assert_eq!(
246            hex(&signing_key(&example())),
247            "dbb893acc010964918f1fd433add87c70e8b0db6be30c1fbeafefa5ec6ba8378"
248        );
249    }
250
251    #[test]
252    fn builds_the_documented_canonical_request() {
253        let headers = vec![
254            ("host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
255            (
256                "x-amz-content-sha256".to_string(),
257                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
258            ),
259            ("x-amz-date".to_string(), "20130524T000000Z".to_string()),
260        ];
261
262        let (canonical, signed) = canonical_request(
263            "GET",
264            "/test.txt",
265            "",
266            &headers,
267            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
268        );
269
270        assert_eq!(signed, "host;x-amz-content-sha256;x-amz-date");
271        assert!(canonical.starts_with("GET\n/test.txt\n\n"));
272        assert!(canonical.contains("host:examplebucket.s3.amazonaws.com\n"));
273    }
274
275    #[test]
276    fn produces_the_documented_authorization_header() {
277        // AWS's "GET Object" worked example, reproduced exactly — the `range`
278        // header is part of it, and leaving it out changes the signature.
279        let headers = vec![
280            ("host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
281            ("range".to_string(), "bytes=0-9".to_string()),
282            (
283                "x-amz-content-sha256".to_string(),
284                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
285            ),
286            ("x-amz-date".to_string(), "20130524T000000Z".to_string()),
287        ];
288
289        let header = authorization(
290            &example(),
291            "GET",
292            "/test.txt",
293            "",
294            &headers,
295            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
296        );
297
298        assert!(header.contains("Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"));
299        assert!(header.contains("SignedHeaders=host;range;x-amz-content-sha256;x-amz-date"));
300        assert!(
301            header.ends_with(
302                "Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
303            ),
304            "{header}"
305        );
306    }
307
308    #[test]
309    fn encodes_keys_without_touching_the_separators() {
310        assert_eq!(encode_path("/photos/my holiday.jpg"), "/photos/my%20holiday.jpg");
311        assert_eq!(encode_path("/a/b/c"), "/a/b/c");
312        assert_eq!(encode_segment("a+b=c"), "a%2Bb%3Dc");
313    }
314
315    #[test]
316    fn header_order_does_not_change_the_signature() {
317        let ordered = vec![
318            ("host".to_string(), "example.com".to_string()),
319            ("x-amz-date".to_string(), "20130524T000000Z".to_string()),
320        ];
321        let reversed: Vec<_> = ordered.iter().cloned().rev().collect();
322
323        assert_eq!(
324            authorization(&example(), "GET", "/x", "", &ordered, UNSIGNED_PAYLOAD),
325            authorization(&example(), "GET", "/x", "", &reversed, UNSIGNED_PAYLOAD)
326        );
327    }
328}