Skip to content

Commit 9e305fd

Browse files
AWS: Implement SigV4 signing for the MiTM proxy
Now that the config is piped through, we can take a request, determine that aws credentials should be applied, determine the correct parameters (region, service), and then apply the signing to the body Streaming-based signing is not implemented Signed-off-by: Anil Kulkarni <anil@terminal.space>
1 parent 0153757 commit 9e305fd

9 files changed

Lines changed: 1672 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 513 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/nono-proxy/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,18 @@ zeroize.workspace = true
3131
sha2.workspace = true
3232
getrandom.workspace = true
3333
base64 = "0.22"
34+
parking_lot = "0.12"
35+
regex = "1"
3436
subtle = "2"
3537
url.workspace = true
3638
urlencoding = "2"
3739
globset.workspace = true
3840

41+
# AWS SigV4 signing support (smithy-rs, no service SDK required)
42+
aws-config = { version = "1", features = ["behavior-version-latest"] }
43+
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
44+
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
45+
3946
# TLS for upstream connections (reverse proxy mode)
4047
hyper-rustls = { version = "0.27", features = ["http1", "ring", "webpki-tokio"] }
4148
tokio-rustls = "0.26"
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
//! AWS hostname parsing: service-code and region extraction.
2+
//!
3+
//! ## Canonical AWS hostname shapes
4+
//!
5+
//! ```text
6+
//! Regional: {service}.{region}.amazonaws.com (4 dot-segments)
7+
//! Global: {service}.amazonaws.com (3 dot-segments)
8+
//! FIPS: {service}-fips.{region}.amazonaws.com (4 dot-segments, service ends in "-fips")
9+
//! Dual-stack:{service}.{region}.api.aws (different TLD — not handled here)
10+
//! ```
11+
//!
12+
//! `api.aws` dual-stack endpoints are out of scope for auto-detection; users
13+
//! on those endpoints supply explicit `aws_auth.region` and `aws_auth.service`
14+
//! overrides in their config.
15+
//!
16+
//! ## Region code pattern
17+
//!
18+
//! Standard regions match `^[a-z]{2,3}-[a-z]+-\d+$`. GovCloud and ISO
19+
//! partition regions have an extra word between the geo prefix and direction
20+
//! segment (`us-gov-east-1`, `us-iso-east-1`, `us-isob-east-1`), so the
21+
//! effective pattern is `^[a-z]{2,3}-(?:[a-z]+-)?[a-z]+-\d+$`. This covers
22+
//! all current regions including newer geo prefixes (`il-`, `mx-`, `me-`,
23+
//! `af-`) and high-index AZ suffixes (`ap-southeast-7`).
24+
//!
25+
//! ## Service-code table
26+
//!
27+
//! The service segment is not always the SigV4 signing name. AWS uses
28+
//! irregular host prefixes (e.g. `bedrock-runtime` signs as `bedrock`,
29+
//! `email` signs as `ses`). The table maps the canonical segment (with any
30+
//! `-fips` suffix stripped) to the signing name.
31+
32+
use std::collections::HashMap;
33+
use std::sync::OnceLock;
34+
35+
use regex::Regex;
36+
use tracing::warn;
37+
38+
fn region_re() -> &'static Regex {
39+
static RE: OnceLock<Regex> = OnceLock::new();
40+
RE.get_or_init(|| {
41+
Regex::new(r"^[a-z]{2,3}-(?:[a-z]+-)?[a-z]+-\d+$").expect("static regex is valid")
42+
})
43+
}
44+
45+
fn service_map() -> &'static HashMap<&'static str, &'static str> {
46+
// Maps from the URL hostname path (key) to the AWS service name (value)
47+
static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
48+
MAP.get_or_init(|| {
49+
HashMap::from([
50+
// Bedrock: multiple host prefixes, one signing name.
51+
("bedrock", "bedrock"),
52+
("bedrock-runtime", "bedrock"),
53+
("bedrock-agent", "bedrock"),
54+
("bedrock-agent-runtime", "bedrock"),
55+
("bedrock-data-automation", "bedrock"),
56+
("bedrock-data-automation-runtime", "bedrock"),
57+
// Core services
58+
("dynamodb", "dynamodb"),
59+
("s3", "s3"),
60+
("lambda", "lambda"),
61+
("sqs", "sqs"),
62+
("sns", "sns"),
63+
("logs", "logs"), // CloudWatch Logs
64+
// API Gateway
65+
("execute-api", "execute-api"),
66+
// Identity / signing
67+
("sts", "sts"),
68+
("iam", "iam"),
69+
// Email: host prefix differs from signing name
70+
("email", "ses"),
71+
("ses", "ses"),
72+
])
73+
})
74+
}
75+
76+
/// Parsed components of an AWS URL
77+
#[derive(Debug, PartialEq, Eq)]
78+
pub struct AwsUrlParts<'a> {
79+
/// Service-code segment from the hostname, with any `-fips` suffix
80+
/// already stripped (e.g. `"bedrock-runtime"`, `"iam"`).
81+
pub normalized_service: &'a str,
82+
/// Region code if present (e.g. `"us-east-1"`), or `None` for
83+
/// global-only endpoints.
84+
pub region: Option<&'a str>,
85+
}
86+
87+
impl<'a> AwsUrlParts<'a> {
88+
/// Parse a hostname into its service segment and optional region.
89+
///
90+
/// Accepts only `amazonaws.com` endpoints in the two canonical shapes:
91+
/// - Regional (4 segments): `{service}.{region}.amazonaws.com`
92+
/// - Global (3 segments): `{service}.amazonaws.com`
93+
///
94+
/// A `-fips` suffix on the service segment is stripped before returning so
95+
/// callers always receive the canonical segment name (e.g. `"bedrock-runtime"`
96+
/// for both `bedrock-runtime.us-east-1.amazonaws.com` and
97+
/// `bedrock-runtime-fips.us-east-1.amazonaws.com`).
98+
///
99+
/// Returns `None` for non-`amazonaws.com` hosts (including `api.aws`
100+
/// dual-stack endpoints), wrong segment counts, and region strings that do
101+
/// not match the regex
102+
#[must_use]
103+
pub fn parse(host: &'a str) -> Option<Self> {
104+
match host.split('.').collect::<Vec<_>>().as_slice() {
105+
[service_raw, region, "amazonaws", "com"] => {
106+
if !region_re().is_match(region) {
107+
return None;
108+
}
109+
Some(AwsUrlParts {
110+
normalized_service: service_raw.strip_suffix("-fips").unwrap_or(service_raw),
111+
region: Some(region),
112+
})
113+
}
114+
[service_raw, "amazonaws", "com"] => Some(AwsUrlParts {
115+
normalized_service: service_raw.strip_suffix("-fips").unwrap_or(service_raw),
116+
region: None,
117+
}),
118+
_ => None,
119+
}
120+
}
121+
122+
/// Return the SigV4 signing service name for this endpoint.
123+
///
124+
/// The `normalized_service` segment (with any `-fips` suffix already
125+
/// stripped) is looked up in the service table. Returns `None` for
126+
/// unrecognised service segments; callers should fall back to an explicit
127+
/// `aws_auth.service` config value or emit a warning.
128+
#[must_use]
129+
pub fn signing_service(&self) -> Option<&'static str> {
130+
service_map().get(self.normalized_service).copied()
131+
}
132+
}
133+
134+
/// Resolve the SigV4 `(region, service)` pair for an AWS route.
135+
///
136+
/// Explicit config values in `aws_auth` take precedence over auto-detection.
137+
/// When either is absent the `upstream_host` is parsed as an `amazonaws.com`
138+
/// endpoint; if the hostname is not recognised and a value cannot be inferred,
139+
/// returns `None` after emitting a `warn!` that names the missing field and
140+
/// how to fix it.
141+
///
142+
/// Returns `Some((region, service))` on success, `None` when the route should
143+
/// be skipped (the warning has already been emitted).
144+
#[must_use]
145+
pub fn resolve_signing_params(
146+
prefix: &str,
147+
aws_auth: &crate::config::AwsAuthConfig,
148+
upstream_host: &str,
149+
) -> Option<(String, String)> {
150+
// When either region or service is missing from config, parse the upstream
151+
// host once and derive both from it. If both are explicit we skip the parse
152+
// entirely.
153+
let parsed_host = if aws_auth.region.is_none() || aws_auth.service.is_none() {
154+
let h = AwsUrlParts::parse(upstream_host);
155+
if h.is_none() {
156+
warn!(
157+
"AWS route '{}': upstream host '{}' is not a recognised \
158+
amazonaws.com endpoint; set aws_auth.region and \
159+
aws_auth.service explicitly — skipping this route.",
160+
prefix, upstream_host
161+
);
162+
return None;
163+
}
164+
h
165+
} else {
166+
None
167+
};
168+
169+
// Region: explicit override wins; otherwise take from parsed host.
170+
let region = match aws_auth.region.as_deref() {
171+
Some(r) => r.to_owned(),
172+
None => match parsed_host.as_ref().and_then(|h| h.region) {
173+
Some(r) => r.to_owned(),
174+
None => {
175+
warn!(
176+
"AWS route '{}': could not determine region from upstream \
177+
host '{}' (global endpoint?); set aws_auth.region \
178+
explicitly — skipping this route.",
179+
prefix, upstream_host
180+
);
181+
return None;
182+
}
183+
},
184+
};
185+
186+
// Service: explicit override wins; otherwise look up signing name from the
187+
// parsed host's service segment.
188+
let service = match aws_auth.service.as_deref() {
189+
Some(s) => s.to_owned(),
190+
None => {
191+
let seg = parsed_host
192+
.as_ref()
193+
.map(|h| h.normalized_service)
194+
.unwrap_or("");
195+
match parsed_host.as_ref().and_then(|h| h.signing_service()) {
196+
Some(s) => s.to_owned(),
197+
None => {
198+
warn!(
199+
"AWS route '{}': service segment '{}' from upstream \
200+
host '{}' is not in the signing-name table; set \
201+
aws_auth.service explicitly — skipping this route.",
202+
prefix, seg, upstream_host
203+
);
204+
return None;
205+
}
206+
}
207+
}
208+
};
209+
210+
Some((region, service))
211+
}
212+
213+
#[cfg(test)]
214+
#[allow(clippy::unwrap_used)]
215+
mod tests {
216+
use super::*;
217+
218+
// =========================================================================
219+
// AwsUrlParts::parse — valid inputs
220+
// =========================================================================
221+
222+
/// Canonical hostname shapes and region varieties that must parse correctly.
223+
#[test]
224+
fn parse_valid_hosts() {
225+
// (host, expected_normalized_service, expected_region)
226+
let cases: &[(&str, &str, Option<&str>)] = &[
227+
// Regional — standard
228+
(
229+
"bedrock-runtime.us-east-1.amazonaws.com",
230+
"bedrock-runtime",
231+
Some("us-east-1"),
232+
),
233+
("s3.eu-west-2.amazonaws.com", "s3", Some("eu-west-2")),
234+
(
235+
"lambda.ap-southeast-1.amazonaws.com",
236+
"lambda",
237+
Some("ap-southeast-1"),
238+
),
239+
// Regional — newer geo prefixes
240+
(
241+
"sts.il-central-1.amazonaws.com",
242+
"sts",
243+
Some("il-central-1"),
244+
),
245+
(
246+
"sts.ap-southeast-7.amazonaws.com",
247+
"sts",
248+
Some("ap-southeast-7"),
249+
),
250+
// Regional — GovCloud (extra word in region code)
251+
(
252+
"s3.us-gov-east-1.amazonaws.com",
253+
"s3",
254+
Some("us-gov-east-1"),
255+
),
256+
// Regional — ISO partition
257+
(
258+
"s3.us-iso-east-1.amazonaws.com",
259+
"s3",
260+
Some("us-iso-east-1"),
261+
),
262+
// Regional — FIPS suffix stripped
263+
(
264+
"bedrock-runtime-fips.us-east-1.amazonaws.com",
265+
"bedrock-runtime",
266+
Some("us-east-1"),
267+
),
268+
// Global (no region)
269+
("iam.amazonaws.com", "iam", None),
270+
("sts.amazonaws.com", "sts", None),
271+
];
272+
for &(host, expected_service, expected_region) in cases {
273+
let h = AwsUrlParts::parse(host).unwrap();
274+
assert_eq!(h.normalized_service, expected_service, "host={host:?}");
275+
assert_eq!(h.region, expected_region, "host={host:?}");
276+
}
277+
}
278+
279+
// =========================================================================
280+
// AwsUrlParts::parse — rejected inputs
281+
// =========================================================================
282+
283+
/// Inputs that must return None, with the reason documented inline.
284+
#[test]
285+
fn parse_rejects_invalid_hosts() {
286+
let cases: &[(&str, &str)] = &[
287+
("api.openai.com", "non-amazonaws TLD"),
288+
(
289+
"bedrock-runtime.us-east-1.api.aws",
290+
"api.aws dual-stack not handled",
291+
),
292+
("localhost", "bare hostname"),
293+
("", "empty string"),
294+
("amazonaws.com", "too few segments"),
295+
(
296+
"extra.bedrock-runtime.us-east-1.amazonaws.com",
297+
"too many segments",
298+
),
299+
("s3.US-EAST-1.amazonaws.com", "uppercase region"),
300+
("s3.us-east.amazonaws.com", "region missing trailing digit"),
301+
("s3.useast1.amazonaws.com", "region missing hyphens"),
302+
(
303+
"iam.amazonaws.amazonaws.com",
304+
"\"amazonaws\" is not a region",
305+
),
306+
];
307+
for &(host, reason) in cases {
308+
assert!(
309+
AwsUrlParts::parse(host).is_none(),
310+
"expected None for {host:?} ({reason})"
311+
);
312+
}
313+
}
314+
315+
// =========================================================================
316+
// AwsUrlParts::signing_service
317+
// =========================================================================
318+
319+
#[test]
320+
fn signing_service_maps_known_segments() {
321+
// Identity: segment equals signing name
322+
let s3 = AwsUrlParts {
323+
normalized_service: "s3",
324+
region: None,
325+
};
326+
assert_eq!(s3.signing_service(), Some("s3"));
327+
328+
// Non-obvious: host prefix differs from signing name
329+
let email = AwsUrlParts {
330+
normalized_service: "email",
331+
region: None,
332+
};
333+
assert_eq!(email.signing_service(), Some("ses"));
334+
335+
// Multi-prefix family: bedrock-runtime signs as bedrock
336+
let runtime = AwsUrlParts {
337+
normalized_service: "bedrock-runtime",
338+
region: None,
339+
};
340+
assert_eq!(runtime.signing_service(), Some("bedrock"));
341+
}
342+
343+
#[test]
344+
fn signing_service_returns_none_for_unknown_segment() {
345+
let unknown = AwsUrlParts {
346+
normalized_service: "unknownsvc",
347+
region: None,
348+
};
349+
assert!(unknown.signing_service().is_none());
350+
}
351+
}

crates/nono-proxy/src/aws/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! AWS SigV4 support for nono-proxy.
2+
//!
3+
//! This module provides:
4+
//! - `endpoints`: host-to-service mapping table and region extraction.
5+
//! - `route`: per-route state (`AwsRoute`) and the owned route+provider table (`AwsRouteTable`).
6+
//! - `sign`: SigV4 request signing with selective auth header stripping.
7+
8+
pub mod endpoints;
9+
pub mod route;
10+
pub mod sign;

0 commit comments

Comments
 (0)