Skip to main content

rust_tts_wrapper/cloud_engine/
sigv4.rs

1// The split modules resolve shared names through the parent glob.
2#![allow(clippy::wildcard_imports)]
3
4//! Minimal AWS Signature Version 4 signer for the Polly engine —
5//! hand-rolled on `sha2` (already a dependency), no AWS SDK.
6//!
7//! Covers exactly what the Polly REST API needs: signed `POST` with a
8//! JSON body and signed `GET` with an optional query string, region- and
9//! service-scoped credentials. Not a general SigV4 implementation (no
10//! chunked uploads, no session tokens, no S3 quirks).
11
12use sha2::{Digest, Sha256};
13
14/// HMAC-SHA256 (RFC 2104). Key-block size for SHA-256 is 64 bytes.
15#[must_use]
16pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
17    fn pad(key: &[u8], byte: u8) -> [u8; 64] {
18        let mut block = [0u8; 64];
19        if key.len() > 64 {
20            // Long keys are hashed first (RFC 2104 §2).
21            let digest = Sha256::digest(key);
22            block[..32].copy_from_slice(&digest);
23        } else {
24            block[..key.len()].copy_from_slice(key);
25        }
26        for b in &mut block {
27            *b ^= byte;
28        }
29        block
30    }
31    let mut inner = Sha256::new();
32    inner.update(pad(key, 0x36));
33    inner.update(message);
34    let inner_digest: [u8; 32] = inner.finalize().into();
35
36    let mut outer = Sha256::new();
37    outer.update(pad(key, 0x5c));
38    outer.update(inner_digest);
39    outer.finalize().into()
40}
41
42/// Lowercase hex encoding.
43fn to_hex(bytes: &[u8]) -> String {
44    use std::fmt::Write;
45    let mut out = String::with_capacity(bytes.len() * 2);
46    for b in bytes {
47        let _ = write!(out, "{b:02x}");
48    }
49    out
50}
51
52/// Lowercase hex of a SHA-256 digest.
53#[must_use]
54pub fn sha256_hex(data: &[u8]) -> String {
55    to_hex(&Sha256::digest(data))
56}
57
58/// RFC 4231 requires percent-encoding of everything except the
59/// unreserved set `A-Z a-z 0-9 - _ . ~` (AWS canonical form — note this
60/// is stricter than form-encoding: space is `%20`, never `+`).
61fn uri_encode(value: &str) -> String {
62    let mut out = String::with_capacity(value.len());
63    for byte in value.bytes() {
64        match byte {
65            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
66                out.push(byte as char);
67            }
68            _ => {
69                use std::fmt::Write;
70                let _ = write!(out, "%{byte:02X}");
71            }
72        }
73    }
74    out
75}
76
77/// A parsed `https://host/path?query` split for canonicalization.
78pub struct SignedUrl {
79    pub host: String,
80    pub path: String,
81    /// (name, value) pairs; canonicalization sorts by encoded name.
82    pub query: Vec<(String, String)>,
83}
84
85/// The credentials a SigV4 request carries.
86pub struct SigV4Credentials<'a> {
87    pub access_key: &'a str,
88    pub secret_key: &'a str,
89    pub region: &'a str,
90    pub service: &'a str,
91}
92
93/// Build the `Authorization` header value for a request.
94///
95/// `amz_date` is `YYYYMMDDTHHMMSSZ`; `payload_hash` is the hex SHA-256
96/// of the exact body bytes sent (`GET` uses the empty-body hash).
97#[must_use]
98pub fn authorization_header(
99    method: &str,
100    url: &SignedUrl,
101    creds: &SigV4Credentials<'_>,
102    amz_date: &str,
103    content_type: Option<&str>,
104    payload_hash: &str,
105) -> String {
106    // Canonical headers: content-type (when present), host, x-amz-date —
107    // trimmed, lowercased names, sorted, terminated by a newline.
108    let mut header_lines: Vec<(String, String)> = Vec::new();
109    if let Some(ct) = content_type {
110        header_lines.push(("content-type".into(), ct.trim().to_string()));
111    }
112    header_lines.push(("host".into(), url.host.clone()));
113    header_lines.push(("x-amz-date".into(), amz_date.to_string()));
114    header_lines.sort();
115    let canonical_headers = header_lines.iter().fold(String::new(), |mut acc, (n, v)| {
116        use std::fmt::Write;
117        let _ = write!(acc, "{n}:{v}");
118        acc.push('\n');
119        acc
120    });
121    let signed_headers = header_lines
122        .iter()
123        .map(|(n, _)| n.as_str())
124        .collect::<Vec<_>>()
125        .join(";");
126
127    let mut query: Vec<(String, String)> = url
128        .query
129        .iter()
130        .map(|(n, v)| (uri_encode(n), uri_encode(v)))
131        .collect();
132    query.sort();
133    let canonical_query = query
134        .iter()
135        .map(|(n, v)| format!("{n}={v}"))
136        .collect::<Vec<_>>()
137        .join("&");
138
139    // Canonical URI: each path segment is percent-encoded, but the '/'
140    // separators stay literal (encoding them as %2F breaks the digest).
141    let canonical_uri = url
142        .path
143        .split('/')
144        .map(uri_encode)
145        .collect::<Vec<_>>()
146        .join("/");
147    let canonical_request = format!(
148        "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
149    );
150
151    let credential_scope = format!(
152        "{}/{}/{}/aws4_request",
153        &amz_date[..8],
154        creds.region,
155        creds.service
156    );
157    let string_to_sign = format!(
158        "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
159        sha256_hex(canonical_request.as_bytes())
160    );
161
162    // Signing key: HMAC chain over date → region → service → terminator.
163    let k_date = hmac_sha256(
164        format!("AWS4{}", creds.secret_key).as_bytes(),
165        &amz_date.as_bytes()[..8],
166    );
167    let k_region = hmac_sha256(&k_date, creds.region.as_bytes());
168    let k_service = hmac_sha256(&k_region, creds.service.as_bytes());
169    let k_signing = hmac_sha256(&k_service, b"aws4_request");
170    let signature = hmac_sha256(&k_signing, string_to_sign.as_bytes());
171    let signature_hex = to_hex(&signature);
172
173    format!(
174        "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
175        creds.access_key, credential_scope, signed_headers, signature_hex
176    )
177}
178
179/// Current UTC as SigV4 needs it: (`YYYYMMDDTHHMMSSZ`, `YYYYMMDD`).
180pub fn amz_date_now() -> (String, String) {
181    let secs = std::time::SystemTime::now()
182        .duration_since(std::time::UNIX_EPOCH)
183        .unwrap_or_default()
184        .as_secs();
185    let days_since_epoch = secs / 86_400;
186    let secs_today = secs % 86_400;
187    // Howard Hinnant's civil-from-days (same approach as the Azure WS
188    // timestamp helper).
189    let z = i64::try_from(days_since_epoch).unwrap_or(0) + 719_468;
190    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
191    let doe = (z - era * 146_097) as u64;
192    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
193    let y = yoe as i64 + era * 400;
194    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
195    let mp = (5 * doy + 2) / 153;
196    let d = doy - (153 * mp + 2) / 5 + 1;
197    let m = if mp < 10 { mp + 3 } else { mp - 9 };
198    let year = if m <= 2 { y + 1 } else { y };
199    let stamp = format!(
200        "{year:04}{m:02}{d:02}T{hour:02}{minute:02}{second:02}Z",
201        hour = secs_today / 3600,
202        minute = (secs_today % 3600) / 60,
203        second = secs_today % 60
204    );
205    (stamp.clone(), stamp[..8].to_string())
206}
207
208/// The empty-payload hash (`GET` requests).
209pub const EMPTY_PAYLOAD_SHA256: &str =
210    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn hmac_matches_rfc4231_vector() {
218        // RFC 4231 test case 1 (HMAC-SHA256): key = 0x0b × 20, data
219        // "Hi There". Guards the hand-rolled HMAC against regressions.
220        let key = [0x0b; 20];
221        let mac = hmac_sha256(&key, b"Hi There");
222        let hex = to_hex(&mac);
223        assert_eq!(
224            hex,
225            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
226        );
227    }
228
229    #[test]
230    fn hmac_long_key_is_hashed_first() {
231        // RFC 4231 test case 6: key longer than the block size.
232        let key = [0xaa; 131];
233        let mac = hmac_sha256(
234            &key,
235            b"Test Using Larger Than Block-Size Key - Hash Key First",
236        );
237        assert_eq!(
238            to_hex(&mac),
239            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
240        );
241    }
242
243    #[test]
244    fn empty_payload_hash_is_the_standard_constant() {
245        assert_eq!(sha256_hex(b""), EMPTY_PAYLOAD_SHA256);
246    }
247
248    #[test]
249    fn authorization_header_shape() {
250        let url = SignedUrl {
251            host: "polly.us-east-1.amazonaws.com".into(),
252            path: "/v1/synthesis".into(),
253            query: Vec::new(),
254        };
255        let creds = SigV4Credentials {
256            access_key: "AKIDEXAMPLE",
257            secret_key: "secret",
258            region: "us-east-1",
259            service: "polly",
260        };
261        let payload_hash = sha256_hex(b"{}");
262        let auth = authorization_header(
263            "POST",
264            &url,
265            &creds,
266            "20260926T120000Z",
267            Some("application/json"),
268            &payload_hash,
269        );
270        assert!(
271            auth.starts_with(
272                "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260926/us-east-1/polly/aws4_request, "
273            ),
274            "{auth}"
275        );
276        assert!(
277            auth.contains("SignedHeaders=content-type;host;x-amz-date"),
278            "{auth}"
279        );
280        assert!(auth.contains("Signature="), "{auth}");
281        // Deterministic: same inputs → same signature.
282        let again = authorization_header(
283            "POST",
284            &url,
285            &creds,
286            "20260926T120000Z",
287            Some("application/json"),
288            &sha256_hex(b"{}"),
289        );
290        assert_eq!(auth, again);
291    }
292
293    #[test]
294    fn query_pairs_are_encoded_and_sorted() {
295        let url = SignedUrl {
296            host: "h".into(),
297            path: "/".into(),
298            query: vec![("b".into(), "x y".into()), ("a".into(), "ü".into())],
299        };
300        let creds = SigV4Credentials {
301            access_key: "k",
302            secret_key: "s",
303            region: "r",
304            service: "svc",
305        };
306        let auth = authorization_header(
307            "GET",
308            &url,
309            &creds,
310            "20260926T120000Z",
311            None,
312            EMPTY_PAYLOAD_SHA256,
313        );
314        // We assert via determinism + shape only; the encoding itself is
315        // exercised below.
316        assert!(auth.contains("Signature="));
317        assert_eq!(uri_encode("x y"), "x%20y");
318        assert_eq!(uri_encode("ü"), "%C3%BC");
319        assert_eq!(uri_encode("a-b~._z"), "a-b~._z");
320    }
321}