Skip to content

Commit 15d91c1

Browse files
jas-mingchuno
authored andcommittedApr 28, 2019
Transparent crypto support (#314)
The following functionality is included: - Symmetric encryption of session data following OWASP recommendations. - Updates to readme providing crypto options - Key derivation to strengthen provided secret in accordance with NIST SP 800-108 & 800-56c - Unique IV conforming to RFC 4309 & NIST SP 800-38d - Support for varying symmetric algorithms, key, iv & authentication tag sizes, hashing algorithms & ciphertext encoding - Test harness for crypto class as well as using transparent crypto functionality
1 parent 08ccada commit 15d91c1

File tree

5 files changed

+321
-1
lines changed

5 files changed

+321
-1
lines changed
 

‎README.md

+21
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,18 @@ app.use(express.session({
214214

215215
by doing this, setting `touchAfter: 24 * 3600` you are saying to the session be updated only one time in a period of 24 hours, does not matter how many request's are made (with the exception of those that change something on the session data)
216216

217+
218+
## Transparent encryption/decryption of session data
219+
220+
When working with sensitive session data it is [recommended](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md) to use encryption
221+
222+
```js
223+
const store = new MongoStore({
224+
url: 'mongodb://localhost/test-app',
225+
secret: 'squirrel'
226+
})
227+
```
228+
217229
## More options
218230

219231
- `collection` Collection (default: `sessions`)
@@ -234,6 +246,15 @@ by doing this, setting `touchAfter: 24 * 3600` you are saying to the session be
234246
from the `writeOperationOptions` object will get overwritten.
235247
- `transformId` (optional) Transform original sessionId in whatever you want to use as storage key.
236248

249+
## Crypto options
250+
- `secret` (optional) Enables transparent crypto in accordance with [OWASP session management recommendations](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Session_Management_Cheat_Sheet.md).
251+
- `algorithm` (optional) Allows for changes to the default symmetric encryption cipher; default is `GCM`. See `crypto.getCiphers()` for supported algorithms.
252+
- `hashing` (optional) May be used to change the default hashing algorithm; default is `sha512`. See `crypto.getHashes()` for supported hashing algorithms.
253+
- `encodeas` (optional) Specify to change the session data cipher text encoding. Default is `hex`.
254+
- `key_size` (optional) When using varying algorithms the key size may be used. Default is `32` based on the `AES` blocksize.
255+
- `iv_size` (optional) This can be used to adjust the default [IV](https://csrc.nist.gov/glossary/term/IV) size if a different algorithm requires a different size. Default is `16`.
256+
- `at_size` (optional) When using newer `AES` modes such as the default `GCM` or `CCM` an authentication tag size can be defined; default is `16`.
257+
237258
## Tests
238259

239260
npm test

‎src/crypto.js

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use strict'
2+
3+
class Crypto {
4+
init(options) {
5+
this.crypto = require('crypto')
6+
this.algorithm = options.algorithm || 'aes-256-gcm'
7+
this.hashing = options.hashing || 'sha512'
8+
this.encodeas = options.encodeas || 'hex'
9+
this.iv_size = options.iv_size || 16
10+
this.at_size = options.at_size || 16
11+
this.key_size = options.key_size || 32
12+
this.secret = this._derive_key(options.secret) || false
13+
}
14+
15+
set(plaintext) {
16+
let iv = this.crypto.randomBytes(this.iv_size).toString(this.encodeas),
17+
aad = this._digest(iv+this.secret, JSON.stringify(plaintext),
18+
this.hashing, this.encodeas),
19+
ct = this._encrypt(this.secret, JSON.stringify(plaintext),
20+
this.algorithm, this.encodeas, iv, aad),
21+
hmac = this._digest(this.secret, ct.ct, this.hashing, this.encodeas)
22+
23+
let obj = JSON.stringify({
24+
hmac: hmac,
25+
ct: ct.ct,
26+
at: ct.at,
27+
aad: aad,
28+
iv: iv
29+
})
30+
31+
return obj
32+
}
33+
34+
get(ciphertext) {
35+
let ct, hmac, pt, sid, session
36+
37+
if (ciphertext)
38+
try {
39+
ct = JSON.parse(ciphertext)
40+
} catch(err) {
41+
ct = ciphertext
42+
}
43+
44+
hmac = this._digest(this.secret, ct.ct, this.hashing, this.encodeas)
45+
46+
if (hmac != ct.hmac)
47+
throw 'Encrypted session was tampered with!'
48+
49+
if (ct.at)
50+
ct.at = Buffer.from(ct.at)
51+
52+
pt = this._decrypt(this.secret, ct.ct, this.algorithm, this.encodeas,
53+
ct.iv, ct.at, ct.aad)
54+
55+
return pt
56+
}
57+
58+
_digest(key, obj, hashing, encodeas) {
59+
let hmac = this.crypto.createHmac(this.hashing, key)
60+
hmac.setEncoding(encodeas)
61+
hmac.write(obj)
62+
hmac.end()
63+
return hmac.read().toString(encodeas)
64+
}
65+
66+
_encrypt(key, pt, algo, encodeas, iv, aad) {
67+
let cipher = this.crypto.createCipheriv(algo, key, iv, {
68+
authTagLength: this.at_size
69+
}), ct, at
70+
71+
if (aad) {
72+
try {
73+
cipher.setAAD(Buffer.from(aad), {
74+
plaintextLength: Buffer.byteLength(pt)
75+
})
76+
} catch(err) {
77+
throw err
78+
}
79+
}
80+
81+
ct = cipher.update(pt, 'utf8', encodeas)
82+
ct += cipher.final(encodeas)
83+
84+
try {
85+
at = cipher.getAuthTag()
86+
} catch(err) {
87+
throw err
88+
}
89+
90+
return (at) ? {'ct': ct, 'at': at} : {'ct': ct}
91+
}
92+
93+
_decrypt(key, ct, algo, encodeas, iv, at, aad) {
94+
let cipher = this.crypto.createDecipheriv(algo, key, iv), pt
95+
96+
if (at) {
97+
try {
98+
cipher.setAuthTag(Buffer.from(at))
99+
} catch(err) {
100+
throw err
101+
}
102+
}
103+
104+
if (aad) {
105+
try {
106+
cipher.setAAD(Buffer.from(aad), {
107+
plaintextLength: Buffer.byteLength(ct)
108+
})
109+
} catch(err) {
110+
throw err
111+
}
112+
}
113+
114+
pt = cipher.update(ct, encodeas, 'utf8')
115+
pt += cipher.final('utf8')
116+
117+
return pt
118+
}
119+
120+
_derive_key(secret) {
121+
let key, hash, salt
122+
123+
hash = this.crypto.createHash(this.hashing)
124+
hash.update(secret)
125+
salt = hash.digest(this.encodeas).substr(0, 16)
126+
127+
key = this.crypto.pbkdf2Sync(secret, salt, 10000, 64, this.hashing)
128+
129+
return key.toString(this.encodeas).substr(0, this.key_size)
130+
}
131+
}
132+
133+
module.exports = new Crypto

‎src/index.js

+30-1
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,24 @@ module.exports = function (connect) {
6767

6868
super(options)
6969

70+
/* Use crypto? */
71+
if (options.secret) {
72+
try {
73+
this.Crypto = require('./crypto.js')
74+
this.Crypto.init(options)
75+
delete options.secret
76+
} catch(error) {
77+
throw error
78+
}
79+
}
80+
7081
/* Options */
7182
this.ttl = options.ttl || 1209600 // 14 days
7283
this.collectionName = options.collection || 'sessions'
7384
this.autoRemove = options.autoRemove || 'native'
7485
this.autoRemoveInterval = options.autoRemoveInterval || 10 // minutes
7586
this.writeOperationOptions = options.writeOperationOptions || {}
7687
this.transformFunctions = computeTransformFunctions(options)
77-
7888
this.options = options
7989

8090
this.changeState('init')
@@ -198,6 +208,16 @@ module.exports = function (connect) {
198208
}))
199209
.then(session => {
200210
if (session) {
211+
212+
if (this.Crypto) {
213+
try {
214+
let tmp_session = this.transformFunctions.unserialize(session.session)
215+
session.session = this.Crypto.get(tmp_session)
216+
} catch(error) {
217+
return callback(error)
218+
}
219+
}
220+
201221
const s = this.transformFunctions.unserialize(session.session)
202222
if (this.options.touchAfter > 0 && session.lastModified) {
203223
s.lastModified = session.lastModified
@@ -217,6 +237,14 @@ module.exports = function (connect) {
217237

218238
let s
219239

240+
if (this.Crypto) {
241+
try {
242+
session = this.Crypto.set(session)
243+
} catch(error) {
244+
return callback(error)
245+
}
246+
}
247+
220248
try {
221249
s = {_id: this.computeStorageId(sid), session: this.transformFunctions.serialize(session)}
222250
} catch (err) {
@@ -344,3 +372,4 @@ module.exports = function (connect) {
344372

345373
return MongoStore
346374
}
375+

‎test/crypto.js

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use strict'
2+
3+
const expect = require('expect.js')
4+
const Crypto = require('../src/crypto.js')
5+
6+
const options = {
7+
secret: 'squirrel'
8+
}
9+
10+
Crypto.init(options)
11+
12+
let hmac
13+
14+
describe('Crypto', () => {
15+
let ct, pt
16+
17+
it('Encrypt data', done => {
18+
ct = JSON.parse(Crypto.set('123, easy as ABC. ABC, easy as 123'))
19+
expect(ct).to.have.property('ct')
20+
expect(ct).to.have.property('iv')
21+
expect(ct).to.have.property('hmac')
22+
done()
23+
})
24+
25+
it('Decrypt data', done => {
26+
pt = Crypto.get(JSON.stringify(ct))
27+
expect(pt).to.match(/123, easy as ABC. ABC, easy as 123/)
28+
done()
29+
})
30+
31+
it('HMAC validation', done => {
32+
hmac = ct.hmac
33+
ct.hmac = 'funky chicken'
34+
ct = JSON.stringify(ct)
35+
36+
try {
37+
pt = Crypto.get(ct)
38+
} catch(err) {
39+
expect(err).to.equal('Encrypted session was tampered with!')
40+
}
41+
done()
42+
})
43+
44+
it('Authentication tag validation', done => {
45+
ct = JSON.parse(ct)
46+
ct.hmac = hmac
47+
48+
if (!ct.at)
49+
done()
50+
51+
ct.at = 'funky chicken'
52+
ct = JSON.stringify(ct)
53+
54+
try {
55+
pt = Crypto.get(ct)
56+
} catch(err) {
57+
expect(err).to.match(/Unsupported state or unable to authenticate data/)
58+
}
59+
done()
60+
})
61+
62+
it('Additional authentication data validation', done => {
63+
ct = JSON.parse(ct)
64+
65+
if (!ct.aad) done()
66+
67+
ct.aad = 'funky chicken'
68+
ct = JSON.stringify(ct)
69+
70+
try {
71+
pt = Crypto.get(ct)
72+
} catch(err) {
73+
expect(err).to.match(/Unsupported state or unable to authenticate data/)
74+
}
75+
done()
76+
})
77+
})

‎test/events.js

+60
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,63 @@ describe('Events', () => {
6969
})
7070
})
7171
})
72+
73+
describe('Events w/ Crypto', () => {
74+
let store, collection
75+
beforeEach(function (done) {
76+
this.timeout(10000)
77+
store = new MongoStore({
78+
url: connectionString,
79+
mongoOptions: { useNewUrlParser: true },
80+
collection: 'sessions-test',
81+
secret: 'squirrel'
82+
})
83+
store.once('connected', () => {
84+
collection = store.collection
85+
collection.deleteMany({}, done)
86+
})
87+
})
88+
afterEach(() => {
89+
store.close()
90+
})
91+
92+
describe('set() with an unknown session id', () => {
93+
it('should emit a `create` event', done => {
94+
store.once('create', sid => {
95+
expect(sid).to.be('foo1')
96+
done()
97+
})
98+
store.set('foo1', {foo: 'bar'}, noop)
99+
})
100+
it('should emit a `set` event', done => {
101+
store.once('set', sid => {
102+
expect(sid).to.be('foo2')
103+
done()
104+
})
105+
store.set('foo2', {foo: 'bar'}, noop)
106+
})
107+
})
108+
109+
describe('set() with a session id associated to an existing session', () => {
110+
it('should emit an `update` event', done => {
111+
store.once('update', sid => {
112+
expect(sid).to.be('foo3')
113+
done()
114+
})
115+
collection.insertOne({_id: 'foo3', session: {foo: 'bar1'}, expires: futureDate}, err => {
116+
expect(err).not.to.be.ok()
117+
store.set('foo3', {foo: 'bar2'}, noop)
118+
})
119+
})
120+
it('should emit an `set` event', done => {
121+
store.once('update', sid => {
122+
expect(sid).to.be('foo4')
123+
done()
124+
})
125+
collection.insertOne({_id: 'foo4', session: {foo: 'bar1'}, expires: futureDate}, err => {
126+
expect(err).not.to.be.ok()
127+
store.set('foo4', {foo: 'bar2'}, noop)
128+
})
129+
})
130+
})
131+
})

0 commit comments

Comments
 (0)
Please sign in to comment.