g1t/crates/blobstore/src/sigv4.rs

201 lines7,800 bytesCodeBlame
1//! AWS Signature Version 4, for S3-compatible storage: signing a request's
2//! headers, and signing a URL that lets its holder download one object for
3//! a while. R2's S3 endpoint takes the same signatures, which is how large
4//! downloads are sent straight to it.
5
6use hmac::{Hmac, Mac};
7use sha2::{Digest as _, Sha256};
8
9type HmacSha256 = Hmac<Sha256>;
10
11/// The hash of a payload that is not signed: bodies stream as they are.
12pub const UNSIGNED: &str = "UNSIGNED-PAYLOAD";
13
14/// Who signs, and for which region.
15#[derive(Clone, Debug)]
16pub struct Credentials {
17 pub access_key_id: String,
18 pub secret_access_key: String,
19 pub region: String,
20}
21
22/// `20130524T000000Z` from milliseconds since the epoch.
23pub fn amz_date(now_ms: u64) -> String {
24 let text = g1t_contracts::time::rfc3339(now_ms);
25 let whole = text.split('.').next().unwrap_or(&text);
26 format!("{}Z", whole.replace(['-', ':'], ""))
27}
28
29/// Percent-encodes everything but the unreserved characters, and `/` too
30/// unless `path`.
31pub fn uri_encode(text: &str, path: bool) -> String {
32 let mut out = String::with_capacity(text.len());
33 for byte in text.bytes() {
34 match byte {
35 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(byte as char),
36 b'/' if path => out.push('/'),
37 _ => out.push_str(&format!("%{byte:02X}")),
38 }
39 }
40 out
41}
42
43fn hmac(key: &[u8], data: &str) -> Vec<u8> {
44 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC takes a key of any length");
45 mac.update(data.as_bytes());
46 mac.finalize().into_bytes().to_vec()
47}
48
49fn sha256_hex(data: &[u8]) -> String {
50 hex::encode(Sha256::digest(data))
51}
52
53/// The query string, sorted and encoded as signing needs it.
54fn canonical_query(query: &[(String, String)]) -> String {
55 let mut pairs: Vec<(String, String)> = query
56 .iter()
57 .map(|(key, value)| (uri_encode(key, false), uri_encode(value, false)))
58 .collect();
59 pairs.sort();
60 pairs
61 .iter()
62 .map(|(key, value)| format!("{key}={value}"))
63 .collect::<Vec<_>>()
64 .join("&")
65}
66
67impl Credentials {
68 fn scope(&self, date: &str) -> String {
69 format!("{}/{}/s3/aws4_request", &date[..8], self.region)
70 }
71
72 fn signature(&self, date: &str, canonical_request: &str) -> String {
73 let to_sign = format!(
74 "AWS4-HMAC-SHA256\n{date}\n{}\n{}",
75 self.scope(date),
76 sha256_hex(canonical_request.as_bytes())
77 );
78 let key = hmac(format!("AWS4{}", self.secret_access_key).as_bytes(), &date[..8]);
79 let key = hmac(&key, &self.region);
80 let key = hmac(&key, "s3");
81 let key = hmac(&key, "aws4_request");
82 hex::encode(hmac(&key, &to_sign))
83 }
84
85 /// The `Authorization` header for a request. `headers` must include
86 /// `host`, `x-amz-date` and `x-amz-content-sha256`, lowercase; every
87 /// one given is signed.
88 pub fn authorization(
89 &self,
90 method: &str,
91 path: &str,
92 query: &[(String, String)],
93 headers: &[(String, String)],
94 payload_hash: &str,
95 ) -> String {
96 let mut headers: Vec<(String, String)> = headers
97 .iter()
98 .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned()))
99 .collect();
100 headers.sort();
101 let date = headers
102 .iter()
103 .find(|(name, _)| name == "x-amz-date")
104 .map(|(_, value)| value.clone())
105 .unwrap_or_default();
106 let signed: Vec<&str> = headers.iter().map(|(name, _)| name.as_str()).collect();
107 let signed = signed.join(";");
108 let canonical_headers: String = headers.iter().map(|(name, value)| format!("{name}:{value}\n")).collect();
109 let canonical = format!(
110 "{method}\n{}\n{}\n{canonical_headers}\n{signed}\n{payload_hash}",
111 uri_encode(path, true),
112 canonical_query(query)
113 );
114 format!(
115 "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={signed}, Signature={}",
116 self.access_key_id,
117 self.scope(&date),
118 self.signature(&date, &canonical)
119 )
120 }
121
122 /// A URL that lets anyone `GET` the object at `path` on `host` for
123 /// `expires` seconds from `date`. `base` is the scheme and host the
124 /// URL starts with.
125 pub fn presign_get(&self, base: &str, host: &str, path: &str, date: &str, expires: u32) -> String {
126 let mut query = vec![
127 ("X-Amz-Algorithm".to_owned(), "AWS4-HMAC-SHA256".to_owned()),
128 ("X-Amz-Credential".to_owned(), format!("{}/{}", self.access_key_id, self.scope(date))),
129 ("X-Amz-Date".to_owned(), date.to_owned()),
130 ("X-Amz-Expires".to_owned(), expires.to_string()),
131 ("X-Amz-SignedHeaders".to_owned(), "host".to_owned()),
132 ];
133 let canonical = format!(
134 "GET\n{}\n{}\nhost:{host}\n\nhost\n{UNSIGNED}",
135 uri_encode(path, true),
136 canonical_query(&query)
137 );
138 query.push(("X-Amz-Signature".to_owned(), self.signature(date, &canonical)));
139 format!("{base}{}?{}", uri_encode(path, true), canonical_query(&query))
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn example() -> Credentials {
148 Credentials {
149 access_key_id: "AKIAIOSFODNN7EXAMPLE".into(),
150 secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
151 region: "us-east-1".into(),
152 }
153 }
154
155 /// AWS's own example of a presigned URL (Authenticating Requests:
156 /// Using Query Parameters).
157 #[test]
158 fn a_presigned_url_matches_the_aws_example() {
159 let url = example().presign_get(
160 "https://examplebucket.s3.amazonaws.com",
161 "examplebucket.s3.amazonaws.com",
162 "/test.txt",
163 "20130524T000000Z",
164 86400,
165 );
166 assert!(url.starts_with("https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256"));
167 assert!(url.contains("X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"));
168 assert!(url.contains("&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404&"), "{url}");
169 }
170
171 /// AWS's own example of a signed GET with a range (Authenticating
172 /// Requests: Using the Authorization Header).
173 #[test]
174 fn a_signed_request_matches_the_aws_example() {
175 let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
176 let headers = [
177 ("Host", "examplebucket.s3.amazonaws.com"),
178 ("Range", "bytes=0-9"),
179 ("x-amz-content-sha256", empty),
180 ("x-amz-date", "20130524T000000Z"),
181 ]
182 .map(|(name, value)| (name.to_owned(), value.to_owned()));
183 let authorization = example().authorization("GET", "/test.txt", &[], &headers, empty);
184 assert_eq!(
185 authorization,
186 "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, \
187 SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \
188 Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
189 );
190 }
191
192 #[test]
193 fn dates_and_encoding() {
194 assert_eq!(amz_date(1_369_353_600_000), "20130524T000000Z");
195 assert_eq!(uri_encode("a b/c+d~", true), "a%20b/c%2Bd~");
196 assert_eq!(uri_encode("a/b", false), "a%2Fb");
197 let query = [("uploadId".to_owned(), "x y".to_owned()), ("partNumber".to_owned(), "2".to_owned())];
198 assert_eq!(canonical_query(&query), "partNumber=2&uploadId=x%20y");
199 assert_eq!(canonical_query(&[("uploads".to_owned(), String::new())]), "uploads=");
200 }
201}