Skip to main content

yapi_ai/aws/
sigv4.rs

1//! AWS Signature Version 4 for JSON requests, as `@smithy/signature-v4`
2//! signs them for services other than S3.
3
4use aws_lc_rs::{digest, hmac};
5use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
6use yapi_types::time::hex;
7
8use super::Credentials;
9
10/// Headers the signer never signs, as Smithy's `ALWAYS_UNSIGNABLE_HEADERS`.
11const 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
29/// Hex SHA-256 of `data`.
30pub 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
40/// RFC 3986 escaping of everything outside the unreserved set, as Smithy's
41/// `escapeUri` (`encodeURIComponent` plus `!'()*`).
42pub 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
51/// The canonical form of an already-encoded path: each segment escaped again.
52fn 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
77/// What to sign with.
78pub struct Scope<'a> {
79    /// Signing region.
80    pub region: &'a str,
81    /// Signing service name, such as `bedrock`.
82    pub service: &'a str,
83    /// `YYYYMMDDTHHMMSSZ`.
84    pub amz_date: &'a str,
85}
86
87/// Signs a request: adds `x-amz-date`, `x-amz-content-sha256` and the
88/// session token to `headers`, then returns the `authorization` value.
89/// `path` is the request path as sent, and `query` is the canonical query
90/// string, empty for none.
91pub 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
153/// Signs a POST of `body` to `url` for `service` in `region` at the current
154/// time: adds the signing headers and `authorization` to `headers`. `host` is
155/// signed but left for the HTTP client to send.
156pub 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
181/// `YYYYMMDDTHHMMSSZ` for Unix time in milliseconds.
182pub 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        // A request pi's Bedrock provider made, with fake credentials.
196        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}