use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
pub(crate) const EMPTY_PAYLOAD_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[derive(Clone, PartialEq, Eq)]
pub struct Credentials {
pub access_key_id: String,
pub secret_access_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_id", &self.access_key_id).finish_non_exhaustive()
}
}
impl Credentials {
pub fn from_env() -> Option<Self> {
if env_flag("AWS_NO_SIGN_REQUEST") == Some(true) {
return None;
}
if let (Some(id), Some(secret)) = (env("AWS_ACCESS_KEY_ID"), env("AWS_SECRET_ACCESS_KEY")) {
return Some(Self { access_key_id: id, secret_access_key: secret, session_token: env("AWS_SESSION_TOKEN") });
}
let profile = profile_name();
for (file, section) in [(credentials_file(), profile.clone()), (config_file(), config_section(&profile))] {
let Some(text) = file.and_then(|f| std::fs::read_to_string(f).ok()) else { continue };
let get = |key: &str| ini_value(&text, §ion, key);
if let (Some(id), Some(secret)) = (get("aws_access_key_id"), get("aws_secret_access_key")) {
return Some(Self { access_key_id: id, secret_access_key: secret, session_token: get("aws_session_token") });
}
}
None
}
}
pub fn region_from_env() -> Option<String> {
env("AWS_REGION").or_else(|| env("AWS_DEFAULT_REGION")).or_else(|| {
let text = std::fs::read_to_string(config_file()?).ok()?;
ini_value(&text, &config_section(&profile_name()), "region")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S3Object {
pub bucket: String,
pub key: String,
}
impl S3Object {
pub fn url(&self, region: Option<&str>) -> String {
let key = uri_encode(&self.key, false);
let https = env_flag("AWS_HTTPS") != Some(false);
let scheme = if https { "https" } else { "http" };
if let Some(endpoint) = env("AWS_S3_ENDPOINT") {
let endpoint = endpoint.trim_end_matches('/');
let (scheme, host) = match endpoint.split_once("://") {
Some((s, h)) => (s, h),
None => (scheme, endpoint),
};
return if env_flag("AWS_VIRTUAL_HOSTING") == Some(true) {
format!("{scheme}://{}.{host}/{key}", self.bucket)
} else {
format!("{scheme}://{host}/{}/{key}", self.bucket)
};
}
let host = match region {
Some(r) => format!("s3.{r}.amazonaws.com"),
None => "s3.amazonaws.com".to_string(),
};
if env_flag("AWS_VIRTUAL_HOSTING") == Some(false) || (https && self.bucket.contains('.')) {
format!("{scheme}://{host}/{}/{key}", self.bucket)
} else {
format!("{scheme}://{}.{host}/{key}", self.bucket)
}
}
}
pub(crate) fn sign_get(url: &str, headers: &[(&str, &str)], creds: &Credentials, region: &str, amz_date: &str) -> Vec<(String, String)> {
let (host, path, query) = split_url(url);
let mut all: Vec<(String, String)> = vec![
("host".into(), host.to_string()),
("x-amz-content-sha256".into(), EMPTY_PAYLOAD_SHA256.into()),
("x-amz-date".into(), amz_date.into()),
];
if let Some(token) = &creds.session_token {
all.push(("x-amz-security-token".into(), token.clone()));
}
all.extend(headers.iter().map(|(k, v)| (k.to_string(), v.to_string())));
let authorization = authorization("GET", path, query, &all, EMPTY_PAYLOAD_SHA256, creds, region, "s3", amz_date);
let mut out: Vec<(String, String)> = all.into_iter().filter(|(k, _)| k.starts_with("x-amz-")).collect();
out.push(("Authorization".into(), authorization));
out
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn authorization(
method: &str,
path: &str,
query: &str,
headers: &[(String, String)],
payload_hash: &str,
creds: &Credentials,
region: &str,
service: &str,
amz_date: &str,
) -> String {
let mut canon: Vec<(String, String)> =
headers.iter().map(|(k, v)| (k.trim().to_ascii_lowercase(), v.split_whitespace().collect::<Vec<_>>().join(" "))).collect();
canon.sort();
let signed_headers = canon.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";");
let canonical_headers: String = canon.iter().map(|(k, v)| format!("{k}:{v}\n")).collect();
let mut params: Vec<(&str, &str)> = query.split('&').filter(|p| !p.is_empty()).map(|p| p.split_once('=').unwrap_or((p, ""))).collect();
params.sort();
let canonical_query = params.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
let path = if path.is_empty() { "/" } else { path };
let canonical_request = format!("{method}\n{path}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}");
let date = &amz_date[..8.min(amz_date.len())];
let scope = format!("{date}/{region}/{service}/aws4_request");
let string_to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", hex(&Sha256::digest(canonical_request.as_bytes())));
let k_date = hmac(format!("AWS4{}", creds.secret_access_key).as_bytes(), date.as_bytes());
let k_region = hmac(&k_date, region.as_bytes());
let k_service = hmac(&k_region, service.as_bytes());
let k_signing = hmac(&k_service, b"aws4_request");
let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes()));
format!("AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}", creds.access_key_id)
}
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = Hmac::<Sha256>::new_from_slice(key).unwrap_or_else(|_| unreachable!());
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn split_url(url: &str) -> (&str, &str, &str) {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let rest = rest.split('#').next().unwrap_or(rest);
let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..], query),
None => (rest, "/", query),
}
}
pub(crate) fn uri_encode(s: &str, encode_slash: bool) -> String {
let mut out = String::with_capacity(s.len());
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 !encode_slash => out.push('/'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub(crate) fn amz_date_now() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
amz_date(secs)
}
fn amz_date(secs: u64) -> String {
let days = (secs / 86400) as i64;
let rem = secs % 86400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
pub(crate) fn env_flag(name: &str) -> Option<bool> {
match env(name)?.to_ascii_uppercase().as_str() {
"YES" | "TRUE" | "ON" | "1" => Some(true),
"NO" | "FALSE" | "OFF" | "0" => Some(false),
_ => None,
}
}
fn profile_name() -> String {
env("AWS_PROFILE").or_else(|| env("AWS_DEFAULT_PROFILE")).unwrap_or_else(|| "default".into())
}
fn config_section(profile: &str) -> String {
if profile == "default" { profile.to_string() } else { format!("profile {profile}") }
}
fn aws_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".aws"))
}
fn credentials_file() -> Option<PathBuf> {
env("AWS_SHARED_CREDENTIALS_FILE").map(PathBuf::from).or_else(|| aws_dir().map(|d| d.join("credentials")))
}
fn config_file() -> Option<PathBuf> {
env("AWS_CONFIG_FILE").map(PathBuf::from).or_else(|| aws_dir().map(|d| d.join("config")))
}
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
let mut in_section = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
in_section = name.trim() == section;
} else if in_section
&& let Some((k, v)) = line.split_once('=')
&& k.trim().eq_ignore_ascii_case(key)
{
return Some(v.trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn example_creds() -> Credentials {
Credentials {
access_key_id: "AKIDEXAMPLE".into(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
session_token: None,
}
}
fn headers(h: &[(&str, &str)]) -> Vec<(String, String)> {
h.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn sigv4_get_vanilla() {
let h = headers(&[("Host", "example.amazonaws.com"), ("X-Amz-Date", "20150830T123600Z")]);
let auth = authorization("GET", "/", "", &h, EMPTY_PAYLOAD_SHA256, &example_creds(), "us-east-1", "service", "20150830T123600Z");
assert_eq!(
auth,
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, \
Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
);
}
#[test]
fn sigv4_query_order() {
let h = headers(&[("Host", "example.amazonaws.com"), ("X-Amz-Date", "20150830T123600Z")]);
let auth = authorization(
"GET",
"/",
"Param2=value2&Param1=value1",
&h,
EMPTY_PAYLOAD_SHA256,
&example_creds(),
"us-east-1",
"service",
"20150830T123600Z",
);
assert!(auth.ends_with("Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500"), "{auth}");
}
#[test]
fn sigv4_header_value_trim() {
let h = headers(&[
("Host", "example.amazonaws.com"),
("My-Header1", " value1"),
("My-Header2", " \"a b c\""),
("X-Amz-Date", "20150830T123600Z"),
]);
let auth = authorization("GET", "/", "", &h, EMPTY_PAYLOAD_SHA256, &example_creds(), "us-east-1", "service", "20150830T123600Z");
assert!(auth.ends_with("Signature=acc3ed3afb60bb290fc8d2dd0098b9911fcaa05412b367055dee359757a9c736"), "{auth}");
}
#[test]
fn sigv4_s3_get_object() {
let creds = Credentials {
access_key_id: "AKIAIOSFODNN7EXAMPLE".into(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(),
session_token: None,
};
let added =
sign_get("https://examplebucket.s3.amazonaws.com/test.txt", &[("range", "bytes=0-9")], &creds, "us-east-1", "20130524T000000Z");
let auth = &added.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"
);
assert!(added.iter().any(|(k, v)| k == "x-amz-content-sha256" && v == EMPTY_PAYLOAD_SHA256));
}
#[test]
fn formats_dates() {
assert_eq!(amz_date(0), "19700101T000000Z");
assert_eq!(amz_date(1_369_353_600), "20130524T000000Z");
assert_eq!(amz_date(1_440_938_160), "20150830T123600Z");
assert_eq!(amz_date(951_782_400), "20000229T000000Z");
}
#[test]
fn encodes_keys() {
assert_eq!(uri_encode("a b/c+d~e.tif", false), "a%20b/c%2Bd~e.tif");
assert_eq!(uri_encode("a/b", true), "a%2Fb");
}
#[test]
fn reads_ini_sections() {
let text = "[default]\naws_access_key_id = A\n\n[profile dev]\nregion=eu-west-1\n";
assert_eq!(ini_value(text, "default", "aws_access_key_id").as_deref(), Some("A"));
assert_eq!(ini_value(text, "profile dev", "region").as_deref(), Some("eu-west-1"));
assert_eq!(ini_value(text, "default", "region"), None);
}
}