221 lines
6.4 KiB
PHP
221 lines
6.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace ParagonIE\PasswordLock;
|
|
|
|
use Defuse\Crypto\Crypto;
|
|
use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
|
|
use Defuse\Crypto\Exception\WrongKeyOrModifiedCiphertextException;
|
|
use Defuse\Crypto\Key;
|
|
use ParagonIE\ConstantTime\Base64;
|
|
use ParagonIE\ConstantTime\Binary;
|
|
|
|
/**
|
|
* Class PasswordLock
|
|
* @package ParagonIE\PasswordLock
|
|
*/
|
|
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,
|
|
?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)
|
|
),
|
|
// PROJECT POLYGON MODIFICATION
|
|
// PASSWORD_DEFAULT,
|
|
PASSWORD_ARGON2ID,
|
|
// END PROJECT POLYGON MODIFICATION
|
|
$hashOptions
|
|
);
|
|
if (!\is_string($hash)) {
|
|
throw new EnvironmentIsBrokenException("Unknown hashing error.");
|
|
}
|
|
return Crypto::encrypt($hash, $aesKey);
|
|
}
|
|
/**
|
|
* 1. VerifyHMAC-then-Decrypt the ciphertext to get the hash
|
|
* 2. Verify that the password matches the hash
|
|
*
|
|
* @param string $password
|
|
* @param string $ciphertext
|
|
* @param string $aesKey - must be exactly 16 bytes
|
|
* @return bool
|
|
*
|
|
* @throws \InvalidArgumentException
|
|
* @throws EnvironmentIsBrokenException
|
|
* @throws WrongKeyOrModifiedCiphertextException
|
|
*/
|
|
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");
|
|
}
|
|
$hash = Crypto::legacyDecrypt(
|
|
$ciphertext,
|
|
$aesKey
|
|
);
|
|
if (!\is_string($hash)) {
|
|
throw new EnvironmentIsBrokenException("Unknown hashing error.");
|
|
}
|
|
return \password_verify(
|
|
Base64::encode(
|
|
\hash('sha256', $password, true)
|
|
),
|
|
$hash
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 1. VerifyHMAC-then-Decrypt the ciphertext to get the hash
|
|
* 2. Verify that the password matches the hash
|
|
*
|
|
* @param string $password
|
|
* @param string $ciphertext
|
|
* @param Key $aesKey
|
|
* @return bool
|
|
*
|
|
* @throws EnvironmentIsBrokenException
|
|
* @throws WrongKeyOrModifiedCiphertextException
|
|
*/
|
|
public static function decryptAndVerify(string $password, string $ciphertext, Key $aesKey): bool
|
|
{
|
|
$hash = Crypto::decrypt(
|
|
$ciphertext,
|
|
$aesKey
|
|
);
|
|
if (!\is_string($hash)) {
|
|
throw new EnvironmentIsBrokenException("Unknown hashing error.");
|
|
}
|
|
return \password_verify(
|
|
Base64::encode(
|
|
\hash('sha384', $password, true)
|
|
),
|
|
$hash
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*
|
|
* @psalm-suppress TypeDoesNotContainType
|
|
*/
|
|
protected static function getDefaultOptions(): array
|
|
{
|
|
// PROJECT POLYGON MODIFICATION
|
|
// Future-proofing:
|
|
// if (PASSWORD_DEFAULT === PASSWORD_ARGON2ID) {
|
|
return self::OPTIONS_DEFAULT_ARGON2ID;
|
|
// }
|
|
// return self::OPTIONS_DEFAULT_BCRYPT;
|
|
// END PROJECT POLYGON MODIFICATION
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @param string $ciphertext
|
|
* @param Key $oldKey
|
|
* @param Key $newKey
|
|
* @return string
|
|
*
|
|
* @throws EnvironmentIsBrokenException
|
|
* @throws WrongKeyOrModifiedCiphertextException
|
|
*/
|
|
public static function rotateKey(string $ciphertext, Key $oldKey, Key $newKey): string
|
|
{
|
|
$plaintext = Crypto::decrypt($ciphertext, $oldKey);
|
|
return Crypto::encrypt($plaintext, $newKey);
|
|
}
|
|
|
|
/**
|
|
* For migrating from an older version of the library
|
|
*
|
|
* @param string $password
|
|
* @param string $ciphertext
|
|
* @param string $oldKey
|
|
* @param Key $newKey
|
|
* @return string
|
|
* @throws \Exception
|
|
*/
|
|
public static function upgradeFromVersion1(
|
|
string $password,
|
|
string $ciphertext,
|
|
string $oldKey,
|
|
Key $newKey
|
|
): string {
|
|
if (!self::decryptAndVerifyLegacy($password, $ciphertext, $oldKey)) {
|
|
throw new \Exception(
|
|
'The correct password is necessary for legacy migration.'
|
|
);
|
|
}
|
|
$plaintext = Crypto::legacyDecrypt($ciphertext, $oldKey);
|
|
return self::hashAndEncrypt($plaintext, $newKey);
|
|
}
|
|
} |