From 3ab50b355141f744a0acec1a5cc3b3b95247d4b1 Mon Sep 17 00:00:00 2001 From: Ryan Dahl Date: Mon, 9 Aug 2021 15:55:00 +0200 Subject: [PATCH] feat: support client certificates for connectTls (#11598) Co-authored-by: Daniel Lamando Co-authored-by: Erik Price --- cli/tests/unit/tls_test.ts | 68 ++++++++++++- extensions/net/02_tls.js | 4 + extensions/net/lib.deno_net.d.ts | 5 +- extensions/net/lib.deno_net.unstable.d.ts | 26 +++++ extensions/net/ops_tls.rs | 78 +++++++++++---- test_util/src/lib.rs | 117 +++++++++++++++++++--- 6 files changed, 262 insertions(+), 36 deletions(-) diff --git a/cli/tests/unit/tls_test.ts b/cli/tests/unit/tls_test.ts index 46a27b7f08..8472d93e05 100644 --- a/cli/tests/unit/tls_test.ts +++ b/cli/tests/unit/tls_test.ts @@ -11,6 +11,7 @@ import { unitTest, } from "./test_util.ts"; import { BufReader, BufWriter } from "../../../test_util/std/io/bufio.ts"; +import { readAll } from "../../../test_util/std/io/util.ts"; import { TextProtoReader } from "../../../test_util/std/textproto/mod.ts"; const encoder = new TextEncoder(); @@ -26,7 +27,7 @@ function unreachable(): never { unitTest(async function connectTLSNoPerm() { await assertThrowsAsync(async () => { - await Deno.connectTls({ hostname: "github.com", port: 443 }); + await Deno.connectTls({ hostname: "deno.land", port: 443 }); }, Deno.errors.PermissionDenied); }); @@ -51,7 +52,7 @@ unitTest( unitTest(async function connectTLSCertFileNoReadPerm() { await assertThrowsAsync(async () => { await Deno.connectTls({ - hostname: "github.com", + hostname: "deno.land", port: 443, certFile: "cli/tests/tls/RootCA.crt", }); @@ -985,3 +986,66 @@ unitTest( conn.close(); }, ); + +unitTest( + { perms: { read: true, net: true } }, + async function connectTLSBadClientCertPrivateKey(): Promise { + await assertThrowsAsync(async () => { + await Deno.connectTls({ + hostname: "deno.land", + port: 443, + certChain: "bad data", + privateKey: await Deno.readTextFile("cli/tests/tls/localhost.key"), + }); + }, Deno.errors.InvalidData); + }, +); + +unitTest( + { perms: { read: true, net: true } }, + async function connectTLSBadPrivateKey(): Promise { + await assertThrowsAsync(async () => { + await Deno.connectTls({ + hostname: "deno.land", + port: 443, + certChain: await Deno.readTextFile("cli/tests/tls/localhost.crt"), + privateKey: "bad data", + }); + }, Deno.errors.InvalidData); + }, +); + +unitTest( + { perms: { read: true, net: true } }, + async function connectTLSNotPrivateKey(): Promise { + await assertThrowsAsync(async () => { + await Deno.connectTls({ + hostname: "deno.land", + port: 443, + certChain: await Deno.readTextFile("cli/tests/tls/localhost.crt"), + privateKey: "", + }); + }, Deno.errors.InvalidData); + }, +); + +unitTest( + { perms: { read: true, net: true } }, + async function connectWithClientCert() { + // The test_server running on port 4552 responds with 'PASS' if client + // authentication was successful. Try it by running test_server and + // curl --key cli/tests/tls/localhost.key \ + // --cert cli/tests/tls/localhost.crt \ + // --cacert cli/tests/tls/RootCA.crt https://localhost:4552/ + const conn = await Deno.connectTls({ + hostname: "localhost", + port: 4552, + certChain: await Deno.readTextFile("cli/tests/tls/localhost.crt"), + privateKey: await Deno.readTextFile("cli/tests/tls/localhost.key"), + certFile: "cli/tests/tls/RootCA.crt", + }); + const result = decoder.decode(await readAll(conn)); + assertEquals(result, "PASS"); + conn.close(); + }, +); diff --git a/extensions/net/02_tls.js b/extensions/net/02_tls.js index 4fafe90792..343ec2e4ff 100644 --- a/extensions/net/02_tls.js +++ b/extensions/net/02_tls.js @@ -28,12 +28,16 @@ hostname = "127.0.0.1", transport = "tcp", certFile = undefined, + certChain = undefined, + privateKey = undefined, }) { const res = await opConnectTls({ port, hostname, transport, certFile, + certChain, + privateKey, }); return new Conn(res.rid, res.remoteAddr, res.localAddr); } diff --git a/extensions/net/lib.deno_net.d.ts b/extensions/net/lib.deno_net.d.ts index 25397f9607..d35e01e316 100644 --- a/extensions/net/lib.deno_net.d.ts +++ b/extensions/net/lib.deno_net.d.ts @@ -68,9 +68,10 @@ declare namespace Deno { ): Listener; export interface ListenTlsOptions extends ListenOptions { - /** Server certificate file. */ + /** Path to a file containing a PEM formatted CA certificate. Requires + * `--allow-read`. */ certFile: string; - /** Server public key file. */ + /** Server public key file. Requires `--allow-read`.*/ keyFile: string; transport?: "tcp"; diff --git a/extensions/net/lib.deno_net.unstable.d.ts b/extensions/net/lib.deno_net.unstable.d.ts index adeeb14666..145f232c09 100644 --- a/extensions/net/lib.deno_net.unstable.d.ts +++ b/extensions/net/lib.deno_net.unstable.d.ts @@ -191,6 +191,32 @@ declare namespace Deno { options: ConnectOptions | UnixConnectOptions, ): Promise; + export interface ConnectTlsClientCertOptions { + /** PEM formatted client certificate chain. */ + certChain: string; + /** PEM formatted (RSA or PKCS8) private key of client certificate. */ + privateKey: string; + } + + /** **UNSTABLE** New API, yet to be vetted. + * + * Create a TLS connection with an attached client certificate. + * + * ```ts + * const conn = await Deno.connectTls({ + * hostname: "deno.land", + * port: 443, + * certChain: "---- BEGIN CERTIFICATE ----\n ...", + * privateKey: "---- BEGIN PRIVATE KEY ----\n ...", + * }); + * ``` + * + * Requires `allow-net` permission. + */ + export function connectTls( + options: ConnectTlsOptions & ConnectTlsClientCertOptions, + ): Promise; + export interface StartTlsOptions { /** A literal IP address or host name that can be resolved to an IP address. * If not specified, defaults to `127.0.0.1`. */ diff --git a/extensions/net/ops_tls.rs b/extensions/net/ops_tls.rs index 124da2f037..7c45633903 100644 --- a/extensions/net/ops_tls.rs +++ b/extensions/net/ops_tls.rs @@ -14,6 +14,7 @@ use deno_core::error::bad_resource_id; use deno_core::error::custom_error; use deno_core::error::generic_error; use deno_core::error::invalid_hostname; +use deno_core::error::type_error; use deno_core::error::AnyError; use deno_core::futures::future::poll_fn; use deno_core::futures::ready; @@ -57,6 +58,7 @@ use std::cell::RefCell; use std::convert::From; use std::fs::File; use std::io; +use std::io::BufRead; use std::io::BufReader; use std::io::ErrorKind; use std::ops::Deref; @@ -649,6 +651,8 @@ pub struct ConnectTlsArgs { hostname: String, port: u16, cert_file: Option, + cert_chain: Option, + private_key: Option, } #[derive(Deserialize)] @@ -717,6 +721,7 @@ where let remote_addr = tcp_stream.peer_addr()?; let tls_config = Arc::new(create_client_config(root_cert_store, ca_data)?); + let tls_stream = TlsStream::new_client_side(tcp_stream, &tls_config, hostname_dns); @@ -755,6 +760,14 @@ where }; let port = args.port; let cert_file = args.cert_file.as_deref(); + + if args.cert_chain.is_some() { + super::check_unstable2(&state, "ConnectTlsOptions.certChain"); + } + if args.private_key.is_some() { + super::check_unstable2(&state, "ConnectTlsOptions.privateKey"); + } + { let mut s = state.borrow_mut(); let permissions = s.borrow_mut::(); @@ -788,7 +801,28 @@ where let tcp_stream = TcpStream::connect(connect_addr).await?; let local_addr = tcp_stream.local_addr()?; let remote_addr = tcp_stream.peer_addr()?; - let tls_config = Arc::new(create_client_config(root_cert_store, ca_data)?); + + let mut tls_config = create_client_config(root_cert_store, ca_data)?; + + if args.cert_chain.is_some() || args.private_key.is_some() { + let cert_chain = args + .cert_chain + .ok_or_else(|| type_error("No certificate chain provided"))?; + let private_key = args + .private_key + .ok_or_else(|| type_error("No private key provided"))?; + + // The `remove` is safe because load_private_keys checks that there is at least one key. + let private_key = load_private_keys(private_key.as_bytes())?.remove(0); + + tls_config.set_single_client_cert( + load_certs(&mut cert_chain.as_bytes())?, + private_key, + )?; + } + + let tls_config = Arc::new(tls_config); + let tls_stream = TlsStream::new_client_side(tcp_stream, &tls_config, hostname_dns); @@ -812,10 +846,7 @@ where }) } -fn load_certs(path: &str) -> Result, AnyError> { - let cert_file = File::open(path)?; - let reader = &mut BufReader::new(cert_file); - +fn load_certs(reader: &mut dyn BufRead) -> Result, AnyError> { let certs = certs(reader) .map_err(|_| custom_error("InvalidData", "Unable to decode certificate"))?; @@ -827,6 +858,12 @@ fn load_certs(path: &str) -> Result, AnyError> { Ok(certs) } +fn load_certs_from_file(path: &str) -> Result, AnyError> { + let cert_file = File::open(path)?; + let reader = &mut BufReader::new(cert_file); + load_certs(reader) +} + fn key_decode_err() -> AnyError { custom_error("InvalidData", "Unable to decode key") } @@ -836,27 +873,22 @@ fn key_not_found_err() -> AnyError { } /// Starts with -----BEGIN RSA PRIVATE KEY----- -fn load_rsa_keys(path: &str) -> Result, AnyError> { - let key_file = File::open(path)?; - let reader = &mut BufReader::new(key_file); - let keys = rsa_private_keys(reader).map_err(|_| key_decode_err())?; +fn load_rsa_keys(mut bytes: &[u8]) -> Result, AnyError> { + let keys = rsa_private_keys(&mut bytes).map_err(|_| key_decode_err())?; Ok(keys) } /// Starts with -----BEGIN PRIVATE KEY----- -fn load_pkcs8_keys(path: &str) -> Result, AnyError> { - let key_file = File::open(path)?; - let reader = &mut BufReader::new(key_file); - let keys = pkcs8_private_keys(reader).map_err(|_| key_decode_err())?; +fn load_pkcs8_keys(mut bytes: &[u8]) -> Result, AnyError> { + let keys = pkcs8_private_keys(&mut bytes).map_err(|_| key_decode_err())?; Ok(keys) } -fn load_keys(path: &str) -> Result, AnyError> { - let path = path.to_string(); - let mut keys = load_rsa_keys(&path)?; +fn load_private_keys(bytes: &[u8]) -> Result, AnyError> { + let mut keys = load_rsa_keys(bytes)?; if keys.is_empty() { - keys = load_pkcs8_keys(&path)?; + keys = load_pkcs8_keys(bytes)?; } if keys.is_empty() { @@ -866,6 +898,13 @@ fn load_keys(path: &str) -> Result, AnyError> { Ok(keys) } +fn load_private_keys_from_file( + path: &str, +) -> Result, AnyError> { + let key_bytes = std::fs::read(path)?; + load_private_keys(&key_bytes) +} + pub struct TlsListenerResource { tcp_listener: AsyncRefCell, tls_config: Arc, @@ -921,7 +960,10 @@ where alpn_protocols.into_iter().map(|s| s.into_bytes()).collect(); } tls_config - .set_single_cert(load_certs(cert_file)?, load_keys(key_file)?.remove(0)) + .set_single_cert( + load_certs_from_file(cert_file)?, + load_private_keys_from_file(key_file)?.remove(0), + ) .expect("invalid key or certificate"); let bind_addr = resolve_addr_sync(hostname, port)? diff --git a/test_util/src/lib.rs b/test_util/src/lib.rs index 228e568f1f..93a02c98e4 100644 --- a/test_util/src/lib.rs +++ b/test_util/src/lib.rs @@ -37,9 +37,10 @@ use std::sync::MutexGuard; use std::task::Context; use std::task::Poll; use tempfile::TempDir; +use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; use tokio::net::TcpStream; -use tokio_rustls::rustls; +use tokio_rustls::rustls::{self, Session}; use tokio_rustls::TlsAcceptor; use tokio_tungstenite::accept_async; @@ -56,6 +57,7 @@ const DOUBLE_REDIRECTS_PORT: u16 = 4548; const INF_REDIRECTS_PORT: u16 = 4549; const REDIRECT_ABSOLUTE_PORT: u16 = 4550; const AUTH_REDIRECT_PORT: u16 = 4551; +const TLS_CLIENT_AUTH_PORT: u16 = 4552; const HTTPS_PORT: u16 = 5545; const WS_PORT: u16 = 4242; const WSS_PORT: u16 = 4243; @@ -224,6 +226,7 @@ async fn auth_redirect(req: Request) -> hyper::Result> { async fn run_ws_server(addr: &SocketAddr) { let listener = TcpListener::bind(addr).await.unwrap(); + println!("ready: ws"); // Eye catcher for HttpServerCount while let Ok((stream, _addr)) = listener.accept().await { tokio::spawn(async move { let ws_stream_fut = accept_async(stream); @@ -260,18 +263,28 @@ async fn run_ws_close_server(addr: &SocketAddr) { async fn get_tls_config( cert: &str, key: &str, + ca: &str, ) -> io::Result> { let mut cert_path = root_path(); let mut key_path = root_path(); + let mut ca_path = root_path(); cert_path.push(cert); key_path.push(key); + ca_path.push(ca); let cert_file = std::fs::File::open(cert_path)?; let key_file = std::fs::File::open(key_path)?; + let ca_file = std::fs::File::open(ca_path)?; let mut cert_reader = io::BufReader::new(cert_file); let cert = rustls::internal::pemfile::certs(&mut cert_reader) .expect("Cannot load certificate"); + + let mut ca_cert_reader = io::BufReader::new(ca_file); + let ca_cert = rustls::internal::pemfile::certs(&mut ca_cert_reader) + .expect("Cannot load CA certificate") + .remove(0); + let mut key_reader = io::BufReader::new(key_file); let key = { let pkcs8_key = @@ -290,7 +303,12 @@ async fn get_tls_config( match key { Some(key) => { - let mut config = rustls::ServerConfig::new(rustls::NoClientAuth::new()); + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.add(&ca_cert).unwrap(); + // Allow (but do not require) client authentication. + let allow_client_auth = + rustls::AllowAnyAnonymousOrAuthenticatedClient::new(root_cert_store); + let mut config = rustls::ServerConfig::new(allow_client_auth); config .set_single_cert(cert, key) .map_err(|e| { @@ -307,10 +325,14 @@ async fn get_tls_config( async fn run_wss_server(addr: &SocketAddr) { let cert_file = "cli/tests/tls/localhost.crt"; let key_file = "cli/tests/tls/localhost.key"; + let ca_cert_file = "cli/tests/tls/RootCA.pem"; - let tls_config = get_tls_config(cert_file, key_file).await.unwrap(); + let tls_config = get_tls_config(cert_file, key_file, ca_cert_file) + .await + .unwrap(); let tls_acceptor = TlsAcceptor::from(tls_config); let listener = TcpListener::bind(addr).await.unwrap(); + println!("ready: wss"); // Eye catcher for HttpServerCount while let Ok((stream, _addr)) = listener.accept().await { let acceptor = tls_acceptor.clone(); @@ -338,6 +360,71 @@ async fn run_wss_server(addr: &SocketAddr) { } } +/// This server responds with 'PASS' if client authentication was successful. Try it by running +/// test_server and +/// curl --key cli/tests/tls/localhost.key \ +/// --cert cli/tests/tls/localhost.crt \ +/// --cacert cli/tests/tls/RootCA.crt https://localhost:4552/ +async fn run_tls_client_auth_server() { + let cert_file = "cli/tests/tls/localhost.crt"; + let key_file = "cli/tests/tls/localhost.key"; + let ca_cert_file = "cli/tests/tls/RootCA.pem"; + let tls_config = get_tls_config(cert_file, key_file, ca_cert_file) + .await + .unwrap(); + let tls_acceptor = TlsAcceptor::from(tls_config); + + // Listen on ALL addresses that localhost can resolves to. + let accept = |listener: tokio::net::TcpListener| { + async { + let result = listener.accept().await; + Some((result, listener)) + } + .boxed() + }; + + let host_and_port = &format!("localhost:{}", TLS_CLIENT_AUTH_PORT); + + let listeners = tokio::net::lookup_host(host_and_port) + .await + .expect(host_and_port) + .inspect(|address| println!("{} -> {}", host_and_port, address)) + .map(tokio::net::TcpListener::bind) + .collect::>() + .collect::>() + .await + .into_iter() + .map(|s| s.unwrap()) + .map(|listener| futures::stream::unfold(listener, accept)) + .collect::>(); + + println!("ready: tls client auth"); // Eye catcher for HttpServerCount + + let mut listeners = futures::stream::select_all(listeners); + + while let Some(Ok((stream, _addr))) = listeners.next().await { + let acceptor = tls_acceptor.clone(); + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(mut tls_stream) => { + let (_, tls_session) = tls_stream.get_mut(); + // We only need to check for the presence of client certificates + // here. Rusttls ensures that they are valid and signed by the CA. + let response = match tls_session.get_peer_certificates() { + Some(_certs) => b"PASS", + None => b"FAIL", + }; + tls_stream.write_all(response).await.unwrap(); + } + + Err(e) => { + eprintln!("TLS accept error: {:?}", e); + } + } + }); + } +} + async fn absolute_redirect( req: Request, ) -> hyper::Result> { @@ -775,14 +862,15 @@ async fn wrap_main_https_server() { let main_server_https_addr = SocketAddr::from(([127, 0, 0, 1], HTTPS_PORT)); let cert_file = "cli/tests/tls/localhost.crt"; let key_file = "cli/tests/tls/localhost.key"; - let tls_config = get_tls_config(cert_file, key_file) + let ca_cert_file = "cli/tests/tls/RootCA.pem"; + let tls_config = get_tls_config(cert_file, key_file, ca_cert_file) .await .expect("Cannot get TLS config"); loop { let tcp = TcpListener::bind(&main_server_https_addr) .await .expect("Cannot bind TCP"); - println!("tls ready"); + println!("ready: https"); // Eye catcher for HttpServerCount let tls_acceptor = TlsAcceptor::from(tls_config.clone()); // Prepare a long-running future stream to accept and serve cients. let incoming_tls_stream = async_stream::stream! { @@ -832,6 +920,8 @@ pub async fn run_all_servers() { let ws_close_addr = SocketAddr::from(([127, 0, 0, 1], WS_CLOSE_PORT)); let ws_close_server_fut = run_ws_close_server(&ws_close_addr); + let tls_client_auth_server_fut = run_tls_client_auth_server(); + let main_server_fut = wrap_main_server(); let main_server_https_fut = wrap_main_https_server(); @@ -840,6 +930,7 @@ pub async fn run_all_servers() { redirect_server_fut, ws_server_fut, wss_server_fut, + tls_client_auth_server_fut, ws_close_server_fut, another_redirect_server_fut, auth_redirect_server_fut, @@ -856,7 +947,7 @@ pub async fn run_all_servers() { futures::future::poll_fn(move |cx| { let poll_result = server_fut.poll_unpin(cx); if !replace(&mut did_print_ready, true) { - println!("ready"); + println!("ready: server_fut"); // Eye catcher for HttpServerCount } poll_result }) @@ -985,17 +1076,15 @@ impl HttpServerCount { let stdout = test_server.stdout.as_mut().unwrap(); use std::io::{BufRead, BufReader}; let lines = BufReader::new(stdout).lines(); - let mut ready = false; - let mut tls_ready = false; + + // Wait for all the servers to report being ready. + let mut ready_count = 0; for maybe_line in lines { if let Ok(line) = maybe_line { - if line.starts_with("ready") { - ready = true; + if line.starts_with("ready:") { + ready_count += 1; } - if line.starts_with("tls ready") { - tls_ready = true; - } - if ready && tls_ready { + if ready_count == 5 { break; } } else {