use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub struct Signing<'a> {
pub access_key: &'a str,
pub secret_key: &'a str,
pub region: &'a str,
pub service: &'a str,
pub timestamp: &'a str,
}
impl Signing<'_> {
fn date(&self) -> &str {
&self.timestamp[..8]
}
fn scope(&self) -> String {
format!("{}/{}/{}/aws4_request", self.date(), self.region, self.service)
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hmac(key: &[u8], message: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
mac.finalize().into_bytes().to_vec()
}
pub fn canonical_request(
method: &str,
path: &str,
query: &str,
headers: &[(String, String)],
payload_hash: &str,
) -> (String, String) {
let mut sorted: Vec<(String, String)> = headers
.iter()
.map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let canonical_headers: String =
sorted.iter().map(|(name, value)| format!("{name}:{value}\n")).collect();
let signed_headers =
sorted.iter().map(|(name, _)| name.as_str()).collect::<Vec<_>>().join(";");
let request = format!(
"{method}\n{}\n{query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
encode_path(path)
);
(request, signed_headers)
}
pub fn encode_path(path: &str) -> String {
path.split('/').map(encode_segment).collect::<Vec<_>>().join("/")
}
pub fn encode_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for byte in segment.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub fn authorization(
signing: &Signing<'_>,
method: &str,
path: &str,
query: &str,
headers: &[(String, String)],
payload_hash: &str,
) -> String {
let (canonical, signed_headers) =
canonical_request(method, path, query, headers, payload_hash);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
signing.timestamp,
signing.scope(),
sha256_hex(canonical.as_bytes())
);
let signature = hex(&hmac(&signing_key(signing), string_to_sign.as_bytes()));
format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={signed_headers}, Signature={signature}",
signing.access_key,
signing.scope()
)
}
fn signing_key(signing: &Signing<'_>) -> Vec<u8> {
let initial = format!("AWS4{}", signing.secret_key);
let date = hmac(initial.as_bytes(), signing.date().as_bytes());
let region = hmac(&date, signing.region.as_bytes());
let service = hmac(®ion, signing.service.as_bytes());
hmac(&service, b"aws4_request")
}
#[cfg(test)]
mod tests {
use super::*;
fn example() -> Signing<'static> {
Signing {
access_key: "AKIAIOSFODNN7EXAMPLE",
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "s3",
timestamp: "20130524T000000Z",
}
}
#[test]
fn hashes_an_empty_payload_to_the_documented_value() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn derives_the_documented_signing_key() {
assert_eq!(
hex(&signing_key(&example())),
"dbb893acc010964918f1fd433add87c70e8b0db6be30c1fbeafefa5ec6ba8378"
);
}
#[test]
fn builds_the_documented_canonical_request() {
let headers = vec![
("host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
(
"x-amz-content-sha256".to_string(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
),
("x-amz-date".to_string(), "20130524T000000Z".to_string()),
];
let (canonical, signed) = canonical_request(
"GET",
"/test.txt",
"",
&headers,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
assert_eq!(signed, "host;x-amz-content-sha256;x-amz-date");
assert!(canonical.starts_with("GET\n/test.txt\n\n"));
assert!(canonical.contains("host:examplebucket.s3.amazonaws.com\n"));
}
#[test]
fn produces_the_documented_authorization_header() {
let headers = vec![
("host".to_string(), "examplebucket.s3.amazonaws.com".to_string()),
("range".to_string(), "bytes=0-9".to_string()),
(
"x-amz-content-sha256".to_string(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
),
("x-amz-date".to_string(), "20130524T000000Z".to_string()),
];
let header = authorization(
&example(),
"GET",
"/test.txt",
"",
&headers,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
assert!(header.contains("Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"));
assert!(header.contains("SignedHeaders=host;range;x-amz-content-sha256;x-amz-date"));
assert!(
header.ends_with(
"Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
),
"{header}"
);
}
#[test]
fn encodes_keys_without_touching_the_separators() {
assert_eq!(encode_path("/photos/my holiday.jpg"), "/photos/my%20holiday.jpg");
assert_eq!(encode_path("/a/b/c"), "/a/b/c");
assert_eq!(encode_segment("a+b=c"), "a%2Bb%3Dc");
}
#[test]
fn header_order_does_not_change_the_signature() {
let ordered = vec![
("host".to_string(), "example.com".to_string()),
("x-amz-date".to_string(), "20130524T000000Z".to_string()),
];
let reversed: Vec<_> = ordered.iter().cloned().rev().collect();
assert_eq!(
authorization(&example(), "GET", "/x", "", &ordered, UNSIGNED_PAYLOAD),
authorization(&example(), "GET", "/x", "", &reversed, UNSIGNED_PAYLOAD)
);
}
}