signv4 1.0.0

AWS Signature Version 4 implementation for Rust
Documentation
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac, digest::FixedOutput};
use sha2::{Digest, Sha256};

mod hmac_msg;

type HmacSha256 = Hmac<Sha256>;

const UTC_ISO_FORMAT: &'static str = "%Y%m%dT%H%M%SZ";
const DATE_FORMAT: &'static str = "%Y%m%d";

pub struct SignatureRequest<'a> {
    headers: Vec<(String, &'a str)>,
    http_verb: &'a str,
    raw_uri: &'a str,

    access_key_id: &'a str,
    secret_access_key: &'a str,

    time: DateTime<Utc>,

    region: &'a str,
    service: &'a str,

    body: &'a [u8],
}

fn format_headers<'a, T>(headers: &'a T) -> Vec<(String, &'a str)>
where
    &'a T: IntoIterator<Item = (&'a &'a str, &'a &'a str)>,
{
    let mut h: Vec<_> = headers
        .into_iter()
        .map(|(k, v)| (k.to_lowercase(), v.trim()))
        .collect();
    h.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
    h
}

fn compose_headers(sorted_headers: &Vec<(String, &str)>) -> String {
    let cap: usize = sorted_headers.iter().fold(0, |acc, (k, v)| {
        // Conservative, because v could contain whitespaces, therefore cap >= needed_cap
        acc + k.len() + 1 + v.len() + 1
    });
    let ch = sorted_headers
        .iter()
        .fold(String::with_capacity(cap), |acc, (k, v)| {
            acc + k + ":" + v.trim() + "\n"
        });
    ch
}

fn compose_request(
    raw_uri: &str,
    http_verb: &str,
    sorted_headers: &[(String, &str)],
    signed_headers: &[&str],
    payload: &[u8],
) -> String {
    // TODO(juf): Do proper error handling
    let uri = url::Url::parse(raw_uri).unwrap();
    let mut creq = String::with_capacity(256);
    creq.push_str(http_verb);
    creq.push_str("\n");
    creq.push_str(uri.path());
    creq.push_str("\n");
    let mut query: Vec<_> = uri.query_pairs().collect();
    if query.len() > 0 {
        query.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
        let pairs: Vec<_> = query
            .iter()
            .map(|(k, v)| {
                format!(
                    "{}={}",
                    percent_encoding::percent_encode(
                        k.as_bytes(),
                        percent_encoding::NON_ALPHANUMERIC
                    ),
                    percent_encoding::percent_encode(
                        v.as_bytes(),
                        percent_encoding::NON_ALPHANUMERIC
                    )
                )
            })
            .collect();
        creq.push_str(&pairs.join("&"));
    }
    creq.push_str("\n");
    if sorted_headers.len() > 0 {
        sorted_headers.iter().for_each(|(k, v)| {
            creq.push_str(k);
            creq.push_str(":");
            creq.push_str(v);
            creq.push_str("\n");
        });
        // Important: there is an extra newline after the headers, before the signed headers
    }
    creq.push_str("\n");
    creq.push_str(&signed_headers.join(";"));
    creq.push_str("\n");
    creq.push_str(sha256_hex_from_ref(payload).as_str());
    creq
}

impl<'a> SignatureRequest<'a> {
    pub fn new<T>(
        headers: &'a T,
        http_verb: &'a str,
        raw_uri: &'a str,
        access_key_id: &'a str,
        secret_access_key: &'a str,
        time: DateTime<Utc>,
        region: &'a str,
        service: &'a str,
        body: &'a [u8],
    ) -> Self
    where
        &'a T: IntoIterator<Item = (&'a &'a str, &'a &'a str)>,
    {
        // TODO(juf): This his hilariously stupid
        let headers = format_headers(headers);
        SignatureRequest {
            headers: headers,
            http_verb,
            raw_uri,
            access_key_id,
            secret_access_key,
            time,
            region,
            service,
            body,
        }
    }
}

impl<'a> SignatureRequest<'a> {
    pub fn canonical_headers(&'a self) -> String {
        compose_headers(&self.headers)
    }

    pub fn canonical_request(&self) -> String {
        // TODO(juf): Do proper error handling
        let signed_headers: Vec<_> = self.headers.iter().map(|(k, _)| k.as_str()).collect();
        let creq = compose_request(
            self.raw_uri,
            self.http_verb,
            &self.headers,
            signed_headers.as_slice(),
            self.body,
        );
        println!("Creq:\n");
        println!("{creq}END");
        println!("\n\n");
        creq
    }

    pub fn string_to_sign(&self) -> String {
        let creq_hash = sha256_hex_from_ref(self.canonical_request());
        let mut sts = String::with_capacity(256);
        sts.push_str("AWS4-HMAC-SHA256\n");
        sts.push_str(self.time.format(UTC_ISO_FORMAT).to_string().as_str());
        sts.push_str("\n");
        sts.push_str(self.time.format(DATE_FORMAT).to_string().as_str());
        sts.push('/');
        sts.push_str(self.region);
        sts.push('/');
        sts.push_str(self.service);
        sts.push('/');
        sts.push_str("aws4_request");
        sts.push_str("\n");
        sts.push_str(&creq_hash);
        println!("STS:");
        println!("{sts}END");
        println!("\n\n");
        sts
    }

    pub fn sign(&self) -> String {
        let sk = generate_signing_key(self.secret_access_key, self.time, self.region, self.service);
        sign_string(sk, self.string_to_sign().as_bytes())
    }

    pub fn authorization_header_with_signature(&self, signature: &str) -> String {
        let mut hd = String::with_capacity(256);
        hd.extend(["AWS4-HMAC-SHA256 ", "Credential=", self.access_key_id]);
        hd.extend(["/", self.time.format(DATE_FORMAT).to_string().as_str()]);
        hd.extend(["/", self.region]);
        hd.extend(["/", self.service]);
        hd.extend(["/", "aws4_request", ",", "SignedHeaders="]);
        let sh = &self
            .headers
            .iter()
            .map(|(k, _)| k.as_str())
            .collect::<Vec<_>>()
            .join(";");
        hd.extend([sh, ",", "Signature=", signature]);
        hd
    }

    pub fn signed_authorization_header(&self) -> String {
        let sig = self.sign();
        self.authorization_header_with_signature(&sig)
    }
}

pub fn sign_string(with_key: impl AsRef<[u8]>, str_slice: &[u8]) -> String {
    let mut hasher = HmacSha256::new_from_slice(with_key.as_ref()).unwrap();
    hasher.update(str_slice);
    let data = hasher.finalize_fixed();
    hex::encode(data)
}

pub fn generate_signing_key<'a>(
    secret_access_key: &str,
    time: DateTime<Utc>,
    region: &str,
    service: &str,
) -> impl AsRef<[u8]> {
    let secret = format!("AWS4{}", secret_access_key);
    let t = time.format(DATE_FORMAT).to_string();
    let mut chain = hmac_msg::HmacMsg::new(vec![secret.as_str(), t.as_str()]);
    chain
        .segment(region)
        .segment(service)
        .segment("aws4_request")
        .finalize_fixed()
}

pub(crate) fn sha256_hex_from_ref(bytes: impl AsRef<[u8]>) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex::encode(hasher.finalize_fixed())
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use chrono::{DateTime, TimeZone, Utc};

    use crate::{
        SignatureRequest, UTC_ISO_FORMAT, compose_headers, compose_request, format_headers,
        generate_signing_key, sha256_hex_from_ref, sign_string,
    };

    #[test]
    fn test_compose_request() {
        let raw_uri = "https://my-s3-compatible.cloud.host.com/v1/path/to/bucket?version=201511&tag=My Custom Tag";
        let http_verb = "GET";
        let mut m = HashMap::new();
        m.insert("Cba", "lkalkshdlh ");
        m.insert("AbC", " lkalkshdlh ");
        m.insert("JkC", "  lkalkshdlh ");
        m.insert("LoOhkjhaC", "lkalkshdlh");
        let sorted_headers = format_headers(&m);
        let signed_headers = vec!["host"];
        let res = compose_request(
            raw_uri,
            http_verb,
            &sorted_headers,
            signed_headers.as_slice(),
            b"",
        );

        assert_eq!(
            "GET\n/v1/path/to/bucket\ntag=My%20Custom%20Tag&version=201511\nabc:lkalkshdlh\ncba:lkalkshdlh\njkc:lkalkshdlh\nloohkjhac:lkalkshdlh\n\nhost\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
            res
        );
    }

    #[test]
    fn test_compose_headers() {
        let mut m = HashMap::new();
        m.insert("Cba", "lkalkshdlh ");
        m.insert("AbC", " lkalkshdlh ");
        m.insert("JkC", "  lkalkshdlh ");
        m.insert("LoOhkjhaC", "lkalkshdlh");
        let m = format_headers(&m);
        let res = compose_headers(&m);
        assert_eq!(
            "abc:lkalkshdlh\ncba:lkalkshdlh\njkc:lkalkshdlh\nloohkjhac:lkalkshdlh\n",
            res
        );
    }

    #[test]
    fn test_signature_req_from_aws_docu() {
        // Taken from: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
        let access_key = "AKIAIOSFODNN7EXAMPLE";
        let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
        let time: DateTime<Utc> = Utc.with_ymd_and_hms(2013, 5, 24, 00, 00, 0).unwrap();
        let formatted = format!("{}", time.format(UTC_ISO_FORMAT));
        let content_hash = sha256_hex_from_ref([]);
        let mut m = HashMap::new();
        m.insert("Host", "examplebucket.s3.amazonaws.com");
        m.insert("X-Amz-Date", &formatted);
        m.insert("X-Amz-Content-Sha256", &content_hash);
        m.insert("Range", "bytes=0-9");
        let empty_body = vec![];
        let url = "https://examplebucket.s3.amazonaws.com/test.txt";
        let method = "GET";
        let region = "us-east-1";
        let service = "s3";
        let req = SignatureRequest::new(
            &m,
            method,
            url,
            access_key,
            secret,
            time,
            region,
            service,
            &empty_body,
        );
        let signature = req.sign();

        assert_eq!(
            "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41",
            signature
        );

        let auth_header = req.authorization_header_with_signature(&signature);
        assert_eq!(
            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41",
            auth_header
        );
    }

    #[test]
    fn test_signature_calculation() {
        let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
        let creq = r#"AWS4-HMAC-SHA256
20150830T123600Z
20150830/us-east-1/iam/aws4_request
f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59"#;
        let time: DateTime<Utc> = Utc.with_ymd_and_hms(2015, 8, 30, 12, 36, 0).unwrap();
        let formatted = format!("{}", time.format(UTC_ISO_FORMAT));
        assert_eq!("20150830T123600Z", formatted);
        let derived_key = generate_signing_key(secret, time, "us-east-1", "iam");
        let signature = sign_string(derived_key, creq.as_bytes());

        let expected = "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7";
        assert_eq!(expected, &signature);
    }

    #[test]
    fn sign_payload_empty_string() {
        let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        let actual = sha256_hex_from_ref([]);
        assert_eq!(expected, actual);
    }
}