Engineering

Encrypting Communication

In the previous post of this series we saw how we could perform RPC on top of a multiplexed duplex stream. In this article we’re going to show you how you increase the security of the client and the server applications by encrypting your communication channel and adding authentication.

Enjoy!

So far our TCP server has communicated with clients without any kind of security: it lets any client connect to it and it communicates with them through a clear-text channel, making it prone to someone eavesdropping on the network. These may not be a big problem for your office refrigerator application, but it can surely be if your data is sensitive, or you can’t let just every and anyone use the service.

Let’s first address this last problem: encrypting the communication channel.

Creating self-signed certificates

In this example we will create a self-signed certificate. This is a certificate that was not issued by a recognised Certificate Authority (CA), which means that the clients won’t be able to verify this certificate unless they have access to the public certificate we’re about to issue.

If you can distribute the server public certificate to the clients, this is the most flexible approach where you don’t have to rely on a third party to issue your certificate.

Before you can generate a certificate you need to have the openssl command-line tool installed. Many Linux distributions, and MacOS, come with the openssl program installed.

Creating a Certification Authority

Now you’re going to create your own Certification Authority, which you will later use to create the server and client certificates.

Once you have that installed, create a certs/ca directory, and in it, generate the CA private key:

$ mkdir -p certs/ca
$ cd certs/ca
$ openssl genrsa -out private-key.pem 2048

This creates a suitable private key and writes it to the private-key.pem file.

Next we’re going to create the CA public certificate based on the private key:

$ openssl req -x509 -new -nodes -key private-key.pem -days 1024 -out certificate.pem -subj "/C=US/ST=Utah/L=Provo/O=ACME Signing Authority Inc/CN=example.com"

This outputs the CA certificate into the certificate.pem file.

Creating the server key and certificate

Now we need to issue the server certificate, but first let’s create the private keys inside the certs/serverdirectory:

$ cd ..
$ mkdir server
$ cd server
$ openssl genrsa -out private-key.pem 2048

Next, create a Certificate Signing Request (CSR) file using your private key:

$ openssl req -new -key private-key.pem -out csr.pem

The purpose of this CSR is to “request” a certificate. That is, if you wanted a CA to sign your certificate, you could give them this file to process and they would give you back a certificate.

However, here you will self-sign your certificate, again using your CA private key:

$ openssl x509 -req -in csr.pem -CA ../root/certificate.pem -CAkey ../root/private-key.pem -CAcreateserial -out certificate.pem -days 500

Adding TLS support to the server

Now we need to modify the server to use TLS instead of raw TCP:

fridge_server.js:

var fs = require('fs');  
var tls = require('tls');  
var path = require('path');  
var DuplexEmitter = require('duplex-emitter');  
var Mux = require('mux-demux');  
var dnode = require('dnode');  
var commands = require('./commands');
var serverOptions = {  
  key:  fs.readFileSync(path.join(__dirname, 'certs', 'server', 'private-key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'certificate.pem'))
};
var server = tls.createServer(serverOptions);
server.on('secureConnection', handleConnection);
server.listen(8000, function() {  
  console.log('door server listening on %j', server.address());
});
// sensors
var sensors = [  
  {
    name: 'door',
    events: ['open', 'close'],
    emitter: require('./door'),
    remotes: {},
    nextId: 0,
    lastEvent: undefined
  },
  {
    name: 'temperature',
    events: ['reading'],
    emitter: require('./thermometer'),
    remotes: {},
    nextId: 0,
    lastEvent: undefined
  },
];
// handle connections
function handleConnection(conn) {  
  var mx = Mux(handleConnection);
  conn.on('error', onError);
  mx.on('error', onError);
  conn.pipe(mx).pipe(conn);
  sensors.forEach(attachSensor);
  function attachSensor(sensor) {
    var stream = mx.createWriteStream(sensor.name);
    var remoteEmitter = DuplexEmitter(stream);
    stream.once('close', onClose);
    stream.on('error', onError);
    mx.on('error', onError);
    // add remote to sensor remotes
    var id = ++ sensor.nextId;
    sensor.remotes[id] = remoteEmitter;
    if (sensor.lastEvent) {
      remoteEmitter.emit.apply(remoteEmitter, sensor.lastEvent);
    }
    function onClose() {
      delete sensor.remotes[id];
    }
  }
  /// RPC
  function handleConnection(conn) {
    if (conn.meta != 'rpc') {
      onError(new Error('Invalid stream name: ' + conn.meta));
    }
    else {
      var d = dnode(commands);
      conn.pipe(d).pipe(conn);
    }
  }
  function onError(err) {
    conn.destroy();
    console.error('Error on connection: ' + err.message);
  }
}
/// broadcast all sensor events to connections
sensors.forEach(function(sensor) {  
  sensor.events.forEach(function(event) {
    // broadcast all events of type `event`
    sensor.emitter.on(event, broadcast(event, sensor.remotes));
    // store last event on `sensor.lastEvent`
    sensor.emitter.on(event, function() {
      var args = Array.prototype.slice.call(arguments);
      args.unshift(event);
      sensor.lastEvent = args;
    });
  });
});
function broadcast(event, remotes) {  
  return function() {
    var args = Array.prototype.slice.call(arguments);
    args.unshift(event);
    Object.keys(remotes).forEach(function(emitterId) {
      var remote = remotes[emitterId];
      remote.emit.apply(remote, args);
    });
  };
}

Here you can see that we’re using tls.createServer(options) to create our server, passing in some options:

  • key: the server private key, loaded as a raw buffer
  • cert: the server certificate, also loaded as a raw buffer

When a client successfully establishes a secure connection, the server emits a secureConnection event, which is what we want to listen to now.

The server also emits a connect event, but that gives you a unencrypted TCP socket as before, which is not what you need now.

Using TLS on the client

Now, let’s enable TLS also on the client:

var fs = require('fs');  
var tls = require('tls');  
var path = require('path');  
var Mux = require('mux-demux');  
var DuplexEmitter = require('duplex-emitter');
var options = {  
  host: process.argv[2],
  port: Number(process.argv[3]),
  rejectUnauthorized: false
};
var conn = tls.connect(options, onConnect);
conn.on('error', function(err) {  
  console.log('error: %j', err);
});
var doorTimeoutSecs = Number(process.argv[4]);  
var maxTemperature = Number(process.argv[5]);
var sensors = {  
  'door': handleDoor,
  'temperature': handleTemperature
};
function onConnect() {  
  console.log('connected', conn.authorized, conn.authorizationError);
  var mx = Mux(onStream);
  conn.pipe(mx).pipe(conn);
  function onStream(stream) {
    var handle = sensors[stream.meta];
    if (! handle) {
      throw new Error('Unknown stream: %j', stream.meta);
    }
    handle(DuplexEmitter(stream));
  }
}
/// Door
function handleDoor(door) {  
  var timeout;
  var warned = false;
  door.on('open', onDoorOpen);
  door.on('close', onDoorClose);
  function onDoorOpen() {
    timeout = setTimeout(onDoorTimeout, doorTimeoutSecs * 1e3);
  }
  function onDoorClose() {
    if (warned) {
      warned = false;
      console.log('closed now');
    }
    if (timeout) {
      clearTimeout(timeout);
    }
  }
  function onDoorTimeout() {
    warned = true;
    console.error(
      'DOOR OPEN FOR MORE THAN %d SECONDS, GO CLOSE IT!!!',
      doorTimeoutSecs);
  }
}
/// Temperature
function handleTemperature(temperature) {  
  temperature.on('reading', onTemperatureReading);
  function onTemperatureReading(temp, units) {
    if (temp > maxTemperature) {
      console.error('FRIDGE IS TOO HOT: %d %s', temp, units);
    }
  }
}

We’re now using tls.createConnection(options) to connect to the server, passing in some options:

  • host: hostname from the command-line arguments, the same as before;
  • port: TCP port number from the command-line arguments, same as before;
  • rejectUnauthorized: false, telling the client that it should not check the server certificate validity. This option is here because, for now, we just wish to have an encrypted channel – we're not looking into authenticating the server (yet).

Authenticating the server

Let’s now allow the client to authenticate the server by making just some small changes to the tls.connect options:

// ...
var options = {  
  host: process.argv[2],
  port: Number(process.argv[3]),
  ca: [fs.readFileSync(path.join(__dirname, 'certs', 'root', 'certificate.pem'))],
  rejectUnauthorized: true
};
// ...

Here we’ve added one attribute to the config: ca, where we define the root Certification Authorities that the client will take into account when validating the server certificate. Here we pass in the root public certificate, admitting that the client trusts the certificates signed by it.

Also, we change rejectUnauthorized from false to true. If, when establishing a secure connection, the client cannot, for some reason, verify that the server certificate was issued by any of the trusted Certification Authorities (or any of their child CAs) or that the server name does not match the common name field (CN) in the certificate data, the connection emits an error and closes.

If, by chance, you choose to run this server in a different host name, you will have to reissue the certificate, changing its CN field.

You can test this by launching the server on a command-line window:

$ node fridge_server
door server listening on {"address":"0.0.0.0","family":"IPv4","port":8000}

and then launching the client on another:

$ node fridge_client localhost 8000 1 10

Authenticating the client

For the server to authenticate the client we need to use our CA to issue a new certificate. First though, we need to create a client certificate:

$ mkdir certs/client-001
$ cd certs/client-001
$ openssl genrsa -out private-key.pem 2048

Next, create a Certificate Signing Request (CSR) file using your private key, just like you did when you requested the server one:

$ openssl req -new -key private-key.pem -out csr.pem

Let’s then use our CA to create the signed certificate:

$ openssl x509 -req -in csr.pem -CA ../root/certificate.pem -CAkey ../root/private-key.pem -CAcreateserial -out certificate.pem -days 500

Now we can use it on the client, modifying the client options to add the private key and the public certificate:

// ...
var options = {  
  host: process.argv[2],
  port: Number(process.argv[3]),
  ca: [fs.readFileSync(path.join(__dirname, 'certs', 'root', 'certificate.pem'))],
  key: fs.readFileSync(path.join(__dirname, 'certs', 'client-001', 'private-key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'certs', 'client-001', 'certificate.pem')),
  rejectUnauthorized: true
};

On the server side, let’s change some options to force the server to get the client certificate and validate the certificate against our CA:

//...
var serverOptions = {  
  key:  fs.readFileSync(path.join(__dirname, 'certs', 'server', 'private-key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'certs', 'server', 'certificate.pem')),
  ca: [fs.readFileSync(path.join(__dirname, 'certs', 'root', 'certificate.pem'))],
  requestCert: true,
  rejectUnauthorized: true
};

Revoking access

Once one client certificate is issued, our server will accept it forever. To be able to reject any given client with a valid certificate, we’d have to keep a list of supported client names (white list) or a list of banned client names (black list). To make things simpler for now, let’s keep a white list in the server memory, against which we check the client certificate before accepting a connection.

On the server, we can then simply create this client white list, containing only our one client name for now:

/// client white list
var clientWhiteList = [  
  'fridge-client-001'
];
function clientAllowed(name) {  
  return clientWhiteList.indexOf(name) > -1;
}

Now, in the connection handler we can fetch the client certificate and then use the clientAllowedfunction to check whether we can proceed:

//...
function handleConnection(conn) {
  var mx = Mux(handleConnection);
  conn.on('error', onError);
  mx.on('error', onError);
  var clientName = conn.getPeerCertificate().subject.CN;
  if (! clientAllowed(clientName)) {
    return conn.emit('error', new Error('client not allowed: ' + clientName));
  }
  conn.pipe(mx).pipe(conn);
  //...

This method of whitelisting client names requires that you restart your process every time there is a change. You can implement a gossip protocol over the network where all the changes (inserts or removals, in this case) in a given list are automatically propagated to all of the nodes within a certain amount of time. For more information you can check another book in this series named “Configuration Patterns”.

Next Article

This was the last article on the subject of Networking Patterns.

You can find all the previous posts on this topic here:

In our next article in this series we’ll start taking a look at queues and how you can use them streamline and share asynchronous work between processes.Next Article

In our next article for this series we’ll start taking a look at queues and how you can use them streamline and share asynchronous work between processes.

This article was extracted from the Networking Patterns, a book from the Node Patterns series.

Encrypting Communication
was originally published in YLD Blog on Medium.
Share this article: