diff --git a/Cargo.lock b/Cargo.lock index 92bc18499..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,7 +3809,7 @@ dependencies = [ "futures", "glob", "hex", - "hmac", + "http 1.4.0", "ipnet", "landlock", "libc", @@ -3697,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", @@ -3823,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" @@ -4075,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" @@ -4615,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", @@ -4655,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", @@ -6223,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", @@ -6303,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", @@ -6322,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", @@ -6447,7 +6622,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6466,7 +6641,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6654,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" @@ -7319,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-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 8dbaf077c..c7691375b 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -135,6 +135,10 @@ 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, + #[serde(default, skip_serializing_if = "String::is_empty")] + signing_service: String, } // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. @@ -344,6 +348,8 @@ 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(), @@ -509,6 +515,8 @@ 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 25c750e63..bb7572749 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -595,6 +595,8 @@ 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(), + signing_service: String::new(), } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6d527bc53..2c62bbe8f 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -34,9 +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" @@ -89,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/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 703aafae4..7edb996b0 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,11 @@ 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, + /// 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. @@ -165,6 +178,13 @@ 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, + }; + + let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -175,6 +195,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { websocket_credential_rewrite, 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 6d271af21..5033b1cb3 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -351,6 +351,9 @@ 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, + signing_service: config.signing_service.clone(), + host: ctx.host.clone(), }, ) .await?; @@ -769,6 +772,9 @@ 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, + signing_service: config.signing_service.clone(), + host: ctx.host.clone(), }, ) .await?; @@ -1417,6 +1423,8 @@ network_policies: websocket_credential_rewrite: true, 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(), @@ -1517,6 +1525,8 @@ network_policies: websocket_credential_rewrite: true, 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(), @@ -1634,6 +1644,8 @@ network_policies: websocket_credential_rewrite: true, 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 c513499f4..431d38e2d 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -377,6 +377,9 @@ where generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), + host: String::new(), }, ) .await @@ -389,12 +392,15 @@ 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) signing_service: String, + pub(crate) host: String, } pub(crate) async fn relay_http_request_with_options_guarded( @@ -421,8 +427,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 +459,76 @@ 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"); + 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; + if service.is_empty() { + return Err(miette!( + "SigV4 signing configured but signing_service not set in policy" + )); + } + 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, + access_key, + secret_key, + session_token.as_deref(), + ); + 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..4ce6f6070 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; +pub 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..61aaaccd1 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1067,6 +1067,12 @@ 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.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(); } @@ -2658,6 +2664,65 @@ 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(), + signing_service: "bedrock".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); + assert_eq!(l7.signing_service, "bedrock"); + } + #[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..63e96fea6 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -1097,6 +1097,8 @@ fn network_endpoint_from_json( graphql_persisted_queries: HashMap::new(), 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 88deb1596..68b773a74 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -2672,6 +2672,9 @@ 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, + signing_service: String::new(), + host: String::new(), }, ) .await @@ -3548,6 +3551,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 +3703,8 @@ mod tests { websocket_credential_rewrite, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), } } @@ -4165,6 +4171,8 @@ network_policies: websocket_credential_rewrite: false, request_body_credential_rewrite: false, websocket_graphql_policy: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: String::new(), }, }, L7ConfigSnapshot { @@ -4178,6 +4186,8 @@ network_policies: websocket_credential_rewrite: false, 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 new file mode 100644 index 000000000..096504fa9 --- /dev/null +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use aws_credential_types::Credentials; +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; + +/// 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" + { + Some(parts[1].to_string()) + } else { + None + } +} + +/// 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 official `aws-sigv4` crate, and returns the rewritten request bytes. +pub fn apply_sigv4_to_request( + raw: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, +) -> 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(); + + 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 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; + } + if let Some((k, v)) = line.split_once(':') { + let lower = k.trim().to_ascii_lowercase(); + if lower == "host" || lower == "content-type" || lower == "content-length" { + existing_headers.push((lower, v.trim().to_string())); + } + } + } + + let uri = format!("https://{host}{path}"); + + let identity: Identity = Credentials::new( + access_key, + secret_key, + session_token.map(|s| s.to_string()), + None, + "openshell", + ) + .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(settings) + .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 + 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()); + } + + // 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"); + + // Body + output.extend_from_slice(body); + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_region_from_hostname() { + let region = + extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-2"); + } + + #[test] + fn extract_region_from_sts_hostname() { + let region = extract_aws_region("sts.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + } + + #[test] + fn non_aws_hostname_returns_none() { + assert!(extract_aws_region("api.anthropic.com").is_none()); + } + + #[test] + fn sign_produces_valid_format() { + 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", + "us-east-2", + "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] + 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 result = apply_sigv4_to_request( + raw, + "bedrock-runtime.us-east-2.amazonaws.com", + "us-east-2", + "bedrock", + "AKIATEST", + "secret", + None, + ); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); + assert!(!result_str.contains("old-invalid-sig")); + assert!(!result_str.contains("old-date")); + } +} 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)") + ); +} diff --git a/proto/sandbox.proto b/proto/sandbox.proto index b40d95cb1..78efa9ccf 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -124,6 +124,13 @@ 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; + // 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.