Skip to content

Commit ac88f91

Browse files
committed
Implement encrypted attributes and tests
1 parent 0024e7f commit ac88f91

File tree

8 files changed

+2392
-2
lines changed

8 files changed

+2392
-2
lines changed

.travis.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
language: node_js
2+
node_js:
3+
- "8"

README.md

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,111 @@
1-
# node-encrypted-attr
2-
Encrypted model attribute implementation that can be easily plugged into your favourite ORM.
1+
# Encrypted Attributes
2+
3+
Encrypted model attribute implementation that can be easily plugged into your
4+
favourite ORM.
5+
6+
# Security model
7+
8+
* AES-256-GCM
9+
* 96-bit random nonce
10+
* 128-bit authentication tag
11+
* Additional authenticated data:
12+
* Key id, allowing use of different keys for different attributes, or
13+
rotating keys over time without re-encrypting all data
14+
* [*Optional*] Object id, allowing to detect substitutions of encrypted
15+
values
16+
17+
All keys should be 32 bytes long, and cryptographically random. Manage these
18+
keys as you would any other credentials (environment config, keychain, vault).
19+
20+
Best way to generate keys:
21+
```
22+
node -p "require('crypto').randomBytes(32).toString('base64')"
23+
```
24+
25+
# Threat model
26+
27+
This is designed to protect you from leaking sensitive user data under very
28+
specific scenarios:
29+
30+
* Full database dump
31+
* Misplaced unencrypted backups
32+
* Compromised database host
33+
* Partial database dump
34+
* Query injection via unsanitized input
35+
36+
Specifically, this does *not* provide any protection in cases of a compromised
37+
web app host, app-level vulnerabilities, or accidental leaks into persistent
38+
logs. It is also in no way a substitute for actually encrypting your backups,
39+
sanitizing all you input, et cetera.
40+
41+
# Install
42+
43+
```
44+
npm install node-encrypted-attr
45+
```
46+
47+
# Use
48+
49+
While this module can be used stand-alone to encrypt individual values (see
50+
[tests](/test/)), it is designed to be wrapped in a plugin or hook for your
51+
favourite ORM. Eventually, this package may include such plugins for common
52+
ORMs, but for now, here's an example of integrating with [thinky](https://github.com/neumino/thinky):
53+
54+
## Thinky
55+
56+
```
57+
const EncryptedAttributes = require('node-encrypted-attr')
58+
const thinky = require('thinky')()
59+
const _ = require('lodash')
60+
61+
let Model = thinky.createModel('Model', {})
62+
63+
Model.encryptedAttributes = EncryptedAttributes(['secret'], {
64+
keys: {
65+
k1: 'bocZRaBnmtHb2pXGTGixiQb9W2MmOtRBpbJn3ADX0cU='
66+
},
67+
keyId: 'k1'
68+
})
69+
70+
// Pre-save hook: encrypt any model attributes that need to be encrypted.
71+
Model.pre('save', function (next) {
72+
try {
73+
this.encryptedAttributes.encryptAll(obj)
74+
process.nextTick(next)
75+
} catch (err) {
76+
process.nextTick(next, err)
77+
}
78+
})
79+
80+
// Post-save hook: decrypt any model attributes that need to be decrypted.
81+
Model.post('save', function (next) {
82+
try {
83+
this.encryptedAttributes.decryptAll(obj)
84+
process.nextTick(next)
85+
} catch (err) {
86+
process.nextTick(next, err)
87+
}
88+
})
89+
90+
// Post-retrieve hook: ditto.
91+
Model.post('retrieve', function (next) {
92+
try {
93+
this.encryptedAttributes.decryptAll(obj)
94+
process.nextTick(next)
95+
} catch (err) {
96+
process.nextTick(next, err)
97+
}
98+
})
99+
100+
// Optionally, add some helper methods in case you need to set or read a value
101+
// directly, without going through model parser.
102+
for (let attr of Model.encryptedAttributes.attributes) {
103+
Mode.define(_.camelCase(`encrypted ${attr}`), function (val) {
104+
return Model.encryptedAttributes.encryptAttribute(this, val)
105+
}
106+
}
107+
```
108+
109+
# License
110+
111+
[MIT](LICENSE)

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('./lib/encrypted-attr')

lib/encrypted-attr.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict'
2+
3+
const crypto = require('crypto')
4+
const gcm = require('node-aes-gcm')
5+
const { get, set } = require('lodash')
6+
7+
function EncryptedAttributes (attributes, options) {
8+
options = options || {}
9+
10+
function encryptAttribute (obj, val) {
11+
// Encrypted attributes are prefixed with "aes-256-gcm$", the base64
12+
// encoding of which is "YWVzLTI1Ni1nY20k". Nulls are not encrypted.
13+
if (val == null || (typeof val === 'string' && val.startsWith('YWVzLTI1Ni1nY20k'))) {
14+
return val
15+
}
16+
if (typeof val !== 'string') {
17+
throw new Error('Encrypted attribute must be a string')
18+
}
19+
if (options.verifyId && !obj.id) {
20+
throw new Error('Cannot encrypt without \'id\' attribute')
21+
}
22+
// Recommended 96-bit nonce with AES-GCM.
23+
let iv = crypto.randomBytes(12)
24+
let aad = Buffer.from(
25+
`aes-256-gcm$${options.verifyId ? obj.id.toString() : ''}$${options.keyId}`)
26+
let key = Buffer.from(options.keys[options.keyId], 'base64')
27+
let result = gcm.encrypt(key, iv, Buffer.from(val), aad)
28+
return aad.toString('base64') + '$' +
29+
iv.toString('base64') + '$' +
30+
result.ciphertext.toString('base64') + '$' +
31+
result.auth_tag.toString('base64').slice(0, 22)
32+
}
33+
34+
function encryptAll (obj) {
35+
for (let attr of attributes) {
36+
set(obj, attr, encryptAttribute(obj, get(obj, attr)))
37+
}
38+
return obj
39+
}
40+
41+
function decryptAttribute (obj, val) {
42+
// Encrypted attributes are prefixed with "aes-256-gcm$", the base64
43+
// encoding of which is "YWVzLTI1Ni1nY20k". Nulls are not encrypted.
44+
if (typeof val !== 'string' || !val.startsWith('YWVzLTI1Ni1nY20k')) {
45+
return val
46+
}
47+
if (options.verifyId && !obj.id) {
48+
throw new Error('Cannot decrypt without \'id\' attribute')
49+
}
50+
let [aad, iv, payload, tag] = val.split('$').map((x) => Buffer.from(x, 'base64'))
51+
let [, id, keyId] = aad.toString().split('$')
52+
if (options.verifyId && (id !== obj.id.toString())) {
53+
throw new Error('Encrypted attribute has invalid id')
54+
}
55+
if (!options.keys[keyId]) {
56+
throw new Error('Encrypted attribute has invalid key id')
57+
}
58+
let key = Buffer.from(options.keys[keyId], 'base64')
59+
let result = gcm.decrypt(key, iv, payload, aad, tag)
60+
if (!result.auth_ok) {
61+
throw new Error('Encrypted attribute has invalid auth tag')
62+
}
63+
return result.plaintext.toString()
64+
}
65+
66+
function decryptAll (obj) {
67+
for (let attr of attributes) {
68+
set(obj, attr, decryptAttribute(obj, get(obj, attr)))
69+
}
70+
return obj
71+
}
72+
73+
return {
74+
attributes,
75+
options,
76+
encryptAttribute,
77+
encryptAll,
78+
decryptAttribute,
79+
decryptAll
80+
}
81+
}
82+
83+
module.exports = EncryptedAttributes

0 commit comments

Comments
 (0)