rust_tts_wrapper/cloud_engine/
sigv4.rs1#![allow(clippy::wildcard_imports)]
3
4use sha2::{Digest, Sha256};
13
14#[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 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
42fn 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#[must_use]
54pub fn sha256_hex(data: &[u8]) -> String {
55 to_hex(&Sha256::digest(data))
56}
57
58fn 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
77pub struct SignedUrl {
79 pub host: String,
80 pub path: String,
81 pub query: Vec<(String, String)>,
83}
84
85pub 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#[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 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 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 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
179pub 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 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
208pub 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 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 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 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 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}