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;
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