diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc3879d1..226efc89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,8 @@ env: SING_BOX_SHA256: 12cb2816b52febb356f6a885b740cc8758c3f30b8ae0ca8edba80f0d2d35343f MIHOMO_VERSION: 1.19.31 MIHOMO_SHA256: 04cf9f09671704f839ddbee2e93069dc831a4123a75281e725d1d96ab9ac1afc + XRAY_VERSION: 26.3.27 + XRAY_SHA256: 23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae jobs: unit-tests: @@ -139,7 +141,7 @@ jobs: key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - name: Install sing-box test peer - if: matrix.peer == 'sing-box' + if: matrix.peer == 'sing-box' || matrix.target == 'optional_peer' run: | archive="$RUNNER_TEMP/sing-box.tar.gz" curl --fail --location --retry 3 \ @@ -164,6 +166,19 @@ jobs: chmod +x "$binary" echo "SHOES_TEST_MIHOMO_BIN=$binary" >> "$GITHUB_ENV" + - name: Install Xray test peer + if: matrix.target == 'optional_peer' + run: | + archive="$RUNNER_TEMP/xray.zip" + curl --fail --location --retry 3 \ + --output "$archive" \ + "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VERSION}/Xray-linux-64.zip" + echo "${XRAY_SHA256} ${archive}" | sha256sum --check --strict + unzip "$archive" xray -d "$RUNNER_TEMP/xray-peer" + binary="$RUNNER_TEMP/xray-peer/xray" + chmod +x "$binary" + echo "SHOES_TEST_XRAY_BIN=$binary" >> "$GITHUB_ENV" + - name: Run sing-box mux lifecycle tests if: matrix.name == 'local protocols' run: >- diff --git a/CONFIG.md b/CONFIG.md index 8c4ec1a2..8744f759 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -82,6 +82,7 @@ quic_settings: client_ca_certs: [string] # Optional client CA certificates client_fingerprints: [string] # Optional client certificate fingerprints num_endpoints: int # Optional, 0 = auto (based on thread count) + key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below # Routing rules (default: allow-all-direct) rules: string | [RuleConfig] @@ -189,6 +190,7 @@ protocol: client_ca_certs: [string] # Optional client CA certs client_fingerprints: [string] # Optional client cert fingerprints vision: false # Enable Vision (requires VLESS inner protocol) + key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below protocol: ServerProxyConfig override_rules: [RuleConfig] # Optional rule override @@ -369,6 +371,7 @@ quic_settings: alpn_protocols: [string] cert: string # Client certificate for mTLS key: string # Client key for mTLS + key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below ``` ## Client Protocols @@ -479,9 +482,56 @@ protocol: cert: string # Client certificate for mTLS key: string # Client key for mTLS vision: false # Enable Vision (requires VLESS inner protocol) + key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below protocol: ClientProxyConfig ``` +### TLS Key Exchange + +`key_exchange_groups` is an optional, ordered allowlist for ordinary TLS clients, +TLS server targets (including `default_tls_target`), and client/server +`quic_settings`. It applies to that TLS layer, regardless of the inner proxy +protocol. Supported names are case-sensitive: + +- `X25519MLKEM768`: native rustls X25519 + ML-KEM-768 hybrid. +- `SecP256r1MLKEM768`: native rustls P-256 + ML-KEM-768 hybrid. +- `X25519`, `SecP256r1`, `SecP384r1`: classical groups. + +Omitting the field preserves rustls defaults: currently X25519+ML-KEM-768 is +preferred, with classical fallback. An explicit list replaces those defaults; +the first client group normally supplies the initial key share. Empty lists, duplicates, +null, unknown names, standalone ML-KEM, and `SecP384r1MLKEM1024` are rejected. +No custom cryptographic implementations are used. + +Client QUIC allowlists require a non-direct client at the first chain hop +(hop 0), where Shoes creates the QUIC transport. Explicit allowlists on direct +clients or later hops are rejected, including entries resolved from groups or +pools. Use ordinary TLS in a later hop's `protocol` when that hop needs its own +hybrid-protected tunnel. + +To require hybrid exchange without classical fallback: + +```yaml +key_exchange_groups: [X25519MLKEM768, SecP256r1MLKEM768] +``` + +Hybrid-only lists force TLS 1.3 and disable early application data. TLS 1.3 +resumption remains enabled and still performs a fresh exchange using an allowed +hybrid group. Peers offering only classical groups or TLS 1.2 cannot connect. +For opportunistic hybrid exchange with explicit classical fallback, use a mixed +list such as `[X25519MLKEM768, X25519, SecP256r1]`. + +Hybrid-only lists cannot be combined with `vision: true` on the same TLS layer: +Vision direct copy bypasses that layer's encryption. They also cannot be +combined with TUIC `zero_rtt_handshake: true`. Ordinary outer TLS or QUIC can +still protect a separate inner Vision connection. The setting does not apply to +REALITY, VLESS encryption, or ShadowTLS camouflage handshakes; it is rejected on +ShadowTLS configurations and with deprecated `shadowtls_password`. + +Hybrid key exchange protects session-key establishment, not certificate +signatures. Certificate verification, fingerprints, and mTLS work as before. +`verify: false` without a trusted fingerprint still permits active impersonation. + ### Reality Client ```yaml protocol: diff --git a/src/config/types/client.rs b/src/config/types/client.rs index 39b1b89e..3811f954 100644 --- a/src/config/types/client.rs +++ b/src/config/types/client.rs @@ -194,6 +194,8 @@ where #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct TlsClientConfigTemp { + #[serde(default)] + key_exchange_groups: super::tls::TlsKeyExchangeGroups, #[serde(default = "default_true")] verify: bool, #[serde(alias = "server_fingerprint", default)] @@ -228,6 +230,11 @@ where // Check if deprecated shadowtls_password was used if let Some(password) = temp.shadowtls_password { + if !temp.key_exchange_groups.is_default() { + return Err(Error::custom( + "key_exchange_groups requires ordinary TLS, not deprecated shadowtls_password", + )); + } log::warn!( "The 'shadowtls_password' field in TLS client configuration is deprecated. \ Please use 'type: shadowtls' with 'password' field instead. \ @@ -236,6 +243,7 @@ where // Transform to ShadowTLS variant internally by wrapping protocol return Ok(TlsClientConfig { + key_exchange_groups: temp.key_exchange_groups, verify: temp.verify, server_fingerprints: temp.server_fingerprints, sni_hostname: temp.sni_hostname.clone(), @@ -254,6 +262,7 @@ where // Normal case - no shadowtls_password Ok(TlsClientConfig { + key_exchange_groups: temp.key_exchange_groups, verify: temp.verify, server_fingerprints: temp.server_fingerprints, sni_hostname: temp.sni_hostname, @@ -274,6 +283,24 @@ where deserialize_tls_client_config(deserializer) } +fn deserialize_shadowtls_variant<'de, D>( + deserializer: D, +) -> Result<(String, Option, Box), D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ShadowTlsFields { + password: String, + #[serde(default)] + sni_hostname: Option, + protocol: Box, + } + let fields = ShadowTlsFields::deserialize(deserializer)?; + Ok((fields.password, fields.sni_hostname, fields.protocol)) +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ClientConfig { @@ -306,6 +333,24 @@ impl Default for ClientConfig { } } +impl ClientConfig { + pub fn validate_quic_key_exchange(&self, initial_hop: bool) -> std::io::Result<()> { + let explicit_groups = self + .quic_settings + .as_ref() + .is_some_and(|settings| !settings.key_exchange_groups.is_default()); + if explicit_groups + && (!initial_hop || self.protocol.is_direct() || self.transport != Transport::Quic) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "QUIC key_exchange_groups requires a non-direct client at chain hop 0 with transport: quic", + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ClientProxyConfig { @@ -384,7 +429,10 @@ pub enum ClientProxyConfig { protocol: Box, }, - #[serde(alias = "shadowtls")] + #[serde( + alias = "shadowtls", + deserialize_with = "deserialize_shadowtls_variant" + )] ShadowTls { /// ShadowTLS password for authentication password: String, @@ -466,6 +514,11 @@ impl ClientProxyConfig { #[derive(Debug, Clone, Serialize)] pub struct TlsClientConfig { + #[serde( + default, + skip_serializing_if = "super::tls::TlsKeyExchangeGroups::is_default" + )] + pub key_exchange_groups: super::tls::TlsKeyExchangeGroups, #[serde(default = "default_true", skip_serializing_if = "is_true")] pub verify: bool, #[serde( diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 7e4fccc5..0967ddb6 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -22,6 +22,7 @@ pub mod rules; pub mod selection; pub mod server; pub mod shadowsocks; +pub mod tls; pub mod transport; pub mod tun; @@ -42,5 +43,6 @@ pub use server::{ direct_allow_rule, }; pub use shadowsocks::ShadowsocksConfig; +pub use tls::TlsKeyExchangeGroups; pub use transport::{BindLocation, ClientQuicConfig, ServerQuicConfig, TcpConfig, Transport}; pub use tun::TunConfig; diff --git a/src/config/types/server.rs b/src/config/types/server.rs index 25cadb72..3f7696a1 100644 --- a/src/config/types/server.rs +++ b/src/config/types/server.rs @@ -348,6 +348,11 @@ pub struct RealityServerConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TlsServerConfig { + #[serde( + default, + skip_serializing_if = "super::tls::TlsKeyExchangeGroups::is_default" + )] + pub key_exchange_groups: super::tls::TlsKeyExchangeGroups, pub cert: String, pub key: String, #[serde(alias = "alpn_protocol", default)] @@ -389,6 +394,7 @@ pub struct TlsServerConfig { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ShadowTlsServerConfig { pub password: String, pub handshake: ShadowTlsServerHandshakeConfig, @@ -903,6 +909,7 @@ mod tests { transport: Transport::Quic, tcp_settings: None, quic_settings: Some(ServerQuicConfig { + key_exchange_groups: Default::default(), cert: "server.crt".to_string(), key: "server.key".to_string(), alpn_protocols: NoneOrSome::Some(vec!["h3".to_string()]), @@ -942,6 +949,7 @@ mod tests { tls_targets.insert( "example.com".to_string(), TlsServerConfig { + key_exchange_groups: Default::default(), cert: "example.crt".to_string(), key: "example.key".to_string(), alpn_protocols: NoneOrSome::Some(vec!["h2".to_string(), "http/1.1".to_string()]), @@ -962,6 +970,7 @@ mod tests { protocol: ServerProxyConfig::Tls { tls_targets, default_tls_target: Some(Box::new(TlsServerConfig { + key_exchange_groups: Default::default(), cert: "default.crt".to_string(), key: "default.key".to_string(), alpn_protocols: NoneOrSome::None, @@ -1059,6 +1068,7 @@ mod tests { transport: Transport::Quic, tcp_settings: None, quic_settings: Some(ServerQuicConfig { + key_exchange_groups: Default::default(), cert: "hysteria.crt".to_string(), key: "hysteria.key".to_string(), alpn_protocols: NoneOrSome::One("hysteria".to_string()), @@ -1083,6 +1093,7 @@ mod tests { transport: Transport::Quic, tcp_settings: None, quic_settings: Some(ServerQuicConfig { + key_exchange_groups: Default::default(), cert: "tuic.crt".to_string(), key: "tuic.key".to_string(), alpn_protocols: NoneOrSome::None, diff --git a/src/config/types/tls.rs b/src/config/types/tls.rs new file mode 100644 index 00000000..e6a874f6 --- /dev/null +++ b/src/config/types/tls.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum TlsKeyExchangeGroup { + X25519MLKEM768, + SecP256r1MLKEM768, + X25519, + SecP256r1, + SecP384r1, +} + +impl TlsKeyExchangeGroup { + pub fn is_hybrid(self) -> bool { + matches!(self, Self::X25519MLKEM768 | Self::SecP256r1MLKEM768) + } + + pub fn rustls_group(self) -> &'static dyn rustls::crypto::SupportedKxGroup { + use rustls::crypto::aws_lc_rs::kx_group; + match self { + Self::X25519MLKEM768 => kx_group::X25519MLKEM768, + Self::SecP256r1MLKEM768 => kx_group::SECP256R1MLKEM768, + Self::X25519 => kx_group::X25519, + Self::SecP256r1 => kx_group::SECP256R1, + Self::SecP384r1 => kx_group::SECP384R1, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct TlsKeyExchangeGroups(Option>); + +impl<'de> Deserialize<'de> for TlsKeyExchangeGroups { + fn deserialize>(deserializer: D) -> Result { + let groups = Vec::::deserialize(deserializer)?; + if groups.is_empty() { + return Err(serde::de::Error::custom( + "key_exchange_groups must not be empty", + )); + } + for (index, group) in groups.iter().enumerate() { + if groups[..index].contains(group) { + return Err(serde::de::Error::custom( + "duplicate key_exchange_groups entry", + )); + } + } + Ok(Self(Some(groups))) + } +} + +impl TlsKeyExchangeGroups { + pub fn is_default(&self) -> bool { + self.0.is_none() + } + + pub fn groups(&self) -> Option<&[TlsKeyExchangeGroup]> { + self.0.as_deref() + } + + pub fn requires_hybrid(&self) -> bool { + self.groups() + .is_some_and(|groups| groups.iter().all(|g| g.is_hybrid())) + } + + pub fn validate_vision(&self, vision: bool) -> std::io::Result<()> { + if vision && self.requires_hybrid() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "hybrid-only key_exchange_groups cannot be combined with Vision: direct copy bypasses TLS encryption", + )); + } + Ok(()) + } + + pub fn validate_zero_rtt(&self, zero_rtt_handshake: bool) -> std::io::Result<()> { + if zero_rtt_handshake && self.requires_hybrid() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "hybrid-only key_exchange_groups cannot be combined with zero_rtt_handshake", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ClientProxyConfig, ClientQuicConfig, ServerQuicConfig, TlsServerConfig}; + + #[test] + fn allowlists_are_nonempty_unique_and_native_only() { + for yaml in [ + "[]", + "null", + "[X25519, X25519]", + "[SecP384r1MLKEM1024]", + "[MLKEM768]", + "[typo]", + ] { + assert!( + serde_yaml::from_str::(yaml).is_err(), + "{yaml}" + ); + } + for (yaml, strict) in [ + ("[X25519MLKEM768]", true), + ("[SecP256r1MLKEM768, X25519MLKEM768]", true), + ("[X25519MLKEM768, X25519]", false), + ("[SecP256r1, SecP384r1]", false), + ] { + let groups: TlsKeyExchangeGroups = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(groups.requires_hybrid(), strict); + assert_eq!( + groups, + serde_yaml::from_str(&serde_yaml::to_string(&groups).unwrap()).unwrap() + ); + assert_eq!(groups.validate_vision(true).is_err(), strict); + assert_eq!(groups.validate_zero_rtt(true).is_err(), strict); + assert!(groups.validate_vision(false).is_ok()); + } + } + + #[test] + fn ordinary_tls_and_quic_share_the_allowlist() { + for field in ["", "key_exchange_groups: [SecP256r1MLKEM768]\n"] { + let strict = !field.is_empty(); + let client: ClientProxyConfig = + serde_yaml::from_str(&format!("type: tls\n{field}protocol: {{type: socks}}\n")) + .unwrap(); + let ClientProxyConfig::Tls(tls) = &client else { + panic!() + }; + assert_eq!(tls.key_exchange_groups.requires_hybrid(), strict); + let serialized = serde_yaml::to_string(&client).unwrap(); + assert_eq!(serialized.contains("key_exchange_groups"), strict); + assert!(serde_yaml::from_str::(&serialized).is_ok()); + let server: TlsServerConfig = serde_yaml::from_str(&format!( + "{field}cert: cert\nkey: key\nprotocol: {{type: socks}}\n" + )) + .unwrap(); + assert_eq!(server.key_exchange_groups.requires_hybrid(), strict); + let client: ClientQuicConfig = + serde_yaml::from_str(&format!("verify: true\n{field}")).unwrap(); + assert_eq!(client.key_exchange_groups.requires_hybrid(), strict); + let server: ServerQuicConfig = + serde_yaml::from_str(&format!("cert: cert\nkey: key\n{field}")).unwrap(); + assert_eq!(server.key_exchange_groups.requires_hybrid(), strict); + } + } + + #[test] + fn shadowtls_cannot_silently_ignore_allowlists() { + for yaml in [ + "type: shadowtls\npassword: password\nkey_exchange_groups: [X25519MLKEM768]\nprotocol: {type: socks}", + "type: tls\nshadowtls_password: password\nkey_exchange_groups: [X25519MLKEM768]\nprotocol: {type: socks}", + ] { + assert!(serde_yaml::from_str::(yaml).is_err()); + } + let yaml = "password: password\nkey_exchange_groups: [X25519MLKEM768]\nhandshake: {cert: cert, key: key}\nprotocol: {type: socks}"; + assert!(serde_yaml::from_str::(yaml).is_err()); + } +} diff --git a/src/config/types/transport.rs b/src/config/types/transport.rs index ef69a500..12e16cec 100644 --- a/src/config/types/transport.rs +++ b/src/config/types/transport.rs @@ -64,6 +64,11 @@ impl Default for TcpConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerQuicConfig { + #[serde( + default, + skip_serializing_if = "super::tls::TlsKeyExchangeGroups::is_default" + )] + pub key_exchange_groups: super::tls::TlsKeyExchangeGroups, pub cert: String, pub key: String, #[serde(alias = "alpn_protocol", default)] @@ -79,6 +84,11 @@ pub struct ServerQuicConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClientQuicConfig { + #[serde( + default, + skip_serializing_if = "super::tls::TlsKeyExchangeGroups::is_default" + )] + pub key_exchange_groups: super::tls::TlsKeyExchangeGroups, #[serde(default = "default_true")] pub verify: bool, #[serde(alias = "server_fingerprint", default)] @@ -96,6 +106,7 @@ pub struct ClientQuicConfig { impl Default for ClientQuicConfig { fn default() -> Self { Self { + key_exchange_groups: Default::default(), verify: true, server_fingerprints: NoneOrSome::Unspecified, sni_hostname: NoneOrOne::Unspecified, diff --git a/src/config/validate.rs b/src/config/validate.rs index 3542442c..f3c1c141 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -650,6 +650,14 @@ fn validate_server_config( } // First handle QUIC settings certificates if let Some(ref mut quic_settings) = server_config.quic_settings { + if let ServerProxyConfig::TuicV5 { + zero_rtt_handshake, .. + } = &server_config.protocol + { + quic_settings + .key_exchange_groups + .validate_zero_rtt(*zero_rtt_handshake)?; + } embed_pem_from_map(&mut quic_settings.cert, named_pems); embed_pem_from_map(&mut quic_settings.key, named_pems); for cert in quic_settings.client_ca_certs.iter_mut() { @@ -875,6 +883,9 @@ fn validate_client_vision_protocol( fn validate_client_proxy_structure(config: &ClientProxyConfig) -> std::io::Result<()> { let inner = match config { ClientProxyConfig::Tls(tls_config) => { + tls_config + .key_exchange_groups + .validate_vision(tls_config.vision)?; validate_client_vision_protocol(tls_config.vision, &tls_config.protocol, "TLS")?; Some(tls_config.protocol.as_ref()) } @@ -904,6 +915,7 @@ fn validate_client_config( client_config: &mut ClientConfig, named_pems: &HashMap, ) -> std::io::Result<()> { + client_config.validate_quic_key_exchange(true)?; if client_config.transport != Transport::Tcp && client_config.tcp_settings.is_some() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -1096,6 +1108,7 @@ fn validate_server_certificates( ca.iter().map(|pem| pem.as_bytes().to_vec()).collect(), &[], &[], + &Default::default(), ) .map(|_| ()) } @@ -1109,6 +1122,7 @@ fn validate_client_certificates(cert: Option<&str>, key: Option<&str>) -> std::i true, Some((key.as_bytes().to_vec(), cert.as_bytes().to_vec())), false, + &Default::default(), )?; } Ok(()) @@ -1264,6 +1278,9 @@ fn validate_server_proxy_config( )); } for tls_server_config in tls_targets.values_mut() { + tls_server_config + .key_exchange_groups + .validate_vision(tls_server_config.vision)?; validate_server_vision(tls_server_config.vision, &tls_server_config.protocol)?; embed_pem_from_map(&mut tls_server_config.cert, named_pems); embed_pem_from_map(&mut tls_server_config.key, named_pems); @@ -1304,6 +1321,9 @@ fn validate_server_proxy_config( } } if let Some(tls_server_config) = default_tls_target { + tls_server_config + .key_exchange_groups + .validate_vision(tls_server_config.vision)?; validate_server_vision(tls_server_config.vision, &tls_server_config.protocol)?; embed_pem_from_map(&mut tls_server_config.cert, named_pems); embed_pem_from_map(&mut tls_server_config.key, named_pems); @@ -1725,6 +1745,21 @@ fn expand_client_chain( .map(|hop| expand_chain_hop(hop, client_groups)) .collect::>>()?; + for (index, hop) in expanded_hops.iter().enumerate() { + let validate = |selection: &ConfigSelection| match selection { + ConfigSelection::Config(config) => config.validate_quic_key_exchange(index == 0), + ConfigSelection::GroupName(_) => unreachable!("groups have been expanded"), + }; + match hop { + ClientChainHop::Single(selection) => validate(selection)?, + ClientChainHop::Pool(selections) => { + for selection in selections.iter() { + validate(selection)?; + } + } + } + } + *client_chain = if expanded_hops.len() == 1 { OneOrSome::One(expanded_hops.into_iter().next().unwrap()) } else { @@ -2028,6 +2063,111 @@ mod tests { Ok(validated.configs) } + #[test] + fn hybrid_only_rejects_vision_and_tuic_early_handshakes() { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let tls_target = serde_json::json!({ + "cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), + "key_exchange_groups": ["X25519MLKEM768"], "vision": true, + "protocol": {"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000"} + }); + for protocol in [ + serde_json::json!({"type": "tls", "tls_targets": {"localhost": tls_target}}), + serde_json::json!({"type": "tls", "default_tls_target": tls_target}), + ] { + let mut server: ServerProxyConfig = serde_json::from_value(protocol).unwrap(); + let error = validate_server_proxy_config( + &mut server, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ServerProtocolContext::Stream, + ) + .unwrap_err(); + assert!(error.to_string().contains("hybrid-only"), "{error}"); + } + let client: ClientProxyConfig = serde_json::from_value(serde_json::json!({ + "type": "tls", "key_exchange_groups": ["SecP256r1MLKEM768"], "vision": true, + "protocol": {"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000"} + })) + .unwrap(); + assert!( + validate_client_proxy_structure(&client) + .unwrap_err() + .to_string() + .contains("hybrid-only") + ); + let mut server: ServerConfig = serde_json::from_value(serde_json::json!({ + "address": "0.0.0.0:443", "transport": "quic", + "quic_settings": {"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), "key_exchange_groups": ["X25519MLKEM768"]}, + "protocol": {"type": "tuic", "uuid": "550e8400-e29b-41d4-a716-446655440000", "password": "test", "zero_rtt_handshake": true} + })).unwrap(); + assert!( + validate_server_config( + &mut server, + &HashMap::new(), + &HashMap::new(), + &HashMap::new() + ) + .unwrap_err() + .to_string() + .contains("zero_rtt_handshake") + ); + } + + #[test] + fn quic_allowlists_cannot_be_ignored_by_direct_or_later_hops() { + use serde_json::json; + for groups in [ + None, + Some(json!(["X25519MLKEM768"])), + Some(json!(["X25519"])), + ] { + let mut settings = json!({}); + if let Some(ref groups) = groups { + settings["key_exchange_groups"] = groups.clone(); + } + let direct = json!({"protocol": {"type": "direct"}, "transport": "quic", "quic_settings": settings}); + let mut config: ClientConfig = serde_json::from_value(direct).unwrap(); + let result = validate_client_config(&mut config, &HashMap::new()); + assert_eq!(result.is_err(), groups.is_some()); + + let proxy = json!({"address": "127.0.0.1:443", "protocol": {"type": "socks"}, "transport": "quic", "quic_settings": settings}); + let group_configs = HashMap::from([( + "proxy".into(), + vec![serde_json::from_value::(proxy.clone()).unwrap()], + )]); + for hop in [ + proxy.clone(), + json!("proxy"), + json!({"pool": ["proxy", proxy.clone()]}), + ] { + let direct = json!({"protocol": {"type": "direct"}}); + for chain in [json!([hop]), json!([direct, hop])] { + let later_hop = chain.as_array().unwrap().len() > 1; + let mut parsed: super::super::types::ClientChain = + serde_json::from_value(chain.clone()).unwrap(); + let result = + validate_client_chain(&mut parsed.hops, 0, &group_configs, &HashMap::new()); + assert_eq!(result.is_err(), later_hop && groups.is_some(), "{chain}"); + if let Err(error) = result { + assert!(error.to_string().contains("QUIC key_exchange_groups")); + } + let spec: DnsServerSpec = serde_json::from_value(json!({ + "url": "https://1.1.1.1/dns-query", "client_chain": {"chain": chain} + })) + .unwrap(); + let result = + expand_dns_specs(&[spec], &group_configs, &HashMap::new(), &HashSet::new()); + assert_eq!(result.is_err(), later_hop && groups.is_some()); + if let Err(error) = result { + assert!(error.to_string().contains("QUIC key_exchange_groups")); + } + } + } + } + } + #[tokio::test] async fn test_validate_config_success() { use crate::config::types::ClientConfigGroup; diff --git a/src/quic_server.rs b/src/quic_server.rs index 8dfb9485..92fe73a4 100644 --- a/src/quic_server.rs +++ b/src/quic_server.rs @@ -157,6 +157,7 @@ pub async fn start_quic_servers( }; let ServerQuicConfig { + key_exchange_groups, cert, key, client_ca_certs, @@ -164,6 +165,12 @@ pub async fn start_quic_servers( client_fingerprints, num_endpoints, } = quic_settings.unwrap(); + if let ServerProxyConfig::TuicV5 { + zero_rtt_handshake, .. + } = &protocol + { + key_exchange_groups.validate_zero_rtt(*zero_rtt_handshake)?; + } // Certificates are already embedded as PEM data during config validation let cert_bytes = cert.as_bytes().to_vec(); @@ -180,9 +187,12 @@ pub async fn start_quic_servers( processed_ca_certs, &alpn_protocols.into_vec(), &client_fingerprints.into_vec(), + &key_exchange_groups, )?; // QUIC manages early data separately; its TLS limit must be zero or u32::MAX. - server_config.max_early_data_size = u32::MAX; + if !key_exchange_groups.requires_hybrid() { + server_config.max_early_data_size = u32::MAX; + } let server_config = Arc::new(server_config); let quic_server_config: quinn::crypto::rustls::QuicServerConfig = server_config @@ -335,6 +345,7 @@ mod tests { transport: Transport::Quic, tcp_settings: None, quic_settings: Some(ServerQuicConfig { + key_exchange_groups: Default::default(), cert: cert.cert.pem(), key: cert.signing_key.serialize_pem(), alpn_protocols: NoneOrSome::One("h3".into()), diff --git a/src/rustls_config_util.rs b/src/rustls_config_util.rs index 5a414982..95dfcf54 100644 --- a/src/rustls_config_util.rs +++ b/src/rustls_config_util.rs @@ -4,6 +4,11 @@ use std::sync::OnceLock; use rustls::pki_types::pem::PemObject; +use crate::config::TlsKeyExchangeGroups; + +#[cfg(test)] +mod hybrid_tests; + pub fn try_create_client_config( verify_webpki: bool, server_fingerprints: Vec, @@ -11,15 +16,17 @@ pub fn try_create_client_config( enable_sni: bool, client_key_and_cert: Option<(Vec, Vec)>, tls13_only: bool, + key_exchange_groups: &TlsKeyExchangeGroups, ) -> std::io::Result { - let builder = rustls::ClientConfig::builder_with_provider(get_crypto_provider()); - let builder = if tls13_only { - builder - .with_protocol_versions(&[&rustls::version::TLS13]) - .unwrap() + let builder = rustls::ClientConfig::builder_with_provider(crypto_provider_with_groups( + key_exchange_groups, + )); + let builder = if tls13_only || key_exchange_groups.requires_hybrid() { + builder.with_protocol_versions(&[&rustls::version::TLS13]) } else { - builder.with_safe_default_protocol_versions().unwrap() - }; + builder.with_safe_default_protocol_versions() + } + .map_err(std::io::Error::other)?; let builder = if verify_webpki { let webpki_verifier = rustls::client::WebPkiServerVerifier::builder_with_provider( @@ -75,6 +82,9 @@ pub fn try_create_client_config( .collect(); config.enable_sni = enable_sni; + if key_exchange_groups.requires_hybrid() { + config.enable_early_data = false; + } Ok(config) } @@ -197,6 +207,17 @@ fn get_supported_algorithms() -> rustls::crypto::WebPkiSupportedAlgorithms { get_crypto_provider().signature_verification_algorithms } +fn crypto_provider_with_groups( + groups: &TlsKeyExchangeGroups, +) -> Arc { + let Some(groups) = groups.groups() else { + return get_crypto_provider(); + }; + let mut provider = (*get_crypto_provider()).clone(); + provider.kx_groups = groups.iter().map(|group| group.rustls_group()).collect(); + Arc::new(provider) +} + fn get_disabled_verifier() -> Arc { static INSTANCE: OnceLock> = OnceLock::new(); INSTANCE @@ -236,6 +257,7 @@ pub fn try_create_server_config( ca_cert_bytes: Vec>, alpn_protocols: &[String], client_fingerprints: &[String], + key_exchange_groups: &TlsKeyExchangeGroups, ) -> std::io::Result { // Parse all certificates from the PEM file (server cert + intermediates) let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(cert_bytes) @@ -269,9 +291,15 @@ pub fn try_create_server_config( Some(verifier) }; - let builder = rustls::ServerConfig::builder_with_provider(get_crypto_provider()) - .with_safe_default_protocol_versions() - .unwrap(); + let builder = rustls::ServerConfig::builder_with_provider(crypto_provider_with_groups( + key_exchange_groups, + )); + let builder = if key_exchange_groups.requires_hybrid() { + builder.with_protocol_versions(&[&rustls::version::TLS13]) + } else { + builder.with_safe_default_protocol_versions() + } + .map_err(std::io::Error::other)?; // Always wraps in ClientFingerprintVerifier even for CA-only auth, because // WebPkiClientVerifier's root_hint_subjects() leaks CA names to unauthenticated clients. let builder = if client_fingerprints.is_empty() && webpki_verifier.is_none() { @@ -293,6 +321,10 @@ pub fn try_create_server_config( .collect(); config.max_fragment_size = None; + if key_exchange_groups.requires_hybrid() { + config.max_early_data_size = 0; + config.send_half_rtt_data = false; + } config.ignore_client_order = true; Ok(config) @@ -344,6 +376,7 @@ mod material_tests { vec![], &[], &[], + &Default::default(), ) .unwrap(); let mut roots = rustls::RootCertStore::empty(); @@ -493,7 +526,15 @@ mod material_tests { (pem.as_str(), wrong_key.as_str(), false), ] { assert_eq!( - try_create_server_config(cert.as_bytes(), key.as_bytes(), vec![], &[], &[]).is_ok(), + try_create_server_config( + cert.as_bytes(), + key.as_bytes(), + vec![], + &[], + &[], + &Default::default() + ) + .is_ok(), valid ); assert_eq!( @@ -503,7 +544,8 @@ mod material_tests { vec![], true, Some((key.as_bytes().to_vec(), cert.as_bytes().to_vec())), - false + false, + &Default::default(), ) .is_ok(), valid @@ -515,7 +557,8 @@ mod material_tests { key.as_bytes(), vec![b"invalid".to_vec()], &[], - &[] + &[], + &Default::default(), ) .is_err() ); diff --git a/src/rustls_config_util/hybrid_tests.rs b/src/rustls_config_util/hybrid_tests.rs new file mode 100644 index 00000000..cdf37e00 --- /dev/null +++ b/src/rustls_config_util/hybrid_tests.rs @@ -0,0 +1,405 @@ +use super::*; +use rustls::{ClientConnection, HandshakeKind, NamedGroup, ServerConnection}; +use std::io::{Read, Write}; + +fn configured_groups(names: &str) -> TlsKeyExchangeGroups { + if names.is_empty() { + return TlsKeyExchangeGroups::default(); + } + serde_yaml::from_str(&format!("[{names}]")).unwrap() +} + +fn fingerprint(cert: &rcgen::Certificate) -> String { + aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, cert.der()) + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn configs( + client_groups: &str, + server_groups: &str, +) -> (rustls::ClientConfig, rustls::ServerConfig) { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let client = try_create_client_config( + false, + vec![fingerprint(&cert.cert)], + vec!["hybrid-test".into()], + true, + None, + false, + &configured_groups(client_groups), + ) + .unwrap(); + let server = try_create_server_config( + cert.cert.pem().as_bytes(), + cert.signing_key.serialize_pem().as_bytes(), + vec![], + &["hybrid-test".into()], + &[], + &configured_groups(server_groups), + ) + .unwrap(); + (client, server) +} + +fn handshake( + client: Arc, + server: Arc, +) -> Result<(ClientConnection, ServerConnection), rustls::Error> { + let mut client = ClientConnection::new(client, "localhost".try_into().unwrap()).unwrap(); + let mut server = ServerConnection::new(server).unwrap(); + for _ in 0..16 { + let mut wire = Vec::new(); + client.write_tls(&mut wire).unwrap(); + for fragment in wire.chunks(37) { + server.read_tls(&mut &fragment[..]).unwrap(); + server.process_new_packets()?; + } + wire.clear(); + server.write_tls(&mut wire).unwrap(); + for fragment in wire.chunks(37) { + client.read_tls(&mut &fragment[..]).unwrap(); + client.process_new_packets()?; + } + if !client.is_handshaking() + && !server.is_handshaking() + && !client.wants_write() + && !server.wants_write() + { + return Ok((client, server)); + } + } + panic!("handshake did not converge"); +} + +fn assert_group(client: &ClientConnection, server: &ServerConnection, group: NamedGroup) { + assert_eq!( + client.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_3) + ); + assert_eq!( + server.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_3) + ); + assert_eq!( + client.negotiated_key_exchange_group().unwrap().name(), + group + ); + assert_eq!( + server.negotiated_key_exchange_group().unwrap().name(), + group + ); + assert_eq!(client.alpn_protocol(), Some(b"hybrid-test".as_slice())); +} + +#[test] +fn native_hybrids_exchange_data_and_resume_with_fresh_hybrid_keys() { + for (name, group) in [ + ("X25519MLKEM768", NamedGroup::X25519MLKEM768), + ("SecP256r1MLKEM768", NamedGroup::secp256r1MLKEM768), + ] { + let (client_config, server_config) = configs(name, name); + assert!(!client_config.enable_early_data); + assert_eq!(server_config.max_early_data_size, 0); + assert!(!server_config.send_half_rtt_data); + let client_config = Arc::new(client_config); + let server_config = Arc::new(server_config); + for kind in [HandshakeKind::Full, HandshakeKind::Resumed] { + let (mut client, mut server) = + handshake(client_config.clone(), server_config.clone()).unwrap(); + assert_group(&client, &server, group); + assert_eq!(client.handshake_kind(), Some(kind)); + assert_eq!(server.handshake_kind(), Some(kind)); + assert!(client.early_data().is_none()); + assert!(server.early_data().is_none()); + client.writer().write_all(b"client payload").unwrap(); + let mut wire = Vec::new(); + client.write_tls(&mut wire).unwrap(); + server.read_tls(&mut wire.as_slice()).unwrap(); + server.process_new_packets().unwrap(); + let mut payload = [0; 14]; + server.reader().read_exact(&mut payload).unwrap(); + assert_eq!(&payload, b"client payload"); + server.writer().write_all(b"server payload").unwrap(); + wire.clear(); + server.write_tls(&mut wire).unwrap(); + client.read_tls(&mut wire.as_slice()).unwrap(); + client.process_new_packets().unwrap(); + client.reader().read_exact(&mut payload).unwrap(); + assert_eq!(&payload, b"server payload"); + } + } +} + +#[test] +fn hybrid_only_rejects_classical_peers_in_both_roles() { + for hybrid in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + for classical in ["X25519", "SecP256r1", "SecP384r1"] { + for (client, server) in [(hybrid, classical), (classical, hybrid)] { + let (client, server) = configs(client, server); + assert!(handshake(Arc::new(client), Arc::new(server)).is_err()); + } + } + } + let (client, server) = configs("X25519MLKEM768", "SecP256r1MLKEM768"); + assert!(handshake(Arc::new(client), Arc::new(server)).is_err()); +} + +#[test] +fn hybrid_only_rejects_tls12_in_both_roles() { + for hybrid in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + let (client, mut server) = configs(hybrid, "X25519"); + server = rustls::ServerConfig::builder_with_provider(server.crypto_provider().clone()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .with_no_client_auth() + .with_cert_resolver(server.cert_resolver.clone()); + assert!(handshake(Arc::new(client), Arc::new(server)).is_err()); + + let (client, server) = configs("X25519", hybrid); + let client = rustls::ClientConfig::builder_with_provider(client.crypto_provider().clone()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(get_disabled_verifier()) + .with_no_client_auth(); + assert!(handshake(Arc::new(client), Arc::new(server)).is_err()); + } +} + +#[test] +fn explicit_order_hello_retry_and_classical_fallback() { + let (client, server) = configs("X25519MLKEM768, SecP256r1MLKEM768", "SecP256r1MLKEM768"); + let (client, server) = handshake(Arc::new(client), Arc::new(server)).unwrap(); + assert_group(&client, &server, NamedGroup::secp256r1MLKEM768); + assert_eq!( + client.handshake_kind(), + Some(HandshakeKind::FullWithHelloRetryRequest) + ); + for (client, server) in [ + ("X25519MLKEM768, X25519", "X25519"), + ("X25519", "X25519MLKEM768, X25519"), + ("", "X25519"), + ("X25519", ""), + ] { + let (client, server) = configs(client, server); + let (client, server) = handshake(Arc::new(client), Arc::new(server)).unwrap(); + assert_group(&client, &server, NamedGroup::X25519); + } +} + +#[test] +fn provider_selection_is_local_and_ordered() { + let original: Vec<_> = get_crypto_provider() + .kx_groups + .iter() + .map(|g| g.name()) + .collect(); + let (client, server) = configs("SecP256r1MLKEM768, X25519MLKEM768", "SecP256r1MLKEM768"); + assert_eq!( + client + .crypto_provider() + .kx_groups + .iter() + .map(|g| g.name()) + .collect::>(), + [NamedGroup::secp256r1MLKEM768, NamedGroup::X25519MLKEM768] + ); + assert_eq!(server.crypto_provider().kx_groups.len(), 1); + for provider in [ + get_crypto_provider(), + create_dns_client_config().crypto_provider().clone(), + try_create_client_config(false, vec![], vec![], true, None, true, &Default::default()) + .unwrap() + .crypto_provider() + .clone(), + ] { + assert_eq!( + provider + .kx_groups + .iter() + .map(|g| g.name()) + .collect::>(), + original + ); + } +} + +#[test] +fn hybrid_exchange_preserves_mutual_authentication_and_pinning() { + for group in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + let server_cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let client_cert = rcgen::generate_simple_self_signed(vec!["client".into()]).unwrap(); + for (client_auth, pin_matches, success) in [ + (true, true, true), + (false, true, false), + (true, false, false), + ] { + let client = try_create_client_config( + false, + vec![if pin_matches { + fingerprint(&server_cert.cert) + } else { + "00".repeat(32) + }], + vec![], + true, + client_auth.then(|| { + ( + client_cert.signing_key.serialize_pem().into_bytes(), + client_cert.cert.pem().into_bytes(), + ) + }), + false, + &configured_groups(group), + ) + .unwrap(); + let server = try_create_server_config( + server_cert.cert.pem().as_bytes(), + server_cert.signing_key.serialize_pem().as_bytes(), + vec![], + &[], + &[fingerprint(&client_cert.cert)], + &configured_groups(group), + ) + .unwrap(); + assert_eq!( + handshake(Arc::new(client), Arc::new(server)).is_ok(), + success + ); + } + } +} + +#[test] +fn default_and_mixed_lists_preserve_tls12_compatibility() { + for configured in ["", "X25519MLKEM768, X25519"] { + let (client, server) = configs(configured, "X25519"); + let mut server = + rustls::ServerConfig::builder_with_provider(server.crypto_provider().clone()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .with_no_client_auth() + .with_cert_resolver(server.cert_resolver.clone()); + server.alpn_protocols = vec![b"hybrid-test".to_vec()]; + let (client, server) = handshake(Arc::new(client), Arc::new(server)).unwrap(); + assert_eq!( + client.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_2) + ); + assert_eq!( + server.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_2) + ); + + let (client, server) = configs("X25519", configured); + let mut client = + rustls::ClientConfig::builder_with_provider(client.crypto_provider().clone()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(get_disabled_verifier()) + .with_no_client_auth(); + client.alpn_protocols = vec![b"hybrid-test".to_vec()]; + let (client, server) = handshake(Arc::new(client), Arc::new(server)).unwrap(); + assert_eq!( + client.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_2) + ); + assert_eq!( + server.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_2) + ); + } +} + +#[tokio::test] +async fn crypto_stream_handles_fragmented_hybrid_handshakes() { + use crate::crypto::{CryptoConnection, CryptoTlsStream, TlsReadMode}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + for name in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + let (client, mut server) = configs(name, name); + // Keep this tiny duplex focused on handshake fragmentation, not post-handshake tickets. + server.send_tls13_tickets = 0; + let client = CryptoConnection::new_rustls_client( + ClientConnection::new(Arc::new(client), "localhost".try_into().unwrap()).unwrap(), + ); + let server = CryptoConnection::new_rustls_server( + ServerConnection::new(Arc::new(server)).unwrap(), + ); + let (client_io, server_io) = tokio::io::duplex(43); + let (mut client, mut server) = tokio::try_join!( + CryptoTlsStream::handshake(client_io, client, TlsReadMode::Stream, &[]), + CryptoTlsStream::handshake(server_io, server, TlsReadMode::Stream, &[]), + ) + .unwrap(); + let send = async { + client.write_all(b"hybrid").await.unwrap(); + client.flush().await.unwrap(); + }; + let receive = async { + let mut data = [0; 6]; + server.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"hybrid"); + }; + tokio::join!(send, receive); + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn quic_hybrid_data_and_disjoint_group_rejection() { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + for (client_name, server_name, success) in [ + ("X25519MLKEM768", "X25519MLKEM768", true), + ("SecP256r1MLKEM768", "SecP256r1MLKEM768", true), + ("X25519MLKEM768", "X25519", false), + ("X25519", "SecP256r1MLKEM768", false), + ("", "X25519", true), + ] { + let (client, server) = configs(client_name, server_name); + let server = quinn::crypto::rustls::QuicServerConfig::try_from(server).unwrap(); + let server = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(server)), + "0.0.0.0:0".parse().unwrap(), + ) + .unwrap(); + let client = { + let config = quinn::crypto::rustls::QuicClientConfig::try_from(client).unwrap(); + let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap(); + endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(config))); + endpoint + }; + let target = ( + std::net::Ipv4Addr::LOCALHOST, + server.local_addr().unwrap().port(), + ) + .into(); + let connect = client.connect(target, "localhost").unwrap(); + let accept = async { server.accept().await.unwrap().await }; + let (outbound, inbound) = tokio::join!(connect, accept); + assert_eq!(outbound.is_ok(), success); + assert_eq!(inbound.is_ok(), success); + if success { + let outbound = outbound.unwrap(); + let inbound = inbound.unwrap(); + let mut send = outbound.open_uni().await.unwrap(); + send.write_all(b"quic hybrid").await.unwrap(); + send.finish().unwrap(); + let mut receive = inbound.accept_uni().await.unwrap(); + assert_eq!(receive.read_to_end(1024).await.unwrap(), b"quic hybrid"); + } + client.close(0u32.into(), b"done"); + server.close(0u32.into(), b"done"); + client.wait_idle().await; + server.wait_idle().await; + } + }) + .await + .unwrap(); +} diff --git a/src/tcp/chain_builder.rs b/src/tcp/chain_builder.rs index 2ea0c369..94d29ab3 100644 --- a/src/tcp/chain_builder.rs +++ b/src/tcp/chain_builder.rs @@ -66,6 +66,12 @@ fn build_client_proxy_chain_with_limits( )); } + for (index, configs) in hops.iter().enumerate() { + for config in configs { + config.validate_quic_key_exchange(index == 0)?; + } + } + // Build initial hop entries from hop 0. // Each entry pairs socket + optional proxy together to ensure atomic selection. let initial_hop: Vec = hops[0] @@ -234,6 +240,50 @@ mod tests { ClientConfig::default() } + #[test] + fn quic_allowlists_fail_before_ignored_transport_construction() { + for groups in [None, Some("X25519MLKEM768"), Some("X25519")] { + let field = groups + .map(|g| format!("key_exchange_groups: [{g}]")) + .unwrap_or_default(); + let direct: ClientConfig = serde_yaml::from_str(&format!( + "protocol: {{type: direct}}\ntransport: quic\nquic_settings: {{{field}}}\n" + )) + .unwrap(); + assert_eq!( + SocketConnectorImpl::from_config(&direct, None).is_err(), + groups.is_some() + ); + let direct_chain = + OneOrSome::One(ClientChainHop::Single(ConfigSelection::Config(direct))); + assert_eq!( + try_build_client_proxy_chain(direct_chain, mock_resolver()).is_err(), + groups.is_some() + ); + + let proxy: ClientConfig = serde_yaml::from_str(&format!( + "address: '127.0.0.1:443'\nprotocol: {{type: socks}}\ntransport: quic\nquic_settings: {{{field}}}\n" + )).unwrap(); + for hop in [ + ClientChainHop::Single(ConfigSelection::Config(proxy.clone())), + ClientChainHop::Pool(OneOrSome::Some(vec![ + ConfigSelection::Config(socks_config(1080)), + ConfigSelection::Config(proxy), + ])), + ] { + let chain = OneOrSome::Some(vec![ + ClientChainHop::Single(ConfigSelection::Config(direct_config())), + hop, + ]); + let result = try_build_client_proxy_chain(chain, mock_resolver()); + assert_eq!(result.is_err(), groups.is_some()); + if let Err(error) = result { + assert!(error.to_string().contains("QUIC key_exchange_groups")); + } + } + } + } + #[test] fn test_build_single_direct_hop() { let chain = build_client_proxy_chain( diff --git a/src/tcp/socket_connector_impl.rs b/src/tcp/socket_connector_impl.rs index e74f84ae..fb9c914d 100644 --- a/src/tcp/socket_connector_impl.rs +++ b/src/tcp/socket_connector_impl.rs @@ -154,6 +154,7 @@ impl SocketConnectorImpl { target_address: Option<&NetLocation>, limits: crate::config::GlobalLimits, ) -> std::io::Result { + config.validate_quic_key_exchange(true)?; let bind_interface = config.bind_interface.clone().into_option(); let default_sni_hostname = @@ -185,6 +186,7 @@ impl SocketConnectorImpl { })?; let ClientQuicConfig { + key_exchange_groups, verify, server_fingerprints, alpn_protocols, @@ -226,6 +228,7 @@ impl SocketConnectorImpl { sni_hostname.is_some(), key_and_cert_bytes, false, // tls13_only - QUIC enforces TLS 1.3 anyway + &key_exchange_groups, )?; let quic_client_config = quinn::crypto::rustls::QuicClientConfig::with_initial( diff --git a/src/tcp/tcp_client_handler_factory.rs b/src/tcp/tcp_client_handler_factory.rs index 43602974..8dd4c02b 100644 --- a/src/tcp/tcp_client_handler_factory.rs +++ b/src/tcp/tcp_client_handler_factory.rs @@ -126,6 +126,7 @@ pub fn create_tcp_client_handler( } ClientProxyConfig::Tls(tls_client_config) => { let TlsClientConfig { + key_exchange_groups, verify, server_fingerprints, sni_hostname, @@ -136,6 +137,7 @@ pub fn create_tcp_client_handler( cert, vision, } = tls_client_config; + key_exchange_groups.validate_vision(vision)?; let sni_hostname = if sni_hostname.is_unspecified() { if let Some(ref hostname) = default_sni_hostname { @@ -164,6 +166,7 @@ pub fn create_tcp_client_handler( sni_hostname.is_some(), key_and_cert_bytes, false, // tls13_only + &key_exchange_groups, )?); let server_name = match sni_hostname { @@ -288,6 +291,7 @@ pub fn create_tcp_client_handler( enable_sni, // Enable SNI if hostname provided None, // No client cert true, // tls13_only - required for ShadowTLS v3 + &Default::default(), )?); let handler = create_tcp_client_handler(*protocol, None, resolver.clone())?; diff --git a/src/tcp/tcp_server_handler_factory.rs b/src/tcp/tcp_server_handler_factory.rs index 62f3ee5c..8be07747 100644 --- a/src/tcp/tcp_server_handler_factory.rs +++ b/src/tcp/tcp_server_handler_factory.rs @@ -312,6 +312,7 @@ fn create_tls_server_target( bind_ip: Option, ) -> std::io::Result { let TlsServerConfig { + key_exchange_groups, cert, key, alpn_protocols, @@ -321,6 +322,7 @@ fn create_tls_server_target( protocol, override_rules, } = tls_server_config; + key_exchange_groups.validate_vision(vision)?; if vision && !matches!(protocol, ServerProxyConfig::Vless { .. }) { return Err(std::io::Error::new( @@ -360,6 +362,7 @@ fn create_tls_server_target( client_ca_certs, &effective_alpn, &client_fingerprints.into_vec(), + &key_exchange_groups, )?); // Compute effective selector: if override_rules exist, create new selector; otherwise use parent's @@ -438,6 +441,7 @@ fn create_shadow_tls_server_target( client_ca_certs, &handshake.alpn_protocols.into_vec(), &handshake.client_fingerprints.into_vec(), + &Default::default(), )?); ShadowTlsServerTargetHandshake::new_local(server_config) diff --git a/tests/cases/integration/tls_hybrid.rs b/tests/cases/integration/tls_hybrid.rs new file mode 100644 index 00000000..16e66f11 --- /dev/null +++ b/tests/cases/integration/tls_hybrid.rs @@ -0,0 +1,238 @@ +use std::{io, sync::Arc, time::Duration}; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use shoes_test_support::{port_helper::PortHelper, test_fixture::start_shoes_server}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +fn provider(name: &str) -> Arc { + use rustls::crypto::aws_lc_rs::{default_provider, kx_group}; + let mut provider = default_provider(); + provider.kx_groups = vec![match name { + "X25519MLKEM768" => kx_group::X25519MLKEM768, + "SecP256r1MLKEM768" => kx_group::SECP256R1MLKEM768, + "X25519" => kx_group::X25519, + _ => unreachable!(), + }]; + Arc::new(provider) +} + +#[tokio::test] +async fn tls_server_named_and_default_targets_enforce_groups() -> io::Result<()> { + let scenario = async { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert.cert.der().clone()).unwrap(); + let echo = + shoes_test_support::test_servers::start_tcp_stream_echo_server("0.0.0.0", 0).await?; + for group in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + for default_target in [false, true] { + let mut ports = PortHelper::new(); + let (_, port) = ports.get_localhost_listener_port(); + let target = serde_json::json!({ + "cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), + "key_exchange_groups": [group], + "protocol": {"type": "forward", "target": format!("127.0.0.1:{}", echo.local_addr().port())} + }); + let mut protocol = serde_json::json!({"type": "tls"}); + if default_target { + protocol["default_tls_target"] = target; + } else { + protocol["tls_targets"] = serde_json::json!({"localhost": target}); + } + let config = serde_json::json!([{"address": format!("0.0.0.0:{port}"), "protocol": protocol}]); + let _shoes = start_shoes_server(&serde_yaml::to_string(&config).unwrap())?; + ports.wait_for_all_ports().await?; + for peer_group in [group, "X25519"] { + let provider = provider(peer_group); + let expected = provider.kx_groups[0].name(); + let client = rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_root_certificates(roots.clone()) + .with_no_client_auth(); + let result = tokio_rustls::TlsConnector::from(Arc::new(client)) + .connect( + "localhost".try_into().unwrap(), + TcpStream::connect(("127.0.0.1", port)).await?, + ) + .await; + if peer_group == "X25519" { + assert!(result.is_err(), "server ignored {group} allowlist"); + continue; + } + let mut stream = result?; + assert_eq!( + stream + .get_ref() + .1 + .negotiated_key_exchange_group() + .unwrap() + .name(), + expected + ); + let payload = vec![0x5a; 64 * 1024]; + stream.write_all(&payload).await?; + stream.flush().await?; + let mut response = vec![0; payload.len()]; + stream.read_exact(&mut response).await?; + assert_eq!(payload, response); + } + } + } + Ok(()) + }; + tokio::time::timeout(Duration::from_secs(30), scenario).await? +} + +#[tokio::test] +async fn tls_client_enforces_groups() -> io::Result<()> { + let scenario = async { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let fingerprint = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, cert.cert.der()); + let fingerprint: String = fingerprint + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + for group in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + for peer_group in [group, "X25519"] { + let listener = TcpListener::bind("0.0.0.0:0").await?; + let peer_port = listener.local_addr()?.port(); + let provider = provider(peer_group); + let expected = provider.kx_groups[0].name(); + let server = rustls::ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from_pem_slice(cert.cert.pem().as_bytes()).unwrap()], + PrivateKeyDer::from_pem_slice(cert.signing_key.serialize_pem().as_bytes()) + .unwrap(), + ) + .unwrap(); + let mut ports = PortHelper::new(); + let (_, port) = ports.get_localhost_listener_port(); + let config = serde_json::json!([{ + "address": format!("0.0.0.0:{port}"), + "protocol": {"type": "http"}, + "rules": [{"mask": "0.0.0.0/0", "action": "allow", "client_chain": [{ + "address": format!("127.0.0.1:{peer_port}"), + "protocol": {"type": "tls", "verify": false, "server_fingerprints": [fingerprint], + "sni_hostname": "localhost", "key_exchange_groups": [group], "protocol": {"type": "portforward"}} + }]}] + }]); + let _shoes = start_shoes_server(&serde_yaml::to_string(&config).unwrap())?; + ports.wait_for_all_ports().await?; + let client = async { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await?; + stream + .write_all(b"CONNECT 127.0.0.1:80 HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await?; + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + match stream.read_u8().await { + Ok(byte) => header.push(byte), + Err(_) if peer_group == "X25519" => return Ok(()), + Err(error) => return Err(error), + } + assert!(header.len() < 4096); + } + if peer_group == "X25519" { + assert!(!header.starts_with(b"HTTP/1.1 200")); + return Ok(()); + } + assert!(header.starts_with(b"HTTP/1.1 200")); + stream.write_all(b"hybrid client").await?; + let mut response = [0; 13]; + stream.read_exact(&mut response).await?; + assert_eq!(&response, b"hybrid client"); + io::Result::Ok(()) + }; + let server = async { + let (socket, _) = listener.accept().await?; + let result = tokio_rustls::TlsAcceptor::from(Arc::new(server)) + .accept(socket) + .await; + if peer_group == "X25519" { + assert!(result.is_err(), "client ignored {group} allowlist"); + return Ok(()); + } + let mut stream = result?; + assert_eq!( + stream + .get_ref() + .1 + .negotiated_key_exchange_group() + .unwrap() + .name(), + expected + ); + let mut payload = [0; 13]; + stream.read_exact(&mut payload).await?; + stream.write_all(&payload).await?; + stream.shutdown().await?; + io::Result::Ok(()) + }; + tokio::try_join!(client, server)?; + } + } + Ok(()) + }; + tokio::time::timeout(Duration::from_secs(30), scenario).await? +} + +#[tokio::test] +async fn hybrid_quic_and_tls_udp_over_tcp() -> Result<(), Box> { + let scenario = async { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let echo = shoes_test_support::test_servers::start_udp_echo_server("0.0.0.0", 0).await?; + for transport in ["tcp", "quic"] { + for group in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + let mut ports = PortHelper::new(); + let (_, server_port) = if transport == "quic" { + ports.get_quic_listener_port() + } else { + ports.get_localhost_listener_port() + }; + let (_, client_port) = ports.get_localhost_listener_port(); + let inner = serde_json::json!({"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000"}); + let server_tls = serde_json::json!({"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), "key_exchange_groups": [group]}); + let client_tls = serde_json::json!({"verify": false, "sni_hostname": "localhost", "key_exchange_groups": [group]}); + let mut server = serde_json::json!({"address": format!("0.0.0.0:{server_port}"), "transport": transport}); + let mut outbound = serde_json::json!({"address": format!("127.0.0.1:{server_port}"), "transport": transport}); + if transport == "quic" { + server["quic_settings"] = server_tls; + server["protocol"] = inner.clone(); + outbound["quic_settings"] = client_tls; + outbound["protocol"] = inner; + } else { + let mut target = server_tls; + target["protocol"] = inner.clone(); + server["protocol"] = + serde_json::json!({"type": "tls", "default_tls_target": target}); + let mut client = client_tls; + client["type"] = "tls".into(); + client["protocol"] = inner; + outbound["protocol"] = client; + } + let config = serde_json::json!([server, { + "address": format!("0.0.0.0:{client_port}"), "protocol": {"type": "socks"}, + "rules": [{"mask": "0.0.0.0/0", "action": "allow", "client_chain": [outbound]}] + }]); + let _shoes = start_shoes_server(&serde_yaml::to_string(&config)?)?; + ports.wait_for_all_ports().await?; + let association = shoes_test_support::socks5::Socks5UdpAssociation::connect( + "127.0.0.1", + client_port, + ) + .await?; + let target = ([127, 0, 0, 1], echo.local_addr().port()).into(); + let response = association.send_to(target, b"hybrid datagram").await?; + assert_eq!(response, b"hybrid datagram [ECHO]"); + } + } + Ok::<_, Box>(()) + }; + tokio::time::timeout(Duration::from_secs(40), scenario).await? +} diff --git a/tests/cases/optional_peer/tls_hybrid.rs b/tests/cases/optional_peer/tls_hybrid.rs new file mode 100644 index 00000000..902c82c5 --- /dev/null +++ b/tests/cases/optional_peer/tls_hybrid.rs @@ -0,0 +1,161 @@ +use std::{io, io::Write, process::Command, time::Duration}; + +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use serde_json::json; +use shoes_test_support::{ + certs::generate_test_cert_files, port_helper::PortHelper, process::ProcessGuard, + test_fixture::start_shoes_server, test_servers::start_tcp_stream_echo_server, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; + +fn start_xray(config: serde_json::Value) -> io::Result<(ProcessGuard, tempfile::NamedTempFile)> { + let binary = std::env::var_os("SHOES_TEST_XRAY_BIN").unwrap_or_else(|| "xray".into()); + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(config.to_string().as_bytes())?; + file.flush()?; + let child = Command::new(binary) + .args(["run", "-format", "json", "-config"]) + .arg(file.path()) + .env("GOMAXPROCS", "1") + .spawn()?; + Ok((ProcessGuard::new(child, "xray hybrid TLS peer"), file)) +} + +async fn echo_through_http(port: u16, target_port: u16) -> io::Result<()> { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await?; + stream + .write_all( + format!( + "CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(stream.read_u8().await?); + assert!(headers.len() < 4096); + } + assert!( + headers.starts_with(b"HTTP/1.1 200"), + "{}", + String::from_utf8_lossy(&headers) + ); + let payload: Vec<_> = (0..65537).map(|i| (i % 251) as u8).collect(); + stream.write_all(&payload).await?; + let mut response = vec![0; payload.len()]; + stream.read_exact(&mut response).await?; + assert_eq!(payload, response); + Ok(()) +} + +#[tokio::test] +async fn xray_both_native_hybrids_in_both_directions() -> Result<(), Box> { + let scenario = async { + let (cert_path, key_path) = generate_test_cert_files()?; + let pem = std::fs::read(&cert_path)?; + let cert = CertificateDer::from_pem_slice(&pem)?; + let fingerprint = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &cert); + let fingerprint: String = fingerprint + .as_ref() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let echo = start_tcp_stream_echo_server("0.0.0.0", 0).await?; + for group in ["X25519MLKEM768", "SecP256r1MLKEM768"] { + for shoes_is_server in [false, true] { + let mut ports = PortHelper::new(); + let (_, server_port) = ports.get_localhost_listener_port(); + let (_, client_port) = ports.get_localhost_listener_port(); + // Xray's "unsafe" fingerprint selects native Go TLS, not a uTLS preset + // that can override curve preferences. Certificate pinning stays enabled. + let (shoes_config, xray_config) = if shoes_is_server { + ( + json!([{ + "address": format!("0.0.0.0:{server_port}"), + "protocol": {"type": "tls", "default_tls_target": { + "cert": cert_path.to_str().unwrap(), "key": key_path.to_str().unwrap(), + "key_exchange_groups": [group], "protocol": {"type": "http"} + }} + }]), + json!({ + "log": {"loglevel": "warning"}, + "inbounds": [{"listen": "0.0.0.0", "port": client_port, "protocol": "http", "settings": {}}], + "outbounds": [{"protocol": "http", "settings": {"servers": [{"address": "127.0.0.1", "port": server_port}]}, + "streamSettings": {"network": "tcp", "security": "tls", "tlsSettings": { + "serverName": "test.local", "pinnedPeerCertSha256": fingerprint, + "minVersion": "1.3", "curvePreferences": [group], "fingerprint": "unsafe" + }}}] + }), + ) + } else { + ( + json!([{ + "address": format!("0.0.0.0:{client_port}"), "protocol": {"type": "http"}, + "rules": [{"mask": "0.0.0.0/0", "action": "allow", "client_chain": [{ + "address": format!("127.0.0.1:{server_port}"), + "protocol": {"type": "tls", "verify": false, "server_fingerprints": [fingerprint], "sni_hostname": "test.local", + "key_exchange_groups": [group], "protocol": {"type": "http"}} + }]}] + }]), + json!({ + "log": {"loglevel": "warning"}, + "inbounds": [{"listen": "0.0.0.0", "port": server_port, "protocol": "http", "settings": {}, + "streamSettings": {"network": "tcp", "security": "tls", "tlsSettings": { + "minVersion": "1.3", "curvePreferences": [group], + "certificates": [{"certificateFile": cert_path.to_str().unwrap(), "keyFile": key_path.to_str().unwrap()}] + }}}], + "outbounds": [{"protocol": "freedom", "settings": {"finalRules": [{"action": "allow", "ip": ["127.0.0.0/8"]}]}}] + }), + ) + }; + let _shoes = start_shoes_server(&serde_yaml::to_string(&shoes_config)?)?; + let _xray = start_xray(xray_config)?; + ports.wait_for_all_ports().await?; + echo_through_http(client_port, echo.local_addr().port()).await?; + } + } + Ok::<_, Box>(()) + }; + tokio::time::timeout(Duration::from_secs(45), scenario).await? +} + +#[tokio::test] +async fn singbox_hybrid_quic_tuic_and_hysteria2() -> Result<(), Box> { + let scenario = async { + let (cert_path, key_path) = generate_test_cert_files()?; + let echo = start_tcp_stream_echo_server("0.0.0.0", 0).await?; + for protocol in ["tuic", "hysteria2"] { + let mut ports = PortHelper::new(); + let (_, server_port) = ports.get_quic_listener_port(); + let (_, client_port) = ports.get_localhost_listener_port(); + let uuid = "550e8400-e29b-41d4-a716-446655440000"; + let mut inner = json!({"type": protocol, "password": "hybrid-test"}); + if protocol == "tuic" { + inner["uuid"] = uuid.into(); + } + let shoes = json!([{ + "address": format!("0.0.0.0:{server_port}"), "transport": "quic", "protocol": inner, + "quic_settings": {"cert": cert_path.to_str().unwrap(), "key": key_path.to_str().unwrap(), + "alpn_protocols": ["h3"], "num_endpoints": 1, "key_exchange_groups": ["X25519MLKEM768"]} + }]); + let mut outbound = json!({"type": protocol, "tag": "peer", "server": "127.0.0.1", "server_port": server_port, "password": "hybrid-test", + "tls": {"enabled": true, "insecure": true, "alpn": ["h3"], "curve_preferences": ["X25519MLKEM768"]}}); + if protocol == "tuic" { + outbound["uuid"] = uuid.into(); + } + let singbox = json!({"log": {"level": "warn"}, "inbounds": [{"type": "http", "listen": "0.0.0.0", "listen_port": client_port}], + "outbounds": [outbound], "route": {"final": "peer"}}); + let _shoes = start_shoes_server(&serde_yaml::to_string(&shoes)?)?; + let _singbox = + shoes_test_support::test_fixture::start_singbox_server(&singbox.to_string())?; + ports.wait_for_all_ports().await?; + echo_through_http(client_port, echo.local_addr().port()).await?; + } + Ok::<_, Box>(()) + }; + tokio::time::timeout(Duration::from_secs(30), scenario).await? +} diff --git a/tests/integration.rs b/tests/integration.rs index 9949e44e..4503d079 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -40,6 +40,8 @@ mod socks; mod socks5_udp_associate; #[path = "cases/integration/socks_uot_integration.rs"] mod socks_uot; +#[path = "cases/integration/tls_hybrid.rs"] +mod tls_hybrid; #[path = "cases/integration/tuic_integration.rs"] mod tuic; #[path = "cases/integration/tuic_udp_integration.rs"] diff --git a/tests/optional_peer.rs b/tests/optional_peer.rs index 778af006..ad3a9950 100644 --- a/tests/optional_peer.rs +++ b/tests/optional_peer.rs @@ -3,3 +3,6 @@ mod anytls_uot_mihomo; #[path = "cases/optional_peer/snell_mihomo.rs"] mod snell_mihomo; + +#[path = "cases/optional_peer/tls_hybrid.rs"] +mod tls_hybrid; diff --git a/tests/process_contract.rs b/tests/process_contract.rs index 4ed3d0c3..bc4079e9 100644 --- a/tests/process_contract.rs +++ b/tests/process_contract.rs @@ -441,6 +441,33 @@ async fn check_alias_and_validation_exit_codes() { ); } +#[tokio::test] +async fn unsupported_quic_allowlists_fail_validation_and_start() { + use serde_json::json; + let groups = json!({"key_exchange_groups": ["X25519MLKEM768"]}); + let direct = json!({"protocol": {"type": "direct"}}); + let guarded_direct = + json!({"protocol": {"type": "direct"}, "transport": "quic", "quic_settings": groups}); + let guarded_proxy = json!({"address": "127.0.0.1:443", "protocol": {"type": "portforward"}, "transport": "quic", "quic_settings": groups}); + for chain in [ + json!([guarded_direct]), + json!([direct, guarded_proxy]), + json!([direct, {"pool": ["guarded"]}]), + ] { + let config = json!([ + {"client_group": "guarded", "client_proxies": [guarded_proxy]}, + {"address": "0.0.0.0:0", "protocol": {"type": "http"}, "rules": [{"mask": "0.0.0.0/0", "action": "allow", "client_chain": chain}]} + ]); + let config_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(config_file.path(), serde_yaml::to_string(&config).unwrap()).unwrap(); + for mode in ["--dry-run", "--no-reload"] { + let mut process = Process::start(config_file.path(), &[mode]); + process.wait_for("QUIC key_exchange_groups").await; + assert_eq!(process.exit().await.code(), Some(1)); + } + } +} + #[tokio::test] async fn initial_partial_bind_failure_exits_and_releases_addresses() { let directory = tempfile::tempdir().unwrap();