diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index f541a2c7bf..12374c0516 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -608,6 +608,7 @@ "addr2line_0.25.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"backtrace\",\"req\":\"^0.3.13\"},{\"features\":[\"wrap_help\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.21\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"findshlibs\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"read\"],\"name\":\"gimli\",\"req\":\"^0.32.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"memmap2\",\"optional\":true,\"req\":\"^0.9.4\"},{\"default_features\":false,\"features\":[\"read\",\"compression\"],\"name\":\"object\",\"optional\":true,\"req\":\"^0.37.0\"},{\"name\":\"rustc-demangle\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"typed-arena\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"all\":[\"bin\",\"wasm\"],\"bin\":[\"loader\",\"rustc-demangle\",\"cpp_demangle\",\"fallible-iterator\",\"smallvec\",\"dep:clap\"],\"cargo-all\":[],\"default\":[\"rustc-demangle\",\"cpp_demangle\",\"loader\",\"fallible-iterator\",\"smallvec\"],\"loader\":[\"std\",\"dep:object\",\"dep:memmap2\",\"dep:typed-arena\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"gimli/rustc-dep-of-std\"],\"std\":[\"gimli/std\"],\"wasm\":[\"object/wasm\"]}}", "adler2_2.0.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[]}}", "aead_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"heapless\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"alloc\":[],\"default\":[\"rand_core\"],\"dev\":[\"blobby\"],\"getrandom\":[\"crypto-common/getrandom\",\"rand_core\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"],\"stream\":[]}}", + "aes-gcm_0.10.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"ctr\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"ghash\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"arrayvec\":[\"aead/arrayvec\"],\"default\":[\"aes\",\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "aes_0.8.4": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"aarch64\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5.6\",\"target\":\"cfg(all(aes_armv8, target_arch = \\\"aarch64\\\"))\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.6.0\",\"target\":\"cfg(not(all(aes_armv8, target_arch = \\\"aarch64\\\")))\"}],\"features\":{\"hazmat\":[]}}", "age-core_0.11.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.21\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"chacha20poly1305\",\"req\":\"^0.10\"},{\"name\":\"cookie-factory\",\"req\":\"^0.3.1\"},{\"name\":\"hkdf\",\"req\":\"^0.12\"},{\"name\":\"io_tee\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"nom\",\"req\":\"^7\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"secrecy\",\"req\":\"^0.10\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.2.0\"}],\"features\":{\"plugin\":[\"tempfile\"],\"unstable\":[]}}", "age_0.11.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"aes-gcm\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"age-core\",\"req\":\"^0.11.0\"},{\"name\":\"base64\",\"req\":\"^0.21\"},{\"name\":\"bcrypt-pbkdf\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"bech32\",\"req\":\"^0.9\"},{\"name\":\"cbc\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"chacha20poly1305\",\"req\":\"^0.10\"},{\"features\":[\"alloc\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"name\":\"console\",\"optional\":true,\"req\":\"^0.15\"},{\"name\":\"cookie-factory\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion-cycles-per-byte\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"ctr\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"curve25519-dalek\",\"optional\":true,\"req\":\"^4\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"hmac\",\"req\":\"^0.12\"},{\"features\":[\"fluent-system\"],\"name\":\"i18n-embed\",\"req\":\"^0.15\"},{\"features\":[\"fluent-system\",\"desktop-requester\"],\"kind\":\"dev\",\"name\":\"i18n-embed\",\"req\":\"^0.15\"},{\"name\":\"i18n-embed-fl\",\"req\":\"^0.9\"},{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"nom\",\"req\":\"^7\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"pin-project\",\"req\":\"^1\"},{\"name\":\"pinentry\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"criterion\",\"flamegraph\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.13\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rpassword\",\"optional\":true,\"req\":\"^7\"},{\"default_features\":false,\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"rust-embed\",\"req\":\"^8\"},{\"default_features\":false,\"name\":\"scrypt\",\"req\":\"^0.11\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"subtle\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"Window\",\"Performance\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(any(unix, windows))\"},{\"name\":\"wsl\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(any(unix, windows))\"},{\"features\":[\"static_secrets\"],\"name\":\"x25519-dalek\",\"req\":\"^2\"},{\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"armor\":[],\"async\":[\"futures\",\"memchr\"],\"cli-common\":[\"console\",\"is-terminal\",\"pinentry\",\"rpassword\"],\"default\":[],\"plugin\":[\"age-core/plugin\",\"which\",\"wsl\"],\"ssh\":[\"aes\",\"aes-gcm\",\"bcrypt-pbkdf\",\"cbc\",\"cipher\",\"ctr\",\"curve25519-dalek\",\"num-traits\",\"rsa\"],\"unstable\":[\"age-core/unstable\"]}}", @@ -662,8 +663,8 @@ "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", "aws-config_1.8.12": "{\"dependencies\":[{\"features\":[\"test-util\"],\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"default_features\":false,\"name\":\"aws-sdk-signin\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sso\",\"optional\":true,\"req\":\"^1.91.0\"},{\"default_features\":false,\"name\":\"aws-sdk-ssooidc\",\"optional\":true,\"req\":\"^1.93.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sts\",\"req\":\"^1.95.0\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"default-client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"base64-simd\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"p256\",\"optional\":true,\"req\":\"^0.13.2\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.5\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.9\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.13.1\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"fmt\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.18.1\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"allow-compilation\":[],\"behavior-version-latest\":[],\"client-hyper\":[\"aws-smithy-runtime/default-https-client\"],\"credentials-login\":[\"dep:aws-sdk-signin\",\"dep:sha2\",\"dep:zeroize\",\"dep:hex\",\"dep:base64-simd\",\"dep:uuid\",\"uuid?/v4\",\"dep:p256\",\"p256?/arithmetic\",\"p256?/pem\",\"dep:rand\"],\"credentials-process\":[\"tokio/process\"],\"default\":[\"default-https-client\",\"rt-tokio\",\"credentials-process\",\"sso\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-runtime/rt-tokio\",\"tokio/rt\"],\"rustls\":[\"client-hyper\"],\"sso\":[\"dep:aws-sdk-sso\",\"dep:aws-sdk-ssooidc\",\"dep:ring\",\"dep:hex\",\"dep:zeroize\",\"aws-smithy-runtime-api/http-auth\"],\"test-util\":[\"aws-runtime/test-util\"]}}", "aws-credential-types_1.2.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.74\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"client\",\"http-auth\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"full\",\"test-util\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"zeroize\",\"req\":\"^1.7.0\"}],\"features\":{\"hardcoded-credentials\":[],\"test-util\":[\"aws-smithy-runtime-api/test-util\"]}}", - "aws-lc-rs_1.16.2": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.39.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", - "aws-lc-sys_0.39.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", + "aws-lc-rs_1.17.0": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.41.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"legacy-des\":[\"aws-lc-sys?/all-bindings\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", + "aws-lc-sys_0.41.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", "aws-runtime_1.5.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.3\"},{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"http0-compat\"],\"name\":\"aws-sigv4\",\"req\":\"^1.3.7\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"bytes-utils\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"convert_case\",\"req\":\"^0.6.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"regex-lite\",\"optional\":true,\"req\":\"^0.1.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"event-stream\":[\"dep:aws-smithy-eventstream\",\"aws-sigv4/sign-eventstream\"],\"http-02x\":[],\"http-1x\":[\"dep:http-1x\",\"dep:http-body-1x\"],\"sigv4a\":[\"aws-sigv4/sigv4a\"],\"test-util\":[\"dep:regex-lite\"]}}", "aws-sdk-signin_1.2.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sdk-sso_1.91.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", @@ -756,6 +757,7 @@ "clap_derive_4.6.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.13\"},{\"name\":\"heck\",\"req\":\"^0.5.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.1\"},{\"name\":\"quote\",\"req\":\"^1.0.45\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.117\"}],\"features\":{\"debug\":[],\"default\":[],\"deprecated\":[],\"raw-deprecated\":[\"deprecated\"],\"unstable-markdown\":[\"dep:pulldown-cmark\",\"dep:anstyle\"],\"unstable-v5\":[\"deprecated\"]}}", "clap_lex_1.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"}],\"features\":{}}", "clap_lex_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.16\"}],\"features\":{}}", + "clatter_2.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"aes\"],\"name\":\"aes-gcm\",\"optional\":true,\"req\":\"^0.10.3\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"arrayvec\",\"req\":\"^0.7.6\"},{\"default_features\":false,\"name\":\"blake2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"chacha20poly1305\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.5\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"ml-kem\",\"optional\":true,\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"pqcrypto-mlkem\",\"optional\":true,\"req\":\"^0.1.1\"},{\"default_features\":false,\"name\":\"pqcrypto-traits\",\"optional\":true,\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.9\"},{\"default_features\":false,\"name\":\"thiserror-no-std\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"features\":[\"static_secrets\",\"zeroize\"],\"name\":\"x25519-dalek\",\"optional\":true,\"req\":\"^2.0.1\"},{\"default_features\":false,\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"core\":[\"use-aes-gcm\",\"use-chacha20poly1305\",\"use-sha\",\"use-blake2\",\"use-25519\",\"use-rust-crypto-ml-kem\"],\"default\":[\"std\",\"use-aes-gcm\",\"use-chacha20poly1305\",\"use-sha\",\"use-blake2\",\"use-25519\",\"use-pqclean-ml-kem\",\"use-rust-crypto-ml-kem\"],\"getrandom\":[\"dep:getrandom\"],\"std\":[\"alloc\",\"sha2/std\",\"blake2/std\",\"aes-gcm/std\",\"chacha20poly1305/std\",\"ml-kem/std\",\"zeroize/std\",\"getrandom\"],\"use-25519\":[\"x25519-dalek\"],\"use-aes-gcm\":[\"aes-gcm\"],\"use-blake2\":[\"blake2\"],\"use-chacha20poly1305\":[\"chacha20poly1305\"],\"use-pqclean-ml-kem\":[\"pqcrypto-mlkem\",\"pqcrypto-traits\",\"getrandom\"],\"use-rust-crypto-ml-kem\":[\"ml-kem\"],\"use-sha\":[\"sha2\"]}}", "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "clru_0.6.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"req\":\"^0.16\"}],\"features\":{}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", @@ -814,6 +816,7 @@ "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctr_0.9.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.8\"},{\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"},{\"kind\":\"dev\",\"name\":\"kuznyechik\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"magma\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[\"cipher/alloc\"],\"block-padding\":[\"cipher/block-padding\"],\"std\":[\"cipher/std\",\"alloc\"],\"zeroize\":[\"cipher/zeroize\"]}}", "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", @@ -963,6 +966,7 @@ "getrandom_0.2.17": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", "getrandom_0.3.4": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "getrandom_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^6\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"name\":\"wasip3\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p3\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"sys_rng\":[\"dep:rand_core\"],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", + "ghash_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"name\":\"polyval\",\"req\":\"^0.6.2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"polyval/std\"]}}", "gif_0.14.1": "{\"dependencies\":[{\"name\":\"color_quant\",\"optional\":true,\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"png\",\"req\":\"^0.18.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.10.0\"},{\"name\":\"weezl\",\"req\":\"^0.1.10\"}],\"features\":{\"color_quant\":[\"dep:color_quant\"],\"default\":[\"raii_no_panic\",\"std\",\"color_quant\"],\"raii_no_panic\":[],\"std\":[]}}", "gimli_0.32.3": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", "gio-sys_0.21.5": "{\"dependencies\":[{\"name\":\"glib-sys\",\"req\":\"^0.21\"},{\"name\":\"gobject-sys\",\"req\":\"^0.21\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"shell-words\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"system-deps\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"v2_58\":[],\"v2_60\":[\"v2_58\"],\"v2_62\":[\"v2_60\"],\"v2_64\":[\"v2_62\"],\"v2_66\":[\"v2_64\"],\"v2_68\":[\"v2_66\"],\"v2_70\":[\"v2_68\"],\"v2_72\":[\"v2_70\"],\"v2_74\":[\"v2_72\"],\"v2_76\":[\"v2_74\"],\"v2_78\":[\"v2_76\"],\"v2_80\":[\"v2_78\"],\"v2_82\":[\"v2_80\"],\"v2_84\":[\"v2_82\"],\"v2_86\":[\"v2_84\"]}}", @@ -1338,6 +1342,7 @@ "png_0.18.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "poly1305_0.8.0": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", + "polyval_0.6.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", "portable-atomic-util_0.2.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"req\":\"^1.5.1\"}],\"features\":{\"alloc\":[],\"default\":[],\"std\":[\"alloc\"]}}", "portable-atomic-util_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"req\":\"^1.5.1\"}],\"features\":{\"alloc\":[],\"default\":[],\"std\":[\"alloc\"]}}", "portable-atomic_1.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"build-context\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"^0.1\",\"target\":\"cfg(valgrind)\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"=0.8.16\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"=0.2.163\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"sptr\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"fallback\"],\"disable-fiq\":[],\"fallback\":[],\"float\":[],\"force-amo\":[],\"require-cas\":[],\"s-mode\":[],\"std\":[],\"unsafe-assume-privileged\":[],\"unsafe-assume-single-core\":[]}}", @@ -1604,8 +1609,10 @@ "tester_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{\"asm_black_box\":[],\"capture\":[]}}", "textwrap_0.11.0": "{\"dependencies\":[{\"features\":[\"embed_all\"],\"name\":\"hyphenation\",\"optional\":true,\"req\":\"^0.7.1\"},{\"kind\":\"dev\",\"name\":\"lipsum\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.1\"},{\"name\":\"term_size\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.6\"}],\"features\":{}}", "textwrap_0.16.2": "{\"dependencies\":[{\"features\":[\"embed_en-us\"],\"name\":\"hyphenation\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"smawk\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"termion\",\"req\":\"^4.0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicode-linebreak\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9.5\"}],\"features\":{\"default\":[\"unicode-linebreak\",\"unicode-width\",\"smawk\"]}}", + "thiserror-impl-no-std_2.0.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^1.0.45\"}],\"features\":{\"std\":[]}}", "thiserror-impl_1.0.69": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", "thiserror-impl_2.0.18": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", + "thiserror-no-std_2.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"name\":\"thiserror-impl-no-std\",\"req\":\"=2.0.2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{\"std\":[\"thiserror-impl-no-std/std\"]}}", "thiserror_1.0.69": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.73\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"name\":\"thiserror-impl\",\"req\":\"=1.0.69\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", "thiserror_2.0.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.73\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"name\":\"thiserror-impl\",\"req\":\"=2.0.18\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "thread_local_1.1.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"}],\"features\":{\"nightly\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33392db931..cd467fc9ab 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -196,6 +196,20 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "age" version = "0.11.2" @@ -468,6 +482,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "zeroize", +] [[package]] name = "ascii" @@ -799,9 +816,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -810,9 +827,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -1748,6 +1765,23 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clatter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fed49fa357a85c377c0f920e86100f5326111b09ad69f6de684e324e3ad8097" +dependencies = [ + "aes-gcm", + "arrayvec", + "displaydoc", + "getrandom 0.3.4", + "rand_core 0.6.4", + "sha2 0.10.9", + "thiserror-no-std", + "x25519-dalek", + "zeroize", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -2768,9 +2802,11 @@ dependencies = [ "anyhow", "arc-swap", "async-trait", + "aws-lc-rs", "axum", "base64 0.22.1", "bytes", + "clatter", "codex-api", "codex-app-server-protocol", "codex-client", @@ -2798,6 +2834,7 @@ dependencies = [ "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", + "url", "uuid", "wiremock", ] @@ -4759,6 +4796,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -6190,6 +6236,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.1" @@ -10066,6 +10122,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -10298,7 +10366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.4.1", + "heck 0.5.0", "itertools 0.11.0", "log", "multimap", @@ -10318,7 +10386,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -11452,7 +11520,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13126,6 +13194,26 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "thiserror-impl-no-std" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e6318948b519ba6dc2b442a6d0b904ebfb8d411a3ad3e07843615a72249758" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "thiserror-no-std" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ad459d94dd517257cc96add8a43190ee620011bb6e6cdc82dafd97dfafafea" +dependencies = [ + "thiserror-impl-no-std", +] + [[package]] name = "thread_local" version = "1.1.9" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cc20f8a3a8..6b2921d5c3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -258,6 +258,7 @@ async-stream = "0.3.6" async-trait = "0.1.89" aws-config = "1" aws-credential-types = "1" +aws-lc-rs = { version = "1.16.3", default-features = false, features = ["non-fips"] } aws-sigv4 = "1" aws-types = "1" axum = { version = "0.8", default-features = false } @@ -268,6 +269,13 @@ chardetng = "0.1.17" chrono = "0.4.43" clap = "4" clap_complete = "4" +clatter = { version = "2.2.0", default-features = false, features = [ + "alloc", + "getrandom", + "use-25519", + "use-aes-gcm", + "use-sha", +] } color-eyre = "0.6.3" constant_time_eq = "0.3.1" crossbeam-channel = "0.5.15" diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index e87ee82f30..cb652c664f 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -45,7 +45,14 @@ pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::ExecServerError; pub use codex_exec_server::ExecServerRuntimePaths; +pub use codex_exec_server::SecureChannelIdentity; +pub use codex_exec_server::SecureChannelPublicKey; +pub use codex_exec_server::SecureRendezvousConnectArgs; +pub use codex_exec_server::SecureRendezvousConnectBundle; +pub use codex_exec_server::SecureRendezvousConnectProvider; +pub use codex_exec_server::SharedSecureRendezvousConnectProvider; pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index d842094a16..8d2d5bfc50 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -13,9 +13,11 @@ workspace = true [dependencies] arc-swap = { workspace = true } async-trait = { workspace = true } +aws-lc-rs = { workspace = true } axum = { workspace = true, features = ["http1", "tokio", "ws"] } base64 = { workspace = true } bytes = { workspace = true } +clatter = { workspace = true } codex-app-server-protocol = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } @@ -46,6 +48,7 @@ tokio = { workspace = true, features = [ tokio-util = { workspace = true, features = ["rt"] } tokio-tungstenite = { workspace = true } tracing = { workspace = true } +url = { workspace = true } uuid = { workspace = true, features = ["v4"] } [dev-dependencies] diff --git a/codex-rs/exec-server/src/aws_lc_ml_kem.rs b/codex-rs/exec-server/src/aws_lc_ml_kem.rs new file mode 100644 index 0000000000..c177dd1233 --- /dev/null +++ b/codex-rs/exec-server/src/aws_lc_ml_kem.rs @@ -0,0 +1,90 @@ +use aws_lc_rs::kem::Ciphertext; +use aws_lc_rs::kem::DecapsulationKey; +use aws_lc_rs::kem::EncapsulationKey; +use aws_lc_rs::kem::ML_KEM_768; +use clatter::KeyPair; +use clatter::bytearray::ByteArray; +use clatter::bytearray::HeapArray; +use clatter::bytearray::SensitiveByteArray; +use clatter::error::KemError; +use clatter::error::KemResult; +use clatter::traits::CryptoComponent; +use clatter::traits::Kem; +use clatter::traits::Rng; + +pub(super) const PUBLIC_KEY_LEN: usize = 1184; +const SECRET_KEY_LEN: usize = 2400; +const CIPHERTEXT_LEN: usize = 1088; +const SHARED_SECRET_LEN: usize = 32; + +/// ML-KEM-768 implementation backed by AWS-LC through `aws-lc-rs`. +#[derive(Clone)] +pub(super) struct AwsLcMlKem768; + +impl CryptoComponent for AwsLcMlKem768 { + fn name() -> &'static str { + "MLKEM768" + } +} + +impl Kem for AwsLcMlKem768 { + type SecretKey = SensitiveByteArray>; + type PubKey = HeapArray; + type Ct = HeapArray; + type Ss = SensitiveByteArray<[u8; SHARED_SECRET_LEN]>; + + fn genkey_rng(_rng: &mut R) -> KemResult> { + // AWS-LC owns ML-KEM key-generation randomness internally, so + // Clatter's injectable RNG cannot be plumbed through this provider. + let decapsulation_key = + DecapsulationKey::generate(&ML_KEM_768).map_err(|_| KemError::KeyGeneration)?; + let encapsulation_key = decapsulation_key + .encapsulation_key() + .map_err(|_| KemError::KeyGeneration)?; + let public = encapsulation_key + .key_bytes() + .map_err(|_| KemError::KeyGeneration)?; + let secret = decapsulation_key + .key_bytes() + .map_err(|_| KemError::KeyGeneration)?; + + Ok(KeyPair { + public: Self::PubKey::from_slice(public.as_ref()), + secret: Self::SecretKey::from_slice(secret.as_ref()), + }) + } + + fn encapsulate(pk: &[u8], _rng: &mut R) -> KemResult<(Self::Ct, Self::Ss)> { + let encapsulation_key = + EncapsulationKey::new(&ML_KEM_768, pk).map_err(|_| KemError::Input)?; + let (ciphertext, shared_secret) = encapsulation_key + .encapsulate() + .map_err(|_| KemError::Encapsulation)?; + + Ok(( + Self::Ct::from_slice(ciphertext.as_ref()), + Self::Ss::from_slice(shared_secret.as_ref()), + )) + } + + fn decapsulate(ct: &[u8], sk: &[u8]) -> KemResult { + // Reject the length before constructing AWS-LC's ciphertext wrapper. + // This keeps malformed wire input classified as an input error rather + // than relying on provider-specific decapsulation behavior. + if ct.len() != CIPHERTEXT_LEN { + return Err(KemError::Input); + } + + let decapsulation_key = + DecapsulationKey::new(&ML_KEM_768, sk).map_err(|_| KemError::Input)?; + let shared_secret = decapsulation_key + .decapsulate(Ciphertext::from(ct)) + .map_err(|_| KemError::Decapsulation)?; + + Ok(Self::Ss::from_slice(shared_secret.as_ref())) + } +} + +#[cfg(test)] +#[path = "aws_lc_ml_kem_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/aws_lc_ml_kem_tests.rs b/codex-rs/exec-server/src/aws_lc_ml_kem_tests.rs new file mode 100644 index 0000000000..281baf404f --- /dev/null +++ b/codex-rs/exec-server/src/aws_lc_ml_kem_tests.rs @@ -0,0 +1,31 @@ +use clatter::bytearray::ByteArray; +use clatter::traits::Kem; +use pretty_assertions::assert_eq; + +use super::AwsLcMlKem768; + +#[test] +fn kem_roundtrip() { + let keypair = AwsLcMlKem768::genkey().expect("generate keypair"); + let mut rng = clatter::crypto::rng::DefaultRng; + let (ciphertext, encapsulated_secret) = + AwsLcMlKem768::encapsulate(keypair.public.as_slice(), &mut rng).expect("encapsulate"); + let decapsulated_secret = + AwsLcMlKem768::decapsulate(ciphertext.as_slice(), keypair.secret.as_slice()) + .expect("decapsulate"); + + assert_eq!( + encapsulated_secret.as_slice(), + decapsulated_secret.as_slice() + ); +} + +#[test] +fn decapsulate_rejects_wrong_ciphertext_length() { + let keypair = AwsLcMlKem768::genkey().expect("generate keypair"); + + let error = AwsLcMlKem768::decapsulate(&[], keypair.secret.as_slice()) + .expect_err("empty ciphertext should be rejected"); + + assert!(matches!(error, clatter::error::KemError::Input)); +} diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index b6c56e9f5e..09c9b2c807 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -24,6 +24,8 @@ use crate::client_api::ExecServerClientConnectOptions; use crate::client_api::ExecServerTransportParams; use crate::client_api::HttpClient; use crate::client_api::RemoteExecServerConnectArgs; +use crate::client_api::SecureRendezvousConnectArgs; +use crate::client_api::SecureRendezvousConnectBundle; use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; @@ -115,6 +117,16 @@ impl From for ExecServerClientConnectOptions { } } +impl From for ExecServerClientConnectOptions { + fn from(value: SecureRendezvousConnectArgs) -> Self { + Self { + client_name: value.client_name, + initialize_timeout: value.initialize_timeout, + resume_session_id: value.resume_session_id, + } + } +} + impl From for ExecServerClientConnectOptions { fn from(value: StdioExecServerConnectArgs) -> Self { Self { @@ -137,6 +149,23 @@ impl RemoteExecServerConnectArgs { } } +impl SecureRendezvousConnectArgs { + pub fn new( + bundle: SecureRendezvousConnectBundle, + harness_identity: crate::SecureChannelIdentity, + client_name: String, + ) -> Self { + Self { + bundle, + harness_identity, + client_name, + connect_timeout: CONNECT_TIMEOUT, + initialize_timeout: INITIALIZE_TIMEOUT, + resume_session_id: None, + } + } +} + pub(crate) struct SessionState { wake_tx: watch::Sender, events: ExecProcessEventLog, @@ -231,6 +260,7 @@ impl LazyRemoteExecServerClient { if matches!( &self.transport_params, ExecServerTransportParams::WebSocketUrl { .. } + | ExecServerTransportParams::SecureRendezvous { .. } ) => { ExecServerClient::connect_for_transport(self.transport_params.clone()).await? @@ -317,6 +347,8 @@ pub enum ExecServerError { EnvironmentRegistryAuth(String), #[error("environment registry request failed: {0}")] EnvironmentRegistryRequest(#[from] reqwest::Error), + #[error(transparent)] + SecureChannel(#[from] crate::SecureChannelError), } impl ExecServerClient { diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 899863723f..30f248b7ea 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use futures::future::BoxFuture; @@ -8,6 +9,8 @@ use crate::ExecServerError; use crate::HttpRequestParams; use crate::HttpRequestResponse; use crate::HttpResponseBodyStream; +use crate::SecureChannelIdentity; +use crate::SecureChannelPublicKey; pub(crate) const DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); @@ -30,6 +33,69 @@ pub struct RemoteExecServerConnectArgs { pub resume_session_id: Option, } +/// Registry-authorized material for one secure rendezvous connection attempt. +#[derive(Clone)] +pub struct SecureRendezvousConnectBundle { + pub websocket_url: String, + pub environment_id: String, + pub executor_registration_id: String, + pub executor_public_key: SecureChannelPublicKey, + pub harness_key_authorization: String, +} + +impl std::fmt::Debug for SecureRendezvousConnectBundle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecureRendezvousConnectBundle") + .field( + "websocket_url", + &redacted_websocket_url(&self.websocket_url), + ) + .field("environment_id", &self.environment_id) + .field("executor_registration_id", &self.executor_registration_id) + .field("executor_public_key", &self.executor_public_key) + .field("harness_key_authorization", &"") + .finish() + } +} + +/// Connection arguments for an authenticated secure rendezvous exec-server. +#[derive(Clone)] +pub struct SecureRendezvousConnectArgs { + pub bundle: SecureRendezvousConnectBundle, + pub harness_identity: SecureChannelIdentity, + pub client_name: String, + pub connect_timeout: Duration, + pub initialize_timeout: Duration, + pub resume_session_id: Option, +} + +impl std::fmt::Debug for SecureRendezvousConnectArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecureRendezvousConnectArgs") + .field("bundle", &self.bundle) + .field("harness_identity", &"") + .field("client_name", &self.client_name) + .field("connect_timeout", &self.connect_timeout) + .field("initialize_timeout", &self.initialize_timeout) + .field("resume_session_id", &self.resume_session_id) + .finish() + } +} + +/// Supplies fresh registry-authorized material for secure rendezvous connections. +/// +/// Implementations must preserve one endpoint-local harness identity while +/// refreshing short-lived registry material for every physical connection attempt. +pub trait SecureRendezvousConnectProvider: Send + Sync { + /// Environment ID this provider is authorized to connect to. + fn environment_id(&self) -> &str; + + /// Returns a fresh atomic bundle for one physical connection attempt. + fn connect_args(&self) -> BoxFuture<'_, Result>; +} + +pub type SharedSecureRendezvousConnectProvider = Arc; + /// Stdio connection arguments for a command-backed exec-server. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StdioExecServerConnectArgs { @@ -49,13 +115,16 @@ pub(crate) struct StdioExecServerCommand { } /// Parameters used to connect to a remote exec-server environment. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone)] pub(crate) enum ExecServerTransportParams { WebSocketUrl { websocket_url: String, connect_timeout: Duration, initialize_timeout: Duration, }, + SecureRendezvous { + provider: SharedSecureRendezvousConnectProvider, + }, #[allow(dead_code)] StdioCommand { command: StdioExecServerCommand, @@ -63,6 +132,35 @@ pub(crate) enum ExecServerTransportParams { }, } +impl std::fmt::Debug for ExecServerTransportParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WebSocketUrl { + websocket_url, + connect_timeout, + initialize_timeout, + } => f + .debug_struct("WebSocketUrl") + .field("websocket_url", websocket_url) + .field("connect_timeout", connect_timeout) + .field("initialize_timeout", initialize_timeout) + .finish(), + Self::SecureRendezvous { provider } => f + .debug_struct("SecureRendezvous") + .field("environment_id", &provider.environment_id()) + .finish(), + Self::StdioCommand { + command, + initialize_timeout, + } => f + .debug_struct("StdioCommand") + .field("command", command) + .field("initialize_timeout", initialize_timeout) + .finish(), + } + } +} + impl ExecServerTransportParams { pub(crate) fn websocket_url(websocket_url: String) -> Self { Self::WebSocketUrl { @@ -73,6 +171,17 @@ impl ExecServerTransportParams { } } +pub(crate) fn redacted_websocket_url(websocket_url: &str) -> String { + match url::Url::parse(websocket_url) { + Ok(mut url) => { + url.set_query(None); + url.set_fragment(None); + url.to_string() + } + Err(_) => "".to_string(), + } +} + /// Sends HTTP requests through a runtime-selected transport. /// /// This is the HTTP capability counterpart to [`crate::ExecBackend`]. Callers diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index 4bdc09a80e..b04b1c7e24 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -4,6 +4,7 @@ use tokio::io::BufReader; use tokio::process::Command; use tokio::time::timeout; use tokio_tungstenite::connect_async; +use tokio_tungstenite::connect_async_with_config; use tracing::debug; use tracing::warn; @@ -12,10 +13,14 @@ use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use crate::ExecServerClient; use crate::ExecServerError; use crate::client_api::RemoteExecServerConnectArgs; +use crate::client_api::SecureRendezvousConnectArgs; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; +use crate::client_api::redacted_websocket_url; use crate::connection::JsonRpcConnection; use crate::relay::harness_connection_from_websocket; +use crate::secure_relay::secure_harness_connection_from_websocket; +use crate::secure_relay::secure_relay_websocket_config; const ENVIRONMENT_CLIENT_NAME: &str = "codex-environment"; @@ -38,6 +43,16 @@ impl ExecServerClient { }) .await } + crate::client_api::ExecServerTransportParams::SecureRendezvous { provider } => { + let args = provider.connect_args().await?; + if args.bundle.environment_id != provider.environment_id() { + return Err(ExecServerError::Protocol( + "secure rendezvous provider returned a different environment id" + .to_string(), + )); + } + Self::connect_secure_rendezvous(args).await + } crate::client_api::ExecServerTransportParams::StdioCommand { command, initialize_timeout, @@ -79,6 +94,44 @@ impl ExecServerClient { Self::connect(connection, args.into()).await } + pub async fn connect_secure_rendezvous( + args: SecureRendezvousConnectArgs, + ) -> Result { + ensure_rustls_crypto_provider(); + let websocket_url = args.bundle.websocket_url.clone(); + let diagnostic_url = redacted_websocket_url(&websocket_url); + let connect_timeout = args.connect_timeout; + let (stream, _) = timeout( + connect_timeout, + connect_async_with_config( + websocket_url.as_str(), + Some(secure_relay_websocket_config()), + /*disable_nagle*/ false, + ), + ) + .await + .map_err(|_| ExecServerError::WebSocketConnectTimeout { + url: diagnostic_url.clone(), + timeout: connect_timeout, + })? + .map_err(|source| ExecServerError::WebSocketConnect { + url: diagnostic_url.clone(), + source, + })?; + + let connection_label = format!("secure exec-server rendezvous websocket {diagnostic_url}"); + let connection = secure_harness_connection_from_websocket( + stream, + connection_label, + args.bundle.environment_id.clone(), + args.bundle.executor_registration_id.clone(), + args.harness_identity.clone(), + args.bundle.executor_public_key.clone(), + args.bundle.harness_key_authorization.clone(), + ); + Self::connect(connection, args.into()).await + } + pub(crate) async fn connect_stdio_command( args: StdioExecServerConnectArgs, ) -> Result { diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index b211c504ac..933ad763b8 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -44,7 +44,7 @@ pub(crate) enum JsonRpcConnectionEvent { #[derive(Clone)] pub(crate) enum JsonRpcTransport { - Plain, + External, Stdio { transport: StdioTransport }, } @@ -57,7 +57,7 @@ impl JsonRpcTransport { pub(crate) fn terminate(&self) { match self { - Self::Plain => {} + Self::External => {} Self::Stdio { transport } => transport.terminate(), } } @@ -315,7 +315,7 @@ impl JsonRpcConnection { incoming_rx, disconnected_rx, task_handles: vec![reader_task, writer_task], - transport: JsonRpcTransport::Plain, + transport: JsonRpcTransport::External, } } @@ -452,7 +452,7 @@ impl JsonRpcConnection { incoming_rx, disconnected_rx, task_handles: vec![websocket_task], - transport: JsonRpcTransport::Plain, + transport: JsonRpcTransport::External, } } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273..e5d58f55c2 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -6,6 +6,7 @@ use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; use crate::HttpClient; +use crate::SharedSecureRendezvousConnectProvider; use crate::client::LazyRemoteExecServerClient; use crate::client::http_client::ReqwestHttpClient; use crate::client_api::ExecServerTransportParams; @@ -276,6 +277,34 @@ impl EnvironmentManager { .insert(environment_id, Arc::new(environment)); Ok(()) } + + /// Adds or replaces a named remote environment that connects through an + /// authenticated, end-to-end encrypted rendezvous stream. + pub fn upsert_secure_environment( + &self, + environment_id: String, + provider: SharedSecureRendezvousConnectProvider, + ) -> Result<(), ExecServerError> { + if environment_id.is_empty() { + return Err(ExecServerError::Protocol( + "environment id cannot be empty".to_string(), + )); + } + if environment_id != provider.environment_id() { + return Err(ExecServerError::Protocol( + "secure environment id does not match connection provider".to_string(), + )); + } + let environment = Environment::remote_with_transport( + ExecServerTransportParams::SecureRendezvous { provider }, + self.local_runtime_paths.clone(), + ); + self.environments + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(environment_id, Arc::new(environment)); + Ok(()) + } } /// Concrete execution/filesystem environment selected for a session. @@ -382,6 +411,7 @@ impl Environment { websocket_url: exec_server_url, .. } => Some(exec_server_url.clone()), + ExecServerTransportParams::SecureRendezvous { .. } => None, ExecServerTransportParams::StdioCommand { .. } => None, }; let client = LazyRemoteExecServerClient::new(remote_transport.clone()); diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 26c178b5b8..daa024b4f5 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -48,7 +48,7 @@ struct EnvironmentToml { initialize_timeout_sec: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] struct TomlEnvironmentProvider { default: EnvironmentDefault, include_local: bool, @@ -577,18 +577,26 @@ mod tests { ) .expect("provider"); + let ExecServerTransportParams::StdioCommand { + command, + initialize_timeout, + } = &provider.environments[0].1 + else { + panic!("expected stdio transport"); + }; assert_eq!( - provider.environments[0].1, - ExecServerTransportParams::StdioCommand { - command: StdioExecServerCommand { - program: "ssh".to_string(), - args: Vec::new(), - env: HashMap::new(), - cwd: Some(config_dir.path().join("workspace")), - }, - initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + command, + &StdioExecServerCommand { + program: "ssh".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: Some(config_dir.path().join("workspace")), } ); + assert_eq!( + *initialize_timeout, + DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT + ); } #[test] @@ -614,26 +622,35 @@ mod tests { }) .expect("provider"); + let ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout, + initialize_timeout, + } = &provider.environments[0].1 + else { + panic!("expected websocket transport"); + }; + assert_eq!(websocket_url, "ws://127.0.0.1:8765"); + assert_eq!(*connect_timeout, Duration::from_secs(12)); + assert_eq!(*initialize_timeout, Duration::from_secs(34)); + + let ExecServerTransportParams::StdioCommand { + command, + initialize_timeout, + } = &provider.environments[1].1 + else { + panic!("expected stdio transport"); + }; assert_eq!( - provider.environments[0].1, - ExecServerTransportParams::WebSocketUrl { - websocket_url: "ws://127.0.0.1:8765".to_string(), - connect_timeout: Duration::from_secs(12), - initialize_timeout: Duration::from_secs(34), - } - ); - assert_eq!( - provider.environments[1].1, - ExecServerTransportParams::StdioCommand { - command: StdioExecServerCommand { - program: "ssh".to_string(), - args: Vec::new(), - env: HashMap::new(), - cwd: None, - }, - initialize_timeout: Duration::from_secs(56), + command, + &StdioExecServerCommand { + program: "ssh".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, } ); + assert_eq!(*initialize_timeout, Duration::from_secs(56)); } #[test] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index dea039c288..beaa3be56d 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -1,3 +1,4 @@ +mod aws_lc_ml_kem; mod client; mod client_api; mod client_transport; @@ -22,6 +23,8 @@ mod remote_process; mod rpc; mod runtime_paths; mod sandboxed_file_system; +mod secure_channel; +mod secure_relay; mod server; pub use client::ExecServerClient; @@ -31,6 +34,10 @@ pub use client::http_client::ReqwestHttpClient; pub use client_api::ExecServerClientConnectOptions; pub use client_api::HttpClient; pub use client_api::RemoteExecServerConnectArgs; +pub use client_api::SecureRendezvousConnectArgs; +pub use client_api::SecureRendezvousConnectBundle; +pub use client_api::SecureRendezvousConnectProvider; +pub use client_api::SharedSecureRendezvousConnectProvider; pub use codex_file_system::CopyOptions; pub use codex_file_system::CreateDirectoryOptions; pub use codex_file_system::ExecutorFileSystem; @@ -102,6 +109,9 @@ pub use protocol::WriteStatus; pub use remote::RemoteEnvironmentConfig; pub use remote::run_remote_environment; pub use runtime_paths::ExecServerRuntimePaths; +pub use secure_channel::SecureChannelError; +pub use secure_channel::SecureChannelIdentity; +pub use secure_channel::SecureChannelPublicKey; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; pub use server::run_main; diff --git a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto index 46527d80cc..3de1bfbe99 100644 --- a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto +++ b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.proto @@ -14,6 +14,7 @@ message RelayMessageFrame { RelayResume resume = 7; RelayReset reset = 8; RelayHeartbeat heartbeat = 9; + RelayHandshake handshake = 10; } } @@ -35,3 +36,7 @@ message RelayReset { } message RelayHeartbeat {} + +message RelayHandshake { + bytes payload = 1; +} diff --git a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs index 072a003dac..f65e1329f5 100644 --- a/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs +++ b/codex-rs/exec-server/src/proto/codex.exec_server.relay.v1.rs @@ -9,7 +9,7 @@ pub struct RelayMessageFrame { pub ack: u32, #[prost(uint32, tag = "4")] pub ack_bits: u32, - #[prost(oneof = "relay_message_frame::Body", tags = "5, 6, 7, 8, 9")] + #[prost(oneof = "relay_message_frame::Body", tags = "5, 6, 7, 8, 9, 10")] pub body: ::core::option::Option, } pub mod relay_message_frame { @@ -25,6 +25,8 @@ pub mod relay_message_frame { Reset(super::RelayReset), #[prost(message, tag = "9")] Heartbeat(super::RelayHeartbeat), + #[prost(message, tag = "10")] + Handshake(super::RelayHandshake), } } #[derive(Clone, PartialEq, ::prost::Message)] @@ -52,3 +54,8 @@ pub struct RelayReset { } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct RelayHeartbeat {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RelayHandshake { + #[prost(bytes = "vec", tag = "1")] + pub payload: ::prost::alloc::vec::Vec, +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index ae07d7652c..e42f520b58 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -1,18 +1,12 @@ -use std::collections::HashMap; - use codex_app_server_protocol::JSONRPCMessage; use futures::Sink; use futures::SinkExt; use futures::Stream; use futures::StreamExt; use prost::Message as ProstMessage; -use tokio::io::AsyncRead; -use tokio::io::AsyncWrite; use tokio::sync::mpsc; use tokio::sync::watch; -use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Message; -use tracing::debug; use tracing::warn; use uuid::Uuid; @@ -22,24 +16,28 @@ use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; use crate::relay_proto::RelayData; +use crate::relay_proto::RelayHandshake; use crate::relay_proto::RelayMessageFrame; +use crate::relay_proto::RelayReset; use crate::relay_proto::RelayResume; use crate::relay_proto::relay_message_frame; -use crate::server::ConnectionProcessor; const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; +const MAX_RELAY_RESET_REASON_BYTES: usize = 256; +const MAX_RELAY_STREAM_ID_BYTES: usize = 128; #[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum RelayFrameBodyKind { +pub(crate) enum RelayFrameBodyKind { Data, Ack, Resume, Reset, Heartbeat, + Handshake, } impl RelayMessageFrame { - fn data(stream_id: String, seq: u32, payload: Vec) -> Self { + pub(crate) fn data(stream_id: String, seq: u32, payload: Vec) -> Self { Self { version: RELAY_MESSAGE_FRAME_VERSION, stream_id, @@ -54,7 +52,7 @@ impl RelayMessageFrame { } } - fn resume(stream_id: String) -> Self { + pub(crate) fn resume(stream_id: String) -> Self { Self { version: RELAY_MESSAGE_FRAME_VERSION, stream_id, @@ -66,18 +64,55 @@ impl RelayMessageFrame { } } - fn validate(&self) -> Result { + pub(crate) fn handshake(stream_id: String, payload: Vec) -> Self { + Self { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id, + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Handshake(RelayHandshake { + payload, + })), + } + } + + pub(crate) fn reset(stream_id: String, reason: String) -> Self { + Self { + version: RELAY_MESSAGE_FRAME_VERSION, + stream_id, + ack: 0, + ack_bits: 0, + body: Some(relay_message_frame::Body::Reset(RelayReset { reason })), + } + } + + /// Validate cleartext routing metadata before a frame reaches either the + /// direct JSON-RPC parser or the secure relay state machine. + /// + /// The encrypted path intentionally leaves this metadata visible to + /// rendezvous, so it must be canonical and tightly bounded everywhere. + pub(crate) fn validate(&self) -> Result { if self.version != RELAY_MESSAGE_FRAME_VERSION { return Err(ExecServerError::Protocol(format!( "unsupported relay message frame version {}", self.version ))); } - if self.stream_id.trim().is_empty() { + if self.stream_id.is_empty() { return Err(ExecServerError::Protocol( "relay message frame is missing stream_id".to_string(), )); } + if self.stream_id.trim() != self.stream_id { + return Err(ExecServerError::Protocol( + "relay message frame stream_id is not canonical".to_string(), + )); + } + if self.stream_id.len() > MAX_RELAY_STREAM_ID_BYTES { + return Err(ExecServerError::Protocol( + "relay message frame stream_id is too long".to_string(), + )); + } match self.body.as_ref() { Some(relay_message_frame::Body::Data(data)) => { if data.segment_index != 0 || data.segment_count != 1 || data.payload.is_empty() { @@ -90,35 +125,64 @@ impl RelayMessageFrame { Some(relay_message_frame::Body::AckFrame(_)) => Ok(RelayFrameBodyKind::Ack), Some(relay_message_frame::Body::Resume(_)) => Ok(RelayFrameBodyKind::Resume), Some(relay_message_frame::Body::Reset(reset)) => { - if reset.reason.is_empty() { + if reset.reason.is_empty() || reset.reason.len() > MAX_RELAY_RESET_REASON_BYTES { return Err(ExecServerError::Protocol( - "relay reset message frame is missing reason".to_string(), + "relay reset message frame has invalid reason".to_string(), )); } Ok(RelayFrameBodyKind::Reset) } Some(relay_message_frame::Body::Heartbeat(_)) => Ok(RelayFrameBodyKind::Heartbeat), + Some(relay_message_frame::Body::Handshake(handshake)) => { + if handshake.payload.is_empty() { + return Err(ExecServerError::Protocol( + "relay handshake message frame is missing payload".to_string(), + )); + } + Ok(RelayFrameBodyKind::Handshake) + } None => Err(ExecServerError::Protocol( "relay message frame is missing body".to_string(), )), } } - fn into_jsonrpc_message(self) -> Result { + pub(crate) fn into_data(self) -> Result { let kind = self.validate()?; if kind != RelayFrameBodyKind::Data { return Err(ExecServerError::Protocol( "expected relay data message frame".to_string(), )); } - let payload = match self.body { - Some(relay_message_frame::Body::Data(data)) => data.payload, - _ => Vec::new(), - }; + match self.body { + Some(relay_message_frame::Body::Data(data)) => Ok(data), + _ => Err(ExecServerError::Protocol( + "expected relay data message frame".to_string(), + )), + } + } + + fn into_jsonrpc_message(self) -> Result { + let payload = self.into_data()?.payload; serde_json::from_slice(&payload).map_err(ExecServerError::Json) } - fn into_reset_reason(self) -> Option { + pub(crate) fn into_handshake_payload(self) -> Result, ExecServerError> { + let kind = self.validate()?; + if kind != RelayFrameBodyKind::Handshake { + return Err(ExecServerError::Protocol( + "expected relay handshake message frame".to_string(), + )); + } + match self.body { + Some(relay_message_frame::Body::Handshake(handshake)) => Ok(handshake.payload), + _ => Err(ExecServerError::Protocol( + "expected relay handshake message frame".to_string(), + )), + } + } + + pub(crate) fn into_reset_reason(self) -> Option { match self.body { Some(relay_message_frame::Body::Reset(reset)) if !reset.reason.is_empty() => { Some(reset.reason) @@ -128,16 +192,18 @@ impl RelayMessageFrame { } } -fn encode_relay_message_frame(frame: &RelayMessageFrame) -> Vec { +pub(crate) fn encode_relay_message_frame(frame: &RelayMessageFrame) -> Vec { frame.encode_to_vec() } -fn decode_relay_message_frame(payload: &[u8]) -> Result { +pub(crate) fn decode_relay_message_frame( + payload: &[u8], +) -> Result { RelayMessageFrame::decode(payload) .map_err(|err| ExecServerError::Protocol(format!("invalid relay message frame: {err}"))) } -fn jsonrpc_payload(message: &JSONRPCMessage) -> Result, ExecServerError> { +pub(crate) fn jsonrpc_payload(message: &JSONRPCMessage) -> Result, ExecServerError> { serde_json::to_vec(message).map_err(ExecServerError::Json) } @@ -253,7 +319,8 @@ where } RelayFrameBodyKind::Ack | RelayFrameBodyKind::Resume - | RelayFrameBodyKind::Heartbeat => {} + | RelayFrameBodyKind::Heartbeat + | RelayFrameBodyKind::Handshake => {} } } Some(Ok(Message::Close(_))) | None => { @@ -294,167 +361,7 @@ where incoming_rx, disconnected_rx, task_handles: vec![websocket_task], - transport: JsonRpcTransport::Plain, - } -} - -pub(crate) async fn run_multiplexed_environment( - stream: WebSocketStream, - processor: ConnectionProcessor, -) where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, -{ - let mut websocket = stream; - let (physical_outgoing_tx, mut physical_outgoing_rx) = - mpsc::channel::>(CHANNEL_CAPACITY); - - let mut streams: HashMap = HashMap::new(); - loop { - let frame = tokio::select! { - maybe_encoded = physical_outgoing_rx.recv() => { - let Some(encoded) = maybe_encoded else { - break; - }; - if websocket.send(Message::Binary(encoded.into())).await.is_err() { - break; - } - continue; - } - incoming_message = websocket.next() => match incoming_message { - Some(Ok(Message::Binary(payload))) => { - match decode_relay_message_frame(payload.as_ref()) { - Ok(frame) => frame, - Err(err) => { - warn!("dropping malformed relay message frame from harness: {err}"); - continue; - } - } - } - Some(Ok(Message::Close(_))) | None => break, - Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, - Some(Ok(Message::Text(_))) => { - warn!("dropping non-binary relay message frame from harness"); - continue; - } - Some(Err(err)) => { - debug!("multiplexed environment websocket read failed: {err}"); - break; - } - } - }; - - let kind = match frame.validate() { - Ok(kind) => kind, - Err(err) => { - warn!("dropping invalid relay message frame: {err}"); - continue; - } - }; - - match kind { - RelayFrameBodyKind::Data => { - let stream_id = frame.stream_id.clone(); - let message = match frame.into_jsonrpc_message() { - Ok(message) => message, - Err(err) => { - warn!("dropping malformed relay data message frame: {err}"); - continue; - } - }; - let stream = streams.entry(stream_id.clone()).or_insert_with(|| { - spawn_virtual_stream( - stream_id.clone(), - processor.clone(), - physical_outgoing_tx.clone(), - ) - }); - if stream - .incoming_tx - .send(JsonRpcConnectionEvent::Message(message)) - .await - .is_err() - { - streams.remove(&stream_id); - } - } - RelayFrameBodyKind::Reset => { - if let Some(stream) = streams.remove(&frame.stream_id) { - stream.disconnect(frame.into_reset_reason()).await; - } - } - RelayFrameBodyKind::Ack - | RelayFrameBodyKind::Resume - | RelayFrameBodyKind::Heartbeat => {} - } - } - - for (_stream_id, stream) in streams { - stream.disconnect(/*reason*/ None).await; - } - drop(physical_outgoing_tx); -} - -struct VirtualStream { - incoming_tx: mpsc::Sender, - disconnected_tx: watch::Sender, -} - -impl VirtualStream { - async fn disconnect(self, reason: Option) { - let _ = self.disconnected_tx.send(true); - let _ = self - .incoming_tx - .send(JsonRpcConnectionEvent::Disconnected { reason }) - .await; - } -} - -fn spawn_virtual_stream( - stream_id: String, - processor: ConnectionProcessor, - physical_outgoing_tx: mpsc::Sender>, -) -> VirtualStream { - let (json_outgoing_tx, mut json_outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); - let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); - let (disconnected_tx, disconnected_rx) = watch::channel(false); - - let writer_stream_id = stream_id; - let writer_task = tokio::spawn(async move { - let mut next_seq = 0u32; - while let Some(message) = json_outgoing_rx.recv().await { - let payload = match jsonrpc_payload(&message) { - Ok(payload) => payload, - Err(err) => { - warn!("failed to serialize virtual stream JSON-RPC payload: {err}"); - break; - } - }; - let frame = RelayMessageFrame::data(writer_stream_id.clone(), next_seq, payload); - next_seq = next_seq.wrapping_add(1); - if physical_outgoing_tx - .send(encode_relay_message_frame(&frame)) - .await - .is_err() - { - break; - } - } - }); - - let connection = JsonRpcConnection { - outgoing_tx: json_outgoing_tx, - incoming_rx, - disconnected_rx, - task_handles: vec![writer_task], - transport: JsonRpcTransport::Plain, - }; - tokio::spawn(async move { - processor.run_connection(connection).await; - }); - - VirtualStream { - incoming_tx, - disconnected_tx, + transport: JsonRpcTransport::External, } } @@ -476,6 +383,7 @@ mod tests { use futures::task::AtomicWaker; use tokio::net::TcpListener; use tokio::time::timeout; + use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::accept_async; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; diff --git a/codex-rs/exec-server/src/relay_proto.rs b/codex-rs/exec-server/src/relay_proto.rs index b8a938b8c7..da7cb5296d 100644 --- a/codex-rs/exec-server/src/relay_proto.rs +++ b/codex-rs/exec-server/src/relay_proto.rs @@ -2,6 +2,8 @@ mod generated; pub(crate) use generated::RelayData; +pub(crate) use generated::RelayHandshake; pub(crate) use generated::RelayMessageFrame; +pub(crate) use generated::RelayReset; pub(crate) use generated::RelayResume; pub(crate) use generated::relay_message_frame; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index ae7242377f..5ebf464dc5 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -3,18 +3,28 @@ use std::time::Duration; use codex_api::SharedAuthProvider; use reqwest::StatusCode; use serde::Deserialize; +use serde::Serialize; use tokio::time::sleep; -use tokio_tungstenite::connect_async; +use tokio::time::timeout; +use tokio_tungstenite::connect_async_with_config; +use tracing::info; use tracing::warn; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::relay::run_multiplexed_environment; +use crate::SecureChannelIdentity; +use crate::SecureChannelPublicKey; +use crate::secure_relay::HarnessKeyValidator; +use crate::secure_relay::run_secure_multiplexed_environment; +use crate::secure_relay::secure_relay_websocket_config; use crate::server::ConnectionProcessor; const ERROR_BODY_PREVIEW_BYTES: usize = 4096; +const ENVIRONMENT_REGISTRY_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_REMOTE_ENVIRONMENT_ID_LEN: usize = 256; +const REMOTE_RENDEZVOUS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone)] struct EnvironmentRegistryClient { @@ -40,6 +50,7 @@ impl EnvironmentRegistryClient { auth_provider, http: reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) + .timeout(ENVIRONMENT_REGISTRY_REQUEST_TIMEOUT) .build()?, }) } @@ -47,6 +58,7 @@ impl EnvironmentRegistryClient { async fn register_environment( &self, environment_id: &str, + executor_public_key: &SecureChannelPublicKey, ) -> Result { let response = self .http @@ -55,11 +67,41 @@ impl EnvironmentRegistryClient { &format!("/cloud/environment/{environment_id}/register"), )) .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryRegistrationRequest { + security_profile: SECURE_RELAY_SECURITY_PROFILE, + executor_public_key, + }) .send() .await?; self.parse_json_response(response).await } + async fn validate_harness_key( + &self, + environment_id: &str, + executor_registration_id: &str, + harness_public_key: &SecureChannelPublicKey, + harness_key_authorization: &str, + ) -> Result<(), ExecServerError> { + let response = self + .http + .post(endpoint_url( + &self.base_url, + &format!("/cloud/environment/{environment_id}/validate-harness-key"), + )) + .headers(self.auth_provider.to_auth_headers()) + .json(&EnvironmentRegistryHarnessKeyValidationRequest { + executor_registration_id, + harness_public_key, + harness_key_authorization, + }) + .send() + .await?; + self.parse_json_response::(response) + .await?; + Ok(()) + } + async fn parse_json_response( &self, response: reqwest::Response, @@ -81,10 +123,54 @@ impl EnvironmentRegistryClient { } } +const SECURE_RELAY_SECURITY_PROFILE: &str = "noise_hybrid_ik_v1"; + +#[derive(Serialize)] +struct EnvironmentRegistryRegistrationRequest<'a> { + security_profile: &'static str, + executor_public_key: &'a SecureChannelPublicKey, +} + #[derive(Debug, Clone, Eq, PartialEq, Deserialize)] struct EnvironmentRegistryRegistrationResponse { environment_id: String, url: String, + security_profile: String, + executor_registration_id: String, +} + +#[derive(Serialize)] +struct EnvironmentRegistryHarnessKeyValidationRequest<'a> { + executor_registration_id: &'a str, + harness_public_key: &'a SecureChannelPublicKey, + harness_key_authorization: &'a str, +} + +#[derive(Deserialize)] +struct EnvironmentRegistryHarnessKeyValidationResponse {} + +#[derive(Clone)] +struct RegistryHarnessKeyValidator { + client: EnvironmentRegistryClient, + environment_id: String, + executor_registration_id: String, +} + +impl HarnessKeyValidator for RegistryHarnessKeyValidator { + async fn validate_harness_key( + &self, + harness_public_key: &SecureChannelPublicKey, + authorization: &str, + ) -> Result<(), ExecServerError> { + self.client + .validate_harness_key( + &self.environment_id, + &self.executor_registration_id, + harness_public_key, + authorization, + ) + .await + } } /// Configuration for registering an exec-server for remote use. @@ -133,23 +219,60 @@ pub async fn run_remote_environment( let client = EnvironmentRegistryClient::new(config.base_url.clone(), config.auth_provider.clone())?; let processor = ConnectionProcessor::new(runtime_paths); + let identity = SecureChannelIdentity::generate()?; + let executor_public_key = identity.public_key(); let mut backoff = Duration::from_secs(1); loop { - let response = client.register_environment(&config.environment_id).await?; - eprintln!( - "codex exec-server remote environment registered with environment_id {}", - response.environment_id + let response = client + .register_environment(&config.environment_id, &executor_public_key) + .await?; + if response.security_profile != SECURE_RELAY_SECURITY_PROFILE { + return Err(ExecServerError::Protocol(format!( + "environment registry returned unsupported security profile `{}`", + response.security_profile + ))); + } + if response.environment_id != config.environment_id { + return Err(ExecServerError::Protocol( + "environment registry returned a different environment id".to_string(), + )); + } + let environment_id = &response.environment_id; + info!( + "codex exec-server remote environment registered with environment_id {environment_id}" ); - match connect_async(response.url.as_str()).await { - Ok((websocket, _)) => { + match timeout( + REMOTE_RENDEZVOUS_CONNECT_TIMEOUT, + connect_async_with_config( + response.url.as_str(), + Some(secure_relay_websocket_config()), + /*disable_nagle*/ false, + ), + ) + .await + { + Ok(Ok((websocket, _))) => { backoff = Duration::from_secs(1); - run_multiplexed_environment(websocket, processor.clone()).await; + run_secure_multiplexed_environment( + websocket, + processor.clone(), + response.environment_id, + response.executor_registration_id.clone(), + identity.clone(), + RegistryHarnessKeyValidator { + client: client.clone(), + environment_id: config.environment_id.clone(), + executor_registration_id: response.executor_registration_id, + }, + ) + .await; } - Err(err) => { + Ok(Err(err)) => { warn!("failed to connect remote exec-server websocket: {err}"); } + Err(_) => warn!("timed out connecting remote exec-server websocket"), } sleep(backoff).await; @@ -158,12 +281,32 @@ pub async fn run_remote_environment( } fn normalize_environment_id(environment_id: String) -> Result { - let environment_id = environment_id.trim().to_string(); if environment_id.is_empty() { return Err(ExecServerError::EnvironmentRegistryConfig( "environment id is required for remote exec-server registration".to_string(), )); } + if environment_id.trim() != environment_id { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment id must not contain surrounding whitespace".to_string(), + )); + } + if environment_id.len() > MAX_REMOTE_ENVIRONMENT_ID_LEN { + return Err(ExecServerError::EnvironmentRegistryConfig(format!( + "environment id cannot be longer than {MAX_REMOTE_ENVIRONMENT_ID_LEN} characters" + ))); + } + // The ID is interpolated into authenticated registry request paths below. + // Keep it to one URL path component so a caller cannot use a delimiter to + // redirect the exec-server's registration credential to another endpoint. + if !environment_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(ExecServerError::EnvironmentRegistryConfig( + "environment id must contain only ASCII letters, numbers, '-' or '_'".to_string(), + )); + } Ok(environment_id) } @@ -242,125 +385,5 @@ fn preview_error_body(body: &str) -> Option { } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use codex_api::AuthProvider; - use http::HeaderMap; - use http::HeaderValue; - use pretty_assertions::assert_eq; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[derive(Debug)] - struct StaticRegistryAuthProvider; - - impl AuthProvider for StaticRegistryAuthProvider { - fn add_auth_headers(&self, headers: &mut HeaderMap) { - let _ = headers.insert( - http::header::AUTHORIZATION, - HeaderValue::from_static("Bearer registry-token"), - ); - let _ = headers.insert( - "ChatGPT-Account-ID", - HeaderValue::from_static("workspace-123"), - ); - } - } - - fn static_registry_auth_provider() -> SharedAuthProvider { - Arc::new(StaticRegistryAuthProvider) - } - - #[tokio::test] - async fn register_environment_posts_with_auth_provider_headers() { - let server = MockServer::start().await; - let config = RemoteEnvironmentConfig::new( - server.uri(), - "environment-requested".to_string(), - static_registry_auth_provider(), - ) - .expect("config"); - Mock::given(method("POST")) - .and(path("/cloud/environment/environment-requested/register")) - .and(header("authorization", "Bearer registry-token")) - .and(header("chatgpt-account-id", "workspace-123")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "environment_id": "env-1", - "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc" - }))) - .mount(&server) - .await; - let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) - .expect("client"); - - let response = client - .register_environment(&config.environment_id) - .await - .expect("register environment"); - - assert_eq!( - response, - EnvironmentRegistryRegistrationResponse { - environment_id: "env-1".to_string(), - url: "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc".to_string(), - } - ); - } - - #[tokio::test] - async fn register_environment_does_not_follow_redirects_with_auth_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/cloud/environment/environment-requested/register")) - .and(header("authorization", "Bearer registry-token")) - .respond_with( - ResponseTemplate::new(302) - .insert_header("location", format!("{}/redirect-target", server.uri())), - ) - .mount(&server) - .await; - Mock::given(path("/redirect-target")) - .and(header("authorization", "Bearer registry-token")) - .respond_with(ResponseTemplate::new(200)) - .expect(0) - .mount(&server) - .await; - let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) - .expect("client"); - - let error = client - .register_environment("environment-requested") - .await - .expect_err("redirect response should not be followed"); - - assert!(matches!( - error, - ExecServerError::EnvironmentRegistryHttp { - status: StatusCode::FOUND, - .. - } - )); - } - - #[test] - fn debug_output_redacts_auth_provider() { - let config = RemoteEnvironmentConfig::new( - "https://registry.example".to_string(), - "env-1".to_string(), - static_registry_auth_provider(), - ) - .expect("config"); - - let debug = format!("{config:?}"); - - assert!(debug.contains("")); - assert!(!debug.contains("workspace-123")); - } -} +#[path = "remote_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/remote_tests.rs b/codex-rs/exec-server/src/remote_tests.rs new file mode 100644 index 0000000000..9f303a2f42 --- /dev/null +++ b/codex-rs/exec-server/src/remote_tests.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use codex_api::AuthProvider; +use http::HeaderMap; +use http::HeaderValue; +use pretty_assertions::assert_eq; +use reqwest::StatusCode; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::*; + +#[derive(Debug)] +struct StaticRegistryAuthProvider; + +impl AuthProvider for StaticRegistryAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let _ = headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer registry-token"), + ); + let _ = headers.insert( + "ChatGPT-Account-ID", + HeaderValue::from_static("workspace-123"), + ); + } +} + +fn static_registry_auth_provider() -> SharedAuthProvider { + Arc::new(StaticRegistryAuthProvider) +} + +#[tokio::test] +async fn register_environment_posts_with_auth_provider_headers() { + let server = MockServer::start().await; + let config = RemoteEnvironmentConfig::new( + server.uri(), + "environment-requested".to_string(), + static_registry_auth_provider(), + ) + .expect("config"); + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "environment_id": "env-1", + "url": "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc", + "security_profile": SECURE_RELAY_SECURITY_PROFILE, + "executor_registration_id": "registration-1", + }))) + .mount(&server) + .await; + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + let executor_public_key = SecureChannelIdentity::generate() + .expect("identity") + .public_key(); + + let response = client + .register_environment(&config.environment_id, &executor_public_key) + .await + .expect("register environment"); + + assert_eq!( + response, + EnvironmentRegistryRegistrationResponse { + environment_id: "env-1".to_string(), + url: "wss://rendezvous.test/cloud-agent/default/ws/environment/env-1?role=environment&sig=abc".to_string(), + security_profile: SECURE_RELAY_SECURITY_PROFILE.to_string(), + executor_registration_id: "registration-1".to_string(), + } + ); +} + +#[tokio::test] +async fn register_environment_does_not_follow_redirects_with_auth_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/redirect-target", server.uri())), + ) + .mount(&server) + .await; + Mock::given(path("/redirect-target")) + .and(header("authorization", "Bearer registry-token")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + let executor_public_key = SecureChannelIdentity::generate() + .expect("identity") + .public_key(); + + let error = client + .register_environment("environment-requested", &executor_public_key) + .await + .expect_err("redirect response should not be followed"); + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryHttp { + status: StatusCode::FOUND, + .. + } + )); +} + +#[test] +fn debug_output_redacts_auth_provider() { + let config = RemoteEnvironmentConfig::new( + "https://registry.example".to_string(), + "env-1".to_string(), + static_registry_auth_provider(), + ) + .expect("config"); + + let debug = format!("{config:?}"); + + assert!(debug.contains("")); + assert!(!debug.contains("workspace-123")); +} + +#[test] +fn remote_environment_config_accepts_cloud_environment_id() { + let environment_id = "ccarenv_b64_Y2Fhcy1zdGFnaW5nLWV4ZWN1dG9yLWVudmlyb25tZW50LTE".to_string(); + + let config = RemoteEnvironmentConfig::new( + "https://registry.example".to_string(), + environment_id.clone(), + static_registry_auth_provider(), + ) + .expect("config"); + + assert_eq!(config.environment_id, environment_id); +} + +#[test] +fn remote_environment_config_rejects_registry_path_injection() { + let error = RemoteEnvironmentConfig::new( + "https://registry.example".to_string(), + "ccarenv_b64_valid/../../status".to_string(), + static_registry_auth_provider(), + ) + .expect_err("path delimiter must not reach an authenticated registry request"); + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryConfig(message) if message.contains("ASCII letters") + )); +} diff --git a/codex-rs/exec-server/src/secure_channel.rs b/codex-rs/exec-server/src/secure_channel.rs new file mode 100644 index 0000000000..bda4109039 --- /dev/null +++ b/codex-rs/exec-server/src/secure_channel.rs @@ -0,0 +1,374 @@ +//! Narrow, misuse-resistant wrapper around the Clatter primitives used by the +//! remote exec-server relay. +//! +//! The harness is the Noise hybrid-IK initiator and pins the registry-provided +//! exec-server static public key. The exec-server is the responder; after it +//! reads the first IK message, Noise exposes the authenticated harness static +//! public key for registry authorization. Application data is allowed only +//! after both the cryptographic handshake and that external authorization pass. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use clatter::HybridHandshake; +use clatter::HybridHandshakeParams; +use clatter::KeyPair; +use clatter::bytearray::ByteArray; +use clatter::constants::MAX_MESSAGE_LEN; +use clatter::crypto::cipher::AesGcm; +use clatter::crypto::dh::X25519; +use clatter::crypto::hash::Sha256; +use clatter::handshakepattern::noise_hybrid_ik; +use clatter::traits::Cipher; +use clatter::traits::Dh; +use clatter::traits::Handshaker; +use clatter::traits::Kem; +use clatter::transportstate::TransportState; +use serde::Deserialize; +use serde::Serialize; + +use crate::aws_lc_ml_kem::AwsLcMlKem768; +use crate::aws_lc_ml_kem::PUBLIC_KEY_LEN as MLKEM768_PUBLIC_KEY_LEN; + +pub const SECURE_CHANNEL_SUITE: &str = "Noise_hybridIK_X25519+MLKEM768_AESGCM_SHA256"; + +const X25519_PUBLIC_KEY_LEN: usize = 32; +const MAX_TRANSPORT_RECORDS_PER_DIRECTION: u64 = u32::MAX as u64; +const PROLOGUE_DOMAIN: &[u8] = b"codex-exec-server-relay-noise/v1"; + +type Handshake = HybridHandshake; +type Transport = TransportState; +type DhKeyPair = KeyPair<::PubKey, ::PrivateKey>; +type KemKeyPair = KeyPair<::PubKey, ::SecretKey>; + +/// Public key material for the exec-server secure relay Noise suite. +/// +/// The suite field is part of the serialized contract. A key from a different +/// suite must not be interpreted as compatible merely because one component has +/// a familiar byte length. +#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecureChannelPublicKey { + suite: String, + x25519_public_key: String, + mlkem768_public_key: String, +} + +impl std::fmt::Debug for SecureChannelPublicKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecureChannelPublicKey") + .field("suite", &self.suite) + .field("x25519_public_key", &"") + .field("mlkem768_public_key", &"") + .finish() + } +} + +impl SecureChannelPublicKey { + /// Validate suite selection, encoding, and exact key lengths. + pub fn validate(&self) -> Result<(), SecureChannelError> { + let _ = self.decode()?; + Ok(()) + } + + fn from_keypairs(dh: &DhKeyPair, kem: &KemKeyPair) -> Self { + Self { + suite: SECURE_CHANNEL_SUITE.to_string(), + x25519_public_key: STANDARD.encode(dh.public), + mlkem768_public_key: STANDARD.encode(kem.public.as_slice()), + } + } + + fn from_raw(dh: &::PubKey, kem: &::PubKey) -> Self { + Self { + suite: SECURE_CHANNEL_SUITE.to_string(), + x25519_public_key: STANDARD.encode(dh), + mlkem768_public_key: STANDARD.encode(kem.as_slice()), + } + } + + fn decode( + &self, + ) -> Result<(::PubKey, ::PubKey), SecureChannelError> { + if self.suite != SECURE_CHANNEL_SUITE { + return Err(SecureChannelError::InvalidPublicKey( + "unsupported secure channel suite", + )); + } + let dh = STANDARD + .decode(&self.x25519_public_key) + .map_err(|_| SecureChannelError::InvalidPublicKey("invalid X25519 public key"))?; + let dh: [u8; X25519_PUBLIC_KEY_LEN] = dh.try_into().map_err(|_| { + SecureChannelError::InvalidPublicKey("invalid X25519 public key length") + })?; + let kem = STANDARD + .decode(&self.mlkem768_public_key) + .map_err(|_| SecureChannelError::InvalidPublicKey("invalid ML-KEM-768 public key"))?; + if kem.len() != MLKEM768_PUBLIC_KEY_LEN { + return Err(SecureChannelError::InvalidPublicKey( + "invalid ML-KEM-768 public key length", + )); + } + + Ok(( + dh, + ::PubKey::from_slice(kem.as_slice()), + )) + } +} + +/// Endpoint-local static identity for the exec-server secure relay Noise suite. +/// +/// Private components never cross the process boundary. Cloning is used only to +/// construct Clatter handshake state for reconnects within the same process. +#[derive(Clone)] +pub struct SecureChannelIdentity { + dh: DhKeyPair, + kem: KemKeyPair, +} + +impl std::fmt::Debug for SecureChannelIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecureChannelIdentity") + .field("public_key", &self.public_key()) + .finish_non_exhaustive() + } +} + +impl SecureChannelIdentity { + /// Generate independent classical and post-quantum static keypairs. + pub fn generate() -> Result { + let dh = X25519::genkey() + .map_err(|error| SecureChannelError::KeyGeneration(error.to_string()))?; + let kem = AwsLcMlKem768::genkey() + .map_err(|error| SecureChannelError::KeyGeneration(error.to_string()))?; + Ok(Self { dh, kem }) + } + + /// Return the distributable public half of this endpoint identity. + pub fn public_key(&self) -> SecureChannelPublicKey { + SecureChannelPublicKey::from_keypairs(&self.dh, &self.kem) + } +} + +pub(crate) struct InitiatorHandshake { + handshake: Handshake, +} + +impl InitiatorHandshake { + /// Start hybrid IK while pinning the expected responder static key. + /// + /// `payload` is encrypted by the first IK message. The relay uses it for + /// the short-lived registry authorization naming this harness public key. + pub(crate) fn start( + identity: &SecureChannelIdentity, + responder_public_key: &SecureChannelPublicKey, + prologue: &[u8], + payload: &[u8], + ) -> Result<(Self, Vec), SecureChannelError> { + let (responder_dh, responder_kem) = responder_public_key.decode()?; + + // IK authenticates both static identities. Supplying both responder + // components here is what makes a misrouted or impersonating + // exec-server fail before any JSON-RPC plaintext is released. + let params = HybridHandshakeParams::new(noise_hybrid_ik(), true) + .with_prologue(prologue) + .with_s(identity.dh.clone()) + .with_s_kem(identity.kem.clone()) + .with_rs(responder_dh) + .with_rs_kem(responder_kem); + let mut handshake = Handshake::new(params)?; + let mut output = [0u8; MAX_MESSAGE_LEN]; + let output_len = handshake.write_message(payload, &mut output)?; + Ok((Self { handshake }, output[..output_len].to_vec())) + } + + /// Consume the responder message and enter transport mode. + /// + /// The responder message carries no application payload in v1. Rejecting + /// one keeps future protocol additions from becoming an implicit channel. + pub(crate) fn finish(mut self, response: &[u8]) -> Result { + ensure_noise_frame_len(response.len(), "handshake response is too large")?; + let mut payload = [0u8; MAX_MESSAGE_LEN]; + let payload_len = self.handshake.read_message(response, &mut payload)?; + if payload_len != 0 { + return Err(SecureChannelError::InvalidMessage( + "handshake response payload must be empty", + )); + } + Ok(SecureTransport { + transport: self.handshake.finalize()?, + }) + } +} + +pub(crate) struct PendingResponderHandshake { + handshake: Handshake, + initiator_public_key: SecureChannelPublicKey, + payload: Vec, +} + +impl PendingResponderHandshake { + /// Authenticate and parse the first IK message without completing it. + /// + /// This split is intentional: callers must authorize `initiator_public_key` + /// with the registry before calling [`Self::complete`]. + pub(crate) fn read_request( + identity: &SecureChannelIdentity, + prologue: &[u8], + request: &[u8], + ) -> Result { + ensure_noise_frame_len(request.len(), "handshake request is too large")?; + let params = HybridHandshakeParams::new(noise_hybrid_ik(), false) + .with_prologue(prologue) + .with_s(identity.dh.clone()) + .with_s_kem(identity.kem.clone()); + let mut handshake = Handshake::new(params)?; + let mut payload = [0u8; MAX_MESSAGE_LEN]; + let payload_len = handshake.read_message(request, &mut payload)?; + // Clatter exposes the initiator static key only after the first IK + // message authenticates and decrypts successfully. + let remote = handshake + .get_remote_static() + .ok_or(SecureChannelError::InvalidMessage( + "handshake request is missing initiator static key", + ))?; + Ok(Self { + handshake, + initiator_public_key: SecureChannelPublicKey::from_raw(remote.dh(), remote.kem()), + payload: payload[..payload_len].to_vec(), + }) + } + + pub(crate) fn initiator_public_key(&self) -> &SecureChannelPublicKey { + &self.initiator_public_key + } + + pub(crate) fn payload(&self) -> &[u8] { + &self.payload + } + + /// Finish the responder handshake after external harness authorization. + pub(crate) fn complete(mut self) -> Result<(SecureTransport, Vec), SecureChannelError> { + let mut response = [0u8; MAX_MESSAGE_LEN]; + let response_len = self.handshake.write_message(&[], &mut response)?; + Ok(( + SecureTransport { + transport: self.handshake.finalize()?, + }, + response[..response_len].to_vec(), + )) + } +} + +pub(crate) struct SecureTransport { + transport: Transport, +} + +impl SecureTransport { + /// Encrypt exactly one ordered transport record. + /// + /// The caller owns relay sequence assignment and must never encrypt the + /// same logical record twice under different transport nonces. + pub(crate) fn encrypt(&mut self, plaintext: &[u8]) -> Result, SecureChannelError> { + if self.transport.sending_nonce() >= MAX_TRANSPORT_RECORDS_PER_DIRECTION { + return Err(SecureChannelError::InvalidState( + "transport record nonce exhausted", + )); + } + let frame_len = plaintext.len().checked_add(AesGcm::tag_len()).ok_or( + SecureChannelError::InvalidMessage("transport plaintext is too large"), + )?; + ensure_noise_frame_len(frame_len, "transport plaintext is too large")?; + Ok(self.transport.send_vec(plaintext)?) + } + + /// Decrypt exactly the next ordered transport record. + /// + /// Clatter advances the receiving nonce during this call, so callers must + /// reorder and deduplicate relay frames before invoking it. + pub(crate) fn decrypt(&mut self, ciphertext: &[u8]) -> Result, SecureChannelError> { + if self.transport.receiving_nonce() >= MAX_TRANSPORT_RECORDS_PER_DIRECTION { + return Err(SecureChannelError::InvalidState( + "transport record nonce exhausted", + )); + } + if ciphertext.len() < AesGcm::tag_len() { + return Err(SecureChannelError::InvalidMessage( + "transport ciphertext is too short", + )); + } + ensure_noise_frame_len(ciphertext.len(), "transport ciphertext is too large")?; + Ok(self.transport.receive_vec(ciphertext)?) + } +} + +/// Build the transcript prologue that binds cryptographic identity to routing. +/// +/// A handshake captured from one environment, exec-server registration, or +/// relay stream cannot be replayed into another because every participant must +/// construct the same prologue before the first Noise message is processed. +pub(crate) fn secure_channel_prologue( + environment_id: &str, + executor_registration_id: &str, + stream_id: &str, +) -> Result, SecureChannelError> { + let mut prologue = Vec::new(); + append_prologue_part(&mut prologue, PROLOGUE_DOMAIN)?; + append_prologue_part(&mut prologue, environment_id.as_bytes())?; + append_prologue_part(&mut prologue, executor_registration_id.as_bytes())?; + append_prologue_part(&mut prologue, stream_id.as_bytes())?; + Ok(prologue) +} + +fn append_prologue_part(prologue: &mut Vec, part: &[u8]) -> Result<(), SecureChannelError> { + // Length prefixes make component boundaries unambiguous. Raw concatenation + // would allow different identifier tuples to produce the same prologue. + let len = u32::try_from(part.len()).map_err(|_| { + SecureChannelError::InvalidMessage("secure channel prologue part is too large") + })?; + prologue.extend_from_slice(&len.to_be_bytes()); + prologue.extend_from_slice(part); + Ok(()) +} + +fn ensure_noise_frame_len( + frame_len: usize, + message: &'static str, +) -> Result<(), SecureChannelError> { + if frame_len > MAX_MESSAGE_LEN { + return Err(SecureChannelError::InvalidMessage(message)); + } + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum SecureChannelError { + #[error("secure channel key generation failed: {0}")] + KeyGeneration(String), + #[error("invalid secure channel public key: {0}")] + InvalidPublicKey(&'static str), + #[error("invalid secure channel state: {0}")] + InvalidState(&'static str), + #[error("invalid secure channel message: {0}")] + InvalidMessage(&'static str), + #[error("secure channel handshake failed: {0}")] + Handshake(String), + #[error("secure channel transport failed: {0}")] + Transport(String), +} + +impl From for SecureChannelError { + fn from(error: clatter::error::HandshakeError) -> Self { + Self::Handshake(error.to_string()) + } +} + +impl From for SecureChannelError { + fn from(error: clatter::error::TransportError) -> Self { + Self::Transport(error.to_string()) + } +} + +#[cfg(test)] +#[path = "secure_channel_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/secure_channel_tests.rs b/codex-rs/exec-server/src/secure_channel_tests.rs new file mode 100644 index 0000000000..ff6d64753b --- /dev/null +++ b/codex-rs/exec-server/src/secure_channel_tests.rs @@ -0,0 +1,252 @@ +use pretty_assertions::assert_eq; + +use super::InitiatorHandshake; +use super::MAX_TRANSPORT_RECORDS_PER_DIRECTION; +use super::PendingResponderHandshake; +use super::SECURE_CHANNEL_SUITE; +use super::SecureChannelError; +use super::SecureChannelIdentity; +use super::SecureChannelPublicKey; +use super::secure_channel_prologue; + +#[test] +fn hybrid_ik_roundtrip_authenticates_both_endpoints() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let responder = SecureChannelIdentity::generate().expect("generate responder identity"); + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + let authorization = b"harness-key-authorization"; + + let (initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &responder.public_key(), + &prologue, + authorization, + ) + .expect("start initiator handshake"); + let responder_handshake = + PendingResponderHandshake::read_request(&responder, &prologue, &request) + .expect("read responder handshake"); + + assert_eq!( + responder_handshake.initiator_public_key(), + &initiator.public_key() + ); + assert_eq!(responder_handshake.payload(), authorization); + + let (mut responder_transport, response) = responder_handshake + .complete() + .expect("complete responder handshake"); + let mut initiator_transport = initiator_handshake + .finish(&response) + .expect("complete initiator handshake"); + + let request_ciphertext = initiator_transport + .encrypt(b"request") + .expect("encrypt request"); + assert_ne!(request_ciphertext, b"request"); + assert_eq!( + responder_transport + .decrypt(&request_ciphertext) + .expect("decrypt request"), + b"request" + ); + + let response_ciphertext = responder_transport + .encrypt(b"response") + .expect("encrypt response"); + assert_ne!(response_ciphertext, b"response"); + assert_eq!( + initiator_transport + .decrypt(&response_ciphertext) + .expect("decrypt response"), + b"response" + ); +} + +#[test] +fn initiator_rejects_wrong_responder_key() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let expected_responder = SecureChannelIdentity::generate().expect("generate expected identity"); + let actual_responder = SecureChannelIdentity::generate().expect("generate actual identity"); + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + + let (_initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &expected_responder.public_key(), + &prologue, + b"authorization", + ) + .expect("start initiator handshake"); + + assert!( + PendingResponderHandshake::read_request(&actual_responder, &prologue, &request).is_err() + ); +} + +#[test] +fn responder_rejects_mismatched_prologue() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let responder = SecureChannelIdentity::generate().expect("generate responder identity"); + let initiator_prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + let responder_prologue = + secure_channel_prologue("env-1", "registration-1", "stream-2").expect("build prologue"); + let (_initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &responder.public_key(), + &initiator_prologue, + b"authorization", + ) + .expect("start initiator handshake"); + + assert!( + PendingResponderHandshake::read_request(&responder, &responder_prologue, &request).is_err() + ); +} + +#[test] +fn prologue_encoding_is_stable_and_unambiguous() { + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + + assert_eq!( + prologue, + b"\x00\x00\x00\x20codex-exec-server-relay-noise/v1\ + \x00\x00\x00\x05env-1\ + \x00\x00\x00\x0eregistration-1\ + \x00\x00\x00\x08stream-1" + .to_vec() + ); +} + +#[test] +fn transport_rejects_tampered_ciphertext() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let responder = SecureChannelIdentity::generate().expect("generate responder identity"); + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + let (initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &responder.public_key(), + &prologue, + b"authorization", + ) + .expect("start initiator handshake"); + let responder_handshake = + PendingResponderHandshake::read_request(&responder, &prologue, &request) + .expect("read responder handshake"); + let (mut responder_transport, response) = responder_handshake + .complete() + .expect("complete responder handshake"); + let mut initiator_transport = initiator_handshake + .finish(&response) + .expect("complete initiator handshake"); + let mut ciphertext = initiator_transport + .encrypt(b"request") + .expect("encrypt request"); + ciphertext[0] ^= 1; + + assert!(responder_transport.decrypt(&ciphertext).is_err()); +} + +#[test] +fn transport_rejects_exhausted_receiving_nonce_before_decryption() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let responder = SecureChannelIdentity::generate().expect("generate responder identity"); + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + let (initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &responder.public_key(), + &prologue, + b"authorization", + ) + .expect("start initiator handshake"); + let responder_handshake = + PendingResponderHandshake::read_request(&responder, &prologue, &request) + .expect("read responder handshake"); + let (mut responder_transport, response) = responder_handshake + .complete() + .expect("complete responder handshake"); + let mut initiator_transport = initiator_handshake + .finish(&response) + .expect("complete initiator handshake"); + let ciphertext = initiator_transport + .encrypt(b"request") + .expect("encrypt request"); + responder_transport + .transport + .set_receiving_nonce(MAX_TRANSPORT_RECORDS_PER_DIRECTION); + + assert!(matches!( + responder_transport.decrypt(&ciphertext), + Err(SecureChannelError::InvalidState( + "transport record nonce exhausted" + )) + )); +} + +#[test] +fn transport_rejects_replayed_ciphertext() { + let initiator = SecureChannelIdentity::generate().expect("generate initiator identity"); + let responder = SecureChannelIdentity::generate().expect("generate responder identity"); + let prologue = + secure_channel_prologue("env-1", "registration-1", "stream-1").expect("build prologue"); + let (initiator_handshake, request) = InitiatorHandshake::start( + &initiator, + &responder.public_key(), + &prologue, + b"authorization", + ) + .expect("start initiator handshake"); + let responder_handshake = + PendingResponderHandshake::read_request(&responder, &prologue, &request) + .expect("read responder handshake"); + let (mut responder_transport, response) = responder_handshake + .complete() + .expect("complete responder handshake"); + let mut initiator_transport = initiator_handshake + .finish(&response) + .expect("complete initiator handshake"); + let ciphertext = initiator_transport + .encrypt(b"request") + .expect("encrypt request"); + + assert_eq!( + responder_transport + .decrypt(&ciphertext) + .expect("decrypt request"), + b"request" + ); + assert!(matches!( + responder_transport.decrypt(&ciphertext), + Err(SecureChannelError::Transport(_)) + )); +} + +#[test] +fn public_key_validation_rejects_unknown_suite() { + let key = SecureChannelIdentity::generate() + .expect("generate identity") + .public_key(); + let json = serde_json::to_value(key).expect("serialize key"); + let mut object = json.as_object().expect("key object").clone(); + object.insert("suite".to_string(), serde_json::json!("unknown")); + let key: SecureChannelPublicKey = + serde_json::from_value(serde_json::Value::Object(object)).expect("deserialize key"); + + assert!(key.validate().is_err()); +} + +#[test] +fn public_key_serializes_with_expected_suite() { + let key = SecureChannelIdentity::generate() + .expect("generate identity") + .public_key(); + + let json = serde_json::to_value(key).expect("serialize key"); + + assert_eq!(json["suite"], SECURE_CHANNEL_SUITE); +} diff --git a/codex-rs/exec-server/src/secure_relay/environment.rs b/codex-rs/exec-server/src/secure_relay/environment.rs new file mode 100644 index 0000000000..ce8d37edff --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/environment.rs @@ -0,0 +1,487 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use futures::SinkExt; +use futures::StreamExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio::task::JoinSet; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use tracing::warn; + +use crate::ExecServerError; +use crate::connection::CHANNEL_CAPACITY; +use crate::connection::JsonRpcConnection; +use crate::connection::JsonRpcConnectionEvent; +use crate::connection::JsonRpcTransport; +use crate::relay::RelayFrameBodyKind; +use crate::relay::decode_relay_message_frame; +use crate::relay::encode_relay_message_frame; +use crate::relay_proto::RelayData; +use crate::relay_proto::RelayMessageFrame; +use crate::secure_channel::PendingResponderHandshake; +use crate::secure_channel::SecureChannelIdentity; +use crate::secure_channel::SecureChannelPublicKey; +use crate::secure_channel::SecureTransport; +use crate::secure_channel::secure_channel_prologue; +use crate::secure_relay::message_framing::JsonRpcMessageDecoder; +use crate::secure_relay::message_framing::SECURE_RECORD_PLAINTEXT_LEN; +use crate::secure_relay::message_framing::frame_jsonrpc_message; +use crate::secure_relay::ordered_ciphertext::OrderedCiphertextFrames; +use crate::secure_relay::take_next_sequence; +use crate::server::ConnectionProcessor; + +const SECURE_RELAY_RESET_REASON: &str = "secure_relay_protocol_error"; +const MAX_ACTIVE_SECURE_RELAY_STREAMS: usize = 128; +const MAX_PENDING_HANDSHAKE_VALIDATIONS: usize = 32; +const HARNESS_KEY_VALIDATION_TIMEOUT: Duration = Duration::from_secs(10); + +/// Validates that a Noise-authenticated harness public key is authorized. +/// +/// Implementations must consult an authority independent of rendezvous. The +/// exec-server invokes this after parsing the first IK message and before +/// completing the responder handshake. +pub(crate) trait HarnessKeyValidator: Send + Sync { + fn validate_harness_key( + &self, + harness_public_key: &SecureChannelPublicKey, + authorization: &str, + ) -> impl std::future::Future> + Send; +} + +/// Serve many authenticated virtual JSON-RPC streams over one executor websocket. +/// +/// Each stream has an independent Noise handshake and transport state. The +/// outer websocket and rendezvous route are treated as untrusted delivery: +/// malformed, unauthorized, or cryptographically invalid streams fail closed +/// without creating a `JsonRpcConnection`. +pub(crate) async fn run_secure_multiplexed_environment( + stream: WebSocketStream, + processor: ConnectionProcessor, + environment_id: String, + executor_registration_id: String, + identity: SecureChannelIdentity, + validator: V, +) where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + V: HarnessKeyValidator + Clone + 'static, +{ + let mut websocket = stream; + let (physical_outgoing_tx, mut physical_outgoing_rx) = + mpsc::channel::>(CHANNEL_CAPACITY); + let mut streams: HashMap = HashMap::new(); + let mut pending_handshakes: HashMap = HashMap::new(); + let mut validation_tasks: JoinSet = JoinSet::new(); + let mut next_validation_id = 0u64; + + loop { + // Keep registry validation out of the main relay loop. A slow or + // malicious authorization request must not block existing streams or + // prevent other handshakes from being received and bounded. + let frame = tokio::select! { + maybe_encoded = physical_outgoing_rx.recv() => { + let Some(encoded) = maybe_encoded else { + break; + }; + if websocket.send(Message::Binary(encoded.into())).await.is_err() { + break; + } + continue; + } + validation_result = validation_tasks.join_next(), if !validation_tasks.is_empty() => { + match validation_result { + Some(Ok(validation_result)) => { + // Stream IDs may be reset and reused while validation + // is in flight. The monotonic validation ID ensures a + // stale task can never complete a newer handshake. + let is_current = pending_handshakes + .get(&validation_result.stream_id) + .is_some_and(|pending| { + pending.validation_id == validation_result.validation_id + }); + if !is_current { + continue; + } + let Some(pending) = + pending_handshakes.remove(&validation_result.stream_id) + else { + continue; + }; + if let Err(error) = validation_result.result { + warn!("secure relay harness key validation failed: {error}"); + send_reset(&physical_outgoing_tx, validation_result.stream_id).await; + continue; + } + if streams.len() >= MAX_ACTIVE_SECURE_RELAY_STREAMS { + warn!("secure relay has too many active streams"); + send_reset(&physical_outgoing_tx, validation_result.stream_id).await; + continue; + } + + // This is the only point where the responder completes + // IK and exposes a JSON-RPC stream: Noise authenticated + // the harness key and the registry authorized it. + let (transport, response) = match pending.handshake.complete() { + Ok(completed) => completed, + Err(error) => { + warn!("failed to complete secure relay handshake: {error}"); + send_reset(&physical_outgoing_tx, validation_result.stream_id).await; + continue; + } + }; + let response = RelayMessageFrame::handshake( + validation_result.stream_id.clone(), + response, + ); + if physical_outgoing_tx + .send(encode_relay_message_frame(&response)) + .await + .is_err() + { + break; + } + streams.insert( + validation_result.stream_id.clone(), + spawn_secure_virtual_stream( + validation_result.stream_id, + processor.clone(), + physical_outgoing_tx.clone(), + transport, + ), + ); + } + Some(Err(error)) => { + warn!("secure relay harness key validation task failed: {error}"); + let stream_ids = pending_handshakes.keys().cloned().collect::>(); + pending_handshakes.clear(); + for stream_id in stream_ids { + send_reset(&physical_outgoing_tx, stream_id).await; + } + } + None => {} + } + continue; + } + incoming_message = websocket.next() => match incoming_message { + Some(Ok(Message::Binary(payload))) => match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(error) => { + warn!("dropping malformed secure relay frame from harness: {error}"); + continue; + } + }, + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, + Some(Ok(Message::Text(_))) => { + warn!("dropping non-binary secure relay frame from harness"); + continue; + } + Some(Err(error)) => { + debug!("secure multiplexed environment websocket read failed: {error}"); + break; + } + } + }; + + let kind = match frame.validate() { + Ok(kind) => kind, + Err(error) => { + warn!("dropping invalid secure relay frame: {error}"); + continue; + } + }; + let stream_id = frame.stream_id.clone(); + match kind { + RelayFrameBodyKind::Handshake => { + // Bound all pre-authentication state before doing expensive + // hybrid cryptography or starting an external validation. + if streams.contains_key(&stream_id) || pending_handshakes.contains_key(&stream_id) { + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + if streams.len() >= MAX_ACTIVE_SECURE_RELAY_STREAMS { + warn!("secure relay has too many active streams"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + if validation_tasks.len() >= MAX_PENDING_HANDSHAKE_VALIDATIONS { + warn!("secure relay has too many pending harness key validations"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + let prologue = match secure_channel_prologue( + &environment_id, + &executor_registration_id, + &stream_id, + ) { + Ok(prologue) => prologue, + Err(error) => { + warn!("failed to build secure relay prologue: {error}"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + }; + let request = match frame.into_handshake_payload() { + Ok(request) => request, + Err(error) => { + warn!("failed to read secure relay handshake frame: {error}"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + }; + let pending = + match PendingResponderHandshake::read_request(&identity, &prologue, &request) { + Ok(pending) => pending, + Err(error) => { + warn!("failed to read secure relay handshake request: {error}"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + }; + + // The authorization is encrypted inside the first IK message. + // It is meaningful only alongside the initiator static key + // that Clatter authenticated from that same message. + let authorization = match std::str::from_utf8(pending.payload()) { + Ok(authorization) => authorization.to_string(), + Err(_) => { + warn!("secure relay handshake authorization is not UTF-8"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + }; + let harness_public_key = pending.initiator_public_key().clone(); + let validation_id = next_validation_id; + let Some(next_id) = next_validation_id.checked_add(1) else { + warn!("secure relay harness key validation id exhausted"); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + }; + next_validation_id = next_id; + pending_handshakes.insert( + stream_id.clone(), + PendingHandshake { + validation_id, + handshake: pending, + }, + ); + let validator = validator.clone(); + + // Validation is time-bounded and concurrency-bounded above. + // Failure leaves no transport state and returns a generic + // protocol reset to avoid exposing authorization details. + validation_tasks.spawn(async move { + let result = match timeout( + HARNESS_KEY_VALIDATION_TIMEOUT, + validator.validate_harness_key(&harness_public_key, &authorization), + ) + .await + { + Ok(result) => result, + Err(_) => Err(ExecServerError::Protocol( + "timed out validating secure relay harness key".to_string(), + )), + }; + HarnessKeyValidationResult { + stream_id, + validation_id, + result, + } + }); + } + RelayFrameBodyKind::Data => { + // Data before handshake completion is always invalid. Removing + // a pending handshake also ensures a peer cannot keep its + // authorization task alive while sending application records. + let Some(stream) = streams.get_mut(&stream_id) else { + pending_handshakes.remove(&stream_id); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + }; + let data = match frame.into_data() { + Ok(data) => data, + Err(error) => { + warn!("dropping malformed secure relay data frame: {error}"); + streams.remove(&stream_id); + send_reset(&physical_outgoing_tx, stream_id).await; + continue; + } + }; + if let Err(error) = stream.receive_data(data).await { + warn!("failed to process secure relay payload: {error}"); + streams.remove(&stream_id); + send_reset(&physical_outgoing_tx, stream_id).await; + } + } + RelayFrameBodyKind::Reset => { + pending_handshakes.remove(&stream_id); + if let Some(stream) = streams.remove(&stream_id) { + stream.disconnect(frame.into_reset_reason()).await; + } + } + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat => {} + } + } + + for (_stream_id, stream) in streams { + stream.disconnect(/*reason*/ None).await; + } +} + +struct PendingHandshake { + validation_id: u64, + handshake: PendingResponderHandshake, +} + +struct HarnessKeyValidationResult { + stream_id: String, + validation_id: u64, + result: Result<(), ExecServerError>, +} + +struct SecureVirtualStream { + incoming_tx: mpsc::Sender, + disconnected_tx: watch::Sender, + transport: Arc>, + inbound_ciphertexts: OrderedCiphertextFrames, + inbound_decoder: JsonRpcMessageDecoder, +} + +impl SecureVirtualStream { + async fn disconnect(self, reason: Option) { + let _ = self.disconnected_tx.send(true); + let _ = self + .incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { reason }) + .await; + } + + async fn receive_data(&mut self, data: RelayData) -> Result<(), ExecServerError> { + // Relay sequence ordering is enforced before taking the transport lock + // and decrypting. Each virtual stream owns one ordered Noise nonce + // space shared by its reader and writer transport halves. + for ciphertext in self.inbound_ciphertexts.push(data.seq, data.payload)? { + let plaintext = { + let mut transport = self + .transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + transport.decrypt(&ciphertext)? + }; + for message in self.inbound_decoder.push(&plaintext)? { + self.incoming_tx + .send(JsonRpcConnectionEvent::Message(message)) + .await + .map_err(|_| ExecServerError::Closed)?; + } + } + Ok(()) + } +} + +fn spawn_secure_virtual_stream( + stream_id: String, + processor: ConnectionProcessor, + physical_outgoing_tx: mpsc::Sender>, + transport: SecureTransport, +) -> SecureVirtualStream { + let (json_outgoing_tx, mut json_outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (disconnected_tx, disconnected_rx) = watch::channel(false); + let transport = Arc::new(Mutex::new(transport)); + let writer_transport = Arc::clone(&transport); + let writer_stream_id = stream_id; + let writer_task = tokio::spawn(async move { + let mut next_seq = 0u32; + 'writer: while let Some(message) = json_outgoing_rx.recv().await { + // Frame first, then split into bounded Noise records. Each record + // receives one checked relay sequence and is encrypted exactly + // once, preserving the implicit Noise sending nonce. + let framed = match frame_jsonrpc_message(&message) { + Ok(framed) => framed, + Err(error) => { + warn!("failed to frame secure virtual stream JSON-RPC payload: {error}"); + break; + } + }; + for plaintext_record in framed.chunks(SECURE_RECORD_PLAINTEXT_LEN) { + let seq = match take_next_sequence(&mut next_seq) { + Ok(seq) => seq, + Err(error) => { + warn!("secure virtual stream sequence exhausted: {error}"); + break 'writer; + } + }; + let ciphertext = { + let mut transport = writer_transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + transport.encrypt(plaintext_record) + }; + let ciphertext = match ciphertext { + Ok(ciphertext) => ciphertext, + Err(error) => { + warn!("failed to encrypt secure virtual stream payload: {error}"); + break 'writer; + } + }; + let frame = RelayMessageFrame::data(writer_stream_id.clone(), seq, ciphertext); + if physical_outgoing_tx + .send(encode_relay_message_frame(&frame)) + .await + .is_err() + { + break 'writer; + } + } + } + + // Tell the harness to discard this virtual stream whenever its writer + // exits, including processor shutdown or a cryptographic/send failure. + // Otherwise the peer could wait indefinitely on a dead stream. + let reset = + RelayMessageFrame::reset(writer_stream_id, SECURE_RELAY_RESET_REASON.to_string()); + let _ = physical_outgoing_tx + .send(encode_relay_message_frame(&reset)) + .await; + }); + + let connection = JsonRpcConnection { + outgoing_tx: json_outgoing_tx, + incoming_rx, + disconnected_rx, + task_handles: vec![writer_task], + transport: JsonRpcTransport::External, + }; + tokio::spawn(async move { + processor.run_connection(connection).await; + }); + + SecureVirtualStream { + incoming_tx, + disconnected_tx, + transport, + inbound_ciphertexts: OrderedCiphertextFrames::default(), + inbound_decoder: JsonRpcMessageDecoder::default(), + } +} + +async fn send_reset(physical_outgoing_tx: &mpsc::Sender>, stream_id: String) { + let reset = RelayMessageFrame::reset(stream_id, SECURE_RELAY_RESET_REASON.to_string()); + let _ = physical_outgoing_tx + .send(encode_relay_message_frame(&reset)) + .await; +} + +#[cfg(test)] +#[path = "environment_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/secure_relay/environment_tests.rs b/codex-rs/exec-server/src/secure_relay/environment_tests.rs new file mode 100644 index 0000000000..7678fd5e22 --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/environment_tests.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use anyhow::Result; +use futures::SinkExt; +use tokio::net::TcpListener; +use tokio::sync::Notify; +use tokio::time::timeout; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +use super::HarnessKeyValidator; +use super::run_secure_multiplexed_environment; +use crate::ExecServerError; +use crate::ExecServerRuntimePaths; +use crate::relay::encode_relay_message_frame; +use crate::relay_proto::RelayMessageFrame; +use crate::secure_channel::InitiatorHandshake; +use crate::secure_channel::SecureChannelIdentity; +use crate::secure_channel::SecureChannelPublicKey; +use crate::secure_channel::secure_channel_prologue; +use crate::server::ConnectionProcessor; + +const ENVIRONMENT_ID: &str = "environment-1"; +const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; + +#[derive(Clone)] +struct BlockingValidator { + calls: Arc, + release: Arc, +} + +impl HarnessKeyValidator for BlockingValidator { + fn validate_harness_key( + &self, + _harness_public_key: &SecureChannelPublicKey, + _authorization: &str, + ) -> impl std::future::Future> + Send { + let calls = Arc::clone(&self.calls); + let release = Arc::clone(&self.release); + async move { + calls.fetch_add(1, Ordering::SeqCst); + release.notified().await; + Ok(()) + } + } +} + +#[tokio::test] +async fn pending_harness_key_validation_does_not_block_new_handshakes() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let harness_connection = tokio::spawn(connect_async(websocket_url)); + let (socket, _peer_addr) = listener.accept().await?; + let environment_websocket = accept_async(socket).await?; + let (mut harness_websocket, _response) = harness_connection.await??; + + let environment_identity = SecureChannelIdentity::generate()?; + let harness_identity = SecureChannelIdentity::generate()?; + let calls = Arc::new(AtomicUsize::new(0)); + let environment_task = tokio::spawn(run_secure_multiplexed_environment( + environment_websocket, + ConnectionProcessor::new(ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ENVIRONMENT_ID.to_string(), + EXECUTOR_REGISTRATION_ID.to_string(), + environment_identity.clone(), + BlockingValidator { + calls: Arc::clone(&calls), + release: Arc::new(Notify::new()), + }, + )); + + for stream_id in ["stream-1", "stream-2"] { + let prologue = + secure_channel_prologue(ENVIRONMENT_ID, EXECUTOR_REGISTRATION_ID, stream_id)?; + let (_handshake, request) = InitiatorHandshake::start( + &harness_identity, + &environment_identity.public_key(), + &prologue, + b"authorization", + )?; + let frame = RelayMessageFrame::handshake(stream_id.to_string(), request); + harness_websocket + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await?; + } + + timeout(Duration::from_secs(1), async { + while calls.load(Ordering::SeqCst) != 2 { + tokio::task::yield_now().await; + } + }) + .await?; + + harness_websocket.close(None).await?; + timeout(Duration::from_secs(1), environment_task).await??; + Ok(()) +} diff --git a/codex-rs/exec-server/src/secure_relay/harness.rs b/codex-rs/exec-server/src/secure_relay/harness.rs new file mode 100644 index 0000000000..8c694050cd --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/harness.rs @@ -0,0 +1,402 @@ +use futures::Sink; +use futures::SinkExt; +use futures::Stream; +use futures::StreamExt; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use tracing::warn; +use uuid::Uuid; + +use crate::ExecServerError; +use crate::connection::CHANNEL_CAPACITY; +use crate::connection::JsonRpcConnection; +use crate::connection::JsonRpcConnectionEvent; +use crate::connection::JsonRpcTransport; +use crate::relay::RelayFrameBodyKind; +use crate::relay::decode_relay_message_frame; +use crate::relay::encode_relay_message_frame; +use crate::relay_proto::RelayData; +use crate::relay_proto::RelayMessageFrame; +use crate::secure_channel::InitiatorHandshake; +use crate::secure_channel::SecureChannelIdentity; +use crate::secure_channel::SecureChannelPublicKey; +use crate::secure_channel::SecureTransport; +use crate::secure_channel::secure_channel_prologue; +use crate::secure_relay::message_framing::JsonRpcMessageDecoder; +use crate::secure_relay::message_framing::SECURE_RECORD_PLAINTEXT_LEN; +use crate::secure_relay::message_framing::frame_jsonrpc_message; +use crate::secure_relay::ordered_ciphertext::OrderedCiphertextFrames; +use crate::secure_relay::take_next_sequence; + +/// Adapt one harness rendezvous websocket into an authenticated JSON-RPC connection. +/// +/// The returned connection is not usable until the background task completes +/// hybrid IK against the registry-pinned exec-server key. Rendezvous can see +/// stream metadata and ciphertext, but never JSON-RPC plaintext or either +/// endpoint's private key. +pub(crate) fn secure_harness_connection_from_websocket( + stream: T, + connection_label: String, + environment_id: String, + executor_registration_id: String, + identity: SecureChannelIdentity, + responder_public_key: SecureChannelPublicKey, + harness_key_authorization: String, +) -> JsonRpcConnection +where + T: Sink + Stream> + Unpin + Send + 'static, + E: std::fmt::Display + Send + 'static, +{ + let stream_id = Uuid::new_v4().to_string(); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (incoming_tx, incoming_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (disconnected_tx, disconnected_rx) = watch::channel(false); + + let websocket_task = tokio::spawn(async move { + let mut websocket = stream; + + // Bind the Noise transcript to the exact environment registration and + // virtual relay stream before emitting any handshake bytes. A captured + // handshake cannot be spliced onto a different routed connection. + let prologue = + match secure_channel_prologue(&environment_id, &executor_registration_id, &stream_id) { + Ok(prologue) => prologue, + Err(error) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + format!("failed to build secure relay prologue: {error}"), + ) + .await; + return; + } + }; + let (initiator_handshake, request) = match InitiatorHandshake::start( + &identity, + &responder_public_key, + &prologue, + harness_key_authorization.as_bytes(), + ) { + Ok(handshake) => handshake, + Err(error) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + format!("failed to start secure relay handshake: {error}"), + ) + .await; + return; + } + }; + + // Resume claims the stream ID at rendezvous; Handshake carries the + // opaque first IK message. No JSON-RPC data is sent before the + // responder proves possession of the pinned static key. + let resume = RelayMessageFrame::resume(stream_id.clone()); + let handshake = RelayMessageFrame::handshake(stream_id.clone(), request); + if websocket + .send(Message::Binary(encode_relay_message_frame(&resume).into())) + .await + .is_err() + || websocket + .send(Message::Binary( + encode_relay_message_frame(&handshake).into(), + )) + .await + .is_err() + { + let _ = disconnected_tx.send(true); + return; + } + + // During the handshake, ignore unrelated routed streams and control + // frames, but reject data on our stream. Accepting early data would + // create a plaintext or unauthenticated application path. + let mut transport = loop { + let Some(incoming_message) = websocket.next().await else { + send_disconnected( + &incoming_tx, + &disconnected_tx, + "secure relay websocket closed during handshake".to_string(), + ) + .await; + return; + }; + let message = match incoming_message { + Ok(Message::Binary(payload)) => payload, + Ok(Message::Close(_)) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + "secure relay websocket closed during handshake".to_string(), + ) + .await; + return; + } + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, + Ok(Message::Text(_)) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + "secure relay transport expects binary protobuf frames".to_string(), + ) + .await; + return; + } + Err(error) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + format!( + "failed to read secure relay websocket from {connection_label}: {error}" + ), + ) + .await; + return; + } + }; + let frame = match decode_relay_message_frame(message.as_ref()) { + Ok(frame) => frame, + Err(error) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + format!("failed to parse secure relay frame: {error}"), + ) + .await; + return; + } + }; + if frame.stream_id != stream_id { + continue; + } + match frame.validate() { + Ok(RelayFrameBodyKind::Handshake) => { + let response = match frame.into_handshake_payload() { + Ok(response) => response, + Err(error) => { + send_disconnected(&incoming_tx, &disconnected_tx, error.to_string()) + .await; + return; + } + }; + match initiator_handshake.finish(&response) { + Ok(transport) => break transport, + Err(error) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + format!("secure relay handshake failed: {error}"), + ) + .await; + return; + } + } + } + Ok(RelayFrameBodyKind::Reset) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + frame + .into_reset_reason() + .unwrap_or_else(|| "secure relay reset during handshake".to_string()), + ) + .await; + return; + } + Ok( + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat, + ) => {} + Ok(RelayFrameBodyKind::Data) | Err(_) => { + send_disconnected( + &incoming_tx, + &disconnected_tx, + "secure relay received data before handshake completion".to_string(), + ) + .await; + return; + } + } + }; + + // After the handshake, each relay sequence maps to exactly one Noise + // transport record. Outbound records are encrypted once; inbound + // records are reordered and deduplicated before decryption. + let mut next_outbound_seq = 0u32; + let mut inbound_ciphertexts = OrderedCiphertextFrames::default(); + let mut inbound_decoder = JsonRpcMessageDecoder::default(); + 'relay: loop { + tokio::select! { + maybe_message = outgoing_rx.recv() => { + let Some(message) = maybe_message else { + break; + }; + let framed = match frame_jsonrpc_message(&message) { + Ok(framed) => framed, + Err(error) => { + warn!("failed to frame JSON-RPC payload for secure relay: {error}"); + break; + } + }; + for plaintext_record in framed.chunks(SECURE_RECORD_PLAINTEXT_LEN) { + let seq = match take_next_sequence(&mut next_outbound_seq) { + Ok(seq) => seq, + Err(error) => { + warn!("secure relay sequence exhausted: {error}"); + break 'relay; + } + }; + let ciphertext = match transport.encrypt(plaintext_record) { + Ok(ciphertext) => ciphertext, + Err(error) => { + warn!("failed to encrypt JSON-RPC payload for secure relay: {error}"); + break 'relay; + } + }; + let frame = RelayMessageFrame::data(stream_id.clone(), seq, ciphertext); + if websocket + .send(Message::Binary(encode_relay_message_frame(&frame).into())) + .await + .is_err() + { + break 'relay; + } + } + } + incoming_message = websocket.next() => { + let Some(incoming_message) = incoming_message else { + break; + }; + match incoming_message { + Ok(Message::Binary(payload)) => { + let frame = match decode_relay_message_frame(payload.as_ref()) { + Ok(frame) => frame, + Err(error) => { + send_malformed(&incoming_tx, error.to_string()).await; + break; + } + }; + if frame.stream_id != stream_id { + continue; + } + match frame.validate() { + Ok(RelayFrameBodyKind::Data) => { + let data = match frame.into_data() { + Ok(data) => data, + Err(error) => { + send_malformed(&incoming_tx, error.to_string()).await; + break; + } + }; + if let Err(error) = receive_data( + &mut inbound_ciphertexts, + &mut transport, + &mut inbound_decoder, + data, + &incoming_tx, + ) + .await + { + send_malformed(&incoming_tx, error.to_string()).await; + break; + } + } + Ok(RelayFrameBodyKind::Reset) => { + let reason = frame.into_reset_reason(); + let _ = incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { reason }) + .await; + break; + } + Ok( + RelayFrameBodyKind::Ack + | RelayFrameBodyKind::Resume + | RelayFrameBodyKind::Heartbeat, + ) => {} + Ok(RelayFrameBodyKind::Handshake) | Err(_) => { + send_malformed( + &incoming_tx, + "secure relay received invalid post-handshake frame".to_string(), + ) + .await; + break; + } + } + } + Ok(Message::Close(_)) => break, + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => {} + Ok(Message::Text(_)) => { + send_malformed( + &incoming_tx, + "secure relay transport expects binary protobuf frames".to_string(), + ) + .await; + break; + } + Err(error) => { + debug!("secure relay websocket read failed: {error}"); + break; + } + } + } + } + } + let _ = disconnected_tx.send(true); + }); + + JsonRpcConnection { + outgoing_tx, + incoming_rx, + disconnected_rx, + task_handles: vec![websocket_task], + transport: JsonRpcTransport::External, + } +} + +async fn receive_data( + inbound_ciphertexts: &mut OrderedCiphertextFrames, + transport: &mut SecureTransport, + decoder: &mut JsonRpcMessageDecoder, + data: RelayData, + incoming_tx: &mpsc::Sender, +) -> Result<(), ExecServerError> { + // Ordering must happen before decryption because Noise transport nonces are + // implicit. A future or duplicate ciphertext passed directly to Clatter + // would desynchronize the channel. + for ciphertext in inbound_ciphertexts.push(data.seq, data.payload)? { + let plaintext = transport.decrypt(&ciphertext)?; + + // The authenticated byte stream can carry partial or multiple JSON-RPC + // messages; emit only complete, successfully parsed messages. + for message in decoder.push(&plaintext)? { + incoming_tx + .send(JsonRpcConnectionEvent::Message(message)) + .await + .map_err(|_| ExecServerError::Closed)?; + } + } + Ok(()) +} + +async fn send_malformed(incoming_tx: &mpsc::Sender, reason: String) { + let _ = incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { reason }) + .await; +} + +async fn send_disconnected( + incoming_tx: &mpsc::Sender, + disconnected_tx: &watch::Sender, + reason: String, +) { + let _ = disconnected_tx.send(true); + let _ = incoming_tx + .send(JsonRpcConnectionEvent::Disconnected { + reason: Some(reason), + }) + .await; +} diff --git a/codex-rs/exec-server/src/secure_relay/message_framing.rs b/codex-rs/exec-server/src/secure_relay/message_framing.rs new file mode 100644 index 0000000000..98e9b86596 --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/message_framing.rs @@ -0,0 +1,92 @@ +use codex_app_server_protocol::JSONRPCMessage; + +use crate::ExecServerError; + +const LENGTH_PREFIX_BYTES: usize = size_of::(); +const MAX_SECURE_JSONRPC_MESSAGE_LEN: usize = 64 * 1024 * 1024; +pub(crate) const SECURE_RECORD_PLAINTEXT_LEN: usize = 60 * 1024; + +/// Serialize one JSON-RPC message into the encrypted record byte stream. +/// +/// Clatter limits an individual Noise message to 65,535 bytes, while valid +/// exec-server responses can be much larger. A four-byte authenticated length +/// prefix lets the caller split this byte stream into bounded Noise records and +/// lets the receiver reconstruct exact JSON-RPC message boundaries. +pub(crate) fn frame_jsonrpc_message(message: &JSONRPCMessage) -> Result, ExecServerError> { + let mut framed = vec![0; LENGTH_PREFIX_BYTES]; + serde_json::to_writer(&mut framed, message)?; + let message_len = framed.len() - LENGTH_PREFIX_BYTES; + if message_len > MAX_SECURE_JSONRPC_MESSAGE_LEN { + return Err(ExecServerError::Protocol( + "secure relay JSON-RPC message exceeds maximum length".to_string(), + )); + } + framed[..LENGTH_PREFIX_BYTES].copy_from_slice(&(message_len as u32).to_be_bytes()); + Ok(framed) +} + +/// Incrementally reconstructs authenticated JSON-RPC messages from Noise records. +#[derive(Default)] +pub(crate) struct JsonRpcMessageDecoder { + buffered: Vec, +} + +impl JsonRpcMessageDecoder { + /// Append one decrypted record and return all complete framed messages. + pub(crate) fn push( + &mut self, + plaintext_record: &[u8], + ) -> Result, ExecServerError> { + if plaintext_record.len() > SECURE_RECORD_PLAINTEXT_LEN { + return Err(ExecServerError::Protocol( + "secure relay plaintext record exceeds maximum length".to_string(), + )); + } + self.buffered.extend_from_slice(plaintext_record); + + // One record can finish multiple messages, and one message can span + // multiple records. Parse only after the authenticated length prefix + // and the full declared payload are present. + let mut messages = Vec::new(); + loop { + let Some(message_len) = self.next_message_len()? else { + break; + }; + let framed_len = LENGTH_PREFIX_BYTES + message_len; + if self.buffered.len() < framed_len { + break; + } + messages.push(serde_json::from_slice( + &self.buffered[LENGTH_PREFIX_BYTES..framed_len], + )?); + self.buffered.drain(..framed_len); + } + + // Even before a message is complete, keep reassembly memory bounded. + if self.buffered.len() > LENGTH_PREFIX_BYTES + MAX_SECURE_JSONRPC_MESSAGE_LEN { + return Err(ExecServerError::Protocol( + "secure relay JSON-RPC reassembly buffer exceeds maximum length".to_string(), + )); + } + Ok(messages) + } + + fn next_message_len(&self) -> Result, ExecServerError> { + let Some(prefix) = self.buffered.get(..LENGTH_PREFIX_BYTES) else { + return Ok(None); + }; + let message_len = u32::from_be_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]) as usize; + // Zero-length is never a JSON-RPC message, and rejecting an oversized + // declaration immediately avoids waiting for attacker-controlled data. + if message_len == 0 || message_len > MAX_SECURE_JSONRPC_MESSAGE_LEN { + return Err(ExecServerError::Protocol( + "secure relay JSON-RPC message has invalid length".to_string(), + )); + } + Ok(Some(message_len)) + } +} + +#[cfg(test)] +#[path = "message_framing_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/secure_relay/message_framing_tests.rs b/codex-rs/exec-server/src/secure_relay/message_framing_tests.rs new file mode 100644 index 0000000000..a94c67f8d3 --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/message_framing_tests.rs @@ -0,0 +1,52 @@ +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use pretty_assertions::assert_eq; + +use super::JsonRpcMessageDecoder; +use super::MAX_SECURE_JSONRPC_MESSAGE_LEN; +use super::SECURE_RECORD_PLAINTEXT_LEN; +use super::frame_jsonrpc_message; +use crate::ExecServerError; + +#[test] +fn fragments_and_reassembles_large_jsonrpc_message() { + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "large/test".to_string(), + params: Some(serde_json::json!({ + "data": "x".repeat(128 * 1024), + })), + }); + let framed = frame_jsonrpc_message(&message).unwrap(); + assert!(framed.len() > 128 * 1024); + + let mut decoder = JsonRpcMessageDecoder::default(); + let mut decoded = Vec::new(); + for record in framed.chunks(SECURE_RECORD_PLAINTEXT_LEN) { + decoded.extend(decoder.push(record).unwrap()); + } + + assert_eq!(decoded, vec![message]); +} + +#[test] +fn rejects_declared_message_length_above_limit_without_payload() { + let mut decoder = JsonRpcMessageDecoder::default(); + let declared_len = (MAX_SECURE_JSONRPC_MESSAGE_LEN as u32 + 1).to_be_bytes(); + + assert!(matches!( + decoder.push(&declared_len), + Err(ExecServerError::Protocol(message)) + if message == "secure relay JSON-RPC message has invalid length" + )); +} + +#[test] +fn rejects_oversized_plaintext_record() { + let mut decoder = JsonRpcMessageDecoder::default(); + + assert!(matches!( + decoder.push(&vec![0; SECURE_RECORD_PLAINTEXT_LEN + 1]), + Err(ExecServerError::Protocol(message)) + if message == "secure relay plaintext record exceeds maximum length" + )); +} diff --git a/codex-rs/exec-server/src/secure_relay/mod.rs b/codex-rs/exec-server/src/secure_relay/mod.rs new file mode 100644 index 0000000000..d2b896ac06 --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/mod.rs @@ -0,0 +1,33 @@ +mod environment; +mod harness; +mod message_framing; +mod ordered_ciphertext; + +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; + +use crate::ExecServerError; + +pub(crate) use environment::HarnessKeyValidator; +pub(crate) use environment::run_secure_multiplexed_environment; +pub(crate) use harness::secure_harness_connection_from_websocket; + +// This bounds allocation in tungstenite before protobuf and secure-record +// validation run. It comfortably fits one maximum Noise record plus metadata. +const MAX_SECURE_RELAY_WEBSOCKET_MESSAGE_SIZE: usize = 256 * 1024; + +/// Return the websocket limits required by every secure relay endpoint. +pub(crate) fn secure_relay_websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_frame_size(Some(MAX_SECURE_RELAY_WEBSOCKET_MESSAGE_SIZE)) + .max_message_size(Some(MAX_SECURE_RELAY_WEBSOCKET_MESSAGE_SIZE)) +} + +fn take_next_sequence(next_seq: &mut u32) -> Result { + // Never wrap: relay sequence is the explicit ordering key for an implicit + // Noise nonce. Reusing zero after u32::MAX would be ambiguous and unsafe. + let seq = *next_seq; + *next_seq = next_seq.checked_add(1).ok_or_else(|| { + ExecServerError::Protocol("secure relay sequence number exhausted".to_string()) + })?; + Ok(seq) +} diff --git a/codex-rs/exec-server/src/secure_relay/ordered_ciphertext.rs b/codex-rs/exec-server/src/secure_relay/ordered_ciphertext.rs new file mode 100644 index 0000000000..57fc957991 --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/ordered_ciphertext.rs @@ -0,0 +1,85 @@ +use std::collections::BTreeMap; + +use crate::ExecServerError; + +const MAX_REORDER_DISTANCE: u32 = 64; +const MAX_PENDING_FRAMES: usize = 64; +const MAX_PENDING_BYTES: usize = 1024 * 1024; + +/// Bounded pre-decryption reorder buffer for Noise transport records. +/// +/// Relay delivery can be duplicated or reordered, but Noise transport nonces +/// are strictly ordered. This type absorbs only a small reliable-delivery +/// window and releases ciphertexts exactly once in nonce order. It must sit +/// before `SecureTransport::decrypt`; attempting to decrypt a future record +/// would advance or desynchronize cryptographic state. +#[derive(Default)] +pub(crate) struct OrderedCiphertextFrames { + next_seq: u32, + pending: BTreeMap>, + pending_bytes: usize, +} + +impl OrderedCiphertextFrames { + /// Accept one relay record and return the newly contiguous ciphertext run. + pub(crate) fn push( + &mut self, + seq: u32, + payload: Vec, + ) -> Result>, ExecServerError> { + // Already-delivered and already-buffered frames are retries. Keep the + // first buffered ciphertext for a sequence so a duplicate cannot + // replace it before authentication. + if seq < self.next_seq || self.pending.contains_key(&seq) { + return Ok(Vec::new()); + } + if seq > self.next_seq { + // Bound both sequence distance and actual buffered memory. Without + // both limits, an authenticated peer could hold the stream open + // while forcing unbounded pre-decryption state. + if seq - self.next_seq > MAX_REORDER_DISTANCE { + return Err(ExecServerError::Protocol( + "secure relay ciphertext exceeds reorder window".to_string(), + )); + } + let pending_bytes = self + .pending_bytes + .checked_add(payload.len()) + .ok_or_else(|| { + ExecServerError::Protocol( + "secure relay pending ciphertext byte count overflowed".to_string(), + ) + })?; + if self.pending.len() >= MAX_PENDING_FRAMES || pending_bytes > MAX_PENDING_BYTES { + return Err(ExecServerError::Protocol( + "secure relay pending ciphertext buffer is full".to_string(), + )); + } + self.pending.insert(seq, payload); + self.pending_bytes = pending_bytes; + return Ok(Vec::new()); + } + + // The expected record closes the current gap. Release it and every + // contiguous buffered successor so Noise sees exactly nonce order. + let mut ready = vec![payload]; + self.advance()?; + while let Some(payload) = self.pending.remove(&self.next_seq) { + self.pending_bytes -= payload.len(); + ready.push(payload); + self.advance()?; + } + Ok(ready) + } + + fn advance(&mut self) -> Result<(), ExecServerError> { + self.next_seq = self.next_seq.checked_add(1).ok_or_else(|| { + ExecServerError::Protocol("secure relay sequence number exhausted".to_string()) + })?; + Ok(()) + } +} + +#[cfg(test)] +#[path = "ordered_ciphertext_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/secure_relay/ordered_ciphertext_tests.rs b/codex-rs/exec-server/src/secure_relay/ordered_ciphertext_tests.rs new file mode 100644 index 0000000000..97f2c1a9ba --- /dev/null +++ b/codex-rs/exec-server/src/secure_relay/ordered_ciphertext_tests.rs @@ -0,0 +1,48 @@ +use pretty_assertions::assert_eq; + +use super::MAX_PENDING_BYTES; +use super::OrderedCiphertextFrames; + +#[test] +fn releases_ciphertexts_only_in_nonce_order() { + let mut frames = OrderedCiphertextFrames::default(); + + assert_eq!( + frames.push(1, b"second".to_vec()).unwrap(), + Vec::>::new() + ); + assert_eq!( + frames.push(0, b"first".to_vec()).unwrap(), + vec![b"first".to_vec(), b"second".to_vec()] + ); +} + +#[test] +fn ignores_duplicate_ciphertexts_without_replacing_buffered_record() { + let mut frames = OrderedCiphertextFrames::default(); + + assert_eq!( + frames.push(1, b"first copy".to_vec()).unwrap(), + Vec::>::new() + ); + assert_eq!( + frames.push(1, b"replacement".to_vec()).unwrap(), + Vec::>::new() + ); + assert_eq!( + frames.push(0, b"zero".to_vec()).unwrap(), + vec![b"zero".to_vec(), b"first copy".to_vec()] + ); + assert_eq!( + frames.push(0, b"duplicate".to_vec()).unwrap(), + Vec::>::new() + ); +} + +#[test] +fn rejects_unbounded_reordering() { + let mut frames = OrderedCiphertextFrames::default(); + + assert!(frames.push(65, Vec::new()).is_err()); + assert!(frames.push(1, vec![0; MAX_PENDING_BYTES + 1]).is_err()); +} diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index ff41dd525e..fb04dd9de3 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -4,40 +4,49 @@ mod common; mod relay_proto; use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Context; use anyhow::Result; -use anyhow::anyhow; -use anyhow::bail; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; use codex_api::AuthProvider; -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCRequest; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecResponse; +use codex_exec_server::ExecServerClient; +use codex_exec_server::ExecServerError; use codex_exec_server::ExecServerRuntimePaths; -use codex_exec_server::InitializeParams; -use codex_exec_server::InitializeResponse; +use codex_exec_server::FsReadFileParams; +use codex_exec_server::ProcessId; use codex_exec_server::RemoteEnvironmentConfig; +use codex_exec_server::SecureChannelIdentity; +use codex_exec_server::SecureChannelPublicKey; +use codex_exec_server::SecureRendezvousConnectArgs; +use codex_exec_server::SecureRendezvousConnectBundle; +use codex_exec_server::SecureRendezvousConnectProvider; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::FutureExt; use futures::SinkExt; use futures::StreamExt; +use futures::future::BoxFuture; use http::HeaderMap; use http::HeaderValue; use pretty_assertions::assert_eq; use prost::Message as ProstMessage; -use relay_proto::RelayData; use relay_proto::RelayMessageFrame; -use relay_proto::RelayReset; use relay_proto::relay_message_frame; -use std::sync::Arc; +use tempfile::TempDir; use tokio::net::TcpListener; +use tokio::net::TcpStream; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -45,10 +54,11 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const ENVIRONMENT_ID: &str = "env-mux-test"; +const ENVIRONMENT_ID: &str = "env-secure-relay-test"; +const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; +const HARNESS_KEY_AUTHORIZATION: &str = "harness-key-authorization"; const REGISTRY_TOKEN: &str = "registry-token"; -const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; -const TEST_TIMEOUT: Duration = Duration::from_secs(5); +const TEST_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug)] struct StaticRegistryAuthProvider; @@ -62,12 +72,135 @@ impl AuthProvider for StaticRegistryAuthProvider { } } +struct FailingSecureConnectProvider { + attempts: Arc, +} + +impl SecureRendezvousConnectProvider for FailingSecureConnectProvider { + fn environment_id(&self) -> &str { + ENVIRONMENT_ID + } + + fn connect_args(&self) -> BoxFuture<'_, Result> { + self.attempts.fetch_add(1, Ordering::SeqCst); + async { + Err(ExecServerError::Protocol( + "test registry connect failure".to_string(), + )) + } + .boxed() + } +} + +struct WrongEnvironmentSecureConnectProvider { + harness_identity: SecureChannelIdentity, + executor_public_key: SecureChannelPublicKey, +} + +impl SecureRendezvousConnectProvider for WrongEnvironmentSecureConnectProvider { + fn environment_id(&self) -> &str { + ENVIRONMENT_ID + } + + fn connect_args(&self) -> BoxFuture<'_, Result> { + async move { + Ok(SecureRendezvousConnectArgs::new( + SecureRendezvousConnectBundle { + websocket_url: "ws://127.0.0.1:1".to_string(), + environment_id: "wrong-environment".to_string(), + executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(), + executor_public_key: self.executor_public_key.clone(), + harness_key_authorization: HARNESS_KEY_AUTHORIZATION.to_string(), + }, + self.harness_identity.clone(), + "secure-relay-test".to_string(), + )) + } + .boxed() + } +} + fn static_registry_auth_provider() -> codex_api::SharedAuthProvider { Arc::new(StaticRegistryAuthProvider) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multiplexed_remote_environment_routes_independent_virtual_streams() -> Result<()> { +#[tokio::test] +async fn secure_environment_refreshes_bundle_for_each_connection_attempt() -> Result<()> { + let attempts = Arc::new(AtomicUsize::new(0)); + let manager = EnvironmentManager::without_environments(); + manager.upsert_secure_environment( + ENVIRONMENT_ID.to_string(), + Arc::new(FailingSecureConnectProvider { + attempts: Arc::clone(&attempts), + }), + )?; + let backend = manager + .get_environment(ENVIRONMENT_ID) + .context("secure environment should be materialized")? + .get_exec_backend(); + + for attempt in 1..=2 { + let result = backend + .start(ExecParams { + process_id: ProcessId::new(format!("proc-{attempt}")), + argv: vec!["true".to_string()], + cwd: std::env::current_dir()?, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await; + assert!(matches!( + result, + Err(ExecServerError::Protocol(ref message)) + if message == "test registry connect failure" + )); + } + + assert_eq!(attempts.load(Ordering::SeqCst), 2); + Ok(()) +} + +#[tokio::test] +async fn secure_environment_rejects_provider_bundle_for_another_environment() -> Result<()> { + let manager = EnvironmentManager::without_environments(); + manager.upsert_secure_environment( + ENVIRONMENT_ID.to_string(), + Arc::new(WrongEnvironmentSecureConnectProvider { + harness_identity: SecureChannelIdentity::generate()?, + executor_public_key: SecureChannelIdentity::generate()?.public_key(), + }), + )?; + let backend = manager + .get_environment(ENVIRONMENT_ID) + .context("secure environment should be materialized")? + .get_exec_backend(); + + let result = backend + .start(ExecParams { + process_id: ProcessId::from("proc-wrong-environment"), + argv: vec!["true".to_string()], + cwd: std::env::current_dir()?, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await; + + assert!(matches!( + result, + Err(ExecServerError::Protocol(ref message)) + if message == "secure rendezvous provider returned a different environment id" + )); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let rendezvous_url = format!("ws://{}", listener.local_addr()?); let registry = MockServer::start().await; @@ -78,10 +211,20 @@ async fn multiplexed_remote_environment_routes_independent_virtual_streams() -> .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "environment_id": ENVIRONMENT_ID, - "url": rendezvous_url, + "url": format!("{rendezvous_url}/relay?role=environment"), + "security_profile": "noise_hybrid_ik_v1", + "executor_registration_id": EXECUTOR_REGISTRATION_ID, }))) .mount(®istry) .await; + Mock::given(method("POST")) + .and(path(format!( + "/cloud/environment/{ENVIRONMENT_ID}/validate-harness-key" + ))) + .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(®istry) + .await; let (codex_exe, codex_linux_sandbox_exe) = common::current_test_binary_helper_paths()?; let runtime_paths = ExecServerRuntimePaths::new(codex_exe, codex_linux_sandbox_exe)?; @@ -95,281 +238,160 @@ async fn multiplexed_remote_environment_routes_independent_virtual_streams() -> runtime_paths, )); - let (socket, _peer_addr) = timeout(TEST_TIMEOUT, listener.accept()) + let environment_websocket = accept_websocket(&listener, "environment").await?; + let executor_public_key = registered_executor_public_key(®istry).await?; + let harness_identity = SecureChannelIdentity::generate()?; + let client_args = SecureRendezvousConnectArgs::new( + SecureRendezvousConnectBundle { + websocket_url: format!("{rendezvous_url}/relay?role=harness"), + environment_id: ENVIRONMENT_ID.to_string(), + executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(), + executor_public_key, + harness_key_authorization: HARNESS_KEY_AUTHORIZATION.to_string(), + }, + harness_identity, + "secure-relay-test".to_string(), + ); + let client_task = + tokio::spawn(async move { ExecServerClient::connect_secure_rendezvous(client_args).await }); + let harness_websocket = accept_websocket(&listener, "harness").await?; + let captured_frames = Arc::new(Mutex::new(Vec::new())); + let relay_task = tokio::spawn(proxy_relay_frames( + environment_websocket, + harness_websocket, + Arc::clone(&captured_frames), + )); + let client = timeout(TEST_TIMEOUT, client_task) .await - .context("remote environment should connect to fake rendezvous")??; - let mut websocket = timeout(TEST_TIMEOUT, accept_async(socket)) - .await - .context("fake rendezvous should accept environment websocket")??; + .context("secure harness client should connect")???; - let stream_a = "stream-a"; - let stream_b = "stream-b"; - send_relay_message( - &mut websocket, - stream_a, - /*seq*/ 0, - initialize_request(/*id*/ 1, "relay-test-a")?, - ) - .await?; - send_relay_message( - &mut websocket, - stream_b, - /*seq*/ 0, - initialize_request(/*id*/ 1, "relay-test-b")?, - ) - .await?; + let response = client + .exec(ExecParams { + process_id: ProcessId::from("proc-1"), + argv: vec!["true".to_string()], + cwd: std::env::current_dir()?, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await?; + assert_eq!( + response, + ExecResponse { + process_id: ProcessId::from("proc-1") + } + ); - let initialize_responses = read_relay_messages_by_stream(&mut websocket, /*count*/ 2).await?; - let session_a = - assert_initialize_response(initialize_responses.get(stream_a), stream_a, /*id*/ 1)?; - let session_b = - assert_initialize_response(initialize_responses.get(stream_b), stream_b, /*id*/ 1)?; - assert_ne!(session_a, session_b); + let temp_dir = TempDir::new()?; + let large_file_path = temp_dir.path().join("large-response.bin"); + let large_file_contents = vec![0x5a; 128 * 1024]; + std::fs::write(&large_file_path, &large_file_contents)?; + let read_response = client + .fs_read_file(FsReadFileParams { + path: AbsolutePathBuf::try_from(large_file_path)?, + sandbox: None, + }) + .await?; + assert_eq!( + STANDARD.decode(read_response.data_base64)?, + large_file_contents + ); - send_relay_message( - &mut websocket, - stream_a, - /*seq*/ 1, - notification("initialized", serde_json::json!({})), - ) - .await?; - send_relay_message( - &mut websocket, - stream_b, - /*seq*/ 1, - notification("initialized", serde_json::json!({})), - ) - .await?; + assert_relay_data_is_encrypted(&captured_frames)?; - send_relay_message( - &mut websocket, - stream_a, - /*seq*/ 2, - request(/*id*/ 2, "test/unknown-a", serde_json::json!({})), - ) - .await?; - send_relay_message( - &mut websocket, - stream_b, - /*seq*/ 2, - request(/*id*/ 2, "test/unknown-b", serde_json::json!({})), - ) - .await?; - - let unknown_method_responses = - read_relay_messages_by_stream(&mut websocket, /*count*/ 2).await?; - assert_error_response( - unknown_method_responses.get(stream_a), - stream_a, - /*id*/ 2, - "test/unknown-a", - )?; - assert_error_response( - unknown_method_responses.get(stream_b), - stream_b, - /*id*/ 2, - "test/unknown-b", - )?; - - send_relay_reset(&mut websocket, stream_a, "test_reset").await?; - send_relay_message( - &mut websocket, - stream_b, - /*seq*/ 3, - request( - /*id*/ 3, - "test/unknown-b-after-reset", - serde_json::json!({}), - ), - ) - .await?; - - let (stream_id, message) = read_relay_message(&mut websocket).await?; - assert_eq!(stream_id, stream_b); - assert_error_response( - Some(&message), - stream_b, - /*id*/ 3, - "test/unknown-b-after-reset", - )?; - - websocket.close(None).await?; + drop(client); + relay_task.abort(); remote_environment.abort(); + let _ = relay_task.await; let _ = remote_environment.await; Ok(()) } -async fn send_relay_message( - websocket: &mut WebSocketStream, - stream_id: &str, - seq: u32, - message: JSONRPCMessage, -) -> Result<()> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - let payload = serde_json::to_vec(&message)?; - let frame = RelayMessageFrame { - version: RELAY_MESSAGE_FRAME_VERSION, - stream_id: stream_id.to_string(), - ack: 0, - ack_bits: 0, - body: Some(relay_message_frame::Body::Data(RelayData { - seq, - segment_index: 0, - segment_count: 1, - payload, - })), - }; - send_relay_frame(websocket, frame).await +async fn accept_websocket( + listener: &TcpListener, + role: &str, +) -> Result> { + let (socket, _peer_addr) = timeout(TEST_TIMEOUT, listener.accept()) + .await + .with_context(|| format!("remote {role} should connect to fake rendezvous"))??; + timeout(TEST_TIMEOUT, accept_async(socket)) + .await + .with_context(|| format!("fake rendezvous should accept {role} websocket"))? + .map_err(Into::into) } -async fn send_relay_reset( - websocket: &mut WebSocketStream, - stream_id: &str, - reason: &str, -) -> Result<()> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - send_relay_frame( - websocket, - RelayMessageFrame { - version: RELAY_MESSAGE_FRAME_VERSION, - stream_id: stream_id.to_string(), - ack: 0, - ack_bits: 0, - body: Some(relay_message_frame::Body::Reset(RelayReset { - reason: reason.to_string(), - })), - }, - ) - .await +async fn registered_executor_public_key(registry: &MockServer) -> Result { + let requests = registry + .received_requests() + .await + .context("wiremock should retain requests")?; + let request = requests + .iter() + .find(|request| request.url.path().ends_with("/register")) + .context("exec-server should register before connecting")?; + let body: serde_json::Value = serde_json::from_slice(&request.body)?; + let key = serde_json::from_value(body["executor_public_key"].clone())?; + Ok(key) } -async fn send_relay_frame( - websocket: &mut WebSocketStream, - frame: RelayMessageFrame, -) -> Result<()> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - websocket - .send(Message::Binary(frame.encode_to_vec().into())) - .await?; +async fn proxy_relay_frames( + mut environment: WebSocketStream, + mut harness: WebSocketStream, + captured_frames: Arc>>>, +) -> Result<()> { + loop { + tokio::select! { + message = environment.next() => { + let Some(message) = message else { + break; + }; + let message = message?; + capture_binary_frame(&captured_frames, &message); + harness.send(message).await?; + } + message = harness.next() => { + let Some(message) = message else { + break; + }; + let message = message?; + capture_binary_frame(&captured_frames, &message); + environment.send(message).await?; + } + } + } Ok(()) } -async fn read_relay_messages_by_stream( - websocket: &mut WebSocketStream, - count: usize, -) -> Result> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - let mut messages = HashMap::new(); - for _ in 0..count { - let (stream_id, message) = read_relay_message(websocket).await?; - if messages.insert(stream_id.clone(), message).is_some() { - bail!("received duplicate response for stream {stream_id}"); - } - } - Ok(messages) -} - -async fn read_relay_message( - websocket: &mut WebSocketStream, -) -> Result<(String, JSONRPCMessage)> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - loop { - let frame = timeout(TEST_TIMEOUT, websocket.next()) - .await - .context("timed out waiting for relay frame")? - .ok_or_else(|| anyhow!("environment websocket closed"))??; - match frame { - Message::Binary(bytes) => { - let frame = RelayMessageFrame::decode(bytes.as_ref())?; - let stream_id = frame.stream_id; - let Some(relay_message_frame::Body::Data(data)) = frame.body else { - continue; - }; - let message = serde_json::from_slice(&data.payload)?; - return Ok((stream_id, message)); - } - Message::Ping(_) | Message::Pong(_) => {} - Message::Close(_) => bail!("environment websocket closed"), - Message::Text(_) => bail!("environment sent text frame on relay websocket"), - Message::Frame(_) => {} - } +fn capture_binary_frame(captured_frames: &Mutex>>, message: &Message) { + if let Message::Binary(bytes) = message { + captured_frames + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bytes.to_vec()); } } -fn initialize_request(id: i64, client_name: &str) -> Result { - Ok(request( - id, - "initialize", - serde_json::to_value(InitializeParams { - client_name: client_name.to_string(), - resume_session_id: None, - })?, - )) -} - -fn request(id: i64, method: &str, params: serde_json::Value) -> JSONRPCMessage { - JSONRPCMessage::Request(JSONRPCRequest { - id: RequestId::Integer(id), - method: method.to_string(), - params: Some(params), - trace: None, - }) -} - -fn notification(method: &str, params: serde_json::Value) -> JSONRPCMessage { - JSONRPCMessage::Notification(JSONRPCNotification { - method: method.to_string(), - params: Some(params), - }) -} - -fn assert_initialize_response( - message: Option<&JSONRPCMessage>, - stream_id: &str, - id: i64, -) -> Result { - let message = message.ok_or_else(|| anyhow!("missing initialize response for {stream_id}"))?; - let JSONRPCMessage::Response(JSONRPCResponse { - id: response_id, - result, - }) = message - else { - bail!("expected initialize response for {stream_id}, got {message:?}"); - }; - assert_eq!(response_id, &RequestId::Integer(id)); - let response: InitializeResponse = serde_json::from_value(result.clone())?; - Ok(Uuid::parse_str(&response.session_id)?) -} - -fn assert_error_response( - message: Option<&JSONRPCMessage>, - stream_id: &str, - id: i64, - expected_method: &str, -) -> Result<()> { - let message = message.ok_or_else(|| anyhow!("missing error response for {stream_id}"))?; - let JSONRPCMessage::Error(JSONRPCError { - id: response_id, - error, - }) = message - else { - bail!("expected error response for {stream_id}, got {message:?}"); - }; - assert_eq!(response_id, &RequestId::Integer(id)); +fn assert_relay_data_is_encrypted(captured_frames: &Mutex>>) -> Result<()> { + let captured_frames = captured_frames + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut data_frames = 0; + for encoded in captured_frames.iter() { + let frame = RelayMessageFrame::decode(encoded.as_slice())?; + let Some(relay_message_frame::Body::Data(data)) = frame.body else { + continue; + }; + data_frames += 1; + let payload = String::from_utf8_lossy(&data.payload); + assert!(!payload.contains("initialize")); + assert!(!payload.contains("process/start")); + assert!(!payload.contains("secure-relay-test")); + } assert!( - error.message.contains(expected_method), - "expected error for {stream_id} to mention {expected_method}, got {}", - error.message + data_frames >= 4, + "expected encrypted request and response frames" ); Ok(()) }