client-sessions
Advanced tools
Comparing version 0.5.0 to 0.6.0
@@ -14,2 +14,24 @@ /* This Source Code Form is subject to the terms of the Mozilla Public | ||
const KDF_ENC = 'cookiesession-encryption'; | ||
const KDF_MAC = 'cookiesession-signature'; | ||
/* map from cipher algorithm to exact key byte length */ | ||
const ENCRYPTION_ALGORITHMS = { | ||
aes128: 16, // implicit CBC mode | ||
aes192: 24, | ||
aes256: 32 | ||
}; | ||
const DEFAULT_ENCRYPTION_ALGO = 'aes256'; | ||
/* map from hmac algorithm to _minimum_ key byte length */ | ||
const SIGNATURE_ALGORITHMS = { | ||
'sha256': 32, | ||
'sha256-drop128': 32, | ||
'sha384': 48, | ||
'sha384-drop192': 48, | ||
'sha512': 64, | ||
'sha512-drop256': 64 | ||
}; | ||
const DEFAULT_SIGNATURE_ALGO = 'sha256'; | ||
function isObject(val) { | ||
@@ -47,2 +69,10 @@ return Object.prototype.toString.call(val) === '[object Object]'; | ||
function forceBuffer(binaryOrBuffer) { | ||
if (Buffer.isBuffer(binaryOrBuffer)) { | ||
return binaryOrBuffer; | ||
} else { | ||
return new Buffer(binaryOrBuffer, 'binary'); | ||
} | ||
} | ||
function deriveKey(master, type) { | ||
@@ -52,5 +82,55 @@ // eventually we want to use HKDF. For now we'll do something simpler. | ||
hmac.update(type); | ||
return hmac.digest('binary'); | ||
return forceBuffer(hmac.digest()); | ||
} | ||
function setupKeys(opts) { | ||
// derive two keys, one for signing one for encrypting, from the secret. | ||
if (!opts.encryptionKey) { | ||
opts.encryptionKey = deriveKey(opts.secret, KDF_ENC); | ||
} | ||
if (!opts.signatureKey) { | ||
opts.signatureKey = deriveKey(opts.secret, KDF_MAC); | ||
} | ||
if (!opts.signatureAlgorithm) { | ||
opts.signatureAlgorithm = DEFAULT_SIGNATURE_ALGO; | ||
} | ||
if (!opts.encryptionAlgorithm) { | ||
opts.encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGO; | ||
} | ||
} | ||
function keyConstraints(opts) { | ||
if (!Buffer.isBuffer(opts.encryptionKey)) { | ||
throw new Error('encryptionKey must be a Buffer'); | ||
} | ||
if (!Buffer.isBuffer(opts.signatureKey)) { | ||
throw new Error('signatureKey must be a Buffer'); | ||
} | ||
if (constantTimeEquals(opts.encryptionKey, opts.signatureKey)) { | ||
throw new Error('Encryption and Signature keys must be different'); | ||
} | ||
var encAlgo = opts.encryptionAlgorithm; | ||
var required = ENCRYPTION_ALGORITHMS[encAlgo]; | ||
if (opts.encryptionKey.length !== required) { | ||
throw new Error( | ||
'Encryption Key for '+encAlgo+' must be exactly '+required+' bytes '+ | ||
'('+(required*8)+' bits)' | ||
); | ||
} | ||
var sigAlgo = opts.signatureAlgorithm; | ||
var minimum = SIGNATURE_ALGORITHMS[sigAlgo]; | ||
if (opts.signatureKey.length < minimum) { | ||
throw new Error( | ||
'Encryption Key for '+sigAlgo+' must be at least '+minimum+' bytes '+ | ||
'('+(minimum*8)+' bits)' | ||
); | ||
} | ||
} | ||
function constantTimeEquals(a, b) { | ||
@@ -69,2 +149,55 @@ // Ideally this would be a native function, so it's less sensitive to how the | ||
// it's good cryptographic pracitice to not leave buffers with sensitive | ||
// contents hanging around. | ||
function zeroBuffer(buf) { | ||
for (var i = 0; i < buf.length; i++) { | ||
buf[i] = 0; | ||
} | ||
return buf; | ||
} | ||
function hmacInit(algo, key) { | ||
var match = algo.match(/^([^-]+)(?:-drop(\d+))?$/); | ||
var baseAlg = match[1]; | ||
var drop = match[2] ? parseInt(match[2], 10) : 0; | ||
var hmacAlg = crypto.createHmac(baseAlg, key); | ||
var origDigest = hmacAlg.digest; | ||
if (drop === 0) { | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when dropping | ||
// 0.8 support. | ||
hmacAlg.digest = function() { | ||
return forceBuffer(origDigest.call(this)); | ||
}; | ||
} else { | ||
var N = drop / 8; // bits to bytes | ||
hmacAlg.digest = function dropN() { | ||
var result = forceBuffer(origDigest.call(this)); | ||
// Throw away the second half of the 512-bit result, leaving the first | ||
// 256-bits. | ||
var truncated = new Buffer(N); | ||
result.copy(truncated, 0, 0, N); | ||
zeroBuffer(result); | ||
return truncated; | ||
}; | ||
} | ||
return hmacAlg; | ||
} | ||
function computeHmac(opts, iv, ciphertext, duration, createdAt) { | ||
var hmacAlg = hmacInit(opts.signatureAlgorithm, opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
return hmacAlg.digest(); | ||
} | ||
function encode(opts, content, duration, createdAt){ | ||
@@ -80,10 +213,4 @@ // format will be: | ||
if (!opts.encryptionKey) { | ||
opts.encryptionKey = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
setupKeys(opts); | ||
if (!opts.signatureKey) { | ||
opts.signatureKey = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
duration = duration || 24*60*60*1000; | ||
@@ -96,26 +223,23 @@ createdAt = createdAt || new Date().getTime(); | ||
// encrypt with encryption key | ||
var plaintext = opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content); | ||
var cipher = crypto.createCipheriv('aes256', opts.encryptionKey, iv); | ||
var ciphertext = cipher.update(plaintext, 'utf8', 'binary'); | ||
ciphertext += cipher.final('binary'); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
ciphertext = new Buffer(ciphertext, 'binary'); | ||
var plaintext = new Buffer( | ||
opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content), | ||
'utf8' | ||
); | ||
var cipher = crypto.createCipheriv( | ||
opts.encryptionAlgorithm, | ||
opts.encryptionKey, | ||
iv | ||
); | ||
var ciphertextStart = forceBuffer(cipher.update(plaintext)); | ||
zeroBuffer(plaintext); | ||
var ciphertextEnd = forceBuffer(cipher.final()); | ||
var ciphertext = Buffer.concat([ciphertextStart, ciphertextEnd]); | ||
zeroBuffer(ciphertextStart); | ||
zeroBuffer(ciphertextEnd); | ||
// hmac it | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
var hmac = computeHmac(opts, iv, ciphertext, duration, createdAt); | ||
var hmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
hmac = new Buffer(hmac, 'binary'); | ||
return [ | ||
var result = [ | ||
base64urlencode(iv), | ||
@@ -127,5 +251,14 @@ base64urlencode(ciphertext), | ||
].join('.'); | ||
zeroBuffer(iv); | ||
zeroBuffer(ciphertext); | ||
zeroBuffer(hmac); | ||
return result; | ||
} | ||
function decode(opts, content) { | ||
if (!opts.cookieName) { | ||
throw new Error("cookieName option required"); | ||
} | ||
@@ -138,14 +271,4 @@ // stop at any time if there's an issue | ||
if (!opts.cookieName) { | ||
throw new Error("cookieName option required"); | ||
} | ||
setupKeys(opts); | ||
if (!opts.encryptionKey) { | ||
opts.encryptionKey = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
} | ||
if (!opts.signatureKey) { | ||
opts.signatureKey = deriveKey(opts.secret, 'cookiesession-signature'); | ||
} | ||
var iv = base64urldecode(components[0]); | ||
@@ -157,4 +280,14 @@ var ciphertext = base64urldecode(components[1]); | ||
function cleanup() { | ||
zeroBuffer(iv); | ||
zeroBuffer(ciphertext); | ||
zeroBuffer(hmac); | ||
if (expectedHmac) { // declared below | ||
zeroBuffer(expectedHmac); | ||
} | ||
} | ||
// make sure IV is right length | ||
if (iv.length !== 16) { | ||
cleanup(); | ||
return; | ||
@@ -164,17 +297,6 @@ } | ||
// check hmac | ||
var hmacAlg = crypto.createHmac('sha256', opts.signatureKey); | ||
hmacAlg.update(iv); | ||
hmacAlg.update("."); | ||
hmacAlg.update(ciphertext); | ||
hmacAlg.update("."); | ||
hmacAlg.update(createdAt.toString()); | ||
hmacAlg.update("."); | ||
hmacAlg.update(duration.toString()); | ||
var expectedHmac = computeHmac(opts, iv, ciphertext, duration, createdAt); | ||
var expectedHmac = hmacAlg.digest(); | ||
// Before 0.10, crypto returns binary-encoded strings. Remove when | ||
// dropping 0.8 support. | ||
expectedHmac = new Buffer(expectedHmac, 'binary'); | ||
if (!constantTimeEquals(hmac, expectedHmac)) { | ||
cleanup(); | ||
return; | ||
@@ -184,3 +306,7 @@ } | ||
// decrypt | ||
var cipher = crypto.createDecipheriv('aes256', opts.encryptionKey, iv); | ||
var cipher = crypto.createDecipheriv( | ||
opts.encryptionAlgorithm, | ||
opts.encryptionKey, | ||
iv | ||
); | ||
var plaintext = cipher.update(ciphertext, 'binary', 'utf8'); | ||
@@ -191,7 +317,9 @@ plaintext += cipher.final('utf8'); | ||
if (cookieName !== opts.cookieName) { | ||
cleanup(); | ||
return; | ||
} | ||
var result; | ||
try { | ||
return { | ||
result = { | ||
content: JSON.parse( | ||
@@ -203,5 +331,7 @@ plaintext.substring(plaintext.indexOf(COOKIE_NAME_SEP) + 1) | ||
}; | ||
} catch (x) { | ||
return; | ||
} catch (ignored) { | ||
} | ||
cleanup(); | ||
return result; | ||
} | ||
@@ -261,3 +391,3 @@ | ||
// the cookie should expire when it becomes invalid | ||
// we add an extra second because the conversion to a date | ||
// we add an extra second because the conversion to a date | ||
// truncates the milliseconds | ||
@@ -398,3 +528,2 @@ this.expires = new Date(time + this.duration + 1000); | ||
function clientSessionFactory(opts) { | ||
@@ -405,4 +534,5 @@ if (!opts) { | ||
if (!opts.secret) { | ||
throw new Error("cannot set up sessions without a secret"); | ||
if (!(opts.secret || (opts.encryptionKey && opts.signatureKey))) { | ||
throw new Error("cannot set up sessions without a secret "+ | ||
"or encryptionKey/signatureKey pair"); | ||
} | ||
@@ -416,2 +546,18 @@ | ||
var encAlg = opts.encryptionAlgorithm || DEFAULT_ENCRYPTION_ALGO; | ||
encAlg = encAlg.toLowerCase(); | ||
if (!ENCRYPTION_ALGORITHMS[encAlg]) { | ||
throw new Error('invalid encryptionAlgorithm, supported are: '+ | ||
Object.keys(ENCRYPTION_ALGORITHMS).join(', ')); | ||
} | ||
opts.encryptionAlgorithm = encAlg; | ||
var sigAlg = opts.signatureAlgorithm || DEFAULT_SIGNATURE_ALGO; | ||
sigAlg = sigAlg.toLowerCase(); | ||
if (!SIGNATURE_ALGORITHMS[sigAlg]) { | ||
throw new Error('invalid signatureAlgorithm, supported are: '+ | ||
Object.keys(SIGNATURE_ALGORITHMS).join(', ')); | ||
} | ||
opts.signatureAlgorithm = sigAlg; | ||
// set up cookie defaults | ||
@@ -431,5 +577,4 @@ opts.cookie = opts.cookie || {}; | ||
// derive two keys, one for signing one for encrypting, from the secret. | ||
opts.encryptionKey = deriveKey(opts.secret, 'cookiesession-encryption'); | ||
opts.signatureKey = deriveKey(opts.secret, 'cookiesession-signature'); | ||
setupKeys(opts); | ||
keyConstraints(opts); | ||
@@ -468,3 +613,3 @@ const propertyName = opts.requestKey || opts.cookieName; | ||
var writeHead = res.writeHead; | ||
@@ -487,3 +632,4 @@ res.writeHead = function () { | ||
encode: encode, | ||
decode: decode | ||
decode: decode, | ||
computeHmac: computeHmac | ||
}; |
{ | ||
"name" : "client-sessions", | ||
"version" : "0.5.0", | ||
"version" : "0.6.0", | ||
"description" : "secure sessions stored in cookies", | ||
@@ -5,0 +5,0 @@ "main" : "lib/client-sessions", |
146
README.md
@@ -78,3 +78,3 @@ [![build status](https://secure.travis-ci.org/mozilla/node-client-sessions.png)](http://travis-ci.org/mozilla/node-client-sessions) | ||
requestKey: 'forcedSessionKey', // requestKey overrides cookieName for the key name added to the request object. | ||
secret: 'blargadeeblargblarg', // should be a large unguessable string | ||
secret: 'blargadeeblargblarg', // should be a large unguessable string or Buffer | ||
duration: 24 * 60 * 60 * 1000, // how long the session will stay valid in ms | ||
@@ -93,2 +93,146 @@ })); | ||
## Cryptography | ||
A pair of encryption and signature keys are derived from the `secret` option | ||
via HMAC-SHA-256; the `secret` isn't used directly to encrypt or compute the | ||
MAC. | ||
The key-derivation function, in pseudocode: | ||
```text | ||
encKey := HMAC-SHA-256(secret, 'cookiesession-encryption'); | ||
sigKey := HMAC-SHA-256(secret, 'cookiesession-signature'); | ||
``` | ||
The **AES-256-CBC** cipher is used to encrypt the session contents, with an | ||
**HMAC-SHA-256** authentication tag (via **Encrypt-then-Mac** composition). A | ||
random 128-bit Initialization Vector (IV) is generated for each encryption | ||
operation (this is the AES block size regardless of the key size). The | ||
CBC-mode input is padded with the usual PKCS#5 scheme. | ||
In pseudocode, the encryption looks like the following, with `||` denoting | ||
concatenation. The `createdAt` and `duration` parameters are decimal strings. | ||
```text | ||
iv := secureRandom(16 bytes) | ||
ciphertext := AES-256-CBC(encKey, iv, sessionJson) | ||
payload := iv || '.' || ciphertext || '.' || createdAt || '.' || duration | ||
hmac := HMAC-SHA-256(sigKey, payload) | ||
cookie := base64url(iv) || '.' || | ||
base64url(ciphertext) || '.' || | ||
createdAt || '.' || | ||
duration || '.' || | ||
base64url(hmac) | ||
``` | ||
For decryption, a constant-time equality operation is used to verify the HMAC | ||
output to avoid the plausible timing attack. | ||
### Advanced Cryptographic Options | ||
The defaults are secure, but may not suit your requirements. Some example scenarios: | ||
- You want to use randomly-generated keys instead of using the key-derivation | ||
function used in this module. | ||
- AES-256 is overkill for the type of data you store in the session (e.g. not | ||
personally-identifiable or sensitive) and you'd like to trade-off decreasing | ||
the security level for CPU economy. | ||
- SHA-256 is maybe too weak for your application and you want to have more | ||
MAC security by using SHA-512, which grows the size of your cookies slightly. | ||
If the defaults don't suit your needs, you can customize client-sessions. | ||
**Beware: Changing keys and/or algorithms will make previously-generated | ||
Cookies invalid!** | ||
#### Configuring Keys | ||
To configure independent encryption and signature (HMAC) keys: | ||
```js | ||
app.use(sessions({ | ||
encryptionKey: loadFromKeyStore('session-encryption-key'), | ||
signatureKey: loadFromKeyStore('session-signature-key'), | ||
// ... other options discussed above ... | ||
})); | ||
``` | ||
#### Configuring Algorithms | ||
To specify custom algorithms and keys: | ||
```js | ||
app.use(sessions({ | ||
// use WEAKER-than-default encryption: | ||
encryptionAlgorithm: 'aes128', | ||
encryptionKey: loadFromKeyStore('session-encryption-key'), | ||
// use a SHORTER-than-default MAC: | ||
signatureAlgorithm: 'sha256-drop128', | ||
signatureKey: loadFromKeyStore('session-signature-key'), | ||
// ... other options discussed above ... | ||
})); | ||
``` | ||
#### Encryption Algorithms | ||
Supported CBC-mode `encryptionAlgorithm`s (and key length requirements): | ||
| Cipher | Key length | | ||
| ------ | ---------- | | ||
| aes128 | 16 bytes | | ||
| aes192 | 24 bytes | | ||
| aes256 | 32 bytes | | ||
These key lengths are exactly as required by the [Advanced Encryption | ||
Standard](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard). | ||
#### Signature (HMAC) Algorithms | ||
Supported HMAC `signatureAlgorithm`s (and key length requirements): | ||
| HMAC | Minimum Key Length | Maximum Key Length | | ||
| -------------- | ------------------ | ------------------ | | ||
| sha256 | 32 bytes | 64 bytes | | ||
| sha256-drop128 | 32 bytes | 64 bytes | | ||
| sha384 | 48 bytes | 128 bytes | | ||
| sha384-drop192 | 48 bytes | 128 bytes | | ||
| sha512 | 64 bytes | 128 bytes | | ||
| sha512-drop256 | 64 bytes | 128 bytes | | ||
The HMAC key length requirements are derived from [RFC 2104 section | ||
3](https://tools.ietf.org/html/rfc2104#section-3). The maximum key length can | ||
be exceeded, but it doesn't increase the security of the signature. | ||
The `-dropN` algorithms discard the latter half of the HMAC output, which | ||
provides some additional protection against SHA2 length-extension attacks on | ||
top of HMAC. The same technique is used in the upcoming [JSON Web Algorithms | ||
`AES_CBC_HMAC_SHA2` authenticated | ||
cipher](http://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-19#section-5.2). | ||
#### Generating Keys | ||
One can easily generate both AES and HMAC-SHA2 keys via command line: `openssl | ||
rand -base64 32` for a 32-byte (256-bit) key. It's easy to then parse that | ||
output into a `Buffer`: | ||
```js | ||
function loadKeyFromStore(name) { | ||
var text = myConfig.keys[name]; | ||
return new Buffer(text, 'base64'); | ||
} | ||
``` | ||
#### Key Constraints | ||
If you specify `encryptionKey` or `signatureKey`, you must supply the other as | ||
well. | ||
The following constraints must be met or an `Error` will be thrown: | ||
1. both keys must be `Buffer`s. | ||
2. the keys must be _different_. | ||
3. the encryption key are _exactly_ the length required (see above). | ||
4. the signature key has _at least_ the length required (see above). | ||
Based on the above, please note that if you specify a `secret` _and_ a | ||
`signatureAlgorithm`, you need to use `sha256` or `sha256-drop128`. | ||
## License | ||
@@ -95,0 +239,0 @@ |
@@ -829,3 +829,2 @@ // a NODE_ENV of test will supress console output to stderr which | ||
}, | ||
"encode " : function(err, req){ | ||
@@ -1191,2 +1190,57 @@ var result = cookieSessions.util.encode({cookieName: 'session', secret: 'yo'}, {foo:'bar'}); | ||
var sixtyFourByteKey = new Buffer( | ||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', | ||
'binary' | ||
); | ||
var HMAC_EXPECT = { | ||
// aligned so you can see the dropN effect: | ||
'sha256': | ||
'PRYaxV/8RkMyIT/Ib+tIUOWiSn+0EvodJ5rtG1FQHz0=', | ||
'sha256-drop128': | ||
'PRYaxV/8RkMyIT/Ib+tIUA==', | ||
'sha384': | ||
'MND9nz6pxbQC5m41ZPRXhJIuqTj9/hu4gtWZ8t8LgdFLQFWQfC8jhijB0NHLpeA7', | ||
'sha384-drop192': | ||
'MND9nz6pxbQC5m41ZPRXhJIuqTj9/hu4', | ||
'sha512': | ||
'Hr4KLVLyglIwQ43C9U2bmieWBVLnD/F+lzCSF072Ds2b87MK+gbnR0p75A+I+5ez+aiemMGuMZyKVAUWfMMaUA==', | ||
'sha512-drop256': | ||
'Hr4KLVLyglIwQ43C9U2bmieWBVLnD/F+lzCSF072Ds0=' | ||
}; | ||
function testHmac(algo) { | ||
var block = {}; | ||
block.topic = function() { | ||
var opts = { | ||
signatureAlgorithm: algo, | ||
signatureKey: sixtyFourByteKey | ||
}; | ||
var iv = new Buffer('01234567890abcdef','binary'); // 128-bits | ||
var ciphertext = new Buffer('0123456789abcdef0123','binary'); | ||
var duration = 876543210; | ||
var createdAt = 1234567890; | ||
return cookieSessions.util.computeHmac( | ||
opts, iv, ciphertext, duration, createdAt | ||
).toString('base64'); | ||
}; | ||
block['equals test vector'] = function(val) { | ||
assert.equal(val, HMAC_EXPECT[algo]); | ||
}; | ||
return block; | ||
} | ||
suite.addBatch({ | ||
"computeHmac": { | ||
"sha256": testHmac('sha256'), | ||
"sha256-drop128": testHmac('sha256-drop128'), | ||
"sha384": testHmac('sha384'), | ||
"sha384-drop192": testHmac('sha384-drop192'), | ||
"sha512": testHmac('sha512'), | ||
"sha512-drop256": testHmac('sha512-drop256'), | ||
} | ||
}); | ||
suite.export(module); |
Sorry, the diff of this file is not supported yet
78286
1599
241