From 8e0c830cd0038577e36456d3e027a4150d68c933 Mon Sep 17 00:00:00 2001 From: Fedor Indutny Date: Sat, 7 Jul 2012 16:20:23 -0400 Subject: [PATCH] tls: async session storage --- doc/api/tls.markdown | 19 ++ lib/tls.js | 42 ++++- src/node_crypto.cc | 250 +++++++++++++++++++++++++- src/node_crypto.h | 71 +++++++- test/simple/test-tls-session-cache.js | 22 ++- 5 files changed, 392 insertions(+), 12 deletions(-) diff --git a/doc/api/tls.markdown b/doc/api/tls.markdown index 8ac3950934..44a70c0c96 100644 --- a/doc/api/tls.markdown +++ b/doc/api/tls.markdown @@ -373,6 +373,25 @@ When a client connection emits an 'error' event before secure connection is established - it will be forwarded here. +### Event: 'newSession' + +`function (sessionId, sessionData) { }` + +Emitted on creation of TLS session. May be used to store sessions in external +storage. + + +### 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. + + ### server.listen(port, [host], [callback]) Begin accepting connections on the specified `port` and `host`. If the diff --git a/lib/tls.js b/lib/tls.js index 8c50599887..43411c0fc3 100644 --- a/lib/tls.js +++ b/lib/tls.js @@ -725,6 +725,37 @@ function onhandshakedone() { debug('onhandshakedone'); } +function onclienthello(hello) { + var self = this, + once = false; + + this.encrypted.pause(); + this.cleartext.pause(); + function callback(err, session) { + if (once) return; + once = true; + + if (err) return self.socket.destroy(err); + + self.ssl.loadSession(session); + + self.encrypted.resume(); + self.cleartext.resume(); + } + + if (hello.sessionId.length <= 0 || + !this.server || + !this.server.emit('resumeSession', hello.sessionId, callback)) { + callback(null, null); + } +} + + +function onnewsession(key, session) { + if (!this.server) return; + this.server.emit('newSession', key, session); +} + /** * Provides a pair of streams to do encrypted communication. @@ -746,6 +777,7 @@ function SecurePair(credentials, isServer, requestCert, rejectUnauthorized, events.EventEmitter.call(this); + this.server = options.server; this._secureEstablished = false; this._isServer = isServer ? true : false; this._encWriteState = true; @@ -768,13 +800,16 @@ function SecurePair(credentials, isServer, requestCert, rejectUnauthorized, this._requestCert = requestCert ? true : false; this.ssl = new Connection(this.credentials.context, - this._isServer ? true : false, - this._isServer ? this._requestCert : options.servername, - this._rejectUnauthorized); + this._isServer ? true : false, + this._isServer ? this._requestCert : + options.servername, + this._rejectUnauthorized); if (this._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.handshakes = 0; this.ssl.timer = null; } @@ -1084,6 +1119,7 @@ function Server(/* [options], listener */) { self.requestCert, self.rejectUnauthorized, { + server: self, NPNProtocols: self.NPNProtocols, SNICallback: self.SNICallback }); diff --git a/src/node_crypto.cc b/src/node_crypto.cc index b3e34b3656..23ef70b3d1 100644 --- a/src/node_crypto.cc +++ b/src/node_crypto.cc @@ -84,6 +84,9 @@ static Persistent version_symbol; static Persistent ext_key_usage_symbol; static Persistent onhandshakestart_sym; static Persistent onhandshakedone_sym; +static Persistent onclienthello_sym; +static Persistent onnewsession_sym; +static Persistent sessionid_sym; static Persistent secure_context_constructor; @@ -221,15 +224,71 @@ Handle SecureContext::Init(const Arguments& args) { } sc->ctx_ = SSL_CTX_new(method); - // Enable session caching? - SSL_CTX_set_session_cache_mode(sc->ctx_, SSL_SESS_CACHE_SERVER); - // SSL_CTX_set_session_cache_mode(sc->ctx_,SSL_SESS_CACHE_OFF); + + // SSL session cache configuration + SSL_CTX_set_session_cache_mode(sc->ctx_, + SSL_SESS_CACHE_SERVER | + SSL_SESS_CACHE_NO_INTERNAL | + SSL_SESS_CACHE_NO_AUTO_CLEAR); + SSL_CTX_sess_set_get_cb(sc->ctx_, GetSessionCallback); + SSL_CTX_sess_set_new_cb(sc->ctx_, NewSessionCallback); sc->ca_store_ = NULL; return True(); } +SSL_SESSION* SecureContext::GetSessionCallback(SSL* s, + unsigned char* key, + int len, + int* copy) { + HandleScope scope; + + Connection* p = static_cast(SSL_get_app_data(s)); + + *copy = 0; + SSL_SESSION* sess = p->next_sess_; + p->next_sess_ = NULL; + + return sess; +} + + +void SessionDataFree(char* data, void* hint) { + delete[] data; +} + + +int SecureContext::NewSessionCallback(SSL* s, SSL_SESSION* sess) { + HandleScope scope; + + Connection* p = static_cast(SSL_get_app_data(s)); + + // Check if session is small enough to be stored + int size = i2d_SSL_SESSION(sess, NULL); + if (size > kMaxSessionSize) return 0; + + // Serialize session + char* serialized = new char[size]; + unsigned char* pserialized = reinterpret_cast(serialized); + memset(serialized, 0, size); + i2d_SSL_SESSION(sess, &pserialized); + + Handle argv[2] = { + Buffer::New(reinterpret_cast(sess->session_id), + sess->session_id_length)->handle_, + Buffer::New(serialized, size, SessionDataFree, NULL)->handle_ + }; + + if (onnewsession_sym.IsEmpty()) { + onnewsession_sym = NODE_PSYMBOL("onnewsession"); + } + MakeCallback(p->handle_, onnewsession_sym, ARRAY_SIZE(argv), argv); + + return 0; +} + + // Takes a string or buffer and loads it into a BIO. // Caller responsible for BIO_free-ing the returned object. static BIO* LoadBIO (Handle v) { @@ -667,6 +726,150 @@ Handle SecureContext::LoadPKCS12(const Arguments& args) { } +size_t ClientHelloParser::Write(const uint8_t* data, size_t len) { + HandleScope scope; + + // Just accumulate data, everything will be pushed to BIO later + if (state_ == kPaused) return 0; + + // Copy incoming data to the internal buffer + // (which has a size of the biggest possible TLS frame) + size_t available = sizeof(data_) - offset_; + size_t copied = len < available ? len : available; + memcpy(data_ + offset_, data, copied); + offset_ += copied; + + // Vars for parsing hello + bool is_clienthello = false; + uint8_t session_size = -1; + uint8_t* session_id = NULL; + Local hello; + Handle argv[1]; + + switch (state_) { + case kWaiting: + // >= 5 bytes for header parsing + if (offset_ < 5) break; + + if (data_[0] == kChangeCipherSpec || data_[0] == kAlert || + data_[0] == kHandshake || data_[0] == kApplicationData) { + frame_len_ = (data_[3] << 8) + data_[4]; + state_ = kTLSHeader; + body_offset_ = 5; + } else { + frame_len_ = (data_[0] << 8) + data_[1]; + state_ = kSSLHeader; + if (*data_ & 0x40) { + // header with padding + body_offset_ = 3; + } else { + // without padding + body_offset_ = 2; + } + } + + // Sanity check (too big frame, or too small) + if (frame_len_ >= sizeof(data_)) { + // Let OpenSSL handle it + Finish(); + return copied; + } + case kTLSHeader: + case kSSLHeader: + // >= 5 + frame size bytes for frame parsing + if (offset_ < body_offset_ + frame_len_) break; + + // Skip unsupported frames and gather some data from frame + + // TODO: Check protocol version + if (data_[body_offset_] == kClientHello) { + is_clienthello = true; + uint8_t* body; + size_t session_offset; + + if (state_ == kTLSHeader) { + // Skip frame header, hello header, protocol version and random data + session_offset = body_offset_ + 4 + 2 + 32; + + if (session_offset + 1 < offset_) { + body = data_ + session_offset; + session_size = *body; + session_id = body + 1; + } + } else if (state_ == kSSLHeader) { + // Skip header, version + session_offset = body_offset_ + 3; + + if (session_offset + 4 < offset_) { + body = data_ + session_offset; + + int ciphers_size = (body[0] << 8) + body[1]; + + if (body + 4 + ciphers_size < data_ + offset_) { + 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_ + offset_) { + Finish(); + return copied; + } + + // TODO: Parse other things? + } + + // Not client hello - let OpenSSL handle it + if (!is_clienthello) { + Finish(); + return copied; + } + + // Parse frame, call javascript handler and + // move parser into the paused state + if (onclienthello_sym.IsEmpty()) { + onclienthello_sym = NODE_PSYMBOL("onclienthello"); + } + if (sessionid_sym.IsEmpty()) { + sessionid_sym = NODE_PSYMBOL("sessionId"); + } + + state_ = kPaused; + hello = Object::New(); + hello->Set(sessionid_sym, + Buffer::New(reinterpret_cast(session_id), + session_size)->handle_); + + argv[0] = hello; + MakeCallback(conn_->handle_, onclienthello_sym, 1, argv); + break; + case kEnded: + default: + break; + } + + return copied; +} + + +void ClientHelloParser::Finish() { + assert(state_ != kEnded); + state_ = kEnded; + + // Write all accumulated data + int r = BIO_write(conn_->bio_read_, reinterpret_cast(data_), offset_); + conn_->HandleBIOError(conn_->bio_read_, "BIO_write", r); + conn_->SetShutdownFlags(); +} + + #ifdef SSL_PRINT_DEBUG # define DEBUG_PRINT(...) fprintf (stderr, __VA_ARGS__) #else @@ -789,6 +992,7 @@ void Connection::Initialize(Handle target) { NODE_SET_PROTOTYPE_METHOD(t, "getPeerCertificate", Connection::GetPeerCertificate); NODE_SET_PROTOTYPE_METHOD(t, "getSession", Connection::GetSession); NODE_SET_PROTOTYPE_METHOD(t, "setSession", Connection::SetSession); + NODE_SET_PROTOTYPE_METHOD(t, "loadSession", Connection::LoadSession); NODE_SET_PROTOTYPE_METHOD(t, "isSessionReused", Connection::IsSessionReused); NODE_SET_PROTOTYPE_METHOD(t, "isInitFinished", Connection::IsInitFinished); NODE_SET_PROTOTYPE_METHOD(t, "verifyError", Connection::VerifyError); @@ -1112,9 +1316,17 @@ Handle Connection::EncIn(const Arguments& args) { String::New("off + len > buffer.length"))); } - int bytes_written = BIO_write(ss->bio_read_, buffer_data + off, len); - ss->HandleBIOError(ss->bio_read_, "BIO_write", bytes_written); - ss->SetShutdownFlags(); + int bytes_written; + char* data = buffer_data + off; + + if (ss->is_server_ && !ss->hello_parser_.ended()) { + bytes_written = ss->hello_parser_.Write(reinterpret_cast(data), + len); + } else { + bytes_written = BIO_write(ss->bio_read_, data, len); + ss->HandleBIOError(ss->bio_read_, "BIO_write", bytes_written); + ss->SetShutdownFlags(); + } return scope.Close(Integer::New(bytes_written)); } @@ -1444,7 +1656,7 @@ Handle Connection::SetSession(const Arguments& args) { ssize_t wlen = DecodeWrite(sbuf, slen, args[0], BINARY); assert(wlen == slen); - const unsigned char* p = (unsigned char*) sbuf; + const unsigned char* p = reinterpret_cast(sbuf); SSL_SESSION* sess = d2i_SSL_SESSION(NULL, &p, wlen); delete [] sbuf; @@ -1463,6 +1675,30 @@ Handle Connection::SetSession(const Arguments& args) { return True(); } +Handle Connection::LoadSession(const Arguments& args) { + HandleScope scope; + + Connection *ss = Connection::Unwrap(args); + + if (args.Length() >= 1 && Buffer::HasInstance(args[0])) { + ssize_t slen = Buffer::Length(args[0].As()); + char* sbuf = Buffer::Data(args[0].As()); + + 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 (ss->next_sess_ != NULL) { + SSL_SESSION_free(ss->next_sess_); + } + ss->next_sess_ = sess; + } + + ss->hello_parser_.Finish(); + + return True(); +} + Handle Connection::IsSessionReused(const Arguments& args) { HandleScope scope; diff --git a/src/node_crypto.h b/src/node_crypto.h index fe433596ac..91fbb2249b 100644 --- a/src/node_crypto.h +++ b/src/node_crypto.h @@ -49,6 +49,9 @@ namespace crypto { static X509_STORE* root_cert_store; +// Forward declaration +class Connection; + class SecureContext : ObjectWrap { public: static void Initialize(v8::Handle target); @@ -58,6 +61,8 @@ class SecureContext : ObjectWrap { X509_STORE *ca_store_; protected: + static const int kMaxSessionSize = 10 * 1024; + static v8::Handle New(const v8::Arguments& args); static v8::Handle Init(const v8::Arguments& args); static v8::Handle SetKey(const v8::Arguments& args); @@ -71,6 +76,12 @@ class SecureContext : ObjectWrap { static v8::Handle Close(const v8::Arguments& args); static v8::Handle LoadPKCS12(const v8::Arguments& args); + static SSL_SESSION* GetSessionCallback(SSL* s, + unsigned char* key, + int len, + int* copy); + static int NewSessionCallback(SSL* s, SSL_SESSION* sess); + SecureContext() : ObjectWrap() { ctx_ = NULL; ca_store_ = NULL; @@ -100,6 +111,51 @@ class SecureContext : ObjectWrap { private: }; +class ClientHelloParser { + public: + enum FrameType { + kChangeCipherSpec = 20, + kAlert = 21, + kHandshake = 22, + kApplicationData = 23, + kOther = 255 + }; + + enum HandshakeType { + kClientHello = 1 + }; + + enum ParseState { + kWaiting, + kTLSHeader, + kSSLHeader, + kPaused, + kEnded + }; + + ClientHelloParser(Connection* c) : conn_(c), + state_(kWaiting), + offset_(0), + body_offset_(0), + written_(0) { + } + + size_t Write(const uint8_t* data, size_t len); + void Finish(); + + inline bool ended() { return state_ == kEnded; } + + private: + Connection* conn_; + ParseState state_; + size_t frame_len_; + + uint8_t data_[18432]; + size_t offset_; + size_t body_offset_; + size_t written_; +}; + class Connection : ObjectWrap { public: static void Initialize(v8::Handle target); @@ -126,6 +182,7 @@ class Connection : ObjectWrap { static v8::Handle GetPeerCertificate(const v8::Arguments& args); static v8::Handle GetSession(const v8::Arguments& args); static v8::Handle SetSession(const v8::Arguments& args); + static v8::Handle LoadSession(const v8::Arguments& args); static v8::Handle IsSessionReused(const v8::Arguments& args); static v8::Handle IsInitFinished(const v8::Arguments& args); static v8::Handle VerifyError(const v8::Arguments& args); @@ -168,9 +225,10 @@ class Connection : ObjectWrap { return ss; } - Connection() : ObjectWrap() { + Connection() : ObjectWrap(), hello_parser_(this) { bio_read_ = bio_write_ = NULL; ssl_ = NULL; + next_sess_ = NULL; } ~Connection() { @@ -179,6 +237,11 @@ class Connection : ObjectWrap { ssl_ = NULL; } + if (next_sess_ != NULL) { + SSL_SESSION_free(next_sess_); + next_sess_ = NULL; + } + #ifdef OPENSSL_NPN_NEGOTIATED if (!npnProtos_.IsEmpty()) npnProtos_.Dispose(); if (!selectedNPNProto_.IsEmpty()) selectedNPNProto_.Dispose(); @@ -198,7 +261,13 @@ class Connection : ObjectWrap { BIO *bio_write_; SSL *ssl_; + ClientHelloParser hello_parser_; + bool is_server_; /* coverity[member_decl] */ + SSL_SESSION* next_sess_; + + friend class ClientHelloParser; + friend class SecureContext; }; void InitCrypto(v8::Handle target); diff --git a/test/simple/test-tls-session-cache.js b/test/simple/test-tls-session-cache.js index 64e4199358..325b2ec7fe 100644 --- a/test/simple/test-tls-session-cache.js +++ b/test/simple/test-tls-session-cache.js @@ -50,18 +50,36 @@ function doTest() { requestCert: true }; var requestCount = 0; + var session; var server = tls.createServer(options, function(cleartext) { ++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' + '-reconnect', + '-no_ticket' ], { customFds: [0, 1, 2] }); @@ -72,6 +90,8 @@ function doTest() { }); process.on('exit', function() { + assert.ok(session); + // initial request + reconnect requests (5 times) assert.equal(requestCount, 6); });