Skip to content

Commit e760777

Browse files
committed
tests: support multiple HTTPS RRs for ECH configs
Similar to a change in the upstream Rustls ech-client.rs demo we want to be able to process _multiple_ HTTPS records for a given domain, and look at each ECH config list from each record for a potential compatible config. Mechanically this means: 1. Updating the `test/ech_fetch.rs` helper to support writing multiple `.bin` files when there are multiple HTTPS records w/ ECH configs. The tool now outputs to stdout a comma separated list of the files it writes to make it easier to use with the `client.c` example. 2. Updating the `tests/client.c` example to treat the `ECH_CONFIG_LIST` env var as a comma separated list of ECH config lists. We now loop through each and only fail if all of the provided files are unable to be used to configure the client config with a compatible ECH config. Doing string manipulation with C remains "a delight". For Windows compat we achieve tokenizing the string by the comma delim with a define to call either `strtok_r` with GCC/clang, or `strtok_s` with MSCV. You can test this update with: ``` ECH_CONFIG_LISTS=$(cargo test --test ech_fetch -- curves1-ng.test.defo.ie /tmp/curves1-ng.test.defo.ie) RUSTLS_PLATFORM_VERIFIER=1 ECH_CONFIG_LIST="$ECH_CONFIG_LISTS" ./cmake-build-debug/tests/client curves1-ng.test.defo.ie 443 /echstat.php?format=json ``` If you're unlucky and the first HTTPS record served is the one with invalid configs you should see output like the following showing the client skipping over the `.1` config list and using the `.2` one instead: ``` client[188911]: no compatible/valid ECH configs found in '/tmp/curves1-ng.test.defo.ie.1' client[188911]: using ECH with config list from '/tmp/curves1-ng.test.defo.ie.2' ```
1 parent b94cb0c commit e760777

File tree

4 files changed

+130
-41
lines changed

4 files changed

+130
-41
lines changed

tests/client.c

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ options_from_env(demo_client_options *opts)
158158

159159
// Consider ECH options.
160160
const char *ech_grease = getenv("ECH_GREASE");
161-
const char *ech_config_list = getenv("ECH_CONFIG_LIST");
162-
if(ech_grease && ech_config_list) {
161+
const char *ech_config_lists = getenv("ECH_CONFIG_LIST");
162+
if(ech_grease && ech_config_lists) {
163163
LOG_SIMPLE(
164164
"must set at most one of ECH_GREASE or ECH_CONFIG_LIST env vars");
165165
return 1;
@@ -168,9 +168,9 @@ options_from_env(demo_client_options *opts)
168168
LOG_SIMPLE("using ECH grease");
169169
opts->use_ech_grease = true;
170170
}
171-
else if(ech_config_list) {
172-
LOG("using ECH config list '%s'", ech_config_list);
173-
opts->use_ech_config_list_file = ech_config_list;
171+
else if(ech_config_lists) {
172+
LOG("using ECH config lists '%s'", ech_config_lists);
173+
opts->use_ech_config_list_files = ech_config_lists;
174174
}
175175

176176
// Consider SSLKEYLOGFILE options.
@@ -304,30 +304,80 @@ new_tls_config(const demo_client_options *opts)
304304
goto cleanup;
305305
}
306306
}
307-
else if(opts->use_ech_config_list_file) {
307+
else if(opts->use_ech_config_list_files) {
308308
const rustls_hpke *hpke = rustls_supported_hpke();
309309
if(hpke == NULL) {
310310
LOG_SIMPLE("client: no HPKE suites for ECH available");
311311
goto cleanup;
312312
}
313-
char ech_config_list_buf[10000];
314-
size_t ech_config_list_len;
315-
const demo_result read_result = read_file(opts->use_ech_config_list_file,
316-
ech_config_list_buf,
317-
sizeof(ech_config_list_buf),
318-
&ech_config_list_len);
319-
if(read_result != DEMO_OK) {
320-
LOG("client: failed to read ECH config list file: '%s'",
321-
getenv("RUSTLS_ECH_CONFIG_LIST"));
313+
314+
// Duplicate the config lists var value - calling STRTOK_R will modify the
315+
// string to add null terminators between tokens.
316+
char *ech_config_list_copy = strdup(opts->use_ech_config_list_files);
317+
if(!ech_config_list_copy) {
318+
LOG_SIMPLE("failed to allocate memory for ECH config list");
322319
goto cleanup;
323320
}
324-
const rustls_result rr =
325-
rustls_client_config_builder_enable_ech(config_builder,
326-
(uint8_t *)ech_config_list_buf,
327-
ech_config_list_len,
328-
hpke);
329-
if(rr != RUSTLS_RESULT_OK) {
330-
print_error("failed to configure ECH", rr);
321+
322+
bool ech_configured = false;
323+
// Tokenize the ech_config_list_copy by comma. The first invocation takes
324+
// ech_config_list_copy. This is reentrant by virtue of saving state to
325+
// saveptr. Only the _first_ invocation is given the original string.
326+
// Subsequent calls should pass NULL and the same delim/saveptr.
327+
const char *delim = ",";
328+
char *saveptr = NULL;
329+
char *ech_config_list_path =
330+
STRTOK_R(ech_config_list_copy, delim, &saveptr);
331+
332+
while(ech_config_list_path) {
333+
// Skip leading spaces
334+
while(*ech_config_list_path == ' ') {
335+
ech_config_list_path++;
336+
}
337+
338+
// Try to read the token as a file path to an ECH config list.
339+
char ech_config_list_buf[10000];
340+
size_t ech_config_list_len;
341+
const enum demo_result read_result =
342+
read_file(ech_config_list_path,
343+
ech_config_list_buf,
344+
sizeof(ech_config_list_buf),
345+
&ech_config_list_len);
346+
347+
// If we can't read the file, warn and continue
348+
if(read_result != DEMO_OK) {
349+
// Continue to the next token.
350+
LOG("unable to read ECH config list from '%s'", ech_config_list_path);
351+
ech_config_list_path = STRTOK_R(NULL, delim, &saveptr);
352+
continue;
353+
}
354+
355+
// Try to enable ECH with the config list. This may error if none
356+
// of the ECH configs are valid/compatible.
357+
const rustls_result rr =
358+
rustls_client_config_builder_enable_ech(config_builder,
359+
(uint8_t *)ech_config_list_buf,
360+
ech_config_list_len,
361+
hpke);
362+
363+
// If we successfully configured ECH with the config list then break.
364+
if(rr == RUSTLS_RESULT_OK) {
365+
LOG("using ECH with config list from '%s'", ech_config_list_path);
366+
ech_configured = true;
367+
break;
368+
}
369+
370+
// Otherwise continue to the next token.
371+
LOG("no compatible/valid ECH configs found in '%s'",
372+
ech_config_list_path);
373+
ech_config_list_path = STRTOK_R(NULL, delim, &saveptr);
374+
}
375+
376+
// Free the copy of the env var we made.
377+
free(ech_config_list_copy);
378+
379+
if(!ech_configured) {
380+
LOG_SIMPLE("failed to configure ECH with any provided config files");
331381
goto cleanup;
332382
}
333383
}

tests/client.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ typedef struct demo_client_options
2121

2222
// Optional encrypted client hello (ECH) settings. Only one should be set.
2323
bool use_ech_grease;
24-
const char *use_ech_config_list_file;
24+
const char *use_ech_config_list_files;
2525

2626
// Optional SSL keylog support. At most one should be set.
2727
const char *use_ssl_keylog_file;

tests/common.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const char *ws_strerror(int err);
2222
#endif /* !STDOUT_FILENO */
2323
#endif /* _WIN32 */
2424

25+
#if defined(_MSC_VER)
26+
#define STRTOK_R strtok_s
27+
#else
28+
#define STRTOK_R strtok_r
29+
#endif
30+
2531
typedef enum demo_result
2632
{
2733
DEMO_OK,

tests/ech_fetch.rs

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::io::Write;
1111
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
1212
use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue};
1313
use hickory_resolver::proto::rr::{RData, RecordType};
14-
use hickory_resolver::{Resolver, TokioResolver};
14+
use hickory_resolver::{ResolveError, Resolver, TokioResolver};
1515

1616
use rustls::pki_types::EchConfigListBytes;
1717

@@ -24,27 +24,60 @@ async fn main() -> Result<(), Box<dyn Error>> {
2424
.unwrap_or(format!("testdata/{}.ech.configs.bin", domain));
2525

2626
let resolver = Resolver::tokio(ResolverConfig::google_https(), ResolverOpts::default());
27-
let tls_encoded_list = lookup_ech(&resolver, &domain).await;
2827

29-
let mut encoded_list_file = File::create(output_path)?;
30-
encoded_list_file.write_all(&tls_encoded_list)?;
28+
let all_lists = lookup_ech_configs(&resolver, &domain).await?;
29+
30+
// If there was only one HTTPS record with an ech config, write it to the output file.
31+
if all_lists.len() == 1 {
32+
let mut encoded_list_file = File::create(&output_path)?;
33+
encoded_list_file.write_all(&all_lists.first().unwrap())?;
34+
println!("{output_path}");
35+
} else {
36+
// Otherwise write each to its own file with a numeric suffix
37+
for (i, ech_config_lists) in all_lists.iter().enumerate() {
38+
let mut encoded_list_file = File::create(format!("{output_path}.{}", i + 1))?;
39+
encoded_list_file.write_all(&ech_config_lists)?;
40+
}
41+
// And print a comma separated list of the file paths.
42+
let paths = (1..=all_lists.len())
43+
.map(|i| format!("{}.{}", output_path, i))
44+
.collect::<Vec<_>>()
45+
.join(",");
46+
println!("{paths}")
47+
}
3148

3249
Ok(())
3350
}
3451

35-
async fn lookup_ech(resolver: &TokioResolver, domain: &str) -> EchConfigListBytes<'static> {
36-
resolver
37-
.lookup(domain, RecordType::HTTPS)
38-
.await
39-
.expect("failed to lookup HTTPS record type")
40-
.record_iter()
41-
.find_map(|r| match r.data() {
42-
RData::HTTPS(svcb) => svcb.svc_params().iter().find_map(|sp| match sp {
43-
(SvcParamKey::EchConfigList, SvcParamValue::EchConfigList(e)) => Some(e.clone().0),
44-
_ => None,
45-
}),
52+
/// Collect up all `EchConfigListBytes` found in the HTTPS record(s) for a given domain name.
53+
///
54+
/// Assumes the port will be 443. For a more complete example see the Rustls' ech-client.rs
55+
/// example's `lookup_ech_configs` function.
56+
///
57+
/// The domain name should be the **inner** name used for Encrypted Client Hello (ECH). The
58+
/// lookup is done using DNS-over-HTTPS to protect that inner name from being disclosed in
59+
/// plaintext ahead of the TLS handshake that negotiates ECH for the inner name.
60+
///
61+
/// Returns an empty vec if no HTTPS records with ECH configs are found.
62+
async fn lookup_ech_configs(
63+
resolver: &TokioResolver,
64+
domain: &str,
65+
) -> Result<Vec<EchConfigListBytes<'static>>, ResolveError> {
66+
let lookup = resolver.lookup(domain, RecordType::HTTPS).await?;
67+
68+
let mut ech_config_lists = Vec::new();
69+
for r in lookup.record_iter() {
70+
let RData::HTTPS(svcb) = r.data() else {
71+
continue;
72+
};
73+
74+
ech_config_lists.extend(svcb.svc_params().iter().find_map(|sp| match sp {
75+
(SvcParamKey::EchConfigList, SvcParamValue::EchConfigList(e)) => {
76+
Some(EchConfigListBytes::from(e.clone().0))
77+
}
4678
_ => None,
47-
})
48-
.expect("missing expected HTTPS SvcParam EchConfig record")
49-
.into()
79+
}))
80+
}
81+
82+
Ok(ech_config_lists)
5083
}

0 commit comments

Comments
 (0)