- Fix RedisCache driver to handle MGET failures gracefully with fallback - Add comprehensive discovery context comparison debug tools - Identify root cause: WEB context discovery missing 166 items vs CLI - WEB context missing RequestFactory class entirely (52 vs 69 commands) - Improved exception handling with detailed binding diagnostics
326 lines
10 KiB
PHP
326 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Cryptography;
|
|
|
|
use App\Framework\Random\RandomGenerator;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* Key Derivation Function Service
|
|
*
|
|
* Provides secure key derivation functions for password hashing and key stretching.
|
|
* Supports PBKDF2, Argon2ID, and scrypt algorithms.
|
|
*/
|
|
final readonly class KeyDerivationFunction
|
|
{
|
|
public function __construct(
|
|
private RandomGenerator $randomGenerator
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Generate salt for key derivation
|
|
*/
|
|
public function generateSalt(int $length = 32): string
|
|
{
|
|
if ($length < 16) {
|
|
throw new InvalidArgumentException('Salt length must be at least 16 bytes');
|
|
}
|
|
|
|
if ($length > 256) {
|
|
throw new InvalidArgumentException('Salt length cannot exceed 256 bytes');
|
|
}
|
|
|
|
return $this->randomGenerator->bytes($length);
|
|
}
|
|
|
|
/**
|
|
* Derive key using PBKDF2
|
|
*/
|
|
public function pbkdf2(
|
|
string $password,
|
|
string $salt,
|
|
int $iterations = 100000,
|
|
int $keyLength = 32,
|
|
string $algorithm = 'sha256'
|
|
): DerivedKey {
|
|
if (empty($password)) {
|
|
throw new InvalidArgumentException('Password cannot be empty');
|
|
}
|
|
|
|
if (strlen($salt) < 16) {
|
|
throw new InvalidArgumentException('Salt must be at least 16 bytes');
|
|
}
|
|
|
|
if ($iterations < 10000) {
|
|
throw new InvalidArgumentException('Iterations must be at least 10,000 for security');
|
|
}
|
|
|
|
if ($keyLength < 16 || $keyLength > 256) {
|
|
throw new InvalidArgumentException('Key length must be between 16 and 256 bytes');
|
|
}
|
|
|
|
$supportedAlgorithms = ['sha256', 'sha512', 'sha384', 'sha224'];
|
|
if (! in_array($algorithm, $supportedAlgorithms, true)) {
|
|
throw new InvalidArgumentException('Unsupported hash algorithm');
|
|
}
|
|
|
|
$derivedKey = hash_pbkdf2($algorithm, $password, $salt, $iterations, $keyLength, true);
|
|
|
|
return new DerivedKey(
|
|
key: $derivedKey,
|
|
salt: $salt,
|
|
algorithm: "pbkdf2-{$algorithm}",
|
|
iterations: $iterations,
|
|
keyLength: $keyLength
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive key using Argon2ID (recommended)
|
|
*/
|
|
public function argon2id(
|
|
string $password,
|
|
string $salt,
|
|
int $memoryCost = 65536, // 64 MB
|
|
int $timeCost = 4,
|
|
int $threads = 3,
|
|
int $keyLength = 32
|
|
): DerivedKey {
|
|
if (empty($password)) {
|
|
throw new InvalidArgumentException('Password cannot be empty');
|
|
}
|
|
|
|
if (strlen($salt) < 16) {
|
|
throw new InvalidArgumentException('Salt must be at least 16 bytes');
|
|
}
|
|
|
|
if ($memoryCost < 1024) {
|
|
throw new InvalidArgumentException('Memory cost must be at least 1024 KB');
|
|
}
|
|
|
|
if ($timeCost < 1) {
|
|
throw new InvalidArgumentException('Time cost must be at least 1');
|
|
}
|
|
|
|
if ($threads < 1 || $threads > 24) {
|
|
throw new InvalidArgumentException('Threads must be between 1 and 24');
|
|
}
|
|
|
|
if ($keyLength < 16 || $keyLength > 256) {
|
|
throw new InvalidArgumentException('Key length must be between 16 and 256 bytes');
|
|
}
|
|
|
|
if (! function_exists('sodium_crypto_pwhash')) {
|
|
throw new InvalidArgumentException('Sodium extension required for Argon2ID');
|
|
}
|
|
|
|
if (! defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID')) {
|
|
throw new InvalidArgumentException('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID constant not available');
|
|
}
|
|
|
|
$derivedKey = sodium_crypto_pwhash(
|
|
$keyLength,
|
|
$password,
|
|
$salt,
|
|
$timeCost,
|
|
$memoryCost,
|
|
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID
|
|
);
|
|
|
|
return new DerivedKey(
|
|
key: $derivedKey,
|
|
salt: $salt,
|
|
algorithm: 'argon2id',
|
|
iterations: $timeCost,
|
|
keyLength: $keyLength,
|
|
memoryCost: $memoryCost,
|
|
threads: $threads
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive key using scrypt
|
|
*/
|
|
public function scrypt(
|
|
string $password,
|
|
string $salt,
|
|
int $costParameter = 16384, // N
|
|
int $blockSize = 8, // r
|
|
int $parallelization = 1, // p
|
|
int $keyLength = 32
|
|
): DerivedKey {
|
|
if (empty($password)) {
|
|
throw new InvalidArgumentException('Password cannot be empty');
|
|
}
|
|
|
|
if (strlen($salt) < 16) {
|
|
throw new InvalidArgumentException('Salt must be at least 16 bytes');
|
|
}
|
|
|
|
if ($costParameter < 1024) {
|
|
throw new InvalidArgumentException('Cost parameter must be at least 1024');
|
|
}
|
|
|
|
if ($blockSize < 1) {
|
|
throw new InvalidArgumentException('Block size must be at least 1');
|
|
}
|
|
|
|
if ($parallelization < 1) {
|
|
throw new InvalidArgumentException('Parallelization must be at least 1');
|
|
}
|
|
|
|
if ($keyLength < 16 || $keyLength > 256) {
|
|
throw new InvalidArgumentException('Key length must be between 16 and 256 bytes');
|
|
}
|
|
|
|
if (! function_exists('scrypt')) {
|
|
throw new InvalidArgumentException('scrypt function not available');
|
|
}
|
|
|
|
$derivedKey = scrypt(
|
|
$password,
|
|
$salt,
|
|
$costParameter,
|
|
$blockSize,
|
|
$parallelization,
|
|
$keyLength
|
|
);
|
|
|
|
return new DerivedKey(
|
|
key: $derivedKey,
|
|
salt: $salt,
|
|
algorithm: 'scrypt',
|
|
iterations: $costParameter,
|
|
keyLength: $keyLength,
|
|
blockSize: $blockSize,
|
|
parallelization: $parallelization
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verify password against derived key
|
|
*/
|
|
public function verify(string $password, DerivedKey $derivedKey): bool
|
|
{
|
|
try {
|
|
$verificationKey = match ($derivedKey->getAlgorithm()) {
|
|
'pbkdf2-sha256' => $this->pbkdf2(
|
|
$password,
|
|
$derivedKey->getSalt(),
|
|
$derivedKey->getIterations(),
|
|
$derivedKey->getKeyLength(),
|
|
'sha256'
|
|
),
|
|
'pbkdf2-sha512' => $this->pbkdf2(
|
|
$password,
|
|
$derivedKey->getSalt(),
|
|
$derivedKey->getIterations(),
|
|
$derivedKey->getKeyLength(),
|
|
'sha512'
|
|
),
|
|
'argon2id' => $this->argon2id(
|
|
$password,
|
|
$derivedKey->getSalt(),
|
|
$derivedKey->getMemoryCost() ?? 65536,
|
|
$derivedKey->getIterations(),
|
|
$derivedKey->getThreads() ?? 3,
|
|
$derivedKey->getKeyLength()
|
|
),
|
|
'scrypt' => $this->scrypt(
|
|
$password,
|
|
$derivedKey->getSalt(),
|
|
$derivedKey->getIterations(),
|
|
$derivedKey->getBlockSize() ?? 8,
|
|
$derivedKey->getParallelization() ?? 1,
|
|
$derivedKey->getKeyLength()
|
|
),
|
|
default => throw new InvalidArgumentException('Unsupported algorithm: ' . $derivedKey->getAlgorithm())
|
|
};
|
|
|
|
return hash_equals($derivedKey->getKey(), $verificationKey->getKey());
|
|
|
|
} catch (\Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hash password with automatic salt generation (recommended for new passwords)
|
|
*/
|
|
public function hashPassword(
|
|
string $password,
|
|
string $algorithm = 'argon2id',
|
|
array $options = []
|
|
): DerivedKey {
|
|
$salt = $this->generateSalt(32);
|
|
|
|
return match ($algorithm) {
|
|
'pbkdf2-sha256' => $this->pbkdf2(
|
|
$password,
|
|
$salt,
|
|
$options['iterations'] ?? 100000,
|
|
$options['key_length'] ?? 32,
|
|
'sha256'
|
|
),
|
|
'pbkdf2-sha512' => $this->pbkdf2(
|
|
$password,
|
|
$salt,
|
|
$options['iterations'] ?? 100000,
|
|
$options['key_length'] ?? 32,
|
|
'sha512'
|
|
),
|
|
'argon2id' => $this->argon2id(
|
|
$password,
|
|
$salt,
|
|
$options['memory_cost'] ?? 65536,
|
|
$options['time_cost'] ?? 4,
|
|
$options['threads'] ?? 3,
|
|
$options['key_length'] ?? 32
|
|
),
|
|
'scrypt' => $this->scrypt(
|
|
$password,
|
|
$salt,
|
|
$options['cost_parameter'] ?? 16384,
|
|
$options['block_size'] ?? 8,
|
|
$options['parallelization'] ?? 1,
|
|
$options['key_length'] ?? 32
|
|
),
|
|
default => throw new InvalidArgumentException("Unsupported algorithm: {$algorithm}")
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get recommended parameters for different security levels
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getRecommendedParameters(string $algorithm, string $securityLevel = 'standard'): array
|
|
{
|
|
return match ([$algorithm, $securityLevel]) {
|
|
['pbkdf2-sha256', 'low'] => ['iterations' => 50000, 'key_length' => 32],
|
|
['pbkdf2-sha256', 'standard'] => ['iterations' => 100000, 'key_length' => 32],
|
|
['pbkdf2-sha256', 'high'] => ['iterations' => 200000, 'key_length' => 32],
|
|
|
|
['argon2id', 'low'] => ['memory_cost' => 32768, 'time_cost' => 3, 'threads' => 2, 'key_length' => 32],
|
|
['argon2id', 'standard'] => ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 3, 'key_length' => 32],
|
|
['argon2id', 'high'] => ['memory_cost' => 131072, 'time_cost' => 6, 'threads' => 4, 'key_length' => 32],
|
|
|
|
['scrypt', 'low'] => ['cost_parameter' => 8192, 'block_size' => 8, 'parallelization' => 1, 'key_length' => 32],
|
|
['scrypt', 'standard'] => ['cost_parameter' => 16384, 'block_size' => 8, 'parallelization' => 1, 'key_length' => 32],
|
|
['scrypt', 'high'] => ['cost_parameter' => 32768, 'block_size' => 8, 'parallelization' => 2, 'key_length' => 32],
|
|
|
|
default => throw new InvalidArgumentException("Unsupported algorithm or security level: {$algorithm}, {$securityLevel}")
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Factory method
|
|
*/
|
|
public static function create(RandomGenerator $randomGenerator): self
|
|
{
|
|
return new self($randomGenerator);
|
|
}
|
|
}
|