mirror of https://github.com/ctz/rustls
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:
parent
7ac753a57b
commit
0d332fc437
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
Loading…
Reference in New Issue