use anyhow::{Context, Result};
use hmac::{Hmac, Mac};
use reqwest::header::HeaderMap;
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
fn canonical_host(parsed: &reqwest::Url) -> Result<String> {
let host = parsed.host_str().context("no host in S3 URL")?;
Ok(match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
})
}
pub fn sign_s3_empty_body(
method: &str,
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
let now = chrono::Utc::now();
let date = now.format("%Y%m%d").to_string();
let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
let host = canonical_host(&parsed)?;
let uri = parsed.path().to_string();
let empty_hash = {
let mut h = Sha256::new();
h.update(b"");
hex::encode(h.finalize())
};
let canonical_headers = format!(
"content-length:0\nhost:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
);
let signed_headers = "content-length;host;x-amz-content-sha256;x-amz-date";
let canonical_request =
format!("{method}\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
let cr_hash = {
let mut h = Sha256::new();
h.update(canonical_request.as_bytes());
hex::encode(h.finalize())
};
let credential_scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
};
let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sign(&date_key, region.as_bytes());
let date_region_service_key = hmac_sign(&date_region_key, b"s3");
let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders={signed_headers}, Signature={signature}"
);
let mut headers = HeaderMap::new();
headers.insert("host", host.parse()?);
headers.insert("x-amz-date", datetime.parse()?);
headers.insert("x-amz-content-sha256", empty_hash.parse()?);
headers.insert("content-length", "0".parse()?);
headers.insert("authorization", authorization.parse()?);
Ok(headers)
}
pub fn sign_s3_put_bucket(
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
sign_s3_empty_body("PUT", url, region, access_key, secret_key)
}
pub fn sign_s3_head_bucket(
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
sign_s3_empty_body("HEAD", url, region, access_key, secret_key)
}
pub fn sign_s3_delete_bucket(
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
sign_s3_empty_body("DELETE", url, region, access_key, secret_key)
}
pub fn sign_s3_get_with_query(
url: &str,
canonical_query: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
sign_s3_no_body("GET", url, canonical_query, region, access_key, secret_key)
}
pub fn sign_s3_no_body(
method: &str,
url: &str,
canonical_query: &str,
region: &str,
access_key: &str,
secret_key: &str,
) -> Result<HeaderMap> {
let now = chrono::Utc::now();
let date = now.format("%Y%m%d").to_string();
let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
let host = canonical_host(&parsed)?;
let uri = parsed.path().to_string();
let empty_hash = {
let mut h = Sha256::new();
h.update(b"");
hex::encode(h.finalize())
};
let canonical_headers = format!(
"host:{host}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"{method}\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}"
);
let cr_hash = {
let mut h = Sha256::new();
h.update(canonical_request.as_bytes());
hex::encode(h.finalize())
};
let credential_scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
};
let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sign(&date_key, region.as_bytes());
let date_region_service_key = hmac_sign(&date_region_key, b"s3");
let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders={signed_headers}, Signature={signature}"
);
let mut headers = HeaderMap::new();
headers.insert("host", host.parse()?);
headers.insert("x-amz-date", datetime.parse()?);
headers.insert("x-amz-content-sha256", empty_hash.parse()?);
headers.insert("authorization", authorization.parse()?);
Ok(headers)
}
#[derive(Debug, Clone, Default)]
pub struct S3PutOptions<'a> {
pub content_type: &'a str,
pub blake3_meta: Option<&'a str>,
pub cache_control: Option<&'a str>,
}
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
pub const CACHE_CONTROL_NO_CACHE: &str = "no-cache, max-age=0";
pub fn sign_s3_put_object(
url: &str,
body_sha256: &str,
content_type: &str,
content_length: usize,
region: &str,
access_key: &str,
secret_key: &str,
blake3_meta: Option<&str>,
) -> Result<HeaderMap> {
sign_s3_put_object_with(
url,
body_sha256,
content_length,
region,
access_key,
secret_key,
&S3PutOptions {
content_type,
blake3_meta,
cache_control: None,
},
)
}
pub fn sign_s3_put_object_with(
url: &str,
body_sha256: &str,
content_length: usize,
region: &str,
access_key: &str,
secret_key: &str,
opts: &S3PutOptions<'_>,
) -> Result<HeaderMap> {
let S3PutOptions {
content_type,
blake3_meta,
cache_control,
} = *opts;
let now = chrono::Utc::now();
let date = now.format("%Y%m%d").to_string();
let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
let parsed = reqwest::Url::parse(url).context("parsing S3 object URL")?;
let host = canonical_host(&parsed)?;
let uri = parsed.path().to_string();
let mut signed: Vec<(&str, String)> = Vec::with_capacity(7);
if let Some(cc) = cache_control {
signed.push(("cache-control", cc.to_string()));
}
signed.push(("content-length", content_length.to_string()));
signed.push(("content-type", content_type.to_string()));
signed.push(("host", host.clone()));
signed.push(("x-amz-content-sha256", body_sha256.to_string()));
signed.push(("x-amz-date", datetime.clone()));
if let Some(b3) = blake3_meta {
signed.push(("x-amz-meta-blake3", b3.to_string()));
}
debug_assert!(
signed.windows(2).all(|w| w[0].0 < w[1].0),
"canonical headers must be lexicographically ordered: {:?}",
signed.iter().map(|(n, _)| *n).collect::<Vec<_>>()
);
let canonical_headers: String = signed
.iter()
.map(|(name, value)| format!("{name}:{value}\n"))
.collect();
let signed_headers = signed
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(";");
let signed_headers = signed_headers.as_str();
let canonical_request =
format!("PUT\n{uri}\n\n{canonical_headers}\n{signed_headers}\n{body_sha256}");
let cr_hash = {
let mut h = Sha256::new();
h.update(canonical_request.as_bytes());
hex::encode(h.finalize())
};
let credential_scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
};
let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sign(&date_key, region.as_bytes());
let date_region_service_key = hmac_sign(&date_region_key, b"s3");
let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders={signed_headers}, Signature={signature}"
);
let mut headers = HeaderMap::new();
headers.insert("host", host.parse()?);
headers.insert("x-amz-date", datetime.parse()?);
headers.insert("x-amz-content-sha256", body_sha256.parse()?);
headers.insert("content-length", content_length.to_string().parse()?);
headers.insert("content-type", content_type.parse()?);
if let Some(cc) = cache_control {
headers.insert("cache-control", cc.parse()?);
}
if let Some(b3) = blake3_meta {
headers.insert("x-amz-meta-blake3", b3.parse()?);
}
headers.insert("authorization", authorization.parse()?);
Ok(headers)
}
pub fn sign_s3_put_bucket_policy(
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
policy_json: &[u8],
) -> Result<HeaderMap> {
let now = chrono::Utc::now();
let date = now.format("%Y%m%d").to_string();
let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
let host = canonical_host(&parsed)?;
let uri = parsed.path().to_string();
let canonical_query = "policy=";
let content_length = policy_json.len();
let body_hash = {
let mut h = Sha256::new();
h.update(policy_json);
hex::encode(h.finalize())
};
let canonical_headers = format!(
"content-length:{content_length}\ncontent-type:application/json\nhost:{host}\nx-amz-content-sha256:{body_hash}\nx-amz-date:{datetime}\n"
);
let signed_headers = "content-length;content-type;host;x-amz-content-sha256;x-amz-date";
let canonical_request =
format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{body_hash}");
let cr_hash = {
let mut h = Sha256::new();
h.update(canonical_request.as_bytes());
hex::encode(h.finalize())
};
let credential_scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
};
let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sign(&date_key, region.as_bytes());
let date_region_service_key = hmac_sign(&date_region_key, b"s3");
let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders={signed_headers}, Signature={signature}"
);
let mut headers = HeaderMap::new();
headers.insert("host", host.parse()?);
headers.insert("x-amz-date", datetime.parse()?);
headers.insert("x-amz-content-sha256", body_hash.parse()?);
headers.insert("content-length", content_length.to_string().parse()?);
headers.insert("content-type", "application/json".parse()?);
headers.insert("authorization", authorization.parse()?);
Ok(headers)
}
pub fn sign_s3_put_bucket_acl(
url: &str,
region: &str,
access_key: &str,
secret_key: &str,
acl: &str,
) -> Result<HeaderMap> {
let now = chrono::Utc::now();
let date = now.format("%Y%m%d").to_string();
let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();
let parsed = reqwest::Url::parse(url).context("parsing S3 URL")?;
let host = canonical_host(&parsed)?;
let uri = parsed.path().to_string();
let canonical_query = "acl=";
let empty_hash = {
let mut h = Sha256::new();
h.update(b"");
hex::encode(h.finalize())
};
let canonical_headers = format!(
"content-length:0\nhost:{host}\nx-amz-acl:{acl}\nx-amz-content-sha256:{empty_hash}\nx-amz-date:{datetime}\n"
);
let signed_headers = "content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date";
let canonical_request =
format!("PUT\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{empty_hash}");
let cr_hash = {
let mut h = Sha256::new();
h.update(canonical_request.as_bytes());
hex::encode(h.finalize())
};
let credential_scope = format!("{date}/{region}/s3/aws4_request");
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{cr_hash}");
let hmac_sign = |key: &[u8], data: &[u8]| -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
};
let date_key = hmac_sign(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sign(&date_key, region.as_bytes());
let date_region_service_key = hmac_sign(&date_region_key, b"s3");
let signing_key = hmac_sign(&date_region_service_key, b"aws4_request");
let signature = hex::encode(hmac_sign(&signing_key, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders={signed_headers}, Signature={signature}"
);
let mut headers = HeaderMap::new();
headers.insert("host", host.parse()?);
headers.insert("x-amz-date", datetime.parse()?);
headers.insert("x-amz-content-sha256", empty_hash.parse()?);
headers.insert("content-length", "0".parse()?);
headers.insert("x-amz-acl", acl.parse()?);
headers.insert("authorization", authorization.parse()?);
Ok(headers)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_host_carries_a_nondefault_port() {
let u = reqwest::Url::parse("http://127.0.0.1:9000/yah-dev/yah/index.json").unwrap();
assert_eq!(canonical_host(&u).unwrap(), "127.0.0.1:9000");
let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com/yah-dev/k").unwrap();
assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
let u = reqwest::Url::parse("https://acct.r2.cloudflarestorage.com:443/yah-dev/k").unwrap();
assert_eq!(canonical_host(&u).unwrap(), "acct.r2.cloudflarestorage.com");
}
#[test]
fn signed_headers_include_the_port_for_a_local_endpoint() {
let headers = sign_s3_put_object(
"http://127.0.0.1:9000/yah-dev/yah/index.json",
&"0".repeat(64),
"application/json",
10,
"auto",
"AK",
"SK",
None,
)
.unwrap();
assert_eq!(
headers.get("host").unwrap().to_str().unwrap(),
"127.0.0.1:9000"
);
}
#[test]
fn sign_produces_required_headers() {
let headers = sign_s3_put_bucket(
"https://fsn1.your-objectstorage.com/test-bucket",
"fsn1",
"AK",
"SK",
)
.unwrap();
assert!(headers.contains_key("authorization"));
assert!(headers.contains_key("x-amz-date"));
assert!(headers.contains_key("x-amz-content-sha256"));
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
assert!(
auth.contains("SignedHeaders=content-length;host;x-amz-content-sha256;x-amz-date")
);
}
#[test]
fn put_object_signs_the_blake3_metadata_header() {
let b3 = "d".repeat(64);
let headers = sign_s3_put_object(
"https://acct.r2.cloudflarestorage.com/yah-dev/some/key.tar.gz",
&"0".repeat(64),
"application/octet-stream",
123,
"auto",
"AK",
"SK",
Some(&b3),
)
.unwrap();
assert_eq!(headers.get("x-amz-meta-blake3").unwrap().to_str().unwrap(), b3);
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(
auth.contains(
"SignedHeaders=content-length;content-type;host;x-amz-content-sha256;\
x-amz-date;x-amz-meta-blake3"
),
"metadata header must be inside SignedHeaders, got: {auth}"
);
}
#[test]
fn put_object_without_metadata_keeps_the_original_signed_header_set() {
let headers = sign_s3_put_object(
"https://acct.r2.cloudflarestorage.com/yah-dev/some/key.bin",
&"0".repeat(64),
"application/octet-stream",
7,
"auto",
"AK",
"SK",
None,
)
.unwrap();
assert!(!headers.contains_key("x-amz-meta-blake3"));
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(
auth.contains(
"SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date,"
),
"unstamped PUT must keep the original signed-header set, got: {auth}"
);
}
#[test]
fn put_object_signs_cache_control_ahead_of_content_length() {
let headers = sign_s3_put_object_with(
"https://acct.r2.cloudflarestorage.com/yah-dev/yah-desktop/latest.json",
&"0".repeat(64),
42,
"auto",
"AK",
"SK",
&S3PutOptions {
content_type: "application/json",
blake3_meta: None,
cache_control: Some(CACHE_CONTROL_NO_CACHE),
},
)
.unwrap();
assert_eq!(
headers.get("cache-control").unwrap().to_str().unwrap(),
"no-cache, max-age=0"
);
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(
auth.contains(
"SignedHeaders=cache-control;content-length;content-type;host;\
x-amz-content-sha256;x-amz-date,"
),
"cache-control must be signed, and first, got: {auth}"
);
}
#[test]
fn put_object_signs_cache_control_and_blake3_together_in_order() {
let b3 = "e".repeat(64);
let headers = sign_s3_put_object_with(
"https://acct.r2.cloudflarestorage.com/yah-dev/a/v1.2.3/yah.tar.gz",
&"0".repeat(64),
9,
"auto",
"AK",
"SK",
&S3PutOptions {
content_type: "application/octet-stream",
blake3_meta: Some(&b3),
cache_control: Some(CACHE_CONTROL_IMMUTABLE),
},
)
.unwrap();
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(
auth.contains(
"SignedHeaders=cache-control;content-length;content-type;host;\
x-amz-content-sha256;x-amz-date;x-amz-meta-blake3,"
),
"got: {auth}"
);
assert_eq!(
headers.get("cache-control").unwrap().to_str().unwrap(),
"public, max-age=31536000, immutable"
);
}
#[test]
fn the_options_form_and_the_legacy_form_sign_identically() {
let url = "https://acct.r2.cloudflarestorage.com/yah-dev/k.bin";
let legacy =
sign_s3_put_object(url, &"0".repeat(64), "text/plain", 3, "auto", "AK", "SK", None)
.unwrap();
let with_opts = sign_s3_put_object_with(
url,
&"0".repeat(64),
3,
"auto",
"AK",
"SK",
&S3PutOptions {
content_type: "text/plain",
blake3_meta: None,
cache_control: None,
},
)
.unwrap();
assert!(!with_opts.contains_key("cache-control"));
let names = |h: &HeaderMap| {
let mut v: Vec<String> = h.keys().map(|k| k.as_str().to_string()).collect();
v.sort();
v
};
assert_eq!(names(&legacy), names(&with_opts));
}
#[test]
fn sign_get_with_query_signed_headers_omit_content_length() {
let headers = sign_s3_get_with_query(
"https://acct.r2.cloudflarestorage.com/yah-dev",
"list-type=2&prefix=whisper%2F",
"auto",
"AK",
"SK",
)
.unwrap();
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
assert!(
auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
"GET with query must NOT include content-length in SignedHeaders: {auth}"
);
assert!(!headers.contains_key("content-length"));
}
#[test]
fn sign_no_body_get_object_omits_content_length() {
let headers = sign_s3_no_body(
"GET",
"https://acct.r2.cloudflarestorage.com/yah-dev/_yah-manifest.json",
"",
"auto",
"AK",
"SK",
)
.unwrap();
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(
auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
"object GET must NOT sign content-length: {auth}"
);
assert!(!headers.contains_key("content-length"));
}
#[test]
fn sign_no_body_head_uses_head_method() {
let head = sign_s3_no_body(
"HEAD",
"https://acct.r2.cloudflarestorage.com/yah-dev/k",
"",
"auto",
"AK",
"SK",
)
.unwrap();
let get = sign_s3_no_body(
"GET",
"https://acct.r2.cloudflarestorage.com/yah-dev/k",
"",
"auto",
"AK",
"SK",
)
.unwrap();
assert!(!head.contains_key("content-length"));
assert_ne!(
head.get("authorization").unwrap().to_str().unwrap(),
get.get("authorization").unwrap().to_str().unwrap(),
);
}
#[test]
fn sign_put_bucket_acl_includes_acl_header_and_query() {
let headers = sign_s3_put_bucket_acl(
"https://fsn1.your-objectstorage.com/test-bucket?acl",
"fsn1",
"AK",
"SK",
"public-read",
)
.unwrap();
assert!(headers.contains_key("authorization"));
assert!(headers.contains_key("x-amz-acl"));
assert_eq!(headers.get("x-amz-acl").unwrap().to_str().unwrap(), "public-read");
let auth = headers.get("authorization").unwrap().to_str().unwrap();
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
assert!(auth.contains("x-amz-acl"));
assert!(auth.contains("SignedHeaders=content-length;host;x-amz-acl;x-amz-content-sha256;x-amz-date"));
}
}