Menu
Public repository / d63e826eb482cb963477774b04fe8a84f23f0e45
AkurAI-Drive / backend/src/sigv4.rs
Latest change feb03c4c2e9a6b0e869a63e52329c938e68dc8e8 - backend: device-token auth, SigV4 presigning, and file API for the sync client by olibuijr
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
/// All inputs needed to build one AWS Signature V4 presigned URL.
///
/// `now` is injected (not read from the clock) so the signer is a pure function
/// and can be checked against AWS's published test vector.
pub struct Sigv4Params<'a> {
pub access_key: &'a str,
pub secret_key: &'a str,
pub region: &'a str,
pub bucket: &'a str,
pub key: &'a str,
/// HTTP verb the URL is signed for — "PUT" or "GET".
pub method: &'a str,
/// Validity window in seconds (we use 900 = 15 min).
pub expires: u64,
pub now: chrono::DateTime<chrono::Utc>,
}
/// Build a virtual-hosted-style S3 presigned URL (query-string auth,
/// `X-Amz-Algorithm=AWS4-HMAC-SHA256`, `UNSIGNED-PAYLOAD`, signed headers = `host`).
///
/// This is pure compute — no network, no `aws` CLI. The box's `aws s3 presign` is
/// GET-only/SigV2, so PUT presigning has to be done by hand anyway.
pub fn presign(p: &Sigv4Params) -> String {
let host = s3_host(p.bucket, p.region);
let amzdate = p.now.format("%Y%m%dT%H%M%SZ").to_string();
let datestamp = p.now.format("%Y%m%d").to_string();
let scope = format!("{}/{}/s3/aws4_request", datestamp, p.region);
// Canonical URI: "/<key>" with each segment encoded but '/' preserved.
let canonical_uri = format!("/{}", uri_encode(p.key, false));
// Canonical query string: the five X-Amz-* params, sorted by key, encoded.
let credential = format!("{}/{}", p.access_key, scope);
let mut params: Vec<(&str, String)> = vec![
("X-Amz-Algorithm", "AWS4-HMAC-SHA256".to_string()),
("X-Amz-Credential", credential),
("X-Amz-Date", amzdate.clone()),
("X-Amz-Expires", p.expires.to_string()),
("X-Amz-SignedHeaders", "host".to_string()),
];
params.sort_by(|a, b| a.0.cmp(b.0));
let canonical_query = params
.iter()
.map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
.collect::<Vec<_>>()
.join("&");
// Canonical request (signed headers = host only; payload unsigned).
let canonical_request = format!(
"{}\n{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
p.method, canonical_uri, canonical_query, host
);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amzdate,
scope,
sha256_hex(canonical_request.as_bytes())
);
// Derive the signing key, then sign.
let k_date = hmac(
format!("AWS4{}", p.secret_key).as_bytes(),
datestamp.as_bytes(),
);
let k_region = hmac(&k_date, p.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!("https://{host}{canonical_uri}?{canonical_query}&X-Amz-Signature={signature}")
}
/// Virtual-hosted-style host. us-east-1 omits the region segment.
fn s3_host(bucket: &str, region: &str) -> String {
if region == "us-east-1" {
format!("{bucket}.s3.amazonaws.com")
} else {
format!("{bucket}.s3.{region}.amazonaws.com")
}
}
/// AWS-style percent-encoding: leave `A-Za-z0-9-._~` alone, encode everything else
/// as uppercase `%XX`. `/` is encoded only when `encode_slash` is true (query
/// values), never in the path.
fn uri_encode(input: &str, encode_slash: bool) -> String {
let mut out = String::with_capacity(input.len());
for &b in input.as_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
}
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
hex::encode(h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
/// AWS's published presigned-URL test vector (Signature V4, "Example: GET Object").
/// access_key/secret/bucket/key/region/date are fixed by the docs; the documented
/// signature is `aeeed9bb...d404`. Matching it exercises the whole chain: canonical
/// URI, sorted+encoded query string (incl. %2F in the credential), the SHA-256 of
/// the canonical request, the derived signing key, and the final HMAC.
#[test]
fn presign_get_matches_aws_known_vector() {
let now = chrono::DateTime::from_timestamp(1_369_353_600, 0).unwrap(); // 2013-05-24T00:00:00Z
let url = presign(&Sigv4Params {
access_key: "AKIAIOSFODNN7EXAMPLE",
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
bucket: "examplebucket",
key: "test.txt",
method: "GET",
expires: 86400,
now,
});
assert_eq!(
url,
"https://examplebucket.s3.amazonaws.com/test.txt?\
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&\
X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
);
}
#[test]
fn put_uses_regional_host_and_put_method() {
let now = chrono::DateTime::from_timestamp(1_369_353_600, 0).unwrap();
let url = presign(&Sigv4Params {
access_key: "AKIAIOSFODNN7EXAMPLE",
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
region: "eu-west-1",
bucket: "mybucket",
key: "folder/my file.bin",
method: "PUT",
expires: 900,
now,
});
assert!(
url.starts_with("https://mybucket.s3.eu-west-1.amazonaws.com/folder/my%20file.bin?")
);
assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
assert!(url.contains("X-Amz-Expires=900"));
assert!(url.contains("X-Amz-Signature="));
// space encoded in path, region present in credential scope.
assert!(url.contains("X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F"));
assert!(url.contains("%2Feu-west-1%2Fs3%2Faws4_request"));
}
#[test]
fn uri_encode_rules() {
assert_eq!(uri_encode("a/b c", false), "a/b%20c");
assert_eq!(uri_encode("a/b c", true), "a%2Fb%20c");
assert_eq!(uri_encode("-._~AZ09", true), "-._~AZ09");
}
}