keyhog-verifier 0.5.43

keyhog-verifier: parallel async credential verification framework
Documentation
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;

const ALGORITHM: &str = "AWS4-HMAC-SHA256";

#[allow(clippy::too_many_arguments)]
pub fn sign_request_authorization(
    access_key: &str,
    secret_key: &str,
    session_token: Option<&str>,
    region: &str,
    service: &str,
    method: &str,
    canonical_uri: &str,
    query_pairs: &[(String, String)],
    host: &str,
    payload_hash: &str,
    unix_secs: u64,
    extra_signed_headers: &[(&str, &str)],
) -> Result<(String, String, String), String> {
    let (date_stamp, amz_date) = format_sigv4_timestamps(unix_secs);
    let canonical_query = canonical_query_string(query_pairs);
    let mut header_map = BTreeMap::<String, Vec<String>>::new();
    push_canonical_header(&mut header_map, "host", host);
    push_canonical_header(&mut header_map, "x-amz-date", &amz_date);
    for (name, value) in extra_signed_headers {
        push_canonical_header(&mut header_map, name, value);
    }
    if let Some(token) = session_token {
        push_canonical_header(&mut header_map, "x-amz-security-token", token);
    }
    let headers = merged_canonical_headers(header_map);

    let canonical_headers = canonical_header_block(&headers);
    let signed_headers = headers
        .iter()
        .map(|(name, _)| name.as_str())
        .collect::<Vec<_>>()
        .join(";");
    let canonical_request = format!(
        "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
    );
    let credential_scope = credential_scope(&date_stamp, region, service);
    let string_to_sign = string_to_sign(&amz_date, &credential_scope, &canonical_request);
    let signature = signature(secret_key, &date_stamp, region, service, &string_to_sign)?;
    let authorization =
        authorization_header(access_key, &credential_scope, &signed_headers, &signature);
    Ok((authorization, amz_date, signed_headers))
}

pub(crate) fn canonical_query_string(pairs: &[(String, String)]) -> String {
    let mut encoded = pairs
        .iter()
        .map(|(key, value)| (aws_uri_encode(key), aws_uri_encode(value)))
        .collect::<Vec<_>>();
    encoded.sort();
    encoded
        .into_iter()
        .map(|(key, value)| format!("{key}={value}"))
        .collect::<Vec<_>>()
        .join("&")
}

pub(crate) fn aws_uri_encode(input: &str) -> String {
    // AWS SigV4 canonical-URI encoding: unreserved chars pass through, every
    // other byte becomes `%XX` with UPPERCASE hex. The escaped arm previously
    // built a throwaway `String` per byte via `format!("%{byte:02X}")`: a heap
    // allocation on every escaped byte of every query key/value we sign. Push
    // the two hex nibbles straight from a static table instead: byte-identical
    // output (same uppercase `%XX`), zero per-byte allocation (Law 7).
    const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
    let mut encoded = String::with_capacity(input.len());
    for byte in input.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(byte as char);
            }
            _ => {
                encoded.push('%');
                encoded.push(HEX_UPPER[(byte >> 4) as usize] as char);
                encoded.push(HEX_UPPER[(byte & 0x0f) as usize] as char);
            }
        }
    }
    encoded
}

pub(crate) fn canonical_header_block(headers: &[(String, String)]) -> String {
    let mut block = String::new();
    for (name, value) in headers {
        block.push_str(name);
        block.push(':');
        block.push_str(value);
        block.push('\n');
    }
    block
}

fn push_canonical_header(headers: &mut BTreeMap<String, Vec<String>>, name: &str, value: &str) {
    let canonical_name = name.trim().to_ascii_lowercase();
    if canonical_name.is_empty() {
        return;
    }
    headers
        .entry(canonical_name)
        .or_default()
        .push(trim_all(value));
}

fn merged_canonical_headers(headers: BTreeMap<String, Vec<String>>) -> Vec<(String, String)> {
    headers
        .into_iter()
        .map(|(name, values)| (name, values.join(",")))
        .collect()
}

fn trim_all(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

pub(crate) fn credential_scope(date_stamp: &str, region: &str, service: &str) -> String {
    format!("{date_stamp}/{region}/{service}/aws4_request")
}

pub(crate) fn string_to_sign(
    amz_date: &str,
    credential_scope: &str,
    canonical_request: &str,
) -> String {
    format!(
        "{ALGORITHM}\n{amz_date}\n{credential_scope}\n{}",
        hex::encode(Sha256::digest(canonical_request.as_bytes()))
    )
}

pub(crate) fn authorization_header(
    access_key: &str,
    credential_scope: &str,
    signed_headers: &str,
    signature: &str,
) -> String {
    format!(
        "{ALGORITHM} Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
    )
}

pub(crate) fn signature(
    secret: &str,
    date_stamp: &str,
    region: &str,
    service: &str,
    string_to_sign: &str,
) -> Result<String, String> {
    let signing_key = signing_key(secret, date_stamp, region, service)?;
    Ok(hex::encode(hmac_sha256(
        &signing_key,
        string_to_sign.as_bytes(),
    )?))
}

fn signing_key(
    key: &str,
    date_stamp: &str,
    region: &str,
    service: &str,
) -> Result<Vec<u8>, String> {
    let k_date = hmac_sha256(format!("AWS4{key}").as_bytes(), date_stamp.as_bytes())?;
    let k_region = hmac_sha256(&k_date, region.as_bytes())?;
    let k_service = hmac_sha256(&k_region, service.as_bytes())?;
    hmac_sha256(&k_service, b"aws4_request")
}

fn hmac_sha256(key: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
    type HmacSha256 = Hmac<Sha256>;
    let mut mac = HmacSha256::new_from_slice(key)
        .map_err(|error| format!("failed to create AWS SigV4 HMAC signer: {error}"))?;
    mac.update(data);
    Ok(mac.finalize().into_bytes().to_vec())
}

/// Format the SigV4 timestamps from a Unix epoch second value.
/// Returns `(date_stamp = "YYYYMMDD", amz_date = "YYYYMMDDTHHMMSSZ")`.
pub(crate) fn format_sigv4_timestamps(unix_secs: u64) -> (String, String) {
    // Civil-from-days, after Howard Hinnant's date algorithm.
    let days = (unix_secs / 86_400) as i64;
    let secs_of_day = (unix_secs % 86_400) as u32;
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u32;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = y + i64::from(m <= 2);

    let hour = secs_of_day / 3600;
    let minute = (secs_of_day % 3600) / 60;
    let second = secs_of_day % 60;

    let date_stamp = format!("{year:04}{m:02}{d:02}");
    let amz_date = format!("{year:04}{m:02}{d:02}T{hour:02}{minute:02}{second:02}Z");
    (date_stamp, amz_date)
}