diff --git a/README.md b/README.md index 41c259e..abd7a26 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - State of the art password hashing algorithm (Argon2i) - Safe defaults for most applications +- Transparent queuing to prevent resource exhaustion - Future-proof so work factors and hashing algorithms can be easily upgraded - `Buffers` everywhere for safer memory management @@ -57,19 +58,31 @@ following keys: // Initialise our password policy (these are the defaults) var pwd = securePassword({ memlimit: securePassword.MEMLIMIT_DEFAULT, - opslimit: securePassword.OPSLIMIT_DEFAULT + opslimit: securePassword.OPSLIMIT_DEFAULT, + parallel: 4 }) ``` -They're both constrained by the constants `SecurePassword.MEMLIMIT_MIN` - - `SecurePassword.MEMLIMIT_MAX` and -`SecurePassword.OPSLIMIT_MIN` - `SecurePassword.OPSLIMIT_MAX`. If not provided -they will be given the default values `SecurePassword.MEMLIMIT_DEFAULT` and -`SecurePassword.OPSLIMIT_DEFAULT` which should be fast enough for a general -purpose web server without your users noticing too much of a load time. However -your should set these as high as possible to make any kind of cracking as costly -as possible. A load time of 1s seems reasonable for login, so test various -settings in your production environment. +* `opts.memlimit` controls how many bytes can be used for each password. It must + be a value between `SecurePassword.MEMLIMIT_MIN` - `SecurePassword.MEMLIMIT_MAX` +* `opts.opslimit` can be viewed as how many passes are done over the memory. It + must be a value between `SecurePassword.OPSLIMIT_MIN` - + `SecurePassword.OPSLIMIT_MAX`. +* `opts.parallel` controls how many simultaneous calls to `hash` or `verify` can + be run, as to prevent your server from running out of memory. + + It will default to `4`, which is the current number of worker threads available + to Node.js. If you increase this value, you should also set `UV_THREADPOOL_SIZE` + to the same value or more, as this environment variable determines the number + of workers available to Node.js. This is done at startup, like this: + + ```sh + UV_THREADPOOL_SIZE=8 node index.js + ``` + +All these options should be set as high as you can afford. Take into account the +resources you have available on your production machines, and adjust +accordingly. The settings can be easily increased at a later time as hardware most likely improves (Moore's law) and adversaries therefore get more powerful. If a hash is @@ -79,7 +92,7 @@ according to the updated policy. In contrast to other modules, this module will not increase these settings automatically as this can have ill effects on services that are not carefully monitored. -### `pwd.hash(password, function (err, hash) {})` +### `var cancel = pwd.hash(password, function (err, hash) {})` Takes Buffer `password` and hashes it. You can call `cancel` to abort the hashing. @@ -89,6 +102,8 @@ potential error, or the Buffer `hash`. * `password` must be a Buffer of length `SecurePassword.PASSWORD_BYTES_MIN` - `SecurePassword.PASSWORD_BYTES_MAX`. * `hash` will be a Buffer of length `SecurePassword.HASH_BYTES`. +* `cancel` is a method that will abort the hashing, if it has not yet started, +and invoke `cb` with and error that has `err.cancelled === true` ### `var hash = pwd.hashSync(password)` @@ -100,7 +115,7 @@ the Buffer `hash`. `password` must be a Buffer of length `SecurePassword.PASSWORD_BYTES_MIN` - `SecurePassword.PASSWORD_BYTES_MAX`. `hash` will be a Buffer of length `SecurePassword.HASH_BYTES`. -### `pwd.verify(password, hash, function (err, enum) {})` +### `var cancel = pwd.verify(password, hash, function (err, enum) {})` Takes Buffer `password` and hashes it and then safely compares it to the Buffer `hash`. The hashing is done by a seperate worker as to not block the @@ -126,6 +141,15 @@ The function may `throw` a potential error, or return one of the enums `SecurePassword.VALID`, `SecurePassword.INVALID`, `SecurePassword.NEEDS_REHASH` or `SecurePassword.INVALID_UNRECOGNIZED_HASH`. Check with strict equality for one the cases as in the example above. +### `pwd.pending` + +Number of hash / verify tasks pending. + +### `pwd.parallel` + +Number of hash / verify tasks that can be processed simultaneously. Can be set +through the constructor, but is **read-only**. + ### `SecurePassword.VALID` The password was verified and is valid diff --git a/index.js b/index.js index 17bb96d..5950bb4 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ var sodium = require('sodium-universal') var assert = require('nanoassert') +var parallelQueue = require('parallel-queue') module.exports = SecurePassword SecurePassword.HASH_BYTES = sodium.crypto_pwhash_STRBYTES @@ -35,8 +36,21 @@ function SecurePassword (opts) { assert(this.opslimit >= SecurePassword.OPSLIMIT_MIN, 'opts.opslimit must be at least OPSLIMIT_MIN (' + SecurePassword.OPSLIMIT_MIN + ')') assert(this.opslimit <= SecurePassword.OPSLIMIT_MAX, 'opts.memlimit must be at most OPSLIMIT_MAX (' + SecurePassword.OPSLIMIT_MAX + ')') + + if (opts.parallel == null) this.parallel = 4 // Default UV_THREADPOOL_SIZE + else this.parallel = opts.parallel + + assert(this.parallel > 0, 'opts.parallel must be at least 1') + + this._queue = parallelQueue(this.parallel, this._process.bind(this)) } +Object.defineProperty(SecurePassword.prototype, 'pending', { + get: function () { + return this._queue.pending + } +}) + SecurePassword.prototype.hashSync = function (passwordBuf) { assert(Buffer.isBuffer(passwordBuf), 'passwordBuf must be Buffer') assert(passwordBuf.length >= SecurePassword.PASSWORD_BYTES_MIN, 'passwordBuf must be at least PASSWORD_BYTES_MIN (' + SecurePassword.PASSWORD_BYTES_MIN + ')') @@ -58,6 +72,10 @@ SecurePassword.prototype.hash = function (passwordBuf, cb) { assert(passwordBuf.length < SecurePassword.PASSWORD_BYTES_MAX, 'passwordBuf must be shorter than PASSWORD_BYTES_MAX (' + SecurePassword.PASSWORD_BYTES_MAX + ')') assert(typeof cb === 'function', 'cb must be function') + return this._queue.push({type: 'hash', passwordBuf: passwordBuf}, cb) +} + +SecurePassword.prototype._hash = function (passwordBuf, cb) { // Unsafe is okay here since sodium will overwrite all bytes var hashBuf = Buffer.allocUnsafe(SecurePassword.HASH_BYTES) sodium.crypto_pwhash_str_async(hashBuf, passwordBuf, this.opslimit, this.memlimit, function (err) { @@ -98,6 +116,12 @@ SecurePassword.prototype.verify = function (passwordBuf, hashBuf, cb) { assert(Buffer.isBuffer(hashBuf), 'hashBuf must be Buffer') assert(hashBuf.length === SecurePassword.HASH_BYTES, 'hashBuf must be HASH_BYTES (' + SecurePassword.HASH_BYTES + ')') + return this._queue.push({type: 'verify', passwordBuf: passwordBuf, hashBuf: hashBuf}, cb) +} + +SecurePassword.prototype._verify = function (passwordBuf, hashBuf, cb) { + var self = this + var parameters = decodeArgon2iStr(hashBuf) if (parameters === false) return process.nextTick(cb, null, SecurePassword.INVALID_UNRECOGNIZED_HASH) @@ -106,12 +130,20 @@ SecurePassword.prototype.verify = function (passwordBuf, hashBuf, cb) { if (bool === false) return cb(null, SecurePassword.INVALID) - if (parameters.memlimit < this.memlimit || parameters.opslimit < this.opslimit) { + if (parameters.memlimit < self.memlimit || parameters.opslimit < self.opslimit) { return cb(null, SecurePassword.VALID_NEEDS_REHASH) } return cb(null, SecurePassword.VALID) - }.bind(this)) + }) +} + +SecurePassword.prototype._process = function (args, done) { + switch (args.type) { + case 'verify': return this._verify(args.passwordBuf, args.hashBuf, done) + case 'hash': return this._hash(args.passwordBuf, done) + default: throw new Error('Illegal method type') + } } var Argon2iStr_ALG_TAG = Buffer.from('$argon2i') diff --git a/package.json b/package.json index 019e0b0..5040b4f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "dependencies": { "nanoassert": "^1.0.0", + "parallel-queue": "^1.0.0", "sodium-universal": "^1.0.0" }, "devDependencies": { diff --git a/test.js b/test.js index 8609840..9f6c021 100644 --- a/test.js +++ b/test.js @@ -222,3 +222,86 @@ test('Can handle invalid hash async', function (assert) { assert.end() }) }) + +test('Can hash password async simultanious queued', function (assert) { + var pwd = securePassword({ + version: 0, + memlimit: securePassword.MEMLIMIT_DEFAULT, + opslimit: securePassword.OPSLIMIT_DEFAULT, + parallel: 1 + }) + + var userPassword = Buffer.from('my secrets') + + var completed = 0 + pwd.hash(userPassword, function (err, passwordHash) { + assert.error(err) + assert.equal(completed, 0) + assert.equal(pwd.pending, 1) + assert.notOk(userPassword.equals(passwordHash)) + + completed++ + }) + + pwd.hash(userPassword, function (err, passwordHash) { + assert.error(err) + assert.equal(completed, 1) + assert.equal(pwd.pending, 0) + assert.notOk(userPassword.equals(passwordHash)) + assert.end() + }) +}) + +test('Can cancel queued hash', function (assert) { + assert.plan(3) + var pwd = securePassword({ + version: 0, + memlimit: securePassword.MEMLIMIT_DEFAULT, + opslimit: securePassword.OPSLIMIT_DEFAULT, + parallel: 1 + }) + + var userPassword = Buffer.from('my secrets') + + pwd.hash(userPassword, function (err, passwordHash) { + assert.error(err, 'err') + assert.notOk(userPassword.equals(passwordHash), 'did hash') + }) + + // Cancel now + setImmediate(pwd.hash(userPassword, function (err, passwordHash) { + assert.ok(err, 'has err') + })) +}) + +test('Can interleave cancel queued hash', function (assert) { + assert.plan(6) + var pwd = securePassword({ + version: 0, + memlimit: securePassword.MEMLIMIT_DEFAULT, + opslimit: securePassword.OPSLIMIT_DEFAULT, + parallel: 1 + }) + + var userPassword = Buffer.from('my secrets') + + pwd.hash(userPassword, function (err, passwordHash) { + assert.error(err, 'err') + assert.notOk(userPassword.equals(passwordHash), 'did hash') + + pwd.verify(userPassword, passwordHash, function (err, res) { + assert.error(err) + assert.ok(res === securePassword.VALID) + }) + + // Cancel now + setImmediate(pwd.verify(userPassword, passwordHash, function (err, res) { + assert.ok(err, 'has err') + })) + }) + + // Cancel now + setImmediate(pwd.hash(userPassword, function (err, passwordHash) { + assert.ok(err, 'has err') + })) +})