Skip to content

Commit

Permalink
Merge pull request #1149 from FoalTS/password-iterations
Browse files Browse the repository at this point in the history
Increase password hashing iterations
  • Loading branch information
LoicPoullain committed Sep 4, 2022
2 parents 71e5a18 + 7fcdf3a commit b75c444
Show file tree
Hide file tree
Showing 15 changed files with 368 additions and 57 deletions.
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

0 comments on commit b75c444

Please sign in to comment.