Support native hybrid TLS key-exchange allowlists (#177)

Add ordered native rustls key-exchange allowlists for TLS and QUIC. Hybrid-only configurations require TLS 1.3 without classical fallback or early data; omitted settings preserve existing defaults. Reject incompatible or ignored security settings, document configuration, and cover transport and Xray/sing-box interoperability.
This commit is contained in:
cfal
2026-10-10 20:33:30 +08:00
committed by GitHub
parent 657501211c
commit eb47459fc1
20 changed files with 1413 additions and 16 deletions
+16 -1
View File
@@ -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: >-
+50
View File
@@ -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:
+54 -1
View File
@@ -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<String>, Box<ClientProxyConfig>), D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ShadowTlsFields {
password: String,
#[serde(default)]
sni_hostname: Option<String>,
protocol: Box<ClientProxyConfig>,
}
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<ClientProxyConfig>,
},
#[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(
+2
View File
@@ -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;
+11
View File
@@ -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,
+164
View File
@@ -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<Vec<TlsKeyExchangeGroup>>);
impl<'de> Deserialize<'de> for TlsKeyExchangeGroups {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let groups = Vec::<TlsKeyExchangeGroup>::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::<TlsKeyExchangeGroups>(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::<ClientProxyConfig>(&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::<ClientProxyConfig>(yaml).is_err());
}
let yaml = "password: password\nkey_exchange_groups: [X25519MLKEM768]\nhandshake: {cert: cert, key: key}\nprotocol: {type: socks}";
assert!(serde_yaml::from_str::<crate::config::ShadowTlsServerConfig>(yaml).is_err());
}
}
+11
View File
@@ -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,
+140
View File
@@ -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<String, String>,
) -> 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::<std::io::Result<Vec<_>>>()?;
for (index, hop) in expanded_hops.iter().enumerate() {
let validate = |selection: &ConfigSelection<ClientConfig>| 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::<ClientConfig>(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;
+12 -1
View File
@@ -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()),
+56 -13
View File
@@ -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<String>,
@@ -11,15 +16,17 @@ pub fn try_create_client_config(
enable_sni: bool,
client_key_and_cert: Option<(Vec<u8>, Vec<u8>)>,
tls13_only: bool,
key_exchange_groups: &TlsKeyExchangeGroups,
) -> std::io::Result<rustls::ClientConfig> {
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<rustls::crypto::CryptoProvider> {
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<DisabledVerifier> {
static INSTANCE: OnceLock<Arc<DisabledVerifier>> = OnceLock::new();
INSTANCE
@@ -236,6 +257,7 @@ pub fn try_create_server_config(
ca_cert_bytes: Vec<Vec<u8>>,
alpn_protocols: &[String],
client_fingerprints: &[String],
key_exchange_groups: &TlsKeyExchangeGroups,
) -> std::io::Result<rustls::ServerConfig> {
// 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()
);
+405
View File
@@ -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<rustls::ClientConfig>,
server: Arc<rustls::ServerConfig>,
) -> 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::<Vec<_>>(),
[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::<Vec<_>>(),
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();
}
+50
View File
@@ -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<InitialHopEntry> = 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(
+3
View File
@@ -154,6 +154,7 @@ impl SocketConnectorImpl {
target_address: Option<&NetLocation>,
limits: crate::config::GlobalLimits,
) -> std::io::Result<Self> {
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(
+4
View File
@@ -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())?;
+4
View File
@@ -312,6 +312,7 @@ fn create_tls_server_target(
bind_ip: Option<IpAddr>,
) -> std::io::Result<TlsServerTarget> {
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)
+238
View File
@@ -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<rustls::crypto::CryptoProvider> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>>(())
};
tokio::time::timeout(Duration::from_secs(40), scenario).await?
}
+161
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>>(())
};
tokio::time::timeout(Duration::from_secs(45), scenario).await?
}
#[tokio::test]
async fn singbox_hybrid_quic_tuic_and_hysteria2() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>>(())
};
tokio::time::timeout(Duration::from_secs(30), scenario).await?
}
+2
View File
@@ -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"]
+3
View File
@@ -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;
+27
View File
@@ -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();