From 212e9cd8c9b10da3c471638ca9b0f8ebef2a67bb Mon Sep 17 00:00:00 2001 From: Fedor Indutny Date: Mon, 17 Jun 2013 12:11:13 +0200 Subject: [PATCH] tls: session API returns --- doc/api/tls.markdown | 25 ++++ lib/_tls_wrap.js | 35 +++++ src/node_crypto.h | 3 +- src/node_crypto_bio.h | 4 +- src/tls_wrap.cc | 265 ++++++++++++++++++++++++++++++++-- src/tls_wrap.h | 41 ++++++ test/simple/test-tls-session-cache.js | 118 +++++++++++++++ 7 files changed, 480 insertions(+), 11 deletions(-) create mode 100644 test/simple/test-tls-session-cache.js diff --git a/doc/api/tls.markdown b/doc/api/tls.markdown index 7c37a6f..fc0d8af 100644 --- a/doc/api/tls.markdown +++ b/doc/api/tls.markdown @@ -403,6 +403,31 @@ established - it will be forwarded here. `tlsSocket` is the [tls.TLSSocket][] that the error originated from. +### Event: 'newSession' + +`function (sessionId, sessionData) { }` + +Emitted on creation of TLS session. May be used to store sessions in external +storage. + +NOTE: adding this event listener will have an effect only on connections +established after addition of event listener. + + +### Event: 'resumeSession' + +`function (sessionId, callback) { }` + +Emitted when client wants to resume previous TLS session. Event listener may +perform lookup in external storage using given `sessionId`, and invoke +`callback(null, sessionData)` once finished. If session can't be resumed +(i.e. doesn't exist in storage) one may call `callback(null, null)`. Calling +`callback(err)` will terminate incoming connection and destroy socket. + +NOTE: adding this event listener will have an effect only on connections +established after addition of event listener. + + ### server.listen(port, [host], [callback]) Begin accepting connections on the specified `port` and `host`. If the diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js index 9294634..849c2c4 100644 --- a/lib/_tls_wrap.js +++ b/lib/_tls_wrap.js @@ -47,6 +47,33 @@ function onhandshakedone() { } +function onclienthello(hello) { + var self = this, + once = false; + + function callback(err, session) { + if (once) + return self.destroy(new Error('TLS session callback was called twice')); + once = true; + + if (err) + return self.destroy(err); + + self.ssl.loadSession(session); + } + + if (hello.sessionId.length <= 0 || + !this.server.emit('resumeSession', hello.sessionId, callback)) { + callback(null, null); + } +} + + +function onnewsession(key, session) { + this.server.emit('newSession', key, session); +} + + /** * Provides a wrap of socket stream to do encrypted communication. */ @@ -92,6 +119,7 @@ TLSSocket.prototype._init = function() { // Wrap socket's handle var credentials = options.credentials || crypto.createCredentials(); this.ssl = tls_wrap.wrap(this._handle, credentials.context, options.isServer); + this.server = options.server || null; // For clients, we will always have either a given ca list or be using // default one @@ -104,8 +132,15 @@ TLSSocket.prototype._init = function() { if (options.isServer) { this.ssl.onhandshakestart = onhandshakestart.bind(this); this.ssl.onhandshakedone = onhandshakedone.bind(this); + this.ssl.onclienthello = onclienthello.bind(this); + this.ssl.onnewsession = onnewsession.bind(this); this.ssl.lastHandshakeTime = 0; this.ssl.handshakes = 0; + + if (this.server.listeners('resumeSession').length > 0 || + this.server.listeners('newSession').length > 0) { + this.ssl.enableSessionCallbacks(); + } } else { this.ssl.onhandshakestart = function() {}; this.ssl.onhandshakedone = this._finishInit.bind(this); diff --git a/src/node_crypto.h b/src/node_crypto.h index 2d5500a..c3c4a89 100644 --- a/src/node_crypto.h +++ b/src/node_crypto.h @@ -60,9 +60,10 @@ class SecureContext : ObjectWrap { // TODO: ca_store_ should probably be removed, it's not used anywhere. X509_STORE *ca_store_; - protected: static const int kMaxSessionSize = 10 * 1024; + protected: + static v8::Handle New(const v8::Arguments& args); static v8::Handle Init(const v8::Arguments& args); static v8::Handle SetKey(const v8::Arguments& args); diff --git a/src/node_crypto_bio.h b/src/node_crypto_bio.h index 4794453..dc45fae 100644 --- a/src/node_crypto_bio.h +++ b/src/node_crypto_bio.h @@ -87,7 +87,9 @@ class NodeBIO { } protected: - static const size_t kBufferLength = 16 * 1024; + // NOTE: Size is maximum TLS frame length, this is required if we want + // to fit whole ClientHello into one Buffer of NodeBIO. + static const size_t kBufferLength = 16 * 1024 + 5; class Buffer { public: diff --git a/src/tls_wrap.cc b/src/tls_wrap.cc index 89d2790..c42898d 100644 --- a/src/tls_wrap.cc +++ b/src/tls_wrap.cc @@ -36,6 +36,8 @@ static Persistent onerror_sym; static Persistent onsniselect_sym; static Persistent onhandshakestart_sym; static Persistent onhandshakedone_sym; +static Persistent onclienthello_sym; +static Persistent onnewsession_sym; static Persistent subject_sym; static Persistent subjectaltname_sym; static Persistent modulus_sym; @@ -47,6 +49,7 @@ static Persistent fingerprint_sym; static Persistent name_sym; static Persistent version_sym; static Persistent ext_key_usage_sym; +static Persistent sessionid_sym; static Persistent tlsWrap; @@ -69,7 +72,9 @@ TLSCallbacks::TLSCallbacks(Kind kind, pending_write_item_(NULL), started_(false), established_(false), - shutdown_(false) { + shutdown_(false), + session_callbacks_(false), + next_sess_(NULL) { // Persist SecureContext sc_ = ObjectWrap::Unwrap(sc); @@ -78,17 +83,68 @@ TLSCallbacks::TLSCallbacks(Kind kind, handle_ = Persistent::New(node_isolate, tlsWrap->NewInstance()); handle_->SetAlignedPointerInInternalField(0, this); - // No session cache support - SSL_CTX_sess_set_get_cb(sc_->ctx_, NULL); - SSL_CTX_sess_set_new_cb(sc_->ctx_, NULL); - // Initialize queue for clearIn writes QUEUE_INIT(&write_item_queue_); + // Initialize hello parser + hello_.state = kParseEnded; + hello_.frame_len = 0; + hello_.body_offset = 0; + + // We've our own session callbacks + SSL_CTX_sess_set_get_cb(sc_->ctx_, GetSessionCallback); + SSL_CTX_sess_set_new_cb(sc_->ctx_, NewSessionCallback); + InitSSL(); } +SSL_SESSION* TLSCallbacks::GetSessionCallback(SSL* s, + unsigned char* key, + int len, + int* copy) { + HandleScope scope(node_isolate); + + TLSCallbacks* c = static_cast(SSL_get_app_data(s)); + + *copy = 0; + SSL_SESSION* sess = c->next_sess_; + c->next_sess_ = NULL; + + return sess; +} + + +int TLSCallbacks::NewSessionCallback(SSL* s, SSL_SESSION* sess) { + HandleScope scope(node_isolate); + + TLSCallbacks* c = static_cast(SSL_get_app_data(s)); + if (!c->session_callbacks_) + return 0; + + // Check if session is small enough to be stored + int size = i2d_SSL_SESSION(sess, NULL); + if (size > SecureContext::kMaxSessionSize) + return 0; + + // Serialize session + Local buff = Local::New(Buffer::New(size)->handle_); + unsigned char* serialized = reinterpret_cast( + Buffer::Data(buff)); + memset(serialized, 0, size); + i2d_SSL_SESSION(sess, &serialized); + + Local session = Local::New( + Buffer::New(reinterpret_cast(sess->session_id), + sess->session_id_length)->handle_); + Handle argv[2] = { session, buff }; + + MakeCallback(c->handle_, onnewsession_sym, ARRAY_SIZE(argv), argv); + + return 0; +} + + TLSCallbacks::~TLSCallbacks() { SSL_free(ssl_); ssl_ = NULL; @@ -306,6 +362,10 @@ void TLSCallbacks::SSLInfoCallback(const SSL* ssl_, int where, int ret) { void TLSCallbacks::EncOut() { + // Ignore cycling data if ClientHello wasn't yet parsed + if (hello_.state != kParseEnded) + return; + // Write in progress if (write_size_ != 0) return; @@ -406,6 +466,10 @@ Handle TLSCallbacks::GetSSLError(int status, int* err) { void TLSCallbacks::ClearOut() { + // Ignore cycling data if ClientHello wasn't yet parsed + if (hello_.state != kParseEnded) + return; + HandleScope scope(node_isolate); assert(ssl_ != NULL); @@ -436,6 +500,10 @@ void TLSCallbacks::ClearOut() { bool TLSCallbacks::ClearIn() { + // Ignore cycling data if ClientHello wasn't yet parsed + if (hello_.state != kParseEnded) + return false; + HandleScope scope(node_isolate); int written = 0; @@ -569,10 +637,12 @@ void TLSCallbacks::DoRead(uv_stream_t* handle, // Commit read data NodeBIO::FromBIO(enc_in_)->Commit(nread); - // Cycle OpenSSL state - ClearIn(); - ClearOut(); - EncOut(); + // Parse ClientHello first + if (hello_.state != kParseEnded) + return ParseClientHello(); + + // Cycle OpenSSL's state + Cycle(); } @@ -585,6 +655,138 @@ int TLSCallbacks::DoShutdown(ShutdownWrap* req_wrap, uv_shutdown_cb cb) { } +void TLSCallbacks::ParseClientHello() { + enum FrameType { + kChangeCipherSpec = 20, + kAlert = 21, + kHandshake = 22, + kApplicationData = 23, + kOther = 255 + }; + + enum HandshakeType { + kClientHello = 1 + }; + + assert(session_callbacks_); + HandleScope scope(node_isolate); + + NodeBIO* enc_in = NodeBIO::FromBIO(enc_in_); + + size_t avail = 0; + uint8_t* data = reinterpret_cast(enc_in->Peek(&avail)); + assert(avail == 0 || data != NULL); + + // Vars for parsing hello + bool is_clienthello = false; + uint8_t session_size = -1; + uint8_t* session_id = NULL; + Local hello_obj; + Handle argv[1]; + + switch (hello_.state) { + case kParseWaiting: + // >= 5 bytes for header parsing + if (avail < 5) + break; + + if (data[0] == kChangeCipherSpec || + data[0] == kAlert || + data[0] == kHandshake || + data[0] == kApplicationData) { + hello_.frame_len = (data[3] << 8) + data[4]; + hello_.state = kParseTLSHeader; + hello_.body_offset = 5; + } else { + hello_.frame_len = (data[0] << 8) + data[1]; + hello_.state = kParseSSLHeader; + if (*data & 0x40) { + // header with padding + hello_.body_offset = 3; + } else { + // without padding + hello_.body_offset = 2; + } + } + + // Sanity check (too big frame, or too small) + // Let OpenSSL handle it + if (hello_.frame_len >= kMaxTLSFrameLen) + return ParseFinish(); + + // Fall through + case kParseTLSHeader: + case kParseSSLHeader: + // >= 5 + frame size bytes for frame parsing + if (avail < hello_.body_offset + hello_.frame_len) + break; + + // Skip unsupported frames and gather some data from frame + + // TODO(indutny): Check protocol version + if (data[hello_.body_offset] == kClientHello) { + is_clienthello = true; + uint8_t* body; + size_t session_offset; + + if (hello_.state == kParseTLSHeader) { + // Skip frame header, hello header, protocol version and random data + session_offset = hello_.body_offset + 4 + 2 + 32; + + if (session_offset + 1 < avail) { + body = data + session_offset; + session_size = *body; + session_id = body + 1; + } + } else if (hello_.state == kParseSSLHeader) { + // Skip header, version + session_offset = hello_.body_offset + 3; + + if (session_offset + 4 < avail) { + body = data + session_offset; + + int ciphers_size = (body[0] << 8) + body[1]; + + if (body + 4 + ciphers_size < data + avail) { + session_size = (body[2] << 8) + body[3]; + session_id = body + 4 + ciphers_size; + } + } + } else { + // Whoa? How did we get here? + abort(); + } + + // Check if we overflowed (do not reply with any private data) + if (session_id == NULL || + session_size > 32 || + session_id + session_size > data + avail) { + return ParseFinish(); + } + + // TODO(indutny): Parse other things? + } + + // Not client hello - let OpenSSL handle it + if (!is_clienthello) + return ParseFinish(); + + hello_.state = kParsePaused; + hello_obj = Object::New(); + hello_obj->Set(sessionid_sym, + Buffer::New(reinterpret_cast(session_id), + session_size)->handle_); + + argv[0] = hello_obj; + MakeCallback(handle_, onclienthello_sym, 1, argv); + break; + case kParseEnded: + default: + break; + } +} + + #define CASE_X509_ERR(CODE) case X509_V_ERR_##CODE: reason = #CODE; break; Handle TLSCallbacks::VerifyError(const Arguments& args) { HandleScope scope(node_isolate); @@ -690,6 +892,20 @@ Handle TLSCallbacks::IsSessionReused(const Arguments& args) { } +Handle TLSCallbacks::EnableSessionCallbacks(const Arguments& args) { + HandleScope scope(node_isolate); + + UNWRAP(TLSCallbacks); + + wrap->session_callbacks_ = true; + wrap->hello_.state = kParseWaiting; + wrap->hello_.frame_len = 0; + wrap->hello_.body_offset = 0; + + return scope.Close(Null(node_isolate)); +} + + Handle TLSCallbacks::GetPeerCertificate(const Arguments& args) { HandleScope scope(node_isolate); @@ -879,6 +1095,30 @@ Handle TLSCallbacks::SetSession(const Arguments& args) { } +Handle TLSCallbacks::LoadSession(const Arguments& args) { + HandleScope scope(node_isolate); + + UNWRAP(TLSCallbacks); + + if (args.Length() >= 1 && Buffer::HasInstance(args[0])) { + ssize_t slen = Buffer::Length(args[0]); + char* sbuf = Buffer::Data(args[0]); + + const unsigned char* p = reinterpret_cast(sbuf); + SSL_SESSION* sess = d2i_SSL_SESSION(NULL, &p, slen); + + // Setup next session and move hello to the BIO buffer + if (wrap->next_sess_ != NULL) + SSL_SESSION_free(wrap->next_sess_); + wrap->next_sess_ = sess; + } + + wrap->ParseFinish(); + + return True(node_isolate); +} + + Handle TLSCallbacks::GetCurrentCipher(const Arguments& args) { HandleScope scope(node_isolate); @@ -1112,10 +1352,14 @@ void TLSCallbacks::Initialize(Handle target) { NODE_SET_PROTOTYPE_METHOD(t, "getPeerCertificate", GetPeerCertificate); NODE_SET_PROTOTYPE_METHOD(t, "getSession", GetSession); NODE_SET_PROTOTYPE_METHOD(t, "setSession", SetSession); + NODE_SET_PROTOTYPE_METHOD(t, "loadSession", LoadSession); NODE_SET_PROTOTYPE_METHOD(t, "getCurrentCipher", GetCurrentCipher); NODE_SET_PROTOTYPE_METHOD(t, "verifyError", VerifyError); NODE_SET_PROTOTYPE_METHOD(t, "setVerifyMode", SetVerifyMode); NODE_SET_PROTOTYPE_METHOD(t, "isSessionReused", IsSessionReused); + NODE_SET_PROTOTYPE_METHOD(t, + "enableSessionCallbacks", + EnableSessionCallbacks); #ifdef OPENSSL_NPN_NEGOTIATED NODE_SET_PROTOTYPE_METHOD(t, "getNegotiatedProtocol", GetNegotiatedProto); @@ -1134,6 +1378,8 @@ void TLSCallbacks::Initialize(Handle target) { onerror_sym = NODE_PSYMBOL("onerror"); onhandshakestart_sym = NODE_PSYMBOL("onhandshakestart"); onhandshakedone_sym = NODE_PSYMBOL("onhandshakedone"); + onclienthello_sym = NODE_PSYMBOL("onclienthello"); + onnewsession_sym = NODE_PSYMBOL("onnewsession"); subject_sym = NODE_PSYMBOL("subject"); issuer_sym = NODE_PSYMBOL("issuer"); @@ -1146,6 +1392,7 @@ void TLSCallbacks::Initialize(Handle target) { name_sym = NODE_PSYMBOL("name"); version_sym = NODE_PSYMBOL("version"); ext_key_usage_sym = NODE_PSYMBOL("ext_key_usage"); + sessionid_sym = NODE_PSYMBOL("sessionId"); } } // namespace node diff --git a/src/tls_wrap.h b/src/tls_wrap.h index 8650756..b36a5b8 100644 --- a/src/tls_wrap.h +++ b/src/tls_wrap.h @@ -61,7 +61,24 @@ class TLSCallbacks : public StreamWrapCallbacks { protected: static const int kClearOutChunkSize = 1024; + static const size_t kMaxTLSFrameLen = 16 * 1024 + 5; + + // ClientHello parser types + enum ParseState { + kParseWaiting, + kParseTLSHeader, + kParseSSLHeader, + kParsePaused, + kParseEnded + }; + + struct HelloState { + ParseState state; + size_t frame_len; + size_t body_offset; + }; + // Write callback queue's item class WriteItem { public: WriteItem(WriteWrap* w, uv_write_cb cb) : w_(w), cb_(cb) { @@ -86,6 +103,18 @@ class TLSCallbacks : public StreamWrapCallbacks { bool ClearIn(); void ClearOut(); void InvokeQueued(int status); + void ParseClientHello(); + + inline void ParseFinish() { + hello_.state = kParseEnded; + Cycle(); + } + + inline void Cycle() { + ClearIn(); + ClearOut(); + EncOut(); + } v8::Handle GetSSLError(int status, int* err); @@ -99,6 +128,14 @@ class TLSCallbacks : public StreamWrapCallbacks { static v8::Handle VerifyError(const v8::Arguments& args); static v8::Handle SetVerifyMode(const v8::Arguments& args); static v8::Handle IsSessionReused(const v8::Arguments& args); + static v8::Handle EnableSessionCallbacks(const v8::Arguments& args); + + // TLS Session API + static SSL_SESSION* GetSessionCallback(SSL* s, + unsigned char* key, + int len, + int* copy); + static int NewSessionCallback(SSL* s, SSL_SESSION* sess); #ifdef OPENSSL_NPN_NEGOTIATED static v8::Handle GetNegotiatedProto(const v8::Arguments& args); @@ -134,9 +171,13 @@ class TLSCallbacks : public StreamWrapCallbacks { size_t write_queue_size_; QUEUE write_item_queue_; WriteItem* pending_write_item_; + HelloState hello_; + int hello_body_; bool started_; bool established_; bool shutdown_; + bool session_callbacks_; + SSL_SESSION* next_sess_; #ifdef OPENSSL_NPN_NEGOTIATED v8::Persistent npn_protos_; diff --git a/test/simple/test-tls-session-cache.js b/test/simple/test-tls-session-cache.js new file mode 100644 index 0000000..fdc4ae1 --- /dev/null +++ b/test/simple/test-tls-session-cache.js @@ -0,0 +1,118 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +if (!process.versions.openssl) { + console.error('Skipping because node compiled without OpenSSL.'); + process.exit(0); +} +require('child_process').exec('openssl version', function(err) { + if (err !== null) { + console.error('Skipping because openssl command is not available.'); + process.exit(0); + } + doTest(); +}); + +function doTest() { + var common = require('../common'); + var assert = require('assert'); + var tls = require('tls'); + var fs = require('fs'); + var join = require('path').join; + var spawn = require('child_process').spawn; + + var keyFile = join(common.fixturesDir, 'agent.key'); + var certFile = join(common.fixturesDir, 'agent.crt'); + var key = fs.readFileSync(keyFile); + var cert = fs.readFileSync(certFile); + var options = { + key: key, + cert: cert, + ca: [cert], + requestCert: true + }; + var requestCount = 0; + var session; + var badOpenSSL = false; + + var server = tls.createServer(options, function(cleartext) { + cleartext.on('error', function(er) { + // We're ok with getting ECONNRESET in this test, but it's + // timing-dependent, and thus unreliable. Any other errors + // are just failures, though. + if (er.code !== 'ECONNRESET') + throw er; + }); + ++requestCount; + cleartext.end(); + }); + server.on('newSession', function(id, data) { + assert.ok(!session); + session = { + id: id, + data: data + }; + }); + server.on('resumeSession', function(id, callback) { + assert.ok(session); + assert.equal(session.id.toString('hex'), id.toString('hex')); + + // Just to check that async really works there + setTimeout(function() { + callback(null, session.data); + }, 100); + }); + server.listen(common.PORT, function() { + var client = spawn('openssl', [ + 's_client', + '-connect', 'localhost:' + common.PORT, + '-key', join(common.fixturesDir, 'agent.key'), + '-cert', join(common.fixturesDir, 'agent.crt'), + '-reconnect', + '-no_ticket' + ], { + stdio: [ 0, 1, 'pipe' ] + }); + var err = ''; + client.stderr.setEncoding('utf8'); + client.stderr.on('data', function(chunk) { + err += chunk; + }); + client.on('exit', function(code) { + if (/^unknown option/.test(err)) { + // using an incompatible version of openssl + assert(code); + badOpenSSL = true; + } else + assert.equal(code, 0); + server.close(); + }); + }); + + process.on('exit', function() { + if (!badOpenSSL) { + assert.ok(session); + + // initial request + reconnect requests (5 times) + assert.equal(requestCount, 6); + } + }); +} -- 2.7.4