Skip to main content

codetether_agent/provider/bedrock/
sigv4.rs

1//! AWS SigV4 request signing and HTTP dispatch for Bedrock.
2//!
3//! This module implements the AWS Signature Version 4 algorithm needed to
4//! authenticate Bedrock API calls when using IAM credentials. It also holds
5//! the request dispatch helpers ([`BedrockProvider::send_request`] and
6//! [`BedrockProvider::send_converse_request`]) that pick between SigV4 and
7//! bearer-token auth.
8//!
9//! # Examples
10//!
11//! ```rust,no_run
12//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
13//! use codetether_agent::provider::bedrock::{AwsCredentials, BedrockProvider};
14//!
15//! let creds = AwsCredentials::from_environment().unwrap();
16//! let p = BedrockProvider::with_credentials(creds, "us-west-2".into()).unwrap();
17//! let body = br#"{"messages":[]}"#;
18//! let url = "https://bedrock-runtime.us-west-2.amazonaws.com/model/amazon.nova-micro-v1:0/converse";
19//! let _resp = p.send_converse_request(url, body).await.unwrap();
20//! # });
21//! ```
22
23use super::BedrockProvider;
24use super::auth::BedrockAuth;
25use anyhow::{Context, Result};
26use hmac::{Hmac, Mac};
27use reqwest::Url;
28use sha2::{Digest, Sha256};
29
30impl BedrockProvider {
31    /// Runtime Bedrock base URL (Converse / Converse-stream).
32    ///
33    /// # Examples
34    ///
35    /// ```rust,no_run
36    /// # use codetether_agent::provider::bedrock::BedrockProvider;
37    /// let p = BedrockProvider::with_region("token".into(), "us-west-2".into()).unwrap();
38    /// // internal: p.base_url() -> "https://bedrock-runtime.us-west-2.amazonaws.com"
39    /// ```
40    pub(super) fn base_url(&self) -> String {
41        format!("https://bedrock-runtime.{}.amazonaws.com", self.region)
42    }
43
44    /// Management API URL (ListFoundationModels, ListInferenceProfiles).
45    pub(super) fn management_url(&self) -> String {
46        format!("https://bedrock.{}.amazonaws.com", self.region)
47    }
48
49    /// Send a pre-formed POST request to any Bedrock runtime URL, signed with
50    /// whichever auth mode is configured. Used by the thinker backend for
51    /// direct URL access.
52    ///
53    /// # Arguments
54    ///
55    /// * `url` — The full HTTPS URL to POST to.
56    /// * `body` — The serialized JSON request body.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`anyhow::Error`] if the network call fails.
61    ///
62    /// # Examples
63    ///
64    /// ```rust,no_run
65    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
66    /// use codetether_agent::provider::bedrock::{AwsCredentials, BedrockProvider};
67    /// let creds = AwsCredentials::from_environment().unwrap();
68    /// let p = BedrockProvider::with_credentials(creds, "us-west-2".into()).unwrap();
69    /// let url = "https://bedrock-runtime.us-west-2.amazonaws.com/model/amazon.nova-micro-v1:0/converse";
70    /// let resp = p.send_converse_request(url, b"{}" ).await.unwrap();
71    /// assert!(resp.status().is_client_error() || resp.status().is_success());
72    /// # });
73    /// ```
74    pub async fn send_converse_request(&self, url: &str, body: &[u8]) -> Result<reqwest::Response> {
75        self.send_request("POST", url, Some(body), "bedrock").await
76    }
77
78    /// Send an HTTP request using whichever auth mode is configured.
79    pub(super) async fn send_request(
80        &self,
81        method: &str,
82        url: &str,
83        body: Option<&[u8]>,
84        service: &str,
85    ) -> Result<reqwest::Response> {
86        match &self.auth {
87            BedrockAuth::SigV4(_) => {
88                self.send_signed_request(method, url, body.unwrap_or(b""), service)
89                    .await
90            }
91            BedrockAuth::BearerToken(token) => {
92                let mut req = self
93                    .client
94                    .request(method.parse().unwrap_or(reqwest::Method::GET), url)
95                    .bearer_auth(token)
96                    .header("content-type", "application/json")
97                    .header("accept", "application/json");
98                if let Some(b) = body {
99                    req = req.body(b.to_vec());
100                }
101                req.send()
102                    .await
103                    .context("Failed to send request to Bedrock")
104            }
105        }
106    }
107
108    /// Build a SigV4-signed request and send it.
109    async fn send_signed_request(
110        &self,
111        method: &str,
112        url: &str,
113        body: &[u8],
114        service: &str,
115    ) -> Result<reqwest::Response> {
116        let creds = match &self.auth {
117            BedrockAuth::SigV4(c) => c,
118            BedrockAuth::BearerToken(_) => {
119                anyhow::bail!("send_signed_request called with bearer token auth");
120            }
121        };
122
123        let now = chrono::Utc::now();
124        let datestamp = now.format("%Y%m%d").to_string();
125        let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
126        let canonical_url = canonicalize_url(url)?;
127        let host = canonical_url.host;
128        let canonical_uri = canonical_url.canonical_uri;
129        let canonical_querystring = canonical_url.canonical_querystring;
130
131        let payload_hash = sha256_hex(body);
132
133        let mut headers_map: Vec<(&str, String)> = vec![
134            ("content-type", "application/json".to_string()),
135            ("host", host.clone()),
136            ("x-amz-content-sha256", payload_hash.clone()),
137            ("x-amz-date", amz_date.clone()),
138        ];
139        if let Some(token) = &creds.session_token {
140            headers_map.push(("x-amz-security-token", token.clone()));
141        }
142        headers_map.sort_by_key(|(k, _)| *k);
143
144        let canonical_headers: String = headers_map
145            .iter()
146            .map(|(k, v)| format!("{k}:{v}"))
147            .collect::<Vec<_>>()
148            .join("\n")
149            + "\n";
150        let signed_headers: String = headers_map
151            .iter()
152            .map(|(k, _)| *k)
153            .collect::<Vec<_>>()
154            .join(";");
155
156        let canonical_request = format!(
157            "{method}\n{canonical_uri}\n{canonical_querystring}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
158        );
159
160        let credential_scope = format!("{datestamp}/{}/{service}/aws4_request", self.region);
161
162        let string_to_sign = format!(
163            "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
164            sha256_hex(canonical_request.as_bytes())
165        );
166
167        let k_date = hmac_sha256(
168            format!("AWS4{}", creds.secret_access_key).as_bytes(),
169            datestamp.as_bytes(),
170        );
171        let k_region = hmac_sha256(&k_date, self.region.as_bytes());
172        let k_service = hmac_sha256(&k_region, service.as_bytes());
173        let k_signing = hmac_sha256(&k_service, b"aws4_request");
174
175        let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
176
177        let authorization = format!(
178            "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
179            creds.access_key_id
180        );
181
182        let mut req = self
183            .client
184            .request(method.parse().unwrap_or(reqwest::Method::POST), url)
185            .header("content-type", "application/json")
186            .header("host", &host)
187            .header("x-amz-date", &amz_date)
188            .header("x-amz-content-sha256", &payload_hash)
189            .header("authorization", &authorization);
190
191        if let Some(token) = &creds.session_token {
192            req = req.header("x-amz-security-token", token);
193        }
194
195        if method == "POST" || method == "PUT" {
196            req = req.body(body.to_vec());
197        }
198
199        req.send()
200            .await
201            .context("Failed to send signed request to Bedrock")
202    }
203}
204
205#[derive(Debug, PartialEq, Eq)]
206pub(super) struct CanonicalUrl {
207    pub(super) host: String,
208    pub(super) canonical_uri: String,
209    pub(super) canonical_querystring: String,
210}
211
212pub(super) fn canonicalize_url(url: &str) -> Result<CanonicalUrl> {
213    let parsed = Url::parse(url).with_context(|| format!("Invalid Bedrock URL: {url}"))?;
214    let host = canonical_host(&parsed).context("Bedrock URL missing host")?;
215    let canonical_uri = canonical_uri(&parsed)?;
216    let canonical_querystring = canonical_querystring(&parsed);
217    Ok(CanonicalUrl {
218        host,
219        canonical_uri,
220        canonical_querystring,
221    })
222}
223
224fn canonical_host(url: &Url) -> Option<String> {
225    let host = url.host_str()?.to_string();
226    Some(match url.port() {
227        Some(port) => format!("{host}:{port}"),
228        None => host,
229    })
230}
231
232fn canonical_uri(url: &Url) -> Result<String> {
233    let segments = url.path_segments().context("Bedrock URL missing path")?;
234    let encoded = segments
235        .map(canonical_path_segment)
236        .collect::<Result<Vec<_>>>()?;
237    Ok(format!("/{}", encoded.join("/")))
238}
239
240fn canonical_path_segment(segment: &str) -> Result<String> {
241    let decoded = urlencoding::decode(segment)
242        .with_context(|| format!("invalid percent-encoding in path segment `{segment}`"))?;
243    Ok(urlencoding::encode(&decoded).into_owned())
244}
245
246fn canonical_querystring(url: &Url) -> String {
247    let mut pairs = url
248        .query_pairs()
249        .map(|(key, value)| {
250            (
251                urlencoding::encode(&key).into_owned(),
252                urlencoding::encode(&value).into_owned(),
253            )
254        })
255        .collect::<Vec<_>>();
256    pairs.sort();
257    pairs
258        .into_iter()
259        .map(|(key, value)| format!("{key}={value}"))
260        .collect::<Vec<_>>()
261        .join("&")
262}
263
264/// HMAC-SHA256 helper returning raw bytes.
265fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
266    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC can take key of any size");
267    mac.update(data);
268    mac.finalize().into_bytes().to_vec()
269}
270
271/// SHA-256 helper returning lowercase hex.
272fn sha256_hex(data: &[u8]) -> String {
273    let mut hasher = Sha256::new();
274    hasher.update(data);
275    hex::encode(hasher.finalize())
276}