Search code examples
node.jssocketsssltcp

How to upgrade a tcp connection to TLS in Node.js?


I have a situation where two "clients" connect to each other by performing simultaneously (TCP hole punching):

// The socket already exist, and was connected to a rendez-vous server
// I re-use it with the same local port and address as it was using with the
// server, except now it's trying to contact its peer.
socket.connect({
    localAddress,
    localPort,
    port: peerAddresses.remoteAddress.port,
    host: peerAddresses.remoteAddress.host,
});

This works well. The socket is a TLSSocket that originally connected to a rendez-vous server.

After performing the hole-punching and connecting to the peer though, the encryption is lost (I think, based on what I saw with Wireshark).

Is there a way I can upgrade the connection between the peers to be encrypted?

I tried various flavors of:

tls.connect({ socket: socket, secureContext: tls.createSecureContext(...) });

// or

new tls.TSLSocket(socket, { secureContext: ..., isServer: true /* just on one end */ })

on only one side of the connection (because my understanding is that TLS is asymmetrical, and you need a server and a client), and on both (because, why not...), but to no avail.

I'm starting to think I'm going to need to do the Diffie-Hellman key exchange manually, but I would really like to avoid that.

EDIT: Here is one concrete example, with two client trying to connect to each other, and then upgrading:

// clientA.js
const net = require('net');
const tls = require('tls');
const fs = require('fs');

const localPort = 3018;
const peerPort = 3019;

const socket = net.createConnection({
  localPort: localPort,
  port: peerPort
});

socket.on('connect', () => {
  console.log('connected!');

  const secureContext = tls.createSecureContext({
    key: fs.readFileSync('./ca-key.pem'),
    cert: fs.readFileSync('./ca-cert.pem'),
  });

  const secureSocket = new tls.TLSSocket(socket, { isServer: true, secureContext });

  secureSocket.on('session', session => console.log('session', session));

  secureSocket.on('secureConnect', () => {
    console.log('securely connected!');
  });

  secureSocket.on('close', () => console.log('closing secure socket A'));
});

socket.on('error', (e) => { console.log(e); });

socket.on('close', () => {
  socket.connect({
    localPort: localPort,
    port: peerPort
  });
});

and

// clientB.js
const net = require('net');
const tls = require('tls');
const fs = require('fs');

const localPort = 3019;
const peerPort = 3018;

const socket = net.createConnection({
  localPort: localPort,
  port: peerPort
});

socket.on('connect', () => {
  console.log('connected!');

  const secureSocket = new tls.TLSSocket(socket, { isServer: false });

  secureSocket.on('session', () => {
    console.log('session');
  });

  secureSocket.on('secureConnect', () => {
    console.log('securely connected!');
  });

  secureSocket.on('close', () => console.log('closing secure socket B'));
});

socket.on('error', (e) => { console.log(e); });

socket.on('close', () => {
  socket.connect({
    localPort: localPort,
    port: peerPort
  });
});

The "securely connected!" message never shows up, nor do the closing / session messages on the secure sockets.

I must say it does seem off to me to just instantiate a new TLS socket around the existing one - and indeed, it seemingly does nothing. I do get the connected! message, but no upgrade seem to happen.

If I were to call connect({ localPort: localPort, port: peerPort }) on the secureSocket, I would get EADDRINUSE, which makes sense to me.

I've also tried a scenario where I'd call tls.connect({ socket: socket, secureContext: tls.createSecureContext(...) }) on both sides, but this also seems off because I cannot indicate which socket should behave as server.

Any tip welcome, I'm pretty much in the dark with what happens under the hood, I may need to dig into Node's internals at some point.


Solution

  • Thanks to user President James K. Polk!

    Working code, to upgrade from regular TCP socket to TLS in Node.js:

    // clientA.js
    const net = require('net');
    const tls = require('tls');
    const fs = require('fs');
    
    const localPort = 20001;
    const peerPort = 20000;
    
    const socket = net.createConnection({
      localPort: localPort,
      port: peerPort
    });
    
    socket.on('connect', () => {
      console.log('connected!');
    
      // Do not call socket.write here! A "clean pipe" is needed to proceed to the upgrade
    
      const secureContext = tls.createSecureContext({
        key: fs.readFileSync('./ca-key.pem'),
        cert: fs.readFileSync('./ca-cert.pem'),
      });
    
      // This constitutes the upgrade. One side needs to be a server, the other not.
      const secureSocket = new tls.TLSSocket(socket, { isServer: true, secureContext });
    
      // You can write here, but the client should initiate data sending, or the ping-pong won't start.
      // secureSocket.write('ping');
    
      secureSocket.on('keylog', (keylog) => {
        console.log('keylog', keylog.toString());
      });
    
      secureSocket.on('data', data => {
        console.log('received', data.toString());
        setTimeout(() => {
          console.log('sending ping');
          secureSocket.write('ping');
        }, 1000);
      });
    });
    
    socket.on('error', (e) => { console.log(e); });
    
    socket.on('close', () => {
      socket.connect({
        localPort: localPort,
        port: peerPort
      });
    });
    

    and

    // clientB.js
    const net = require('net');
    const tls = require('tls');
    
    const localPort = 20000;
    const peerPort = 20001;
    
    const socket = net.createConnection({
      localPort: localPort,
      port: peerPort
    });
    
    socket.on('connect', () => {
      console.log('connected!');
    
      // Do not call socket.write here! A "clean pipe" is needed to proceed to the upgrade
    
      // This constitutes the upgrade.
      const secureSocket = new tls.TLSSocket(socket, { isServer: false });
    
      // This will start the ping-pong
      secureSocket.write('pong');
    
      secureSocket.on('keylog', (keylog) => {
        console.log('keylog', keylog.toString());
      });
    
      secureSocket.on('data', data => {
        console.log('received', data.toString());
        setTimeout(() => {
          console.log('sending pong');
          secureSocket.write('pong');
        }, 1000);
      });
    });
    
    socket.on('error', (e) => { console.log(e); });
    
    socket.on('close', () => {
      socket.connect({
        localPort: localPort,
        port: peerPort
      });
    });
    

    new tls.TLSSocket(socket, option) does indeed perform an upgrade when one side is declared as server and provides a secureContext, and the other one is declared as a client.

    The client should write some data first.

    There is no need to call connect on the secureSocket - the socket is connected, doing so would result in an EADDRINUSE.

    The 'secureConnect' event is not emitted when a <tls.TLSSocket> is created using the new tls.TLSSocket() constructor.

    Thanks again!