Skip to content

Commit e5c6e99

Browse files
committed
Add Node.js port, supporting Node.js >=4
1 parent ef0f347 commit e5c6e99

File tree

5 files changed

+281
-1
lines changed

5 files changed

+281
-1
lines changed

PasswordStorage.js

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"use strict";
2+
// Conforms to Node.js >= v4
3+
const crypto = require("crypto");
4+
5+
// These constants may be changed without breaking existing hashes.
6+
const SALT_BYTE_SIZE = 24;
7+
const HASH_BYTE_SIZE = 18;
8+
const PBKDF2_ITERATIONS = 64000;
9+
const DEFAULT_DIGEST = "sha1";
10+
11+
// These constants define the encoding and may not be changed.
12+
const HASH_SECTIONS = 5;
13+
const HASH_ALGORITHM_INDEX = 0;
14+
const ITERATION_INDEX = 1;
15+
const HASH_SIZE_INDEX = 2;
16+
const SALT_INDEX = 3;
17+
const PBKDF2_INDEX = 4;
18+
19+
class PasswordStorage {
20+
// @param password String Clear text password to be hashed
21+
// @param digest String Hash algorithm to apply, enumerate with crypto.getHashes()
22+
// Optional, default: "sha1"
23+
static createHash(password, digest) {
24+
digest = digest || DEFAULT_DIGEST;
25+
return new Promise((resolve, reject) => {
26+
crypto.randomBytes(SALT_BYTE_SIZE, (error, salt) => {
27+
if (error) {
28+
reject(error);
29+
return;
30+
}
31+
crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_BYTE_SIZE, digest,
32+
(error, hash) => {
33+
if (error)
34+
reject(error);
35+
else
36+
resolve([
37+
"sha1",
38+
PBKDF2_ITERATIONS,
39+
HASH_BYTE_SIZE,
40+
salt.toString("base64"),
41+
hash.toString("base64")
42+
].join(":"));
43+
});
44+
});
45+
});
46+
}
47+
static verifyPassword(password, correctHash) {
48+
return new Promise((resolve, reject) => {
49+
// Decode the hash into its parameters
50+
const params = correctHash.split(":");
51+
if (params.length !== HASH_SECTIONS)
52+
reject(new InvalidHashException(
53+
"Fields are missing from the password hash."));
54+
55+
const digest = params[HASH_ALGORITHM_INDEX];
56+
if (crypto.getHashes().indexOf(digest) === -1)
57+
reject(new CannotPerformOperationException(
58+
"Unsupported hash type"));
59+
60+
const iterations = parseInt(params[ITERATION_INDEX], 10);
61+
if (isNaN(iterations))
62+
reject(new InvalidHashException(
63+
"Could not parse the iteration count as an interger."));
64+
65+
if (iterations < 1)
66+
reject(new InvalidHashException(
67+
"Invalid number of iteration. Must be >= 1."));
68+
69+
const salt = initBuffer(params[SALT_INDEX]);
70+
const hash = initBuffer(params[PBKDF2_INDEX]);
71+
72+
const storedHashSize = parseInt(params[HASH_SIZE_INDEX], 10);
73+
if (isNaN(storedHashSize))
74+
reject(new InvalidHashException(
75+
"Could not parse the hash size as an interger."));
76+
if (storedHashSize !== hash.length)
77+
reject(new InvalidHashException(
78+
"Hash length doesn't match stored hash length." + hash.length));
79+
80+
// Compute the hash of the provided password, using the same salt,
81+
// iteration count, and hash length
82+
crypto.pbkdf2(initBuffer(password, 'utf8'), salt, iterations, storedHashSize, digest,
83+
(error, testHash) => {
84+
if (error)
85+
reject(error);
86+
else
87+
// Compare the hashes in constant time. The password is correct if
88+
// both hashes match.
89+
resolve(slowEquals(hash, testHash));
90+
});
91+
});
92+
}
93+
}
94+
95+
function initBuffer(input, inputEncoding) {
96+
inputEncoding = inputEncoding || 'base64';
97+
if(Buffer.from && Buffer.alloc && Buffer.allocUnsafe && Buffer.allocUnsafeSlow)
98+
// Node.js >= 6
99+
return Buffer.from(input, inputEncoding);
100+
else
101+
// Node.js < 6
102+
return new Buffer(input, inputEncoding);
103+
}
104+
105+
function slowEquals(a, b) {
106+
let diff = a.length ^ b.length;
107+
for(let i = 0; i < a.length && i < b.length; i++)
108+
diff |= a[i] ^ b[i];
109+
return diff === 0;
110+
}
111+
112+
class InvalidHashException extends Error {};
113+
class CannotPerformOperationException extends Error {};
114+
115+
exports.PasswordStorage = PasswordStorage;
116+
exports.InvalidHashException = InvalidHashException;
117+
exports.CannotPerformOperationException = CannotPerformOperationException;

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Secure Password Storage v2.0
44
[![Build Status](https://travis-ci.org/defuse/password-hashing.svg?branch=master)](https://travis-ci.org/defuse/password-hashing)
55

66
This repository containes peer-reviewed libraries for password storage in PHP,
7-
C#, Ruby, and Java. Passwords are "hashed" with PBKDF2 (64,000 iterations of
7+
C#, Ruby, Java, and Node.js. Passwords are "hashed" with PBKDF2 (64,000 iterations of
88
SHA1 by default) using a cryptographically-random salt. The implementations are
99
compatible with each other, so you can, for instance, create a hash in PHP and
1010
then verify it in C#.

package.json

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "secure-password-storage",
3+
"version": "2.0.0",
4+
"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#.",
5+
"main": "PasswordStorage.js",
6+
"directories": {
7+
"test": "tests"
8+
},
9+
"scripts": {
10+
"test": "node tests/Test.js"
11+
},
12+
"repository": {
13+
"type": "git",
14+
"url": "git+https://github.com/defuse/password-hashing.git"
15+
},
16+
"keywords": [
17+
"password",
18+
"hash",
19+
"secure",
20+
"random",
21+
"salt"
22+
],
23+
"contributors": [
24+
"Taylor Hornby <[email protected]> (https://github.com/defuse)",
25+
"Ben Green <[email protected]> (https://github.com/numtel)"
26+
],
27+
"license": "BSD-2-Clause",
28+
"bugs": {
29+
"url": "https://github.com/defuse/password-hashing/issues"
30+
},
31+
"homepage": "https://github.com/defuse/password-hashing#readme"
32+
}

tests/Test.js

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"use strict";
2+
const assert = require('assert');
3+
const crypto = require('crypto');
4+
const execFile = require('child_process').execFile;
5+
const sps = require('..');
6+
7+
Promise.all([
8+
(function truncatedHashTest() {
9+
const testPassword = crypto.randomBytes(3).toString('hex');
10+
return sps.PasswordStorage.createHash(testPassword)
11+
.then(hash =>
12+
sps.PasswordStorage.verifyPassword(testPassword, hash.slice(0, hash.length - 1)))
13+
.then(accepted => assert(false, 'Should not have accepted password'))
14+
.catch(reason => {
15+
if (!(reason instanceof sps.InvalidHashException))
16+
throw reason;
17+
});
18+
})(),
19+
(function basicTests() {
20+
const testPassword = crypto.randomBytes(3).toString('hex');
21+
const anotherPassword = crypto.randomBytes(3).toString('hex');
22+
23+
return Promise.all([
24+
sps.PasswordStorage.createHash(testPassword),
25+
sps.PasswordStorage.createHash(testPassword),
26+
]).then(hashes => {
27+
assert.notStrictEqual(hashes[0], hashes[1], 'Two hashes are equal');
28+
return Promise.all([
29+
sps.PasswordStorage.verifyPassword(anotherPassword, hashes[0]),
30+
sps.PasswordStorage.verifyPassword(testPassword, hashes[0])
31+
]);
32+
}).then(accepted => {
33+
assert.strictEqual(accepted[0], false, 'Wrong password accepted');
34+
assert.strictEqual(accepted[1], true, 'Good password not accepted');
35+
});
36+
})(),
37+
(function testHashFunctionChecking() {
38+
const testPassword = crypto.randomBytes(3).toString('hex');
39+
return sps.PasswordStorage.createHash(testPassword)
40+
.then(hash =>
41+
sps.PasswordStorage.verifyPassword(testPassword, hash.replace(/^sha1/, 'md5')))
42+
.then(accepted => assert.strictEqual(accepted, false,
43+
'Should not have accepted password'));
44+
})(),
45+
(function testGoodHashInPhp() {
46+
const testPassword = crypto.randomBytes(3).toString('hex');
47+
return sps.PasswordStorage.createHash(testPassword)
48+
.then(hash => phpVerify(testPassword, hash));
49+
})(),
50+
(function testBadHashInPhp() {
51+
const testPassword = crypto.randomBytes(3).toString('hex');
52+
const errorOccurred = Symbol();
53+
return sps.PasswordStorage.createHash(testPassword)
54+
.then(hash => phpVerify(testPassword, hash.slice(0, hash.length - 1)))
55+
.catch(reason => {
56+
// Swallow this error, it is expected
57+
return errorOccurred;
58+
})
59+
.then(result => assert.strictEqual(result, errorOccurred,
60+
'Should not have accepted password'));
61+
})(),
62+
(function testHashFromPhp() {
63+
return phpHashMaker()
64+
.then(pair => sps.PasswordStorage.verifyPassword(pair.password, pair.hash))
65+
.then(accepted => assert.strictEqual(accepted, true,
66+
'Should have accepted password'));
67+
})(),
68+
(function testHashFromPhpFailsWithWrongPassword() {
69+
const testPassword = crypto.randomBytes(3).toString('hex');
70+
return phpHashMaker()
71+
.then(pair => sps.PasswordStorage.verifyPassword(testPassword, pair.hash))
72+
.then(accepted => assert.strictEqual(accepted, false,
73+
'Should not have accepted password'));
74+
})(),
75+
])
76+
.then(results => {
77+
// Test cases can be disabled by NOT immediately invoking their function
78+
const testCount = results.filter(x=>typeof x !== 'function').length;
79+
console.log(`✔ ${testCount} Passed`);
80+
})
81+
.catch(reason => {
82+
if(reason.name === 'AssertionError')
83+
console.error('AssertionError:',
84+
reason.actual, reason.operator, reason.expected);
85+
86+
console.error(reason.stack);
87+
process.exit(1);
88+
});
89+
90+
function phpVerify(password, hash) {
91+
return new Promise((resolve, reject) => {
92+
execFile('php', [ 'tests/phpVerify.php', password, hash ],
93+
(error, stdout, stderr) => {
94+
if(error) reject(error);
95+
else resolve(stdout);
96+
});
97+
});
98+
}
99+
100+
function phpHashMaker(password, hash) {
101+
return new Promise((resolve, reject) => {
102+
execFile('php', [ 'tests/phpHashMaker.php' ],
103+
(error, stdout, stderr) => {
104+
if(error) reject(error);
105+
else {
106+
const hashPair = stdout.trim().split(' ');
107+
if (hashPair[1].length !== parseInt(hashPair[0], 10))
108+
reject(new Error('Unicode test is invalid'));
109+
else
110+
resolve({ password: hashPair[1], hash: hashPair[2] });
111+
}
112+
});
113+
});
114+
}

tests/runtests.sh

+17
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ cd ..
6464
echo "---------------------------------------------"
6565
echo ""
6666

67+
echo "Node.js"
68+
echo "---------------------------------------------"
69+
70+
. $HOME/.nvm/nvm.sh
71+
nvm install v4.3.2
72+
nvm use v4.3.2
73+
node -v
74+
openssl version
75+
node test1.js
76+
if [ $? -ne 0 ]; then
77+
echo "FAIL."
78+
exit 1
79+
fi
80+
81+
echo "---------------------------------------------"
82+
echo ""
83+
6784
echo "PHP<->Ruby Compatibility"
6885
echo "---------------------------------------------"
6986
ruby tests/testRubyPhpCompatibility.rb

0 commit comments

Comments
 (0)