DEV Community

Alexander Gusev
Alexander Gusev

Posted on

3 1 1

Making TLS client with Chrome-like SSL Handshake (Rust, Boring SSL, H2)

An overview guide to building a TLS client in Rust that can simulate Chrome's SSL handshake. Plus nodejs module and proxy support in the repo.

The source code is available here — github.com/gssvv/rust-boring-ssl-client

Tools used

  1. Rust (JavaScript cannot handle such low-level operations)
  2. BoringSSL bindings for the Rust (Chromium uses BoringSSL)
  3. H2 (HTTP/2 client)
  4. Neon (to create native Node.js modules)

What's the result?

Let's take an opensea.io GraphQL for example, which uses Cloudflare WAF.

Regular axios request won't work:

const config = {
  uri: "https://opensea.io/__api/graphql/",
  host: "opensea.io",
  method: "POST",
  headers: [
    ["authority", "opensea.io"],
    ["content-type", "application/json"],
    // ...
  ],
  body: `{
    \"id\": \"NavbarQuery\",
    \"query\": \"query NavbarQuery(\\n  $identity: AddressScalar!\\n) {\\n  getAccount(address: $identity) {\\n    imageUrl\\n    id\\n  }\\n}\\n\",
    \"variables\": {
      \"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
    }
  }`,
};

const axios = require("axios");

axios({
  ...config,
  url: config.uri,
  headers: Object.fromEntries(config.headers),
  data: config.body,
  validateStatus: () => true,
}).then((e) => console.log({ status: e.status, body: e.data }));
// {
//   status: 403,
//   body: '<!DOCTYPE html>\n' +
//     '<html lang="en-US">\n' +
//     '   <head>\n' +
//     '      <title>Access denied</title>\n' +
//     '      <meta http-equiv="X-UA-Compatible" content="IE=Edge" />\n' +
// ...
Enter fullscreen mode Exit fullscreen mode

Let's try our module:

const { request } = require("./build/macos.node");

request(config, "", "").then(console.log);
// {
//   status: 200,
//   bodyJson: '{"data":{"getAccount":{"imageUrl":"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format","id":"QWNjb3VudFR5cGU6ODY3MDYyNDQw"}}}'
// }
Enter fullscreen mode Exit fullscreen mode

You can also use it in Rust:

mod lib;
use tokio;

#[tokio::main]
async fn main() {
    let body = String::from(
        "{
            \"id\": \"NavbarQuery\",
            \"query\": \"query NavbarQuery(\\n  $identity: AddressScalar!\\n) {\\n  getAccount(address: $identity) {\\n    imageUrl\\n    id\\n  }\\n}\\n\",
            \"variables\": {
              \"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
            }
          }",
    );

    let config = lib::RequestConfig {
        body,
        method: "POST".to_string(),
        host: "opensea.io".to_string(),
        uri: "https://opensea.io/__api/graphql/".to_string(),
        headers: vec![
            vec!["authority".to_string(), "opensea.io".to_string()],
            vec!["content-type".to_string(),"application/json".to_string()],
            vec!["origin".to_string(), 
        // ...
    };

    let res = lib::request(config).await.unwrap();
    println!("res: {:?}", res);
    // res: (200, "{\"data\":{\"getAccount\":{\"imageUrl\":\"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format\",\"id\":\"QWNjb3VudFR5cGU6ODY3MDYyNDQw\"}}}")
}

Enter fullscreen mode Exit fullscreen mode

Let's build

I will cover the key points of the TLS client implementation. I will not describe the Neon interface building, Tokio runtime setup and other secondary aspects in detail.

So, here're the dependencies we need:

use h2::client;
use http::Request;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;

use boring::ssl::{ConnectConfiguration, SslConnector, SslMethod};

use once_cell::sync::OnceCell;
use std::error::Error;
use std::net::ToSocketAddrs;

use bytes::{BufMut, Bytes, BytesMut};

use neon::context::{Context, FunctionContext, ModuleContext};
use neon::prelude::*;

use tokio::runtime::Runtime;
Enter fullscreen mode Exit fullscreen mode

Now we can start writing the main request function that opens a TCP connection:

pub struct RequestConfig {
    pub method: String,
    pub body: String,
    pub host: String,
    pub uri: String,
    pub headers: Vec<Vec<String>>,
}

pub async fn request(request_config: RequestConfig) -> Result<(u16, String), Box<dyn Error>> {
    let addr = format!("{}:443", request_config.host)
        .to_socket_addrs()
        .unwrap()
        .next()
        .unwrap();
    let tcp = TcpStream::connect(&addr).await?;

    connect_and_send_request(tcp, request_config).await
}
Enter fullscreen mode Exit fullscreen mode

We'll get to connect_and_send_request later.
The next step is to create SSL configuration. This is where we specify ciphers and TLS extensions settings.

pub fn get_connect_config() -> ConnectConfiguration {
    let cipher_list = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA";
    let mut builder = SslConnector::builder(SslMethod::tls()).unwrap();
    builder.set_verify(boring::ssl::SslVerifyMode::NONE);
    builder.set_grease_enabled(true);
    builder.enable_ocsp_stapling();
    builder.set_cipher_list(&cipher_list).unwrap();
    builder
        .set_alpn_protos(&[2, 104, 50, 8, 104, 116, 116, 112, 47, 49, 46, 49])
        .unwrap();
    builder.enable_signed_cert_timestamps();
    let connector = builder.build();

    let mut connect_config = connector.configure().unwrap();
    connect_config.set_verify_hostname(false);

    connect_config
}
Enter fullscreen mode Exit fullscreen mode

Now we can use that config to perform a SSL handshake and initialize a client:

async fn connect_and_send_request(
    tcp: TcpStream,
    request_config: RequestConfig,
) -> Result<(u16, String), Box<dyn Error>> {
    let connect_config = get_connect_config();

    let res = tokio_boring::connect(connect_config, request_config.host.as_str(), tcp).await;
    let tls = res.unwrap();

    let (mut client, h2) = client::Builder::new()
        .initial_connection_window_size(1024 * 1024 * 1024)
        .initial_window_size(1024 * 1024 * 1024)
        .handshake::<_, Bytes>(tls)
        .await
        .unwrap();
    // ...
Enter fullscreen mode Exit fullscreen mode

Now we can create our request and send it:

    // ...
    let mut request = Request::builder()
        .version(http::version::Version::HTTP_2)
        .method(request_config.method.as_str())
        .uri(request_config.uri);

    let mut i = 0;

    while i < request_config.headers.len() {
        request = request.header(
            request_config.headers[i][0].as_str(),
            request_config.headers[i][1].as_str(),
        );
        i = i + 1;
    }

    let has_body = request_config.body.len() > 0;

    if has_body {
        request = request.header("content-length", request_config.body.len());
    }

    let request = request.body(()).unwrap();
    let (response, mut send_stream) = client.send_request(request, !has_body).unwrap();

    if has_body {
        send_stream
            .send_data(Bytes::from(request_config.body), true)
            .unwrap();
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

When the request is sent, we can get and return its result:

    // ...
    tokio::spawn(async move {
        if let Err(e) = h2.await {
            println!("GOT ERR={:?}", e);
        }
    });

    let (res_parts, mut body) = response.await?.into_parts();
    let mut response_buf = BytesMut::new();

    while let Some(chunk) = body.data().await {
        response_buf.put(chunk?);
    }

    Ok((
        res_parts.status.as_u16(),
        String::from_utf8(response_buf.to_vec())?,
    ))
}
Enter fullscreen mode Exit fullscreen mode

Now we have a working TLS client!

Proxy implementation

To make it work with proxy, we have to send extra headers before the SSL handshake. Everything else is the same:

pub async fn request_with_proxy(
    request_config: RequestConfig,
    proxy_addr: String,
    proxy_auth_in_base64: String,
) -> Result<(u16, String), Box<dyn Error>> {
    let addr = proxy_addr.to_socket_addrs().unwrap().next().unwrap();
    let mut tcp = TcpStream::connect(&addr).await?;

    let connect_request = [
        format!("CONNECT {}:443 HTTP/1.1", request_config.host).to_string(),
        format!("Host: {}:443", request_config.host).to_string(),
        format!("Proxy-Authorization: Basic {}", proxy_auth_in_base64),
        "User-Agent: curl/7.81.0".to_string(),
        "Connection: keep-alive".to_string(),
        "\r\n".to_string(),
    ]
    .join("\r\n");

    tcp.write_all(connect_request.as_bytes()).await.unwrap();
    let mut msg = vec![0; 1024];

    loop {
        tcp.readable().await?;

        match tcp.try_read(&mut msg) {
            Ok(n) => {
                msg.truncate(n);
                break;
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                continue;
            }
            Err(e) => {
                return Err(e.into());
            }
        }
    }

    connect_and_send_request(tcp, request_config).await
}
Enter fullscreen mode Exit fullscreen mode

That's it!

I couldn't find a solution like that on the internet and collected information bit by bit.

I really hope this quickly-made article will save someone's time just like it would save mine.

P.S. I am not a Rust developer, so be careful using this code in production.

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

Top comments (0)

The Most Contextual AI Development Assistant

Pieces.app image

Our centralized storage agent works on-device, unifying various developer tools to proactively capture and enrich useful materials, streamline collaboration, and solve complex problems through a contextual understanding of your unique workflow.

👥 Ideal for solo developers, teams, and cross-company projects

Learn more