Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
unreleased
==========

* Inline `keygrip` dependency (no longer requires external package)

0.9.1 / 2024-01-01
==================

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,15 +27,15 @@ $ 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

### new Cookies(request, response [, options])

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`.

Expand All @@ -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.
Expand All @@ -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
Expand Down
73 changes: 72 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

'use strict'

var crypto = require('crypto')
var deprecate = require('depd')('cookies')
var Keygrip = require('keygrip')
var http = require('http')

/**
Expand Down Expand Up @@ -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
}
Comment on lines +309 to +311
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to check whether crypto.timingSafeEqual(...) is existing and call it here rather than in bufferEqual?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update, as is, preserves expected node compatibility; adapted from keygrip.
Happy to update along with a node compatibility update if desired!

}

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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions test/express.js
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
2 changes: 1 addition & 1 deletion test/restify.js
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
212 changes: 211 additions & 1 deletion test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need this change in other test files that require keygrip.

var path = require('path')
var request = require('supertest')

Expand Down Expand Up @@ -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 {
Expand Down