From f9180ee388be5feb0eaf9fb7fd60f6ea4115bd28 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Thu, 28 May 2026 18:42:38 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(sandbox):=20proxy-side=20AWS=20SigV4?= =?UTF-8?q?=20credential=20signing=20(POC)=20Add=20`credential=5Fsigning:?= =?UTF-8?q?=20sigv4`=20policy=20field=20for=20REST=20endpoints.=20When=20s?= =?UTF-8?q?et,=20the=20sandbox=20proxy=20strips=20the=20client's=20invalid?= =?UTF-8?q?=20SigV4=20Authorization=20header=20(computed=20with=20placehol?= =?UTF-8?q?der=20credentials)=20and=20re-signs=20the=20request=20using=20r?= =?UTF-8?q?eal=20AWS=20credentials=20from=20the=20SecretResolver=20before?= =?UTF-8?q?=20forwarding=20upstream.=20This=20enables=20AWS=20services=20l?= =?UTF-8?q?ike=20Bedrock=20that=20use=20SigV4=20auth=20to=20work=20through?= =?UTF-8?q?=20OpenShell's=20credential=20proxy=20without=20exposing=20real?= =?UTF-8?q?=20secrets=20to=20the=20sandbox.=20Changes:=20-=20proto:=20add?= =?UTF-8?q?=20credential=5Fsigning=20field=20(18)=20to=20NetworkEndpoint?= =?UTF-8?q?=20-=20l7/mod.rs:=20add=20CredentialSigning=20enum,=20parse=20f?= =?UTF-8?q?rom=20policy=20YAML=20-=20sigv4.rs:=20new=20module=20=E2=80=94?= =?UTF-8?q?=20SigV4=20signing=20with=20AWS=20service=20name=20normalizatio?= =?UTF-8?q?n=20-=20l7/rest.rs:=20hook=20SigV4=20into=20CONNECT=20tunnel=20?= =?UTF-8?q?relay=20(strip=20AWS=20headers=20=20=20before=20fail-closed=20s?= =?UTF-8?q?can,=20re-sign=20after=20rewrite)=20-=20opa.rs:=20plumb=20crede?= =?UTF-8?q?ntial=5Fsigning=20through=20OPA=20data=20-=20policy=20lib:=20ad?= =?UTF-8?q?d=20credential=5Fsigning=20to=20serde=20and=20proto=20conversio?= =?UTF-8?q?n=20Tested=20end-to-end:=20Claude=20Code=20=E2=86=92=20OpenShel?= =?UTF-8?q?l=20sandbox=20=E2=86=92=20Bedrock=20(us-east-2)=20with=20proxy-?= =?UTF-8?q?side=20SigV4=20re-signing.=20Sandbox=20never=20sees=20real=20AW?= =?UTF-8?q?S=20credentials.=20TODO:=20replace=20hand-rolled=20SigV4=20with?= =?UTF-8?q?=20aws-sigv4=20crate=20for=20correct=20service=20name=20resolut?= =?UTF-8?q?ion=20across=20all=20AWS=20services.=20Co-Authored-By:=20Claude?= =?UTF-8?q?=20Opus=204.6=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesse Jaggars --- Cargo.lock | 1 + crates/openshell-policy/src/lib.rs | 4 + crates/openshell-providers/src/profiles.rs | 1 + crates/openshell-sandbox/Cargo.toml | 1 + crates/openshell-sandbox/src/l7/mod.rs | 16 + crates/openshell-sandbox/src/l7/relay.rs | 7 + crates/openshell-sandbox/src/l7/rest.rs | 83 ++++- crates/openshell-sandbox/src/lib.rs | 1 + crates/openshell-sandbox/src/opa.rs | 60 +++ crates/openshell-sandbox/src/policy_local.rs | 1 + crates/openshell-sandbox/src/proxy.rs | 6 + crates/openshell-sandbox/src/sigv4.rs | 362 +++++++++++++++++++ proto/sandbox.proto | 4 + 13 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 crates/openshell-sandbox/src/sigv4.rs diff --git a/Cargo.lock b/Cargo.lock index 92bc18499..a691ab963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3647,6 +3647,7 @@ dependencies = [ "glob", "hex", "hmac", + "http", "ipnet", "landlock", "libc", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 8dbaf077c..d54351aab 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -135,6 +135,8 @@ struct NetworkEndpointDef { graphql_persisted_queries: BTreeMap, #[serde(default, skip_serializing_if = "is_zero_u32")] graphql_max_body_bytes: u32, + #[serde(default, skip_serializing_if = "String::is_empty")] + credential_signing: String, } // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. @@ -344,6 +346,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing, } }) .collect(), @@ -509,6 +512,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, + credential_signing: e.credential_signing.clone(), } }) .collect(), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 25c750e63..17192129f 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -595,6 +595,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: String::new(), } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6d527bc53..d01a6af60 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -37,6 +37,7 @@ anyhow = { workspace = true } hmac = "0.12" sha2 = { workspace = true } hex = "0.4" +http = { workspace = true } russh = "0.57" rand_core = "0.6" diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 703aafae4..57ba0f862 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -50,6 +50,14 @@ pub enum TlsMode { Skip, } +/// Credential signing mode for proxy-side request signing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CredentialSigning { + #[default] + None, + SigV4, +} + /// Enforcement mode for L7 policy decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EnforcementMode { @@ -88,6 +96,8 @@ pub struct L7EndpointConfig { /// When true, client-to-server GraphQL-over-WebSocket operation messages /// are classified with the same operation policy used by GraphQL-over-HTTP. pub websocket_graphql_policy: bool, + /// Proxy-side credential signing mode for this endpoint. + pub credential_signing: CredentialSigning, } /// Result of an L7 policy decision for a single request. @@ -165,6 +175,11 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { .filter(|v| *v > 0) .unwrap_or(graphql::DEFAULT_MAX_BODY_BYTES); + let credential_signing = match get_object_str(val, "credential_signing").as_deref() { + Some("sigv4") => CredentialSigning::SigV4, + _ => CredentialSigning::None, + }; + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -175,6 +190,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { websocket_credential_rewrite, request_body_credential_rewrite, websocket_graphql_policy, + credential_signing, }) } diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 6d271af21..ec1651450 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -351,6 +351,8 @@ where websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + credential_signing: config.credential_signing, + host: ctx.host.clone(), }, ) .await?; @@ -769,6 +771,8 @@ where websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, + credential_signing: config.credential_signing, + host: ctx.host.clone(), }, ) .await?; @@ -1417,6 +1421,7 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, }]; let ctx = L7EvalContext { host: "gateway.example.test".into(), @@ -1517,6 +1522,7 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(), @@ -1634,6 +1640,7 @@ network_policies: websocket_credential_rewrite: true, request_body_credential_rewrite: false, websocket_graphql_policy: true, + credential_signing: crate::l7::CredentialSigning::None, }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("T".to_string(), "real-token".to_string())).collect(), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index c513499f4..0703846a5 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -377,6 +377,8 @@ where generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + host: String::new(), }, ) .await @@ -389,12 +391,14 @@ pub(crate) enum WebSocketExtensionMode { PermessageDeflate, } -#[derive(Clone, Copy, Default)] +#[derive(Clone, Default)] pub(crate) struct RelayRequestOptions<'a> { pub(crate) resolver: Option<&'a SecretResolver>, pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, + pub(crate) credential_signing: crate::l7::CredentialSigning, + pub(crate) host: String, } pub(crate) async fn relay_http_request_with_options_guarded( @@ -421,8 +425,19 @@ where parse_websocket_upgrade_request(&req.raw_header[..header_end])? }; + // When SigV4 signing is configured, strip AWS auth headers before credential + // rewriting so the fail-closed placeholder scan doesn't reject the SigV4 + // Authorization header (which embeds placeholder strings). + let raw_for_rewrite; + let header_source = if options.credential_signing == crate::l7::CredentialSigning::SigV4 { + raw_for_rewrite = crate::sigv4::strip_aws_headers(&req.raw_header[..header_end]); + &raw_for_rewrite[..] + } else { + &req.raw_header[..header_end] + }; + let (header_bytes, expected_websocket_extension) = rewrite_websocket_extensions_for_mode( - &req.raw_header[..header_end], + header_source, options.websocket_extensions, websocket_request.is_some(), )?; @@ -442,7 +457,69 @@ where guard.ensure_current()?; } - if options.request_body_credential_rewrite { + // Apply SigV4 signing if configured. We need the full request (headers + body) + // to compute the signature, so for SigV4 we always buffer the body first. + if options.credential_signing == crate::l7::CredentialSigning::SigV4 { + if let Some(resolver) = options.resolver { + let access_key_placeholder = + crate::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); + let secret_key_placeholder = + crate::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY"); + + match ( + resolver.resolve_placeholder(&access_key_placeholder), + resolver.resolve_placeholder(&secret_key_placeholder), + ) { + (Some(access_key), Some(secret_key)) => { + let creds = crate::sigv4::AwsCredentials { + access_key_id: access_key.to_string(), + secret_access_key: secret_key.to_string(), + }; + let (region, service) = + crate::sigv4::extract_aws_region_and_service(&options.host) + .unwrap_or_else(|| { + ("us-east-1".to_string(), "execute-api".to_string()) + }); + tracing::warn!( + host = %options.host, + region = %region, + service = %service, + "applying SigV4 signing to CONNECT tunnel request" + ); + + // Collect body from overflow + stream + let overflow = &req.raw_header[header_end..]; + let mut full_request = rewrite_result.rewritten.clone(); + full_request.extend_from_slice(overflow); + // Read remaining body based on content-length + if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? { + let already_have = overflow.len() as u64; + if body_len > already_have { + let remaining = (body_len - already_have) as usize; + let mut body_buf = vec![0u8; remaining]; + client.read_exact(&mut body_buf).await.into_diagnostic()?; + full_request.extend_from_slice(&body_buf); + } + } + + let signed = + crate::sigv4::apply_sigv4_to_request( + &full_request, &options.host, ®ion, &service, &creds, + ); + upstream.write_all(&signed).await.into_diagnostic()?; + } + _ => { + return Err(miette!( + "SigV4 signing configured but AWS credentials not found in provider" + )); + } + } + } else { + return Err(miette!( + "SigV4 signing configured but no secret resolver available" + )); + } + } else if options.request_body_credential_rewrite { let body = collect_and_rewrite_request_body( req, client, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4a0e61e57..839f56345 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -23,6 +23,7 @@ mod provider_credentials; pub mod proxy; mod sandbox; mod secrets; +mod sigv4; mod skills; mod ssh; mod supervisor_session; diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 0acbbe93d..72695e7ea 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1067,6 +1067,9 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if e.request_body_credential_rewrite { ep["request_body_credential_rewrite"] = true.into(); } + if !e.credential_signing.is_empty() { + ep["credential_signing"] = e.credential_signing.clone().into(); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -2658,6 +2661,63 @@ network_policies: assert!(l7.websocket_credential_rewrite); } + #[test] + fn l7_endpoint_config_preserves_proto_credential_signing() { + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "bedrock".to_string(), + NetworkPolicyRule { + name: "bedrock".to_string(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-2.amazonaws.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + credential_signing: "sigv4".to_string(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/local/bin/claude".to_string(), + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + + let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); + let input = NetworkInput { + host: "bedrock-runtime.us-east-2.amazonaws.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let config = engine + .query_endpoint_config(&input) + .unwrap() + .expect("endpoint config"); + let l7 = crate::l7::parse_l7_config(&config).unwrap(); + assert_eq!(l7.credential_signing, crate::l7::CredentialSigning::SigV4); + } + #[test] fn l7_endpoint_config_preserves_proto_request_body_credential_rewrite() { let mut network_policies = std::collections::HashMap::new(); diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index 657fd760f..17b1d32b1 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -1097,6 +1097,7 @@ fn network_endpoint_from_json( graphql_persisted_queries: HashMap::new(), graphql_max_body_bytes: 0, path: String::new(), + credential_signing: String::new(), }) } diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 88deb1596..775988f21 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -2672,6 +2672,8 @@ where generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, + credential_signing: crate::l7::CredentialSigning::None, + host: String::new(), }, ) .await @@ -3548,6 +3550,7 @@ async fn handle_forward_proxy( return Ok(()); } }; + if let Err(e) = forward_generation_guard.ensure_current() { emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); respond( @@ -3699,6 +3702,7 @@ mod tests { websocket_credential_rewrite, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, } } @@ -4165,6 +4169,7 @@ network_policies: websocket_credential_rewrite: false, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, }, }, L7ConfigSnapshot { @@ -4178,6 +4183,7 @@ network_policies: websocket_credential_rewrite: false, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, }, }, ]; diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs new file mode 100644 index 000000000..b3053fae8 --- /dev/null +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use std::time::SystemTime; + +type HmacSha256 = Hmac; + +pub struct AwsCredentials { + pub access_key_id: String, + pub secret_access_key: String, +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = + HmacSha256::new_from_slice(key).expect("HMAC can take key of any size"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + hex::encode(hasher.finalize()) +} + +fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec { + let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes()); + let k_region = hmac_sha256(&k_date, region.as_bytes()); + let k_service = hmac_sha256(&k_region, service.as_bytes()); + hmac_sha256(&k_service, b"aws4_request") +} + +pub fn extract_aws_region_and_service(host: &str) -> Option<(String, String)> { + // Pattern: ..amazonaws.com + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" + { + let raw_service = parts[0]; + let region = parts[1].to_string(); + // AWS signing service name overrides: some services use a base name + // that differs from the hostname prefix. The aws-sigv4 SDK crate + // handles this automatically via internal endpoint metadata — for this + // POC we hardcode known overrides. TODO: replace with aws-sigv4 crate. + let service = normalize_aws_signing_name(raw_service).to_string(); + Some((region, service)) + } else { + None + } +} + +fn normalize_aws_signing_name(hostname_prefix: &str) -> &str { + match hostname_prefix { + "bedrock-runtime" | "bedrock-agent" | "bedrock-agent-runtime" => "bedrock", + other => other, + } +} + +pub fn sign_request( + method: &str, + path: &str, + host: &str, + headers: &[(String, String)], + body: &[u8], + region: &str, + service: &str, + credentials: &AwsCredentials, +) -> (String, String, String) { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock before epoch"); + let secs = now.as_secs(); + let datetime = format_iso8601(secs); + let date = &datetime[..8]; + + let payload_hash = sha256_hex(body); + + // Canonical headers: must include host, x-amz-date, x-amz-content-sha256 + // plus any existing headers the SDK sent that are in the signed-headers list. + let mut canonical_headers: Vec<(String, String)> = Vec::new(); + canonical_headers.push(("host".to_string(), host.to_string())); + canonical_headers.push(("x-amz-content-sha256".to_string(), payload_hash.clone())); + canonical_headers.push(("x-amz-date".to_string(), datetime.clone())); + + // Include content-type if present in original headers + for (k, v) in headers { + let lower = k.to_ascii_lowercase(); + if lower == "content-type" || lower == "content-length" { + canonical_headers.push((lower, v.trim().to_string())); + } + } + + canonical_headers.sort_by(|a, b| a.0.cmp(&b.0)); + + let canonical_headers_str: String = canonical_headers + .iter() + .map(|(k, v)| format!("{k}:{v}\n")) + .collect(); + + let signed_headers: String = canonical_headers + .iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(";"); + + // Split path from query string + let (canon_path, query_string) = match path.split_once('?') { + Some((p, q)) => (p, q.to_string()), + None => (path, String::new()), + }; + + let canonical_request = format!( + "{method}\n{canon_path}\n{query_string}\n{canonical_headers_str}\n{signed_headers}\n{payload_hash}" + ); + + let credential_scope = format!("{date}/{region}/{service}/aws4_request"); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + + let key = signing_key(&credentials.secret_access_key, date, region, service); + let signature = hex::encode(hmac_sha256(&key, string_to_sign.as_bytes())); + + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + credentials.access_key_id, + ); + + (authorization, datetime, payload_hash) +} + +fn format_iso8601(epoch_secs: u64) -> String { + let days_since_epoch = epoch_secs / 86400; + let time_of_day = epoch_secs % 86400; + + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Civil date from days since 1970-01-01 (algorithm from Howard Hinnant) + let z = days_since_epoch as i64 + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!("{y:04}{m:02}{d:02}T{hours:02}{minutes:02}{seconds:02}Z") +} + +/// Strip AWS auth headers from raw HTTP request bytes. +/// +/// Removes Authorization, X-Amz-Date, X-Amz-Security-Token, and +/// X-Amz-Content-Sha256 headers so the request can pass through the +/// proxy's fail-closed placeholder scan before SigV4 re-signing. +pub fn strip_aws_headers(raw: &[u8]) -> Vec { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(raw.len(), |p| p + 4); + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + let lines: Vec<&str> = header_str.split("\r\n").collect(); + + let mut output = Vec::with_capacity(raw.len()); + + for (i, line) in lines.iter().enumerate() { + if i == 0 { + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + continue; + } + if line.is_empty() { + break; + } + let lower = line.to_ascii_lowercase(); + if lower.starts_with("authorization:") + || lower.starts_with("x-amz-date:") + || lower.starts_with("x-amz-security-token:") + || lower.starts_with("x-amz-content-sha256:") + { + continue; + } + output.extend_from_slice(line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + + if header_end < raw.len() { + output.extend_from_slice(&raw[header_end..]); + } + + output +} + +/// Apply SigV4 signing to a raw HTTP request buffer. +/// +/// Strips existing AWS auth headers, computes a new SigV4 signature using +/// the provided credentials, and returns the rewritten request bytes. +pub fn apply_sigv4_to_request( + raw: &[u8], + host: &str, + region: &str, + service: &str, + credentials: &AwsCredentials, +) -> Vec { + let header_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(raw.len(), |p| p + 4); + + let body = if header_end < raw.len() { + &raw[header_end..] + } else { + &[] + }; + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + let lines: Vec<&str> = header_str.split("\r\n").collect(); + + // Parse method and path from request line + let (method, path) = if let Some(first_line) = lines.first() { + let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); + if parts.len() >= 2 { + (parts[0], parts[1]) + } else { + ("GET", "/") + } + } else { + ("GET", "/") + }; + + // Collect existing headers, skipping AWS auth headers we'll replace + let mut existing_headers: Vec<(String, String)> = Vec::new(); + for line in lines.iter().skip(1) { + if line.is_empty() { + break; + } + let lower = line.to_ascii_lowercase(); + if lower.starts_with("authorization:") + || lower.starts_with("x-amz-date:") + || lower.starts_with("x-amz-security-token:") + || lower.starts_with("x-amz-content-sha256:") + { + continue; + } + if let Some((k, v)) = line.split_once(':') { + existing_headers.push((k.trim().to_string(), v.trim().to_string())); + } + } + + let (authorization, amz_date, content_sha256) = + sign_request(method, path, host, &existing_headers, body, region, service, credentials); + + // Rebuild the request + let mut output = Vec::with_capacity(raw.len() + 256); + + // Request line + if let Some(first_line) = lines.first() { + output.extend_from_slice(first_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + // Existing headers (filtered) + for (k, v) in &existing_headers { + output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); + } + + // Injected AWS headers + output.extend_from_slice(format!("Authorization: {authorization}\r\n").as_bytes()); + output.extend_from_slice(format!("X-Amz-Date: {amz_date}\r\n").as_bytes()); + output.extend_from_slice(format!("X-Amz-Content-Sha256: {content_sha256}\r\n").as_bytes()); + + // End of headers + output.extend_from_slice(b"\r\n"); + + // Body + output.extend_from_slice(body); + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_region_and_service_from_hostname() { + let (region, service) = + extract_aws_region_and_service("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-2"); + assert_eq!(service, "bedrock"); + } + + #[test] + fn extract_sts_from_hostname() { + let (region, service) = + extract_aws_region_and_service("sts.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + assert_eq!(service, "sts"); + } + + #[test] + fn non_aws_hostname_returns_none() { + assert!(extract_aws_region_and_service("api.anthropic.com").is_none()); + } + + #[test] + fn sign_produces_valid_format() { + let creds = AwsCredentials { + access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + }; + let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + let (auth, date, hash) = sign_request( + "POST", + "/model/us.anthropic.claude-sonnet-4-6/invoke", + "bedrock-runtime.us-east-2.amazonaws.com", + &headers, + b"{}", + "us-east-2", + "bedrock", + &creds, + ); + assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!(auth.contains("SignedHeaders=")); + assert!(auth.contains("Signature=")); + assert_eq!(date.len(), 16); // 20060102T150405Z + assert_eq!(hash.len(), 64); // SHA256 hex + } + + #[test] + fn apply_sigv4_rewrites_request() { + let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAuthorization: AWS4-HMAC-SHA256 old-invalid-sig\r\nX-Amz-Date: old-date\r\n\r\n{}"; + let creds = AwsCredentials { + access_key_id: "AKIATEST".to_string(), + secret_access_key: "secret".to_string(), + }; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock-runtime", + &creds, + ); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("Authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); + assert!(result_str.contains("X-Amz-Date: ")); + assert!(result_str.contains("X-Amz-Content-Sha256: ")); + // Old auth headers should be gone + assert!(!result_str.contains("old-invalid-sig")); + assert!(!result_str.contains("old-date")); + } +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index b40d95cb1..7f4c477d3 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -124,6 +124,10 @@ message NetworkEndpoint { // inside supported textual HTTP request bodies before forwarding upstream. // Defaults to false. bool request_body_credential_rewrite = 17; + // Proxy-side credential signing mode: "sigv4" for AWS SigV4 re-signing. + // When set, the proxy strips the client's Authorization header and computes + // a fresh SigV4 signature using real credentials from the provider. + string credential_signing = 18; } // Trusted GraphQL operation classification. From 1477f2144c80754a24b5337b134a6ae663195ac0 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Thu, 28 May 2026 19:30:57 -0400 Subject: [PATCH 2/7] refactor(sandbox): replace hand-rolled SigV4 with aws-sigv4 crate Replace the hand-rolled HMAC key derivation, canonical request formatting, and ISO 8601 date math with the official aws-sigv4 crate. This gives us a tested, correct SigV4 implementation while keeping the same proxy-side re-signing flow. - Add aws-sigv4, aws-credential-types, aws-smithy-runtime-api deps - Remove hmac dependency (no longer used directly) - Rewrite apply_sigv4_to_request to delegate to aws_sigv4::http_request::sign() - Delete AwsCredentials struct, sign_request, format_iso8601, and internal crypto helpers (~90 lines of hand-rolled code) - Keep normalize_aws_signing_name table (no SDK alternative exists) - Keep strip_aws_headers (raw byte manipulation, no SDK equivalent) - Update l7/rest.rs call site to pass credentials as &str directly Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jesse Jaggars --- Cargo.lock | 260 ++++++++++++++++++++---- crates/openshell-sandbox/Cargo.toml | 6 +- crates/openshell-sandbox/src/l7/rest.rs | 16 +- crates/openshell-sandbox/src/sigv4.rs | 231 ++++++--------------- 4 files changed, 296 insertions(+), 217 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a691ab963..f3a3e694c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,18 @@ dependencies = [ "cc", ] +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -303,6 +315,112 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.10.9", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + [[package]] name = "axum" version = "0.7.9" @@ -313,8 +431,8 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "itoa", "matchit 0.7.3", @@ -341,8 +459,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -375,8 +493,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -394,8 +512,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -464,6 +582,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -566,7 +694,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -626,6 +754,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bzip2" version = "0.6.1" @@ -1854,7 +1992,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -2001,6 +2139,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2020,6 +2169,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2027,7 +2187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2038,8 +2198,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2075,8 +2235,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2107,7 +2267,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "log", @@ -2142,8 +2302,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -2657,8 +2817,8 @@ dependencies = [ "either", "futures", "home", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -2690,7 +2850,7 @@ checksum = "7845bcc3e0f422df4d9049570baedd9bc1942f0504594e393e72fe24092559cf" dependencies = [ "chrono", "form_urlencoded", - "http", + "http 1.4.0", "json-patch", "k8s-openapi", "schemars", @@ -3282,7 +3442,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http", + "http 1.4.0", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -3311,7 +3471,7 @@ dependencies = [ "bytes", "chrono", "futures-util", - "http", + "http 1.4.0", "http-auth", "jsonwebtoken 10.3.0", "lazy_static", @@ -3639,6 +3799,9 @@ version = "0.0.0" dependencies = [ "anyhow", "apollo-parser", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "base64 0.22.1", "bytes", "clap", @@ -3646,8 +3809,7 @@ dependencies = [ "futures", "glob", "hex", - "hmac", - "http", + "http 1.4.0", "ipnet", "landlock", "libc", @@ -3698,8 +3860,8 @@ dependencies = [ "futures-util", "hex", "hmac", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -3824,6 +3986,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.3.0" @@ -4076,6 +4244,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -4616,8 +4790,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -4656,8 +4830,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -6224,8 +6398,8 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -6304,8 +6478,8 @@ dependencies = [ "base64 0.21.7", "bitflags", "bytes", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -6323,8 +6497,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower 0.5.3", @@ -6448,7 +6622,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6467,7 +6641,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6655,6 +6829,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -7320,7 +7500,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index d01a6af60..f74378a4a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -34,10 +34,14 @@ clap = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } -hmac = "0.12" sha2 = { workspace = true } hex = "0.4" http = { workspace = true } + +# AWS SigV4 request signing +aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +aws-smithy-runtime-api = { version = "1", features = ["client"] } russh = "0.57" rand_core = "0.6" diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 0703846a5..181118a57 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -471,10 +471,6 @@ where resolver.resolve_placeholder(&secret_key_placeholder), ) { (Some(access_key), Some(secret_key)) => { - let creds = crate::sigv4::AwsCredentials { - access_key_id: access_key.to_string(), - secret_access_key: secret_key.to_string(), - }; let (region, service) = crate::sigv4::extract_aws_region_and_service(&options.host) .unwrap_or_else(|| { @@ -502,10 +498,14 @@ where } } - let signed = - crate::sigv4::apply_sigv4_to_request( - &full_request, &options.host, ®ion, &service, &creds, - ); + let signed = crate::sigv4::apply_sigv4_to_request( + &full_request, + &options.host, + ®ion, + &service, + access_key, + secret_key, + ); upstream.write_all(&signed).await.into_diagnostic()?; } _ => { diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index b3053fae8..6e1afdcc5 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -1,37 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use hmac::{Hmac, Mac}; -use sha2::{Digest, Sha256}; +use aws_credential_types::Credentials; +use aws_sigv4::http_request::{sign, SignableBody, SignableRequest, SigningSettings}; +use aws_sigv4::sign::v4; +use aws_smithy_runtime_api::client::identity::Identity; use std::time::SystemTime; -type HmacSha256 = Hmac; - -pub struct AwsCredentials { - pub access_key_id: String, - pub secret_access_key: String, -} - -fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { - let mut mac = - HmacSha256::new_from_slice(key).expect("HMAC can take key of any size"); - mac.update(data); - mac.finalize().into_bytes().to_vec() -} - -fn sha256_hex(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - hex::encode(hasher.finalize()) -} - -fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec { - let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes()); - let k_region = hmac_sha256(&k_date, region.as_bytes()); - let k_service = hmac_sha256(&k_region, service.as_bytes()); - hmac_sha256(&k_service, b"aws4_request") -} - pub fn extract_aws_region_and_service(host: &str) -> Option<(String, String)> { // Pattern: ..amazonaws.com let parts: Vec<&str> = host.split('.').collect(); @@ -39,10 +14,6 @@ pub fn extract_aws_region_and_service(host: &str) -> Option<(String, String)> { { let raw_service = parts[0]; let region = parts[1].to_string(); - // AWS signing service name overrides: some services use a base name - // that differs from the hostname prefix. The aws-sigv4 SDK crate - // handles this automatically via internal endpoint metadata — for this - // POC we hardcode known overrides. TODO: replace with aws-sigv4 crate. let service = normalize_aws_signing_name(raw_service).to_string(); Some((region, service)) } else { @@ -50,6 +21,10 @@ pub fn extract_aws_region_and_service(host: &str) -> Option<(String, String)> { } } +// AWS services have signing name overrides that differ from hostname +// prefixes. The full SDK embeds these per-service (e.g. +// aws-sdk-bedrockruntime hardcodes SigningName("bedrock")). There is +// no lightweight crate for this mapping, so we maintain our own table. fn normalize_aws_signing_name(hostname_prefix: &str) -> &str { match hostname_prefix { "bedrock-runtime" | "bedrock-agent" | "bedrock-agent-runtime" => "bedrock", @@ -57,104 +32,6 @@ fn normalize_aws_signing_name(hostname_prefix: &str) -> &str { } } -pub fn sign_request( - method: &str, - path: &str, - host: &str, - headers: &[(String, String)], - body: &[u8], - region: &str, - service: &str, - credentials: &AwsCredentials, -) -> (String, String, String) { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .expect("system clock before epoch"); - let secs = now.as_secs(); - let datetime = format_iso8601(secs); - let date = &datetime[..8]; - - let payload_hash = sha256_hex(body); - - // Canonical headers: must include host, x-amz-date, x-amz-content-sha256 - // plus any existing headers the SDK sent that are in the signed-headers list. - let mut canonical_headers: Vec<(String, String)> = Vec::new(); - canonical_headers.push(("host".to_string(), host.to_string())); - canonical_headers.push(("x-amz-content-sha256".to_string(), payload_hash.clone())); - canonical_headers.push(("x-amz-date".to_string(), datetime.clone())); - - // Include content-type if present in original headers - for (k, v) in headers { - let lower = k.to_ascii_lowercase(); - if lower == "content-type" || lower == "content-length" { - canonical_headers.push((lower, v.trim().to_string())); - } - } - - canonical_headers.sort_by(|a, b| a.0.cmp(&b.0)); - - let canonical_headers_str: String = canonical_headers - .iter() - .map(|(k, v)| format!("{k}:{v}\n")) - .collect(); - - let signed_headers: String = canonical_headers - .iter() - .map(|(k, _)| k.as_str()) - .collect::>() - .join(";"); - - // Split path from query string - let (canon_path, query_string) = match path.split_once('?') { - Some((p, q)) => (p, q.to_string()), - None => (path, String::new()), - }; - - let canonical_request = format!( - "{method}\n{canon_path}\n{query_string}\n{canonical_headers_str}\n{signed_headers}\n{payload_hash}" - ); - - let credential_scope = format!("{date}/{region}/{service}/aws4_request"); - - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{datetime}\n{credential_scope}\n{}", - sha256_hex(canonical_request.as_bytes()) - ); - - let key = signing_key(&credentials.secret_access_key, date, region, service); - let signature = hex::encode(hmac_sha256(&key, string_to_sign.as_bytes())); - - let authorization = format!( - "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", - credentials.access_key_id, - ); - - (authorization, datetime, payload_hash) -} - -fn format_iso8601(epoch_secs: u64) -> String { - let days_since_epoch = epoch_secs / 86400; - let time_of_day = epoch_secs % 86400; - - let hours = time_of_day / 3600; - let minutes = (time_of_day % 3600) / 60; - let seconds = time_of_day % 60; - - // Civil date from days since 1970-01-01 (algorithm from Howard Hinnant) - let z = days_since_epoch as i64 + 719_468; - let era = z / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - format!("{y:04}{m:02}{d:02}T{hours:02}{minutes:02}{seconds:02}Z") -} - /// Strip AWS auth headers from raw HTTP request bytes. /// /// Removes Authorization, X-Amz-Date, X-Amz-Security-Token, and @@ -204,13 +81,14 @@ pub fn strip_aws_headers(raw: &[u8]) -> Vec { /// Apply SigV4 signing to a raw HTTP request buffer. /// /// Strips existing AWS auth headers, computes a new SigV4 signature using -/// the provided credentials, and returns the rewritten request bytes. +/// the official `aws-sigv4` crate, and returns the rewritten request bytes. pub fn apply_sigv4_to_request( raw: &[u8], host: &str, region: &str, service: &str, - credentials: &AwsCredentials, + access_key: &str, + secret_key: &str, ) -> Vec { let header_end = raw .windows(4) @@ -226,7 +104,6 @@ pub fn apply_sigv4_to_request( let header_str = String::from_utf8_lossy(&raw[..header_end]); let lines: Vec<&str> = header_str.split("\r\n").collect(); - // Parse method and path from request line let (method, path) = if let Some(first_line) = lines.first() { let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); if parts.len() >= 2 { @@ -257,10 +134,42 @@ pub fn apply_sigv4_to_request( } } - let (authorization, amz_date, content_sha256) = - sign_request(method, path, host, &existing_headers, body, region, service, credentials); - - // Rebuild the request + let uri = format!("https://{host}{path}"); + + let identity: Identity = Credentials::new( + access_key, + secret_key, + None, + None, + "openshell", + ) + .into(); + + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name(service) + .time(SystemTime::now()) + .settings(SigningSettings::default()) + .build() + .expect("all required signing params provided") + .into(); + + let signable_request = SignableRequest::new( + method, + &uri, + existing_headers + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + SignableBody::Bytes(body), + ) + .expect("valid signable request"); + + let (instructions, _signature) = sign(signable_request, &signing_params) + .expect("signing should not fail with valid inputs") + .into_parts(); + + // Rebuild the request with signed headers let mut output = Vec::with_capacity(raw.len() + 256); // Request line @@ -274,10 +183,10 @@ pub fn apply_sigv4_to_request( output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); } - // Injected AWS headers - output.extend_from_slice(format!("Authorization: {authorization}\r\n").as_bytes()); - output.extend_from_slice(format!("X-Amz-Date: {amz_date}\r\n").as_bytes()); - output.extend_from_slice(format!("X-Amz-Content-Sha256: {content_sha256}\r\n").as_bytes()); + // Signed headers from the SDK + for (name, value) in instructions.headers() { + output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } // End of headers output.extend_from_slice(b"\r\n"); @@ -315,47 +224,33 @@ mod tests { #[test] fn sign_produces_valid_format() { - let creds = AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - }; - let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; - let (auth, date, hash) = sign_request( - "POST", - "/model/us.anthropic.claude-sonnet-4-6/invoke", + let raw = b"POST /model/us.anthropic.claude-sonnet-4-6/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, "bedrock-runtime.us-east-2.amazonaws.com", - &headers, - b"{}", "us-east-2", "bedrock", - &creds, + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ); - assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); - assert!(auth.contains("SignedHeaders=")); - assert!(auth.contains("Signature=")); - assert_eq!(date.len(), 16); // 20060102T150405Z - assert_eq!(hash.len(), 64); // SHA256 hex + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!(result_str.contains("x-amz-date: ")); } #[test] fn apply_sigv4_rewrites_request() { let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\nAuthorization: AWS4-HMAC-SHA256 old-invalid-sig\r\nX-Amz-Date: old-date\r\n\r\n{}"; - let creds = AwsCredentials { - access_key_id: "AKIATEST".to_string(), - secret_access_key: "secret".to_string(), - }; let result = apply_sigv4_to_request( raw, "bedrock-runtime.us-east-2.amazonaws.com", "us-east-2", - "bedrock-runtime", - &creds, + "bedrock", + "AKIATEST", + "secret", ); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("Authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); - assert!(result_str.contains("X-Amz-Date: ")); - assert!(result_str.contains("X-Amz-Content-Sha256: ")); - // Old auth headers should be gone + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); assert!(!result_str.contains("old-invalid-sig")); assert!(!result_str.contains("old-date")); } From 32b2b361484bef0ef81740a4eaa7e5bc1010caef Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Thu, 28 May 2026 19:40:42 -0400 Subject: [PATCH 3/7] feat(sandbox): add signing_service policy field, remove signing name guessing Move the AWS signing service name into the policy document instead of inferring it from the hostname. The policy author specifies the correct signing service name (e.g. "bedrock") directly, eliminating the fragile normalize_aws_signing_name mapping table. Policy YAML example: credential_signing: sigv4 signing_service: bedrock - Proto: add signing_service field (19) to NetworkEndpoint - Policy lib: add signing_service to serde struct and proto conversions - L7/OPA/relay: plumb signing_service through to RelayRequestOptions - rest.rs: use signing_service from policy; error if empty with SigV4 - sigv4.rs: replace extract_aws_region_and_service with extract_aws_region, delete normalize_aws_signing_name entirely Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jesse Jaggars --- crates/openshell-policy/src/lib.rs | 4 +++ crates/openshell-providers/src/profiles.rs | 1 + crates/openshell-sandbox/src/l7/mod.rs | 6 ++++ crates/openshell-sandbox/src/l7/relay.rs | 5 +++ crates/openshell-sandbox/src/l7/rest.rs | 15 +++++--- crates/openshell-sandbox/src/opa.rs | 5 +++ crates/openshell-sandbox/src/policy_local.rs | 1 + crates/openshell-sandbox/src/proxy.rs | 4 +++ crates/openshell-sandbox/src/sigv4.rs | 36 ++++++-------------- proto/sandbox.proto | 3 ++ 10 files changed, 49 insertions(+), 31 deletions(-) diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index d54351aab..c7691375b 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -137,6 +137,8 @@ struct NetworkEndpointDef { graphql_max_body_bytes: u32, #[serde(default, skip_serializing_if = "String::is_empty")] credential_signing: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + signing_service: String, } // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. @@ -347,6 +349,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, credential_signing: e.credential_signing, + signing_service: e.signing_service, } }) .collect(), @@ -513,6 +516,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { .collect(), graphql_max_body_bytes: e.graphql_max_body_bytes, credential_signing: e.credential_signing.clone(), + signing_service: e.signing_service.clone(), } }) .collect(), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 17192129f..bb7572749 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -596,6 +596,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), credential_signing: String::new(), + signing_service: String::new(), } } diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 57ba0f862..7edb996b0 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -98,6 +98,9 @@ pub struct L7EndpointConfig { pub websocket_graphql_policy: bool, /// Proxy-side credential signing mode for this endpoint. pub credential_signing: CredentialSigning, + /// AWS signing service name (e.g. "bedrock"). Required when + /// credential_signing is SigV4. + pub signing_service: String, } /// Result of an L7 policy decision for a single request. @@ -180,6 +183,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { _ => CredentialSigning::None, }; + let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -191,6 +196,7 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { request_body_credential_rewrite, websocket_graphql_policy, credential_signing, + signing_service, }) } diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index ec1651450..5033b1cb3 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -352,6 +352,7 @@ where request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, credential_signing: config.credential_signing, + signing_service: config.signing_service.clone(), host: ctx.host.clone(), }, ) @@ -772,6 +773,7 @@ where request_body_credential_rewrite: config.protocol == L7Protocol::Rest && config.request_body_credential_rewrite, credential_signing: config.credential_signing, + signing_service: config.signing_service.clone(), host: ctx.host.clone(), }, ) @@ -1422,6 +1424,7 @@ network_policies: request_body_credential_rewrite: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let ctx = L7EvalContext { host: "gateway.example.test".into(), @@ -1523,6 +1526,7 @@ network_policies: request_body_credential_rewrite: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())).collect(), @@ -1641,6 +1645,7 @@ network_policies: request_body_credential_rewrite: false, websocket_graphql_policy: true, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }]; let (child_env, resolver) = SecretResolver::from_provider_env( std::iter::once(("T".to_string(), "real-token".to_string())).collect(), diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 181118a57..4f0c6acda 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -378,6 +378,7 @@ where websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), host: String::new(), }, ) @@ -398,6 +399,7 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, pub(crate) credential_signing: crate::l7::CredentialSigning, + pub(crate) signing_service: String, pub(crate) host: String, } @@ -471,11 +473,14 @@ where resolver.resolve_placeholder(&secret_key_placeholder), ) { (Some(access_key), Some(secret_key)) => { - let (region, service) = - crate::sigv4::extract_aws_region_and_service(&options.host) - .unwrap_or_else(|| { - ("us-east-1".to_string(), "execute-api".to_string()) - }); + let region = crate::sigv4::extract_aws_region(&options.host) + .unwrap_or_else(|| "us-east-1".to_string()); + let service = &options.signing_service; + if service.is_empty() { + return Err(miette!( + "SigV4 signing configured but signing_service not set in policy" + )); + } tracing::warn!( host = %options.host, region = %region, diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index 72695e7ea..61aaaccd1 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1070,6 +1070,9 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St if !e.credential_signing.is_empty() { ep["credential_signing"] = e.credential_signing.clone().into(); } + if !e.signing_service.is_empty() { + ep["signing_service"] = e.signing_service.clone().into(); + } if !e.persisted_queries.is_empty() { ep["persisted_queries"] = e.persisted_queries.clone().into(); } @@ -2675,6 +2678,7 @@ network_policies: enforcement: "enforce".to_string(), access: "read-write".to_string(), credential_signing: "sigv4".to_string(), + signing_service: "bedrock".to_string(), ..Default::default() }], binaries: vec![NetworkBinary { @@ -2716,6 +2720,7 @@ network_policies: .expect("endpoint config"); let l7 = crate::l7::parse_l7_config(&config).unwrap(); assert_eq!(l7.credential_signing, crate::l7::CredentialSigning::SigV4); + assert_eq!(l7.signing_service, "bedrock"); } #[test] diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index 17b1d32b1..63e96fea6 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -1098,6 +1098,7 @@ fn network_endpoint_from_json( graphql_max_body_bytes: 0, path: String::new(), credential_signing: String::new(), + signing_service: String::new(), }) } diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 775988f21..68b773a74 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -2673,6 +2673,7 @@ where websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), host: String::new(), }, ) @@ -3703,6 +3704,7 @@ mod tests { request_body_credential_rewrite: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), } } @@ -4170,6 +4172,7 @@ network_policies: request_body_credential_rewrite: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }, }, L7ConfigSnapshot { @@ -4184,6 +4187,7 @@ network_policies: request_body_credential_rewrite: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }, }, ]; diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 6e1afdcc5..1585287d9 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -7,31 +7,18 @@ use aws_sigv4::sign::v4; use aws_smithy_runtime_api::client::identity::Identity; use std::time::SystemTime; -pub fn extract_aws_region_and_service(host: &str) -> Option<(String, String)> { - // Pattern: ..amazonaws.com +/// Extract the AWS region from a standard AWS hostname. +/// Pattern: `..amazonaws.com` → ``. +pub fn extract_aws_region(host: &str) -> Option { let parts: Vec<&str> = host.split('.').collect(); if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" { - let raw_service = parts[0]; - let region = parts[1].to_string(); - let service = normalize_aws_signing_name(raw_service).to_string(); - Some((region, service)) + Some(parts[1].to_string()) } else { None } } -// AWS services have signing name overrides that differ from hostname -// prefixes. The full SDK embeds these per-service (e.g. -// aws-sdk-bedrockruntime hardcodes SigningName("bedrock")). There is -// no lightweight crate for this mapping, so we maintain our own table. -fn normalize_aws_signing_name(hostname_prefix: &str) -> &str { - match hostname_prefix { - "bedrock-runtime" | "bedrock-agent" | "bedrock-agent-runtime" => "bedrock", - other => other, - } -} - /// Strip AWS auth headers from raw HTTP request bytes. /// /// Removes Authorization, X-Amz-Date, X-Amz-Security-Token, and @@ -202,24 +189,21 @@ mod tests { use super::*; #[test] - fn extract_region_and_service_from_hostname() { - let (region, service) = - extract_aws_region_and_service("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); + fn extract_region_from_hostname() { + let region = + extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); assert_eq!(region, "us-east-2"); - assert_eq!(service, "bedrock"); } #[test] - fn extract_sts_from_hostname() { - let (region, service) = - extract_aws_region_and_service("sts.us-east-1.amazonaws.com").unwrap(); + fn extract_region_from_sts_hostname() { + let region = extract_aws_region("sts.us-east-1.amazonaws.com").unwrap(); assert_eq!(region, "us-east-1"); - assert_eq!(service, "sts"); } #[test] fn non_aws_hostname_returns_none() { - assert!(extract_aws_region_and_service("api.anthropic.com").is_none()); + assert!(extract_aws_region("api.anthropic.com").is_none()); } #[test] diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 7f4c477d3..78efa9ccf 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -128,6 +128,9 @@ message NetworkEndpoint { // When set, the proxy strips the client's Authorization header and computes // a fresh SigV4 signature using real credentials from the provider. string credential_signing = 18; + // AWS signing service name override. Required when credential_signing is + // "sigv4" — e.g. "bedrock" for bedrock-runtime endpoints. + string signing_service = 19; } // Trusted GraphQL operation classification. From 181af80a009d09951acab28c0d0751d97ae0b7a3 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Fri, 29 May 2026 07:41:45 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(sandbox):=20sign=20only=20minimal=20hea?= =?UTF-8?q?ders=20and=20include=20payload=20checksum=20The=20aws-sigv4=20S?= =?UTF-8?q?DK=20signs=20every=20header=20passed=20to=20SignableRequest.=20?= =?UTF-8?q?When=20the=20proxy=20or=20transport=20modifies=20headers=20like?= =?UTF-8?q?=20Connection=20or=20Accept-Encoding=20between=20signing=20and?= =?UTF-8?q?=20delivery,=20the=20signature=20breaks=20(403=20InvalidSignatu?= =?UTF-8?q?re=20from=20AWS).=20Fix=20by=20only=20including=20host,=20conte?= =?UTF-8?q?nt-type,=20and=20content-length=20in=20the=20signed=20headers?= =?UTF-8?q?=20=E2=80=94=20matching=20the=20working=20hand-rolled=20impleme?= =?UTF-8?q?ntation.=20Also=20enable=20PayloadChecksumKind::XAmzSha256=20so?= =?UTF-8?q?=20the=20x-amz-content-sha256=20header=20is=20included=20in=20t?= =?UTF-8?q?he=20signature,=20which=20AWS=20Bedrock=20requires.=20Tested=20?= =?UTF-8?q?end-to-end:=20Claude=20Code=20=E2=86=92=20OpenShell=20sandbox?= =?UTF-8?q?=20=E2=86=92=20Bedrock=20succeeds.=20Co-Authored-By:=20Claude?= =?UTF-8?q?=20Opus=204.6=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesse Jaggars --- crates/openshell-sandbox/src/l7/rest.rs | 2 +- crates/openshell-sandbox/src/sigv4.rs | 29 +++++++++++++++---------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 4f0c6acda..1b2592a56 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -507,7 +507,7 @@ where &full_request, &options.host, ®ion, - &service, + service, access_key, secret_key, ); diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 1585287d9..6557326f9 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use aws_credential_types::Credentials; -use aws_sigv4::http_request::{sign, SignableBody, SignableRequest, SigningSettings}; +use aws_sigv4::http_request::{ + sign, PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, +}; use aws_sigv4::sign::v4; use aws_smithy_runtime_api::client::identity::Identity; use std::time::SystemTime; @@ -102,22 +104,21 @@ pub fn apply_sigv4_to_request( ("GET", "/") }; - // Collect existing headers, skipping AWS auth headers we'll replace + // Collect only headers that should be included in the SigV4 signature. + // The old hand-rolled code only signed host, content-type, and + // content-length. Signing all headers causes failures when the proxy + // or transport modifies unsigned-by-convention headers (Connection, + // Accept-Encoding, etc.) between signing and delivery. let mut existing_headers: Vec<(String, String)> = Vec::new(); for line in lines.iter().skip(1) { if line.is_empty() { break; } - let lower = line.to_ascii_lowercase(); - if lower.starts_with("authorization:") - || lower.starts_with("x-amz-date:") - || lower.starts_with("x-amz-security-token:") - || lower.starts_with("x-amz-content-sha256:") - { - continue; - } if let Some((k, v)) = line.split_once(':') { - existing_headers.push((k.trim().to_string(), v.trim().to_string())); + let lower = k.trim().to_ascii_lowercase(); + if lower == "host" || lower == "content-type" || lower == "content-length" { + existing_headers.push((lower, v.trim().to_string())); + } } } @@ -132,12 +133,15 @@ pub fn apply_sigv4_to_request( ) .into(); + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + let signing_params = v4::SigningParams::builder() .identity(&identity) .region(region) .name(service) .time(SystemTime::now()) - .settings(SigningSettings::default()) + .settings(settings) .build() .expect("all required signing params provided") .into(); @@ -219,6 +223,7 @@ mod tests { ); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!(result_str.contains("x-amz-content-sha256: ")); assert!(result_str.contains("x-amz-date: ")); } From 37f0499249cf7a26ed4d4ab4dad50b5aa3db0033 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Fri, 29 May 2026 07:59:34 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(sandbox):=20support=20AWS=20session=20?= =?UTF-8?q?tokens=20in=20SigV4=20signing=20Add=20session=5Ftoken=20paramet?= =?UTF-8?q?er=20to=20apply=5Fsigv4=5Fto=5Frequest=20so=20SigV4=20signing?= =?UTF-8?q?=20works=20with=20temporary=20STS=20credentials=20(AccessKeyId?= =?UTF-8?q?=20+=20SecretAccessKey=20+=20SessionToken).=20The=20aws-sigv4?= =?UTF-8?q?=20SDK=20handles=20injecting=20the=20x-amz-security-token=20hea?= =?UTF-8?q?der=20automatically=20when=20a=20session=20token=20is=20present?= =?UTF-8?q?.=20The=20call=20site=20resolves=20AWS=5FSESSION=5FTOKEN=20from?= =?UTF-8?q?=20the=20SecretResolver=20alongside=20the=20access/secret=20key?= =?UTF-8?q?s.=20The=20token=20is=20optional=20=E2=80=94=20static=20IAM=20k?= =?UTF-8?q?eys=20continue=20to=20work=20without=20it.=20Prepares=20for=20i?= =?UTF-8?q?ssue=20#1576=20(aws=5Fsts=5Fassume=5Frole=20refresh=20strategy)?= =?UTF-8?q?=20which=20will=20provide=20short-lived=20STS=20credentials=20v?= =?UTF-8?q?ia=20gateway-side=20rotation.=20Co-Authored-By:=20Claude=20Opus?= =?UTF-8?q?=204.6=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesse Jaggars --- crates/openshell-sandbox/src/l7/rest.rs | 4 ++++ crates/openshell-sandbox/src/sigv4.rs | 23 ++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 1b2592a56..431d38e2d 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -467,12 +467,15 @@ where crate::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); let secret_key_placeholder = crate::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY"); + let session_token_placeholder = + crate::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN"); match ( resolver.resolve_placeholder(&access_key_placeholder), resolver.resolve_placeholder(&secret_key_placeholder), ) { (Some(access_key), Some(secret_key)) => { + let session_token = resolver.resolve_placeholder(&session_token_placeholder); let region = crate::sigv4::extract_aws_region(&options.host) .unwrap_or_else(|| "us-east-1".to_string()); let service = &options.signing_service; @@ -510,6 +513,7 @@ where service, access_key, secret_key, + session_token.as_deref(), ); upstream.write_all(&signed).await.into_diagnostic()?; } diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 6557326f9..096504fa9 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -78,6 +78,7 @@ pub fn apply_sigv4_to_request( service: &str, access_key: &str, secret_key: &str, + session_token: Option<&str>, ) -> Vec { let header_end = raw .windows(4) @@ -127,7 +128,7 @@ pub fn apply_sigv4_to_request( let identity: Identity = Credentials::new( access_key, secret_key, - None, + session_token.map(|s| s.to_string()), None, "openshell", ) @@ -220,11 +221,30 @@ mod tests { "bedrock", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, ); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); assert!(result_str.contains("x-amz-content-sha256: ")); assert!(result_str.contains("x-amz-date: ")); + assert!(!result_str.contains("x-amz-security-token")); + } + + #[test] + fn sign_with_session_token() { + let raw = b"POST /model/test/invoke HTTP/1.1\r\nHost: bedrock-runtime.us-east-2.amazonaws.com\r\nContent-Type: application/json\r\n\r\n{}"; + let result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "ASIAEXAMPLE", + "secret", + Some("FwoGZXIvYXdzEBYaDH+session+token"), + ); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=ASIAEXAMPLE/")); + assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); } #[test] @@ -237,6 +257,7 @@ mod tests { "bedrock", "AKIATEST", "secret", + None, ); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); From 95ef1f8c8e8fdd63b64fef9c6045d5af84cbac60 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Fri, 29 May 2026 08:37:56 -0400 Subject: [PATCH 6/7] =?UTF-8?q?test(sandbox):=20add=20SigV4=20integration?= =?UTF-8?q?=20test=20against=20real=20Bedrock=20Add=20a=20standalone=20int?= =?UTF-8?q?egration=20test=20that=20exercises=20the=20full=20proxy-side=20?= =?UTF-8?q?re-signing=20flow:=20take=20a=20raw=20HTTP=20request=20with=20f?= =?UTF-8?q?ake=20AWS=20auth=20headers,=20strip=20them,=20re-sign=20with=20?= =?UTF-8?q?real=20credentials=20via=20apply=5Fsigv4=5Fto=5Frequest,=20send?= =?UTF-8?q?=20over=20TLS=20to=20Bedrock,=20and=20verify=20a=20200=20respon?= =?UTF-8?q?se.=20Marked=20#[ignore]=20=E2=80=94=20requires=20real=20AWS=20?= =?UTF-8?q?credentials:=20=20=20AWS=5FACCESS=5FKEY=5FID=3Dxxx=20AWS=5FSECR?= =?UTF-8?q?ET=5FACCESS=5FKEY=3Dxxx=20cargo=20test=20\=20=20=20=20=20-p=20o?= =?UTF-8?q?penshell-sandbox=20--test=20sigv4=5Fsigning=20--=20--ignored=20?= =?UTF-8?q?--nocapture=20Also=20makes=20the=20sigv4=20module=20pub=20for?= =?UTF-8?q?=20integration=20test=20access.=20Co-Authored-By:=20Claude=20Op?= =?UTF-8?q?us=204.6=20(1M=20context)=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jesse Jaggars --- crates/openshell-sandbox/Cargo.toml | 4 + crates/openshell-sandbox/src/lib.rs | 2 +- .../openshell-sandbox/tests/sigv4_signing.rs | 93 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 crates/openshell-sandbox/tests/sigv4_signing.rs diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index f74378a4a..2c62bbe8f 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -94,6 +94,10 @@ seccompiler = "0.5" tempfile = "3" uuid = { version = "1", features = ["v4"] } +[[test]] +name = "sigv4_signing" +path = "tests/sigv4_signing.rs" + [dev-dependencies] tempfile = "3" temp-env = "0.3" diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 839f56345..4ce6f6070 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -23,7 +23,7 @@ mod provider_credentials; pub mod proxy; mod sandbox; mod secrets; -mod sigv4; +pub mod sigv4; mod skills; mod ssh; mod supervisor_session; diff --git a/crates/openshell-sandbox/tests/sigv4_signing.rs b/crates/openshell-sandbox/tests/sigv4_signing.rs new file mode 100644 index 000000000..cdf9f9d09 --- /dev/null +++ b/crates/openshell-sandbox/tests/sigv4_signing.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test for SigV4 proxy-side re-signing. +//! +//! Simulates what the proxy does: takes a raw HTTP request (like the AWS SDK +//! would generate with placeholder credentials), strips the invalid AWS auth +//! headers, re-signs with real credentials, and sends to Bedrock. +//! +//! Run with real AWS credentials: +//! AWS_ACCESS_KEY_ID=AKIAxxx AWS_SECRET_ACCESS_KEY=xxx cargo test \ +//! -p openshell-sandbox --test sigv4_signing -- --ignored --nocapture + +use std::io::{Read, Write}; +use std::net::TcpStream; + +#[test] +#[ignore] // requires real AWS credentials +fn sigv4_resign_and_call_bedrock() { + let access_key = + std::env::var("AWS_ACCESS_KEY_ID").expect("AWS_ACCESS_KEY_ID must be set"); + let secret_key = + std::env::var("AWS_SECRET_ACCESS_KEY").expect("AWS_SECRET_ACCESS_KEY must be set"); + let session_token = std::env::var("AWS_SESSION_TOKEN").ok(); + let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-2".to_string()); + let host = format!("bedrock.{region}.amazonaws.com"); + + // Build a raw HTTP request as if the AWS SDK generated it with fake creds. + // This is what arrives at the proxy from inside the sandbox. + let fake_signed_request = format!( + "GET /foundation-models HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Authorization: AWS4-HMAC-SHA256 Credential=FAKEFAKEFAKE/20260101/us-east-2/bedrock/aws4_request, SignedHeaders=host, Signature=0000000000000000000000000000000000000000000000000000000000000000\r\n\ + X-Amz-Date: 20260101T000000Z\r\n\ + X-Amz-Content-Sha256: fake-hash\r\n\ + Accept: application/json\r\n\ + Connection: keep-alive\r\n\ + \r\n" + ); + + // Step 1: Strip invalid AWS auth headers (proxy does this before + // the fail-closed placeholder scan) + let stripped = openshell_sandbox::sigv4::strip_aws_headers(fake_signed_request.as_bytes()); + let stripped_str = String::from_utf8_lossy(&stripped); + assert!(!stripped_str.contains("FAKEFAKEFAKE"), "old auth should be stripped"); + assert!(!stripped_str.contains("fake-hash"), "old hash should be stripped"); + + // Step 2: Re-sign with real credentials + let signed = openshell_sandbox::sigv4::apply_sigv4_to_request( + &stripped, + &host, + ®ion, + "bedrock", + &access_key, + &secret_key, + session_token.as_deref(), + ); + + let signed_str = String::from_utf8_lossy(&signed); + eprintln!("--- Signed request headers ---"); + if let Some(end) = signed_str.find("\r\n\r\n") { + eprintln!("{}", &signed_str[..end]); + } + + // Step 3: Send to Bedrock over TLS + let mut tcp = TcpStream::connect(format!("{host}:443")).expect("TCP connect"); + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + let server_name: rustls::pki_types::ServerName = host.clone().try_into().unwrap(); + let mut tls = rustls::ClientConnection::new(std::sync::Arc::new(config), server_name).unwrap(); + let mut stream = rustls::Stream::new(&mut tls, &mut tcp); + + stream.write_all(&signed).expect("TLS write"); + stream.flush().expect("TLS flush"); + + let mut response = vec![0u8; 4096]; + let n = stream.read(&mut response).expect("TLS read"); + let response_str = String::from_utf8_lossy(&response[..n]); + + eprintln!("\n--- Response (first {n} bytes) ---"); + eprintln!("{response_str}"); + + // Verify we got HTTP 200, not 403 InvalidSignatureException + assert!( + response_str.starts_with("HTTP/1.1 200"), + "Expected 200 OK but got: {}", + response_str.lines().next().unwrap_or("(empty)") + ); +} From bec3afc562898e297a1951de766c14ba468e016b Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Fri, 29 May 2026 15:50:45 -0400 Subject: [PATCH 7/7] style(sandbox): fix clippy and rustfmt warnings in SigV4 code - Reorder imports per rustfmt - Use map_or instead of if-let/else for request line parsing - Use ToString::to_string instead of redundant closure - Use usize::try_from instead of truncating as-cast - Remove redundant as_deref on Option<&str> - Backtick-wrap identifiers in doc comments - Reformat long assert! lines Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jesse Jaggars --- crates/openshell-sandbox/src/l7/mod.rs | 4 +-- crates/openshell-sandbox/src/l7/rest.rs | 5 ++-- crates/openshell-sandbox/src/sigv4.rs | 29 +++++++++---------- .../openshell-sandbox/tests/sigv4_signing.rs | 13 ++++++--- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 7edb996b0..5b6f84a73 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -98,8 +98,8 @@ pub struct L7EndpointConfig { pub websocket_graphql_policy: bool, /// Proxy-side credential signing mode for this endpoint. pub credential_signing: CredentialSigning, - /// AWS signing service name (e.g. "bedrock"). Required when - /// credential_signing is SigV4. + /// AWS signing service name (e.g. `"bedrock"`). Required when + /// `credential_signing` is `SigV4`. pub signing_service: String, } diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index 431d38e2d..e9feb6af0 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -499,7 +499,8 @@ where if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? { let already_have = overflow.len() as u64; if body_len > already_have { - let remaining = (body_len - already_have) as usize; + let remaining = + usize::try_from(body_len - already_have).unwrap_or(usize::MAX); let mut body_buf = vec![0u8; remaining]; client.read_exact(&mut body_buf).await.into_diagnostic()?; full_request.extend_from_slice(&body_buf); @@ -513,7 +514,7 @@ where service, access_key, secret_key, - session_token.as_deref(), + session_token, ); upstream.write_all(&signed).await.into_diagnostic()?; } diff --git a/crates/openshell-sandbox/src/sigv4.rs b/crates/openshell-sandbox/src/sigv4.rs index 096504fa9..be3cbdebc 100644 --- a/crates/openshell-sandbox/src/sigv4.rs +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -3,7 +3,7 @@ use aws_credential_types::Credentials; use aws_sigv4::http_request::{ - sign, PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, + PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, sign, }; use aws_sigv4::sign::v4; use aws_smithy_runtime_api::client::identity::Identity; @@ -23,9 +23,9 @@ pub fn extract_aws_region(host: &str) -> Option { /// Strip AWS auth headers from raw HTTP request bytes. /// -/// Removes Authorization, X-Amz-Date, X-Amz-Security-Token, and -/// X-Amz-Content-Sha256 headers so the request can pass through the -/// proxy's fail-closed placeholder scan before SigV4 re-signing. +/// Removes `Authorization`, `X-Amz-Date`, `X-Amz-Security-Token`, and +/// `X-Amz-Content-Sha256` headers so the request can pass through the +/// proxy's fail-closed placeholder scan before re-signing. pub fn strip_aws_headers(raw: &[u8]) -> Vec { let header_end = raw .windows(4) @@ -67,10 +67,10 @@ pub fn strip_aws_headers(raw: &[u8]) -> Vec { output } -/// Apply SigV4 signing to a raw HTTP request buffer. +/// Apply AWS Signature Version 4 signing to a raw HTTP request buffer. /// -/// Strips existing AWS auth headers, computes a new SigV4 signature using -/// the official `aws-sigv4` crate, and returns the rewritten request bytes. +/// Strips existing AWS auth headers, computes a new signature using the +/// `aws-sigv4` crate, and returns the rewritten request bytes. pub fn apply_sigv4_to_request( raw: &[u8], host: &str, @@ -94,16 +94,14 @@ pub fn apply_sigv4_to_request( let header_str = String::from_utf8_lossy(&raw[..header_end]); let lines: Vec<&str> = header_str.split("\r\n").collect(); - let (method, path) = if let Some(first_line) = lines.first() { + let (method, path) = lines.first().map_or(("GET", "/"), |first_line| { let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); if parts.len() >= 2 { (parts[0], parts[1]) } else { ("GET", "/") } - } else { - ("GET", "/") - }; + }); // Collect only headers that should be included in the SigV4 signature. // The old hand-rolled code only signed host, content-type, and @@ -128,7 +126,7 @@ pub fn apply_sigv4_to_request( let identity: Identity = Credentials::new( access_key, secret_key, - session_token.map(|s| s.to_string()), + session_token.map(ToString::to_string), None, "openshell", ) @@ -195,8 +193,7 @@ mod tests { #[test] fn extract_region_from_hostname() { - let region = - extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); + let region = extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); assert_eq!(region, "us-east-2"); } @@ -224,7 +221,9 @@ mod tests { None, ); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!( + result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") + ); assert!(result_str.contains("x-amz-content-sha256: ")); assert!(result_str.contains("x-amz-date: ")); assert!(!result_str.contains("x-amz-security-token")); diff --git a/crates/openshell-sandbox/tests/sigv4_signing.rs b/crates/openshell-sandbox/tests/sigv4_signing.rs index cdf9f9d09..d46a2aad5 100644 --- a/crates/openshell-sandbox/tests/sigv4_signing.rs +++ b/crates/openshell-sandbox/tests/sigv4_signing.rs @@ -17,8 +17,7 @@ use std::net::TcpStream; #[test] #[ignore] // requires real AWS credentials fn sigv4_resign_and_call_bedrock() { - let access_key = - std::env::var("AWS_ACCESS_KEY_ID").expect("AWS_ACCESS_KEY_ID must be set"); + let access_key = std::env::var("AWS_ACCESS_KEY_ID").expect("AWS_ACCESS_KEY_ID must be set"); let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").expect("AWS_SECRET_ACCESS_KEY must be set"); let session_token = std::env::var("AWS_SESSION_TOKEN").ok(); @@ -43,8 +42,14 @@ fn sigv4_resign_and_call_bedrock() { // the fail-closed placeholder scan) let stripped = openshell_sandbox::sigv4::strip_aws_headers(fake_signed_request.as_bytes()); let stripped_str = String::from_utf8_lossy(&stripped); - assert!(!stripped_str.contains("FAKEFAKEFAKE"), "old auth should be stripped"); - assert!(!stripped_str.contains("fake-hash"), "old hash should be stripped"); + assert!( + !stripped_str.contains("FAKEFAKEFAKE"), + "old auth should be stripped" + ); + assert!( + !stripped_str.contains("fake-hash"), + "old hash should be stripped" + ); // Step 2: Re-sign with real credentials let signed = openshell_sandbox::sigv4::apply_sigv4_to_request(