CVE-2023-27580 (PHP, published 2023-03-13T18:15Z)
Description: value: CodeIgniter Shield provides authentication and authorization for the CodeIgniter 4 PHP framework. An improper implementation was found in the password storage process. All hashed passwords stored in Shield v1.0.0-beta.3 or earlier are easier to crack than expected due to the vulnerability. Therefore, they should be removed as soon as possible. If an attacker gets (1) the user's hashed password by Shield, and (2) the hashed password (SHA-384 hash without salt) from somewhere, the attacker may easily crack the user's password. Upgrade to Shield v1.0.0-beta.4 or later to fix this issue. After upgrading, all users’ hashed passwords should be updated (saved to the database). There are no known workarounds.
Severity: nan (CVSS v3 base score: 5.9)
Weakness: CWE-916 \u2014 Use of Password Hash With Insufficient Computational Effort
The product generates a hash for a password, but it uses a scheme that does not provide a sufficient level of computational effort that would make password cracking attacks infeasible or expensive.
Repository: https://github.com/codeigniter4/shield
Files changed:
README.md
UPGRADING.md
docs/guides/strengthen_password.md
docs/index.md
mkdocs.yml
src/Authentication/Authenticators/Session.php
src/Authentication/Passwords.php
src/Authentication/Passwords/ValidationRules.php
src/Config/Auth.php
src/Controllers/LoginController.php
src/Controllers/RegisterController.php
src/Language/de/Auth.php
src/Language/en/Auth.php
src/Language/es/Auth.php
src/Language/fa/Auth.php
src/Language/fr/Auth.php
src/Language/id/Auth.php
src/Language/it/Auth.php
src/Language/ja/Auth.php
src/Language/pt-BR/Auth.php
src/Language/sk/Auth.php
src/Language/tr/Auth.php
tests/Authentication/Authenticators/SessionAuthenticatorTest.php
tests/Controllers/LoginTest.php
tests/Controllers/RegisterTest.php
tests/Unit/PasswordsTest.php
Commit message: Merge pull request from GHSA-c5vj-f36q-p9vg

* feat: copy the current methods as depreacted

* fix: password shucking

* feat: support old dangerous passwords

* docs: add Upgrade Guide

* refactor: fix variable name typo

Co-authored-by: MGatner <mgatner@icloud.com>

* refactor: fix variable name typo

* fix: $options is missing for password_needs_rehash()

* docs: add OWASP Cheat Sheet in README

* docs: add explanation in UPGRADING.md

* docs: replace Config/Auth.php with app/Config/Auth.php

* docs: add How to Strengthen the Password

* docs: UPGRADING.md

* docs: add note in "Minimum Password Length"

* docs: $supportOldDangerousPassword will be removed in v1.0.0

* feat: add validation rule for max password length

PASSWORD_BCRYPT -> 72 bytes
Others -> 255 characters

* docs: update docs

* feat: add new lang item to new lang files

* feat: add errorPasswordTooLongBytes to Language/pt-BR

* refactor: ChangeIfElseValueAssignToEarlyReturnRector

---------

Co-authored-by: MGatner <mgatner@icloud.com>

Vulnerable code:
            return new Result([
                'success' => false,
                'reason'  => lang('Auth.invalidPassword'),
            ]);
        if ($passwords->needsRehash($user->password_hash)) {
        if ((defined('PASSWORD_ARGON2I') && $this->config->hashAlgorithm === PASSWORD_ARGON2I)
            $hashOptions = [
        } else {
            $hashOptions = [
                'cost' => $this->config->hashCost,
            ];
            $hashOptions
        return password_needs_rehash($hashedPassword, $this->config->hashAlgorithm);
                'label' => 'Auth.password',
                'rules' => 'required',
                'label' => 'Auth.password',
                'rules' => 'required|strong_password',

Fixed code:
- [Password Storage - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
# Upgrade Guide

## Version 1.0.0-beta.3 to 1.0.0-beta.4

### Important Password Changes

#### Password Incompatibility

Shield 1.0.0-beta.4 fixes a [vulnerability related to password storage](https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg).
As a result, hashed passwords already stored in the database are no longer compatible
and cannot be used by default.

All hashed passwords stored in Shield v1.0.0-beta.3 or earlier are easier to
crack than expected due to the above vulnerability. Therefore, they should be
removed as soon as possible.

Existing users will no longer be able to log in with their passwords and will
need to log in with the magic link and then set their passwords again.

#### If You Want to Allow Login with Existing Passwords

If you want to use passwords saved in Shield v1.0.0-beta.3 or earlier,
you must add the following property in `app/Config/Auth.php`:

```php
    public bool $supportOldDangerousPassword = true;
```

After upgrading, with the above setting, once a user logs in with the password,
the hashed password is updated and stored in the database.

In this case, the existing hashed passwords are still easier to crack than expected.
Therefore, this setting should not be used for an extended period of time.
So you should change the setting to `false` as soon as possible, and remove old
hashed password.

> **Note**
>
> This setting is deprecated. It will be removed in v1.0.0 official release.

#### Limitations for the Default Password Handling

By default, Shield uses the hashing algorithm `PASSWORD_DEFAULT` (see `app/Config/Auth.php`),
that is, `PASSWORD_BCRYPT` at the time of writing.

Now there are two limitations when you use `PASSWORD_BCRYPT`.

1. the password will be truncated to a maximum length of 72 bytes.
2. the password will be truncated at the first NULL byte (`\0`).

If these behaviors are unacceptable, see [How to Strengthen the Password](https://github.com/codeigniter4/shield/blob/develop/docs/guides/strengthen_password.md).
# How to Strengthen the Password

Shield allows you to customize password-related settings to make your passwords more secure.

## Minimum Password Length

The most important factor when it comes to passwords is the number of characters in the password.
You can check password strength with [Password Strength Testing Tool](https://bitwarden.com/password-strength/).
Short passwords may be cracked in less than one day.

In Shield, you can set the users' minimum password length. The setting is
`$minimumPasswordLength` in `app/Config/Auth.php`. The default value is 8 characters.
It is the recommended minimum value by NIST. However, some organizations recommend
12 to 14 characters.

The longer the password, the stronger it is. Consider increasing the value.

> **Note**
>
> This checking works when you validate passwords with the `strong_password`
> validation rule.
>
> If you disable `CompositionValidator` (enabled by default) in `$passwordValidators`,
> this checking will not work.

## Password Hashing Algorithm

You can change the password hashing algorithm by `$hashAlgorithm` in `app/Config/Auth.php`.
The default value is `PASSWORD_DEFAULT` that is `PASSWORD_BCRYPT` at the time of writing.

`PASSWORD_BCRYPT` means to create new password hashes using the bcrypt algorithm.

You can use `PASSWORD_ARGON2ID` if your PHP has been compiled with Argon2 support.

### PASSWORD_BCRYPT

`PASSWORD_BCRYPT` has one configuration `$hashCost`. The bigger the cost, hashed passwords will be the stronger.

You can find your appropriate cost with the following code:

```php
<?php
/**
 * This code will benchmark your server to determine how high of a cost you can
 * afford. You want to set the highest cost that you can without slowing down
 * you server too much. 8-10 is a good baseline, and more is good if your servers
 * are fast enough. The code below aims for ≤ 50 milliseconds stretching time,
 * which is a good baseline for systems handling interactive logins.
 *
 * From: https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-examples
 */
$timeTarget = 0.05; // 50 milliseconds

$cost = 8;
do {
    $cost++;
    $start = microtime(true);
    password_hash("test", PASSWORD_BCRYPT, ["cost" => $cost]);
    $end = microtime(true);
} while (($end - $start) < $timeTarget);

echo "Appropriate Cost Found: " . $cost;
```

#### Limitations

There are two limitations when you use `PASSWORD_BCRYPT`:

1. the password will be truncated to a maximum length of 72 bytes.
2. the password will be truncated at the first NULL byte (`\0`).

##### 72 byte issue

If a user submits a password longer than 72 bytes, the validation error will occur.
If this behavior is unacceptable, consider:

1. change the hashing algorithm to `PASSWORD_ARGON2ID`. It does not have such a limitation.

##### NULL byte issue

This is because `PASSWORD_BCRYPT` is not binary-safe. Normal users cannot
send NULL bytes in a password string, so this is not a problem in most cases.

But if this behavior is unacceptable, consider:

1. adding a validation rule to prohibit NULL bytes or control codes.
2. or change the hashing algorithm to `PASSWORD_ARGON2ID`. It is binary-safe.

### PASSWORD_ARGON2ID

`PASSWORD_ARGON2ID` has three configuration `$hashMemoryCost`, `$hashTimeCost`,
and `$hashThreads`.

If you use `PASSWORD_ARGON2ID`, you should use PHP's constants:

```php
    public int $hashMemoryCost = PASSWORD_ARGON2_DEFAULT_MEMORY_COST;

    public int $hashTimeCost = PASSWORD_ARGON2_DEFAULT_TIME_COST;
    public int $hashThreads  = PASSWORD_ARGON2_DEFAULT_THREADS;
```

## Maximum Password Length

By default, Shield has the validation rules for maximum password length.

- 72 bytes for PASSWORD_BCRYPT
- 255 characters for others

You can customize the validation rule. See [Customizing Shield](../customization.md).

## $supportOldDangerousPassword

In `app/Config/Auth.php` there is `$supportOldDangerousPassword`, which is a
setting for using passwords stored in older versions of Shield that were [vulnerable](https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg).

This setting is deprecated.  If you have this setting set to `true`, you should change
it to `false` as soon as possible, and remove old hashed password in your database.

> **Note**
>
> This setting will be removed in v1.0.0 official release.
* [How to Strengthen the Password](guides/strengthen_password.md)
    - guides/strengthen_password.md
        // This is only for supportOldDangerousPassword.
        $needsRehash = false;

            if (
                ! setting('Auth.supportOldDangerousPassword')
                || ! $passwords->verifyDanger($givenPassword, $user->password_hash) // @phpstan-ignore-line
            ) {
                return new Result([
                    'success' => false,
                    'reason'  => lang('Auth.invalidPassword'),
                ]);
            }

            // Passed with old dangerous password.
            $needsRehash = true;
        if ($passwords->needsRehash($user->password_hash) || $needsRehash) {
        return password_hash($password, $this->config->hashAlgorithm, $this->getHashOptions());
    }

    private function getHashOptions(): array
    {
        if (
            (defined('PASSWORD_ARGON2I') && $this->config->hashAlgorithm === PASSWORD_ARGON2I)
            return [
        return [
            'cost' => $this->config->hashCost,
        ];
    }

    /**
     * Hash a password.
     *
     * @return false|string|null
     *
     * @deprecated This is only for backward compatibility.
     */
    public function hashDanger(string $password)
    {
            $this->getHashOptions()
    {
        return password_verify($password, $hash);
    }

    /**
     * Verifies a password against a previously hashed password.
     *
     * @param string $password The password we're checking
     * @param string $hash     The previously hashed password
     *
     * @deprecated This is only for backward compatibility.
     */
    public function verifyDanger(string $password, string $hash): bool
        return password_needs_rehash($hashedPassword, $this->config->hashAlgorithm, $this->getHashOptions());

    /**
     * Returns the validation rule for max length.
     */
    public static function getMaxLenghtRule(): string
    {
        if (config('Auth')->hashAlgorithm === PASSWORD_BCRYPT) {
            return 'max_byte[72]';
        }

        return 'max_length[255]';
    }
    /**
     * Returns true if $str is $val or fewer bytes in length.
     */
    public function max_byte(?string $str, string $val): bool
    {
        return is_numeric($val) && $val >= strlen($str ?? '');
    }

    /**
     * If you need to support passwords saved in versions prior to Shield v1.0.0-beta.4.
     * set this to true.
     *
     * See https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg
     *
     * @deprecated This is only for backward compatibility.
     */
    public bool $supportOldDangerousPassword = false;

use CodeIgniter\Shield\Authentication\Passwords;
                'label'  => 'Auth.password',
                'rules'  => 'required|' . Passwords::getMaxLenghtRule(),
                'errors' => [
                    'max_byte' => 'Auth.errorPasswordTooLongBytes',
                ],
use CodeIgniter\Shield\Authentication\Passwords;
                'label'  => 'Auth.password',
                'rules'  => 'required|' . Passwords::getMaxLenghtRule() . '|strong_password',
                'errors' => [
                    'max_byte' => 'Auth.errorPasswordTooLongBytes',
                ],
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => 'Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
    'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.',
use CodeIgniter\Shield\Models\UserIdentityModel;
    public function testCheckSuccessOldDangerousPassword(): void
    {
        /** @var Auth $config */
        $config                              = config('Auth');
        $config->supportOldDangerousPassword = true; // @phpstan-ignore-line

        fake(
            UserIdentityModel::class,
            [
                'user_id' => $this->user->id,
                'type'    => Session::ID_TYPE_EMAIL_PASSWORD,
                'secret'  => 'foo@example.com',
                'secret2' => '$2y$10$WswjNNcR24cJvsXvBc5TveVVVQ9/EYC0eq.Ad9e/2cVnmeSEYBOEm',
            ]
        );

        $result = $this->auth->check([
            'email'    => 'foo@example.com',
            'password' => 'passw0rd!',
        ]);

        $this->assertInstanceOf(Result::class, $result);
        $this->assertTrue($result->isOK());

        $foundUser = $result->extraInfo();
        $this->assertSame($this->user->id, $foundUser->id);
    }

use CodeIgniter\Shield\Config\Auth;
    public function testLoginTooLongPasswordDefault(): void
    {
        $this->user->createEmailIdentity([
            'email'    => 'foo@example.com',
            'password' => 'secret123',
        ]);

        $result = $this->post('/login', [
            'email'    => 'fooled@example.com',
            'password' => str_repeat('a', 73),
        ]);

        $result->assertStatus(302);
        $result->assertRedirect();
        $result->assertSessionMissing('error');
        $result->assertSessionHas(
            'errors',
            ['password' => 'Password cannot exceed 72 bytes in length.']
        );
    }

    public function testLoginTooLongPasswordArgon2id(): void
    {
        /** @var Auth $config */
        $config                = config('Auth');
        $config->hashAlgorithm = PASSWORD_ARGON2ID;

        $this->user->createEmailIdentity([
            'email'    => 'foo@example.com',
            'password' => 'secret123',
        ]);

        $result = $this->post('/login', [
            'email'    => 'fooled@example.com',
            'password' => str_repeat('a', 256),
        ]);

        $result->assertStatus(302);
        $result->assertRedirect();
        $result->assertSessionMissing('error');
        $result->assertSessionHas(
            'errors',
            ['password' => 'The Password field cannot exceed 255 characters in length.']
        );
    }

use CodeIgniter\Shield\Config\Auth;
    public function testRegisterTooLongPasswordDefault(): void
    {
        $result = $this->withSession()->post('/register', [
            'username'         => 'JohnDoe',
            'email'            => 'john.doe@example.com',
            'password'         => str_repeat('a', 73),
            'password_confirm' => str_repeat('a', 73),
        ]);

        $result->assertStatus(302);
        $result->assertRedirect();
        $result->assertSessionMissing('error');
        $result->assertSessionHas(
            'errors',
            ['password' => 'Password cannot exceed 72 bytes in length.']
        );
    }

    public function testRegisterTooLongPasswordArgon2id(): void
    {
        /** @var Auth $config */
        $config                = config('Auth');
        $config->hashAlgorithm = PASSWORD_ARGON2ID;

        $result = $this->withSession()->post('/register', [
            'username'         => 'JohnDoe',
            'email'            => 'john.doe@example.com',
            'password'         => str_repeat('a', 256),
            'password_confirm' => str_repeat('a', 256),
        ]);

        $result->assertStatus(302);
        $result->assertRedirect();
        $result->assertSessionMissing('error');
        $result->assertSessionHas(
            'errors',
            ['password' => 'The Password field cannot exceed 255 characters in length.']
        );
    }


    public function testHash(): string
    {
        $config    = new AuthConfig();
        $passwords = new Passwords($config);

        $plainPassword  = 'passw0rd!';
        $hashedPassword = $passwords->hash($plainPassword);

        $user = new User([
            'id'       => 1,
            'username' => 'John',
        ]);
        $user->email         = 'john@example.org';
        $user->password_hash = $hashedPassword;

        $result = $passwords->check($plainPassword, $user);

        $this->assertTrue($result->isOK());

        return $hashedPassword;
    }

    /**
     * @depends testHash
     */
    public function testNeedsRehashTakesCareOptions(string $hashedPassword): void
    {
        $config           = new AuthConfig();
        $config->hashCost = 12;
        $passwords        = new Passwords($config);

        $result = $passwords->needsRehash($hashedPassword);

        $this->assertTrue($result);
    }