use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
pub const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
pub fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
fn hmac(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any size");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
pub fn uri_encode(s: &str, keep_slash: bool) -> String {
let mut out = String::with_capacity(s.len() + 8);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b'/' if keep_slash => out.push('/'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub fn canonical_query(params: &[(String, String)]) -> String {
let mut pairs: Vec<(String, String)> = params
.iter()
.map(|(k, v)| (uri_encode(k, false), uri_encode(v, false)))
.collect();
pairs.sort();
pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&")
}
#[derive(Clone)]
pub struct Credentials {
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("access_key", &self.access_key)
.field("secret_key", &"***")
.field("session_token", &self.session_token.as_ref().map(|_| "***"))
.finish()
}
}
pub struct SignRequest<'a> {
pub method: &'a str,
pub host: &'a str,
pub path: &'a str,
pub query: &'a [(String, String)],
pub payload_hash: &'a str,
pub amz_date: &'a str,
pub extra_headers: &'a [(String, String)],
}
pub fn sign(req: &SignRequest, creds: &Credentials, region: &str) -> Vec<(String, String)> {
let mut headers: Vec<(String, String)> = vec![
("host".into(), req.host.to_string()),
("x-amz-content-sha256".into(), req.payload_hash.to_string()),
("x-amz-date".into(), req.amz_date.to_string()),
];
if let Some(token) = &creds.session_token {
headers.push(("x-amz-security-token".into(), token.clone()));
}
for (k, v) in req.extra_headers {
headers.push((k.to_ascii_lowercase(), v.trim().to_string()));
}
headers.sort();
let canonical_headers: String = headers.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
let signed_headers = headers
.iter()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>()
.join(";");
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
req.method,
req.path,
canonical_query(req.query),
canonical_headers,
signed_headers,
req.payload_hash
);
let date = &req.amz_date[..8];
let scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
req.amz_date,
scope,
sha256_hex(canonical_request.as_bytes())
);
let k_date = hmac(
format!("AWS4{}", creds.secret_key).as_bytes(),
date.as_bytes(),
);
let k_region = hmac(&k_date, region.as_bytes());
let k_service = hmac(&k_region, b"s3");
let k_signing = hmac(&k_service, b"aws4_request");
let signature = hex::encode(hmac(&k_signing, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
creds.access_key, scope, signed_headers, signature
);
let mut out: Vec<(String, String)> = headers.into_iter().filter(|(k, _)| k != "host").collect();
out.push(("authorization".into(), authorization));
out
}
pub struct PresignRequest<'a> {
pub method: &'a str,
pub host: &'a str,
pub path: &'a str,
pub query: &'a [(String, String)],
pub amz_date: &'a str,
pub expires: u64,
}
pub fn presign(req: &PresignRequest, creds: &Credentials, region: &str) -> String {
let date = &req.amz_date[..8];
let scope = format!("{date}/{region}/s3/aws4_request");
let mut params: Vec<(String, String)> = req.query.to_vec();
params.push(("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()));
params.push((
"X-Amz-Credential".into(),
format!("{}/{scope}", creds.access_key),
));
params.push(("X-Amz-Date".into(), req.amz_date.to_string()));
params.push(("X-Amz-Expires".into(), req.expires.to_string()));
params.push(("X-Amz-SignedHeaders".into(), "host".into()));
if let Some(token) = &creds.session_token {
params.push(("X-Amz-Security-Token".into(), token.clone()));
}
let canonical_query = canonical_query(¶ms);
let canonical_request = format!(
"{}\n{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
req.method, req.path, canonical_query, req.host
);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
req.amz_date,
scope,
sha256_hex(canonical_request.as_bytes())
);
let k_date = hmac(
format!("AWS4{}", creds.secret_key).as_bytes(),
date.as_bytes(),
);
let k_region = hmac(&k_date, region.as_bytes());
let k_service = hmac(&k_region, b"s3");
let k_signing = hmac(&k_service, b"aws4_request");
let signature = hex::encode(hmac(&k_signing, string_to_sign.as_bytes()));
format!("{canonical_query}&X-Amz-Signature={signature}")
}
pub fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
T[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
T[n as usize & 63] as char
} else {
'='
});
}
out
}
pub fn amz_date_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
amz_date_from_unix(secs)
}
pub fn amz_date_from_unix(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}{m:02}{d:02}T{:02}{:02}{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aws_documentation_example() {
let creds = Credentials {
access_key: "AKIAIOSFODNN7EXAMPLE".into(),
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
session_token: None,
};
let extra = vec![("range".to_string(), "bytes=0-9".to_string())];
let req = SignRequest {
method: "GET",
host: "examplebucket.s3.amazonaws.com",
path: "/test.txt",
query: &[],
payload_hash: EMPTY_SHA256,
amz_date: "20130524T000000Z",
extra_headers: &extra,
};
let headers = sign(&req, &creds, "us-east-1");
let auth = &headers
.iter()
.find(|(k, _)| k == "authorization")
.unwrap()
.1;
assert_eq!(
auth,
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, \
SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, \
Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
);
}
#[test]
fn aws_presigned_example() {
let creds = Credentials {
access_key: "AKIAIOSFODNN7EXAMPLE".into(),
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
session_token: None,
};
let qs = presign(
&PresignRequest {
method: "GET",
host: "examplebucket.s3.amazonaws.com",
path: "/test.txt",
query: &[],
amz_date: "20130524T000000Z",
expires: 86400,
},
&creds,
"us-east-1",
);
assert!(
qs.ends_with(
"X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
),
"{qs}"
);
assert!(qs.starts_with("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host"));
}
#[test]
fn base64_matches_the_standard() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn encoding_keeps_unreserved_and_slashes_in_paths() {
assert_eq!(uri_encode("a b/รง~x.y_z-1", true), "a%20b/%C3%A7~x.y_z-1");
assert_eq!(uri_encode("a/b", false), "a%2Fb");
assert_eq!(uri_encode("*+", false), "%2A%2B");
}
#[test]
fn query_is_sorted_and_encoded() {
let q = vec![
("prefix".to_string(), "dados/x y".to_string()),
("list-type".to_string(), "2".to_string()),
];
assert_eq!(canonical_query(&q), "list-type=2&prefix=dados%2Fx%20y");
assert_eq!(canonical_query(&[]), "");
}
#[test]
fn dates() {
assert_eq!(amz_date_from_unix(0), "19700101T000000Z");
assert_eq!(amz_date_from_unix(1_369_353_600), "20130524T000000Z");
assert_eq!(amz_date_from_unix(1_761_264_000), "20251024T000000Z");
assert_eq!(amz_date_from_unix(951_782_400), "20000229T000000Z");
}
}