diff --git a/.travis.yml b/.travis.yml index 9a9ec88..0291bf5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ node_js: - "11.15" - "12.12" - "13.6" + - "14.6" sudo: false dist: trusty cache: diff --git a/README.md b/README.md index 1d97e8d..0cf5aad 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ change signature parameters like the algorithm of the signature. A string which will be used as single key if `keys` is not provided. +##### encryptionKeys + +A list of keys used to derive the decryption key for the cookie. The encryption will use a passphrase derived from the first key and a random initialisation vector. + ##### Cookie Options Other options are passed to `cookies.get()` and `cookies.set()` allowing you diff --git a/index.js b/index.js index 9ece996..984cd24 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ var Buffer = require('safe-buffer').Buffer var debug = require('debug')('cookie-session') var Cookies = require('cookies') var onHeaders = require('on-headers') +var crypto = require('crypto') /** * Module exports. @@ -34,6 +35,7 @@ module.exports = cookieSession * @param {boolean} [options.overwrite=true] * @param {string} [options.secret] * @param {boolean} [options.signed=true] + * @param {array} [options.encryptionKeys] The keys used for the crypto * @return {function} middleware * @public */ @@ -125,7 +127,11 @@ function cookieSession (options) { } else if ((!sess.isNew || sess.isPopulated) && sess.isChanged) { // save populated or non-new changed session debug('save %s', name) - cookies.set(name, Session.serialize(sess), req.sessionOptions) + var serializedSession = Session.serialize(sess) + if (opts.encryptionKeys) { + serializedSession = encryptString(serializedSession, opts.encryptionKeys[0]) + } + cookies.set(name, serializedSession, req.sessionOptions) } } catch (e) { debug('error saving session %s', e.message) @@ -278,6 +284,10 @@ function tryGetSession (cookies, name, opts) { return undefined } + if (opts.encryptionKeys) { + str = decryptString(str, opts.encryptionKeys) + } + debug('parse %s', str) try { @@ -286,3 +296,45 @@ function tryGetSession (cookies, name, opts) { return undefined } } + +var inputEncoding = 'base64' +var outputEncoding = 'base64' + +function encryptString (cleartext, keyphrase) { + var key = crypto + .createHash('sha256') + .update(keyphrase) + .digest() + + var iv = crypto.randomBytes(16) + + var cipher = crypto.createCipheriv('aes256', key, iv) + var cipherText = cipher.update(cleartext, inputEncoding, outputEncoding) + cipherText += cipher.final(outputEncoding) + return Buffer.from(iv, 'binary').toString(outputEncoding) + '@' + cipherText.toString() +} + +function decryptString (cipherText, keyphrases) { + var encodedVi = cipherText.split('@')[0] + var encryptedText = cipherText.split('@')[1] + var iv = Buffer.from(encodedVi, outputEncoding) + var lastError = null + for (var i = 0; i < keyphrases.length; i++) { + var keyphrase = keyphrases[i] + try { + var key = crypto + .createHash('sha256') + .update(keyphrase) + .digest() + + var decipher = crypto.createDecipheriv('aes256', key, iv) + var clearText = decipher.update(encryptedText, outputEncoding, inputEncoding) + clearText += decipher.final(inputEncoding) + return clearText.toString() + } catch (e) { + lastError = e + // ignore and just use the next key + } + } + throw new Error('Could not decrypt the cookie with any key. Caused by \n' + lastError.stack) +} diff --git a/test/test.js b/test/test.js index 2d4fa81..09c8dc1 100644 --- a/test/test.js +++ b/test/test.js @@ -1,10 +1,10 @@ - process.env.NODE_ENV = 'test' var assert = require('assert') var connect = require('connect') var request = require('supertest') var session = require('..') +var Buffer = require('safe-buffer').Buffer describe('Cookie Session', function () { describe('"httpOnly" option', function () { @@ -195,6 +195,93 @@ describe('Cookie Session', function () { }) }) + describe('when the session is encrypted', function () { + it('should still be able to decrypt the cookie', function (done) { + var app = App({ encryptionKeys: ['anyString'] }) + const mySession = { + someKey: 'someValue' + } + + app.use(function (req, res, next) { + if (req.method === 'POST') { + req.session = mySession + res.statusCode = 200 + res.end() + } else { + res.end(JSON.stringify(req.session)) + } + }) + + request(app) + .post('/') + .expect(shouldNotHaveCookieWithValue('session', Buffer.from(JSON.stringify(mySession)).toString('base64'))) + .expect(200, function (err, res) { + if (err) return done(err) + request(app) + .get('/') + .set('Cookie', cookieHeader(cookies(res))) + .expect(JSON.stringify(mySession), done) + }) + }) + + it('should be able to use a rotated key to decrypt the cookie', function (done) { + var app = App({ encryptionKeys: ['anyString'] }) + var newApp = App({ encryptionKeys: ['newPrimaryKey', 'anyString'] }) + const mySession = { + someKey: 'someValue' + } + + app.use(function (req, res, next) { + req.session = mySession + res.statusCode = 200 + res.end() + }) + + newApp.use(function (req, res, next) { + res.end(JSON.stringify(req.session)) + }) + + request(app) + .post('/') + .expect(shouldHaveCookie('session')) + .expect(200, function (err, res) { + if (err) return done(err) + request(newApp) + .get('/') + .set('Cookie', cookieHeader(cookies(res))) + .expect(JSON.stringify(mySession), done) + }) + }) + it('should not be able to to decrypt the cookie without a propper key', function (done) { + var app = App({ encryptionKeys: ['anyString'] }) + var newApp = App({ encryptionKeys: ['newPrimaryKeyWithoutOldKey'] }) + const mySession = { + someKey: 'someValue' + } + + app.use(function (req, res, next) { + req.session = mySession + res.statusCode = 200 + res.end() + }) + + newApp.use(function (req, res, next) { + res.end(JSON.stringify(req.session)) + }) + + request(app) + .post('/') + .expect(shouldHaveCookie('session')) + .expect(200, function (err, res) { + if (err) return done(err) + request(newApp) + .get('/') + .set('Cookie', cookieHeader(cookies(res))) + .expect(500, done) + }) + }) + }) + describe('when the session is invalid', function () { it('should create new session', function (done) { var app = App({ name: 'my.session', signed: false }) @@ -267,7 +354,7 @@ describe('Cookie Session', function () { request(app) .get('/') - .expect(shouldHaveCookie('session')) + .expect(shouldHaveCookieWithValue('session', Buffer.from(JSON.stringify({ message: 'hello' })).toString('base64'))) .expect(200, function (err, res) { if (err) return done(err) cookie = cookieHeader(cookies(res)) @@ -557,6 +644,13 @@ function shouldHaveCookieWithValue (name, value) { } } +function shouldNotHaveCookieWithValue (name, value) { + return function (res) { + assert.ok((name in cookies(res)), 'should have cookie "' + name + '"') + assert.notStrictEqual(cookies(res)[name].value, value) + } +} + function shouldNotSetCookies () { return function (res) { assert.strictEqual(res.headers['set-cookie'], undefined, 'should not set cookies')