use super::{AccessKeySecret, AliyunAuth, hexed_sha256, hmac_sha256, percent_encode};
use crate::QueryValue;
use crate::Result;
use anyhow::Context;
use http::{HeaderMap, HeaderValue};
use std::borrow::Cow;
use time::OffsetDateTime;
use tracing::debug;
use reqwest::Body;
fn hexed_hmac_sha256(key: &[u8], to_sign: &[u8]) -> Result<String> {
let result = hmac_sha256(key, to_sign)?;
Ok(hex::encode(result))
}
#[derive(Clone, Debug)]
pub struct Oss4HmacSha256 {
credentials: AccessKeySecret,
region: Cow<'static, str>,
}
impl Oss4HmacSha256 {
pub const UNSIGNED_PAYLOAD: &'static str = "UNSIGNED-PAYLOAD";
const OSS4_SIGNATURE_ALGORITHM: &'static str = "OSS4-HMAC-SHA256";
pub fn new(key_secret: AccessKeySecret, region: impl Into<Cow<'static, str>>) -> Self {
Self {
credentials: key_secret,
region: region.into(),
}
}
}
impl AliyunAuth for Oss4HmacSha256 {
fn create_headers(&self, _action: &str, _version: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static(Self::UNSIGNED_PAYLOAD),
);
headers.insert(
"x-oss-date",
HeaderValue::from_str(&format_oss_timestamp(OffsetDateTime::now_utc())?)
.context("convert timestamp to header value")?,
);
Ok(headers)
}
fn sign(
&self,
headers: &mut HeaderMap,
_path: &str,
query_string: &str,
method: &str,
_body: &Body,
resource_path: &str,
) -> Result<String> {
let timestamp = headers
.get("x-oss-date")
.context("x-oss-date header is required for OSS V4 signing")?
.to_str()
.context("convert x-oss-date header value to string")?;
let sign_date = ×tamp[..8];
let canonical_path = resource_path;
let (canonical_request, additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
method,
&canonical_path,
query_string,
headers,
)?;
debug!("canonical_request: {}", canonical_request);
debug!("additional_headers_str: {}", additional_headers_str);
let hashed_canonical_request = hexed_sha256(&canonical_request);
let scope = format!("{}/{}/oss/aliyun_v4_request", sign_date, self.region);
let string_to_sign = format!(
"{}\n{}\n{}\n{}",
Self::OSS4_SIGNATURE_ALGORITHM,
timestamp,
scope,
hashed_canonical_request
);
let signing_key = derive_signing_key(
self.credentials.access_key_secret(),
sign_date,
&self.region,
)?;
let signature = hexed_hmac_sha256(&signing_key, string_to_sign.as_bytes())?;
let credential = format!("{}/{}", self.credentials.access_key_id(), scope);
Ok(if additional_headers_str.is_empty() {
format!(
"{} Credential={}, Signature={}",
Self::OSS4_SIGNATURE_ALGORITHM,
credential,
signature
)
} else {
format!(
"{} Credential={}, Signature={}, AdditionalHeaders={}",
Self::OSS4_SIGNATURE_ALGORITHM,
credential,
signature,
additional_headers_str
)
})
}
fn canonical_query_string(&self, values: Vec<(Cow<'static, str>, QueryValue)>) -> String {
let mut map: Vec<(String, String)> = Vec::new();
for (k, v) in values {
let encoded_key = percent_encode(&k).to_string();
let encoded_value = percent_encode(&v.to_query_value()).to_string();
map.push((encoded_key, encoded_value));
}
map.sort_by(|a, b| a.0.cmp(&b.0));
map.into_iter()
.map(|(encoded_key, encoded_value)| {
if encoded_value.is_empty() {
encoded_key
} else {
format!("{}={}", encoded_key, encoded_value)
}
})
.collect::<Vec<_>>()
.join("&")
}
}
fn derive_signing_key(secret: &str, date: &str, region: &str) -> Result<Vec<u8>> {
let initial_key = format!("aliyun_v4{}", secret);
let date_key = hmac_sha256(initial_key.as_bytes(), date.as_bytes())?;
let date_region_key = hmac_sha256(&date_key, region.as_bytes())?;
let date_region_service_key = hmac_sha256(&date_region_key, b"oss")?;
let signing_key = hmac_sha256(&date_region_service_key, b"aliyun_v4_request")?;
Ok(signing_key)
}
fn format_oss_timestamp(dt: OffsetDateTime) -> Result<String> {
use time::format_description::well_known::iso8601::Config;
use time::format_description::well_known::iso8601::TimePrecision;
use time::format_description::well_known::{Iso8601, iso8601::EncodedConfig};
const CONFIG: EncodedConfig = Config::DEFAULT
.set_time_precision(TimePrecision::Second {
decimal_digits: None,
})
.encode();
const FORMAT: Iso8601<CONFIG> = Iso8601::<CONFIG>;
let formatted = dt.format(&FORMAT).context("format OSS timestamp failed")?;
let oss_formatted = formatted.replace("-", "").replace(":", "");
Ok(oss_formatted)
}
fn build_oss4_canonical_request_and_additional_headers(
method: &str,
path: &str,
query_string: &str,
headers: &HeaderMap,
) -> Result<(String, String)> {
use std::collections::{BTreeMap, BTreeSet};
let mut canonical_headers_map: BTreeMap<String, String> = BTreeMap::new();
let mut additional_headers_set: BTreeSet<String> = BTreeSet::new();
for (k, v) in headers.iter() {
let key = k.as_str().to_lowercase();
let value = v.to_str().context("convert header value to string")?;
if key.starts_with("x-oss-") {
canonical_headers_map.insert(key, value.to_string());
} else if key == "content-type" || key == "content-md5" {
canonical_headers_map.insert(key, value.to_string());
} else if matches!(key.as_str(), "content-disposition" | "content-length") {
canonical_headers_map.insert(key.clone(), value.to_string());
additional_headers_set.insert(key);
}
}
let mut canonical_headers = String::new();
for (k, v) in &canonical_headers_map {
canonical_headers.push_str(k);
canonical_headers.push(':');
canonical_headers.push_str(v);
canonical_headers.push('\n');
}
let additional_headers_str: String = additional_headers_set
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(";");
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method,
path,
query_string,
canonical_headers,
additional_headers_str,
Oss4HmacSha256::UNSIGNED_PAYLOAD,
);
Ok((canonical_request, additional_headers_str))
}
#[cfg(test)]
mod tests {
use super::*;
use http::header::HeaderValue;
use test_log::test;
#[test]
fn test_oss4_hmac_sha256_sign_empty_query() {
let auth = Oss4HmacSha256::new(
AccessKeySecret::new("test-access-key-id", "test-access-key-secret"),
"cn-hangzhou",
);
let body: Body = b"".as_slice().into();
let mut headers = auth.create_headers("", "").unwrap();
headers.insert("x-oss-date", HeaderValue::from_static("20240101T000000Z"));
let query_string = "";
let result = auth
.sign(&mut headers, "/", query_string, "GET", &body, "/")
.unwrap();
assert!(result.starts_with("OSS4-HMAC-SHA256 Credential=test-access-key-id/"));
assert!(result.contains("/cn-hangzhou/oss/aliyun_v4_request"));
assert!(result.contains("Signature="));
}
#[test]
fn test_oss4_hmac_sha256_sign_with_query() {
let auth = Oss4HmacSha256::new(
AccessKeySecret::new("test-access-key-id", "test-access-key-secret"),
"cn-hangzhou",
);
let body: Body = b"".as_slice().into();
let mut headers = auth.create_headers("", "").unwrap();
headers.insert("x-oss-date", HeaderValue::from_static("20240101T000000Z"));
let query_string = "max-keys=100&prefix=test%2F";
let result = auth
.sign(&mut headers, "/", query_string, "GET", &body, "/")
.unwrap();
assert!(result.starts_with("OSS4-HMAC-SHA256 Credential=test-access-key-id/"));
assert!(result.contains("/cn-hangzhou/oss/aliyun_v4_request"));
assert!(result.contains("Signature="));
}
#[test]
fn test_oss4_full_signature_example() {
let auth = Oss4HmacSha256::new(
AccessKeySecret::new("LTAI**************", "yourAccessKeySecret"),
"cn-hangzhou",
);
let mut headers = auth.create_headers("", "").unwrap();
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("content-length", HeaderValue::from_static("3"));
headers.insert(
"content-md5",
HeaderValue::from_static("ICy5YqxZB1uWSwcVLSNLcA=="),
);
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
let result = auth
.sign(
&mut headers,
"/examplebucket/exampleobject",
"",
"PUT",
&b"".as_slice().into(),
"/",
)
.unwrap();
assert!(result.starts_with("OSS4-HMAC-SHA256 Credential=LTAI**************/20250411/cn-hangzhou/oss/aliyun_v4_request"));
assert!(result.contains("AdditionalHeaders="));
assert!(result.contains("Signature="));
if let Some(sig_pos) = result.find("Signature=") {
let sig_start = sig_pos + "Signature=".len();
let sig_end = result[sig_start..]
.find(',')
.unwrap_or(result[sig_start..].len());
let sig = &result[sig_start..sig_start + sig_end];
assert_eq!(sig.len(), 64, "Signature should be 64 hex characters");
assert!(
sig.chars().all(|c| c.is_ascii_hexdigit()),
"Signature should be hex digits"
);
}
}
#[test]
fn test_oss4_canonical_request_hash() {
let canonical_request = "PUT
/examplebucket/exampleobject
content-disposition:attachment
content-length:3
content-md5:ICy5YqxZB1uWSwcVLSNLcA==
content-type:text/plain
x-oss-content-sha256:UNSIGNED-PAYLOAD
x-oss-date:20250411T064124Z
content-disposition;content-length
UNSIGNED-PAYLOAD";
let hash = hexed_sha256(canonical_request);
let expected_hash = "c46d96390bdbc2d739ac9363293ae9d710b14e48081fcb22cd8ad54b63136eca";
assert_eq!(hash, expected_hash);
}
#[test]
fn test_oss4_additional_headers_excludes_default_signing_headers() {
let auth = Oss4HmacSha256::new(
AccessKeySecret::new("test-key", "test-secret"),
"cn-hangzhou",
);
let mut headers = auth.create_headers("", "").unwrap();
headers.insert("content-type", HeaderValue::from_static("application/json"));
headers.insert("content-md5", HeaderValue::from_static("abc123"));
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
let result = auth
.sign(
&mut headers,
"/test",
"",
"GET",
&b"".as_slice().into(),
"/",
)
.unwrap();
assert!(
result.contains("AdditionalHeaders=content-disposition"),
"AdditionalHeaders should contain content-disposition"
);
assert!(
!result.contains("content-type")
|| result
.split("AdditionalHeaders=")
.all(|part| !part.starts_with("content-type")),
"AdditionalHeaders should NOT contain content-type (it's a default signing header)"
);
assert!(
!result.contains("content-md5")
|| result
.split("AdditionalHeaders=")
.all(|part| !part.starts_with("content-md5")),
"AdditionalHeaders should NOT contain content-md5 (it's a default signing header)"
);
if let Some(additional_part) = result.split("AdditionalHeaders=").nth(1) {
let additional_headers_value = additional_part.split(',').next().unwrap_or("");
assert_eq!(
additional_headers_value, "content-disposition",
"AdditionalHeaders should only contain 'content-disposition', not 'content-type' or 'content-md5'"
);
}
}
#[test]
fn test_oss4_signature_format_matches_documentation() {
let auth = Oss4HmacSha256::new(
AccessKeySecret::new("LTAI**************", "yourAccessKeySecret"),
"cn-hangzhou",
);
let mut headers = auth.create_headers("", "").unwrap();
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("content-length", HeaderValue::from_static("3"));
headers.insert(
"content-md5",
HeaderValue::from_static("ICy5YqxZB1uWSwcVLSNLcA=="),
);
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
let result = auth
.sign(
&mut headers,
"/examplebucket/exampleobject",
"",
"PUT",
&b"".as_slice().into(),
"/",
)
.unwrap();
assert!(
result.starts_with("OSS4-HMAC-SHA256 Credential=LTAI**************/20250411/cn-hangzhou/oss/aliyun_v4_request"),
"Credential format should match documentation"
);
assert!(
result.contains("AdditionalHeaders=content-disposition;content-length"),
"AdditionalHeaders should be 'content-disposition;content-length', not include content-type or content-md5"
);
assert!(
!result.contains("AdditionalHeaders=content-type"),
"AdditionalHeaders should NOT contain content-type"
);
assert!(
!result.contains("AdditionalHeaders=content-md5"),
"AdditionalHeaders should NOT contain content-md5"
);
}
#[test]
fn test_oss4_canonical_request_from_documentation() {
let mut headers = HeaderMap::new();
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("content-length", HeaderValue::from_static("3"));
headers.insert(
"content-md5",
HeaderValue::from_static("ICy5YqxZB1uWSwcVLSNLcA=="),
);
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
let (canonical_request, _additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"PUT",
"/examplebucket/exampleobject",
"",
&headers,
)
.unwrap();
let expected = r#"PUT
/examplebucket/exampleobject
content-disposition:attachment
content-length:3
content-md5:ICy5YqxZB1uWSwcVLSNLcA==
content-type:text/plain
x-oss-content-sha256:UNSIGNED-PAYLOAD
x-oss-date:20250411T064124Z
content-disposition;content-length
UNSIGNED-PAYLOAD"#;
assert_eq!(canonical_request, expected);
}
#[test]
fn test_oss4_canonical_request_empty_query() {
let mut headers = HeaderMap::new();
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
let (canonical_request, _additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"GET",
"/test-bucket/",
"",
&headers,
)
.unwrap();
assert!(canonical_request.starts_with("GET\n/test-bucket/\n\n"));
assert!(canonical_request.contains("x-oss-date:20250411T064124Z"));
assert!(canonical_request.ends_with("UNSIGNED-PAYLOAD"));
}
#[test]
fn test_oss4_canonical_request_with_query_string() {
let mut headers = HeaderMap::new();
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
let (canonical_request, _additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"GET",
"/test-bucket/test-object",
"max-keys=100&prefix=test%2F",
&headers,
)
.unwrap();
assert!(
canonical_request
.starts_with("GET\n/test-bucket/test-object\nmax-keys=100&prefix=test%2F\n")
);
}
#[test]
fn test_oss4_canonical_request_headers_ordering() {
let mut headers = HeaderMap::new();
headers.insert("content-length", HeaderValue::from_static("1024"));
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
let (canonical_request, _additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"PUT",
"/bucket/object",
"",
&headers,
)
.unwrap();
let cd_pos = canonical_request
.find("content-disposition:attachment")
.unwrap();
let cl_pos = canonical_request.find("content-length:1024").unwrap();
let ct_pos = canonical_request.find("content-type:text/plain").unwrap();
assert!(
cd_pos < cl_pos,
"content-disposition should come before content-length"
);
assert!(
cl_pos < ct_pos,
"content-length should come before content-type"
);
}
#[test]
fn test_oss4_canonical_request_without_additional_headers() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("application/octet-stream"),
);
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
let (canonical_request, _additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"PUT",
"/bucket/object",
"",
&headers,
)
.unwrap();
assert!(canonical_request.contains("content-type:application/octet-stream"));
assert!(canonical_request.contains("\n\nUNSIGNED-PAYLOAD"));
}
#[test]
fn test_oss4_canonical_request_additional_headers_field() {
let mut headers = HeaderMap::new();
headers.insert(
"content-disposition",
HeaderValue::from_static("attachment"),
);
headers.insert("content-length", HeaderValue::from_static("1024"));
headers.insert("content-md5", HeaderValue::from_static("abc123"));
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert("x-oss-date", HeaderValue::from_static("20250411T064124Z"));
headers.insert(
"x-oss-content-sha256",
HeaderValue::from_static("UNSIGNED-PAYLOAD"),
);
let (canonical_request, additional_headers_str) =
build_oss4_canonical_request_and_additional_headers(
"PUT",
"/bucket/object",
"",
&headers,
)
.unwrap();
assert!(canonical_request.contains("content-disposition:attachment"));
assert!(canonical_request.contains("content-length:1024"));
assert!(canonical_request.contains("content-md5:abc123"));
assert!(canonical_request.contains("content-type:text/plain"));
assert_eq!(
additional_headers_str, "content-disposition;content-length",
"AdditionalHeaders field should only contain content-disposition and content-length"
);
assert!(
!additional_headers_str.contains("content-type"),
"content-type should not be in AdditionalHeaders"
);
assert!(
!additional_headers_str.contains("content-md5"),
"content-md5 should not be in AdditionalHeaders"
);
}
#[test]
fn test_oss4_signature_alignment() -> Result<()> {
let access_key_id = "LTAI****************";
let secret = "yourAccessKeySecret";
let region = "cn-hangzhou";
let timestamp = "20250411T064124Z";
let sign_date = "20250411";
let auth = Oss4HmacSha256::new(AccessKeySecret::new(access_key_id, secret), region);
let mut headers = auth.create_headers("", "")?;
headers.insert("x-oss-date", timestamp.parse().unwrap());
headers.insert("content-type", "text/plain".parse().unwrap());
headers.insert("content-md5", "ICy5YqxZB1uWSwcVLSNLcA==".parse().unwrap());
headers.insert("content-length", "3".parse().unwrap());
headers.insert("content-disposition", "attachment".parse().unwrap());
let method = "PUT";
let path = "/examplebucket/exampleobject";
let query_string = "";
let (canonical_request, _) = build_oss4_canonical_request_and_additional_headers(
method,
path,
query_string,
&headers,
)?;
let hashed_cr = hexed_sha256(canonical_request.as_bytes());
assert_eq!(
hashed_cr,
"c46d96390bdbc2d739ac9363293ae9d710b14e48081fcb22cd8ad54b63136eca"
);
let signing_key = derive_signing_key(secret, sign_date, region)?;
assert_eq!(
hex::encode(&signing_key),
"8a01ff4efcc65ca2cbc75375045c61ab5f3fa8b9a2d84f0add27ef16a25feb3c"
);
let signature_res = auth.sign(
&mut headers,
"",
query_string,
method,
&b"".as_slice().into(),
path,
)?;
assert!(
signature_res
.contains("d3694c2dfc5371ee6acd35e88c4871ac95a7ba01d3a2f476768fe61218590097")
);
Ok(())
}
#[test]
fn test_oss4_auth_header_from_python_test() -> Result<()> {
let auth = Oss4HmacSha256::new(AccessKeySecret::new("ak", "sk"), "cn-hangzhou");
let mut headers = auth.create_headers("", "")?;
headers.insert("x-oss-head1", HeaderValue::from_static("value"));
headers.insert("abc", HeaderValue::from_static("value"));
headers.insert("ZAbc", HeaderValue::from_static("value"));
headers.insert("XYZ", HeaderValue::from_static("value"));
headers.insert("content-type", HeaderValue::from_static("text/plain"));
headers.insert("x-oss-date", HeaderValue::from_static("20231216T162057Z"));
let query_string =
"%2Bparam1=value3&%2Bparam2&%7Cparam1=value4&%7Cparam2¶m1=value1¶m2";
let path = "/bucket/1234%2B-/123/1.txt";
let result = auth
.sign(
&mut headers,
"",
query_string,
"PUT",
&b"".as_slice().into(),
path,
)
.unwrap();
assert_eq!(
"OSS4-HMAC-SHA256 Credential=ak/20231216/cn-hangzhou/oss/aliyun_v4_request, Signature=e21d18daa82167720f9b1047ae7e7f1ce7cb77a31e8203a7d5f4624fa0284afe",
&result
);
println!("Authorization header: {}", result);
Ok(())
}
}