emilbayes / secure-password

Making Password storage safer for all
ISC License
569 stars 22 forks source link

Migrate to WASM #30

Closed marcbachmann closed 2 years ago

marcbachmann commented 2 years ago

By now libsodium supports an official wasm build. Are you interested in getting a PR that completely replaces sodium-native with it?

One challenge would be async support without blocking the event loop. It most likely needs a worker thread for production systems.

marcbachmann commented 2 years ago

Just checked. Would need some api changes. E.g. .hashSync and .hash currently modify the input buffer This probably doesn't make much sense anymore with the libsodium wasm api.

marcbachmann commented 2 years ago

The wasm version is about 6 times slower with the same configs.

marcbachmann commented 2 years ago

My motivation is mainly that the native build is causing issues. I guess I'll try https://github.com/ranisalt/node-argon2 next. It has prebuilt bindings and is a bit better maintained than sodium-native.

In case anybody is interested, here's the wasm-based module using libsodium.js. I've used the wasm bindings directly without the libsodium-wrappers library to prevent .toString() calls on the buffers.

wasm.js ```js class AssertionError extends Error {} AssertionError.prototype.name = 'AssertionError' function assert (t, m) { if (t) return const err = new AssertionError(m) if (Error.captureStackTrace) Error.captureStackTrace(err, assert) throw err } const VALID = Symbol('VALID') const INVALID = Symbol('INVALID') const VALID_NEEDS_REHASH = Symbol('VALID_NEEDS_REHASH') const INVALID_UNRECOGNIZED_HASH = Symbol('INVALID_UNRECOGNIZED_HASH') const sodium = require('libsodium') let ready = new Promise((resolve, reject) => { sodium.ready .then(() => { if (sodium._sodium_init() !== 0) { throw new Error("libsodium was not correctly initialized.") } resolve() ready = undefined }) .catch(reject) }) async function SecurePassword (opts = {}) { if (ready) await ready const pwhash_STRBYTES = sodium._crypto_pwhash_strbytes() const pwhash_PASSWD_MIN = sodium._crypto_pwhash_passwd_min() const pwhash_PASSWD_MAX = sodium._crypto_pwhash_passwd_max() === -1 ? 4294967295 : sodium._crypto_pwhash_passwd_max() const pwhash_MEMLIMIT_MIN = 8192 || sodium._crypto_pwhash_memlimit_min() const pwhash_MEMLIMIT_MAX = 4294966272 || sodium._crypto_pwhash_memlimit_max() const pwhash_OPSLIMIT_MIN = 1 || sodium._crypto_pwhash_opslimit_min() const pwhash_OPSLIMIT_MAX = 4294967295 || sodium._crypto_pwhash_opslimit_max() const pwhash_MEMLIMIT_INTERACTIVE = sodium._crypto_pwhash_memlimit_interactive() const pwhash_OPSLIMIT_INTERACTIVE = sodium._crypto_pwhash_opslimit_interactive() SecurePassword.MEMLIMIT_DEFAULT = pwhash_MEMLIMIT_INTERACTIVE SecurePassword.OPSLIMIT_DEFAULT = pwhash_OPSLIMIT_INTERACTIVE SecurePassword.HASH_BYTES = pwhash_STRBYTES const memlimit = opts.memlimit == null ? pwhash_MEMLIMIT_INTERACTIVE : opts.memlimit const opslimit = opts.opslimit == null ? pwhash_OPSLIMIT_INTERACTIVE : opts.opslimit assert(memlimit >= pwhash_MEMLIMIT_MIN, 'opts.memlimit must be at least MEMLIMIT_MIN (' + pwhash_MEMLIMIT_MIN + ')') assert(memlimit <= pwhash_MEMLIMIT_MAX, 'opts.memlimit must be at most MEMLIMIT_MAX (' + pwhash_MEMLIMIT_MAX + ')') assert(opslimit >= pwhash_OPSLIMIT_MIN, 'opts.opslimit must be at least OPSLIMIT_MIN (' + pwhash_OPSLIMIT_MIN + ')') assert(opslimit <= pwhash_OPSLIMIT_MAX, 'opts.memlimit must be at most OPSLIMIT_MAX (' + pwhash_OPSLIMIT_MAX + ')') function _free (a, b) { if (a !== undefined) sodium._free(a) if (b !== undefined) sodium._free(b) return true } function crypto_pwhash_str (passwordBuf) { const hashAddr = sodium._malloc(pwhash_STRBYTES) const passwordAddr = sodium._malloc(passwordBuf.length) sodium.HEAPU8.set(passwordBuf, passwordAddr) const error = sodium._crypto_pwhash_str(hashAddr, passwordAddr, passwordBuf.length, 0, opslimit, 0, memlimit) | 0 if (error !== 0) throw _free(a, b) && new Error('Invalid usage of sodium._crypto_pwhash_str') const hashBuf = Buffer.alloc(pwhash_STRBYTES) hashBuf.write(sodium.UTF8ToString(hashAddr)) _free(hashAddr, passwordAddr) return hashBuf } function crypto_pwhash_str_verify (hashBuf, passwordBuf) { const hashAddr = sodium._malloc(pwhash_STRBYTES) const passwordAddr = sodium._malloc(passwordBuf.length) sodium.HEAPU8.set(hashBuf, hashAddr) sodium.HEAPU8.set(passwordBuf, passwordAddr) const result = sodium._crypto_pwhash_str_verify(hashAddr, passwordAddr, passwordBuf.length, 0) | 0 _free(hashAddr, passwordAddr) return result === 0 } function crypto_pwhash_str_needs_rehash (hashBuf) { const address = sodium._malloc(pwhash_STRBYTES) sodium.HEAPU8.set(hashBuf, address) const result = sodium._crypto_pwhash_str_needs_rehash(address, opslimit, 0, memlimit) | 0 sodium._free(address) return result !== 0 } async function hash (passwordBuf) { assert(passwordBuf instanceof Uint8Array, 'passwordBuf must be Buffer or Uint8Array') assert(passwordBuf.length >= pwhash_PASSWD_MIN, 'passwordBuf must be at least PASSWD_MIN (' + pwhash_PASSWD_MIN + ')') assert(passwordBuf.length < pwhash_PASSWD_MAX, 'passwordBuf must be shorter than PASSWD_MAX (' + pwhash_PASSWD_MAX + ')') return crypto_pwhash_str(passwordBuf) } async function verify (passwordBuf, hashBuf) { assert(passwordBuf instanceof Uint8Array, 'passwordBuf must be Buffer or Uint8Array') assert(passwordBuf.length >= pwhash_PASSWD_MIN, 'passwordBuf must be at least PASSWD_MIN (' + pwhash_PASSWD_MIN + ')') assert(passwordBuf.length < pwhash_PASSWD_MAX, 'passwordBuf must be shorter than PASSWD_MAX (' + pwhash_PASSWD_MAX + ')') assert(hashBuf instanceof Uint8Array, 'hashBuf must be Buffer or Uint8Array') assert(hashBuf.length === pwhash_STRBYTES, 'hashBuf must be STRBYTES (' + pwhash_STRBYTES + ')') if (recognizedAlgorithm(hashBuf) === false) return INVALID_UNRECOGNIZED_HASH if (crypto_pwhash_str_verify(hashBuf, passwordBuf) === false) return INVALID if (crypto_pwhash_str_needs_rehash(hashBuf)) return VALID_NEEDS_REHASH return VALID } function recognizedAlgorithm (hashBuf) { return hashBuf.indexOf('$argon2i$') > -1 || hashBuf.indexOf('$argon2id$') > -1 } return {hash, verify} } SecurePassword.INVALID_UNRECOGNIZED_HASH = INVALID_UNRECOGNIZED_HASH SecurePassword.INVALID = INVALID SecurePassword.VALID = VALID SecurePassword.VALID_NEEDS_REHASH = VALID_NEEDS_REHASH module.exports = SecurePassword ```
wasm.test.js ```js const test = require('tape') const securePassword = require('./wasm.js') const messages = { [securePassword.VALID]: 'valid', [securePassword.INVALID]: 'invalid', [securePassword.VALID_NEEDS_REHASH]: 'valid needs rehash', [securePassword.INVALID_UNRECOGNIZED_HASH]: 'invalid unrecognized hash' } function verifyStatus (assert, name, expected, actual) { if (expected === actual) return assert.ok(true, `'${name}' is ${messages[expected]}`) assert.ok(false, `'${name}' expected to be ${messages[expected]}, but was ${messages[actual]}`) } test('Can hash password', async function (assert) { const pwd = await securePassword() const userPassword = Buffer.from('my secrets') const passwordHash = await pwd.hash(userPassword) assert.notOk(userPassword.equals(passwordHash)) assert.end() }) test('Can hash password simultaneous', async function (assert) { assert.plan(2) const pwd = await securePassword({ memlimit: securePassword.MEMLIMIT_DEFAULT, opslimit: securePassword.OPSLIMIT_DEFAULT }) const userPassword = Buffer.from('my secrets') const [hash1, hash2] = await Promise.all([pwd.hash(userPassword), pwd.hash(userPassword)]) assert.notOk(userPassword.equals(hash1)) assert.notOk(userPassword.equals(hash2)) }) test('Can verify password (identity) using promises', async function (assert) { const pwd = await securePassword({ memlimit: securePassword.MEMLIMIT_DEFAULT, opslimit: securePassword.OPSLIMIT_DEFAULT }) const userPassword = Buffer.from('my secret') const passwordHash = await pwd.hash(userPassword) const bool = await pwd.verify(userPassword, passwordHash) assert.ok(bool === securePassword.VALID) assert.end() }) test('Needs rehash async', async function (assert) { assert.plan(7) const weakPwd = await securePassword({ memlimit: securePassword.MEMLIMIT_DEFAULT, opslimit: securePassword.OPSLIMIT_DEFAULT }) const betterPwd = await securePassword({ memlimit: securePassword.MEMLIMIT_DEFAULT + 1024, opslimit: securePassword.OPSLIMIT_DEFAULT + 1 }) const userPassword = Buffer.from('my secret') const wrongPassword = Buffer.from('my secret 2') const pass = Buffer.from('hello world') const empty = Buffer.from('') const argon2ipass = Buffer.from('JGFyZ29uMmkkdj0xOSRtPTMyNzY4LHQ9NCxwPTEkYnB2R2dVNjR1Q3h4TlF2aWYrd2Z3QSR3cXlWL1EvWi9UaDhVNUlaeEFBN0RWYjJVMWtLSG01VHhLOWE2QVlkOUlVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'base64') const argon2ipassempty = Buffer.from('JGFyZ29uMmkkdj0xOSRtPTMyNzY4LHQ9NCxwPTEkN3dZV0EvbjBHQjRpa3lwSWN5UVh6USRCbjd6TnNrcW03aWNwVGNjNGl6WC9xa0liNUZBQnZVNGw2MUVCaTVtaWFZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'base64') const weakHash = await weakPwd.hash(userPassword) const weakValid = await weakPwd.verify(userPassword, weakHash) verifyStatus(assert, 'weak valid', securePassword.VALID, weakValid) const weakInvalid = await weakPwd.verify(wrongPassword, weakHash) verifyStatus(assert, 'weak invalid', securePassword.INVALID, weakInvalid) const rehashValid = await betterPwd.verify(userPassword, weakHash) verifyStatus(assert, 'weak right', securePassword.VALID_NEEDS_REHASH, rehashValid) const rehashValidAlgo = await weakPwd.verify(pass, argon2ipass) verifyStatus(assert, 'weak argon2idpass right', securePassword.VALID_NEEDS_REHASH, rehashValidAlgo) const weakNotRight = await weakPwd.verify(empty, argon2ipassempty) verifyStatus(assert, 'weak argon2ipassempty right', securePassword.VALID_NEEDS_REHASH, weakNotRight) const betterHash = await betterPwd.hash(userPassword) const betterValid = await betterPwd.verify(userPassword, betterHash) verifyStatus(assert, 'better valid', securePassword.VALID, betterValid) const betterInvalid = await betterPwd.verify(wrongPassword, betterHash) verifyStatus(assert, 'better invalid', securePassword.INVALID, betterInvalid) }) test('Can handle invalid hash sync', async function (assert) { const pwd = await securePassword() const userPassword = Buffer.from('my secret') const invalidHash = Buffer.allocUnsafe(securePassword.HASH_BYTES) const unrecognizedHash = await pwd.verify(userPassword, invalidHash) verifyStatus(assert, 'unrecognized hash', securePassword.INVALID_UNRECOGNIZED_HASH, unrecognizedHash) assert.end() }) ```
marcbachmann commented 2 years ago

Some first version https://github.com/livingdocsIO/secure-password/blob/argon2/index.js

marcbachmann commented 2 years ago

@emilbayes if you're interested, I could do that PR against this repo. https://github.com/livingdocsIO/secure-password/pull/1

But it includes some breaking changes.