diff --git a/HISTORY.md b/HISTORY.md index e61b24d..8231d4d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,8 @@ +unreleased +========== + + * Inline `keygrip` dependency (no longer requires external package) + 0.9.1 / 2024-01-01 ================== diff --git a/README.md b/README.md index 6b323bd..07764ba 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Cookies [![Build Status][ci-image]][ci-url] [![Test Coverage][coveralls-image]][coveralls-url] -Cookies is a [node.js](http://nodejs.org/) module for getting and setting HTTP(S) cookies. Cookies can be signed to prevent tampering, using [Keygrip](https://www.npmjs.com/package/keygrip). It can be used with the built-in node.js HTTP library, or as Connect/Express middleware. +Cookies is a [node.js](http://nodejs.org/) module for getting and setting HTTP(S) cookies. Cookies can be signed to prevent tampering, using Keygrip (included). It can be used with the built-in node.js HTTP library, or as Connect/Express middleware. ## Install @@ -27,7 +27,7 @@ $ npm install cookies * **Unobtrusive**: Signed cookies are stored the same way as unsigned cookies, instead of in an obfuscated signing format. An additional signature cookie is stored for each signed cookie, using a standard naming convention (_cookie-name_`.sig`). This allows other libraries to access the original cookies without having to know the signing mechanism. -* **Agnostic**: This library is optimized for use with [Keygrip](https://www.npmjs.com/package/keygrip), but does not require it; you can implement your own signing scheme instead if you like and use this library only to read/write cookies. Factoring the signing into a separate library encourages code reuse and allows you to use the same signing library for other areas where signing is needed, such as in URLs. +* **Agnostic**: This library includes Keygrip for signing, but does not require it; you can implement your own signing scheme instead if you like and use this library only to read/write cookies. ## API @@ -35,7 +35,7 @@ $ npm install cookies Create a new cookie jar for a given `request` and `response` pair. The `request` argument is a [Node.js HTTP incoming request object](https://nodejs.org/dist/latest-v16.x/docs/api/http.html#class-httpincomingmessage) and the `response` argument is a [Node.js HTTP server response object](https://nodejs.org/dist/latest-v16.x/docs/api/http.html#class-httpserverresponse). -A [Keygrip](https://www.npmjs.com/package/keygrip) object or an array of keys can optionally be passed as `options.keys` to enable cryptographic signing based on SHA1 HMAC, using rotated credentials. +A Keygrip object or an array of keys can optionally be passed as `options.keys` to enable cryptographic signing based on SHA1 HMAC, using rotated credentials. A Boolean can optionally be passed as `options.secure` to explicitally specify if the connection is secure, rather than this module examining `request`. @@ -51,7 +51,7 @@ This extracts the cookie with the given name from the `Cookie` header in the req `{ signed: true }` can optionally be passed as the second parameter _options_. In this case, a signature cookie (a cookie of same name ending with the `.sig` suffix appended) is fetched. If no such cookie exists, nothing is returned. -If the signature cookie _does_ exist, the provided [Keygrip](https://www.npmjs.com/package/keygrip) object is used to check whether the hash of _cookie-name_=_cookie-value_ matches that of any registered key: +If the signature cookie _does_ exist, the provided Keygrip object is used to check whether the hash of _cookie-name_=_cookie-value_ matches that of any registered key: * If the signature cookie hash matches the first key, the original cookie value is returned. * If the signature cookie hash matches any other key, the original cookie value is returned AND an outbound header is set to update the signature cookie's value to the hash of the first key. This enables automatic freshening of signature cookies that have become stale due to key rotation. @@ -74,7 +74,7 @@ If the _options_ object is provided, it will be used to generate the outbound co * `partitioned`: a boolean indicating whether to partition the cookie in Chrome for the [CHIPS Update](https://developers.google.com/privacy-sandbox/3pcd/chips) (`false` by default). If this is true, Cookies from embedded sites will be partitioned and only readable from the same top level site from which it was created. * `priority`: a string indicating the cookie priority. This can be set to `'low'`, `'medium'`, or `'high'`. * `sameSite`: a boolean or string indicating whether the cookie is a "same site" cookie (`false` by default). This can be set to `'strict'`, `'lax'`, `'none'`, or `true` (which maps to `'strict'`). -* `signed`: a boolean indicating whether the cookie is to be signed (`false` by default). If this is true, another cookie of the same name with the `.sig` suffix appended will also be sent, with a 27-byte url-safe base64 SHA1 value representing the hash of _cookie-name_=_cookie-value_ against the first [Keygrip](https://www.npmjs.com/package/keygrip) key. This signature key is used to detect tampering the next time a cookie is received. +* `signed`: a boolean indicating whether the cookie is to be signed (`false` by default). If this is true, another cookie of the same name with the `.sig` suffix appended will also be sent, with a 27-byte url-safe base64 SHA1 value representing the hash of _cookie-name_=_cookie-value_ against the first Keygrip key. This signature key is used to detect tampering the next time a cookie is received. * `overwrite`: a boolean indicating whether to overwrite previously set cookies of the same name (`false` by default). If this is true, all cookies set during the same request with the same name (regardless of path or domain) are filtered out of the Set-Cookie header when setting this cookie. ### Secure cookies diff --git a/index.js b/index.js index 133e4d1..e2ece27 100644 --- a/index.js +++ b/index.js @@ -7,8 +7,8 @@ 'use strict' +var crypto = require('crypto') var deprecate = require('depd')('cookies') -var Keygrip = require('keygrip') var http = require('http') /** @@ -287,4 +287,75 @@ Cookies.connect = Cookies.express = function(keys) { Cookies.Cookie = Cookie +function Keygrip (keys, algorithm, encoding) { + if (!(this instanceof Keygrip)) return new Keygrip(keys, algorithm, encoding) + + if (!keys || !(0 in keys)) { + throw new Error('Keys must be provided.') + } + + this.keys = keys + this.algorithm = algorithm || 'sha1' + this.encoding = encoding || 'base64' +} + +Keygrip.prototype.sign = function sign (data) { + return hmac(this.algorithm, this.keys[0], data, this.encoding) +} + +Keygrip.prototype.index = function index (data, digest) { + for (var i = 0, l = this.keys.length; i < l; i++) { + var computed = hmac(this.algorithm, this.keys[i], data, this.encoding) + if (constantTimeCompare(digest, computed)) { + return i + } + } + + return -1 +} + +Keygrip.prototype.verify = function verify (data, digest) { + return this.index(data, digest) > -1 +} + +function hmac (algorithm, key, data, encoding) { + return crypto + .createHmac(algorithm, key) + .update(data) + .digest(encoding) + .replace(/\/|\+|=/g, function (x) { + return ({ '/': '_', '+': '-', '=': '' })[x] + }) +} + +// Constant-time comparison, preserves compatibility w/ package.json[engines][node] +// adapted from https://github.com/crypto-utils/keygrip +function constantTimeCompare (a, b) { + var sa = String(a) + var sb = String(b) + var key = crypto.randomBytes(32) + var ah = crypto.createHmac('sha256', key).update(sa).digest() + var bh = crypto.createHmac('sha256', key).update(sb).digest() + + return bufferEqual(ah, bh) +} + +function bufferEqual (a, b) { + if (a.length !== b.length) { + return false + } + + if (crypto.timingSafeEqual) { + return crypto.timingSafeEqual(a, b) + } + + var result = 0 + for (var i = 0; i < a.length; i++) { + result |= a[i] ^ b[i] + } + return result === 0 +} + +Cookies.Keygrip = Keygrip + module.exports = Cookies diff --git a/package.json b/package.json index 29a9ce6..2c1e226 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "license": "MIT", "repository": "pillarjs/cookies", "dependencies": { - "depd": "~2.0.0", - "keygrip": "~1.1.0" + "depd": "~2.0.0" }, "devDependencies": { "eslint": "8.56.0", diff --git a/test/express.js b/test/express.js index fa21d5f..c8513e3 100644 --- a/test/express.js +++ b/test/express.js @@ -1,7 +1,8 @@ var assert = require( "assert" ) - , keys = require( "keygrip" )(['a', 'b']) - , cookies = require( "../" ).express + , Cookies = require( "../" ) + , keys = new Cookies.Keygrip(['a', 'b']) + , cookies = Cookies.express , request = require('supertest') var express = tryRequire('express') diff --git a/test/restify.js b/test/restify.js index 141b97a..d06d3ff 100644 --- a/test/restify.js +++ b/test/restify.js @@ -1,6 +1,6 @@ var assert = require('assert') - , keys = require('keygrip')(['a', 'b']) , Cookies = require('../') + , keys = new Cookies.Keygrip(['a', 'b']) , request = require('supertest') var restify = tryRequire('restify') diff --git a/test/test.js b/test/test.js index e6a5901..0f7a325 100644 --- a/test/test.js +++ b/test/test.js @@ -4,7 +4,7 @@ var Cookies = require('..') var fs = require('fs') var http = require('http') var https = require('https') -var Keygrip = require('keygrip') +var Keygrip = require('..').Keygrip var path = require('path') var request = require('supertest') @@ -605,6 +605,216 @@ describe('Cookies(req, res, [options])', function () { }) }) +describe('Keygrip', function () { + describe('constructor', function () { + it('should have correct constructor', function () { + var keys = new Keygrip(['key1']) + assert.strictEqual(keys.constructor, Keygrip) + }) + + it('should work without new keyword', function () { + var keys = Keygrip(['key1']) + assert.strictEqual(keys.constructor, Keygrip) + }) + + it('should throw without keys', function () { + assert.throws(function () { + new Keygrip() + }, /Keys must be provided/) + }) + + it('should throw with empty array', function () { + assert.throws(function () { + new Keygrip([]) + }, /Keys must be provided/) + }) + + it('should default to sha1 algorithm', function () { + var keys = new Keygrip(['key1']) + assert.strictEqual(keys.algorithm, 'sha1') + }) + + it('should default to base64 encoding', function () { + var keys = new Keygrip(['key1']) + assert.strictEqual(keys.encoding, 'base64') + }) + + it('should accept custom algorithm', function () { + var keys = new Keygrip(['key1'], 'sha256') + assert.strictEqual(keys.algorithm, 'sha256') + }) + + it('should accept custom encoding', function () { + var keys = new Keygrip(['key1'], 'sha1', 'hex') + assert.strictEqual(keys.encoding, 'hex') + }) + }) + + describe('.sign(data)', function () { + it('should sign data with first key', function () { + var keys = new Keygrip(['keyboard cat']) + var signature = keys.sign('foo=bar') + assert.strictEqual(signature, 'iW2fuCIzk9Cg_rqLT1CAqrtdWs8') + }) + + it('should produce url-safe base64', function () { + var keys = new Keygrip(['test key']) + var signature = keys.sign('test data') + assert.ok(!/[\/\+=]/.test(signature), 'signature should not contain /, +, or =') + }) + + it('should produce different signatures for different data', function () { + var keys = new Keygrip(['key1']) + var sig1 = keys.sign('data1') + var sig2 = keys.sign('data2') + assert.notStrictEqual(sig1, sig2) + }) + + it('should produce different signatures for different keys', function () { + var keys1 = new Keygrip(['key1']) + var keys2 = new Keygrip(['key2']) + var sig1 = keys1.sign('data') + var sig2 = keys2.sign('data') + assert.notStrictEqual(sig1, sig2) + }) + + it('should work with sha256 algorithm', function () { + var keys = new Keygrip(['key1'], 'sha256') + var signature = keys.sign('test') + assert.ok(signature.length > 0) + }) + }) + + describe('.verify(data, digest)', function () { + it('should verify valid signature', function () { + var keys = new Keygrip(['key1']) + var signature = keys.sign('data') + assert.ok(keys.verify('data', signature)) + }) + + it('should reject invalid signature', function () { + var keys = new Keygrip(['key1']) + assert.ok(!keys.verify('data', 'invalidsignature')) + }) + + it('should reject signature from different key', function () { + var keys1 = new Keygrip(['key1']) + var keys2 = new Keygrip(['key2']) + var signature = keys1.sign('data') + assert.ok(!keys2.verify('data', signature)) + }) + + it('should reject signature for different data', function () { + var keys = new Keygrip(['key1']) + var signature = keys.sign('data1') + assert.ok(!keys.verify('data2', signature)) + }) + + it('should verify with any key in keylist', function () { + var keys = new Keygrip(['key1', 'key2', 'key3']) + var signature = keys.sign('data') + assert.ok(keys.verify('data', signature)) + }) + }) + + describe('.index(data, digest)', function () { + it('should return 0 for signature from first key', function () { + var keys = new Keygrip(['key1', 'key2']) + var signature = keys.sign('data') + assert.strictEqual(keys.index('data', signature), 0) + }) + + it('should return correct index for old key', function () { + var oldKeys = new Keygrip(['oldkey']) + var signature = oldKeys.sign('data') + var newKeys = new Keygrip(['newkey', 'oldkey']) + assert.strictEqual(newKeys.index('data', signature), 1) + }) + + it('should return -1 for invalid signature', function () { + var keys = new Keygrip(['key1']) + assert.strictEqual(keys.index('data', 'invalidsignature'), -1) + }) + + it('should return -1 for signature from unknown key', function () { + var keys1 = new Keygrip(['key1']) + var keys2 = new Keygrip(['key2']) + var signature = keys1.sign('data') + assert.strictEqual(keys2.index('data', signature), -1) + }) + + it('should support key rotation', function () { + var keys = new Keygrip(['newest', 'older', 'oldest']) + var oldSignature = new Keygrip(['oldest']).sign('data') + assert.strictEqual(keys.index('data', oldSignature), 2) + }) + }) + + describe('key rotation', function () { + it('should verify old signatures after key rotation', function () { + var oldKeys = new Keygrip(['key1']) + var signature = oldKeys.sign('session=abc123') + + var newKeys = new Keygrip(['key2', 'key1']) + assert.ok(newKeys.verify('session=abc123', signature)) + assert.strictEqual(newKeys.index('session=abc123', signature), 1) + }) + + it('should sign with newest key', function () { + var keys = new Keygrip(['newest', 'older', 'oldest']) + var signature = keys.sign('data') + assert.strictEqual(keys.index('data', signature), 0) + }) + }) + + describe('edge cases', function () { + it('should handle empty string data', function () { + var keys = new Keygrip(['key1']) + var signature = keys.sign('') + assert.ok(keys.verify('', signature)) + }) + + it('should handle special characters in data', function () { + var keys = new Keygrip(['key1']) + var data = 'foo=bar&baz=qux;path=/;secure' + var signature = keys.sign(data) + assert.ok(keys.verify(data, signature)) + }) + + it('should handle unicode in data', function () { + var keys = new Keygrip(['key1']) + var data = 'hello=世界' + var signature = keys.sign(data) + assert.ok(keys.verify(data, signature)) + }) + + it('should handle long keys', function () { + var keys = new Keygrip(['a'.repeat(1000)]) + var signature = keys.sign('data') + assert.ok(keys.verify('data', signature)) + }) + + it('should handle many keys in rotation', function () { + var keyList = [] + for (var i = 0; i < 100; i++) { + keyList.push('key' + i) + } + var keys = new Keygrip(keyList) + var signature = keys.sign('data') + assert.strictEqual(keys.index('data', signature), 0) + }) + + it('should reject signatures with different lengths', function () { + var keys = new Keygrip(['key1']) + var signature = keys.sign('data') + var shortSignature = signature.slice(0, 10) + var longSignature = signature + 'extra' + assert.ok(!keys.verify('data', shortSignature)) + assert.ok(!keys.verify('data', longSignature)) + }) + }) +}) + function assertServer (done, test) { var server = http.createServer(function (req, res) { try {