Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-ac5b181a013e54348' | 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 | ||
| 6 | use hmac::{Hmac, Mac}; | |
| 7 | use sha2::{Digest as _, Sha256}; | |
| 8 | ||
| 9 | type HmacSha256 = Hmac<Sha256>; | |
| 10 | ||
| 11 | /// The hash of a payload that is not signed: bodies stream as they are. | |
| 12 | pub const UNSIGNED: &str = "UNSIGNED-PAYLOAD"; | |
| 13 | ||
| 14 | /// Who signs, and for which region. | |
| 15 | #[derive(Clone, Debug)] | |
| 16 | pub 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. | |
| 23 | pub 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`. | |
| 31 | pub 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 | ||
| 43 | fn 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 | ||
| 49 | fn sha256_hex(data: &[u8]) -> String { | |
| 50 | hex::encode(Sha256::digest(data)) | |
| 51 | } | |
| 52 | ||
| 53 | /// The query string, sorted and encoded as signing needs it. | |
| 54 | fn 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 | ||
| 67 | impl 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)] | |
| 144 | mod 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.