Implementing STARTTLS in a protocol in NodeJS

3.5k views Asked by At

I'm trying to add a STARTTLS upgrade to an existing protocol (which currently works in plaintext).

As a start, I'm using a simple line-based echoing server (it's a horrible kludge with no error handling or processing of packets into lines - but it usually just works as the console sends a line-at-a-time to stdin).

I think my server is right, but both ends exit with identical errors when I type starttls:

events.js:72
        throw er; // Unhandled 'error' event
              ^
Error: 139652888721216:error:140770FC:SSL routines:SSL23_GET_SERVER_HELLO:unknown protocol:../deps/openssl/openssl/ssl/s23_clnt.c:766:

    at SlabBuffer.use (tls.js:232:18)
    at CleartextStream.read [as _read] (tls.js:450:29)
    at CleartextStream.Readable.read (_stream_readable.js:320:10)
    at EncryptedStream.write [as _write] (tls.js:366:25)
    at doWrite (_stream_writable.js:221:10)
    at writeOrBuffer (_stream_writable.js:211:5)
    at EncryptedStream.Writable.write (_stream_writable.js:180:11)
    at Socket.ondata (stream.js:51:26)
    at Socket.EventEmitter.emit (events.js:95:17)
    at Socket.<anonymous> (_stream_readable.js:746:14)

Have I completely misunderstood how to do an upgrade on the client side?

Currently, I'm using the same method to add TLS-ness to plain streams at each end. This feels wrong, as both client and server will be trying to play the same role in the negotiation.

tlsserver.js:

r tls = require('tls');
var net = require('net');
var fs = require('fs');

var options = {
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem'),

  // This is necessary only if using the client certificate authentication.
  requestCert: true,

  // This is necessary only if the client uses the self-signed certificate.
  ca: [ fs.readFileSync('client-cert.pem') ],

  rejectUnauthorized: false
};

var server = net.createServer(function(socket) {
  socket.setEncoding('utf8');
  socket.on('data', function(data) {
    console.log('plain data: ', data);
    // FIXME: this is not robust, it should be processing the stream into lines
    if (data.substr(0, 8) === 'starttls') {
      console.log('server starting TLS');
      //socket.write('server starting TLS');
      socket.removeAllListeners('data');

      options.socket = socket;
      sec_socket = tls.connect(options, (function() {
        sec_socket.on('data', function() {
          console.log('secure data: ', data);
        });
        return callback(null, true);
      }).bind(this));
    } else {
      console.log('plain data', data);
    }
  });
});

server.listen(9999, function() {
  console.log('server bound');
});

client.js:

var tls = require('tls');
var fs = require('fs');
var net = require('net');

var options = {
  // These are necessary only if using the client certificate authentication
  key: fs.readFileSync('client-key.pem'),
  cert: fs.readFileSync('client-cert.pem'),

  // This is necessary only if the server uses the self-signed certificate
  ca: [ fs.readFileSync('server-cert.pem') ],
  rejectUnauthorized: false
};


var socket = new net.Socket();
var sec_socket = undefined;

socket.setEncoding('utf8');
socket.on('data', function(data) {
  console.log('plain data:', data);
});
socket.connect(9999, function() {
  process.stdin.setEncoding('utf8');
  process.stdin.on('data', function(data) {
    if (!sec_socket) {
      console.log('sending plain:', data);
      socket.write(data);
    } else {
      console.log('sending secure:', data);
      sec_socket.write(data);
    }
    if (data.substr(0, 8) === 'starttls') {
      console.log('client starting tls');
      socket.removeAllListeners('data');
      options.socket = socket;
      sec_socket = tls.connect(options, (function() {
        sec_socket.on('data', function() {
          console.log('secure data: ', data);
        });
        return callback(null, true);
      }).bind(this));
    }
  });
});

Got it working, thanks to Matt Seargeant's answer. My code now looks like:

server.js:

var ts = require('./tls_socket');
var fs = require('fs');

var options = {
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem'),

  // This is necessary only if using the client certificate authentication.
  requestCert: false,

  // This is necessary only if the client uses the self-signed certificate.
  ca: [ fs.readFileSync('client-cert.pem') ],

  rejectUnauthorized: false
};



var server = ts.createServer(function(socket) {
  console.log('connected');
  socket.on('data', function(data) {
    console.log('data', data);
    if (data.length === 9) {
      console.log('upgrading to TLS');
      socket.upgrade(options, function() {
        console.log('upgraded to TLS');
      });
    }
  });
});
server.listen(9999);

client.js:

var ts = require('./tls_socket');
var fs = require('fs');
var crypto = require('crypto');

var options = {
  // These are necessary only if using the client certificate authentication
  key: fs.readFileSync('client-key.pem'),
  cert: fs.readFileSync('client-cert.pem'),

  // This is necessary only if the server uses the self-signed certificate
  ca: [ fs.readFileSync('server-cert.pem') ],

  rejectUnauthorized: false
};

var socket = ts.connect(9999, 'localhost', function() {
  console.log('secured');
});

process.stdin.on('data', function(data) {
  console.log('sending:', data);
  socket.write(data);
  if (data.length === 9) {
    socket.upgrade(options);
  }
});

tls_socket.js:

"use strict";
/*----------------------------------------------------------------------------------------------*/
/* Obtained and modified from http://js.5sh.net/starttls.js on 8/18/2011.                       */
/*----------------------------------------------------------------------------------------------*/

var tls = require('tls');
var crypto = require('crypto');
var util = require('util');
var net = require('net');
var stream = require('stream');
var SSL_OP_ALL = require('constants').SSL_OP_ALL;

// provides a common socket for attaching
// and detaching from either main socket, or crypto socket
function pluggableStream(socket) {
    stream.Stream.call(this);
    this.readable = this.writable = true;
    this._timeout = 0;
    this._keepalive = false;
    this._writeState = true;
    this._pending = [];
    this._pendingCallbacks = [];
    if (socket)
        this.attach(socket);
}

util.inherits(pluggableStream, stream.Stream);

pluggableStream.prototype.pause = function () {
    if (this.targetsocket.pause) {
        this.targetsocket.pause();
        this.readable = false;
    }
}

pluggableStream.prototype.resume = function () {
    if (this.targetsocket.resume) {
        this.readable = true;
        this.targetsocket.resume();
    }
}

pluggableStream.prototype.attach = function (socket) {
    var self = this;
    self.targetsocket = socket;
    self.targetsocket.on('data', function (data) {
        self.emit('data', data);
    });
    self.targetsocket.on('connect', function (a, b) {
        self.emit('connect', a, b);
    });
    self.targetsocket.on('secureConnection', function (a, b) {
        self.emit('secureConnection', a, b);
        self.emit('secure', a, b);
    });
    self.targetsocket.on('secure', function (a, b) {
        self.emit('secureConnection', a, b);
        self.emit('secure', a, b);
    });
    self.targetsocket.on('end', function () {
        self.writable = self.targetsocket.writable;
        self.emit('end');
    });
    self.targetsocket.on('close', function (had_error) {
        self.writable = self.targetsocket.writable;
        self.emit('close', had_error);
    });
    self.targetsocket.on('drain', function () {
        self.emit('drain');
    });
    self.targetsocket.on('error', function (exception) {
        self.writable = self.targetsocket.writable;
        self.emit('error', exception);
    });
    self.targetsocket.on('timeout', function () {
        self.emit('timeout');
    });
    if (self.targetsocket.remotePort) {
        self.remotePort = self.targetsocket.remotePort;
    }
    if (self.targetsocket.remoteAddress) {
        self.remoteAddress = self.targetsocket.remoteAddress;
    }
};

pluggableStream.prototype.clean = function (data) {
    if (this.targetsocket && this.targetsocket.removeAllListeners) {
        this.targetsocket.removeAllListeners('data');
        this.targetsocket.removeAllListeners('secureConnection');
        this.targetsocket.removeAllListeners('secure');
        this.targetsocket.removeAllListeners('end');
        this.targetsocket.removeAllListeners('close');
        this.targetsocket.removeAllListeners('error');
        this.targetsocket.removeAllListeners('drain');
    }
    this.targetsocket = {};
    this.targetsocket.write = function () {};
};

pluggableStream.prototype.write = function (data, encoding, callback) {
    if (this.targetsocket.write) {
        return this.targetsocket.write(data, encoding, callback);
    }
    return false;
};

pluggableStream.prototype.end = function (data, encoding) {
    if (this.targetsocket.end) {
        return this.targetsocket.end(data, encoding);
    }
}

pluggableStream.prototype.destroySoon = function () {
    if (this.targetsocket.destroySoon) {
        return this.targetsocket.destroySoon();
    }
}

pluggableStream.prototype.destroy = function () {
    if (this.targetsocket.destroy) {
        return this.targetsocket.destroy();
    }
}

pluggableStream.prototype.setKeepAlive = function (bool) {
    this._keepalive = bool;
    return this.targetsocket.setKeepAlive(bool);
};

pluggableStream.prototype.setNoDelay = function (/* true||false */) {
};

pluggableStream.prototype.setTimeout = function (timeout) {
    this._timeout = timeout;
    return this.targetsocket.setTimeout(timeout);
};

function pipe(pair, socket) {
    pair.encrypted.pipe(socket);
    socket.pipe(pair.encrypted);

    pair.fd = socket.fd;
    var cleartext = pair.cleartext;
    cleartext.socket = socket;
    cleartext.encrypted = pair.encrypted;
    cleartext.authorized = false;

    function onerror(e) {
        if (cleartext._controlReleased) {
            cleartext.emit('error', e);
        }
    }

    function onclose() {
        socket.removeListener('error', onerror);
        socket.removeListener('close', onclose);
    }

    socket.on('error', onerror);
    socket.on('close', onclose);

    return cleartext;
}

function createServer(cb) {
    var serv = net.createServer(function (cryptoSocket) {

        var socket = new pluggableStream(cryptoSocket);

        socket.upgrade = function (options, cb) {
            console.log("Upgrading to TLS");

            socket.clean();
            cryptoSocket.removeAllListeners('data');

            // Set SSL_OP_ALL for maximum compatibility with broken clients
            // See http://www.openssl.org/docs/ssl/SSL_CTX_set_options.html
            if (!options) options = {};
            // TODO: bug in Node means we can't do this until it's fixed
            // options.secureOptions = SSL_OP_ALL;

            var sslcontext = crypto.createCredentials(options);

            var pair = tls.createSecurePair(sslcontext, true, true, false);

            var cleartext = pipe(pair, cryptoSocket);

            pair.on('error', function(exception) {
                socket.emit('error', exception);
            });

            pair.on('secure', function() {
                var verifyError = (pair.ssl || pair._ssl).verifyError();

                console.log("TLS secured.");
                if (verifyError) {
                    cleartext.authorized = false;
                    cleartext.authorizationError = verifyError;
                } else {
                    cleartext.authorized = true;
                }
                var cert = pair.cleartext.getPeerCertificate();
                if (pair.cleartext.getCipher) {
                    var cipher = pair.cleartext.getCipher();
                }
                socket.emit('secure');
                if (cb) cb(cleartext.authorized, verifyError, cert, cipher);
            });

            cleartext._controlReleased = true;

            socket.cleartext = cleartext;

            if (socket._timeout) {
                cleartext.setTimeout(socket._timeout);
            }

            cleartext.setKeepAlive(socket._keepalive);

            socket.attach(socket.cleartext);
        };

        cb(socket);
    });

    return serv;
}

if (require('semver').gt(process.version, '0.7.0')) {
    var _net_connect = function (options) {
        return net.connect(options);
    }
}
else {
    var _net_connect = function (options) {
        return net.connect(options.port, options.host);
    }
}

function connect(port, host, cb) {
    var options = {};
    if (typeof port === 'object') {
        options = port;
        cb = host;
    }
    else {
        options.port = port;
        options.host = host;
    }

    var cryptoSocket = _net_connect(options);

    var socket = new pluggableStream(cryptoSocket);

    socket.upgrade = function (options) {
        socket.clean();
        cryptoSocket.removeAllListeners('data');

        // Set SSL_OP_ALL for maximum compatibility with broken servers
        // See http://www.openssl.org/docs/ssl/SSL_CTX_set_options.html
        if (!options) options = {};
        // TODO: bug in Node means we can't do this until it's fixed
        // options.secureOptions = SSL_OP_ALL;

        var sslcontext = crypto.createCredentials(options);

        var pair = tls.createSecurePair(sslcontext, false);

        socket.pair = pair;

        var cleartext = pipe(pair, cryptoSocket);

        pair.on('error', function(exception) {
            socket.emit('error', exception);
        });

        pair.on('secure', function() {
            var verifyError = (pair.ssl || pair._ssl).verifyError();

            console.log("client TLS secured.");
            if (verifyError) {
                cleartext.authorized = false;
                cleartext.authorizationError = verifyError;
            } else {
                cleartext.authorized = true;
            }

            if (cb) cb();

            socket.emit('secure');
        });

        cleartext._controlReleased = true;
        socket.cleartext = cleartext;

        if (socket._timeout) {
            cleartext.setTimeout(socket._timeout);
        }

        cleartext.setKeepAlive(socket._keepalive);

        socket.attach(socket.cleartext);

        console.log("client TLS upgrade in progress, awaiting secured.");
    };

    return (socket);
}

exports.connect = connect;
exports.createConnection = connect;
exports.Server = createServer;
exports.createServer = createServer;
2

There are 2 answers

2
Matt Sergeant On BEST ANSWER

tls.connect() doesn't support the server doing the upgrade unfortunately.

You have to use code similar to how Haraka does it - basically creating your own shim using a SecurePair.

See here for the code we use: https://github.com/baudehlo/Haraka/blob/master/tls_socket.js#L171

0
codermapuche On

STARTTLS Client-Server is trivial to implement in node, but it be a little buggy so, we need to go around.

For Server of STARTTLS we need do it:

// sock come from: net.createServer(function(sock) { ... });            
sock.removeAllListeners('data');
sock.removeAllListeners('error');

sock.write('220 Go ahead' + CRLF); 
sock = new tls.TLSSocket(sock, { secureContext : tls.createSecureContext({ key: cfg.stls.key, cert: cfg.stls.cert }), rejectUnauthorized: false, isServer: true });
sock.setEncoding('utf8');  

// 'secureConnect' event is buggy :/ we need to use 'secure' here.
sock.on('secure', function() {
  // STARTTLS is done here. sock is a secure socket in server side.
  sock.on('error', parseError);
  sock.on('data', parseData); 
}); 

For Client of STARTTLS we need do it:

// sock come from: net.connect(cfg.port, curr.exchange);
// here we already read the '220 Go ahead' from the server.
sock.removeAllListeners('data');
sock.removeAllListeners('error');

sock = tls.connect({ socket: sock, secureContext : tls.createSecureContext({ key: cfg.stls.key, cert: cfg.stls.cert }), rejectUnauthorized: false });

sock.on('secureConnect', function() {
  // STARTTLS is done here. sock is a secure socket in client side.
  sock.on('error', parseError);
  sock.on('data', parseData); 
  // Resend the helo message.
  sock.write(helo); 
});