diff --git a/README.md b/README.md index f6b8370..c6ef2c6 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,24 @@ const Crypt = require('garbados-crypt') const crypt = new Crypt(password) ``` -### new Crypt(password) +### new Crypt(password, [salt, [opts]]) - `password`: A string. Make sure it's good! Or not. +- `salt`: A salt, either as a byte array or a string. If omitted, a random salt is generated. *Rather than bother carrying this with you, use `crypt.export()` and `Crypt.import()` to transport your credentials!* +- `opts`: Options! +- `opts.iterations`: The number of iterations to use to hash your password via [pbkdf2](https://en.wikipedia.org/wiki/PBKDF2). Defaults to 10,000. + +### async Crypt.import(password, exportString) => new Crypt + +Instantiates a new Crypt instance using an encoded string generated by `crypt.export()`. Use this method to import credentials generated on another device! + +- `password`: A string, the same string password you used with the Crypt instance that generated the `exportString`! +- `exportString`: A string generated by `crypt.export()`. + +### async crypt.export() => string + +Exports a string you can use to create a new Crypt instance with `Crypt.import()`. +*Rather than bother carrying around your password and salt, use this to transport credentials across devices!* ### async crypt.encrypt(plaintext) => ciphertext @@ -60,6 +75,8 @@ const crypt = new Crypt(password) If decryption fails, for example because your password is incorrect, an error will be thrown. +### async crypt. + ## Development First, get the source: diff --git a/index.js b/index.js index 59e4f95..f001bdc 100644 --- a/index.js +++ b/index.js @@ -1,16 +1,70 @@ const { secretbox, hash, randomBytes } = require('tweetnacl') const { decodeUTF8, encodeUTF8, encodeBase64, decodeBase64 } = require('tweetnacl-util') +const { pbkdf2, createSHA512 } = require('hash-wasm') const NO_PASSWORD = 'A password is required for encryption or decryption.' const COULD_NOT_DECRYPT = 'Could not decrypt!' +const SALT_LENGTH = 32 +const KEY_LENGTH = 32 +const ITERATIONS = 1e4 + module.exports = class Crypt { - constructor (password) { + static async deriveKey (password, salt, opts = {}) { + const iterations = opts.iterations || ITERATIONS + if (!salt) { salt = randomBytes(SALT_LENGTH) } + const key = await pbkdf2({ + password, + salt, + iterations, + hashLength: KEY_LENGTH, + hashFunction: createSHA512(), + outputType: 'binary' + }) + return { key, salt } + } + + static async import (password, exportString) { + // parse exportString: decodeBase64 => + const fullMessage = decodeBase64(exportString) + const tempSalt = fullMessage.slice(0, SALT_LENGTH) + const exportBytes = fullMessage.slice(SALT_LENGTH) + const exportEncrypted = encodeUTF8(exportBytes) + const tempCrypt = new Crypt(password, tempSalt) + const exportJson = await tempCrypt.decrypt(exportEncrypted) + const [saltString, opts] = JSON.parse(exportJson) + const salt = decodeBase64(saltString) + return new Crypt(password, salt, opts) + } + + constructor (password, salt, opts = {}) { if (!password) { throw new Error(NO_PASSWORD) } - this._key = hash(decodeUTF8(password)).slice(0, secretbox.keyLength) + this._raw_pass = password + this._pass = hash(decodeUTF8(password)) + this._opts = { iterations: opts.iterations || ITERATIONS } + this._setup = Crypt.deriveKey(this._pass, salt, this._opts) + .then(({ key, salt: newSalt }) => { + this._key = key + this._salt = salt || newSalt + }) + } + + async export () { + await this._setup + const tempCrypt = new Crypt(this._raw_pass) + await tempCrypt._setup + const saltString = encodeBase64(this._salt) + const exportJson = JSON.stringify([saltString, this._opts]) + const exportEncrypted = await tempCrypt.encrypt(exportJson) + const exportBytes = decodeUTF8(exportEncrypted) + const fullMessage = new Uint8Array(tempCrypt._salt.length + exportBytes.length) + fullMessage.set(tempCrypt._salt) + fullMessage.set(exportBytes, tempCrypt._salt.length) + return encodeBase64(fullMessage) } async encrypt (plaintext) { + await this._setup const nonce = randomBytes(secretbox.nonceLength) const messageUint8 = decodeUTF8(plaintext) const box = secretbox(messageUint8, nonce, this._key) @@ -22,6 +76,7 @@ module.exports = class Crypt { } async decrypt (messageWithNonce) { + await this._setup const messageWithNonceAsUint8Array = decodeBase64(messageWithNonce) const nonce = messageWithNonceAsUint8Array.slice(0, secretbox.nonceLength) const message = messageWithNonceAsUint8Array.slice(secretbox.nonceLength) diff --git a/package-lock.json b/package-lock.json index 5afc1d5..164e503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,11 @@ "requires": true, "packages": { "": { + "name": "garbados-crypt", "version": "1.0.2-alpha", "license": "Apache-2.0", "dependencies": { + "hash-wasm": "^4.9.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -4044,6 +4046,11 @@ "node": ">= 6" } }, + "node_modules/hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -13471,6 +13478,11 @@ } } }, + "hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, "hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", diff --git a/package.json b/package.json index f0d3bdb..162eb12 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "tinyify": "^3.0.0" }, "dependencies": { + "hash-wasm": "^4.9.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" } diff --git a/test.js b/test.js index 8951417..568d661 100644 --- a/test.js +++ b/test.js @@ -1,5 +1,6 @@ /* global describe, it */ const assert = require('assert').strict +const { encodeBase64 } = require('tweetnacl-util') const Crypt = require('.') const PLAINTEXT = 'hello world' @@ -7,6 +8,28 @@ const PASSWORD = 'password' const BENCHMARK = 1e4 // note: 1e4 = 1 and 4 zeroes (10,000) describe('crypt', function () { + it('should derive a key from a password', async function () { + let { key, salt } = await Crypt.deriveKey(PASSWORD) + key = encodeBase64(key) + assert.equal(typeof key, 'string') + let { key: key2 } = await Crypt.deriveKey(PASSWORD, salt) + key2 = encodeBase64(key2) + assert.equal(key, key2) + }) + + it('should require a password', function () { + let ok = false + try { + const crypt = new Crypt() + throw new Error(`crypt created: ${!!crypt}`) + } catch (error) { + if (error.message === 'A password is required for encryption or decryption.') { + ok = true + } + } + assert(ok) + }) + it('should do the crypto dance', async function () { const crypt = new Crypt(PASSWORD) const ciphertext = await crypt.encrypt(PLAINTEXT) @@ -29,7 +52,7 @@ describe('crypt', function () { }) it(`should do the crypto dance ${BENCHMARK} times`, async function () { - this.timeout(BENCHMARK) // assume each op will take no more than 1ms + this.timeout(BENCHMARK * 10) // assume each op will take no more than 10ms const crypt = new Crypt(PASSWORD) for (let i = 0; i < BENCHMARK; i++) { const ciphertext = await crypt.encrypt(PLAINTEXT) @@ -37,4 +60,19 @@ describe('crypt', function () { assert.strictEqual(decryptext, PLAINTEXT) } }) + + it('should export to a string', async function () { + const crypt = new Crypt(PASSWORD) + const exportString = await crypt.export() + assert.equal(typeof exportString, 'string') + }) + + it('should import from an export payload', async function () { + const crypt1 = new Crypt(PASSWORD) + const exportString = await crypt1.export() + const crypt2 = await Crypt.import(PASSWORD, exportString) + const encrypted = await crypt1.encrypt(PLAINTEXT) + const decrypted = await crypt2.decrypt(encrypted) + assert.equal(decrypted, PLAINTEXT) + }) })