diff --git a/Cargo.lock b/Cargo.lock index 4bc657be3..fe12b61de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,43 @@ dependencies = [ "cc", ] +[[package]] +name = "aws-config" +version = "1.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[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 +340,286 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc35b7a14cabdad13795fbbbd26d5ddec0882c01492ceedf2af575aad5f37dd" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[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-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.38", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "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-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[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", + "futures-core", + "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", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.7.9" @@ -313,8 +630,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,10 +658,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -375,8 +692,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 +711,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 +781,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,9 +893,9 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http", + "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -626,6 +953,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" @@ -1843,6 +2180,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1854,7 +2210,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -2001,6 +2357,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 +2387,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 +2405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2038,8 +2416,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2064,6 +2442,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2074,9 +2476,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2093,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -2101,20 +2503,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", "log", - "rustls", + "rustls 0.23.38", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.7", ] @@ -2125,7 +2542,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -2142,9 +2559,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", @@ -2163,7 +2580,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -2657,18 +3074,18 @@ dependencies = [ "either", "futures", "home", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-timeout", "hyper-util", "jsonpath-rust", "k8s-openapi", "kube-core", "pem", - "rustls", + "rustls 0.23.38", "rustls-pemfile", "secrecy", "serde", @@ -2690,7 +3107,7 @@ checksum = "7845bcc3e0f422df4d9049570baedd9bc1942f0504594e393e72fe24092559cf" dependencies = [ "chrono", "form_urlencoded", - "http", + "http 1.4.0", "json-patch", "k8s-openapi", "schemars", @@ -3009,7 +3426,7 @@ checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda" dependencies = [ "base64 0.22.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "indexmap 2.14.0", "ipnet", @@ -3282,7 +3699,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 +3728,7 @@ dependencies = [ "bytes", "chrono", "futures-util", - "http", + "http 1.4.0", "http-auth", "jsonwebtoken 10.3.0", "lazy_static", @@ -3416,8 +3833,8 @@ dependencies = [ "dialoguer", "futures", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "indicatif", "miette", @@ -3433,7 +3850,7 @@ dependencies = [ "prost-types", "rcgen", "reqwest 0.12.28", - "rustls", + "rustls 0.23.38", "rustls-pemfile", "serde", "serde_json", @@ -3443,7 +3860,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -3523,7 +3940,7 @@ dependencies = [ "clap", "futures", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "miette", "nix", @@ -3644,6 +4061,9 @@ version = "0.0.0" dependencies = [ "anyhow", "apollo-parser", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "base64 0.22.1", "bytes", "clap", @@ -3651,7 +4071,7 @@ dependencies = [ "futures", "glob", "hex", - "hmac", + "http 1.4.0", "ipnet", "landlock", "libc", @@ -3666,7 +4086,7 @@ dependencies = [ "regorus", "russh", "rustix 1.1.4", - "rustls", + "rustls 0.23.38", "rustls-pemfile", "seccompiler", "serde", @@ -3678,7 +4098,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tokio-tungstenite 0.26.2", "tonic", @@ -3695,6 +4115,8 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "aws-config", + "aws-sdk-sts", "axum 0.8.9", "bytes", "clap", @@ -3702,11 +4124,11 @@ dependencies = [ "futures-util", "hex", "hmac", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "ipnet", "jsonwebtoken 9.3.1", @@ -3735,7 +4157,7 @@ dependencies = [ "reqwest 0.12.28", "russh", "rustix 1.1.4", - "rustls", + "rustls 0.23.38", "rustls-pemfile", "serde", "serde_json", @@ -3744,7 +4166,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tokio-tungstenite 0.26.2", "toml", @@ -3829,6 +4251,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" @@ -4081,6 +4509,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" @@ -4362,7 +4796,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls", + "rustls 0.23.38", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -4383,7 +4817,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash 2.1.2", - "rustls", + "rustls 0.23.38", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -4584,6 +5018,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -4621,18 +5061,18 @@ 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", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.38", "rustls-native-certs", "rustls-pki-types", "serde", @@ -4640,7 +5080,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -4661,18 +5101,18 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.38", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -4680,7 +5120,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -4916,6 +5356,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.38" @@ -4927,7 +5379,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -4974,10 +5426,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.38", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.13", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4990,6 +5442,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5082,6 +5544,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5536,7 +6008,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.38", "serde", "serde_json", "sha2 0.10.9", @@ -6113,13 +6585,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.38", "tokio", ] @@ -6142,11 +6624,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls", + "rustls 0.23.38", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tungstenite 0.26.2", ] @@ -6228,11 +6710,11 @@ dependencies = [ "axum 0.7.9", "base64 0.22.1", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -6242,7 +6724,7 @@ dependencies = [ "rustls-pemfile", "socket2 0.5.10", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tower 0.4.13", "tower-layer", @@ -6309,8 +6791,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", @@ -6328,8 +6810,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", @@ -6453,11 +6935,11 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", - "rustls", + "rustls 0.23.38", "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", @@ -6472,7 +6954,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -6613,6 +7095,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -6660,6 +7148,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" @@ -7325,9 +7819,9 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http", + "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "log", "once_cell", @@ -7465,6 +7959,12 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xterm-color" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 079e1e172..09e30212f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,10 @@ apollo-parser = "0.8.5" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } +# AWS SDK +aws-config = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] } +aws-sdk-sts = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] } + # WebSocket tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 4bc6803eb..a29b432a0 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -74,6 +74,25 @@ Credential placeholders in proxied HTTP requests can be resolved by the proxy when policy allows the target endpoint. Secrets must not be logged in OCSF or plain tracing output. +For AWS endpoints that require request-level signing, the proxy supports SigV4 +re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy +strips the client's placeholder-based AWS auth headers, re-signs with real +credentials from the provider, and forwards the request upstream. The signing +mode is auto-detected from the client SDK's `x-amz-content-sha256` header: + +- **Signed body** (hex hash): buffers the request body, computes its SHA-256, + and includes the hash in the signature. Used by Bedrock and most AWS services. +- **Streaming unsigned** (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`): signs headers + only and streams the body through without buffering. Used by S3 uploads with + `aws-chunked` encoding. +- **Unsigned payload** (`UNSIGNED-PAYLOAD`): signs headers only with no body + hash. Used by S3 over HTTPS for non-chunked requests. + +Two explicit overrides are available: `credential_signing: sigv4:body` (always +buffer and hash) and `sigv4:no_body` (always unsigned). The `Expect: +100-continue` header is handled within the SigV4 path so clients like boto3 +transmit the body before the proxy forwards to upstream. + ## Connect and Logs The supervisor runs an SSH server on a Unix socket inside the sandbox. The diff --git a/architecture/security-policy.md b/architecture/security-policy.md index b7f5262e6..b3348929a 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -46,18 +46,21 @@ request is denied. ## Host Wildcards Network endpoint `host` patterns accept a `*` wildcard inside the first DNS -label only. The OPA runtime matches with a `.` label boundary, so a wildcard -never spans dots. The validator enforces the same boundary so that policy load -fails fast instead of silently mismatching at the proxy. +label and as an entire middle DNS label. The OPA runtime matches with a `.` +label boundary, so a wildcard never spans dots. The validator enforces the same +boundary so that policy load fails fast instead of silently mismatching at the +proxy. | Pattern | Accepted | Example match | Notes | |---|---|---|---| | `*.example.com` | Yes | `api.example.com` | Single first label of any value. | | `**.example.com` | Yes | `a.b.example.com` | Recursive wildcard as the entire first label. | | `*-aiplatform.googleapis.com` | Yes | `us-central1-aiplatform.googleapis.com` | Intra-label wildcard inside the first DNS label. | +| `*.s3.*.amazonaws.com` | Yes | `bucket.s3.us-east-1.amazonaws.com` | Middle-label `*` matches exactly one DNS label. | | `*` or `**` | No | — | Matches every host. | | `*.com`, `**.com` | No | — | TLD wildcards (`labels <= 2`). | -| `foo.*.example.com` | No | — | Wildcard outside the first DNS label. | +| `foo.us-*.example.com` | No | — | Partial middle-label wildcards are not allowed. | +| `foo.**.example.com` | No | — | Recursive wildcard outside the first label is not allowed. | | `foo**.example.com` | No | — | Recursive `**` mixed inside a label; allowed only as the entire first label. | Validation rejects the disallowed patterns at policy load time with a message diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 21f15834b..9cde2e493 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -661,6 +661,7 @@ enum CliProviderRefreshStrategy { Oauth2RefreshToken, Oauth2ClientCredentials, GoogleServiceAccountJwt, + AwsStsAssumeRole, } impl CliProviderRefreshStrategy { @@ -669,6 +670,7 @@ impl CliProviderRefreshStrategy { Self::Oauth2RefreshToken => "oauth2_refresh_token", Self::Oauth2ClientCredentials => "oauth2_client_credentials", Self::GoogleServiceAccountJwt => "google_service_account_jwt", + Self::AwsStsAssumeRole => "aws_sts_assume_role", } } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 3b2adfc59..67c8ad81a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5039,6 +5039,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result { Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } + "aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), } } @@ -5091,6 +5092,7 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) - ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 26c8fc9d3..5fe8e9ce2 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`. @@ -347,6 +351,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(), @@ -512,6 +518,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(), @@ -694,6 +702,16 @@ pub enum PolicyViolation { TooManyPaths { count: usize }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, + /// A network endpoint uses a wildcard shape that does not match runtime semantics. + InvalidHostWildcard { policy_name: String, host: String }, + /// `credential_signing` is set but `signing_service` is missing. + MissingSigningService { policy_name: String, host: String }, + /// `credential_signing` has an unrecognized value. + UnknownCredentialSigning { + policy_name: String, + host: String, + value: String, + }, } impl fmt::Display for PolicyViolation { @@ -730,6 +748,32 @@ impl fmt::Display for PolicyViolation { use subdomain wildcards like '*.example.com' instead" ) } + Self::InvalidHostWildcard { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': invalid host wildcard '{host}'; \ + middle DNS label wildcards must be the entire label '*' and recursive '**' \ + is only allowed as the entire first label" + ) + } + Self::MissingSigningService { policy_name, host } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has credential_signing \ + set but signing_service is empty" + ) + } + Self::UnknownCredentialSigning { + policy_name, + host, + value, + } => { + write!( + f, + "network policy '{policy_name}': endpoint '{host}' has unrecognized \ + credential_signing value '{value}' (expected sigv4, sigv4:body, or sigv4:no_body)" + ) + } } } } @@ -834,6 +878,30 @@ pub fn validate_sandbox_policy( }); } } + if host_wildcard_shape_invalid(&ep.host) { + violations.push(PolicyViolation::InvalidHostWildcard { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } + if !ep.credential_signing.is_empty() + && !matches!( + ep.credential_signing.as_str(), + "sigv4" | "sigv4:body" | "sigv4:no_body" + ) + { + violations.push(PolicyViolation::UnknownCredentialSigning { + policy_name: name.clone(), + host: ep.host.clone(), + value: ep.credential_signing.clone(), + }); + } + if !ep.credential_signing.is_empty() && ep.signing_service.is_empty() { + violations.push(PolicyViolation::MissingSigningService { + policy_name: name.clone(), + host: ep.host.clone(), + }); + } } } @@ -844,6 +912,22 @@ pub fn validate_sandbox_policy( } } +fn host_wildcard_shape_invalid(host: &str) -> bool { + if !host.contains('*') || host == "*" || host == "**" { + return false; + } + let labels: Vec<&str> = host.split('.').collect(); + let first_label = labels.first().copied().unwrap_or_default(); + if first_label.contains("**") && first_label != "**" { + return true; + } + labels + .iter() + .skip(1) + .copied() + .any(|label| label.contains("**") || (label.contains('*') && label != "*")) +} + /// Truncate a string for safe inclusion in error messages. fn truncate_for_display(s: &str) -> String { if s.len() <= 80 { @@ -1375,6 +1459,47 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_accepts_middle_label_star_wildcard() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "ok".into(), + NetworkPolicyRule { + name: "ok-rule".into(), + endpoints: vec![NetworkEndpoint { + host: "*.s3.*.amazonaws.com".into(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_rejects_partial_middle_label_wildcard() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "bad".into(), + NetworkPolicyRule { + name: "bad-rule".into(), + endpoints: vec![NetworkEndpoint { + host: "*.s3.us-*.amazonaws.com".into(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::InvalidHostWildcard { .. })) + ); + } + #[test] fn validate_accepts_explicit_domain() { let mut policy = restrictive_default_policy(); @@ -1393,6 +1518,141 @@ network_policies: assert!(validate_sandbox_policy(&policy).is_ok()); } + #[test] + fn validate_rejects_credential_signing_without_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4".into(), + signing_service: String::new(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. })) + ); + } + + #[test] + fn validate_accepts_credential_signing_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_accepts_sigv4_body_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "bedrock".into(), + endpoints: vec![NetworkEndpoint { + host: "bedrock-runtime.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:body".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_accepts_sigv4_no_body_with_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "s3".into(), + endpoints: vec![NetworkEndpoint { + host: "s3.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:no_body".into(), + signing_service: "s3".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + assert!(validate_sandbox_policy(&policy).is_ok()); + } + + #[test] + fn validate_rejects_sigv4_no_body_without_signing_service() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "s3".into(), + endpoints: vec![NetworkEndpoint { + host: "s3.us-east-1.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4:no_body".into(), + signing_service: String::new(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::MissingSigningService { .. })) + ); + } + + #[test] + fn validate_rejects_unknown_credential_signing() { + let mut policy = restrictive_default_policy(); + policy.network_policies.insert( + "aws".into(), + NetworkPolicyRule { + name: "test".into(), + endpoints: vec![NetworkEndpoint { + host: "example.amazonaws.com".into(), + port: 443, + credential_signing: "sigv4_typo".into(), + signing_service: "bedrock".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let violations = validate_sandbox_policy(&policy).unwrap_err(); + assert!( + violations + .iter() + .any(|v| matches!(v, PolicyViolation::UnknownCredentialSigning { .. })) + ); + } + #[test] fn normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 63a6b2eb3..f2f9a7db6 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -17,6 +17,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ + include_str!("../../../providers/aws.yaml"), + include_str!("../../../providers/aws-s3.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/codex.yaml"), include_str!("../../../providers/copilot.yaml"), @@ -165,6 +167,10 @@ pub struct EndpointProfile { pub graphql_max_body_bytes: u32, #[serde(default, skip_serializing_if = "String::is_empty")] pub path: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub credential_signing: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub signing_service: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -537,6 +543,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option { Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } + "aws_sts_assume_role" => Some(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), _ => None, } } @@ -551,6 +558,7 @@ pub fn provider_refresh_strategy_to_yaml( ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } @@ -632,6 +640,8 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), } } @@ -662,6 +672,8 @@ fn endpoint_from_proto(endpoint: &NetworkEndpoint) -> EndpointProfile { .collect(), graphql_max_body_bytes: endpoint.graphql_max_body_bytes, path: endpoint.path.clone(), + credential_signing: endpoint.credential_signing.clone(), + signing_service: endpoint.signing_service.clone(), } } @@ -1590,4 +1602,80 @@ endpoints: assert!(matches!(err, ProfileError::InvalidEndpoint { id, .. } if id == "bad-endpoint")); } + + #[test] + fn aws_sts_strategy_serde_roundtrip() { + use openshell_core::proto::ProviderCredentialRefreshStrategy; + assert_eq!( + super::provider_refresh_strategy_from_yaml("aws_sts_assume_role"), + Some(ProviderCredentialRefreshStrategy::AwsStsAssumeRole) + ); + assert_eq!( + super::provider_refresh_strategy_to_yaml( + ProviderCredentialRefreshStrategy::AwsStsAssumeRole + ), + "aws_sts_assume_role" + ); + } + + #[test] + fn aws_profile_parses_correctly() { + let aws = get_default_profile("aws").expect("aws profile should exist"); + assert_eq!(aws.display_name, "AWS"); + assert_eq!(aws.credentials.len(), 3); + let access_key = aws + .credentials + .iter() + .find(|c| c.name == "access_key_id") + .unwrap(); + assert!(access_key.refresh.is_some()); + let refresh = access_key.refresh.as_ref().unwrap(); + assert_eq!( + refresh.strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole + ); + assert!( + refresh + .material + .iter() + .any(|m| m.name == "role_arn" && m.required) + ); + } + + #[test] + fn aws_s3_profile_parses_with_endpoints() { + let aws_s3 = get_default_profile("aws-s3").expect("aws-s3 profile should exist"); + assert_eq!(aws_s3.display_name, "AWS S3"); + assert!(!aws_s3.endpoints.is_empty()); + assert!( + !aws_s3 + .endpoints + .iter() + .any(|e| e.host == "**.amazonaws.com") + ); + assert!( + aws_s3 + .endpoints + .iter() + .any(|e| e.host == "*.s3.*.amazonaws.com") + ); + assert!( + aws_s3 + .endpoints + .iter() + .any(|e| e.host == "s3.*.amazonaws.com") + ); + assert!( + aws_s3 + .endpoints + .iter() + .any(|e| e.host == "*.s3.dualstack.*.amazonaws.com") + ); + assert!( + aws_s3 + .endpoints + .iter() + .any(|e| e.host == "s3.dualstack.*.amazonaws.com") + ); + } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 6d527bc53..f74378a4a 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" diff --git a/crates/openshell-sandbox/src/l7/mod.rs b/crates/openshell-sandbox/src/l7/mod.rs index 703aafae4..9f4a4d9df 100644 --- a/crates/openshell-sandbox/src/l7/mod.rs +++ b/crates/openshell-sandbox/src/l7/mod.rs @@ -50,6 +50,26 @@ pub enum TlsMode { Skip, } +/// Credential signing mode for proxy-side request signing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CredentialSigning { + #[default] + None, + /// Auto-detect: include body in signature when Content-Length is present, + /// skip body when Transfer-Encoding is chunked or body is absent. + SigV4, + /// Always include body in signature (buffer body, compute SHA-256 hash). + SigV4Body, + /// Never include body in signature (use UNSIGNED-PAYLOAD, stream through). + SigV4NoBody, +} + +impl CredentialSigning { + pub fn is_sigv4(&self) -> bool { + matches!(self, Self::SigV4 | Self::SigV4Body | Self::SigV4NoBody) + } +} + /// Enforcement mode for L7 policy decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EnforcementMode { @@ -88,6 +108,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 +190,36 @@ 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, + Some("sigv4:body") => CredentialSigning::SigV4Body, + Some("sigv4:no_body") => CredentialSigning::SigV4NoBody, + Some(other) if !other.is_empty() => { + let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .severity(openshell_ocsf::SeverityId::High) + .message(format!( + "rejecting endpoint: unrecognized credential_signing value {other:?}" + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + return None; + } + _ => CredentialSigning::None, + }; + + let signing_service = get_object_str(val, "signing_service").unwrap_or_default(); + + if credential_signing.is_sigv4() && signing_service.is_empty() { + let event = openshell_ocsf::NetworkActivityBuilder::new(crate::ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .severity(openshell_ocsf::SeverityId::High) + .message("rejecting endpoint: credential_signing requires signing_service".to_string()) + .build(); + openshell_ocsf::ocsf_emit!(event); + return None; + } + Some(L7EndpointConfig { protocol, path: get_object_str(val, "path").unwrap_or_default(), @@ -175,6 +230,8 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { websocket_credential_rewrite, request_body_credential_rewrite, websocket_graphql_policy, + credential_signing, + signing_service, }) } @@ -367,18 +424,26 @@ fn validate_host_wildcard(errors: &mut Vec, loc: &str, host: &str) { let labels: Vec<&str> = host.split('.').collect(); let first_label = labels.first().copied().unwrap_or_default(); - if labels.iter().skip(1).any(|label| label.contains('*')) { - errors.push(format!( - "{loc}: host wildcard may only appear in the first DNS label, got '{host}'" - )); - return; - } if first_label.contains("**") && first_label != "**" { errors.push(format!( "{loc}: recursive host wildcard '**' is only allowed as the entire first DNS label, got '{host}'" )); return; } + for label in labels.iter().skip(1).copied() { + if label.contains("**") { + errors.push(format!( + "{loc}: recursive host wildcard '**' is only allowed as the entire first DNS label, got '{host}'" + )); + return; + } + if label.contains('*') && label != "*" { + errors.push(format!( + "{loc}: middle DNS label wildcard must be the entire label '*', got '{host}'" + )); + return; + } + } // Reject TLD or single-label wildcards. They are accepted by the policy // engine but silently fail at the proxy layer (see #787). @@ -1196,6 +1261,41 @@ mod tests { assert_eq!(config.enforcement, EnforcementMode::Audit); } + #[test] + fn parse_credential_signing_sigv4() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn parse_credential_signing_sigv4_body() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4:body", "signing_service": "bedrock", "host": "bedrock.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4Body); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn parse_credential_signing_sigv4_no_body() { + let val = regorus::Value::from_json_str( + r#"{"protocol": "rest", "credential_signing": "sigv4:no_body", "signing_service": "s3", "host": "s3.us-east-1.amazonaws.com", "port": 443}"#, + ).unwrap(); + let config = parse_l7_config(&val).unwrap(); + assert_eq!(config.credential_signing, CredentialSigning::SigV4NoBody); + assert!(config.credential_signing.is_sigv4()); + } + + #[test] + fn is_sigv4_false_for_none() { + assert!(!CredentialSigning::None.is_sigv4()); + } + #[test] fn parse_l7_config_websocket_protocol() { let val = regorus::Value::from_json_str( @@ -1808,12 +1908,36 @@ mod tests { } #[test] - fn validate_wildcard_host_mid_label_error() { + fn validate_wildcard_host_middle_label_star_valid_no_error() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "*.s3.*.amazonaws.com", + "port": 443 + }], + "binaries": [] + } + } + }); + let (errors, warnings) = validate_l7_policies(&data); + assert!( + errors.is_empty(), + "*.s3.*.amazonaws.com should be valid, got errors: {errors:?}" + ); + assert!( + warnings.is_empty(), + "*.s3.*.amazonaws.com should not warn, got warnings: {warnings:?}" + ); + } + + #[test] + fn validate_wildcard_host_middle_label_partial_star_error() { let data = serde_json::json!({ "network_policies": { "test": { "endpoints": [{ - "host": "foo.*.example.com", + "host": "*.s3.us-*.amazonaws.com", "port": 443 }], "binaries": [] @@ -1822,8 +1946,30 @@ mod tests { }); let (errors, _warnings) = validate_l7_policies(&data); assert!( - errors.iter().any(|e| e.contains("first DNS label")), - "Mid-label wildcard should be rejected, got errors: {errors:?}" + errors + .iter() + .any(|e| e.contains("middle DNS label wildcard")), + "Partial middle-label wildcard should be rejected, got errors: {errors:?}" + ); + } + + #[test] + fn validate_wildcard_host_middle_label_double_star_error() { + let data = serde_json::json!({ + "network_policies": { + "test": { + "endpoints": [{ + "host": "*.s3.**.amazonaws.com", + "port": 443 + }], + "binaries": [] + } + } + }); + let (errors, _warnings) = validate_l7_policies(&data); + assert!( + errors.iter().any(|e| e.contains("recursive host wildcard")), + "Middle-label ** wildcard should be rejected, got errors: {errors:?}" ); } diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 9efa7ca9f..ce387beef 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/src/l7/relay.rs @@ -355,6 +355,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, + host: &ctx.host, }, ) .await?; @@ -780,6 +783,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, + host: &ctx.host, }, ) .await?; @@ -1429,6 +1435,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(), @@ -1530,6 +1538,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(), @@ -1648,6 +1658,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 20d52459c..b6fac71b8 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -12,10 +12,12 @@ use crate::opa::PolicyGenerationGuard; use crate::secrets::{ SecretResolver, contains_reserved_credential_marker, rewrite_http_header_block, }; +use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; +use std::fmt; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; @@ -377,6 +379,9 @@ where generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + host: "", }, ) .await @@ -395,6 +400,9 @@ pub(crate) struct RelayRequestOptions<'a> { 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: &'a str, + pub(crate) host: &'a str, } pub(crate) async fn relay_http_request_with_options_guarded( @@ -421,8 +429,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.is_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 +461,182 @@ where guard.ensure_current()?; } - if options.request_body_credential_rewrite { + // Apply SigV4 signing if configured. + if options.credential_signing.is_sigv4() { + // SigV4 re-signing needs the body before forwarding. If the client + // sent `Expect: 100-continue`, acknowledge it so the client transmits + // the body. Scoped to SigV4 paths only — non-SigV4 traffic forwards + // the Expect header to upstream for normal handling. + if has_expect_continue(header_str) { + client + .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + 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" + )); + } + + let payload_mode = match options.credential_signing { + crate::l7::CredentialSigning::SigV4Body => SigV4PayloadMode::SignBody, + crate::l7::CredentialSigning::SigV4NoBody => { + SigV4PayloadMode::UnsignedPayload + } + crate::l7::CredentialSigning::SigV4 => detect_payload_mode(header_str)?, + crate::l7::CredentialSigning::None => unreachable!(), + }; + + let event = openshell_ocsf::NetworkActivityBuilder::new( + crate::ocsf_ctx(), + ) + .activity(openshell_ocsf::ActivityId::Traffic) + .action(openshell_ocsf::ActionId::Allowed) + .disposition(openshell_ocsf::DispositionId::Allowed) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + options.host, + 0, + )) + .message(format!( + "SigV4 re-signing {host} service={service} region={region} mode={payload_mode}", + host = options.host, + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + + if payload_mode == SigV4PayloadMode::SignBody { + // Buffer body and include its hash in the signature. + // This requires Content-Length — chunked bodies cannot + // be buffered for signing. detect_payload_mode() should + // route chunked requests to the streaming path, but + // guard here as defense-in-depth. + if matches!(parse_body_length(header_str)?, BodyLength::Chunked) { + return Err(miette!( + "SigV4 body signing requires Content-Length; \ + chunked transfer encoding is not supported in this mode" + )); + } + let overflow = &req.raw_header[header_end..]; + let mut full_request = rewrite_result.rewritten.clone(); + full_request.extend_from_slice(overflow); + if let BodyLength::ContentLength(body_len) = parse_body_length(header_str)? + { + if body_len > MAX_REWRITE_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 body signing buffers at most {MAX_REWRITE_BODY_BYTES} bytes" + )); + } + let already_have = overflow.len() as u64; + if body_len > already_have { + 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); + } + } + + let signed = crate::sigv4::apply_sigv4_to_request( + &full_request, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + )?; + upstream.write_all(&signed).await.into_diagnostic()?; + } else { + // Sign headers only, stream body through. + let signable_body = match payload_mode { + SigV4PayloadMode::StreamingUnsignedTrailer => { + SignableBody::StreamingUnsignedPayloadTrailer + } + _ => SignableBody::UnsignedPayload, + }; + let signed_headers = crate::sigv4::apply_sigv4_headers_only_with_body( + &rewrite_result.rewritten, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + signable_body, + )?; + upstream + .write_all(&signed_headers) + .await + .into_diagnostic()?; + + let overflow = &req.raw_header[header_end..]; + if !overflow.is_empty() { + if let Some(guard) = options.generation_guard { + guard.ensure_current()?; + } + upstream.write_all(overflow).await.into_diagnostic()?; + } + let overflow_len = overflow.len() as u64; + + match req.body_length { + BodyLength::ContentLength(len) => { + let remaining = len.saturating_sub(overflow_len); + if remaining > 0 { + relay_fixed( + client, + upstream, + remaining, + options.generation_guard, + ) + .await?; + } + } + BodyLength::Chunked => { + relay_chunked( + client, + upstream, + &req.raw_header[header_end..], + options.generation_guard, + ) + .await?; + } + BodyLength::None => {} + } + } + } + _ => { + 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, @@ -1328,6 +1522,74 @@ fn non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } +/// Check if the request includes `Expect: 100-continue`. +fn has_expect_continue(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + let lower = line.to_ascii_lowercase(); + lower.starts_with("expect:") + && lower + .split_once(':') + .map_or(false, |(_, v)| v.trim() == "100-continue") + }) +} + +/// Resolved payload signing mode for a SigV4 request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SigV4PayloadMode { + /// Buffer body and include its SHA-256 hash in the signature. + SignBody, + /// Use literal `UNSIGNED-PAYLOAD` — no body buffering needed. + UnsignedPayload, + /// Use `STREAMING-UNSIGNED-PAYLOAD-TRAILER` for `aws-chunked` streams. + StreamingUnsignedTrailer, +} + +impl fmt::Display for SigV4PayloadMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SignBody => write!(f, "sign_body"), + Self::UnsignedPayload => write!(f, "unsigned_payload"), + Self::StreamingUnsignedTrailer => write!(f, "streaming_unsigned_trailer"), + } + } +} + +/// Auto-detect the payload signing mode from the client's original headers. +/// +/// Mirrors the mode the client SDK chose by inspecting `x-amz-content-sha256`: +/// - `STREAMING-UNSIGNED-PAYLOAD-TRAILER` → `StreamingUnsignedTrailer` +/// - `UNSIGNED-PAYLOAD` → `UnsignedPayload` +/// - Hex hash → `SignBody` (buffer + hash, requires `Content-Length`) +/// - `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` → `StreamingUnsignedTrailer` (re-sign +/// headers only; the proxy cannot reproduce per-chunk signatures, but the +/// body streams through intact and AWS accepts unsigned streaming payloads) +/// - Absent → `SignBody` if `Content-Length` present, else `UnsignedPayload` +fn detect_payload_mode(headers: &str) -> Result { + for line in headers.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("x-amz-content-sha256:") { + let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); + return match val { + "streaming-unsigned-payload-trailer" | "streaming-aws4-hmac-sha256-payload" => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), + v if v.starts_with("streaming-") => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + _ => Ok(SigV4PayloadMode::SignBody), + }; + } + } + Ok( + if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { + SigV4PayloadMode::SignBody + } else { + SigV4PayloadMode::UnsignedPayload + }, + ) +} + /// Parse Content-Length or Transfer-Encoding from HTTP headers. /// /// Per RFC 7230 Section 3.3.3, rejects requests containing both diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index e9d8921b6..5bf8823ef 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -24,6 +24,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 f73f3bc14..1be595455 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -1116,6 +1116,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(); } @@ -2718,6 +2724,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(); @@ -4295,6 +4360,88 @@ network_policies: ); } + #[test] + fn wildcard_host_middle_label_matches_one_region_label() { + let data = r#" +network_policies: + s3: + name: s3 + endpoints: + - { host: "*.s3.*.amazonaws.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "my-bucket.s3.us-east-1.amazonaws.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "*.s3.*.amazonaws.com should match one regional S3 label: {}", + decision.reason + ); + } + + #[test] + fn wildcard_host_middle_label_does_not_match_missing_bucket_label() { + let data = r#" +network_policies: + s3: + name: s3 + endpoints: + - { host: "*.s3.*.amazonaws.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "s3.us-east-1.amazonaws.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + !decision.allowed, + "*.s3.*.amazonaws.com should not match path-style S3 host" + ); + } + + #[test] + fn wildcard_host_middle_label_does_not_skip_dualstack_label() { + let data = r#" +network_policies: + s3: + name: s3 + endpoints: + - { host: "*.s3.*.amazonaws.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "my-bucket.s3.dualstack.us-east-1.amazonaws.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + !decision.allowed, + "*.s3.*.amazonaws.com should not match the extra dualstack label" + ); + } + #[test] fn wildcard_host_multi_port() { let data = r#" diff --git a/crates/openshell-sandbox/src/policy_local.rs b/crates/openshell-sandbox/src/policy_local.rs index d65bce324..3a9de88a2 100644 --- a/crates/openshell-sandbox/src/policy_local.rs +++ b/crates/openshell-sandbox/src/policy_local.rs @@ -1121,6 +1121,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 ae100d734..701d73a5c 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -2865,6 +2865,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: "", + host: "", }, ) .await @@ -3819,6 +3822,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( @@ -3970,6 +3974,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(), } } @@ -4516,6 +4522,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 { @@ -4529,6 +4537,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..8765c0427 --- /dev/null +++ b/crates/openshell-sandbox/src/sigv4.rs @@ -0,0 +1,556 @@ +// 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::{ + PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, sign, +}; +use aws_sigv4::sign::v4; +use aws_smithy_runtime_api::client::identity::Identity; +use miette::{Result, miette}; +use std::time::SystemTime; + +/// AWS regions contain a hyphen followed by a digit (e.g., `us-east-1`). +/// Service names like `s3` or `bedrock-runtime` do not. +fn looks_like_region(s: &str) -> bool { + let bytes = s.as_bytes(); + for i in 0..bytes.len().saturating_sub(1) { + if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() { + return true; + } + } + false +} + +/// Extract the AWS region from an AWS hostname. +/// +/// Supports standard, dualstack, FIPS, virtual-hosted, and China partition +/// hostnames. The region is the label immediately before `amazonaws.com` +/// (or `amazonaws.com.cn`). +pub fn extract_aws_region(host: &str) -> Option { + let parts: Vec<&str> = host.split('.').collect(); + // China partition: *.amazonaws.com.cn + if parts.len() >= 5 + && parts[parts.len() - 3] == "amazonaws" + && parts[parts.len() - 2] == "com" + && parts[parts.len() - 1] == "cn" + { + let candidate = parts[parts.len() - 4]; + if looks_like_region(candidate) { + return Some(candidate.to_string()); + } + return None; + } + // Standard/dualstack/FIPS/virtual-hosted: *.amazonaws.com + // Scan right-to-left from "amazonaws", skipping non-region labels + // like "dualstack". Handles: s3.us-east-1.amazonaws.com, + // s3.dualstack.us-west-2.amazonaws.com, + // s3-fips.dualstack.us-west-2.amazonaws.com, etc. + if parts.len() >= 4 && parts[parts.len() - 2] == "amazonaws" && parts[parts.len() - 1] == "com" + { + let mut idx = parts.len() - 3; + while idx > 0 && parts[idx] == "dualstack" { + idx -= 1; + } + if idx > 0 && looks_like_region(parts[idx]) { + return Some(parts[idx].to_string()); + } + } + 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 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 +} + +struct RequestParts<'a> { + method: &'a str, + path: &'a str, + request_line: &'a str, + headers_to_sign: Vec<(String, String)>, + all_headers: Vec<(String, String)>, +} + +/// Parse raw HTTP headers into components needed for `SigV4` signing. +/// +/// Only host, content-type, and content-length are included in the `SigV4` +/// signature. Signing all headers causes failures when the proxy or +/// transport modifies unsigned-by-convention headers (Connection, +/// Accept-Encoding, etc.) between signing and delivery. +fn parse_request_parts(header_str: &str) -> RequestParts<'_> { + let lines: Vec<&str> = header_str.split("\r\n").collect(); + + let (method, path, request_line) = + lines + .first() + .map_or(("GET", "/", "GET / HTTP/1.1"), |first_line| { + let parts: Vec<&str> = first_line.splitn(3, ' ').collect(); + if parts.len() >= 2 { + (parts[0], parts[1], *first_line) + } else { + ("GET", "/", *first_line) + } + }); + + // Headers stripped entirely — the SDK re-generates auth headers, and + // `Expect` is handled by the proxy before forwarding. + const STRIP_HEADERS: &[&str] = &[ + "authorization", + "x-amz-date", + "x-amz-security-token", + "x-amz-content-sha256", + "expect", + ]; + // Headers forwarded but NOT signed — the proxy or transport may modify + // them between signing and delivery, which would invalidate the signature. + const UNSIGNED_HEADERS: &[&str] = &[ + "connection", + "accept-encoding", + "transfer-encoding", + "user-agent", + "amz-sdk-invocation-id", + "amz-sdk-request", + ]; + + let mut headers_to_sign: Vec<(String, String)> = Vec::new(); + let mut all_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 STRIP_HEADERS.iter().any(|s| lower.starts_with(s)) { + continue; + } + all_headers.push((lower.clone(), v.trim().to_string())); + if !UNSIGNED_HEADERS.iter().any(|s| lower.starts_with(s)) { + headers_to_sign.push((lower, v.trim().to_string())); + } + } + } + + RequestParts { + method, + path, + request_line, + headers_to_sign, + all_headers, + } +} + +fn build_signing_params<'a>( + identity: &'a Identity, + region: &'a str, + service: &'a str, +) -> Result> { + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + + Ok(v4::SigningParams::builder() + .identity(identity) + .region(region) + .name(service) + .time(SystemTime::now()) + .settings(settings) + .build() + .map_err(|e| miette!("SigV4 signing params: {e}"))? + .into()) +} + +fn build_identity(access_key: &str, secret_key: &str, session_token: Option<&str>) -> Identity { + Credentials::new( + access_key, + secret_key, + session_token.map(ToString::to_string), + None, + "openshell", + ) + .into() +} + +fn rebuild_request( + parts: &RequestParts<'_>, + instructions: &aws_sigv4::http_request::SigningInstructions, + body: &[u8], +) -> Vec { + let mut output = Vec::with_capacity(256 + body.len()); + + output.extend_from_slice(parts.request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + for (k, v) in &parts.all_headers { + output.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); + } + + for (name, value) in instructions.headers() { + output.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(body); + + output +} + +/// Apply AWS Signature Version 4 signing to a raw HTTP request buffer. +/// +/// Strips existing AWS auth headers, computes a new signature using the +/// `aws-sigv4` crate, and returns the rewritten request bytes including body. +pub fn apply_sigv4_to_request( + raw: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, +) -> Result> { + 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 parts = parse_request_parts(&header_str); + let uri = format!("https://{host}{}", parts.path); + let identity = build_identity(access_key, secret_key, session_token); + let signing_params = build_signing_params(&identity, region, service)?; + + let signable_request = SignableRequest::new( + parts.method, + &uri, + parts + .headers_to_sign + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + SignableBody::Bytes(body), + ) + .map_err(|e| miette!("SigV4 signable request: {e}"))?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|e| miette!("SigV4 signing failed: {e}"))? + .into_parts(); + + Ok(rebuild_request(&parts, &instructions, body)) +} + +/// Apply AWS `SigV4` signing to HTTP headers only, using UNSIGNED-PAYLOAD. +/// +/// Returns signed headers ending with `\r\n\r\n`. The caller is responsible +/// for streaming the body separately. Use when the body is chunked or when +/// the service accepts unsigned payloads (e.g. S3 over HTTPS). +pub fn apply_sigv4_headers_only( + raw_headers: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, +) -> Result> { + apply_sigv4_headers_only_with_body( + raw_headers, + host, + region, + service, + access_key, + secret_key, + session_token, + SignableBody::UnsignedPayload, + ) +} + +/// Apply AWS `SigV4` signing to HTTP headers only with a caller-chosen +/// `SignableBody` mode (e.g. `UnsignedPayload` or +/// `StreamingUnsignedPayloadTrailer`). +/// +/// Returns signed headers ending with `\r\n\r\n`. +pub fn apply_sigv4_headers_only_with_body( + raw_headers: &[u8], + host: &str, + region: &str, + service: &str, + access_key: &str, + secret_key: &str, + session_token: Option<&str>, + body: SignableBody<'_>, +) -> Result> { + let header_str = String::from_utf8_lossy(raw_headers); + let parts = parse_request_parts(&header_str); + let uri = format!("https://{host}{}", parts.path); + let identity = build_identity(access_key, secret_key, session_token); + let signing_params = build_signing_params(&identity, region, service)?; + + let signable_request = SignableRequest::new( + parts.method, + &uri, + parts + .headers_to_sign + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())), + body, + ) + .map_err(|e| miette!("SigV4 signable request: {e}"))?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .map_err(|e| miette!("SigV4 signing failed: {e}"))? + .into_parts(); + + Ok(rebuild_request(&parts, &instructions, &[])) +} + +#[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 global_endpoint_returns_none() { + assert!(extract_aws_region("s3.amazonaws.com").is_none()); + } + + #[test] + fn virtual_hosted_global_endpoint_returns_none() { + assert!(extract_aws_region("my-bucket.s3.amazonaws.com").is_none()); + } + + #[test] + fn extract_region_dualstack() { + let region = extract_aws_region("s3.dualstack.us-west-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-west-2"); + } + + #[test] + fn extract_region_fips() { + let region = extract_aws_region("bedrock-runtime-fips.us-east-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-1"); + } + + #[test] + fn extract_region_china() { + let region = extract_aws_region("s3.cn-north-1.amazonaws.com.cn").unwrap(); + assert_eq!(region, "cn-north-1"); + } + + #[test] + fn extract_region_fips_dualstack() { + let region = extract_aws_region("s3-fips.dualstack.us-west-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-west-2"); + } + + #[test] + fn extract_region_govcloud() { + let region = extract_aws_region("s3.us-gov-west-1.amazonaws.com").unwrap(); + assert_eq!(region, "us-gov-west-1"); + } + + #[test] + fn extract_region_virtual_hosted_s3() { + let region = extract_aws_region("my-bucket.s3.us-east-2.amazonaws.com").unwrap(); + assert_eq!(region, "us-east-2"); + } + + #[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, + ) + .unwrap(); + 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"), + ) + .unwrap(); + 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 non_signed_headers_preserved() { + 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\nAccept: application/json\r\nUser-Agent: my-agent/1.0\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, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("accept: application/json\r\n")); + assert!(result_str.contains("user-agent: my-agent/1.0\r\n")); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=")); + } + + #[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, + ) + .unwrap(); + 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")); + } + + #[test] + fn headers_only_produces_unsigned_payload() { + let raw = b"PUT /my-bucket/my-key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\nContent-Length: 1024\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + ) + .unwrap(); + 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: UNSIGNED-PAYLOAD")); + assert!(result_str.contains("x-amz-date: ")); + assert!(result_str.ends_with("\r\n\r\n")); + } + + #[test] + fn headers_only_strips_old_auth() { + let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nAuthorization: AWS4-HMAC-SHA256 old-sig\r\nX-Amz-Date: old-date\r\nX-Amz-Content-Sha256: old-hash\r\nContent-Type: application/octet-stream\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "AKIATEST", + "secret", + None, + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("authorization: AWS4-HMAC-SHA256 Credential=AKIATEST/")); + assert!(!result_str.contains("old-sig")); + assert!(!result_str.contains("old-date")); + assert!(!result_str.contains("old-hash")); + assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + } + + #[test] + fn headers_only_with_session_token() { + let raw = b"PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nContent-Type: application/octet-stream\r\n\r\n"; + let result = apply_sigv4_headers_only( + raw, + "s3.us-east-1.amazonaws.com", + "us-east-1", + "s3", + "ASIAEXAMPLE", + "secret", + Some("FwoGZXIvYXdzEBYaDH+session+token"), + ) + .unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("x-amz-security-token: FwoGZXIvYXdzEBYaDH+session+token")); + assert!(result_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + } +} diff --git a/crates/openshell-sandbox/tests/sigv4_real_aws.rs b/crates/openshell-sandbox/tests/sigv4_real_aws.rs new file mode 100644 index 000000000..009d70805 --- /dev/null +++ b/crates/openshell-sandbox/tests/sigv4_real_aws.rs @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for SigV4 signing against real AWS endpoints. +//! +//! These tests are `#[ignore]`d by default — they require real AWS credentials +//! in `~/.aws/credentials` and network access. +//! +//! Run with: +//! cargo test -p openshell-sandbox --test sigv4_real_aws -- --ignored --nocapture +//! +//! For S3 tests, also set: +//! S3_TEST_BUCKET=your-bucket-name + +use std::io::BufRead; +use std::net::TcpStream; +use std::sync::Arc; + +fn load_aws_credentials() -> Option<(String, String, Option)> { + let home = std::env::var("HOME").ok()?; + let path = std::path::Path::new(&home).join(".aws/credentials"); + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + + let mut access_key = None; + let mut secret_key = None; + let mut session_token = None; + let mut in_default = false; + + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_default = trimmed == "[default]"; + continue; + } + if !in_default { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + match k.trim() { + "aws_access_key_id" => access_key = Some(v.trim().to_string()), + "aws_secret_access_key" => secret_key = Some(v.trim().to_string()), + "aws_session_token" => session_token = Some(v.trim().to_string()), + _ => {} + } + } + } + + Some((access_key?, secret_key?, session_token)) +} + +/// Send raw signed HTTP bytes over TLS and return (status_code, response_body). +fn send_https_request(host: &str, signed_request: &[u8]) -> (u16, String) { + use std::io::{Read, Write}; + + let root_store = + rustls::RootCertStore::from_iter(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.to_string().try_into().unwrap(); + let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap(); + let mut sock = TcpStream::connect(format!("{host}:443")).expect("TCP connect"); + sock.set_read_timeout(Some(std::time::Duration::from_secs(30))) + .ok(); + let mut tls = rustls::Stream::new(&mut conn, &mut sock); + + tls.write_all(signed_request).expect("write request"); + tls.flush().expect("flush"); + + // Read response headers + body. We read in chunks and stop when we've + // read Content-Length bytes of body, or on connection close / timeout. + let mut response = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + match tls.read(&mut buf) { + Ok(0) => break, + Ok(n) => response.extend_from_slice(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => break, + Err(e) => { + // ConnectionAborted / UnexpectedEof are normal for Connection: close + if matches!( + e.kind(), + std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::UnexpectedEof + ) { + break; + } + panic!("read error: {e}"); + } + } + // Check if we have the full response (headers + content-length body) + let resp_str = String::from_utf8_lossy(&response); + if let Some(header_end) = resp_str.find("\r\n\r\n") { + let headers = &resp_str[..header_end]; + let body_start = header_end + 4; + if let Some(cl) = headers.lines().find_map(|l| { + let lower = l.to_ascii_lowercase(); + lower + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) { + if response.len() >= body_start + cl { + break; + } + } + } + } + + let response_str = String::from_utf8_lossy(&response); + let status = response_str + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let body = response_str + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .to_string(); + + (status, body) +} + +#[test] +#[ignore] +fn bedrock_invoke_with_signed_body() { + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = "bedrock-runtime.us-east-2.amazonaws.com"; + let body = r#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":10,"messages":[{"role":"user","content":"Say exactly: sigv4_ok"}]}"#; + + let raw_request = format!( + "POST /model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/invoke HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let signed = openshell_sandbox::sigv4::apply_sigv4_to_request( + raw_request.as_bytes(), + host, + "us-east-2", + "bedrock", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing failed"); + + let signed_str = String::from_utf8_lossy(&signed); + assert!( + signed_str.contains("x-amz-content-sha256: "), + "should contain body hash header" + ); + assert!( + !signed_str.contains("UNSIGNED-PAYLOAD"), + "should NOT contain UNSIGNED-PAYLOAD" + ); + + let (status, body) = send_https_request(host, &signed); + println!("Bedrock signed-body response: status={status}"); + println!(" body: {}", &body[..body.len().min(200)]); + + assert_eq!(status, 200, "Bedrock should accept signed payload"); +} + +#[test] +#[ignore] +fn bedrock_rejects_unsigned_body() { + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = "bedrock-runtime.us-east-2.amazonaws.com"; + let body = r#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":10,"messages":[{"role":"user","content":"test"}]}"#; + + let raw_headers = format!( + "POST /model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/invoke HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + + let signed_headers = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_headers.as_bytes(), + host, + "us-east-2", + "bedrock", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing failed"); + + let signed_str = String::from_utf8_lossy(&signed_headers); + assert!(signed_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + + let mut full_request = signed_headers; + full_request.extend_from_slice(body.as_bytes()); + + let (status, resp_body) = send_https_request(host, &full_request); + println!("Bedrock unsigned-body response: status={status}"); + println!(" body: {}", &resp_body[..resp_body.len().min(200)]); + + assert_eq!(status, 403, "Bedrock should reject UNSIGNED-PAYLOAD"); +} + +#[test] +#[ignore] +fn s3_put_get_delete_with_unsigned_body() { + let bucket = + std::env::var("S3_TEST_BUCKET").expect("Set S3_TEST_BUCKET env var to run this test"); + let (access_key, secret_key, session_token) = + load_aws_credentials().expect("AWS credentials not found in ~/.aws/credentials"); + + let host = format!("{bucket}.s3.us-east-2.amazonaws.com"); + let key = format!( + "openshell-sigv4-test-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + let body = b"Hello from OpenShell SigV4 unsigned payload test"; + + // --- PUT --- + let raw_put = format!( + "PUT /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + + let signed_put = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_put.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing PUT failed"); + + let signed_str = String::from_utf8_lossy(&signed_put); + assert!(signed_str.contains("x-amz-content-sha256: UNSIGNED-PAYLOAD")); + + let mut full_put = signed_put; + full_put.extend_from_slice(body); + + let (put_status, _) = send_https_request(&host, &full_put); + println!("S3 PUT unsigned-body: status={put_status}"); + assert_eq!(put_status, 200, "S3 should accept UNSIGNED-PAYLOAD PUT"); + + // --- GET --- + let raw_get = format!( + "GET /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Connection: close\r\n\ + \r\n" + ); + + let signed_get = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_get.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing GET failed"); + + let (get_status, get_body) = send_https_request(&host, &signed_get); + println!("S3 GET: status={get_status}"); + println!(" body: {}", &get_body[..get_body.len().min(200)]); + assert_eq!(get_status, 200, "S3 GET should succeed"); + assert!( + get_body.contains("Hello from OpenShell"), + "GET body should contain uploaded content" + ); + + // --- DELETE cleanup --- + let raw_del = format!( + "DELETE /{key} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Connection: close\r\n\ + \r\n" + ); + + let signed_del = openshell_sandbox::sigv4::apply_sigv4_headers_only( + raw_del.as_bytes(), + &host, + "us-east-2", + "s3", + &access_key, + &secret_key, + session_token.as_deref(), + ) + .expect("signing DELETE failed"); + + let (del_status, _) = send_https_request(&host, &signed_del); + println!("S3 DELETE: status={del_status}"); +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 0b7e3a97e..1f503ab86 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -80,6 +80,8 @@ toml = { workspace = true } tokio-stream = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true } +aws-config = { workspace = true } +aws-sdk-sts = { workspace = true } uuid = { workspace = true } hmac = "0.12" sha2 = { workspace = true } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 380671f10..f2ec8d4b2 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1329,7 +1329,7 @@ async fn profile_provider_policy_layers( Ok(layers) } -fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { +pub(super) fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { match settings.settings.get(key) { None => Ok(false), Some(StoredSettingValue::Bool(value)) => Ok(*value), diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 8743564bf..d355e0c64 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1268,6 +1268,18 @@ pub(super) async fn handle_configure_provider_refresh( crate::provider_refresh::refresh_strategy_name(strategy as i32) ))); } + if strategy == ProviderCredentialRefreshStrategy::AwsStsAssumeRole { + let global_settings = + crate::grpc::policy::load_global_settings(state.store.as_ref()).await?; + if !crate::grpc::policy::bool_setting_enabled( + &global_settings, + openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, + )? { + return Err(Status::failed_precondition( + "aws_sts_assume_role requires providers_v2_enabled=true", + )); + } + } if request.material.len() > MAX_PROVIDER_CONFIG_ENTRIES { return Err(Status::invalid_argument(format!( "material exceeds maximum entries ({} > {MAX_PROVIDER_CONFIG_ENTRIES})", @@ -1336,6 +1348,16 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; + if strategy == ProviderCredentialRefreshStrategy::AwsStsAssumeRole { + for additional_key in ["AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"] { + validate_provider_credential_key_available_for_attached_sandboxes( + state.store.as_ref(), + &provider, + additional_key, + ) + .await?; + } + } let refresh_defaults = provider_refresh_defaults(state.store.as_ref(), &provider, credential_key).await?; validate_refresh_material(&request.material, refresh_defaults.as_ref())?; @@ -1819,6 +1841,8 @@ mod tests { assert_eq!( ids, vec![ + "aws", + "aws-s3", "claude-code", "codex", "copilot", @@ -4643,4 +4667,228 @@ mod tests { .count(); assert_eq!(new_keys_count, 1); } + + #[tokio::test] + async fn configure_aws_sts_requires_v2_enabled() { + let state = test_server_state().await; + create_provider_record( + state.store.as_ref(), + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "my-aws".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: "aws".to_string(), + credentials: std::iter::once(( + "AWS_ACCESS_KEY_ID".to_string(), + "placeholder".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }, + ) + .await + .unwrap(); + + let err = handle_configure_provider_refresh( + &state, + Request::new(ConfigureProviderRefreshRequest { + provider: "my-aws".to_string(), + credential_key: "AWS_ACCESS_KEY_ID".to_string(), + strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, + material: HashMap::from([( + "role_arn".to_string(), + "arn:aws:iam::123456789012:role/Test".to_string(), + )]), + secret_material_keys: Vec::new(), + expires_at_ms: None, + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("providers_v2_enabled")); + } + + #[tokio::test] + async fn configure_aws_sts_succeeds_with_v2_enabled() { + use crate::grpc::StoredSettingValue; + use crate::grpc::StoredSettings; + use crate::grpc::policy::save_global_settings; + + let state = test_server_state().await; + + let global_settings = StoredSettings { + revision: 1, + settings: std::iter::once(( + openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), + StoredSettingValue::Bool(true), + )) + .collect(), + ..Default::default() + }; + save_global_settings(state.store.as_ref(), &global_settings) + .await + .unwrap(); + + create_provider_record( + state.store.as_ref(), + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "my-aws-v2".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: "aws".to_string(), + credentials: std::iter::once(( + "AWS_ACCESS_KEY_ID".to_string(), + "placeholder".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }, + ) + .await + .unwrap(); + + let response = handle_configure_provider_refresh( + &state, + Request::new(ConfigureProviderRefreshRequest { + provider: "my-aws-v2".to_string(), + credential_key: "AWS_ACCESS_KEY_ID".to_string(), + strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, + material: HashMap::from([( + "role_arn".to_string(), + "arn:aws:iam::123456789012:role/Test".to_string(), + )]), + secret_material_keys: Vec::new(), + expires_at_ms: None, + }), + ) + .await + .unwrap() + .into_inner() + .status + .expect("status"); + + assert_eq!(response.credential_key, "AWS_ACCESS_KEY_ID"); + assert_eq!( + response.strategy, + ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32 + ); + } + + #[tokio::test] + async fn configure_aws_sts_validates_additional_credential_key_collision() { + use crate::grpc::StoredSettingValue; + use crate::grpc::StoredSettings; + use crate::grpc::policy::save_global_settings; + + let state = test_server_state().await; + + let global_settings = StoredSettings { + revision: 1, + settings: std::iter::once(( + openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), + StoredSettingValue::Bool(true), + )) + .collect(), + ..Default::default() + }; + save_global_settings(state.store.as_ref(), &global_settings) + .await + .unwrap(); + + let mut existing_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "existing-aws-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: "aws".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }; + existing_provider.credentials.insert( + "AWS_SECRET_ACCESS_KEY".to_string(), + "existing-key".to_string(), + ); + create_provider_record(state.store.as_ref(), existing_provider) + .await + .unwrap(); + + let new_provider = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "new-aws-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: "aws".to_string(), + credentials: std::iter::once(( + "AWS_ACCESS_KEY_ID".to_string(), + "placeholder".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }; + create_provider_record(state.store.as_ref(), new_provider) + .await + .unwrap(); + + state + .store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-aws-configure-collision".to_string(), + name: "aws-configure-collision".to_string(), + created_at_ms: 1, + labels: HashMap::new(), + resource_version: 0, + }), + spec: Some(SandboxSpec { + providers: vec![ + "existing-aws-provider".to_string(), + "new-aws-provider".to_string(), + ], + ..SandboxSpec::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = handle_configure_provider_refresh( + &state, + Request::new(ConfigureProviderRefreshRequest { + provider: "new-aws-provider".to_string(), + credential_key: "AWS_ACCESS_KEY_ID".to_string(), + strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, + material: HashMap::from([( + "role_arn".to_string(), + "arn:aws:iam::123456789012:role/Test".to_string(), + )]), + secret_material_keys: Vec::new(), + expires_at_ms: None, + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("AWS_SECRET_ACCESS_KEY")); + } } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c..9ed9f1f89 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -226,6 +226,7 @@ struct MintedCredential { access_token: String, expires_at_ms: i64, refresh_token: Option, + additional_credentials: HashMap, } #[derive(Debug, Deserialize)] @@ -279,6 +280,7 @@ pub fn refresh_strategy_name(strategy: i32) -> &'static str { ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } @@ -289,6 +291,7 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt + | ProviderCredentialRefreshStrategy::AwsStsAssumeRole ) } @@ -407,12 +410,23 @@ async fn apply_minted_credential( updated .credentials .insert(credential_key.to_string(), minted.access_token.clone()); + for (key, value) in &minted.additional_credentials { + updated.credentials.insert(key.clone(), value.clone()); + } if minted.expires_at_ms > 0 { updated .credential_expires_at_ms .insert(credential_key.to_string(), minted.expires_at_ms); + for key in minted.additional_credentials.keys() { + updated + .credential_expires_at_ms + .insert(key.clone(), minted.expires_at_ms); + } } else { updated.credential_expires_at_ms.remove(credential_key); + for key in minted.additional_credentials.keys() { + updated.credential_expires_at_ms.remove(key); + } } crate::grpc::provider::validate_provider_update_against_attached_sandboxes(store, &updated) .await?; @@ -421,12 +435,23 @@ async fn apply_minted_credential( current .credentials .insert(credential_key.to_string(), minted.access_token.clone()); + for (key, value) in &minted.additional_credentials { + current.credentials.insert(key.clone(), value.clone()); + } if minted.expires_at_ms > 0 { current .credential_expires_at_ms .insert(credential_key.to_string(), minted.expires_at_ms); + for key in minted.additional_credentials.keys() { + current + .credential_expires_at_ms + .insert(key.clone(), minted.expires_at_ms); + } } else { current.credential_expires_at_ms.remove(credential_key); + for key in minted.additional_credentials.keys() { + current.credential_expires_at_ms.remove(key); + } } }) .await @@ -449,6 +474,9 @@ async fn mint_credential( ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { mint_google_service_account_jwt(state).await } + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { + mint_aws_sts_assume_role(state).await + } ProviderCredentialRefreshStrategy::External | ProviderCredentialRefreshStrategy::Static | ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition( @@ -544,6 +572,97 @@ async fn mint_google_service_account_jwt( request_token(&token_url, &form, lifetime_secs).await } +async fn mint_aws_sts_assume_role( + state: &StoredProviderCredentialRefreshState, +) -> Result { + let role_arn = required_material(&state.material, "role_arn")?; + let session_name = material_value(&state.material, &["session_name"]) + .unwrap_or_else(|| "openshell-sandbox".to_string()); + let external_id = material_value(&state.material, &["external_id"]); + let region = + material_value(&state.material, &["aws_region"]).unwrap_or_else(|| "us-east-1".to_string()); + + let region_provider = aws_sdk_sts::config::Region::new(region); + let mut config_loader = + aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider); + + if let (Some(access_key), Some(secret_key)) = ( + material_value(&state.material, &["aws_access_key_id"]), + material_value(&state.material, &["aws_secret_access_key"]), + ) { + let creds = aws_sdk_sts::config::Credentials::new( + access_key, + secret_key, + None, + None, + "openshell-provider-refresh", + ); + config_loader = config_loader.credentials_provider(creds); + } + + let sdk_config = config_loader.load().await; + let sts_config = { + let mut builder = aws_sdk_sts::config::Builder::from(&sdk_config); + if let Some(endpoint) = material_value(&state.material, &["sts_endpoint_url"]) + && let Ok(parsed) = reqwest::Url::parse(&endpoint) + && parsed.host_str().is_some_and(is_loopback_host) + { + builder = builder.endpoint_url(endpoint); + } + builder.build() + }; + let client = aws_sdk_sts::Client::from_conf(sts_config); + + let max_lifetime_i64 = if state.max_lifetime_seconds > 0 { + state.max_lifetime_seconds + } else { + DEFAULT_MAX_LIFETIME_SECONDS + }; + let max_lifetime = i32::try_from(max_lifetime_i64.min(i64::from(i32::MAX))).unwrap_or(i32::MAX); + + let mut req = client + .assume_role() + .role_arn(&role_arn) + .role_session_name(&session_name) + .duration_seconds(max_lifetime); + + if let Some(eid) = external_id { + req = req.external_id(eid); + } + + let resp = req + .send() + .await + .map_err(|e| Status::internal(format!("STS AssumeRole failed: {e}")))?; + + let creds = resp + .credentials() + .ok_or_else(|| Status::internal("STS AssumeRole response missing credentials"))?; + + let access_key_id = creds.access_key_id().to_string(); + let secret_access_key = creds.secret_access_key().to_string(); + let session_token = creds.session_token().to_string(); + + let now_ms = current_time_ms(); + let expires_at_ms = creds + .expiration() + .to_millis() + .unwrap_or_else(|_| now_ms + max_lifetime_i64 * 1000); + let max_expires = now_ms + max_lifetime_i64 * 1000; + let expires_at_ms = expires_at_ms.min(max_expires); + + let mut additional = HashMap::new(); + additional.insert("AWS_SECRET_ACCESS_KEY".to_string(), secret_access_key); + additional.insert("AWS_SESSION_TOKEN".to_string(), session_token); + + Ok(MintedCredential { + access_token: access_key_id, + expires_at_ms, + refresh_token: None, + additional_credentials: additional, + }) +} + async fn request_token( token_url: &str, form: &[(String, String)], @@ -603,6 +722,7 @@ async fn request_token( refresh_token: token .refresh_token .filter(|refresh_token| !refresh_token.trim().is_empty()), + additional_credentials: HashMap::new(), }) } @@ -1164,6 +1284,214 @@ mod tests { ); } + #[test] + fn refresh_strategy_name_includes_aws_sts() { + assert_eq!( + refresh_strategy_name(ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32), + "aws_sts_assume_role" + ); + } + + #[tokio::test] + async fn aws_sts_assume_role_mints_three_credentials_from_mock_endpoint() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_string_contains("Action=AssumeRole")) + .and(body_string_contains("RoleArn=arn")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#" + + + AROA3XFRBF23:test-session + arn:aws:sts::123456789012:assumed-role/TestRole/test-session + + + ASIAMOCKKEY + MockSecretAccessKey123 + MockSessionTokenXYZ + 2099-01-01T00:00:00Z + + + + 01234567-89ab-cdef-0123-456789abcdef + +"#, + )) + .mount(&mock_server) + .await; + + let store = test_store().await; + let prov = provider("aws-sts-test", "aws"); + store.put_message(&prov).await.unwrap(); + + let state = new_refresh_state( + &prov, + "AWS_ACCESS_KEY_ID", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole, + material: HashMap::from([ + ( + "role_arn".to_string(), + "arn:aws:iam::123456789012:role/TestRole".to_string(), + ), + ("session_name".to_string(), "test-session".to_string()), + ("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()), + ( + "aws_secret_access_key".to_string(), + "TestSecretKey".to_string(), + ), + ("sts_endpoint_url".to_string(), mock_server.uri()), + ]), + secret_material_keys: vec!["aws_secret_access_key".to_string()], + expires_at_ms: 0, + token_url: String::new(), + scopes: Vec::new(), + refresh_before_seconds: 300, + max_lifetime_seconds: 3600, + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let refreshed = refresh_provider_credential(&store, "aws-sts-test", "AWS_ACCESS_KEY_ID") + .await + .unwrap(); + assert_eq!(refreshed.status, "refreshed"); + assert!(refreshed.expires_at_ms > 0); + + let stored = store + .get_message_by_name::("aws-sts-test") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.credentials.get("AWS_ACCESS_KEY_ID"), + Some(&"ASIAMOCKKEY".to_string()) + ); + assert_eq!( + stored.credentials.get("AWS_SECRET_ACCESS_KEY"), + Some(&"MockSecretAccessKey123".to_string()) + ); + assert_eq!( + stored.credentials.get("AWS_SESSION_TOKEN"), + Some(&"MockSessionTokenXYZ".to_string()) + ); + } + + #[tokio::test] + async fn apply_minted_credential_writes_additional_keys() { + use super::apply_minted_credential; + + let store = test_store().await; + let mut prov = provider("aws-test", "aws"); + prov.credentials + .insert("AWS_ACCESS_KEY_ID".to_string(), "old-key".to_string()); + store.put_message(&prov).await.unwrap(); + + let minted = super::MintedCredential { + access_token: "AKIAIOSFODNN7EXAMPLE".to_string(), + expires_at_ms: 4_000_000_000_000, + refresh_token: None, + additional_credentials: HashMap::from([ + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + ), + ( + "AWS_SESSION_TOKEN".to_string(), + "FwoGZXIvYXdzEBYaDH...EXAMPLETOKEN".to_string(), + ), + ]), + }; + + apply_minted_credential(&store, &prov, "AWS_ACCESS_KEY_ID", &minted) + .await + .unwrap(); + + let stored = store + .get_message_by_name::("aws-test") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.credentials.get("AWS_ACCESS_KEY_ID"), + Some(&"AKIAIOSFODNN7EXAMPLE".to_string()) + ); + assert_eq!( + stored.credentials.get("AWS_SECRET_ACCESS_KEY"), + Some(&"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string()) + ); + assert_eq!( + stored.credentials.get("AWS_SESSION_TOKEN"), + Some(&"FwoGZXIvYXdzEBYaDH...EXAMPLETOKEN".to_string()) + ); + assert_eq!( + stored.credential_expires_at_ms.get("AWS_ACCESS_KEY_ID"), + Some(&4_000_000_000_000) + ); + assert_eq!( + stored.credential_expires_at_ms.get("AWS_SECRET_ACCESS_KEY"), + Some(&4_000_000_000_000) + ); + assert_eq!( + stored.credential_expires_at_ms.get("AWS_SESSION_TOKEN"), + Some(&4_000_000_000_000) + ); + } + + #[tokio::test] + async fn apply_minted_credential_validates_additional_keys_against_sandboxes() { + use super::apply_minted_credential; + + let store = test_store().await; + let mut existing_provider = provider("existing-aws", "aws"); + existing_provider + .credentials + .insert("AWS_SECRET_ACCESS_KEY".to_string(), "existing".to_string()); + store.put_message(&existing_provider).await.unwrap(); + + let refreshing_provider = provider("refreshing-aws", "aws"); + store.put_message(&refreshing_provider).await.unwrap(); + + store + .put_message(&Sandbox { + metadata: Some(ObjectMeta { + id: "sandbox-aws-collision".to_string(), + name: "aws-collision".to_string(), + created_at_ms: 1, + labels: HashMap::new(), + resource_version: 0, + }), + spec: Some(SandboxSpec { + providers: vec!["existing-aws".to_string(), "refreshing-aws".to_string()], + ..SandboxSpec::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let minted = super::MintedCredential { + access_token: "AKIAIOSFODNN7EXAMPLE".to_string(), + expires_at_ms: 4_000_000_000_000, + refresh_token: None, + additional_credentials: HashMap::from([ + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + "secret-key".to_string(), + ), + ("AWS_SESSION_TOKEN".to_string(), "session-token".to_string()), + ]), + }; + + let err = + apply_minted_credential(&store, &refreshing_provider, "AWS_ACCESS_KEY_ID", &minted) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("AWS_SECRET_ACCESS_KEY")); + } + fn provider(name: &str, provider_type: &str) -> Provider { Provider { metadata: Some(ObjectMeta { diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index cb02c8c24..bdb0f6cba 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -732,6 +732,9 @@ fn refresh_strategy_label(strategy: i32) -> &'static str { openshell_core::proto::ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { "google_service_account_jwt" } + openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole => { + "aws_sts_assume_role" + } openshell_core::proto::ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } } diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index a6b9654d0..18da214f9 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -111,9 +111,9 @@ Use `0` as the timestamp to clear expiry for a credential key. Provider refresh stores non-injectable refresh material separately from the provider's current credential values. The gateway can mint OAuth2 refresh-token -tokens, OAuth2 client credentials tokens, and Google service account JWT tokens, -then write the current access token back to the provider record for sandbox -injection. +tokens, OAuth2 client credentials tokens, Google service account JWT tokens, and +AWS STS temporary credentials, then write the current access token back to the +provider record for sandbox injection. Configure refresh metadata for one injectable credential key: @@ -147,6 +147,46 @@ Force a gateway-managed refresh for one credential: openshell provider refresh rotate my-graph --credential-key MS_GRAPH_ACCESS_TOKEN ``` +### AWS STS + +AWS STS refresh requires `providers_v2_enabled=true`. The gateway calls +`sts:AssumeRole` and writes three short-lived credentials (`AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the provider record atomically. + +```shell +openshell settings set --global --key providers_v2_enabled --value true --yes + +openshell provider create --name my-aws --type aws-s3 + +openshell provider refresh configure my-aws \ + --credential-key AWS_ACCESS_KEY_ID \ + --strategy aws-sts-assume-role \ + --material role_arn="arn:aws:iam::123456789012:role/SandboxS3Writer" \ + --material session_name="openshell-sandbox" + +openshell provider refresh rotate my-aws --credential-key AWS_ACCESS_KEY_ID +``` + +The gateway resolves its own AWS credentials using the default credential chain +(instance role, IRSA, ECS task role, environment variables). For gateways not +running on AWS, provide explicit IAM keys via refresh material: + +```shell +openshell provider refresh configure my-aws \ + --credential-key AWS_ACCESS_KEY_ID \ + --strategy aws-sts-assume-role \ + --material role_arn="arn:aws:iam::123456789012:role/SandboxS3Writer" \ + --material aws_access_key_id="$AWS_ACCESS_KEY_ID" \ + --material aws_secret_access_key="$AWS_SECRET_ACCESS_KEY" \ + --secret-material-key aws_secret_access_key +``` + +Use the generic `aws` profile type for multi-service access and scope endpoints +via sandbox network policy. Use `aws-s3` for S3-specific endpoint rules. + +The proxy re-signs requests using SigV4 before forwarding to AWS. Both curl and +Python boto3 are supported. + External refresh systems should continue to push new current credentials through `openshell provider update`. The `--credential-expires-at` option works for static credentials, externally refreshed credentials, and gateway-managed diff --git a/examples/aws-s3-sts.md b/examples/aws-s3-sts.md new file mode 100644 index 000000000..26ae03594 --- /dev/null +++ b/examples/aws-s3-sts.md @@ -0,0 +1,269 @@ +# Manual E2E Test: S3 Access via STS Credentials + +This guide walks through an end-to-end test of an OpenShell sandbox accessing +AWS S3 using gateway-minted STS temporary credentials with proxy-side SigV4 +re-signing. The sandbox never sees real AWS credentials — the proxy resolves +placeholders and signs requests on the fly. + +## Prerequisites + +- AWS CLI authenticated (`aws sts get-caller-identity` succeeds) +- Podman running (`podman info` succeeds) +- OpenShell built from source with AWS STS refresh and SigV4 signing support + +## 1. Create AWS test resources + +Create an S3 bucket and an IAM role the gateway can assume: + +```shell +BUCKET="openshell-sts-test-$(date +%s)" +ACCOUNT=$(aws sts get-caller-identity --query Account --output text) + +aws s3 mb "s3://${BUCKET}" --region us-east-1 + +aws iam create-role \ + --role-name openshell-sts-test-role \ + --assume-role-policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::'${ACCOUNT}':root"}, + "Action": "sts:AssumeRole" + }] + }' + +aws iam put-role-policy \ + --role-name openshell-sts-test-role \ + --policy-name s3-access \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::'${BUCKET}'", "arn:aws:s3:::'${BUCKET}'/*"] + }] + }' +``` + +Verify the role works: + +```shell +aws sts assume-role \ + --role-arn "arn:aws:iam::${ACCOUNT}:role/openshell-sts-test-role" \ + --role-session-name test \ + --query Credentials.AccessKeyId --output text +``` + +## 2. Build the supervisor image + +The supervisor image must include the SigV4 re-signing code and the updated +proto definitions. Build it from the branch: + +```shell +CONTAINER_ENGINE=podman IMAGE_TAG=dev mise run build:docker:supervisor +``` + +Verify the image exists locally: + +```shell +podman images | grep "openshell/supervisor.*dev" +``` + +## 3. Start the gateway + +The gateway needs AWS credentials in its environment to call `sts:AssumeRole`. +Export them before starting: + +```shell +eval "$(aws configure export-credentials --format env)" +``` + +The gateway must use the locally built supervisor image. The `mise run gateway` +script places `supervisor_image` in the wrong TOML section, so start the gateway +binary directly with a hand-written config. + +Write `.cache/gateway-podman/gateway.toml` in the repo root (adjust JWT paths +if your gateway cache directory differs): + +```toml +[openshell] +version = 1 + +[openshell.gateway] +compute_drivers = ["podman"] +default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +disable_tls = true +supervisor_image = "localhost/openshell/supervisor:dev" + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = ".cache/gateway-podman/tls/jwt/signing.pem" +public_key_path = ".cache/gateway-podman/tls/jwt/public.pem" +kid_path = ".cache/gateway-podman/tls/jwt/kid" +gateway_id = "podman-dev" +ttl_secs = 3600 + +[openshell.drivers.podman] +image_pull_policy = "missing" +``` + +If the JWT key files do not exist yet, run `mise run gateway` once to generate +them, then stop the gateway and restart with the config above. + +Start the gateway: + +```shell +eval "$(aws configure export-credentials --format env)" +./target/debug/openshell-gateway \ + --config .cache/gateway-podman/gateway.toml \ + --port 18080 --log-level info --drivers podman --disable-tls \ + --db-url "sqlite:.cache/gateway-podman/gateway.db?mode=rwc" +``` + +## 4. Configure the provider + +In a separate terminal: + +```shell +export OPENSHELL_BASE_URL=http://localhost:18080 + +# Enable provider v2 (required for STS) +openshell settings set --global --key providers_v2_enabled --value true --yes + +# Create the provider with the aws-s3 profile +openshell provider create --name s3-test --type aws-s3 \ + --credential AWS_ACCESS_KEY_ID=placeholder + +# Configure STS refresh +openshell provider refresh configure s3-test \ + --credential-key AWS_ACCESS_KEY_ID \ + --strategy aws-sts-assume-role \ + --material role_arn="arn:aws:iam::${ACCOUNT}:role/openshell-sts-test-role" \ + --material session_name="openshell-sandbox" \ + --material aws_region="us-east-1" + +# Mint the first set of credentials +openshell provider refresh rotate s3-test \ + --credential-key AWS_ACCESS_KEY_ID + +# Verify +openshell provider refresh status s3-test +``` + +The status should show `refreshed` with an expiry ~1 hour from now. + +## 5. Test S3 access from a sandbox + +### Using boto3 (Python) + +```shell +openshell sandbox create --name s3-smoke \ + --provider s3-test \ + -- bash -c ' +export AWS_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem +pip install boto3 -q 2>&1 | tail -1 +python3 -c " +import boto3 + +s3 = boto3.client(\"s3\", region_name=\"us-east-1\") + +print(\"Upload...\") +s3.put_object( + Bucket=\"'"${BUCKET}"'\", + Key=\"from-sandbox.txt\", + Body=b\"hello from openshell sandbox via STS\" +) +print(\"OK\") + +print(\"List...\") +resp = s3.list_objects_v2(Bucket=\"'"${BUCKET}"'\", MaxKeys=5) +for obj in resp.get(\"Contents\", []): + print(\" \" + obj[\"Key\"] + \" (\" + str(obj[\"Size\"]) + \" bytes)\") + +print(\"Download...\") +body = s3.get_object( + Bucket=\"'"${BUCKET}"'\", + Key=\"from-sandbox.txt\" +)[\"Body\"].read() +print(body.decode()) +" +' +``` + +All three operations should succeed. The download should print +`hello from openshell sandbox via STS`. + +### Using curl + +```shell +openshell sandbox create --name s3-curl \ + --provider s3-test \ + -- bash -c ' +BUCKET="'"${BUCKET}"'" +REGION="us-east-1" +CA=/etc/openshell-tls/ca-bundle.pem + +echo "=== Upload ===" +curl -s --cacert $CA -X PUT -H "Content-Type: text/plain" \ + -d "hello from openshell sandbox via STS" \ + "https://${BUCKET}.s3.${REGION}.amazonaws.com/from-sandbox.txt" \ + -w "HTTP %{http_code}\n" + +echo "" +echo "=== Download ===" +curl -s --cacert $CA \ + "https://${BUCKET}.s3.${REGION}.amazonaws.com/from-sandbox.txt" \ + -w "\nHTTP %{http_code}\n" +' +``` + +Both operations should return `HTTP 200`. + +### What's happening + +1. The gateway called `sts:AssumeRole` and stored three short-lived credentials + (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) in the + provider record. +2. The sandbox received placeholder values for these credentials as environment + variables. +3. The client (boto3 or curl) sent an HTTP request through the sandbox proxy's + CONNECT tunnel. boto3 signs the request with placeholder credentials; curl + sends unsigned requests. Either way, the proxy handles it. +4. The proxy terminated TLS, stripped any existing AWS auth headers, resolved + the real credentials from the `SecretResolver`, computed a fresh SigV4 + signature using the `aws-sigv4` crate, and forwarded the signed request to + S3. +5. S3 validated the signature and accepted the request. + +The sandbox never saw real AWS credentials — only placeholders. + +### TLS CA trust + +The proxy terminates TLS and presents a certificate signed by the OpenShell +Sandbox CA. Curl needs `--cacert /etc/openshell-tls/ca-bundle.pem` to trust +it. Python clients need `AWS_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem` set +in the environment. + +## 6. Clean up + +```shell +# Delete the sandbox +openshell sandbox delete s3-smoke + +# Delete AWS resources +aws s3 rb "s3://${BUCKET}" --force +aws iam delete-role-policy --role-name openshell-sts-test-role --policy-name s3-access +aws iam delete-role --role-name openshell-sts-test-role +``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `STS AssumeRole failed: dispatch failure` | Gateway doesn't have AWS credentials | Export credentials before starting: `eval "$(aws configure export-credentials --format env)"` | +| `Policy discovery sync failed: invalid wire type` | Supervisor image doesn't have updated proto | Rebuild: `CONTAINER_ENGINE=podman IMAGE_TAG=dev mise run build:docker:supervisor` | +| `CONNECT ... not permitted by policy` | Binary not in profile's `binaries` list | Use curl (in the list) or add your binary path to the policy | +| `403 AccessDenied` from S3 | IAM role missing permissions, or STS creds expired | Check `openshell provider refresh status`; re-rotate if expired | +| Supervisor uses wrong image | `mise run gateway` places `supervisor_image` in wrong TOML section | Use the hand-written config from step 3 instead of `mise run gateway` | diff --git a/proto/openshell.proto b/proto/openshell.proto index a8ead0d31..35768476e 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -906,6 +906,7 @@ enum ProviderCredentialRefreshStrategy { PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE = 6; } message ProviderCredentialRefreshMaterial { diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..64dc26d7f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -128,6 +128,13 @@ message NetworkEndpoint { // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless // they are converted through an explicit user-authored policy path. bool advisor_proposed = 18; + // 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 = 19; + // AWS signing service name override. Required when credential_signing is + // "sigv4" — e.g. "bedrock" for bedrock-runtime endpoints. + string signing_service = 20; } // Trusted GraphQL operation classification. diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml new file mode 100644 index 000000000..3d5fb30c6 --- /dev/null +++ b/providers/aws-s3.yaml @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: aws-s3 +display_name: AWS S3 +description: AWS S3 storage via STS temporary credentials +category: other +credentials: + - name: access_key_id + description: AWS access key ID (gateway-minted via STS) + env_vars: [AWS_ACCESS_KEY_ID] + required: true + refresh: + strategy: aws_sts_assume_role + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: role_arn + description: ARN of the IAM role to assume + required: true + secret: false + - name: session_name + description: Session name for CloudTrail attribution + required: false + secret: false + - name: external_id + description: External ID for cross-account role assumption + required: false + secret: false + - name: aws_region + description: AWS region for STS endpoint + required: false + secret: false + - name: aws_access_key_id + description: Long-lived IAM access key (only needed if gateway lacks ambient AWS credentials) + required: false + secret: false + - name: aws_secret_access_key + description: Long-lived IAM secret key (only needed if gateway lacks ambient AWS credentials) + required: false + secret: true + - name: secret_access_key + description: AWS secret access key (co-managed with access_key_id) + env_vars: [AWS_SECRET_ACCESS_KEY] + required: true + - name: session_token + description: AWS session token (co-managed with access_key_id) + env_vars: [AWS_SESSION_TOKEN] + required: true +endpoints: + # S3 regional endpoints. `*` matches exactly one DNS label, so the region + # wildcard does not cover extra labels like dualstack unless listed. + - host: "*.s3.*.amazonaws.com" + port: 443 + protocol: rest + tls: terminate + access: read-write + enforcement: enforce + credential_signing: sigv4 + signing_service: s3 + - host: "s3.*.amazonaws.com" + port: 443 + protocol: rest + tls: terminate + access: read-write + enforcement: enforce + credential_signing: sigv4 + signing_service: s3 + - host: "*.s3.dualstack.*.amazonaws.com" + port: 443 + protocol: rest + tls: terminate + access: read-write + enforcement: enforce + credential_signing: sigv4 + signing_service: s3 + - host: "s3.dualstack.*.amazonaws.com" + port: 443 + protocol: rest + tls: terminate + access: read-write + enforcement: enforce + credential_signing: sigv4 + signing_service: s3 +binaries: + - /sandbox/.venv/bin/python + - /sandbox/.venv/bin/python3 + - /sandbox/.uv/python/** + - /usr/bin/python3 + - /usr/bin/python3.* + - /usr/bin/curl + - /usr/local/bin/aws + - /bin/bash diff --git a/providers/aws.yaml b/providers/aws.yaml new file mode 100644 index 000000000..41323d204 --- /dev/null +++ b/providers/aws.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: aws +display_name: AWS +description: Generic AWS access via STS temporary credentials +category: other +credentials: + - name: access_key_id + description: AWS access key ID (gateway-minted via STS) + env_vars: [AWS_ACCESS_KEY_ID] + required: true + refresh: + strategy: aws_sts_assume_role + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: role_arn + description: ARN of the IAM role to assume + required: true + secret: false + - name: session_name + description: Session name for CloudTrail attribution + required: false + secret: false + - name: external_id + description: External ID for cross-account role assumption + required: false + secret: false + - name: aws_region + description: AWS region for STS endpoint + required: false + secret: false + - name: aws_access_key_id + description: Long-lived IAM access key (only needed if gateway lacks ambient AWS credentials) + required: false + secret: false + - name: aws_secret_access_key + description: Long-lived IAM secret key (only needed if gateway lacks ambient AWS credentials) + required: false + secret: true + - name: secret_access_key + description: AWS secret access key (co-managed with access_key_id) + env_vars: [AWS_SECRET_ACCESS_KEY] + required: true + - name: session_token + description: AWS session token (co-managed with access_key_id) + env_vars: [AWS_SESSION_TOKEN] + required: true