Skip to content

Commit

Permalink
Add Node.js port, supporting Node.js >=4
Browse files Browse the repository at this point in the history
  • Loading branch information
numtel committed Sep 2, 2016
1 parent ef0f347 commit e5c6e99
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 1 deletion.
117 changes: 117 additions & 0 deletions PasswordStorage.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#.
Expand Down
32 changes: 32 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]> (https://github.com/defuse)",
"Ben Green <[email protected]> (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"
}
114 changes: 114 additions & 0 deletions tests/Test.js
Original file line number Diff line number Diff line change
@@ -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] });
}
});
});
}
17 changes: 17 additions & 0 deletions tests/runtests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ cd ..
echo "---------------------------------------------"
echo ""

echo "Node.js"
echo "---------------------------------------------"

. $HOME/.nvm/nvm.sh
nvm install v4.3.2
nvm use v4.3.2
node -v
openssl version
node test1.js
if [ $? -ne 0 ]; then
echo "FAIL."
exit 1
fi

echo "---------------------------------------------"
echo ""

echo "PHP<->Ruby Compatibility"
echo "---------------------------------------------"
ruby tests/testRubyPhpCompatibility.rb
Expand Down

0 comments on commit e5c6e99

Please sign in to comment.