provider-example: enhance ech-client

* Use docopt to make it feasible to provide args/flags
* Add flags for choosing CA cert, DNS-over-HTTPS server, etc
* Add config for performing placeholder "GREASE" ECH for hosts without
  ECH configs, doing so by default for hosts without ECH configs, and
  forcing it if desired
* Add config flag for specifying a ECH config from disk.
* Add flag for specifying how many requests to do, to test resumption.
* Asserts on the expected ECH status after handshaking.
This commit is contained in:
Daniel McCarney 2024-04-12 11:05:27 -04:00
parent 7ac753a57b
commit 0d332fc437
3 changed files with 200 additions and 43 deletions

2
Cargo.lock generated
View File

@ -2297,6 +2297,7 @@ version = "0.0.1"
dependencies = [
"chacha20poly1305",
"der",
"docopt",
"ecdsa",
"env_logger",
"hex",
@ -2315,6 +2316,7 @@ dependencies = [
"rustls-pki-types",
"rustls-webpki 0.102.3",
"serde",
"serde_derive",
"serde_json",
"sha2",
"signature",

View File

@ -9,6 +9,7 @@ publish = false
[dependencies]
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
der = "0.7"
docopt = "~1.1"
ecdsa = "0.16.8"
hickory-resolver = { version = "0.24", features = ["dns-over-https-rustls", "webpki-roots"] }
hmac = "0.12"
@ -22,6 +23,8 @@ rand_core = { version = "0.6", features = ["getrandom"] }
rustls = { path = "../rustls", default-features = false, features = ["logging", "tls12"] }
rustls-pemfile = "2"
rsa = { version = "0.9", features = ["sha2"], default-features = false }
serde = "1.0"
serde_derive = "1.0"
sha2 = { version = "0.10", default-features = false }
signature = "2"
webpki = { package = "rustls-webpki", version = "0.102", features = ["alloc"], default-features = false }

View File

@ -3,22 +3,63 @@
//!
//! Note that `unwrap()` is used to deal with networking errors; this is not something
//! that is sensible outside of example code.
//!
//! Example usage:
//! ```
//! cargo run --package rustls-provider-example --example ech-client -- --host defo.ie defo.ie www.defo.ie
//! ```
//!
//! This will perform a DNS-over-HTTPS lookup for the defo.ie ECH config, using it to determine
//! the plaintext SNI ot send to the server. The protected encrypted SNI will be "www.defo.ie".
//! An HTTP request for Host: defo.ie will be made once the handshake completes. You should
//! observe output that contains:
//! ```
//! <p>SSL_ECH_OUTER_SNI: cover.defo.ie <br />
//! SSL_ECH_INNER_SNI: www.defo.ie <br />
//! SSL_ECH_STATUS: success <img src="greentick-small.png" alt="good" /> <br/>
//! </p>
//! ```
use std::io::{stdout, Read, Write};
use std::net::TcpStream;
use std::fs;
use std::io::{stdout, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use docopt::Docopt;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue};
use hickory_resolver::proto::rr::{RData, RecordType};
use hickory_resolver::Resolver;
use rustls::client::EchConfig;
use rustls::client::{EchConfig, EchGreaseConfig, EchStatus};
use rustls::crypto::hpke::{HpkePublicKey, HpkeSuite};
use rustls::internal::msgs::enums::{HpkeAead, HpkeKdf, HpkeKem};
use rustls::internal::msgs::handshake::HpkeSymmetricCipherSuite;
use rustls::pki_types::ServerName;
use rustls::RootCertStore;
use serde_derive::Deserialize;
fn main() {
// Find raw ECH configs using DNS-over-HTTPS with Hickory DNS:
let resolver = Resolver::new(ResolverConfig::google_https(), ResolverOpts::default()).unwrap();
let ech_config_list = lookup_ech_configs(&resolver, "defo.ie");
let version = env!("CARGO_PKG_NAME").to_string() + ", version: " + env!("CARGO_PKG_VERSION");
let args: Args = Docopt::new(USAGE)
.map(|d| d.help(true))
.map(|d| d.version(Some(version)))
.and_then(|d| d.deserialize())
.unwrap_or_else(|e| e.exit());
// Find raw ECH configs using DNS-over-HTTPS with Hickory DNS.
let resolver_config = if args.flag_cloudflare_dns {
ResolverConfig::cloudflare_https()
} else {
ResolverConfig::google_https()
};
let resolver = Resolver::new(resolver_config, ResolverOpts::default()).unwrap();
let server_ech_config = match args.flag_grease {
true => None, // Force the use of the GREASE ext by skipping ECH config lookup
false => match args.flag_ech_config {
Some(path) => Some(read_ech(&path)),
None => lookup_ech_configs(&resolver, &args.arg_outer_hostname),
},
};
// NOTE: we defer setting up env_logger and setting the trace default filter level until
// after doing the DNS-over-HTTPS lookup above - we don't want to muddy the output
@ -27,14 +68,33 @@ fn main() {
.parse_filters("trace")
.init();
// Select a compatible ECH config.
let hpke_provider = rustls_provider_example::HPKE_PROVIDER;
let ech_mode = EchConfig::new(ech_config_list, hpke_provider)
let ech_mode = match server_ech_config {
Some(ech_config_list) => EchConfig::new(ech_config_list, hpke_provider)
.unwrap()
.into(),
None => EchGreaseConfig::new(
hpke_provider,
GREASE_HPKE_SUITE,
HpkePublicKey(GREASE_25519_PUBKEY.to_vec()),
)
.unwrap()
.into();
.into(),
};
let root_store = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
let root_store = match args.flag_cafile {
Some(file) => {
let mut root_store = RootCertStore::empty();
let certfile = fs::File::open(file).expect("Cannot open CA file");
let mut reader = BufReader::new(certfile);
root_store.add_parsable_certificates(
rustls_pemfile::certs(&mut reader).map(|result| result.unwrap()),
);
root_store
}
None => RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
},
};
// Construct a rustls client config with a custom provider, and ECH enabled.
@ -47,44 +107,106 @@ fn main() {
// Allow using SSLKEYLOGFILE.
config.key_log = Arc::new(rustls::KeyLogFile::new());
let config = Arc::new(config);
// The "inner" SNI that we're really trying to reach.
let server_name = "www.defo.ie".try_into().unwrap();
let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap();
// The "outer" server that we're connecting to.
let mut sock = TcpStream::connect("defo.ie:443").unwrap();
let mut tls = rustls::Stream::new(&mut conn, &mut sock);
tls.write_all(
concat!(
"GET /ech-check.php HTTP/1.1\r\n",
"Host: defo.ie\r\n",
"Connection: close\r\n",
"Accept-Encoding: identity\r\n",
"\r\n"
)
.as_bytes(),
)
.unwrap();
let ciphersuite = tls
.conn
.negotiated_cipher_suite()
let server_name: ServerName<'static> = args
.arg_inner_hostname
.clone()
.try_into()
.unwrap();
writeln!(
&mut std::io::stderr(),
"Current ciphersuite: {:?}",
ciphersuite.suite()
)
.unwrap();
let mut plaintext = Vec::new();
tls.read_to_end(&mut plaintext).unwrap();
stdout().write_all(&plaintext).unwrap();
for i in 0..args.flag_num_reqs {
println!("\nRequest {}", i);
let mut conn = rustls::ClientConnection::new(config.clone(), server_name.clone()).unwrap();
// The "outer" server that we're connecting to.
let sock_addr = (
args.arg_outer_hostname.as_str(),
args.flag_port.unwrap_or(443),
)
.to_socket_addrs()
.unwrap()
.next()
.unwrap();
let mut sock = TcpStream::connect(sock_addr).unwrap();
let mut tls = rustls::Stream::new(&mut conn, &mut sock);
let request =
format!(
"GET /{} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
args.flag_path.clone()
.unwrap_or("ech-check.php".to_owned()),
args.flag_host.as_ref().unwrap_or(&args.arg_inner_hostname),
);
dbg!(&request);
tls.write_all(request.as_bytes())
.unwrap();
assert!(!tls.conn.is_handshaking());
assert_eq!(
tls.conn.ech_status(),
match args.flag_grease {
true => EchStatus::Grease,
false => EchStatus::Accepted,
}
);
let mut plaintext = Vec::new();
tls.read_to_end(&mut plaintext).unwrap();
stdout().write_all(&plaintext).unwrap();
}
}
const USAGE: &str = "
Connects to the TLS server at hostname:PORT. The default PORT
is 443. If an ECH config can be fetched for hostname using
DNS-over-HTTPS, ECH is enabled. Otherwise, a placeholder ECH
extension is sent for anti-ossification testing.
If --cafile is not supplied, a built-in set of CA certificates
are used from the webpki-roots crate.
Usage:
ech-client [options] <outer-hostname> <inner-hostname>
ech-client (--version | -v)
ech-client (--help | -h)
Options:
-p, --port PORT Connect to PORT [default: 443].
--cafile CAFILE Read root certificates from CAFILE.
--path PATH HTTP GET this PATH [default: ech-check.php].
--host HOST HTTP HOST to use for GET request [default: inner-hostname].
--google-dns Use Google DNS for the DNS-over-HTTPS lookup [default].
--cloudflare-dns Use Cloudflare DNS for the DNS-over-HTTPS lookup.
--grease Skip looking up an ECH config and send a GREASE placeholder.
--ech-config ECHFILE Skip looking up an ECH config and read it from the provided file (in binary TLS encoding).
--num-reqs NUM Number of requests to make [default: 1].
--version, -v Show tool version.
--help, -h Show this screen.
";
#[derive(Debug, Deserialize)]
struct Args {
flag_port: Option<u16>,
flag_cafile: Option<String>,
flag_path: Option<String>,
flag_host: Option<String>,
#[allow(dead_code)] // implied default
flag_google_dns: bool,
flag_cloudflare_dns: bool,
flag_grease: bool,
flag_ech_config: Option<String>,
flag_num_reqs: usize,
arg_outer_hostname: String,
arg_inner_hostname: String,
}
// TODO(@cpu): consider upstreaming to hickory-dns
fn lookup_ech_configs(resolver: &Resolver, domain: &str) -> pki_types::EchConfigListBytes<'static> {
fn lookup_ech_configs(
resolver: &Resolver,
domain: &str,
) -> Option<pki_types::EchConfigListBytes<'static>> {
resolver
.lookup(domain, RecordType::HTTPS)
.expect("failed to lookup HTTPS record type")
.ok()?
.record_iter()
.find_map(|r| match r.data() {
Some(RData::HTTPS(svcb)) => svcb
@ -98,6 +220,36 @@ fn lookup_ech_configs(resolver: &Resolver, domain: &str) -> pki_types::EchConfig
}),
_ => None,
})
.expect("missing expected HTTPS SvcParam EchConfig record")
.into()
.map(Into::into)
}
fn read_ech(path: &str) -> pki_types::EchConfigListBytes<'static> {
let file = fs::File::open(path).unwrap_or_else(|_| panic!("Cannot open ECH file: {path}"));
let mut reader = BufReader::new(file);
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.unwrap_or_else(|_| panic!("Cannot read ECH file: {path}"));
bytes.into()
}
/// A HPKE suite to use for GREASE ECH.
///
/// A real implementation should vary this suite across the suites supported by the [HpkeProvider]
/// in use.
const GREASE_HPKE_SUITE: HpkeSuite = HpkeSuite {
kem: HpkeKem::DHKEM_X25519_HKDF_SHA256,
sym: HpkeSymmetricCipherSuite {
kdf_id: HpkeKdf::HKDF_SHA256,
aead_id: HpkeAead::AES_128_GCM,
},
};
/// Randomly generated X25519 public key to use for GREASE ECH.
///
/// A real implementation should vary this pubkey, ensuring the public key type matches the KEM
/// selected in the GREASE [HpkeSuite].
const GREASE_25519_PUBKEY: &[u8] = &[
0x67, 0x35, 0xCA, 0x50, 0x21, 0xFC, 0x4F, 0xE6, 0x29, 0x3B, 0x31, 0x2C, 0xB5, 0xE0, 0x97, 0xD8,
0xD0, 0x58, 0x97, 0xCF, 0x5C, 0x15, 0x12, 0x79, 0x4B, 0xEF, 0x1D, 0x98, 0x52, 0x74, 0xDC, 0x5E,
];