1use eyre::Result;
10use reqwest::header::{HeaderName, HeaderValue};
11use sha2::{Digest as _, Sha256};
12use std::time::{SystemTime, UNIX_EPOCH};
13use url::Url;
14
15const ALGORITHM: &str = "AWS4-HMAC-SHA256";
17const SERVICE: &str = "s3";
19const EMPTY_PAYLOAD_SHA256: &str =
21 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
22const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
24
25pub(crate) const X_AMZ_DATE: &str = "x-amz-date";
26pub(crate) const X_AMZ_CONTENT_SHA256: &str = "x-amz-content-sha256";
27pub(crate) const X_AMZ_SECURITY_TOKEN: &str = "x-amz-security-token";
28
29#[derive(Clone)]
34pub struct S3Credentials {
35 pub access_key_id: String,
37 pub secret_access_key: String,
39 pub session_token: Option<String>,
41}
42
43impl S3Credentials {
44 pub fn from_env() -> Option<Self> {
52 Some(Self {
53 access_key_id: non_empty_var("AWS_ACCESS_KEY_ID")?,
54 secret_access_key: non_empty_var("AWS_SECRET_ACCESS_KEY")?,
55 session_token: non_empty_var("AWS_SESSION_TOKEN"),
56 })
57 }
58}
59
60fn non_empty_var(name: &str) -> Option<String> {
61 std::env::var(name)
62 .ok()
63 .map(|value| value.trim().to_string())
64 .filter(|value| !value.is_empty())
65}
66
67pub(crate) enum PayloadHash {
69 Sha256Hex(String),
71 Unsigned,
77}
78
79impl PayloadHash {
80 pub(crate) fn empty() -> Self {
82 Self::Sha256Hex(EMPTY_PAYLOAD_SHA256.to_string())
83 }
84
85 pub(crate) fn of(bytes: &[u8]) -> Self {
87 Self::Sha256Hex(hex::encode(Sha256::digest(bytes)))
88 }
89
90 fn as_str(&self) -> &str {
91 match self {
92 Self::Sha256Hex(hash) => hash,
93 Self::Unsigned => UNSIGNED_PAYLOAD,
94 }
95 }
96}
97
98pub(crate) struct SigningContext<'a> {
100 pub(crate) credentials: &'a S3Credentials,
101 pub(crate) region: &'a str,
102 pub(crate) timestamp: SystemTime,
105}
106
107pub(crate) fn sign(
115 method: &str,
116 url: &Url,
117 context: &SigningContext<'_>,
118 payload: &PayloadHash,
119) -> Result<Vec<(HeaderName, HeaderValue)>> {
120 let host = url
121 .host_str()
122 .ok_or_else(|| eyre::eyre!("an S3 endpoint must have a host"))?;
123 let host = match url.port() {
124 Some(port) => format!("{host}:{port}"),
125 None => host.to_string(),
126 };
127 let (date, date_time) = format_timestamp(context.timestamp)?;
128 let payload_hash = payload.as_str();
129
130 let mut headers = vec![
131 ("host".to_string(), host),
132 (X_AMZ_CONTENT_SHA256.to_string(), payload_hash.to_string()),
133 (X_AMZ_DATE.to_string(), date_time.clone()),
134 ];
135 if let Some(token) = &context.credentials.session_token {
136 headers.push((X_AMZ_SECURITY_TOKEN.to_string(), token.clone()));
137 }
138 headers.sort_by(|left, right| left.0.cmp(&right.0));
139
140 let signed_headers = headers
141 .iter()
142 .map(|(name, _)| name.as_str())
143 .collect::<Vec<_>>()
144 .join(";");
145 let canonical_headers = headers
146 .iter()
147 .map(|(name, value)| format!("{name}:{}\n", value.trim()))
148 .collect::<String>();
149
150 let canonical_request = format!(
151 "{method}\n{}\n{}\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
152 canonical_uri(url.path()),
153 canonical_query(url),
154 );
155 let scope = format!("{date}/{}/{SERVICE}/aws4_request", context.region);
156 let string_to_sign = format!(
157 "{ALGORITHM}\n{date_time}\n{scope}\n{}",
158 hex::encode(Sha256::digest(canonical_request.as_bytes()))
159 );
160
161 let signature = hex::encode(hmac_sha256(
162 &signing_key(
163 &context.credentials.secret_access_key,
164 &date,
165 context.region,
166 ),
167 string_to_sign.as_bytes(),
168 ));
169 let authorization = format!(
170 "{ALGORITHM} Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
171 context.credentials.access_key_id
172 );
173
174 let mut signed = Vec::with_capacity(headers.len());
175 for (name, value) in headers {
176 if name == "host" {
179 continue;
180 }
181 signed.push((
182 HeaderName::from_bytes(name.as_bytes())?,
183 header_value(&name, &value)?,
184 ));
185 }
186 signed.push((
187 reqwest::header::AUTHORIZATION,
188 header_value("authorization", &authorization)?,
189 ));
190 Ok(signed)
191}
192
193fn header_value(name: &str, value: &str) -> Result<HeaderValue> {
194 let mut header = HeaderValue::from_str(value)?;
195 if name == X_AMZ_SECURITY_TOKEN || name == "authorization" {
196 header.set_sensitive(true);
197 }
198 Ok(header)
199}
200
201fn signing_key(secret: &str, date: &str, region: &str) -> Vec<u8> {
206 let date_key = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
207 let region_key = hmac_sha256(&date_key, region.as_bytes());
208 let service_key = hmac_sha256(®ion_key, SERVICE.as_bytes());
209 hmac_sha256(&service_key, b"aws4_request")
210}
211
212fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec<u8> {
218 const BLOCK_BYTES: usize = 64;
219 let mut block = [0_u8; BLOCK_BYTES];
220 if key.len() > BLOCK_BYTES {
221 block[..32].copy_from_slice(&Sha256::digest(key));
222 } else {
223 block[..key.len()].copy_from_slice(key);
224 }
225 let mut inner = Sha256::new();
226 inner.update(block.map(|byte| byte ^ 0x36));
227 inner.update(message);
228 let mut outer = Sha256::new();
229 outer.update(block.map(|byte| byte ^ 0x5c));
230 outer.update(inner.finalize());
231 outer.finalize().to_vec()
232}
233
234fn canonical_uri(path: &str) -> String {
241 if path.is_empty() {
242 "/".to_string()
243 } else {
244 path.to_string()
245 }
246}
247
248fn canonical_query(url: &Url) -> String {
253 let mut pairs = url
254 .query_pairs()
255 .map(|(key, value)| (uri_encode(&key), uri_encode(&value)))
256 .collect::<Vec<_>>();
257 pairs.sort();
258 pairs
259 .iter()
260 .map(|(key, value)| format!("{key}={value}"))
261 .collect::<Vec<_>>()
262 .join("&")
263}
264
265fn uri_encode(value: &str) -> String {
267 let mut encoded = String::with_capacity(value.len());
268 for byte in value.bytes() {
269 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
270 encoded.push(byte as char);
271 } else {
272 encoded.push_str(&format!("%{byte:02X}"));
273 }
274 }
275 encoded
276}
277
278fn format_timestamp(timestamp: SystemTime) -> Result<(String, String)> {
281 let seconds = timestamp
282 .duration_since(UNIX_EPOCH)
283 .map_err(|_| eyre::eyre!("system clock is before the Unix epoch"))?
284 .as_secs();
285 let days = (seconds / 86_400) as i64;
286 let time_of_day = seconds % 86_400;
287 let (year, month, day) = civil_from_days(days);
288 Ok((
289 format!("{year:04}{month:02}{day:02}"),
290 format!(
291 "{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z",
292 time_of_day / 3_600,
293 (time_of_day % 3_600) / 60,
294 time_of_day % 60
295 ),
296 ))
297}
298
299fn civil_from_days(days: i64) -> (i64, u32, u32) {
306 let z = days + 719_468;
307 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
308 let day_of_era = z - era * 146_097;
309 let year_of_era =
310 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
311 let year = year_of_era + era * 400;
312 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
313 let shifted_month = (5 * day_of_year + 2) / 153;
314 let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
315 let month = if shifted_month < 10 {
316 shifted_month + 3
317 } else {
318 shifted_month - 9
319 } as u32;
320 (if month <= 2 { year + 1 } else { year }, month, day)
321}
322
323#[cfg(test)]
325fn epoch_plus(seconds: u64) -> SystemTime {
326 UNIX_EPOCH + std::time::Duration::from_secs(seconds)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn credentials() -> S3Credentials {
334 S3Credentials {
335 access_key_id: "AKIDEXAMPLE".into(),
336 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
337 session_token: None,
338 }
339 }
340
341 #[test]
343 fn hmac_matches_rfc_4231() {
344 assert_eq!(
345 hex::encode(hmac_sha256(&[0x0b; 20], b"Hi There")),
346 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
347 );
348 }
349
350 #[test]
352 fn hmac_matches_rfc_4231_multi_block_message() {
353 assert_eq!(
354 hex::encode(hmac_sha256(&[0xaa; 20], &[0xdd; 50])),
355 "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe"
356 );
357 }
358
359 #[test]
362 fn hmac_hashes_keys_longer_than_a_block() {
363 assert_eq!(
364 hex::encode(hmac_sha256(
365 &[0xaa; 131],
366 b"Test Using Larger Than Block-Size Key - Hash Key First"
367 )),
368 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
369 );
370 }
371
372 #[test]
373 fn empty_payload_constant_is_the_hash_of_no_bytes() {
374 let PayloadHash::Sha256Hex(hash) = PayloadHash::empty() else {
375 panic!("empty payload is hashed");
376 };
377 assert_eq!(hash, hex::encode(Sha256::digest(b"")));
378 }
379
380 #[test]
381 fn timestamps_format_as_sigv4_expects() {
382 assert_eq!(
383 format_timestamp(epoch_plus(0)).unwrap(),
384 ("19700101".to_string(), "19700101T000000Z".to_string())
385 );
386 assert_eq!(
388 format_timestamp(epoch_plus(1_440_938_160)).unwrap(),
389 ("20150830".to_string(), "20150830T123600Z".to_string())
390 );
391 assert_eq!(
393 format_timestamp(epoch_plus(1_709_251_199)).unwrap(),
394 ("20240229".to_string(), "20240229T235959Z".to_string())
395 );
396 assert_eq!(
398 format_timestamp(epoch_plus(4_107_542_400)).unwrap(),
399 ("21000301".to_string(), "21000301T000000Z".to_string())
400 );
401 }
402
403 #[test]
404 fn paths_are_signed_exactly_as_the_request_spells_them() {
405 assert_eq!(
406 canonical_uri("/ns/v1/blobs/blake3/ab/12"),
407 "/ns/v1/blobs/blake3/ab/12"
408 );
409 assert_eq!(canonical_uri(""), "/");
410 assert_eq!(canonical_uri("/a%20b"), "/a%20b");
413 }
414
415 #[test]
416 fn query_values_are_encoded_with_the_unreserved_set() {
417 assert_eq!(uri_encode("-._~"), "-._~");
418 assert_eq!(uri_encode("a b"), "a%20b");
419 assert_eq!(uri_encode("c+d/e"), "c%2Bd%2Fe");
420 }
421
422 #[test]
423 fn query_strings_are_sorted_and_encoded() {
424 let url: Url = "https://bucket.example.com/key?b=2&a=1&c=with%20space"
425 .parse()
426 .unwrap();
427 assert_eq!(canonical_query(&url), "a=1&b=2&c=with%20space");
428 let bare: Url = "https://bucket.example.com/key".parse().unwrap();
429 assert_eq!(canonical_query(&bare), "");
430 }
431
432 #[test]
436 fn signs_a_get_the_way_the_specification_does() {
437 let url: Url = "https://examplebucket.s3.amazonaws.com/test.txt"
438 .parse()
439 .unwrap();
440 let context = SigningContext {
441 credentials: &credentials(),
442 region: "us-east-1",
443 timestamp: epoch_plus(1_440_938_160),
444 };
445
446 let headers = sign("GET", &url, &context, &PayloadHash::empty()).unwrap();
447
448 let authorization = header(&headers, "authorization");
449 assert_eq!(
450 authorization,
451 "AWS4-HMAC-SHA256 \
452 Credential=AKIDEXAMPLE/20150830/us-east-1/s3/aws4_request, \
453 SignedHeaders=host;x-amz-content-sha256;x-amz-date, \
454 Signature=bbfdf4d3c3eab24da182f8f790e0c7d8e2a20658191717a6546076effa9f5a5e"
455 );
456 assert_eq!(header(&headers, X_AMZ_DATE), "20150830T123600Z");
457 assert_eq!(header(&headers, X_AMZ_CONTENT_SHA256), EMPTY_PAYLOAD_SHA256);
458 assert!(!headers.iter().any(|(name, _)| name.as_str() == "host"));
459 }
460
461 #[test]
462 fn temporary_credentials_sign_and_send_their_session_token() {
463 let url: Url = "https://examplebucket.s3.amazonaws.com/test.txt"
464 .parse()
465 .unwrap();
466 let credentials = S3Credentials {
467 session_token: Some("session-token".into()),
468 ..credentials()
469 };
470 let context = SigningContext {
471 credentials: &credentials,
472 region: "us-east-1",
473 timestamp: epoch_plus(1_440_938_160),
474 };
475
476 let headers = sign("PUT", &url, &context, &PayloadHash::Unsigned).unwrap();
477
478 assert_eq!(
479 header(&headers, "authorization"),
480 "AWS4-HMAC-SHA256 \
481 Credential=AKIDEXAMPLE/20150830/us-east-1/s3/aws4_request, \
482 SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \
483 Signature=37e8be08e60e6a292f591b6a462e7fb96436b4c1925f6978e8869a7c4577e12f"
484 );
485 assert_eq!(header(&headers, X_AMZ_SECURITY_TOKEN), "session-token");
486 assert_eq!(header(&headers, X_AMZ_CONTENT_SHA256), UNSIGNED_PAYLOAD);
487 }
488
489 #[test]
490 fn a_signature_covers_the_payload_the_method_and_the_key() {
491 let url: Url = "https://examplebucket.s3.amazonaws.com/test.txt"
492 .parse()
493 .unwrap();
494 let other: Url = "https://examplebucket.s3.amazonaws.com/other.txt"
495 .parse()
496 .unwrap();
497 let credentials = credentials();
498 let signature = |method: &str, url: &Url, region: &str, payload: PayloadHash| {
499 let context = SigningContext {
500 credentials: &credentials,
501 region,
502 timestamp: epoch_plus(1_440_938_160),
503 };
504 header(
505 &sign(method, url, &context, &payload).unwrap(),
506 "authorization",
507 )
508 .to_string()
509 };
510
511 let baseline = signature("GET", &url, "us-east-1", PayloadHash::empty());
512 for (what, other) in [
513 (
514 "method",
515 signature("PUT", &url, "us-east-1", PayloadHash::empty()),
516 ),
517 (
518 "key",
519 signature("GET", &other, "us-east-1", PayloadHash::empty()),
520 ),
521 (
522 "region",
523 signature("GET", &url, "eu-west-1", PayloadHash::empty()),
524 ),
525 (
526 "payload",
527 signature("GET", &url, "us-east-1", PayloadHash::of(b"body")),
528 ),
529 (
530 "unsigned payload",
531 signature("GET", &url, "us-east-1", PayloadHash::Unsigned),
532 ),
533 ] {
534 assert_ne!(baseline, other, "signature ignores the request's {what}");
535 }
536 }
537
538 fn header<'a>(headers: &'a [(HeaderName, HeaderValue)], name: &str) -> &'a str {
539 headers
540 .iter()
541 .find(|(header, _)| header.as_str() == name)
542 .map(|(_, value)| value.to_str().unwrap())
543 .unwrap_or_else(|| panic!("missing header {name}"))
544 }
545}