Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Increase password hashing iterations #1149

Merged
merged 9 commits into from
Sep 4, 2022
36 changes: 35 additions & 1 deletion docs/blog/version-2.11-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,38 @@ tags: [release]

Version 2.11 of [Foal](https://foalts.org/) is out! Here are the improvements that it brings:

<!--truncate-->
<!--truncate-->

## Number of Iterations on Password Hashing Has Been Increased

The PBKDF2 algorithm (used for password hashing) uses a number of iterations to hash passwords. This work factor is deliberate and slows down potential attackers, making attacks against hashed passwords more difficult.

As computing power increases, the number of iterations must also increase. This is why, starting with version 2.11, the number of iterations has been increased to 310,000.

To check that a password hash is using the latest recommended number of iterations, you can use the `passwordHashNeedsToBeRefreshed` function.

The example below shows how to perform this check during a login and how to upgrade the password hash if the number of iterations turns out to be too low.

```typescript
const { email, password } = ctx.request.body;

const user = await User.findOne({ email });

if (!user) {
return new HttpResponseUnauthorized();
}

if (!await verifyPassword(password, user.password)) {
return new HttpResponseUnauthorized();
}

// highlight-start
// This line must be after the password verification.
if (passwordHashNeedsToBeRefreshed(user.password)) {
user.password = await hashPassword(password);
await user.save();
}
// highlight-end

// Log the user in.
```
53 changes: 43 additions & 10 deletions docs/docs/authentication-and-access-control/password-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,62 @@ title: Password Management
sidebar_label: Passwords
---

Passwords must never be stored in the database in plain text. If they were and attackers were to gain access to the database, all passwords would be compromised. To prevent this, they must be hashed and salted and their hashes stored. Foal provides two functions for this purpose.

Every application must store passwords using a cryptographic technique. FoalTS provides two functions to hash and verify passwords.
## Hashing and Salting Passwords

## Hash and Salt Passwords

The `hashPassword` utility uses the [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) algorithm with a SHA256 hash. It takes as parameters the password in plain text and an optional `options` object. It returns a promise which value is a password hash.

> The function generates a unique cryptographically-strong random salt for each password. This salt is returned by the function beside the password hash.
The `hashPassword` function hashes and salts a plain text password and returns a password hash. The returned value is meant to be stored in the database and used by the `verifyPassword` function.

```typescript
const passwordHash = await hashPassword(plainTextPassword);
```

## Verify Passwords
> *Note: password hashes are generated using [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) and HMAC-SHA256.*

## Verifying Passwords

The `verifyPassword` takes three arguments:
- the password to check in plain text,
- and the password hash (usually fetched from the database).
In order to verify that a password is correct when logging in, the `verifyPassword` function can be used. It takes as parameters the plaintext password that is being tested and the hash of the password stored in the database. It returns a promise whose value is a boolean.

```typescript
const isEqual = await verifyPassword(plainTextPassword, passwordHash);
```

## Password Upgrading

> *This feature is available from version 2.11 onwards.*

The PBKDF2 algorithm uses a number of iterations to hash passwords. This work factor is deliberate and slows down potential attackers, making attacks against hashed passwords more difficult.

As computing power increases, the number of iterations must also increase. This is why the latest versions of Foal use a higher number of iterations.

To check that a password hash is using the latest recommended number of iterations, you can use the `passwordHashNeedsToBeRefreshed` function.

The example below shows how to perform this check during a login and how to upgrade the password hash if the number of iterations turns out to be too low.

```typescript
const { email, password } = ctx.request.body;

const user = await User.findOne({ email });

if (!user) {
return new HttpResponseUnauthorized();
}

if (!await verifyPassword(password, user.password)) {
return new HttpResponseUnauthorized();
}

// highlight-start
// This line must be after the password verification.
if (passwordHashNeedsToBeRefreshed(user.password)) {
user.password = await hashPassword(password);
await user.save();
}
// highlight-end

// Log the user in.
```

## Forbid Overly Common Passwords

```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// std
import { notStrictEqual, strictEqual} from 'assert';
import { pbkdf2, randomBytes } from 'crypto';
import { promisify } from 'util';

// FoalTS
import { hashPassword, HttpResponseOK, HttpResponseUnauthorized, isHttpResponseOK, passwordHashNeedsToBeRefreshed, verifyPassword } from '@foal/core';
import { BaseEntity, Column, Connection, Entity, PrimaryGeneratedColumn } from '@foal/typeorm/node_modules/typeorm';
import { createTestConnection } from '../../../common';

describe('Feature: Upgrading passwords', () => {

let connection: Connection;

afterEach(async () => {
if (connection) {
await connection.close();
}
});

it('Example: simple example.', async () => {

async function hashPasswordWithOldNumberOfIterations(plainTextPassword: string): Promise<string> {
const saltBuffer = await promisify(randomBytes)(16);
const iterations = 150000;
const keylen = 32;
const digest = 'sha256';
const derivedKeyBuffer = await promisify(pbkdf2)(plainTextPassword, saltBuffer, iterations, keylen, digest);

const salt = saltBuffer.toString('base64');
const derivedKey = derivedKeyBuffer.toString('base64');
return `pbkdf2_${digest}$${iterations}$${salt}$${derivedKey}`;
}

@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;

@Column()
email: string;

@Column()
password: string;
}

async function login(email: string, password: string): Promise<any> {

/* ======================= DOCUMENTATION BEGIN ======================= */

const user = await User.findOne({ email });

if (!user) {
return new HttpResponseUnauthorized();
}

if (!await verifyPassword(password, user.password)) {
return new HttpResponseUnauthorized();
}

// highlight-start
// This line must be after the password verification.
if (passwordHashNeedsToBeRefreshed(user.password)) {
user.password = await hashPassword(password);
await user.save();
}
// highlight-end

// Log the user in.

/* ======================= DOCUMENTATION END ========================= */

return new HttpResponseOK();
}

connection = await createTestConnection([ User ]);

const plainTextPassword = 'password';

const user = new User();
user.email = 'foo@foalts.org';
user.password = await hashPasswordWithOldNumberOfIterations(plainTextPassword);
await user.save();

const passwordHash = user.password;
strictEqual(user.password.split('$')[1], '150000');

const result = await login(user.email, plainTextPassword);
if (!isHttpResponseOK(result)) {
throw new Error('The function should have returned a HttpResponseOK.');
}

await user.reload();
notStrictEqual(user.password, passwordHash);
strictEqual(user.password.split('$')[1], '310000');

const passwordHash2 = user.password;
const result2 = await login(user.email, plainTextPassword);
if (!isHttpResponseOK(result2)) {
throw new Error('The function should have returned a HttpResponseOK.');
}

await user.reload();
strictEqual(user.password, passwordHash2);
});

});
6 changes: 3 additions & 3 deletions packages/core/src/common/auth/passwords/hash-password.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ import { hashPassword } from './hash-password';
describe('hashPassword', () => {

it('should hash the plain password into a 32-byte derived key with PBKDF2/SHA256,'
+ ' 150 000 iterations and a 16-byte random salt.', async () => {
+ ' 310 000 iterations and a 16-byte random salt.', async () => {
const plainPassword = 'hello world';
const actual = await hashPassword(plainPassword);

const [ algorithm, iterations, salt, derivedKey ] = actual.split('$');

strictEqual(algorithm, 'pbkdf2_sha256');
strictEqual(parseInt(iterations, 10), 150000);
strictEqual(parseInt(iterations, 10), 310000);
strictEqual(Buffer.from(salt, 'base64').length, 16);
strictEqual(Buffer.from(derivedKey, 'base64').length, 32);

const expectedBuffer = await pbkdf2Sync(plainPassword, Buffer.from(salt, 'base64'), 150000, 32, 'sha256');
const expectedBuffer = await pbkdf2Sync(plainPassword, Buffer.from(salt, 'base64'), 310000, 32, 'sha256');

strictEqual(derivedKey, expectedBuffer.toString('base64'));
});
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/common/auth/passwords/hash-password.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { pbkdf2, randomBytes } from 'crypto';
import { promisify } from 'util';

export const PASSWORD_ITERATIONS = 310000;

/**
* Hash a password using the PBKDF2 algorithm.
*
* Configured to use PBKDF2 + HMAC + SHA256.
* The result is a 64 byte binary string.
*
* The random salt is 16 bytes long.
* The number of iterations is 150000.
* The number of iterations is 310000.
* The length key is 32 bytes long.
*
* @export
Expand All @@ -17,7 +19,7 @@ import { promisify } from 'util';
*/
export async function hashPassword(plainTextPassword: string): Promise<string> {
const saltBuffer = await promisify(randomBytes)(16);
const iterations = 150000;
const iterations = PASSWORD_ITERATIONS;
const keylen = 32;
const digest = 'sha256';
const derivedKeyBuffer = await promisify(pbkdf2)(plainTextPassword, saltBuffer, iterations, keylen, digest);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/common/auth/passwords/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { hashPassword } from './hash-password';
export { passwordHashNeedsToBeRefreshed } from './password-hash-needs-to-be-refreshed';
export { verifyPassword } from './verify-password';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// std
import { strictEqual } from 'assert';

// FoalTS
import { PASSWORD_ITERATIONS } from './hash-password';
import { passwordHashNeedsToBeRefreshed } from './password-hash-needs-to-be-refreshed';

describe('passwordHashNeedsToBeRefreshed', () => {
function createPasswordHash(iterations: number): string {
return `pbkdf2_sha256$${iterations}$salt$derivedKey`;
}

it('should return true if the number of iterations of the password hash is less than the current number of iterations of "hashPassword".', () => {
const passwordHash = createPasswordHash(PASSWORD_ITERATIONS - 1);
strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), true);
});
it('should return false if the number of iterations of the password hash is greater than the current number of iterations of "hashPassword".', () => {
const passwordHash = createPasswordHash(PASSWORD_ITERATIONS + 1);
strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), false);
});
it('should return false if the number of iterations of the password hash is equal to the current number of iterations of "hashPassword".', () => {
const passwordHash = createPasswordHash(PASSWORD_ITERATIONS);
strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PASSWORD_ITERATIONS } from './hash-password';
import { decomposePbkdf2PasswordHash } from './utils';

export function passwordHashNeedsToBeRefreshed(passwordHash: string): boolean {
const { iterations } = decomposePbkdf2PasswordHash(passwordHash);
return iterations < PASSWORD_ITERATIONS;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// std
import { deepStrictEqual, throws } from 'assert';

// FoalTS
import { decomposePbkdf2PasswordHash } from './decompose-pbkdf2-password-hash';

describe('decomposePbkdf2PasswordHash', () => {

it('should throw an error if the password hash was NOT generated with PBKDF2.', () => {
const passwordHash = 'foobar';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid algorithm.')
);
});

it('should throw an error if the digest algorithm is NOT SHA256.', () => {
const passwordHash = 'pbkdf2_sha1';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid algorithm.')
);
});

it('should throw an error if no iterations is found.', () => {
const passwordHash = 'pbkdf2_sha256';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid password format.')
);
});

it('should throw an error if the given iterations are NOT a number.', () => {
const passwordHash = 'pbkdf2_sha256$foobar';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid password format.')
);
});

it('should throw an error if no salt is found.', () => {
const passwordHash = 'pbkdf2_sha256$100000';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid password format.')
);
});

it('should throw an error if no derived key is found.', () => {
const passwordHash = 'pbkdf2_sha256$100000$xxxx';

throws(
() => decomposePbkdf2PasswordHash(passwordHash),
new Error('Invalid password format.')
);
});

it('should return the derivedKey, the digest algorithm, the iterations, the salt and the key length.', () => {
const digestAlgorithm = 'sha256';
const iterations = 90000;
const saltInBase64 = 'hello salt';
const saltBuffer = Buffer.from(saltInBase64, 'base64');
const derivedKeyInBase64 = 'hello derived key';
const derivedKeyBuffer = Buffer.from(derivedKeyInBase64, 'base64');
const keyLength = derivedKeyBuffer.length;

const passwordHash = `pbkdf2_${digestAlgorithm}$${iterations}$${saltInBase64}$${derivedKeyInBase64}`;

const actual = decomposePbkdf2PasswordHash(passwordHash);
const expected: ReturnType<typeof decomposePbkdf2PasswordHash> = {
derivedKey: derivedKeyBuffer,
digestAlgorithm,
iterations,
keyLength,
salt: saltBuffer,
};

deepStrictEqual(actual, expected);
});

});
Loading