AkurAI Build
Menu

Public repository / master

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");
    }
}