use super::accept_signal::{
hello_retry_request_signal, patch_random_tail, random_tail, random_with_zero_tail,
server_hello_signal, signals_eq_ct,
};
use super::config::{
ECH_VERSION_DRAFT_22, EchConfig, EchConfigContents, EchConfigList, HpkeKeyConfig,
HpkeSymCipherSuite,
};
use super::extension::{EchExtension, zero_payload};
use super::grease::{GreaseConfigIdStrategy, GreaseParams};
use super::hpke_setup::{ech_info, map_kem, map_sym_suite};
use super::inner::{
compress_extensions, decode_outer_extensions, decompress_extensions, encode_outer_extensions,
inner_extension_body,
};
use super::keys::{EchKeyPair, EchKeyRing};
use super::retry::{decode_retry_configs, encode_retry_configs};
use crate::hpke::{HpkeAead, HpkeKdf, HpkeKem};
use crate::rng::HmacDrbg;
use crate::tls::Error;
use crate::tls::codec::ExtensionType;
use crate::tls::crypto::HashAlg;
use alloc::vec::Vec;
fn drbg(seed: &[u8]) -> HmacDrbg<crate::hash::Sha256> {
HmacDrbg::<crate::hash::Sha256>::new(seed, b"ech test seed", b"")
}
fn sample_sym_suites() -> Vec<HpkeSymCipherSuite> {
alloc::vec![
HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0001,
},
HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0003,
},
]
}
fn sample_config() -> EchConfig {
let contents = EchConfigContents {
key_config: HpkeKeyConfig {
config_id: 7,
kem_id: HpkeKem::DhkemX25519HkdfSha256.id(),
public_key: alloc::vec![0x11u8; 32],
cipher_suites: sample_sym_suites(),
},
maximum_name_length: 64,
public_name: b"public.example".to_vec(),
extensions: Vec::new(),
};
EchConfig::new(contents)
}
#[test]
fn hpke_sym_cipher_suite_roundtrip() {
let s = HpkeSymCipherSuite {
kdf_id: 0x0002,
aead_id: 0x0003,
};
let mut buf = Vec::new();
s.encode_into(&mut buf);
assert_eq!(buf, alloc::vec![0x00, 0x02, 0x00, 0x03]);
let back = HpkeSymCipherSuite::decode(&buf).unwrap();
assert_eq!(back, s);
}
#[test]
fn hpke_sym_cipher_suite_wrong_length_rejected() {
assert!(matches!(
HpkeSymCipherSuite::decode(&[0u8; 3]),
Err(Error::EchDecodeError)
));
}
#[test]
fn ech_config_list_roundtrip() {
let cfg = sample_config();
let list = EchConfigList::new(alloc::vec![cfg.clone()]);
let bytes = list.encode();
let parsed = EchConfigList::decode(&bytes).unwrap();
assert_eq!(parsed.configs.len(), 1);
let first = parsed.first_supported().unwrap();
assert_eq!(first.version, ECH_VERSION_DRAFT_22);
let c = first.contents.as_ref().unwrap();
assert_eq!(c.key_config.config_id, 7);
assert_eq!(c.key_config.kem_id, HpkeKem::DhkemX25519HkdfSha256.id());
assert_eq!(c.public_name, b"public.example");
}
#[test]
fn ech_config_list_unknown_version_preserved_but_unsupported() {
let supported = sample_config();
let unknown = EchConfig {
version: 0xFEEE,
contents: None,
raw_contents: alloc::vec![0xde, 0xad, 0xbe, 0xef],
};
let list = EchConfigList::new(alloc::vec![unknown.clone(), supported.clone()]);
let bytes = list.encode();
let parsed = EchConfigList::decode(&bytes).unwrap();
assert_eq!(parsed.configs.len(), 2);
assert!(!parsed.configs[0].is_supported());
assert!(parsed.configs[1].is_supported());
let first = parsed.first_supported().unwrap();
assert_eq!(first.version, ECH_VERSION_DRAFT_22);
}
#[test]
fn ech_config_list_empty_rejected() {
let bytes = alloc::vec![0x00, 0x00];
assert!(matches!(
EchConfigList::decode(&bytes),
Err(Error::EchDecodeError)
));
}
#[test]
fn ech_config_list_trailing_bytes_rejected() {
let cfg = sample_config();
let list = EchConfigList::new(alloc::vec![cfg]);
let mut bytes = list.encode();
bytes.push(0x99);
assert!(matches!(
EchConfigList::decode(&bytes),
Err(Error::EchDecodeError)
));
}
#[test]
fn ech_config_truncated_rejected() {
let cfg = sample_config();
let list = EchConfigList::new(alloc::vec![cfg]);
let bytes = list.encode();
let truncated = &bytes[..bytes.len() - 1];
assert!(matches!(
EchConfigList::decode(truncated),
Err(Error::EchDecodeError)
));
}
#[test]
fn ech_config_public_name_must_be_non_empty() {
let mut contents = EchConfigContents {
key_config: HpkeKeyConfig {
config_id: 1,
kem_id: HpkeKem::DhkemX25519HkdfSha256.id(),
public_key: alloc::vec![0u8; 32],
cipher_suites: sample_sym_suites(),
},
maximum_name_length: 32,
public_name: Vec::new(), extensions: Vec::new(),
};
let mut raw = Vec::new();
contents.key_config.encode_into(&mut raw);
raw.push(contents.maximum_name_length);
raw.push(0); raw.extend_from_slice(&[0x00, 0x00]); let cfg = EchConfig {
version: ECH_VERSION_DRAFT_22,
contents: None,
raw_contents: raw,
};
let list = EchConfigList::new(alloc::vec![cfg]);
let bytes = list.encode();
assert!(matches!(
EchConfigList::decode(&bytes),
Err(Error::EchDecodeError)
));
contents.public_name = b"x".to_vec();
}
#[test]
fn mandatory_unknown_extension_rejected() {
let key_config = HpkeKeyConfig {
config_id: 1,
kem_id: HpkeKem::DhkemX25519HkdfSha256.id(),
public_key: alloc::vec![0u8; 32],
cipher_suites: sample_sym_suites(),
};
let mut raw = Vec::new();
key_config.encode_into(&mut raw);
raw.push(64);
raw.push(7);
raw.extend_from_slice(b"example");
let mut ext_bytes = Vec::new();
ext_bytes.extend_from_slice(&0x8001u16.to_be_bytes());
ext_bytes.extend_from_slice(&0u16.to_be_bytes());
raw.extend_from_slice(&(ext_bytes.len() as u16).to_be_bytes());
raw.extend_from_slice(&ext_bytes);
let cfg = EchConfig {
version: ECH_VERSION_DRAFT_22,
contents: None,
raw_contents: raw,
};
let list = EchConfigList::new(alloc::vec![cfg]);
let bytes = list.encode();
assert!(matches!(
EchConfigList::decode(&bytes),
Err(Error::EchDecodeError)
));
}
#[test]
fn ext_outer_roundtrip() {
let ext = EchExtension::Outer {
cipher_suite: HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0003,
},
config_id: 0x42,
enc: alloc::vec![0xAA; 32],
payload: alloc::vec![0xBB; 144],
};
let bytes = ext.encode();
assert_eq!(bytes.len(), 186);
let back = EchExtension::decode(&bytes).unwrap();
assert_eq!(back, ext);
}
#[test]
fn ext_inner_roundtrip() {
let ext = EchExtension::Inner;
let bytes = ext.encode();
assert_eq!(bytes, alloc::vec![0x01]);
let back = EchExtension::decode(&bytes).unwrap();
assert_eq!(back, ext);
}
#[test]
fn ext_inner_extra_bytes_rejected() {
assert!(matches!(
EchExtension::decode(&[0x01, 0x00]),
Err(Error::EchDecodeError)
));
}
#[test]
fn ext_outer_empty_payload_rejected() {
let bytes = alloc::vec![
0x00, 0x00, 0x01, 0x00, 0x01, 0x07, 0x00, 0x01, 0xAA, 0x00, 0x00, ];
assert!(matches!(
EchExtension::decode(&bytes),
Err(Error::EchDecodeError)
));
}
#[test]
fn ext_outer_unknown_type_rejected() {
let bytes = alloc::vec![0x02, 0x00, 0x00];
assert!(matches!(
EchExtension::decode(&bytes),
Err(Error::EchDecodeError)
));
}
#[test]
fn zero_payload_keeps_other_fields() {
let ext = EchExtension::Outer {
cipher_suite: HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0003,
},
config_id: 0x42,
enc: alloc::vec![0xAA; 16],
payload: alloc::vec![0xBB; 32],
};
let bytes = ext.encode();
let zeroed = zero_payload(&bytes).unwrap();
assert_eq!(zeroed.len(), bytes.len());
assert_eq!(&zeroed[..26], &bytes[..26]);
assert!(zeroed[26..].iter().all(|&b| b == 0));
let parsed = EchExtension::decode(&bytes).unwrap();
match parsed {
EchExtension::Outer { payload, .. } => assert!(payload.iter().all(|&b| b == 0xBB)),
_ => panic!("expected outer"),
}
}
#[test]
fn zero_payload_rejects_inner_form() {
assert!(matches!(zero_payload(&[0x01]), Err(Error::EchDecodeError)));
}
#[test]
fn retry_configs_roundtrip() {
let list = EchConfigList::new(alloc::vec![sample_config()]);
let bytes = encode_retry_configs(&list);
let back = decode_retry_configs(&bytes).unwrap();
assert_eq!(back.configs.len(), 1);
assert!(back.configs[0].is_supported());
}
#[test]
fn key_pair_generate_produces_published_pubkey() {
let mut rng = drbg(b"keygen-pubkey-roundtrip");
let kp = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
17,
b"public.example.com",
64,
sample_sym_suites(),
)
.expect("generate");
assert_eq!(kp.config_id(), 17);
let contents = kp.config().contents.as_ref().unwrap();
assert_eq!(
contents.key_config.kem_id,
HpkeKem::DhkemX25519HkdfSha256.id()
);
assert_eq!(contents.key_config.public_key.len(), 32);
assert_eq!(contents.public_name, b"public.example.com");
assert!(kp.accepts(HpkeKdf::HkdfSha256, HpkeAead::Aes128Gcm));
assert!(kp.accepts(HpkeKdf::HkdfSha256, HpkeAead::ChaCha20Poly1305));
assert!(!kp.accepts(HpkeKdf::HkdfSha384, HpkeAead::Aes128Gcm));
}
#[test]
fn key_pair_rejects_empty_public_name() {
let mut rng = drbg(b"keygen-empty-name");
let res = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0,
b"",
64,
sample_sym_suites(),
);
assert!(matches!(res, Err(Error::EchDecodeError)));
}
#[test]
fn key_pair_rejects_empty_suite_list() {
let mut rng = drbg(b"keygen-empty-suites");
let res = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0,
b"x",
64,
Vec::new(),
);
assert!(matches!(res, Err(Error::EchDecodeError)));
}
#[test]
fn key_ring_lookup_by_config_id() {
let mut rng = drbg(b"ring");
let kp1 = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
1,
b"a",
32,
sample_sym_suites(),
)
.unwrap();
let kp2 = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
2,
b"b",
32,
sample_sym_suites(),
)
.unwrap();
let ring = EchKeyRing::from_pairs(alloc::vec![kp1, kp2]);
assert!(ring.find_by_config_id(1).is_some());
assert!(ring.find_by_config_id(2).is_some());
assert!(ring.find_by_config_id(99).is_none());
let list = ring.to_config_list();
assert_eq!(list.configs.len(), 2);
}
#[test]
fn grease_extension_has_expected_shape() {
let mut rng = drbg(b"grease-shape");
let params = GreaseParams {
cipher_suite: HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0001,
},
enc_len: 32,
payload_len: 144,
config_id_strategy: GreaseConfigIdStrategy::Fixed(0xab),
};
let body = params.build_extension_bytes(&mut rng);
let ext = EchExtension::decode(&body).unwrap();
match ext {
EchExtension::Outer {
cipher_suite,
config_id,
enc,
payload,
} => {
assert_eq!(cipher_suite.kdf_id, 0x0001);
assert_eq!(cipher_suite.aead_id, 0x0001);
assert_eq!(config_id, 0xab);
assert_eq!(enc.len(), 32);
assert_eq!(payload.len(), 144);
}
_ => panic!("expected Outer"),
}
assert_eq!(body.len(), 186);
}
#[test]
fn grease_default_is_well_formed() {
let mut rng = drbg(b"grease-default");
let body = GreaseParams::default().build_extension_bytes(&mut rng);
let ext = EchExtension::decode(&body).unwrap();
assert!(matches!(ext, EchExtension::Outer { .. }));
}
#[test]
fn grease_from_seed_differs_per_seed_for_same_ch_random() {
let params = GreaseParams {
cipher_suite: HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0001,
},
enc_len: 32,
payload_len: 144,
config_id_strategy: GreaseConfigIdStrategy::Random,
};
let ch_random = [0x42u8; 32];
let seed_a = [0x01u8; 32];
let seed_b = [0x02u8; 32];
let a = params.build_extension_from_seed(&seed_a, &ch_random);
let b = params.build_extension_from_seed(&seed_b, &ch_random);
assert_ne!(a, b, "GREASE must differ across distinct private seeds");
let a2 = params.build_extension_from_seed(&seed_a, &ch_random);
assert_eq!(a, a2);
}
#[test]
fn grease_from_seed_differs_per_ch_random_for_same_seed() {
let params = GreaseParams::default();
let seed = [0x77u8; 32];
let r1 = [0x11u8; 32];
let r2 = [0x99u8; 32];
let a = params.build_extension_from_seed(&seed, &r1);
let b = params.build_extension_from_seed(&seed, &r2);
assert_ne!(a, b);
}
#[test]
fn map_sym_suite_resolves_supported_pairs() {
let kdfs: &[(u16, HpkeKdf)] = &[
(0x0001, HpkeKdf::HkdfSha256),
(0x0002, HpkeKdf::HkdfSha384),
(0x0003, HpkeKdf::HkdfSha512),
];
let aeads: &[(u16, HpkeAead)] = &[
(0x0001, HpkeAead::Aes128Gcm),
(0x0002, HpkeAead::Aes256Gcm),
(0x0003, HpkeAead::ChaCha20Poly1305),
];
for &(kid, kexp) in kdfs {
for &(aid, aexp) in aeads {
let s = HpkeSymCipherSuite {
kdf_id: kid,
aead_id: aid,
};
let (k, a) = map_sym_suite(s).unwrap();
assert_eq!(k, kexp);
assert_eq!(a, aexp);
}
}
}
#[test]
fn map_sym_suite_rejects_export_only_and_unknowns() {
let s = HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0xFFFF,
};
assert!(matches!(map_sym_suite(s), Err(Error::EchDecodeError)));
let s = HpkeSymCipherSuite {
kdf_id: 0xABCD,
aead_id: 0x0001,
};
assert!(matches!(map_sym_suite(s), Err(Error::EchDecodeError)));
}
#[test]
fn map_kem_resolves_all_supported() {
assert_eq!(map_kem(0x0010).unwrap(), HpkeKem::DhkemP256HkdfSha256);
assert_eq!(map_kem(0x0011).unwrap(), HpkeKem::DhkemP384HkdfSha384);
assert_eq!(map_kem(0x0012).unwrap(), HpkeKem::DhkemP521HkdfSha512);
assert_eq!(map_kem(0x0020).unwrap(), HpkeKem::DhkemX25519HkdfSha256);
assert!(matches!(map_kem(0xDEAD), Err(Error::EchDecodeError)));
}
#[test]
fn ech_info_prefix_and_shape() {
let cfg = sample_config();
let info = ech_info(&cfg).expect("sample config raw_contents fits in u16");
assert!(info.starts_with(b"tls ech\0"));
let after_prefix = &info[8..];
assert_eq!(&after_prefix[..2], &ECH_VERSION_DRAFT_22.to_be_bytes());
let len = u16::from_be_bytes([after_prefix[2], after_prefix[3]]) as usize;
assert_eq!(len, cfg.raw_contents.len());
assert_eq!(&after_prefix[4..], &cfg.raw_contents[..]);
}
#[test]
fn ech_info_rejects_oversize_raw_contents() {
let mut cfg = sample_config();
cfg.raw_contents = alloc::vec![0u8; (u16::MAX as usize) + 1];
assert!(matches!(ech_info(&cfg), Err(Error::EchDecodeError)));
}
#[test]
fn inner_extension_body_matches_marker() {
assert_eq!(inner_extension_body(), alloc::vec![0x01]);
}
#[test]
fn accept_signal_deterministic_and_label_separated() {
let inner_random = [0x42u8; 32];
let th = [0x33u8; 32];
let sh = server_hello_signal(HashAlg::Sha256, &inner_random, &th);
let sh2 = server_hello_signal(HashAlg::Sha256, &inner_random, &th);
assert_eq!(sh, sh2);
let hrr = hello_retry_request_signal(HashAlg::Sha256, &inner_random, &th);
assert_ne!(sh, hrr);
}
#[test]
fn accept_signals_match_raw_hkdf_formula() {
use crate::hash::{Sha256, Sha384};
use crate::kdf::{hkdf_expand, hkdf_extract};
fn raw<D: crate::hash::Digest>(
label: &[u8],
inner_random: &[u8; 32],
transcript_hash: &[u8],
) -> [u8; 8] {
let zeros = alloc::vec![0u8; D::OUTPUT_LEN];
let prk = hkdf_extract::<D>(&zeros, inner_random);
let mut info = Vec::new();
info.extend_from_slice(&8u16.to_be_bytes());
info.push((6 + label.len()) as u8);
info.extend_from_slice(b"tls13 ");
info.extend_from_slice(label);
info.push(transcript_hash.len() as u8);
info.extend_from_slice(transcript_hash);
let mut out = [0u8; 8];
hkdf_expand::<D>(&prk, &info, &mut out);
out
}
let inner_random: [u8; 32] = core::array::from_fn(|i| i as u8);
let th256 = HashAlg::Sha256.hash(b"transcript_ech_conf");
let th384 = HashAlg::Sha384.hash(b"transcript_ech_conf");
assert_eq!(
server_hello_signal(HashAlg::Sha256, &inner_random, th256.as_slice()),
raw::<Sha256>(b"ech accept confirmation", &inner_random, th256.as_slice()),
);
assert_eq!(
server_hello_signal(HashAlg::Sha384, &inner_random, th384.as_slice()),
raw::<Sha384>(b"ech accept confirmation", &inner_random, th384.as_slice()),
);
assert_eq!(
hello_retry_request_signal(HashAlg::Sha256, &inner_random, th256.as_slice()),
raw::<Sha256>(
b"hrr ech accept confirmation",
&inner_random,
th256.as_slice()
),
);
assert_eq!(
hello_retry_request_signal(HashAlg::Sha384, &inner_random, th384.as_slice()),
raw::<Sha384>(
b"hrr ech accept confirmation",
&inner_random,
th384.as_slice()
),
);
}
#[test]
fn accept_signal_constant_time_eq_matches_plain_eq() {
let mut a = [0u8; 8];
let mut b = [0u8; 8];
assert!(signals_eq_ct(&a, &b));
a[3] = 1;
assert!(!signals_eq_ct(&a, &b));
b[3] = 1;
assert!(signals_eq_ct(&a, &b));
}
#[test]
fn random_tail_helpers_invert_each_other() {
let r = [0xAAu8; 32];
let signal = [0xBBu8; 8];
let patched = patch_random_tail(&r, &signal);
assert_eq!(&patched[..24], &r[..24]);
assert_eq!(random_tail(&patched), signal);
let zeroed = random_with_zero_tail(&r);
assert_eq!(&zeroed[..24], &r[..24]);
assert_eq!(&zeroed[24..], &[0u8; 8]);
}
#[test]
fn ech_config_list_supports_first_supported_when_first_is_unknown() {
let unknown = EchConfig {
version: 0xFFFF,
contents: None,
raw_contents: alloc::vec![0xCA, 0xFE],
};
let supported = sample_config();
let list = EchConfigList::new(alloc::vec![unknown, supported]);
let first = list.first_supported().expect("a supported entry");
assert_eq!(first.version, ECH_VERSION_DRAFT_22);
}
fn ext(t: ExtensionType, body: &[u8]) -> (ExtensionType, Vec<u8>) {
(t, body.to_vec())
}
#[test]
fn outer_extensions_body_roundtrip() {
let types = [
ExtensionType::SUPPORTED_GROUPS,
ExtensionType::KEY_SHARE,
ExtensionType::SUPPORTED_VERSIONS,
];
let body = encode_outer_extensions(&types);
let decoded = decode_outer_extensions(&body).expect("decode");
assert_eq!(decoded, types);
assert_eq!(body[0] as usize, types.len() * 2);
assert_eq!(body.len(), 1 + types.len() * 2);
}
#[test]
fn outer_extensions_body_rejects_malformed() {
assert!(matches!(
decode_outer_extensions(&[]),
Err(Error::EchDecodeError)
));
assert!(matches!(
decode_outer_extensions(&[0x04, 0x00, 0x0a]),
Err(Error::EchDecodeError)
));
assert!(matches!(
decode_outer_extensions(&[0x03, 0x00, 0x0a, 0x00]),
Err(Error::EchDecodeError)
));
assert!(matches!(
decode_outer_extensions(&[0x00]),
Err(Error::EchDecodeError)
));
assert!(matches!(
decode_outer_extensions(&[0x02, 0x00, 0x0a, 0xff]),
Err(Error::EchDecodeError)
));
}
#[test]
fn compress_then_decompress_yields_canonical() {
let outer = alloc::vec![
ext(ExtensionType::SERVER_NAME, b"public.example"),
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00, 0x02, 0x00, 0x1d]),
ext(
ExtensionType::SIGNATURE_ALGORITHMS,
&[0x00, 0x02, 0x08, 0x07]
),
ext(ExtensionType::KEY_SHARE, &[0x00, 0x00]),
ext(ExtensionType::SUPPORTED_VERSIONS, &[0x02, 0x03, 0x04]),
];
let canonical_inner = alloc::vec![
ext(ExtensionType::SERVER_NAME, b"secret.example"),
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00, 0x02, 0x00, 0x1d]),
ext(
ExtensionType::SIGNATURE_ALGORITHMS,
&[0x00, 0x02, 0x08, 0x07]
),
ext(ExtensionType::KEY_SHARE, &[0x00, 0x00]),
ext(ExtensionType::SUPPORTED_VERSIONS, &[0x02, 0x03, 0x04]),
(
ExtensionType::ENCRYPTED_CLIENT_HELLO,
inner_extension_body()
),
];
let share = [
ExtensionType::SUPPORTED_GROUPS,
ExtensionType::SIGNATURE_ALGORITHMS,
ExtensionType::KEY_SHARE,
ExtensionType::SUPPORTED_VERSIONS,
];
let compressed = compress_extensions(&canonical_inner, &outer, &share).expect("compress");
assert_eq!(compressed.len(), 3);
assert_eq!(compressed[0].0, ExtensionType::SERVER_NAME);
assert_eq!(compressed[1].0, ExtensionType::ECH_OUTER_EXTENSIONS);
assert_eq!(compressed[2].0, ExtensionType::ENCRYPTED_CLIENT_HELLO);
let decompressed = decompress_extensions(&compressed, &outer).expect("decompress");
assert_eq!(decompressed, canonical_inner);
}
#[test]
fn compress_empty_share_is_identity() {
let outer = alloc::vec![ext(ExtensionType::SERVER_NAME, b"public.example")];
let canonical = alloc::vec![
ext(ExtensionType::SERVER_NAME, b"secret.example"),
(
ExtensionType::ENCRYPTED_CLIENT_HELLO,
inner_extension_body()
),
];
let out = compress_extensions(&canonical, &outer, &[]).expect("identity");
assert_eq!(out, canonical);
}
#[test]
fn compress_rejects_duplicate_share_types() {
let outer = alloc::vec![
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00]),
ext(ExtensionType::KEY_SHARE, &[0x00]),
];
let canonical = alloc::vec![
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00]),
ext(ExtensionType::KEY_SHARE, &[0x00]),
];
let share = [
ExtensionType::SUPPORTED_GROUPS,
ExtensionType::KEY_SHARE,
ExtensionType::SUPPORTED_GROUPS,
];
assert!(matches!(
compress_extensions(&canonical, &outer, &share),
Err(Error::EchDecodeError)
));
}
#[test]
fn compress_rejects_reserved_share_types() {
let outer = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let canonical = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let share_a = [ExtensionType::ECH_OUTER_EXTENSIONS];
let share_b = [ExtensionType::ENCRYPTED_CLIENT_HELLO];
assert!(matches!(
compress_extensions(&canonical, &outer, &share_a),
Err(Error::EchDecodeError)
));
assert!(matches!(
compress_extensions(&canonical, &outer, &share_b),
Err(Error::EchDecodeError)
));
}
#[test]
fn compress_rejects_when_share_block_not_contiguous_in_inner() {
let outer = alloc::vec![
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00]),
ext(ExtensionType::KEY_SHARE, &[0x00]),
];
let canonical = alloc::vec![
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00]),
ext(ExtensionType::SERVER_NAME, b"x"),
ext(ExtensionType::KEY_SHARE, &[0x00]),
];
let share = [ExtensionType::SUPPORTED_GROUPS, ExtensionType::KEY_SHARE];
assert!(matches!(
compress_extensions(&canonical, &outer, &share),
Err(Error::EchDecodeError)
));
}
#[test]
fn decompress_rejects_unknown_type_referenced() {
let outer = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let compressed = alloc::vec![
ext(ExtensionType::SERVER_NAME, b"x"),
(
ExtensionType::ECH_OUTER_EXTENSIONS,
encode_outer_extensions(&[ExtensionType::KEY_SHARE]),
),
];
assert!(matches!(
decompress_extensions(&compressed, &outer),
Err(Error::EchDecodeError)
));
}
#[test]
fn decompress_rejects_wrong_order_in_outer() {
let outer = alloc::vec![
ext(ExtensionType::KEY_SHARE, &[0x00]),
ext(ExtensionType::SUPPORTED_GROUPS, &[0x00]),
];
let compressed = alloc::vec![(
ExtensionType::ECH_OUTER_EXTENSIONS,
encode_outer_extensions(&[ExtensionType::SUPPORTED_GROUPS, ExtensionType::KEY_SHARE,]),
)];
assert!(matches!(
decompress_extensions(&compressed, &outer),
Err(Error::EchDecodeError)
));
}
#[test]
fn decompress_rejects_multiple_placeholders() {
let outer = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let compressed = alloc::vec![
(
ExtensionType::ECH_OUTER_EXTENSIONS,
encode_outer_extensions(&[ExtensionType::SUPPORTED_GROUPS]),
),
(
ExtensionType::ECH_OUTER_EXTENSIONS,
encode_outer_extensions(&[ExtensionType::SUPPORTED_GROUPS]),
),
];
assert!(matches!(
decompress_extensions(&compressed, &outer),
Err(Error::EchDecodeError)
));
}
#[test]
fn decompress_rejects_reserved_in_list() {
let outer = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let mut body = alloc::vec![2u8];
body.extend_from_slice(&ExtensionType::ENCRYPTED_CLIENT_HELLO.0.to_be_bytes());
let compressed = alloc::vec![(ExtensionType::ECH_OUTER_EXTENSIONS, body)];
assert!(matches!(
decompress_extensions(&compressed, &outer),
Err(Error::EchDecodeError)
));
}
#[test]
fn decompress_passes_through_when_no_placeholder() {
let outer = alloc::vec![ext(ExtensionType::SUPPORTED_GROUPS, &[0x00])];
let compressed = alloc::vec![
ext(ExtensionType::SERVER_NAME, b"secret.example"),
(
ExtensionType::ENCRYPTED_CLIENT_HELLO,
inner_extension_body()
),
];
let out = decompress_extensions(&compressed, &outer).expect("passthrough");
assert_eq!(out, compressed);
}
use super::outer::{
HPKE_TAG_LEN, build_outer_ext_body, locate_payload_in_handshake, pad_inner, seal_with,
try_decap_inner,
};
fn build_outer_ch_with_ech(ech_ext_body: &[u8]) -> Vec<u8> {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes()); body.extend_from_slice(&[0x42u8; 32]); body.push(0); body.extend_from_slice(&2u16.to_be_bytes()); body.extend_from_slice(&0x1301u16.to_be_bytes()); body.push(1); body.push(0); let mut exts: Vec<u8> = Vec::new();
let ty = ExtensionType::ENCRYPTED_CLIENT_HELLO.0;
exts.extend_from_slice(&ty.to_be_bytes());
let bl: u16 = u16::try_from(ech_ext_body.len()).unwrap();
exts.extend_from_slice(&bl.to_be_bytes());
exts.extend_from_slice(ech_ext_body);
let el: u16 = u16::try_from(exts.len()).unwrap();
body.extend_from_slice(&el.to_be_bytes());
body.extend_from_slice(&exts);
let mut msg: Vec<u8> = Vec::new();
msg.push(crate::tls::codec::hs_type::CLIENT_HELLO);
let bl_u32 = u32::try_from(body.len()).unwrap();
msg.push(((bl_u32 >> 16) & 0xff) as u8);
msg.push(((bl_u32 >> 8) & 0xff) as u8);
msg.push((bl_u32 & 0xff) as u8);
msg.extend_from_slice(&body);
msg
}
fn build_inner_ch_without_marker() -> Vec<u8> {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes());
body.extend_from_slice(&[0x55u8; 32]);
body.push(0); body.extend_from_slice(&2u16.to_be_bytes());
body.extend_from_slice(&0x1301u16.to_be_bytes());
body.push(1);
body.push(0);
let mut exts: Vec<u8> = Vec::new();
exts.extend_from_slice(&ExtensionType::SERVER_NAME.0.to_be_bytes());
exts.extend_from_slice(&0u16.to_be_bytes());
let el: u16 = u16::try_from(exts.len()).unwrap();
body.extend_from_slice(&el.to_be_bytes());
body.extend_from_slice(&exts);
let mut msg: Vec<u8> = Vec::new();
msg.push(crate::tls::codec::hs_type::CLIENT_HELLO);
let bl_u32 = u32::try_from(body.len()).unwrap();
msg.push(((bl_u32 >> 16) & 0xff) as u8);
msg.push(((bl_u32 >> 8) & 0xff) as u8);
msg.push((bl_u32 & 0xff) as u8);
msg.extend_from_slice(&body);
msg
}
fn build_inner_ch_marker() -> Vec<u8> {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes());
body.extend_from_slice(&[0x55u8; 32]);
body.push(0); body.extend_from_slice(&2u16.to_be_bytes());
body.extend_from_slice(&0x1301u16.to_be_bytes());
body.push(1);
body.push(0);
let mut exts: Vec<u8> = Vec::new();
let ech_inner = inner_extension_body();
exts.extend_from_slice(&ExtensionType::ENCRYPTED_CLIENT_HELLO.0.to_be_bytes());
let bl: u16 = u16::try_from(ech_inner.len()).unwrap();
exts.extend_from_slice(&bl.to_be_bytes());
exts.extend_from_slice(&ech_inner);
let el: u16 = u16::try_from(exts.len()).unwrap();
body.extend_from_slice(&el.to_be_bytes());
body.extend_from_slice(&exts);
let mut msg: Vec<u8> = Vec::new();
msg.push(crate::tls::codec::hs_type::CLIENT_HELLO);
let bl_u32 = u32::try_from(body.len()).unwrap();
msg.push(((bl_u32 >> 16) & 0xff) as u8);
msg.push(((bl_u32 >> 8) & 0xff) as u8);
msg.push((bl_u32 & 0xff) as u8);
msg.extend_from_slice(&body);
msg
}
#[test]
fn pad_inner_rounds_up_to_multiple_of_32_with_min_32() {
let p = pad_inner(&[0x11u8; 5], 0, 0);
assert_eq!(p.len(), 32);
assert_eq!(&p[..5], &[0x11; 5]);
assert!(p[5..].iter().all(|b| *b == 0));
}
#[test]
fn pad_inner_extra_for_sni_shorter_than_maximum_name_length() {
const L_IN: usize = 40;
let l_sni = 5usize;
let l_max = 64u8;
let p = pad_inner(&[0xAAu8; L_IN], l_sni, l_max);
assert_eq!(p.len(), 128);
assert!(p[L_IN..].iter().all(|b| *b == 0));
}
#[test]
fn pad_inner_no_extra_when_sni_at_least_max() {
let p = pad_inner(&[0xBBu8; 50], 200, 64);
assert_eq!(p.len(), 64);
}
#[test]
fn locate_payload_finds_ech_payload_offset_and_length() {
let ext_body = build_outer_ext_body(
HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0001,
},
0xab,
&[0x77u8; 32],
48,
);
let msg = build_outer_ch_with_ech(&ext_body);
let (off, len) = locate_payload_in_handshake(&msg).expect("locate");
assert_eq!(len, 48 + HPKE_TAG_LEN);
assert!(msg[off..off + len].iter().all(|b| *b == 0));
}
#[test]
fn locate_payload_rejects_wrong_handshake_type() {
let mut msg = build_outer_ch_with_ech(&build_outer_ext_body(
HpkeSymCipherSuite {
kdf_id: 0x0001,
aead_id: 0x0001,
},
1,
&[0x00u8; 8],
32,
));
msg[0] = crate::tls::codec::hs_type::SERVER_HELLO;
assert!(matches!(
locate_payload_in_handshake(&msg),
Err(Error::EchDecodeError)
));
}
#[test]
fn locate_payload_rejects_no_ech_extension() {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(&0x0303u16.to_be_bytes());
body.extend_from_slice(&[0u8; 32]);
body.push(0);
body.extend_from_slice(&2u16.to_be_bytes());
body.extend_from_slice(&0x1301u16.to_be_bytes());
body.push(1);
body.push(0);
let mut exts: Vec<u8> = Vec::new();
exts.extend_from_slice(&ExtensionType::SERVER_NAME.0.to_be_bytes());
exts.extend_from_slice(&0u16.to_be_bytes());
let el: u16 = u16::try_from(exts.len()).unwrap();
body.extend_from_slice(&el.to_be_bytes());
body.extend_from_slice(&exts);
let mut msg: Vec<u8> = Vec::new();
msg.push(crate::tls::codec::hs_type::CLIENT_HELLO);
let bl = u32::try_from(body.len()).unwrap();
msg.extend_from_slice(&[
((bl >> 16) & 0xff) as u8,
((bl >> 8) & 0xff) as u8,
(bl & 0xff) as u8,
]);
msg.extend_from_slice(&body);
assert!(matches!(
locate_payload_in_handshake(&msg),
Err(Error::EchDecodeError)
));
}
#[test]
fn seal_and_decap_round_trip_x25519_aes128gcm() {
let mut rng = drbg(b"seal-x25519");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x42,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x42, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
let recovered = try_decap_inner(&sealed.outer_ch, &ring).expect("decap");
assert_eq!(recovered.inner_ch_bytes, inner);
}
#[test]
fn decap_rejects_inner_ch_without_marker() {
let mut rng = drbg(b"decap-no-marker");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x42,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_without_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x42, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
assert!(matches!(
try_decap_inner(&sealed.outer_ch, &ring),
Err(Error::EchDecodeError)
));
}
#[test]
fn decap_rejects_unknown_config_id() {
let mut rng = drbg(b"decap-cfg-id");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x42,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x99, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
assert!(matches!(
try_decap_inner(&sealed.outer_ch, &ring),
Err(Error::EchDecryptionFailed)
));
}
#[test]
fn decap_rejects_unpublished_hpke_suite() {
let mut rng = drbg(b"decap-unpublished-suite");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x42,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_marker();
let unpublished = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes256Gcm.id(),
};
let sealed = seal_with(
&config,
unpublished,
&inner,
5,
&mut rng,
|enc, padded_len| {
let body = build_outer_ext_body(unpublished, 0x42, enc, padded_len);
build_outer_ch_with_ech(&body)
},
)
.expect("seal");
assert!(matches!(
try_decap_inner(&sealed.outer_ch, &ring),
Err(Error::EchDecryptionFailed)
));
let published = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let ok = seal_with(
&config,
published,
&inner,
5,
&mut rng,
|enc, padded_len| {
let body = build_outer_ext_body(published, 0x42, enc, padded_len);
build_outer_ch_with_ech(&body)
},
)
.expect("seal");
let recovered = try_decap_inner(&ok.outer_ch, &ring).expect("decap");
assert_eq!(recovered.inner_ch_bytes, inner);
}
#[test]
fn decap_rejects_aead_corruption() {
let mut rng = drbg(b"decap-corrupt");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x07,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x07, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
let (off, len) = locate_payload_in_handshake(&sealed.outer_ch).expect("locate");
let mut corrupted = sealed.outer_ch.clone();
let _ = len;
corrupted[off] ^= 0x01;
assert!(matches!(
try_decap_inner(&corrupted, &ring),
Err(Error::EchDecryptionFailed)
));
}
#[test]
fn decap_rejects_aad_mutation_outside_payload() {
let mut rng = drbg(b"decap-aad");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x11,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner = build_inner_ch_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x11, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
let mut tampered = sealed.outer_ch.clone();
tampered[10] ^= 0xff;
assert!(matches!(
try_decap_inner(&tampered, &ring),
Err(Error::EchDecryptionFailed)
));
}
#[test]
fn full_ech_round_trip_seal_decap_and_accept_signal() {
let mut rng = drbg(b"phase5-e2e");
let suites = alloc::vec![HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
}];
let pair = EchKeyPair::generate(
&mut rng,
HpkeKem::DhkemX25519HkdfSha256,
0x42,
b"public.example",
64,
suites,
)
.expect("generate");
let config = pair.config().clone();
let ring = EchKeyRing::from_pairs(alloc::vec![pair]);
let inner_ch = build_inner_ch_marker();
let sym = HpkeSymCipherSuite {
kdf_id: HpkeKdf::HkdfSha256.id(),
aead_id: HpkeAead::Aes128Gcm.id(),
};
let sealed = seal_with(&config, sym, &inner_ch, 5, &mut rng, |enc, padded_len| {
let body = build_outer_ext_body(sym, 0x42, enc, padded_len);
build_outer_ch_with_ech(&body)
})
.expect("seal");
let recovered_inner = try_decap_inner(&sealed.outer_ch, &ring).expect("decap");
assert_eq!(recovered_inner.inner_ch_bytes, inner_ch);
let sh_random = [0x77u8; 32];
let sh_with_zero_tail_random = random_with_zero_tail(&sh_random);
let alg = HashAlg::Sha256;
let mut to_hash = inner_ch.clone();
to_hash.extend_from_slice(&sh_with_zero_tail_random);
let th = alg.hash(&to_hash);
let mut inner_ch_random = [0u8; 32];
inner_ch_random.copy_from_slice(&inner_ch[6..38]);
let signal = server_hello_signal(alg, &inner_ch_random, th.as_slice());
let patched_random = patch_random_tail(&sh_random, &signal);
assert_eq!(random_tail(&patched_random), signal);
let mut client_hash_input = inner_ch.clone();
client_hash_input.extend_from_slice(&random_with_zero_tail(&patched_random));
let client_th = alg.hash(&client_hash_input);
let expected = server_hello_signal(alg, &inner_ch_random, client_th.as_slice());
let received = random_tail(&patched_random);
assert!(signals_eq_ct(&expected, &received));
let wrong_random = [0xCDu8; 32];
let wrong_expected = server_hello_signal(alg, &wrong_random, client_th.as_slice());
assert!(!signals_eq_ct(&wrong_expected, &received));
}
#[test]
fn ech_rejected_retry_configs_round_trip_in_ee_body() {
let list = EchConfigList::new(alloc::vec![sample_config()]);
let encoded = encode_retry_configs(&list);
let decoded = decode_retry_configs(&encoded).expect("decode");
assert_eq!(list.encode(), decoded.encode());
let err = Error::EchRejected(encoded.clone());
match err {
Error::EchRejected(bytes) => {
let again = decode_retry_configs(&bytes).expect("re-decode");
assert_eq!(again.encode(), list.encode());
}
_ => panic!("wrong variant"),
}
}