From 0db41db06ea9c4d7ee4ff379e01e51cdc1ac8d3d Mon Sep 17 00:00:00 2001 From: Paragon Initiative Enterprises Date: Wed, 2 Jun 2021 07:08:53 -0400 Subject: [PATCH 1/2] Version 3.1.0 --- CHANGELOG.md | 16 ++++++++ README.md | 14 +++++++ composer.json | 2 +- src/PasswordLock.php | 78 ++++++++++++++++++++++++++++++++++++-- tests/PasswordLockTest.php | 9 +++++ 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76cc134..7ff38e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# Version 3.1.0 (2021-06-02) + +* Added `needsRehash()` method. +* Added support for `$hashOptions` in `hashAndEncrypt()` to support + custom bcrypt costs. (This can also be used to support custom Argon2id + parameters, should the default ever change in PHP.) + +# Version 3.0.3 (2021-06-02) + +* Support PHP 8. +* The previous tag (v3.0.2) was erroneous and erased. + +# Version 3.0.1 (2016-05-20) + +* Fixed `autoload.php` + # Version 3.0.0 (2016-05-18) * Set minimum PHP version to 7.0 diff --git a/README.md b/README.md index c5e09f6..c9f205f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,20 @@ if (isset($_POST['password'])) { } ``` +### Determine if a re-hash is necessary + +```php +use ParagonIE\PasswordLock\PasswordLock; +/** + * @var string $encryptedPwhash + * @var Defuse\Crypto\Key $key + */ + +if (PasswordLock::needsRehash($encryptedPwhash, $key)) { + // Recalculate PasswordLock::hashAndEncrypt() +} +``` + ### Re-encrypt a hash with a different encryption key ```php diff --git a/composer.json b/composer.json index 8dc4403..b8aab26 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ] }, "require": { - "php": "^7|^8", + "php": "^7.3|^8", "defuse/php-encryption": "^2", "paragonie/constant_time_encoding": "^2" }, diff --git a/src/PasswordLock.php b/src/PasswordLock.php index 0439252..ddb21c7 100644 --- a/src/PasswordLock.php +++ b/src/PasswordLock.php @@ -15,25 +15,47 @@ */ class PasswordLock { + /** + * @ref https://www.php.net/manual/en/function.password-hash.php + */ + const OPTIONS_DEFAULT_BCRYPT = ['cost' => 12]; + const OPTIONS_DEFAULT_ARGON2ID = [ + 'memory_cost' => 65536, + 'time_cost' => 4, + 'threads' => 1 + ]; + /** * 1. Hash password using bcrypt-base64-SHA256 * 2. Encrypt-then-MAC the hash * * @param string $password * @param Key $aesKey + * @param ?array $hashOptions * @return string * * @throws EnvironmentIsBrokenException + * @throws \InvalidArgumentException * @psalm-suppress InvalidArgument */ - public static function hashAndEncrypt(string $password, Key $aesKey): string - { + public static function hashAndEncrypt( + string $password, + Key $aesKey, + ?array $hashOptions = null + ): string { + if (is_null($hashOptions)) { + $hashOptions = static::getDefaultOptions(); + } + if (array_key_exists('salt', $hashOptions)) { + throw new \InvalidArgumentException('Explicit salts are unsupported.'); + } /** @var string $hash */ $hash = \password_hash( Base64::encode( \hash('sha384', $password, true) ), - PASSWORD_DEFAULT + PASSWORD_DEFAULT, + $hashOptions ); if (!\is_string($hash)) { throw new EnvironmentIsBrokenException("Unknown hashing error."); @@ -53,7 +75,11 @@ public static function hashAndEncrypt(string $password, Key $aesKey): string * @throws EnvironmentIsBrokenException * @throws WrongKeyOrModifiedCiphertextException */ - public static function decryptAndVerifyLegacy(string $password, string $ciphertext, string $aesKey): bool + public static function decryptAndVerifyLegacy( + string $password, + string $ciphertext, + string $aesKey + ): bool { if (Binary::safeStrlen($aesKey) !== 16) { throw new \InvalidArgumentException("Encryption keys must be 16 bytes long"); @@ -102,6 +128,50 @@ public static function decryptAndVerify(string $password, string $ciphertext, Ke ); } + /** + * @return array + * + * @psalm-suppress TypeDoesNotContainType + */ + protected static function getDefaultOptions(): array + { + // Future-proofing: + if (PASSWORD_DEFAULT === PASSWORD_ARGON2ID) { + return self::OPTIONS_DEFAULT_ARGON2ID; + } + return self::OPTIONS_DEFAULT_BCRYPT; + } + + /** + * Decrypt the ciphertext and ascertain if the stored password needs to be rehashed? + * + * @param string $ciphertext + * @param Key $aesKey + * @param ?array $hashOptions + * @return bool + * + * @throws EnvironmentIsBrokenException + * @throws WrongKeyOrModifiedCiphertextException + */ + public static function needsRehash( + string $ciphertext, + Key $aesKey, + ?array $hashOptions = null + ): bool { + if (is_null($hashOptions)) { + $hashOptions = static::getDefaultOptions(); + } + $hash = Crypto::decrypt( + $ciphertext, + $aesKey + ); + if (!\is_string($hash)) { + throw new EnvironmentIsBrokenException("Unknown hashing error."); + } + /** @psalm-suppress InvalidArgument */ + return password_needs_rehash($hash, PASSWORD_DEFAULT, $hashOptions); + } + /** * Key rotation method -- decrypt with your old key then re-encrypt with your new key * diff --git a/tests/PasswordLockTest.php b/tests/PasswordLockTest.php index 558b6ff..c6dcc58 100644 --- a/tests/PasswordLockTest.php +++ b/tests/PasswordLockTest.php @@ -41,4 +41,13 @@ public function testBitflip() } $this->assertTrue($failed, 'Bitflips should break the decryption'); } + + public function testNeedsRehash() + { + $lowCost = ['cost' => 8]; + $key = Key::createNewRandomKey(); + $password = PasswordLock::hashAndEncrypt('YELLOW SUBMARINE', $key, $lowCost); + $this->assertTrue(PasswordLock::needsRehash($password, $key)); + $this->assertFalse(PasswordLock::needsRehash($password, $key, $lowCost)); + } } From e92e8250699fb8dcf022fb5e7852af50f2d7e251 Mon Sep 17 00:00:00 2001 From: Paragon Initiative Enterprises Date: Wed, 2 Jun 2021 07:10:26 -0400 Subject: [PATCH 2/2] Remove old PHP versions from CI --- .github/workflows/ci.yml | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f68034..b74de4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,44 +3,13 @@ name: CI on: [push] jobs: - old: - name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: ['ubuntu-16.04'] - php-versions: ['7.0'] - phpunit-versions: ['7.5.20'] - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: mbstring, intl - ini-values: post_max_size=256M, max_execution_time=180 - tools: psalm, phpunit:${{ matrix.phpunit-versions }} - - - name: Fix permissions - run: sudo chmod -R 0777 . - - - name: Install dependencies - run: composer self-update --1; composer install - - - name: PHPUnit tests - uses: php-actions/phpunit@v2 - with: - memory_limit: 256M - moderate: name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} runs-on: ${{ matrix.operating-system }} strategy: matrix: operating-system: ['ubuntu-latest'] - php-versions: ['7.1', '7.2', '7.3'] + php-versions: ['7.3'] phpunit-versions: ['latest'] steps: - name: Checkout