From 069a9d4aeffe3a53f3f9802abcce55e95e8a530a Mon Sep 17 00:00:00 2001 From: Ben Green Date: Thu, 1 Sep 2016 20:39:32 +0000 Subject: [PATCH 1/2] Add Node.js port, supporting Node.js >=4 --- PasswordStorage.js | 117 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- package.json | 32 +++++++++++++ tests/Test.js | 114 +++++++++++++++++++++++++++++++++++++++++++ tests/runtests.sh | 15 ++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 PasswordStorage.js create mode 100644 package.json create mode 100644 tests/Test.js diff --git a/PasswordStorage.js b/PasswordStorage.js new file mode 100644 index 0000000..34c6f6b --- /dev/null +++ b/PasswordStorage.js @@ -0,0 +1,117 @@ +"use strict"; +// Conforms to Node.js >= v4 +const crypto = require("crypto"); + +// These constants may be changed without breaking existing hashes. +const SALT_BYTE_SIZE = 24; +const HASH_BYTE_SIZE = 18; +const PBKDF2_ITERATIONS = 64000; +const DEFAULT_DIGEST = "sha1"; + +// These constants define the encoding and may not be changed. +const HASH_SECTIONS = 5; +const HASH_ALGORITHM_INDEX = 0; +const ITERATION_INDEX = 1; +const HASH_SIZE_INDEX = 2; +const SALT_INDEX = 3; +const PBKDF2_INDEX = 4; + +class PasswordStorage { + // @param password String Clear text password to be hashed + // @param digest String Hash algorithm to apply, enumerate with crypto.getHashes() + // Optional, default: "sha1" + static createHash(password, digest) { + digest = digest || DEFAULT_DIGEST; + return new Promise((resolve, reject) => { + crypto.randomBytes(SALT_BYTE_SIZE, (error, salt) => { + if (error) { + reject(error); + return; + } + crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_BYTE_SIZE, digest, + (error, hash) => { + if (error) + reject(error); + else + resolve([ + "sha1", + PBKDF2_ITERATIONS, + HASH_BYTE_SIZE, + salt.toString("base64"), + hash.toString("base64") + ].join(":")); + }); + }); + }); + } + static verifyPassword(password, correctHash) { + return new Promise((resolve, reject) => { + // Decode the hash into its parameters + const params = correctHash.split(":"); + if (params.length !== HASH_SECTIONS) + reject(new InvalidHashException( + "Fields are missing from the password hash.")); + + const digest = params[HASH_ALGORITHM_INDEX]; + if (crypto.getHashes().indexOf(digest) === -1) + reject(new CannotPerformOperationException( + "Unsupported hash type")); + + const iterations = parseInt(params[ITERATION_INDEX], 10); + if (isNaN(iterations)) + reject(new InvalidHashException( + "Could not parse the iteration count as an interger.")); + + if (iterations < 1) + reject(new InvalidHashException( + "Invalid number of iteration. Must be >= 1.")); + + const salt = initBuffer(params[SALT_INDEX]); + const hash = initBuffer(params[PBKDF2_INDEX]); + + const storedHashSize = parseInt(params[HASH_SIZE_INDEX], 10); + if (isNaN(storedHashSize)) + reject(new InvalidHashException( + "Could not parse the hash size as an interger.")); + if (storedHashSize !== hash.length) + reject(new InvalidHashException( + "Hash length doesn't match stored hash length." + hash.length)); + + // Compute the hash of the provided password, using the same salt, + // iteration count, and hash length + crypto.pbkdf2(initBuffer(password, 'utf8'), salt, iterations, storedHashSize, digest, + (error, testHash) => { + if (error) + reject(error); + else + // Compare the hashes in constant time. The password is correct if + // both hashes match. + resolve(slowEquals(hash, testHash)); + }); + }); + } +} + +function initBuffer(input, inputEncoding) { + inputEncoding = inputEncoding || 'base64'; + if(Buffer.from && Buffer.alloc && Buffer.allocUnsafe && Buffer.allocUnsafeSlow) + // Node.js >= 6 + return Buffer.from(input, inputEncoding); + else + // Node.js < 6 + return new Buffer(input, inputEncoding); +} + +function slowEquals(a, b) { + let diff = a.length ^ b.length; + for(let i = 0; i < a.length && i < b.length; i++) + diff |= a[i] ^ b[i]; + return diff === 0; +} + +class InvalidHashException extends Error {}; +class CannotPerformOperationException extends Error {}; + +exports.PasswordStorage = PasswordStorage; +exports.InvalidHashException = InvalidHashException; +exports.CannotPerformOperationException = CannotPerformOperationException; diff --git a/README.md b/README.md index dbbb5ab..f36320b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Secure Password Storage v2.0 [![Build Status](https://travis-ci.org/defuse/password-hashing.svg?branch=master)](https://travis-ci.org/defuse/password-hashing) This repository containes peer-reviewed libraries for password storage in PHP, -C#, Ruby, and Java. Passwords are "hashed" with PBKDF2 (64,000 iterations of +C#, Ruby, Java, and Node.js. Passwords are "hashed" with PBKDF2 (64,000 iterations of SHA1 by default) using a cryptographically-random salt. The implementations are compatible with each other, so you can, for instance, create a hash in PHP and then verify it in C#. diff --git a/package.json b/package.json new file mode 100644 index 0000000..79e2a23 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "secure-password-storage", + "version": "2.0.0", + "description": "This repository contains peer-reviewed libraries for password storage in PHP, C#, Ruby, Java, and Node.js. Passwords are \"hashed\" with PBKDF2 (64,000 iterations of SHA1 by default) using a cryptographically-random salt. The implementations are compatible with each other, so you can, for instance, create a hash in PHP and then verify it in C#.", + "main": "PasswordStorage.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "node tests/Test.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/defuse/password-hashing.git" + }, + "keywords": [ + "password", + "hash", + "secure", + "random", + "salt" + ], + "contributors": [ + "Taylor Hornby (https://github.com/defuse)", + "Ben Green (https://github.com/numtel)" + ], + "license": "BSD-2-Clause", + "bugs": { + "url": "https://github.com/defuse/password-hashing/issues" + }, + "homepage": "https://github.com/defuse/password-hashing#readme" +} diff --git a/tests/Test.js b/tests/Test.js new file mode 100644 index 0000000..bcfe558 --- /dev/null +++ b/tests/Test.js @@ -0,0 +1,114 @@ +"use strict"; +const assert = require('assert'); +const crypto = require('crypto'); +const execFile = require('child_process').execFile; +const sps = require('..'); + +Promise.all([ + (function truncatedHashTest() { + const testPassword = crypto.randomBytes(3).toString('hex'); + return sps.PasswordStorage.createHash(testPassword) + .then(hash => + sps.PasswordStorage.verifyPassword(testPassword, hash.slice(0, hash.length - 1))) + .then(accepted => assert(false, 'Should not have accepted password')) + .catch(reason => { + if (!(reason instanceof sps.InvalidHashException)) + throw reason; + }); + })(), + (function basicTests() { + const testPassword = crypto.randomBytes(3).toString('hex'); + const anotherPassword = crypto.randomBytes(3).toString('hex'); + + return Promise.all([ + sps.PasswordStorage.createHash(testPassword), + sps.PasswordStorage.createHash(testPassword), + ]).then(hashes => { + assert.notStrictEqual(hashes[0], hashes[1], 'Two hashes are equal'); + return Promise.all([ + sps.PasswordStorage.verifyPassword(anotherPassword, hashes[0]), + sps.PasswordStorage.verifyPassword(testPassword, hashes[0]) + ]); + }).then(accepted => { + assert.strictEqual(accepted[0], false, 'Wrong password accepted'); + assert.strictEqual(accepted[1], true, 'Good password not accepted'); + }); + })(), + (function testHashFunctionChecking() { + const testPassword = crypto.randomBytes(3).toString('hex'); + return sps.PasswordStorage.createHash(testPassword) + .then(hash => + sps.PasswordStorage.verifyPassword(testPassword, hash.replace(/^sha1/, 'md5'))) + .then(accepted => assert.strictEqual(accepted, false, + 'Should not have accepted password')); + })(), + (function testGoodHashInPhp() { + const testPassword = crypto.randomBytes(3).toString('hex'); + return sps.PasswordStorage.createHash(testPassword) + .then(hash => phpVerify(testPassword, hash)); + })(), + (function testBadHashInPhp() { + const testPassword = crypto.randomBytes(3).toString('hex'); + const errorOccurred = Symbol(); + return sps.PasswordStorage.createHash(testPassword) + .then(hash => phpVerify(testPassword, hash.slice(0, hash.length - 1))) + .catch(reason => { + // Swallow this error, it is expected + return errorOccurred; + }) + .then(result => assert.strictEqual(result, errorOccurred, + 'Should not have accepted password')); + })(), + (function testHashFromPhp() { + return phpHashMaker() + .then(pair => sps.PasswordStorage.verifyPassword(pair.password, pair.hash)) + .then(accepted => assert.strictEqual(accepted, true, + 'Should have accepted password')); + })(), + (function testHashFromPhpFailsWithWrongPassword() { + const testPassword = crypto.randomBytes(3).toString('hex'); + return phpHashMaker() + .then(pair => sps.PasswordStorage.verifyPassword(testPassword, pair.hash)) + .then(accepted => assert.strictEqual(accepted, false, + 'Should not have accepted password')); + })(), +]) +.then(results => { + // Test cases can be disabled by NOT immediately invoking their function + const testCount = results.filter(x=>typeof x !== 'function').length; + console.log(`✔ ${testCount} Passed`); +}) +.catch(reason => { + if(reason.name === 'AssertionError') + console.error('AssertionError:', + reason.actual, reason.operator, reason.expected); + + console.error(reason.stack); + process.exit(1); +}); + +function phpVerify(password, hash) { + return new Promise((resolve, reject) => { + execFile('php', [ 'tests/phpVerify.php', password, hash ], + (error, stdout, stderr) => { + if(error) reject(error); + else resolve(stdout); + }); + }); +} + +function phpHashMaker(password, hash) { + return new Promise((resolve, reject) => { + execFile('php', [ 'tests/phpHashMaker.php' ], + (error, stdout, stderr) => { + if(error) reject(error); + else { + const hashPair = stdout.trim().split(' '); + if (hashPair[1].length !== parseInt(hashPair[0], 10)) + reject(new Error('Unicode test is invalid')); + else + resolve({ password: hashPair[1], hash: hashPair[2] }); + } + }); + }); +} diff --git a/tests/runtests.sh b/tests/runtests.sh index 1d72f12..ddff64b 100755 --- a/tests/runtests.sh +++ b/tests/runtests.sh @@ -64,6 +64,21 @@ cd .. echo "---------------------------------------------" echo "" +echo "Node.js" +echo "---------------------------------------------" + +. $HOME/.nvm/nvm.sh +nvm install v4.3.2 +nvm use v4.3.2 +npm test +if [ $? -ne 0 ]; then + echo "FAIL." + exit 1 +fi + +echo "---------------------------------------------" +echo "" + echo "PHP<->Ruby Compatibility" echo "---------------------------------------------" ruby tests/testRubyPhpCompatibility.rb From c88f4c63f91a8bbb506952dc284d9826d3325fca Mon Sep 17 00:00:00 2001 From: Ben Green Date: Fri, 2 Sep 2016 00:34:00 +0000 Subject: [PATCH 2/2] Also test Node.js 6.1.0 --- tests/runtests.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/runtests.sh b/tests/runtests.sh index ddff64b..8a6f40d 100755 --- a/tests/runtests.sh +++ b/tests/runtests.sh @@ -76,6 +76,14 @@ if [ $? -ne 0 ]; then exit 1 fi +nvm install v6.1.0 +nvm use v6.1.0 +npm test +if [ $? -ne 0 ]; then + echo "FAIL." + exit 1 +fi + echo "---------------------------------------------" echo ""