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

use pbkdf2 at instantiation #4

Merged
merged 6 commits into from
Jul 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
59 changes: 57 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"tinyify": "^3.0.0"
},
"dependencies": {
"hash-wasm": "^4.9.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
}
Expand Down
40 changes: 39 additions & 1 deletion test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
/* global describe, it */
const assert = require('assert').strict
const { encodeBase64 } = require('tweetnacl-util')
const Crypt = require('.')

const PLAINTEXT = 'hello world'
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)
Expand All @@ -29,12 +52,27 @@ 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)
const decryptext = await crypt.decrypt(ciphertext)
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)
})
})