TLS Over Websocket

In JavaScript it's possible to negotiate a TLS exchange, via a WebSocket proxy connection forming a TCP tunnel to port 443 on another site.

But Why? Be-CORS, that's why.

I was writing an unofficial light-weight browser experience for members of an online comedy channel. I'd reversed their API and navigated the simple enough OAuth implementation they had, but then ran up against the issue of CORS.

Cross-Origin-Resource-Security roughly means that the JavaScript code at tv.bonner.is isn't allowed to communicate with auth.comedy-channel.com.

Now, the easy fix is by simply writing up a forwarding-proxy at red.bonner.is that looks something like this:

Node-RED is amazing, install it now.

The rough gist is that someone can make a request to my site, my server will make a request on their behalf and respond with the result. It avoids CORS because I  can tell bonner.is to accept any requests. The problem, is that my site can see the user's password as it's sent over, and that's a BAD THING™.

The problem therefore, is how can our user, from their browser, setup a secure connection with a server. Or more technically, how can we (from the JavaScript layer), send and receive a JSON payload to https://auth.comedy-channel.com.

It's simple, we implement fetch again.

auth.comedy-channel.com offers the ability to open a TLS channel on their port 443. This is what the https part of a URL represents when you visit a site.

Transport-Layer-Security (TLS) is a protocol that allows two people to communicate, even when passing all the messages via a third-party. You can read up on my rough summary of the underpinning math here. The important part is that it's a protocol that's communicated over TCP. What this means is that a series of packets are exchanged between your computer and auth.comedy-channel.com's port 443 over TCP which then establish a TLS channel, over which an HTTP request is made.

In the browser we have a few ways to communicate with other computers. One is the simple fetch which allows us to make HTTP calls. But there isn't a way to renegotiate a TLS exchange from HTTP (wait, what?! No...) so this isn't an option.

Another is WebRTC which uses SCTP which is a amalgamation of both UDP and TCP. This is maybe an option given it'd be possible to create a bridge between the ports to allow the TLS stream to be setup.

But the next and most interesting choice is WebSockets, which allows us to upgrade to a bi-directional data-stream with an accepting HTTP endpoint. An amazing project called WebSocketify exists which has the following description:

Websockify is a WebSocket to TCP proxy/bridge.

Bingo. What they're saying is that they'll accept a WebSocket connection, and convert the data-stream into a TCP stream to a target port.

All we have to do is setup the Websockify server on our server, this is done in my setup via a docker-compose setup found here:

Here is my docker-compose.yml file.

With the docker file looking like this:

# Initialize
FROM python:3

# Install
WORKDIR /app
RUN apt-get update && apt-get install --yes net-tools 
RUN pip install --no-cache-dir numpy

RUN git clone https://github.com/novnc/websockify /app/websockify

# Run-time
# ENTRYPOINT [ "ls", "/app/websockify" ]
RUN chmod +x /app/websockify/run

ENTRYPOINT bash /app/websockify/run -v 6060 "$TARGET"

The net result is that we end up with a sub-domain that accepts WebSocket connections, which will proxy the incoming raw byte-stream over to our target site's port 443 as a TCP stream.

Soooo.... a TLS handshake in Javascript....?

Given we can now send a byte-stream to auth.comedy-channel.com:443 all we need to perform an HTTPS POST request. This is made up of two parts:

  • A POST request, written as a simple ASCII payload.
  • A TLS handshake between the client and the server.

The first is trivial and can be done in Notepad, the second requires tens of thousands of lines of code.

After a bit of hunting, we find the digitalbazaar, who've written a library called forge which provides a native implementation of TLS in JavaScript.

digitalbazaar/forge
A native implementation of TLS in Javascript and tools to write crypto-based and network-heavy webapps - digitalbazaar/forge

Which allows us to throw together the following library:

moritonal/FetchOverWebSockets
Implementation of Fetch over TCP via WebSockets. Contribute to moritonal/FetchOverWebSockets development by creating an account on GitHub.

So FetchOverWebSockets provides us with the ability to run the following code:

const FetchOverWebSocket = (await import("fetch-over-websockets/src/FetchOverWebSocket.ts")).default;

const req = new FetchOverWebSocket("wss://****.bonner.is");

let newToken : OAuth.OAuth = null;

if (this.options.Username === "" || this.options.Password === "") 
    return null;

let options = {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "User-Agent": "tls-over-websocket",
        "Keep-Alive": "false",
        "Accept": "*/*",
    },
    body: JSON.stringify({
        "client_id": "****",
        "grant_type": "password",
        "password": this.options.Password,
        "scope": "user public",
        "username": this.options.Username
    })
};

newToken = await (await req.fetch("https://auth.comedy-channel.com", options)).json();
Login script using FetchOverWebSocets aimed at the tunnel entrance on the same-site domain

So, to summarise. We establish a secure WebSocket connection to bonner.is which proxies a byte-stream to comedy-channel.com:443, then we perform a TLS hand-shake between client and the end-server, and then finally POST a login request.

All completely secure from the clients machine.

All of this should have the large caveat that the client trusts the code sent to it, so unless this was authenticated via a third-party the whole point is moot, but it was very fun to put together. I'm fairy sure the server would be unable to MITM this connection given it's end-to-end encrypted in the sub-stream.