diff --git a/examples/encrypted-cookies/index.js b/examples/encrypted-cookies/index.js new file mode 100644 index 00000000000..c4ec38e5e48 --- /dev/null +++ b/examples/encrypted-cookies/index.js @@ -0,0 +1,72 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var express = require('../../'); +var app = (module.exports = express()); +var logger = require('morgan'); +var cookieParser = require('cookie-parser'); +var crypto = require('node:crypto'); + +// custom log format +if (process.env.NODE_ENV !== 'test') app.use(logger(':method :url')); + +// parses request cookies, populating +// req.cookies and req.signedCookies +// when the secret is passed, used +// for signing the cookies. +app.use(cookieParser('my secret here')); + +// parses x-www-form-urlencoded +app.use(express.urlencoded()); + +app.get('/', function (req, res) { + if (req.signedCookies.encryptedCookie) { + res.send('Remembered and encrypted :). Click to forget! Click here to decrypt.' + + '
' + ); + } else { + res.send( + '

Check to ' + + '.

', + ); + } +}); + +app.get('/forget', function (req, res) { + res.clearCookie('encryptedCookie'); + res.redirect(req.get('Referrer') || '/'); +}); + +const key = crypto.randomBytes(32); + +app.post('/', function (req, res) { + var minute = 60000; + + if (req.body && req.body.encryptedCookie) { + res.cookie( + 'encryptedCookie', + 'I like to hide by cookies under the sofa', + { signed: true, maxAge: minute }, + { key }, + ); + } + res.redirect(req.get('Referrer') || '/'); +}); + +app.post('/decryptCookies', function (req, res) { + const encryptedCookie = req.signedCookies.encryptedCookie; + + const decryptedCookie = res.decryptCookie(encryptedCookie, key); + + res.send(decryptedCookie + '
Go back'); +}); + +/* istanbul ignore next */ +if (!module.parent) { + app.listen(3000); + console.log('Express started on port 3000'); +} diff --git a/lib/response.js b/lib/response.js index f965e539dd2..729e178c514 100644 --- a/lib/response.js +++ b/lib/response.js @@ -33,6 +33,10 @@ var extname = path.extname; var resolve = path.resolve; var vary = require('vary'); const { Buffer } = require('node:buffer'); +var crypto = require('node:crypto') + +const encryptionAlgorithm = "aes-256-gcm"; + /** * Response prototype. @@ -714,7 +718,6 @@ res.clearCookie = function clearCookie(name, options) { return this.cookie(name, '', opts); }; - /** * Set cookie `name` to `value`, with the given `options`. * @@ -732,28 +735,63 @@ res.clearCookie = function clearCookie(name, options) { * // same as above * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) * + * Encrypt: + * - `iv` Initialization Vector used for encryption is recommended you create a entropied random value + * - `key` Key for encrypting and decrypting the encrypted cookie + * + * Examples: + * // Create an encrypted cookie + * res.cookie('encryptedCookie', 'secret thing to be encrypted', {signed: false}, { key: crypto.randomBytes(), iv: crypto.randomBytes(16) }) + * res.cookie('encryptedSignedCookie', 'secret thing to be encrypted', {signed: true}, { key: crypto.randomBytes(), iv: crypto.randomBytes(16) }) + * * @param {String} name * @param {String|Object} value * @param {Object} [options] + * @param {key: String, iv: Buffer,} encrypt * @return {ServerResponse} for chaining * @public */ -res.cookie = function (name, value, options) { - var opts = { ...options }; - var secret = this.req.secret; - var signed = opts.signed; +res.cookie = function (name, value, options, encrypt) { + var opts = { ...options } + var secret = this.req.secret + var signed = opts.signed if (signed && !secret) { - throw new Error('cookieParser("secret") required for signed cookies'); + throw new Error('cookieParser("secret") required for signed cookies') + } + + var val = + typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value) + + if (signed && !encrypt) { + val = 's:' + sign(val, secret) } - var val = typeof value === 'object' - ? 'j:' + JSON.stringify(value) - : String(value); + if (encrypt) { + let { key, iv } = encrypt + + if (!iv) { + iv = crypto.randomBytes(16); + } + + let cipher = crypto.createCipheriv(encryptionAlgorithm, key, iv) + + const encryptedText = Buffer.concat([cipher.update(val), cipher.final()]) + + const encryptedTextObject = { + encryptedText: encryptedText.toString('base64'), + iv: iv.toString('base64'), + } + + // If you will use a encryption algorithm that don't support auth tags please remove this part of the code + encryptedTextObject['authTag'] = cipher.getAuthTag().toString('base64') - if (signed) { - val = 's:' + sign(val, secret); + if (signed) { + val = 's:' + sign(JSON.stringify(encryptedTextObject), secret) + } else { + val = JSON.stringify(encryptedTextObject) + } } if (opts.maxAge != null) { @@ -766,12 +804,39 @@ res.cookie = function (name, value, options) { } if (opts.path == null) { - opts.path = '/'; + opts.path = '/' } - this.append('Set-Cookie', cookie.serialize(name, String(val), opts)); + this.append('Set-Cookie', cookie.serialize(name, String(val), opts)) - return this; + return this +}; + +/** + * @param {String} encryptedCookie + * @return {String} + * @public + **/ + +res.decryptCookie = function decryptCookie(encryptedCookie, key) { + let { encryptedText, iv, authTag } = JSON.parse(encryptedCookie) + + iv = Buffer.from(iv, 'base64') + + encryptedText = Buffer.from(encryptedText, 'base64') + + const decipher = crypto.createDecipheriv(encryptionAlgorithm, key, iv) + + if (authTag) { + decipher.setAuthTag(Buffer.from(authTag, 'base64')) + } + + const plainText = Buffer.concat([ + decipher.update(encryptedText), + decipher.final(), + ]) + + return plainText.toString('utf8') }; /** diff --git a/test/res.cookie.js b/test/res.cookie.js index 180d1be3452..0ccd4aac826 100644 --- a/test/res.cookie.js +++ b/test/res.cookie.js @@ -51,6 +51,72 @@ describe('res', function(){ }) }) + describe('.cookie(name, string, options, encrypt)', function () { + it('should return a stringified json with the encrypted cookie', function (done) { + var app = express() + var { Buffer } = require('node:buffer') + + app.use(cookieParser('my-secret')) + + app.use(function (req, res) { + res.cookie('name', 'tobi', undefined, { + key: Buffer.from([ + 0x66, 0xcc, 0xc0, 0xa1, 0x9f, 0x64, 0x26, 0x70, 0x84, 0xfe, 0xc7, + 0x0b, 0x2a, 0xf5, 0xf9, 0x45, 0x8e, 0xbc, 0x80, 0x4b, 0x60, 0x64, + 0xff, 0xc7, 0x77, 0x4f, 0xde, 0x97, 0xc1, 0xdf, 0x09, 0x5b, + ]), + iv: Buffer.from([ + 0xdf, 0x16, 0x7e, 0xd1, 0xc9, 0x2c, 0x24, 0x1b, 0x02, 0x4f, 0x48, + 0x24, 0x62, 0xc6, 0x3b, 0x9b, + ]), + }) + res.end(); + }) + + request(app) + .get('/') + .expect( + 'Set-Cookie', + 'name=%7B%22encryptedText%22%3A%22wdYTOw%3D%3D%22%2C%22iv%22%3A%223xZ%2B0cksJBsCT0gkYsY7mw%3D%3D%22%2C%22authTag%22%3A%22pbC2HFCHVKkeAVA46GoNtg%3D%3D%22%7D; Path=/', + ) + .expect(200, done) + }) + + it('should return a stringified json with the encrypted signed cookie', function (done) { + var app = express() + var { Buffer } = require('node:buffer') + + app.use(cookieParser('my-secret')) + + app.use(function (req, res) { + res.cookie( + 'name', + 'tobi', + { signed: true }, + { + key: Buffer.from([ + 0x66, 0xcc, 0xc0, 0xa1, 0x9f, 0x64, 0x26, 0x70, 0x84, 0xfe, 0xc7, + 0x0b, 0x2a, 0xf5, 0xf9, 0x45, 0x8e, 0xbc, 0x80, 0x4b, 0x60, 0x64, + 0xff, 0xc7, 0x77, 0x4f, 0xde, 0x97, 0xc1, 0xdf, 0x09, 0x5b, + ]), + iv: Buffer.from([ + 0xdf, 0x16, 0x7e, 0xd1, 0xc9, 0x2c, 0x24, 0x1b, 0x02, 0x4f, 0x48, + 0x24, 0x62, 0xc6, 0x3b, 0x9b, + ]), + }, + ) + res.end() + }); + + request(app) + .get('/') + .expect( + 'Set-Cookie', + 'name=s%3A%7B%22encryptedText%22%3A%22wdYTOw%3D%3D%22%2C%22iv%22%3A%223xZ%2B0cksJBsCT0gkYsY7mw%3D%3D%22%2C%22authTag%22%3A%22pbC2HFCHVKkeAVA46GoNtg%3D%3D%22%7D.%2FbjKv%2BoqY%2BsjNKQp%2FyAgxhemLopKyKnQt1ngpRxhfL0; Path=/', + ) + .expect(200, done) + }) + }) describe('.cookie(name, string, options)', function(){ it('should set params', function(done){ var app = express();