1use aws_lc_rs::{digest, hmac};
5use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
6use yapi_types::time::hex;
7
8use super::Credentials;
9
10const UNSIGNABLE: &[&str] = &[
12 "authorization",
13 "cache-control",
14 "connection",
15 "expect",
16 "from",
17 "keep-alive",
18 "max-forwards",
19 "pragma",
20 "referer",
21 "te",
22 "trailer",
23 "transfer-encoding",
24 "upgrade",
25 "user-agent",
26 "x-amzn-trace-id",
27];
28
29pub fn sha256_hex(data: &[u8]) -> String {
31 hex(digest::digest(&digest::SHA256, data).as_ref())
32}
33
34fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
35 hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, key), data)
36 .as_ref()
37 .to_vec()
38}
39
40pub fn escape(text: &str) -> String {
43 const RESERVED: &AsciiSet = &NON_ALPHANUMERIC
44 .remove(b'-')
45 .remove(b'_')
46 .remove(b'.')
47 .remove(b'~');
48 utf8_percent_encode(text, RESERVED).to_string()
49}
50
51fn canonical_path(path: &str) -> String {
53 let mut segments: Vec<&str> = Vec::new();
54 for segment in path.split('/') {
55 match segment {
56 "" | "." => {}
57 ".." => {
58 segments.pop();
59 }
60 segment => segments.push(segment),
61 }
62 }
63 let mut out = format!(
64 "/{}",
65 segments
66 .iter()
67 .map(|segment| escape(segment))
68 .collect::<Vec<_>>()
69 .join("/")
70 );
71 if path.ends_with('/') && out.len() > 1 {
72 out.push('/');
73 }
74 out
75}
76
77pub struct Scope<'a> {
79 pub region: &'a str,
81 pub service: &'a str,
83 pub amz_date: &'a str,
85}
86
87pub fn sign(
92 method: &str,
93 path: &str,
94 query: &str,
95 headers: &mut Vec<(String, String)>,
96 body: &[u8],
97 credentials: &Credentials,
98 scope: &Scope<'_>,
99) -> String {
100 let payload_hash = sha256_hex(body);
101 let mut set = |name: &str, value: String| {
102 headers.retain(|(key, _)| !key.eq_ignore_ascii_case(name));
103 headers.push((name.to_owned(), value));
104 };
105 set("x-amz-date", scope.amz_date.to_owned());
106 set("x-amz-content-sha256", payload_hash.clone());
107 if let Some(token) = &credentials.session_token {
108 set("x-amz-security-token", token.clone());
109 }
110 let mut canonical: Vec<(String, String)> = headers
111 .iter()
112 .map(|(name, value)| {
113 let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
114 (name.to_lowercase(), value)
115 })
116 .filter(|(name, _)| !UNSIGNABLE.contains(&name.as_str()))
117 .collect();
118 canonical.sort();
119 let signed_headers = canonical
120 .iter()
121 .map(|(name, _)| name.as_str())
122 .collect::<Vec<_>>()
123 .join(";");
124 let header_block: String = canonical
125 .iter()
126 .map(|(name, value)| format!("{name}:{value}\n"))
127 .collect();
128 let request = format!(
129 "{method}\n{}\n{query}\n{header_block}\n{signed_headers}\n{payload_hash}",
130 canonical_path(path)
131 );
132 let date = &scope.amz_date[..8];
133 let credential_scope = format!("{date}/{}/{}/aws4_request", scope.region, scope.service);
134 let string_to_sign = format!(
135 "AWS4-HMAC-SHA256\n{}\n{credential_scope}\n{}",
136 scope.amz_date,
137 sha256_hex(request.as_bytes())
138 );
139 let mut key = hmac_sha256(
140 format!("AWS4{}", credentials.secret_access_key).as_bytes(),
141 date.as_bytes(),
142 );
143 for part in [scope.region, scope.service, "aws4_request"] {
144 key = hmac_sha256(&key, part.as_bytes());
145 }
146 let signature = hex(&hmac_sha256(&key, string_to_sign.as_bytes()));
147 format!(
148 "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
149 credentials.access_key_id
150 )
151}
152
153pub fn sign_request(
157 url: &url::Url,
158 headers: &mut Vec<(String, String)>,
159 body: &[u8],
160 credentials: &Credentials,
161 region: &str,
162 service: &str,
163) {
164 let host = url.host_str().unwrap_or_default();
165 let host = match url.port() {
166 Some(port) => format!("{host}:{port}"),
167 None => host.to_owned(),
168 };
169 headers.push(("host".to_owned(), host));
170 let amz_date = amz_date(yapi_types::time::now_ms());
171 let scope = Scope {
172 region,
173 service,
174 amz_date: &amz_date,
175 };
176 let authorization = sign("POST", url.path(), "", headers, body, credentials, &scope);
177 headers.retain(|(name, _)| name != "host");
178 headers.push(("authorization".to_owned(), authorization));
179}
180
181pub fn amz_date(now_ms: u64) -> String {
183 format!(
184 "{}Z",
185 yapi_types::time::iso(now_ms)[..19].replace(['-', ':'], "")
186 )
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn matches_sdk_signature() {
195 let body = r#"{"messages":[{"role":"user","content":[{"text":"hi"},{"cachePoint":{"type":"default"}}]}],"inferenceConfig":{"maxTokens":64000}}"#;
197 let mut headers: Vec<(String, String)> = [
198 ("content-type", "application/json"),
199 ("content-length", "128"),
200 ("x-amz-user-agent", "aws-sdk-js/3.1126.0"),
201 ("user-agent", "aws-sdk-js/3.1126.0 ua/2.1"),
202 ("host", "127.0.0.1:46595"),
203 (
204 "amz-sdk-invocation-id",
205 "7226f89e-2566-48f7-a706-83de04cf2e08",
206 ),
207 ("amz-sdk-request", "attempt=1; max=3"),
208 ]
209 .iter()
210 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
211 .collect();
212 let credentials = Credentials {
213 access_key_id: "AKIDEXAMPLE".into(),
214 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
215 session_token: None,
216 expiration_ms: None,
217 };
218 let authorization = sign(
219 "POST",
220 "/model/us.anthropic.claude-sonnet-4-5-20250929-v1%3A0/converse-stream",
221 "",
222 &mut headers,
223 body.as_bytes(),
224 &credentials,
225 &Scope {
226 region: "us-east-1",
227 service: "bedrock",
228 amz_date: "20261004T190829Z",
229 },
230 );
231 assert_eq!(
232 authorization,
233 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261004/us-east-1/bedrock/aws4_request, SignedHeaders=amz-sdk-invocation-id;amz-sdk-request;content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, Signature=d8bfe77b50c1d282905f3dd383c9c21223013443acb57692c7782fd152662aa1"
234 );
235 assert!(headers.contains(&(
236 "x-amz-content-sha256".into(),
237 "5f46bb63fdce94cc025c273a1e4cbbffb21d8fc0c69bb5f44c03fa7e322acd8d".into()
238 )));
239 }
240
241 #[test]
242 fn formats_dates_and_paths() {
243 assert_eq!(amz_date(0), "19700101T000000Z");
244 assert_eq!(amz_date(1_791_140_909_000), "20261004T190829Z");
245 assert_eq!(
246 canonical_path("/model/arn%3Aaws%3Abedrock/converse-stream"),
247 "/model/arn%253Aaws%253Abedrock/converse-stream"
248 );
249 assert_eq!(escape("a b!*"), "a%20b%21%2A");
250 }
251}