Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: use webcrypto over tweetnacl #1

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
21 changes: 18 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [12, 14]
node: [12,14,16]
steps:
- uses: actions/checkout@v2

Expand All @@ -30,8 +30,11 @@ jobs:

- run: npm ci

- name: unit test
run: npm test
# install chromium manually
- run: node node_modules/puppeteer/install.js

- name: release testing
run: npm run release

- name: produce coverage report
run: npm run coveralls
Expand All @@ -41,3 +44,15 @@ jobs:
with:
github-token: ${{ github.token }}
path-to-lcov: ./lcov.info
flag-name: run-${{ matrix.os }}-${{ matrix.node }}
parallel: true
finish:
needs: tests
runs-on: ubuntu-latest
steps:
- name: Coveralls Finished
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
parallel-finished: true
path-to-lcov: ./lcov.info
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
.nyc_output/
bundle.js
bundle.js*
lcov.info
62 changes: 47 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
[![NPM Version](https://img.shields.io/npm/v/garbados-crypt.svg?style=flat-square)](https://www.npmjs.com/package/garbados-crypt)
[![JS Standard Style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard)

Easy password-based encryption, by [garbados](https://garbados.github.io/my-blog/).
[garbados]: https://garbados.github.io/my-blog/
[browserify]: https://www.npmjs.com/package/browserify
[webpack]: https://www.npmjs.com/package/webpack
[npm]: https://www.npmjs.com/

Easy password-based encryption, by [garbados][garbados].

This library attempts to reflect [informed opinions](https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html) while respecting realities like resource constraints, tech debt, and so on. The idea is to provide some very simple methods that just do the hard thing for you.

Expand All @@ -26,9 +31,21 @@ console.log(decrypted)

Crypt only works with plaintext, so remember to use `JSON.stringify()` on objects before encryption and `JSON.parse()` after decryption. For classes and the like, you'll need to choose your own encoding / decoding approach.

Crypt works in the browser, too! You can require it like this:

```html
<script src="https://raw.githubusercontent.com/garbados/crypt/master/bundle.min.js" charset="utf-8"></script>
<script type="text/javascript">
// now you can encrypt in the browser! 4.6kb!
const crypt = new Crypt('a very good password')
</script>
```

You can also require it with [browserify][browserify] or [webpack][webpack], of course, but there are some [caveats](#also-how-to-bundle-crypt) to doing so.

## Install

Use [npm](https://www.npmjs.com/) or whatever.
Use [npm][npm] or whatever.

```bash
$ npm i -S garbados-crypt
Expand Down Expand Up @@ -60,6 +77,34 @@ const crypt = new Crypt(password)

If decryption fails, for example because your password is incorrect, an error will be thrown.

## Also: How To Securely Store A Password

For a password-based encryption system, it makes sense to have a good reference on how to store passwords in a database. To this effect I have written [this gist](https://gist.github.com/garbados/29ca945d5964ef85e7936804c23edb9d#file-how_to_store_passwords-js) to demonstrate safe password obfuscation and verification. If you have any issue with the advice offered there, leave a comment!

## Also: How To Bundle Crypt

You probably use [browserify][browserify] or [webpack][webpack] to bundle your project together, by walking all your dependencies and transpiling them for browser environments. Some dependencies, like NodeJS [Crypto](https://nodejs.org/api/crypto.html), are replaced by complex shims like [crypto-browserify](https://github.com/crypto-browserify/crypto-browserify/) that aren't needed in [modern browsers](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto). These shims weigh in at more than half a meg when uncompressed, which renders Crypt a very heavy library for use in the browser -- especially when all those shims are never run in the browser!

To prevent your bundler from bundling crypto-browserify, you'll have to modify its invocation. For browserify, you can use the `-x crypto` option to tell browserify not to process any `require('crypto')` calls, since we can safely assume they will only be run in a non-browser environment.

```bash
$ browserify -x crypto index.js -o bundle.js
```

In webpack, use the [externals](https://webpack.js.org/configuration/externals/) configuration option to achieve the same effect:

```javascript
// webpack.config.js
module.exports = {
//...
externals: {
crypto: 'crypto',
},
}
```

In the end, Crypt weighs in at around 4.6kb -- not bad for native crypto! But not all browsers have the [necessary primitives](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), and in order to support them you *will* want to bundle crypto-browserify. This will cause Crypt's bundle to weigh about half a megabyte, but if you need that browser support, that's your option.

## Development

First, get the source:
Expand All @@ -83,19 +128,6 @@ To see test coverage:
```bash
$ npm run cov
```

## Also: How To Securely Store A Password

For a password-based encryption system, it makes sense to have a good reference on how to store passwords in a database. To this effect I have written [this gist](https://gist.github.com/garbados/29ca945d5964ef85e7936804c23edb9d#file-how_to_store_passwords-js) to demonstrate safe password obfuscation and verification. If you have any issue with the advice offered there, leave a comment!

## Why TweetNaCl.js?

This library uses [tweetnacl](https://www.npmjs.com/package/tweetnacl) rather than native crypto. You might have feelings about this.

I chose it because it's fast on NodeJS, bundles conveniently (33kb!), uses top-shelf algorithms, and has undergone a [reasonable audit](https://www.npmjs.com/package/tweetnacl#audits).

That said, I'm open to PRs that replace it with native crypto while retaining Crypt's API.

## License

[Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0)
1 change: 1 addition & 0 deletions bundle.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

218 changes: 194 additions & 24 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,205 @@
const { secretbox, hash, randomBytes } = require('tweetnacl')
const { decodeUTF8, encodeUTF8, encodeBase64, decodeBase64 } = require('tweetnacl-util')
const { encodeBase64, decodeBase64 } = require('tweetnacl-util')

/* CONSTANTS */

const NO_PASSWORD = 'A password is required for encryption or decryption.'
const COULD_NOT_DECRYPT = 'Could not decrypt!'
const NO_PASSWORD = 'A password is required for encryption or decryption.'

const ITERATIONS = 1e4
const IV_LENGTH = 16
const SALT_LENGTH = 16

/* UTILS */

// string -> buffer
function encodeUTF8 (string) {
return new TextEncoder('utf8').encode(string)
}

// buffer -> string
function decodeUTF8 (buffer) {
return new TextDecoder('utf8').decode(buffer)
}

// convenient promise -> callback
function cbify (resolve, reject) {
return (err, result) => {
if (err) { reject(err) } else { resolve(result) }
}
}

// check an error message against failure messages
// to determine if a decryption failure occurred
function checkDecrypt (error, ...failMessages) {
if (failMessages.includes(error.message)) {
throw new Error(COULD_NOT_DECRYPT)
} else {
throw error
}
}

/* BOOTSTRAP CRYPTO PRIMITIVES */

let crypto, browserCrypto
// check window, if you're in a browser
try { browserCrypto = (window && window.crypto) } catch {}
// check for global, if we're in a web worker
if (!browserCrypto) try { browserCrypto = (global && global.crypto) } catch {}
// lastly try using node crypto
if (!browserCrypto) { browserCrypto = require('crypto').webcrypto }
if (browserCrypto) {
// finally, parse it for the basics we require
const { subtle, getRandomValues } = browserCrypto

// browser-specific constants
const HASH_ALGO = 'SHA-256'
const KEY_ALGO = 'AES-GCM'
const PASS_ALGO = 'PBKDF2'
const KEY_LENGTH = 256
const DECRYPT_FAIL = 'Cipher job failed'

// very random number generator
async function randomBytes (n) {
const buf = new Uint8Array(n)
return getRandomValues.call(browserCrypto, buf)
}

// derive op-specific key from another key and a salt
async function deriveKey (key, salt) {
return subtle.deriveKey({
name: PASS_ALGO,
salt,
iterations: ITERATIONS,
hash: HASH_ALGO
}, key, {
name: KEY_ALGO,
length: KEY_LENGTH
}, true, ['encrypt', 'decrypt'])
}

crypto = {
// derive an intermediate key from a password
getKeyFromPassword: async function (password) {
return subtle.importKey(
'raw',
encodeUTF8(password),
PASS_ALGO,
false,
['deriveKey'])
},
// given a key and plaintext, return an encrypted buffer
encrypt: async function (key, plaintext) {
const iv = await randomBytes(IV_LENGTH)
const salt = await randomBytes(SALT_LENGTH)
const derivedKey = await deriveKey(key, salt)
const encoded = encodeUTF8(plaintext)
const opts = { name: KEY_ALGO, iv }
const ciphertext = await subtle.encrypt(opts, derivedKey, encoded)
const fullMessage = new Uint8Array(iv.length + salt.length + ciphertext.byteLength)
fullMessage.set(iv)
fullMessage.set(salt, iv.length)
fullMessage.set(new Uint8Array(ciphertext), iv.length + salt.length)
return fullMessage
},
// given a key and an encrypted buffer, return plaintext
decrypt: async function (key, fullMessage) {
const iv = fullMessage.slice(0, IV_LENGTH)
const salt = fullMessage.slice(IV_LENGTH, IV_LENGTH + SALT_LENGTH)
const ciphertext = fullMessage.slice(IV_LENGTH + SALT_LENGTH)
const derivedKey = await deriveKey(key, salt)
const opts = { name: KEY_ALGO, iv }
try {
const plaintext = await subtle.decrypt(opts, derivedKey, ciphertext)
return plaintext
} catch (error) {
// error message is empty in browser but not in node
checkDecrypt(error, DECRYPT_FAIL, '')
}
}
}
} else {
// fallback to node not-web crypto
const nodeCrypto = require('crypto')

// node-specific constants
const HASH_ALGO = 'sha256'
const KEY_ALGO = 'AES-256-GCM'
const KEY_LENGTH = 32
const TAG_LENGTH = 16
const DECRYPT_FAIL = 'Unsupported state or unable to authenticate data'

async function randomBytes (n) {
return new Promise((resolve, reject) => {
const cb = cbify(resolve, reject)
nodeCrypto.randomBytes(n, cb)
})
}

async function pbkdf2 (password, salt) {
return new Promise((resolve, reject) => {
const cb = cbify(resolve, reject)
nodeCrypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, HASH_ALGO, cb)
})
}

crypto = {
getKeyFromPassword: async function (password) {
return (salt) => {
return pbkdf2(password, salt)
}
},
encrypt: async function (deriveKey, plaintext) {
const iv = await randomBytes(IV_LENGTH)
const salt = await randomBytes(SALT_LENGTH)
const derivedKey = await deriveKey(salt)
const cipher = nodeCrypto.createCipheriv(KEY_ALGO, derivedKey, iv)
const ciphertext = Buffer.concat([
cipher.update(plaintext),
cipher.final()
])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, salt, ciphertext, tag])
},
decrypt: async function (deriveKey, buffer) {
const iv = buffer.slice(0, IV_LENGTH)
const salt = buffer.slice(IV_LENGTH, IV_LENGTH + SALT_LENGTH)
const ciphertext = buffer.slice(IV_LENGTH + SALT_LENGTH, buffer.length - TAG_LENGTH)
const tag = buffer.slice(buffer.length - TAG_LENGTH)
const derivedKey = await deriveKey(salt)
try {
const cipher = nodeCrypto.createDecipheriv(KEY_ALGO, derivedKey, iv)
cipher.setAuthTag(tag)
return Buffer.concat([
cipher.update(ciphertext),
cipher.final()
])
} catch (error) {
checkDecrypt(error, DECRYPT_FAIL)
}
}
}
}

/* AND NOW, THE CRYPT! */

module.exports = class Crypt {
constructor (password) {
if (!password) { throw new Error(NO_PASSWORD) }
this._key = hash(decodeUTF8(password)).slice(0, secretbox.keyLength)
this._pending = crypto.getKeyFromPassword(password).then((key) => {
this._key = key
})
}

async encrypt (plaintext) {
const nonce = randomBytes(secretbox.nonceLength)
const messageUint8 = decodeUTF8(plaintext)
const box = secretbox(messageUint8, nonce, this._key)
const fullMessage = new Uint8Array(nonce.length + box.length)
fullMessage.set(nonce)
fullMessage.set(box, nonce.length)
const base64FullMessage = encodeBase64(fullMessage)
return base64FullMessage
}

async decrypt (messageWithNonce) {
const messageWithNonceAsUint8Array = decodeBase64(messageWithNonce)
const nonce = messageWithNonceAsUint8Array.slice(0, secretbox.nonceLength)
const message = messageWithNonceAsUint8Array.slice(secretbox.nonceLength)
const decrypted = secretbox.open(message, nonce, this._key)
if (!decrypted) {
throw new Error(COULD_NOT_DECRYPT)
} else {
return encodeUTF8(decrypted)
}
await this._pending
const buffer = await crypto.encrypt(this._key, plaintext)
return encodeBase64(buffer)
}

async decrypt (encrypted) {
await this._pending
const buffer = decodeBase64(encrypted)
const encodedPlaintext = await crypto.decrypt(this._key, buffer)
return decodeUTF8(encodedPlaintext)
}
}
Loading