Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
334
src/Framework/Cryptography/AdvancedHash.php
Normal file
334
src/Framework/Cryptography/AdvancedHash.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Advanced Hash Service
|
||||
*
|
||||
* Provides advanced cryptographic hash functions beyond basic MD5/SHA-1.
|
||||
* Supports SHA-3, BLAKE2, and other modern hash algorithms.
|
||||
*/
|
||||
final readonly class AdvancedHash
|
||||
{
|
||||
/**
|
||||
* Hash data using SHA-3
|
||||
*/
|
||||
public function sha3(string $data, int $length = 256): HashResult
|
||||
{
|
||||
$supportedLengths = [224, 256, 384, 512];
|
||||
if (! in_array($length, $supportedLengths, true)) {
|
||||
throw new InvalidArgumentException('SHA-3 length must be 224, 256, 384, or 512 bits');
|
||||
}
|
||||
|
||||
$algorithm = "sha3-{$length}";
|
||||
|
||||
if (! in_array($algorithm, hash_algos(), true)) {
|
||||
throw new InvalidArgumentException('SHA-3 not available in this PHP installation');
|
||||
}
|
||||
|
||||
$hash = hash($algorithm, $data, true);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: $algorithm,
|
||||
inputLength: strlen($data)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using BLAKE2b
|
||||
*/
|
||||
public function blake2b(string $data, int $length = 64, ?string $key = null): HashResult
|
||||
{
|
||||
if ($length < 1 || $length > 64) {
|
||||
throw new InvalidArgumentException('BLAKE2b length must be between 1 and 64 bytes');
|
||||
}
|
||||
|
||||
if (! function_exists('sodium_crypto_generichash')) {
|
||||
throw new InvalidArgumentException('Sodium extension required for BLAKE2b');
|
||||
}
|
||||
|
||||
if ($key !== null && (strlen($key) < 16 || strlen($key) > 64)) {
|
||||
throw new InvalidArgumentException('BLAKE2b key must be between 16 and 64 bytes');
|
||||
}
|
||||
|
||||
$hash = sodium_crypto_generichash($data, $key, $length);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: 'blake2b',
|
||||
inputLength: strlen($data),
|
||||
keyLength: $key ? strlen($key) : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using BLAKE2s (faster variant for smaller outputs)
|
||||
*/
|
||||
public function blake2s(string $data, int $length = 32, ?string $key = null): HashResult
|
||||
{
|
||||
if ($length < 1 || $length > 32) {
|
||||
throw new InvalidArgumentException('BLAKE2s length must be between 1 and 32 bytes');
|
||||
}
|
||||
|
||||
// Fallback to BLAKE2b if BLAKE2s not available
|
||||
if (! function_exists('sodium_crypto_generichash')) {
|
||||
throw new InvalidArgumentException('Sodium extension required for BLAKE2s');
|
||||
}
|
||||
|
||||
if ($key !== null && (strlen($key) < 16 || strlen($key) > 32)) {
|
||||
throw new InvalidArgumentException('BLAKE2s key must be between 16 and 32 bytes');
|
||||
}
|
||||
|
||||
// Note: PHP's sodium uses BLAKE2b, but we can limit output length
|
||||
$hash = sodium_crypto_generichash($data, $key, $length);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: 'blake2s',
|
||||
inputLength: strlen($data),
|
||||
keyLength: $key ? strlen($key) : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using SHAKE128 (extendable-output function)
|
||||
*/
|
||||
public function shake128(string $data, int $outputLength = 32): HashResult
|
||||
{
|
||||
if ($outputLength < 1 || $outputLength > 1024) {
|
||||
throw new InvalidArgumentException('SHAKE128 output length must be between 1 and 1024 bytes');
|
||||
}
|
||||
|
||||
// Check if SHAKE128 is available
|
||||
if (! in_array('shake128', hash_algos(), true)) {
|
||||
throw new InvalidArgumentException('SHAKE128 not available in this PHP installation');
|
||||
}
|
||||
|
||||
$hash = hash('shake128', $data, true, ['length' => $outputLength]);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: 'shake128',
|
||||
inputLength: strlen($data),
|
||||
outputLength: $outputLength
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using SHAKE256 (extendable-output function)
|
||||
*/
|
||||
public function shake256(string $data, int $outputLength = 64): HashResult
|
||||
{
|
||||
if ($outputLength < 1 || $outputLength > 1024) {
|
||||
throw new InvalidArgumentException('SHAKE256 output length must be between 1 and 1024 bytes');
|
||||
}
|
||||
|
||||
// Check if SHAKE256 is available
|
||||
if (! in_array('shake256', hash_algos(), true)) {
|
||||
throw new InvalidArgumentException('SHAKE256 not available in this PHP installation');
|
||||
}
|
||||
|
||||
$hash = hash('shake256', $data, true, ['length' => $outputLength]);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: 'shake256',
|
||||
inputLength: strlen($data),
|
||||
outputLength: $outputLength
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data using xxHash (fast non-cryptographic hash)
|
||||
*/
|
||||
public function xxhash(string $data, int $seed = 0): HashResult
|
||||
{
|
||||
if (! function_exists('hash')) {
|
||||
throw new InvalidArgumentException('Hash extension required');
|
||||
}
|
||||
|
||||
// xxHash may not be available in all PHP installations
|
||||
if (in_array('xxh64', hash_algos(), true)) {
|
||||
$hash = hash('xxh64', $data, true);
|
||||
$algorithm = 'xxh64';
|
||||
} elseif (in_array('xxh32', hash_algos(), true)) {
|
||||
$hash = hash('xxh32', $data, true);
|
||||
$algorithm = 'xxh32';
|
||||
} else {
|
||||
// Fallback to CRC32 for fast non-cryptographic hash
|
||||
$hash = pack('N', crc32($data));
|
||||
$algorithm = 'crc32';
|
||||
}
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: $algorithm,
|
||||
inputLength: strlen($data),
|
||||
seed: $seed !== 0 ? $seed : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute HMAC with advanced hash functions
|
||||
*/
|
||||
public function hmac(string $data, string $key, string $algorithm = 'sha3-256'): HashResult
|
||||
{
|
||||
if (empty($key)) {
|
||||
throw new InvalidArgumentException('HMAC key cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($key) < 16) {
|
||||
throw new InvalidArgumentException('HMAC key should be at least 16 bytes');
|
||||
}
|
||||
|
||||
// For BLAKE2b with key, use keyed hashing directly
|
||||
if ($algorithm === 'blake2b') {
|
||||
return $this->blake2b($data, 64, $key);
|
||||
}
|
||||
|
||||
if ($algorithm === 'blake2s') {
|
||||
return $this->blake2s($data, 32, $key);
|
||||
}
|
||||
|
||||
// Standard HMAC for other algorithms
|
||||
$supportedAlgorithms = ['sha3-256', 'sha3-512', 'sha256', 'sha512'];
|
||||
if (! in_array($algorithm, $supportedAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported HMAC algorithm');
|
||||
}
|
||||
|
||||
if (! in_array($algorithm, hash_algos(), true)) {
|
||||
throw new InvalidArgumentException("Algorithm {$algorithm} not available");
|
||||
}
|
||||
|
||||
$hash = hash_hmac($algorithm, $data, $key, true);
|
||||
|
||||
return new HashResult(
|
||||
hash: $hash,
|
||||
algorithm: "hmac-{$algorithm}",
|
||||
inputLength: strlen($data),
|
||||
keyLength: strlen($key)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hash against expected value (timing-safe comparison)
|
||||
*/
|
||||
public function verify(string $data, HashResult $expectedHash): bool
|
||||
{
|
||||
try {
|
||||
$computedHash = match ($expectedHash->getAlgorithm()) {
|
||||
'sha3-224' => $this->sha3($data, 224),
|
||||
'sha3-256' => $this->sha3($data, 256),
|
||||
'sha3-384' => $this->sha3($data, 384),
|
||||
'sha3-512' => $this->sha3($data, 512),
|
||||
'blake2b' => $this->blake2b(
|
||||
$data,
|
||||
$expectedHash->getOutputLength() ?? 64,
|
||||
null // Key not available for verification
|
||||
),
|
||||
'blake2s' => $this->blake2s(
|
||||
$data,
|
||||
$expectedHash->getOutputLength() ?? 32,
|
||||
null // Key not available for verification
|
||||
),
|
||||
'shake128' => $this->shake128($data, $expectedHash->getOutputLength() ?? 32),
|
||||
'shake256' => $this->shake256($data, $expectedHash->getOutputLength() ?? 64),
|
||||
default => throw new InvalidArgumentException('Unsupported algorithm for verification')
|
||||
};
|
||||
|
||||
return hash_equals($expectedHash->getHash(), $computedHash->getHash());
|
||||
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash file using advanced algorithms
|
||||
*/
|
||||
public function hashFile(string $filePath, string $algorithm = 'sha3-256'): HashResult
|
||||
{
|
||||
if (! file_exists($filePath)) {
|
||||
throw new InvalidArgumentException('File does not exist');
|
||||
}
|
||||
|
||||
if (! is_readable($filePath)) {
|
||||
throw new InvalidArgumentException('File is not readable');
|
||||
}
|
||||
|
||||
$fileSize = filesize($filePath);
|
||||
if ($fileSize === false) {
|
||||
throw new InvalidArgumentException('Cannot determine file size');
|
||||
}
|
||||
|
||||
$data = file_get_contents($filePath);
|
||||
if ($data === false) {
|
||||
throw new InvalidArgumentException('Cannot read file contents');
|
||||
}
|
||||
|
||||
return match ($algorithm) {
|
||||
'sha3-224' => $this->sha3($data, 224),
|
||||
'sha3-256' => $this->sha3($data, 256),
|
||||
'sha3-384' => $this->sha3($data, 384),
|
||||
'sha3-512' => $this->sha3($data, 512),
|
||||
'blake2b' => $this->blake2b($data),
|
||||
'blake2s' => $this->blake2s($data),
|
||||
'shake128' => $this->shake128($data),
|
||||
'shake256' => $this->shake256($data),
|
||||
default => throw new InvalidArgumentException("Unsupported algorithm: {$algorithm}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available hash algorithms
|
||||
*/
|
||||
public function getAvailableAlgorithms(): array
|
||||
{
|
||||
$algorithms = [];
|
||||
|
||||
// SHA-3 variants
|
||||
foreach ([224, 256, 384, 512] as $length) {
|
||||
$algo = "sha3-{$length}";
|
||||
if (in_array($algo, hash_algos(), true)) {
|
||||
$algorithms[] = $algo;
|
||||
}
|
||||
}
|
||||
|
||||
// BLAKE2 variants
|
||||
if (function_exists('sodium_crypto_generichash')) {
|
||||
$algorithms[] = 'blake2b';
|
||||
$algorithms[] = 'blake2s';
|
||||
}
|
||||
|
||||
// SHAKE variants
|
||||
if (in_array('shake128', hash_algos(), true)) {
|
||||
$algorithms[] = 'shake128';
|
||||
}
|
||||
if (in_array('shake256', hash_algos(), true)) {
|
||||
$algorithms[] = 'shake256';
|
||||
}
|
||||
|
||||
// xxHash variants
|
||||
if (in_array('xxh64', hash_algos(), true)) {
|
||||
$algorithms[] = 'xxh64';
|
||||
}
|
||||
if (in_array('xxh32', hash_algos(), true)) {
|
||||
$algorithms[] = 'xxh32';
|
||||
}
|
||||
|
||||
return $algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
104
src/Framework/Cryptography/ConstantTimeExecutor.php
Normal file
104
src/Framework/Cryptography/ConstantTimeExecutor.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\HighResolutionClock;
|
||||
use App\Framework\DateTime\Timer;
|
||||
|
||||
/**
|
||||
* Constant Time Executor
|
||||
*
|
||||
* Executes operations in constant time to prevent timing attacks.
|
||||
* Enforces a minimum execution time by sleeping if the operation
|
||||
* completes faster than the target duration.
|
||||
*
|
||||
* Particularly important for:
|
||||
* - Password verification
|
||||
* - TOTP verification
|
||||
* - Token validation
|
||||
* - Any security-sensitive comparison operations
|
||||
*/
|
||||
final readonly class ConstantTimeExecutor
|
||||
{
|
||||
private Duration $targetDuration;
|
||||
|
||||
public function __construct(
|
||||
private HighResolutionClock $clock,
|
||||
private Timer $timer,
|
||||
?Duration $targetDuration = null
|
||||
) {
|
||||
$this->targetDuration = $targetDuration ?? Duration::fromMilliseconds(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create with millisecond target duration
|
||||
*/
|
||||
public static function withMilliseconds(
|
||||
HighResolutionClock $clock,
|
||||
Timer $timer,
|
||||
int $milliseconds
|
||||
): self {
|
||||
return new self($clock, $timer, Duration::fromMilliseconds($milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation in constant time
|
||||
*/
|
||||
public function execute(callable $operation): mixed
|
||||
{
|
||||
$startTime = $this->clock->hrtime();
|
||||
|
||||
try {
|
||||
$result = $operation();
|
||||
} finally {
|
||||
$this->enforceConstantTime($startTime);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation and return both result and actual duration
|
||||
*/
|
||||
public function executeWithTiming(callable $operation): array
|
||||
{
|
||||
$startTime = $this->clock->hrtime();
|
||||
$result = $operation();
|
||||
$operationEndTime = $this->clock->hrtime();
|
||||
|
||||
$this->enforceConstantTime($startTime);
|
||||
$totalEndTime = $this->clock->hrtime();
|
||||
|
||||
return [
|
||||
'result' => $result,
|
||||
'operation_duration' => $operationEndTime->subtract($startTime),
|
||||
'total_duration' => $totalEndTime->subtract($startTime),
|
||||
'sleep_duration' => $totalEndTime->subtract($operationEndTime),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured target duration
|
||||
*/
|
||||
public function getTargetDuration(): Duration
|
||||
{
|
||||
return $this->targetDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce constant time by sleeping if needed
|
||||
*/
|
||||
private function enforceConstantTime(Duration $startTime): void
|
||||
{
|
||||
$currentTime = $this->clock->hrtime();
|
||||
$elapsed = $currentTime->subtract($startTime);
|
||||
|
||||
if ($elapsed->toNanoseconds() < $this->targetDuration->toNanoseconds()) {
|
||||
$sleepDuration = $this->targetDuration->subtract($elapsed);
|
||||
$this->timer->sleep($sleepDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
380
src/Framework/Cryptography/CryptographicUtilities.php
Normal file
380
src/Framework/Cryptography/CryptographicUtilities.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Cryptographic Utilities Service
|
||||
*
|
||||
* Provides various cryptographic utility functions including timing-safe
|
||||
* comparisons, secure random validation, and constant-time operations.
|
||||
*/
|
||||
final readonly class CryptographicUtilities
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing-safe string comparison
|
||||
*/
|
||||
public function timingSafeEquals(string $known, string $user): bool
|
||||
{
|
||||
return hash_equals($known, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing-safe comparison of arrays
|
||||
*/
|
||||
public function timingSafeArrayEquals(array $known, array $user): bool
|
||||
{
|
||||
if (count($known) !== count($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sort both arrays to ensure consistent comparison
|
||||
ksort($known);
|
||||
ksort($user);
|
||||
|
||||
$knownSerialized = serialize($known);
|
||||
$userSerialized = serialize($user);
|
||||
|
||||
return hash_equals($knownSerialized, $userSerialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure nonce
|
||||
*/
|
||||
public function generateNonce(int $length = 32): string
|
||||
{
|
||||
if ($length < 8) {
|
||||
throw new InvalidArgumentException('Nonce length must be at least 8 bytes');
|
||||
}
|
||||
|
||||
if ($length > 256) {
|
||||
throw new InvalidArgumentException('Nonce length cannot exceed 256 bytes');
|
||||
}
|
||||
|
||||
return $this->randomGenerator->bytes($length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initialization vector (IV)
|
||||
*/
|
||||
public function generateIv(int $length = 16): string
|
||||
{
|
||||
if ($length < 8) {
|
||||
throw new InvalidArgumentException('IV length must be at least 8 bytes');
|
||||
}
|
||||
|
||||
if ($length > 32) {
|
||||
throw new InvalidArgumentException('IV length cannot exceed 32 bytes');
|
||||
}
|
||||
|
||||
return $this->randomGenerator->bytes($length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entropy of random data
|
||||
*/
|
||||
public function validateEntropy(string $data, float $minimumEntropy = 7.0): bool
|
||||
{
|
||||
if (empty($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entropy = $this->calculateShannonEntropy($data);
|
||||
|
||||
return $entropy >= $minimumEntropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Shannon entropy of data
|
||||
*/
|
||||
public function calculateShannonEntropy(string $data): float
|
||||
{
|
||||
if (empty($data)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$length = strlen($data);
|
||||
$frequencies = [];
|
||||
|
||||
// Count byte frequencies
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$byte = ord($data[$i]);
|
||||
$frequencies[$byte] = ($frequencies[$byte] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$entropy = 0.0;
|
||||
|
||||
foreach ($frequencies as $frequency) {
|
||||
$probability = $frequency / $length;
|
||||
if ($probability > 0) {
|
||||
$entropy -= $probability * log($probability, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return $entropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time modular exponentiation
|
||||
*/
|
||||
public function constantTimeModPow(string $base, string $exponent, string $modulus): string
|
||||
{
|
||||
if (! extension_loaded('gmp')) {
|
||||
throw new InvalidArgumentException('GMP extension required for modular exponentiation');
|
||||
}
|
||||
|
||||
$baseGmp = gmp_init($base, 10);
|
||||
$exponentGmp = gmp_init($exponent, 10);
|
||||
$modulusGmp = gmp_init($modulus, 10);
|
||||
|
||||
if ($baseGmp === false || $exponentGmp === false || $modulusGmp === false) {
|
||||
throw new InvalidArgumentException('Invalid numeric input');
|
||||
}
|
||||
|
||||
$result = gmp_powm($baseGmp, $exponentGmp, $modulusGmp);
|
||||
|
||||
return gmp_strval($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure memory wipe (attempt to clear sensitive data)
|
||||
*/
|
||||
public function secureWipe(string &$data): void
|
||||
{
|
||||
if (function_exists('sodium_memzero')) {
|
||||
sodium_memzero($data);
|
||||
} else {
|
||||
// Fallback: overwrite with random data multiple times
|
||||
$length = strlen($data);
|
||||
|
||||
for ($pass = 0; $pass < 3; $pass++) {
|
||||
$data = $this->randomGenerator->bytes($length);
|
||||
}
|
||||
|
||||
$data = str_repeat("\0", $length);
|
||||
}
|
||||
|
||||
$data = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure UUID v4
|
||||
*/
|
||||
public function generateUuid4(): string
|
||||
{
|
||||
$data = $this->randomGenerator->bytes(16);
|
||||
|
||||
// Set version to 4
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
|
||||
// Set variant
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
bin2hex(substr($data, 0, 4)),
|
||||
bin2hex(substr($data, 4, 2)),
|
||||
bin2hex(substr($data, 6, 2)),
|
||||
bin2hex(substr($data, 8, 2)),
|
||||
bin2hex(substr($data, 10, 6))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key stretching using iteration
|
||||
*/
|
||||
public function stretchKey(
|
||||
string $key,
|
||||
string $salt,
|
||||
int $iterations = 10000,
|
||||
int $outputLength = 32
|
||||
): string {
|
||||
if ($iterations < 1000) {
|
||||
throw new InvalidArgumentException('Iterations must be at least 1000');
|
||||
}
|
||||
|
||||
if ($outputLength < 16 || $outputLength > 256) {
|
||||
throw new InvalidArgumentException('Output length must be between 16 and 256 bytes');
|
||||
}
|
||||
|
||||
return hash_pbkdf2('sha256', $key, $salt, $iterations, $outputLength, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* XOR two strings (for XOR cipher operations)
|
||||
*/
|
||||
public function xorStrings(string $str1, string $str2): string
|
||||
{
|
||||
$length = min(strlen($str1), strlen($str2));
|
||||
$result = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$result .= chr(ord($str1[$i]) ^ ord($str2[$i]));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure random padding
|
||||
*/
|
||||
public function generatePadding(int $blockSize, int $dataLength): string
|
||||
{
|
||||
if ($blockSize < 1 || $blockSize > 256) {
|
||||
throw new InvalidArgumentException('Block size must be between 1 and 256');
|
||||
}
|
||||
|
||||
$paddingLength = $blockSize - ($dataLength % $blockSize);
|
||||
|
||||
// PKCS#7 padding
|
||||
return str_repeat(chr($paddingLength), $paddingLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove PKCS#7 padding
|
||||
*/
|
||||
public function removePadding(string $data): string
|
||||
{
|
||||
if (empty($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$paddingLength = ord($data[strlen($data) - 1]);
|
||||
|
||||
if ($paddingLength < 1 || $paddingLength > 16) {
|
||||
throw new InvalidArgumentException('Invalid padding');
|
||||
}
|
||||
|
||||
// Verify padding
|
||||
$padding = substr($data, -$paddingLength);
|
||||
if ($padding !== str_repeat(chr($paddingLength), $paddingLength)) {
|
||||
throw new InvalidArgumentException('Invalid padding format');
|
||||
}
|
||||
|
||||
return substr($data, 0, -$paddingLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure bit string
|
||||
*/
|
||||
public function generateBitString(int $bits): string
|
||||
{
|
||||
if ($bits < 8) {
|
||||
throw new InvalidArgumentException('Bit length must be at least 8');
|
||||
}
|
||||
|
||||
if ($bits % 8 !== 0) {
|
||||
throw new InvalidArgumentException('Bit length must be divisible by 8');
|
||||
}
|
||||
|
||||
$bytes = intval($bits / 8);
|
||||
$randomBytes = $this->randomGenerator->bytes($bytes);
|
||||
|
||||
$bitString = '';
|
||||
for ($i = 0; $i < $bytes; $i++) {
|
||||
$bitString .= sprintf('%08b', ord($randomBytes[$i]));
|
||||
}
|
||||
|
||||
return $bitString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bit string to bytes
|
||||
*/
|
||||
public function bitStringToBytes(string $bitString): string
|
||||
{
|
||||
if (strlen($bitString) % 8 !== 0) {
|
||||
throw new InvalidArgumentException('Bit string length must be divisible by 8');
|
||||
}
|
||||
|
||||
if (! preg_match('/^[01]+$/', $bitString)) {
|
||||
throw new InvalidArgumentException('Bit string contains invalid characters');
|
||||
}
|
||||
|
||||
$bytes = '';
|
||||
$chunks = str_split($bitString, 8);
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$bytes .= chr(bindec($chunk));
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cryptographic key strength
|
||||
*/
|
||||
public function validateKeyStrength(string $key, int $minimumBits = 128): bool
|
||||
{
|
||||
$keyLengthBits = strlen($key) * 8;
|
||||
|
||||
if ($keyLengthBits < $minimumBits) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check entropy
|
||||
return $this->validateEntropy($key, 6.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic UUID from data (UUID v5)
|
||||
*/
|
||||
public function generateUuid5(string $namespace, string $name): string
|
||||
{
|
||||
if (strlen($namespace) !== 16) {
|
||||
throw new InvalidArgumentException('Namespace must be 16 bytes');
|
||||
}
|
||||
|
||||
$hash = hash('sha1', $namespace . $name, true);
|
||||
|
||||
// Set version to 5
|
||||
$hash[6] = chr(ord($hash[6]) & 0x0f | 0x50);
|
||||
|
||||
// Set variant
|
||||
$hash[8] = chr(ord($hash[8]) & 0x3f | 0x80);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
bin2hex(substr($hash, 0, 4)),
|
||||
bin2hex(substr($hash, 4, 2)),
|
||||
bin2hex(substr($hash, 6, 2)),
|
||||
bin2hex(substr($hash, 8, 2)),
|
||||
bin2hex(substr($hash, 10, 6))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time array search
|
||||
*/
|
||||
public function constantTimeArraySearch(array $haystack, mixed $needle): bool
|
||||
{
|
||||
$found = false;
|
||||
|
||||
foreach ($haystack as $value) {
|
||||
if (is_string($value) && is_string($needle)) {
|
||||
$found = $found || hash_equals($value, $needle);
|
||||
} else {
|
||||
$found = $found || ($value === $needle);
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator): self
|
||||
{
|
||||
return new self($randomGenerator);
|
||||
}
|
||||
}
|
||||
316
src/Framework/Cryptography/DerivedKey.php
Normal file
316
src/Framework/Cryptography/DerivedKey.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Derived Key Value Object
|
||||
*
|
||||
* Represents a key derived from a password using key derivation functions.
|
||||
* Contains the derived key material and all parameters needed for verification.
|
||||
*/
|
||||
final readonly class DerivedKey
|
||||
{
|
||||
public function __construct(
|
||||
private string $key,
|
||||
private string $salt,
|
||||
private string $algorithm,
|
||||
private int $iterations,
|
||||
private int $keyLength,
|
||||
private ?int $memoryCost = null,
|
||||
private ?int $threads = null,
|
||||
private ?int $blockSize = null,
|
||||
private ?int $parallelization = null
|
||||
) {
|
||||
if (empty($key)) {
|
||||
throw new InvalidArgumentException('Key cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($salt)) {
|
||||
throw new InvalidArgumentException('Salt cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($algorithm)) {
|
||||
throw new InvalidArgumentException('Algorithm cannot be empty');
|
||||
}
|
||||
|
||||
if ($iterations < 1) {
|
||||
throw new InvalidArgumentException('Iterations must be positive');
|
||||
}
|
||||
|
||||
if ($keyLength < 1) {
|
||||
throw new InvalidArgumentException('Key length must be positive');
|
||||
}
|
||||
|
||||
if (strlen($key) !== $keyLength) {
|
||||
throw new InvalidArgumentException('Key length does not match specified length');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived key bytes
|
||||
*/
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the salt used for derivation
|
||||
*/
|
||||
public function getSalt(): string
|
||||
{
|
||||
return $this->salt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the algorithm used
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the iteration count
|
||||
*/
|
||||
public function getIterations(): int
|
||||
{
|
||||
return $this->iterations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key length in bytes
|
||||
*/
|
||||
public function getKeyLength(): int
|
||||
{
|
||||
return $this->keyLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory cost (Argon2 only)
|
||||
*/
|
||||
public function getMemoryCost(): ?int
|
||||
{
|
||||
return $this->memoryCost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thread count (Argon2 only)
|
||||
*/
|
||||
public function getThreads(): ?int
|
||||
{
|
||||
return $this->threads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block size (scrypt only)
|
||||
*/
|
||||
public function getBlockSize(): ?int
|
||||
{
|
||||
return $this->blockSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parallelization factor (scrypt only)
|
||||
*/
|
||||
public function getParallelization(): ?int
|
||||
{
|
||||
return $this->parallelization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key as hexadecimal string
|
||||
*/
|
||||
public function getKeyHex(): string
|
||||
{
|
||||
return bin2hex($this->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the salt as hexadecimal string
|
||||
*/
|
||||
public function getSaltHex(): string
|
||||
{
|
||||
return bin2hex($this->salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key as Base64 string
|
||||
*/
|
||||
public function getKeyBase64(): string
|
||||
{
|
||||
return base64_encode($this->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get salt as Base64 string
|
||||
*/
|
||||
public function getSaltBase64(): string
|
||||
{
|
||||
return base64_encode($this->salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another DerivedKey
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->key, $other->key) &&
|
||||
hash_equals($this->salt, $other->salt) &&
|
||||
$this->algorithm === $other->algorithm &&
|
||||
$this->iterations === $other->iterations &&
|
||||
$this->keyLength === $other->keyLength &&
|
||||
$this->memoryCost === $other->memoryCost &&
|
||||
$this->threads === $other->threads &&
|
||||
$this->blockSize === $other->blockSize &&
|
||||
$this->parallelization === $other->parallelization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to array (for serialization)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'key' => $this->getKeyBase64(),
|
||||
'salt' => $this->getSaltBase64(),
|
||||
'algorithm' => $this->algorithm,
|
||||
'iterations' => $this->iterations,
|
||||
'key_length' => $this->keyLength,
|
||||
];
|
||||
|
||||
if ($this->memoryCost !== null) {
|
||||
$data['memory_cost'] = $this->memoryCost;
|
||||
}
|
||||
|
||||
if ($this->threads !== null) {
|
||||
$data['threads'] = $this->threads;
|
||||
}
|
||||
|
||||
if ($this->blockSize !== null) {
|
||||
$data['block_size'] = $this->blockSize;
|
||||
}
|
||||
|
||||
if ($this->parallelization !== null) {
|
||||
$data['parallelization'] = $this->parallelization;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$requiredFields = ['key', 'salt', 'algorithm', 'iterations', 'key_length'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$key = base64_decode($data['key'], true);
|
||||
$salt = base64_decode($data['salt'], true);
|
||||
|
||||
if ($key === false || $salt === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 encoding');
|
||||
}
|
||||
} catch (\Exception) {
|
||||
throw new InvalidArgumentException('Failed to decode Base64 data');
|
||||
}
|
||||
|
||||
return new self(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: $data['algorithm'],
|
||||
iterations: (int)$data['iterations'],
|
||||
keyLength: (int)$data['key_length'],
|
||||
memoryCost: isset($data['memory_cost']) ? (int)$data['memory_cost'] : null,
|
||||
threads: isset($data['threads']) ? (int)$data['threads'] : null,
|
||||
blockSize: isset($data['block_size']) ? (int)$data['block_size'] : null,
|
||||
parallelization: isset($data['parallelization']) ? (int)$data['parallelization'] : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from hexadecimal strings
|
||||
*/
|
||||
public static function fromHex(
|
||||
string $keyHex,
|
||||
string $saltHex,
|
||||
string $algorithm,
|
||||
int $iterations,
|
||||
int $keyLength,
|
||||
?int $memoryCost = null,
|
||||
?int $threads = null,
|
||||
?int $blockSize = null,
|
||||
?int $parallelization = null
|
||||
): self {
|
||||
$key = hex2bin($keyHex);
|
||||
$salt = hex2bin($saltHex);
|
||||
|
||||
if ($key === false || $salt === false) {
|
||||
throw new InvalidArgumentException('Invalid hexadecimal encoding');
|
||||
}
|
||||
|
||||
return new self(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: $algorithm,
|
||||
iterations: $iterations,
|
||||
keyLength: $keyLength,
|
||||
memoryCost: $memoryCost,
|
||||
threads: $threads,
|
||||
blockSize: $blockSize,
|
||||
parallelization: $parallelization
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary information (safe for logging)
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'algorithm' => $this->algorithm,
|
||||
'iterations' => $this->iterations,
|
||||
'key_length' => $this->keyLength,
|
||||
'salt_length' => strlen($this->salt),
|
||||
'memory_cost' => $this->memoryCost,
|
||||
'threads' => $this->threads,
|
||||
'block_size' => $this->blockSize,
|
||||
'parallelization' => $this->parallelization,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an Argon2 derived key
|
||||
*/
|
||||
public function isArgon2(): bool
|
||||
{
|
||||
return $this->algorithm === 'argon2id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a PBKDF2 derived key
|
||||
*/
|
||||
public function isPbkdf2(): bool
|
||||
{
|
||||
return str_starts_with($this->algorithm, 'pbkdf2-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a scrypt derived key
|
||||
*/
|
||||
public function isScrypt(): bool
|
||||
{
|
||||
return $this->algorithm === 'scrypt';
|
||||
}
|
||||
}
|
||||
313
src/Framework/Cryptography/DigitalSignature.php
Normal file
313
src/Framework/Cryptography/DigitalSignature.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Digital Signature Service
|
||||
*
|
||||
* Provides digital signature creation and verification using RSA and ECDSA algorithms.
|
||||
* Supports various hash algorithms and key formats.
|
||||
*/
|
||||
final readonly class DigitalSignature
|
||||
{
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RSA key pair
|
||||
*/
|
||||
public function generateRsaKeyPair(int $keySize = 2048): KeyPair
|
||||
{
|
||||
if (! in_array($keySize, [2048, 3072, 4096], true)) {
|
||||
throw new InvalidArgumentException('RSA key size must be 2048, 3072, or 4096 bits');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => $keySize,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Failed to generate RSA key pair');
|
||||
}
|
||||
|
||||
// Extract private key
|
||||
if (! openssl_pkey_export($resource, $privateKey)) {
|
||||
throw new InvalidArgumentException('Failed to export RSA private key');
|
||||
}
|
||||
|
||||
// Extract public key
|
||||
$publicKeyDetails = openssl_pkey_get_details($resource);
|
||||
if ($publicKeyDetails === false) {
|
||||
throw new InvalidArgumentException('Failed to extract RSA public key');
|
||||
}
|
||||
|
||||
$publicKey = $publicKeyDetails['key'];
|
||||
|
||||
return new KeyPair(
|
||||
privateKey: new PrivateKey($privateKey, 'rsa', $keySize),
|
||||
publicKey: new PublicKey($publicKey, 'rsa', $keySize)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ECDSA key pair
|
||||
*/
|
||||
public function generateEcdsaKeyPair(string $curve = 'prime256v1'): KeyPair
|
||||
{
|
||||
$supportedCurves = ['prime256v1', 'secp384r1', 'secp521r1'];
|
||||
if (! in_array($curve, $supportedCurves, true)) {
|
||||
throw new InvalidArgumentException('Unsupported ECDSA curve');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => $curve,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Failed to generate ECDSA key pair');
|
||||
}
|
||||
|
||||
// Extract private key
|
||||
if (! openssl_pkey_export($resource, $privateKey)) {
|
||||
throw new InvalidArgumentException('Failed to export ECDSA private key');
|
||||
}
|
||||
|
||||
// Extract public key
|
||||
$publicKeyDetails = openssl_pkey_get_details($resource);
|
||||
if ($publicKeyDetails === false) {
|
||||
throw new InvalidArgumentException('Failed to extract ECDSA public key');
|
||||
}
|
||||
|
||||
$publicKey = $publicKeyDetails['key'];
|
||||
$keySize = $publicKeyDetails['bits'] ?? 256;
|
||||
|
||||
return new KeyPair(
|
||||
privateKey: new PrivateKey($privateKey, 'ecdsa', $keySize, $curve),
|
||||
publicKey: new PublicKey($publicKey, 'ecdsa', $keySize, $curve)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with private key
|
||||
*/
|
||||
public function sign(
|
||||
string $data,
|
||||
PrivateKey $privateKey,
|
||||
string $hashAlgorithm = 'sha256'
|
||||
): DigitalSignatureResult {
|
||||
if (empty($data)) {
|
||||
throw new InvalidArgumentException('Data to sign cannot be empty');
|
||||
}
|
||||
|
||||
$supportedAlgorithms = ['sha256', 'sha384', 'sha512', 'sha224'];
|
||||
if (! in_array($hashAlgorithm, $supportedAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported hash algorithm');
|
||||
}
|
||||
|
||||
$privateKeyResource = openssl_pkey_get_private($privateKey->getKeyMaterial());
|
||||
if ($privateKeyResource === false) {
|
||||
throw new InvalidArgumentException('Invalid private key');
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
$success = openssl_sign($data, $signature, $privateKeyResource, $hashAlgorithm);
|
||||
|
||||
if (! $success) {
|
||||
throw new InvalidArgumentException('Failed to create signature');
|
||||
}
|
||||
|
||||
return new DigitalSignatureResult(
|
||||
signature: $signature,
|
||||
algorithm: $privateKey->getAlgorithm(),
|
||||
hashAlgorithm: $hashAlgorithm,
|
||||
keySize: $privateKey->getKeySize(),
|
||||
curve: $privateKey->getCurve()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify signature with public key
|
||||
*/
|
||||
public function verify(
|
||||
string $data,
|
||||
DigitalSignatureResult $signature,
|
||||
PublicKey $publicKey
|
||||
): bool {
|
||||
if (empty($data)) {
|
||||
throw new InvalidArgumentException('Data to verify cannot be empty');
|
||||
}
|
||||
|
||||
if ($signature->getAlgorithm() !== $publicKey->getAlgorithm()) {
|
||||
throw new InvalidArgumentException('Algorithm mismatch between signature and public key');
|
||||
}
|
||||
|
||||
$publicKeyResource = openssl_pkey_get_public($publicKey->getKeyMaterial());
|
||||
if ($publicKeyResource === false) {
|
||||
throw new InvalidArgumentException('Invalid public key');
|
||||
}
|
||||
|
||||
$result = openssl_verify(
|
||||
$data,
|
||||
$signature->getSignature(),
|
||||
$publicKeyResource,
|
||||
$signature->getHashAlgorithm()
|
||||
);
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign JSON data (useful for API signatures)
|
||||
*/
|
||||
public function signJson(
|
||||
array $data,
|
||||
PrivateKey $privateKey,
|
||||
string $hashAlgorithm = 'sha256'
|
||||
): DigitalSignatureResult {
|
||||
// Sort keys for consistent signing
|
||||
ksort($data);
|
||||
|
||||
$jsonData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($jsonData === false) {
|
||||
throw new InvalidArgumentException('Failed to encode JSON data');
|
||||
}
|
||||
|
||||
return $this->sign($jsonData, $privateKey, $hashAlgorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JSON signature
|
||||
*/
|
||||
public function verifyJson(
|
||||
array $data,
|
||||
DigitalSignatureResult $signature,
|
||||
PublicKey $publicKey
|
||||
): bool {
|
||||
// Sort keys for consistent verification
|
||||
ksort($data);
|
||||
|
||||
$jsonData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($jsonData === false) {
|
||||
throw new InvalidArgumentException('Failed to encode JSON data');
|
||||
}
|
||||
|
||||
return $this->verify($jsonData, $signature, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detached signature (signature separate from data)
|
||||
*/
|
||||
public function createDetachedSignature(
|
||||
string $data,
|
||||
PrivateKey $privateKey,
|
||||
string $hashAlgorithm = 'sha256'
|
||||
): string {
|
||||
$signature = $this->sign($data, $privateKey, $hashAlgorithm);
|
||||
|
||||
return base64_encode($signature->getSignature());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify detached signature
|
||||
*/
|
||||
public function verifyDetachedSignature(
|
||||
string $data,
|
||||
string $base64Signature,
|
||||
PublicKey $publicKey,
|
||||
string $hashAlgorithm = 'sha256'
|
||||
): bool {
|
||||
$signature = base64_decode($base64Signature, true);
|
||||
if ($signature === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 signature');
|
||||
}
|
||||
|
||||
$signatureResult = new DigitalSignatureResult(
|
||||
signature: $signature,
|
||||
algorithm: $publicKey->getAlgorithm(),
|
||||
hashAlgorithm: $hashAlgorithm,
|
||||
keySize: $publicKey->getKeySize(),
|
||||
curve: $publicKey->getCurve()
|
||||
);
|
||||
|
||||
return $this->verify($data, $signatureResult, $publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load private key from PEM string
|
||||
*/
|
||||
public function loadPrivateKey(string $pemData, ?string $passphrase = null): PrivateKey
|
||||
{
|
||||
$resource = openssl_pkey_get_private($pemData, $passphrase ?? '');
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Invalid private key PEM data');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
if ($details === false) {
|
||||
throw new InvalidArgumentException('Failed to get private key details');
|
||||
}
|
||||
|
||||
$algorithm = match ($details['type']) {
|
||||
OPENSSL_KEYTYPE_RSA => 'rsa',
|
||||
OPENSSL_KEYTYPE_EC => 'ecdsa',
|
||||
default => throw new InvalidArgumentException('Unsupported key type')
|
||||
};
|
||||
|
||||
$curve = null;
|
||||
if ($algorithm === 'ecdsa' && isset($details['ec']['curve_name'])) {
|
||||
$curve = $details['ec']['curve_name'];
|
||||
}
|
||||
|
||||
return new PrivateKey($pemData, $algorithm, $details['bits'], $curve);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public key from PEM string
|
||||
*/
|
||||
public function loadPublicKey(string $pemData): PublicKey
|
||||
{
|
||||
$resource = openssl_pkey_get_public($pemData);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Invalid public key PEM data');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
if ($details === false) {
|
||||
throw new InvalidArgumentException('Failed to get public key details');
|
||||
}
|
||||
|
||||
$algorithm = match ($details['type']) {
|
||||
OPENSSL_KEYTYPE_RSA => 'rsa',
|
||||
OPENSSL_KEYTYPE_EC => 'ecdsa',
|
||||
default => throw new InvalidArgumentException('Unsupported key type')
|
||||
};
|
||||
|
||||
$curve = null;
|
||||
if ($algorithm === 'ecdsa' && isset($details['ec']['curve_name'])) {
|
||||
$curve = $details['ec']['curve_name'];
|
||||
}
|
||||
|
||||
return new PublicKey($pemData, $algorithm, $details['bits'], $curve);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator): self
|
||||
{
|
||||
return new self($randomGenerator);
|
||||
}
|
||||
}
|
||||
257
src/Framework/Cryptography/DigitalSignatureResult.php
Normal file
257
src/Framework/Cryptography/DigitalSignatureResult.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Digital Signature Result Value Object
|
||||
*
|
||||
* Represents the result of a digital signature operation including the signature
|
||||
* and all metadata needed for verification.
|
||||
*/
|
||||
final readonly class DigitalSignatureResult
|
||||
{
|
||||
public function __construct(
|
||||
private string $signature,
|
||||
private string $algorithm,
|
||||
private string $hashAlgorithm,
|
||||
private int $keySize,
|
||||
private ?string $curve = null
|
||||
) {
|
||||
if (empty($signature)) {
|
||||
throw new InvalidArgumentException('Signature cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($algorithm)) {
|
||||
throw new InvalidArgumentException('Algorithm cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($hashAlgorithm)) {
|
||||
throw new InvalidArgumentException('Hash algorithm cannot be empty');
|
||||
}
|
||||
|
||||
$supportedAlgorithms = ['rsa', 'ecdsa'];
|
||||
if (! in_array($algorithm, $supportedAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported algorithm');
|
||||
}
|
||||
|
||||
$supportedHashAlgorithms = ['sha256', 'sha384', 'sha512', 'sha224'];
|
||||
if (! in_array($hashAlgorithm, $supportedHashAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported hash algorithm');
|
||||
}
|
||||
|
||||
if ($keySize < 256) {
|
||||
throw new InvalidArgumentException('Key size must be at least 256 bits');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the signature bytes
|
||||
*/
|
||||
public function getSignature(): string
|
||||
{
|
||||
return $this->signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the signature algorithm
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hash algorithm used
|
||||
*/
|
||||
public function getHashAlgorithm(): string
|
||||
{
|
||||
return $this->hashAlgorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key size in bits
|
||||
*/
|
||||
public function getKeySize(): int
|
||||
{
|
||||
return $this->keySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the curve name (ECDSA only)
|
||||
*/
|
||||
public function getCurve(): ?string
|
||||
{
|
||||
return $this->curve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signature as Base64 string
|
||||
*/
|
||||
public function getSignatureBase64(): string
|
||||
{
|
||||
return base64_encode($this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signature as hexadecimal string
|
||||
*/
|
||||
public function getSignatureHex(): string
|
||||
{
|
||||
return bin2hex($this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an RSA signature
|
||||
*/
|
||||
public function isRsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'rsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an ECDSA signature
|
||||
*/
|
||||
public function isEcdsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'ecdsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signature length in bytes
|
||||
*/
|
||||
public function getSignatureLength(): int
|
||||
{
|
||||
return strlen($this->signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to array (for serialization)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'signature' => $this->getSignatureBase64(),
|
||||
'algorithm' => $this->algorithm,
|
||||
'hash_algorithm' => $this->hashAlgorithm,
|
||||
'key_size' => $this->keySize,
|
||||
];
|
||||
|
||||
if ($this->curve !== null) {
|
||||
$data['curve'] = $this->curve;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$requiredFields = ['signature', 'algorithm', 'hash_algorithm', 'key_size'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
$signature = base64_decode($data['signature'], true);
|
||||
if ($signature === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 signature');
|
||||
}
|
||||
|
||||
return new self(
|
||||
signature: $signature,
|
||||
algorithm: $data['algorithm'],
|
||||
hashAlgorithm: $data['hash_algorithm'],
|
||||
keySize: (int)$data['key_size'],
|
||||
curve: $data['curve'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from Base64 signature
|
||||
*/
|
||||
public static function fromBase64(
|
||||
string $base64Signature,
|
||||
string $algorithm,
|
||||
string $hashAlgorithm,
|
||||
int $keySize,
|
||||
?string $curve = null
|
||||
): self {
|
||||
$signature = base64_decode($base64Signature, true);
|
||||
if ($signature === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 signature');
|
||||
}
|
||||
|
||||
return new self(
|
||||
signature: $signature,
|
||||
algorithm: $algorithm,
|
||||
hashAlgorithm: $hashAlgorithm,
|
||||
keySize: $keySize,
|
||||
curve: $curve
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from hexadecimal signature
|
||||
*/
|
||||
public static function fromHex(
|
||||
string $hexSignature,
|
||||
string $algorithm,
|
||||
string $hashAlgorithm,
|
||||
int $keySize,
|
||||
?string $curve = null
|
||||
): self {
|
||||
$signature = hex2bin($hexSignature);
|
||||
if ($signature === false) {
|
||||
throw new InvalidArgumentException('Invalid hexadecimal signature');
|
||||
}
|
||||
|
||||
return new self(
|
||||
signature: $signature,
|
||||
algorithm: $algorithm,
|
||||
hashAlgorithm: $hashAlgorithm,
|
||||
keySize: $keySize,
|
||||
curve: $curve
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signature description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$description = strtoupper($this->algorithm);
|
||||
|
||||
if ($this->algorithm === 'rsa') {
|
||||
$description .= " {$this->keySize}-bit";
|
||||
} elseif ($this->algorithm === 'ecdsa') {
|
||||
$description .= " {$this->curve}";
|
||||
}
|
||||
|
||||
$description .= " with " . strtoupper($this->hashAlgorithm);
|
||||
|
||||
return $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary information (safe for logging)
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'algorithm' => $this->algorithm,
|
||||
'hash_algorithm' => $this->hashAlgorithm,
|
||||
'key_size' => $this->keySize,
|
||||
'curve' => $this->curve,
|
||||
'signature_length' => $this->getSignatureLength(),
|
||||
'description' => $this->getDescription(),
|
||||
];
|
||||
}
|
||||
}
|
||||
327
src/Framework/Cryptography/HashResult.php
Normal file
327
src/Framework/Cryptography/HashResult.php
Normal file
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Hash Result Value Object
|
||||
*
|
||||
* Represents the result of a cryptographic hash operation including the hash
|
||||
* and all metadata about the hashing process.
|
||||
*/
|
||||
final readonly class HashResult
|
||||
{
|
||||
public function __construct(
|
||||
private string $hash,
|
||||
private string $algorithm,
|
||||
private int $inputLength,
|
||||
private ?int $keyLength = null,
|
||||
private ?int $outputLength = null,
|
||||
private ?int $seed = null
|
||||
) {
|
||||
if (empty($hash)) {
|
||||
throw new InvalidArgumentException('Hash cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($algorithm)) {
|
||||
throw new InvalidArgumentException('Algorithm cannot be empty');
|
||||
}
|
||||
|
||||
if ($inputLength < 0) {
|
||||
throw new InvalidArgumentException('Input length cannot be negative');
|
||||
}
|
||||
|
||||
if ($keyLength !== null && $keyLength < 0) {
|
||||
throw new InvalidArgumentException('Key length cannot be negative');
|
||||
}
|
||||
|
||||
if ($outputLength !== null && $outputLength < 1) {
|
||||
throw new InvalidArgumentException('Output length must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hash bytes
|
||||
*/
|
||||
public function getHash(): string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hash algorithm used
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input data length in bytes
|
||||
*/
|
||||
public function getInputLength(): int
|
||||
{
|
||||
return $this->inputLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key length in bytes (for keyed hashes)
|
||||
*/
|
||||
public function getKeyLength(): ?int
|
||||
{
|
||||
return $this->keyLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the output length in bytes (for extendable-output functions)
|
||||
*/
|
||||
public function getOutputLength(): ?int
|
||||
{
|
||||
return $this->outputLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the seed value (for non-cryptographic hashes)
|
||||
*/
|
||||
public function getSeed(): ?int
|
||||
{
|
||||
return $this->seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash as hexadecimal string
|
||||
*/
|
||||
public function getHashHex(): string
|
||||
{
|
||||
return bin2hex($this->hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash as Base64 string
|
||||
*/
|
||||
public function getHashBase64(): string
|
||||
{
|
||||
return base64_encode($this->hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash as Base64 URL-safe string
|
||||
*/
|
||||
public function getHashBase64Url(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($this->hash), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash length in bytes
|
||||
*/
|
||||
public function getHashLength(): int
|
||||
{
|
||||
return strlen($this->hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash length in bits
|
||||
*/
|
||||
public function getHashLengthBits(): int
|
||||
{
|
||||
return $this->getHashLength() * 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a cryptographic hash
|
||||
*/
|
||||
public function isCryptographic(): bool
|
||||
{
|
||||
$cryptographicAlgorithms = [
|
||||
'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512',
|
||||
'blake2b', 'blake2s',
|
||||
'shake128', 'shake256',
|
||||
'sha256', 'sha512',
|
||||
'hmac-sha256', 'hmac-sha512', 'hmac-sha3-256', 'hmac-sha3-512',
|
||||
];
|
||||
|
||||
return in_array($this->algorithm, $cryptographicAlgorithms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a keyed hash
|
||||
*/
|
||||
public function isKeyed(): bool
|
||||
{
|
||||
return $this->keyLength !== null || str_starts_with($this->algorithm, 'hmac-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an extendable-output function
|
||||
*/
|
||||
public function isExtendableOutput(): bool
|
||||
{
|
||||
return in_array($this->algorithm, ['shake128', 'shake256'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another hash result
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->hash, $other->hash) &&
|
||||
$this->algorithm === $other->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify against input data (recompute and compare)
|
||||
*/
|
||||
public function verifyAgainst(string $data, AdvancedHash $hasher): bool
|
||||
{
|
||||
try {
|
||||
return $hasher->verify($data, $this);
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to array (for serialization)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'hash' => $this->getHashBase64(),
|
||||
'algorithm' => $this->algorithm,
|
||||
'input_length' => $this->inputLength,
|
||||
'hash_length' => $this->getHashLength(),
|
||||
];
|
||||
|
||||
if ($this->keyLength !== null) {
|
||||
$data['key_length'] = $this->keyLength;
|
||||
}
|
||||
|
||||
if ($this->outputLength !== null) {
|
||||
$data['output_length'] = $this->outputLength;
|
||||
}
|
||||
|
||||
if ($this->seed !== null) {
|
||||
$data['seed'] = $this->seed;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$requiredFields = ['hash', 'algorithm', 'input_length'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
$hash = base64_decode($data['hash'], true);
|
||||
if ($hash === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 hash');
|
||||
}
|
||||
|
||||
return new self(
|
||||
hash: $hash,
|
||||
algorithm: $data['algorithm'],
|
||||
inputLength: (int)$data['input_length'],
|
||||
keyLength: isset($data['key_length']) ? (int)$data['key_length'] : null,
|
||||
outputLength: isset($data['output_length']) ? (int)$data['output_length'] : null,
|
||||
seed: isset($data['seed']) ? (int)$data['seed'] : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from hexadecimal hash
|
||||
*/
|
||||
public static function fromHex(
|
||||
string $hexHash,
|
||||
string $algorithm,
|
||||
int $inputLength,
|
||||
?int $keyLength = null,
|
||||
?int $outputLength = null,
|
||||
?int $seed = null
|
||||
): self {
|
||||
$hash = hex2bin($hexHash);
|
||||
if ($hash === false) {
|
||||
throw new InvalidArgumentException('Invalid hexadecimal hash');
|
||||
}
|
||||
|
||||
return new self(
|
||||
hash: $hash,
|
||||
algorithm: $algorithm,
|
||||
inputLength: $inputLength,
|
||||
keyLength: $keyLength,
|
||||
outputLength: $outputLength,
|
||||
seed: $seed
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate hash to specified length
|
||||
*/
|
||||
public function truncate(int $length): self
|
||||
{
|
||||
if ($length < 1) {
|
||||
throw new InvalidArgumentException('Truncation length must be positive');
|
||||
}
|
||||
|
||||
if ($length >= $this->getHashLength()) {
|
||||
return $this; // No truncation needed
|
||||
}
|
||||
|
||||
$truncatedHash = substr($this->hash, 0, $length);
|
||||
|
||||
return new self(
|
||||
hash: $truncatedHash,
|
||||
algorithm: $this->algorithm . "-truncated-{$length}",
|
||||
inputLength: $this->inputLength,
|
||||
keyLength: $this->keyLength,
|
||||
outputLength: $length,
|
||||
seed: $this->seed
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary information (safe for logging)
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'algorithm' => $this->algorithm,
|
||||
'hash_length' => $this->getHashLength(),
|
||||
'hash_length_bits' => $this->getHashLengthBits(),
|
||||
'input_length' => $this->inputLength,
|
||||
'key_length' => $this->keyLength,
|
||||
'output_length' => $this->outputLength,
|
||||
'is_cryptographic' => $this->isCryptographic(),
|
||||
'is_keyed' => $this->isKeyed(),
|
||||
'is_extendable_output' => $this->isExtendableOutput(),
|
||||
'hash_prefix' => substr($this->getHashHex(), 0, 16), // First 8 bytes in hex
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get algorithm family (e.g., 'sha3', 'blake2', 'shake')
|
||||
*/
|
||||
public function getAlgorithmFamily(): string
|
||||
{
|
||||
return match (true) {
|
||||
str_starts_with($this->algorithm, 'sha3-') => 'sha3',
|
||||
str_starts_with($this->algorithm, 'blake2') => 'blake2',
|
||||
str_starts_with($this->algorithm, 'shake') => 'shake',
|
||||
str_starts_with($this->algorithm, 'hmac-') => 'hmac',
|
||||
str_starts_with($this->algorithm, 'xxh') => 'xxhash',
|
||||
$this->algorithm === 'crc32' => 'crc',
|
||||
default => 'other'
|
||||
};
|
||||
}
|
||||
}
|
||||
324
src/Framework/Cryptography/KeyDerivationFunction.php
Normal file
324
src/Framework/Cryptography/KeyDerivationFunction.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
100
src/Framework/Cryptography/KeyPair.php
Normal file
100
src/Framework/Cryptography/KeyPair.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
/**
|
||||
* Key Pair Value Object
|
||||
*
|
||||
* Represents a cryptographic key pair consisting of a private and public key.
|
||||
*/
|
||||
final readonly class KeyPair
|
||||
{
|
||||
public function __construct(
|
||||
private PrivateKey $privateKey,
|
||||
private PublicKey $publicKey
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key
|
||||
*/
|
||||
public function getPrivateKey(): PrivateKey
|
||||
{
|
||||
return $this->privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key
|
||||
*/
|
||||
public function getPublicKey(): PublicKey
|
||||
{
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key algorithm
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->privateKey->getAlgorithm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key size in bits
|
||||
*/
|
||||
public function getKeySize(): int
|
||||
{
|
||||
return $this->privateKey->getKeySize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get curve name (ECDSA only)
|
||||
*/
|
||||
public function getCurve(): ?string
|
||||
{
|
||||
return $this->privateKey->getCurve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an RSA key pair
|
||||
*/
|
||||
public function isRsa(): bool
|
||||
{
|
||||
return $this->privateKey->isRsa();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an ECDSA key pair
|
||||
*/
|
||||
public function isEcdsa(): bool
|
||||
{
|
||||
return $this->privateKey->isEcdsa();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export both keys to array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'private_key' => $this->privateKey->toArray(),
|
||||
'public_key' => $this->publicKey->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key pair summary (safe for logging)
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'algorithm' => $this->getAlgorithm(),
|
||||
'key_size' => $this->getKeySize(),
|
||||
'curve' => $this->getCurve(),
|
||||
'private_key_present' => true,
|
||||
'public_key_present' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
188
src/Framework/Cryptography/PrivateKey.php
Normal file
188
src/Framework/Cryptography/PrivateKey.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Private Key Value Object
|
||||
*
|
||||
* Represents a cryptographic private key with metadata.
|
||||
*/
|
||||
final readonly class PrivateKey
|
||||
{
|
||||
public function __construct(
|
||||
private string $keyMaterial,
|
||||
private string $algorithm,
|
||||
private int $keySize,
|
||||
private ?string $curve = null
|
||||
) {
|
||||
if (empty($keyMaterial)) {
|
||||
throw new InvalidArgumentException('Key material cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($algorithm)) {
|
||||
throw new InvalidArgumentException('Algorithm cannot be empty');
|
||||
}
|
||||
|
||||
$supportedAlgorithms = ['rsa', 'ecdsa'];
|
||||
if (! in_array($algorithm, $supportedAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported algorithm');
|
||||
}
|
||||
|
||||
if ($keySize < 256) {
|
||||
throw new InvalidArgumentException('Key size must be at least 256 bits');
|
||||
}
|
||||
|
||||
if ($algorithm === 'rsa' && ! in_array($keySize, [2048, 3072, 4096], true)) {
|
||||
throw new InvalidArgumentException('RSA key size must be 2048, 3072, or 4096 bits');
|
||||
}
|
||||
|
||||
if ($algorithm === 'ecdsa' && $curve === null) {
|
||||
throw new InvalidArgumentException('ECDSA keys must specify a curve');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key material (PEM format)
|
||||
*/
|
||||
public function getKeyMaterial(): string
|
||||
{
|
||||
return $this->keyMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the algorithm
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key size in bits
|
||||
*/
|
||||
public function getKeySize(): int
|
||||
{
|
||||
return $this->keySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the curve name (ECDSA only)
|
||||
*/
|
||||
public function getCurve(): ?string
|
||||
{
|
||||
return $this->curve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an RSA key
|
||||
*/
|
||||
public function isRsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'rsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an ECDSA key
|
||||
*/
|
||||
public function isEcdsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'ecdsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Export key information to array (excludes sensitive key material)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'algorithm' => $this->algorithm,
|
||||
'key_size' => $this->keySize,
|
||||
];
|
||||
|
||||
if ($this->curve !== null) {
|
||||
$data['curve'] = $this->curve;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key fingerprint (SHA-256 hash of public key)
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
// Extract public key from private key
|
||||
$resource = openssl_pkey_get_private($this->keyMaterial);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Invalid private key');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
if ($details === false) {
|
||||
throw new InvalidArgumentException('Failed to get key details');
|
||||
}
|
||||
|
||||
return hash('sha256', $details['key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract public key from this private key
|
||||
*/
|
||||
public function getPublicKey(): PublicKey
|
||||
{
|
||||
$resource = openssl_pkey_get_private($this->keyMaterial);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Invalid private key');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
if ($details === false) {
|
||||
throw new InvalidArgumentException('Failed to get key details');
|
||||
}
|
||||
|
||||
return new PublicKey(
|
||||
keyMaterial: $details['key'],
|
||||
algorithm: $this->algorithm,
|
||||
keySize: $this->keySize,
|
||||
curve: $this->curve
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key is encrypted
|
||||
*/
|
||||
public function isEncrypted(): bool
|
||||
{
|
||||
return str_contains($this->keyMaterial, 'ENCRYPTED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key type description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$description = strtoupper($this->algorithm);
|
||||
|
||||
if ($this->algorithm === 'rsa') {
|
||||
$description .= " {$this->keySize}-bit";
|
||||
} elseif ($this->algorithm === 'ecdsa') {
|
||||
$description .= " {$this->curve}";
|
||||
}
|
||||
|
||||
return $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate key material
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
$resource = openssl_pkey_get_private($this->keyMaterial);
|
||||
|
||||
return $resource !== false;
|
||||
}
|
||||
}
|
||||
259
src/Framework/Cryptography/PublicKey.php
Normal file
259
src/Framework/Cryptography/PublicKey.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Public Key Value Object
|
||||
*
|
||||
* Represents a cryptographic public key with metadata.
|
||||
*/
|
||||
final readonly class PublicKey
|
||||
{
|
||||
public function __construct(
|
||||
private string $keyMaterial,
|
||||
private string $algorithm,
|
||||
private int $keySize,
|
||||
private ?string $curve = null
|
||||
) {
|
||||
if (empty($keyMaterial)) {
|
||||
throw new InvalidArgumentException('Key material cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($algorithm)) {
|
||||
throw new InvalidArgumentException('Algorithm cannot be empty');
|
||||
}
|
||||
|
||||
$supportedAlgorithms = ['rsa', 'ecdsa'];
|
||||
if (! in_array($algorithm, $supportedAlgorithms, true)) {
|
||||
throw new InvalidArgumentException('Unsupported algorithm');
|
||||
}
|
||||
|
||||
if ($keySize < 256) {
|
||||
throw new InvalidArgumentException('Key size must be at least 256 bits');
|
||||
}
|
||||
|
||||
if ($algorithm === 'ecdsa' && $curve === null) {
|
||||
throw new InvalidArgumentException('ECDSA keys must specify a curve');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key material (PEM format)
|
||||
*/
|
||||
public function getKeyMaterial(): string
|
||||
{
|
||||
return $this->keyMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the algorithm
|
||||
*/
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return $this->algorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key size in bits
|
||||
*/
|
||||
public function getKeySize(): int
|
||||
{
|
||||
return $this->keySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the curve name (ECDSA only)
|
||||
*/
|
||||
public function getCurve(): ?string
|
||||
{
|
||||
return $this->curve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an RSA key
|
||||
*/
|
||||
public function isRsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'rsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an ECDSA key
|
||||
*/
|
||||
public function isEcdsa(): bool
|
||||
{
|
||||
return $this->algorithm === 'ecdsa';
|
||||
}
|
||||
|
||||
/**
|
||||
* Export key to array (includes key material - safe for public keys)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'key_material' => $this->keyMaterial,
|
||||
'algorithm' => $this->algorithm,
|
||||
'key_size' => $this->keySize,
|
||||
];
|
||||
|
||||
if ($this->curve !== null) {
|
||||
$data['curve'] = $this->curve;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$requiredFields = ['key_material', 'algorithm', 'key_size'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
keyMaterial: $data['key_material'],
|
||||
algorithm: $data['algorithm'],
|
||||
keySize: (int)$data['key_size'],
|
||||
curve: $data['curve'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key fingerprint (SHA-256 hash)
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
return hash('sha256', $this->keyMaterial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short fingerprint (first 16 chars of full fingerprint)
|
||||
*/
|
||||
public function getShortFingerprint(): string
|
||||
{
|
||||
return substr($this->getFingerprint(), 0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key in DER format (binary)
|
||||
*/
|
||||
public function getDerFormat(): string
|
||||
{
|
||||
$resource = openssl_pkey_get_public($this->keyMaterial);
|
||||
if ($resource === false) {
|
||||
throw new InvalidArgumentException('Invalid public key');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
if ($details === false || ! isset($details['key'])) {
|
||||
throw new InvalidArgumentException('Failed to get key details');
|
||||
}
|
||||
|
||||
// Convert PEM to DER
|
||||
$pem = $details['key'];
|
||||
$pem = str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----'], '', $pem);
|
||||
$pem = str_replace(["\r", "\n", " "], '', $pem);
|
||||
|
||||
$der = base64_decode($pem);
|
||||
if ($der === false) {
|
||||
throw new InvalidArgumentException('Failed to convert to DER format');
|
||||
}
|
||||
|
||||
return $der;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key type description
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$description = strtoupper($this->algorithm);
|
||||
|
||||
if ($this->algorithm === 'rsa') {
|
||||
$description .= " {$this->keySize}-bit";
|
||||
} elseif ($this->algorithm === 'ecdsa') {
|
||||
$description .= " {$this->curve}";
|
||||
}
|
||||
|
||||
return $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another public key
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->keyMaterial, $other->keyMaterial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate key material
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
$resource = openssl_pkey_get_public($this->keyMaterial);
|
||||
|
||||
return $resource !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key as JWK (JSON Web Key) format
|
||||
*/
|
||||
public function toJwk(): array
|
||||
{
|
||||
if (! $this->isValid()) {
|
||||
throw new InvalidArgumentException('Invalid key material');
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_get_public($this->keyMaterial);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
|
||||
if ($this->algorithm === 'rsa') {
|
||||
return [
|
||||
'kty' => 'RSA',
|
||||
'alg' => 'RS256',
|
||||
'use' => 'sig',
|
||||
'n' => $this->base64UrlEncode($details['rsa']['n']),
|
||||
'e' => $this->base64UrlEncode($details['rsa']['e']),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->algorithm === 'ecdsa') {
|
||||
$crv = match ($this->curve) {
|
||||
'prime256v1' => 'P-256',
|
||||
'secp384r1' => 'P-384',
|
||||
'secp521r1' => 'P-521',
|
||||
default => throw new InvalidArgumentException('Unsupported curve for JWK')
|
||||
};
|
||||
|
||||
return [
|
||||
'kty' => 'EC',
|
||||
'alg' => 'ES256',
|
||||
'use' => 'sig',
|
||||
'crv' => $crv,
|
||||
'x' => $this->base64UrlEncode($details['ec']['x']),
|
||||
'y' => $this->base64UrlEncode($details['ec']['y']),
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unsupported algorithm for JWK conversion');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode (for JWK format)
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
388
src/Framework/Cryptography/SecureToken.php
Normal file
388
src/Framework/Cryptography/SecureToken.php
Normal file
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Secure Token Value Object
|
||||
*
|
||||
* Represents a cryptographically secure token with metadata about its
|
||||
* generation, format, and intended use.
|
||||
*/
|
||||
final readonly class SecureToken
|
||||
{
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
private string $value,
|
||||
private string $type,
|
||||
private string $format,
|
||||
private int $length,
|
||||
private ?string $prefix,
|
||||
private string $rawBytes,
|
||||
private array $metadata
|
||||
) {
|
||||
if (empty($value)) {
|
||||
throw new InvalidArgumentException('Token value cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($type)) {
|
||||
throw new InvalidArgumentException('Token type cannot be empty');
|
||||
}
|
||||
|
||||
if (empty($format)) {
|
||||
throw new InvalidArgumentException('Token format cannot be empty');
|
||||
}
|
||||
|
||||
if ($length < 1) {
|
||||
throw new InvalidArgumentException('Token length must be positive');
|
||||
}
|
||||
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token value
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token type
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token format
|
||||
*/
|
||||
public function getFormat(): string
|
||||
{
|
||||
return $this->format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token length
|
||||
*/
|
||||
public function getLength(): int
|
||||
{
|
||||
return $this->length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token prefix (if any)
|
||||
*/
|
||||
public function getPrefix(): ?string
|
||||
{
|
||||
return $this->prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw bytes
|
||||
*/
|
||||
public function getRawBytes(): string
|
||||
{
|
||||
return $this->rawBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get creation timestamp
|
||||
*/
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method for string conversion
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token without prefix
|
||||
*/
|
||||
public function getValueWithoutPrefix(): string
|
||||
{
|
||||
if ($this->prefix === null) {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
$prefixLength = strlen($this->prefix) + 1; // +1 for underscore
|
||||
|
||||
return substr($this->value, $prefixLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has prefix
|
||||
*/
|
||||
public function hasPrefix(): bool
|
||||
{
|
||||
return $this->prefix !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token length in characters (not bytes)
|
||||
*/
|
||||
public function getValueLength(): int
|
||||
{
|
||||
return strlen($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw bytes as hexadecimal
|
||||
*/
|
||||
public function getRawBytesHex(): string
|
||||
{
|
||||
return bin2hex($this->rawBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw bytes as Base64
|
||||
*/
|
||||
public function getRawBytesBase64(): string
|
||||
{
|
||||
return base64_encode($this->rawBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another token (timing-safe)
|
||||
*/
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->value, $other->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify token value (timing-safe)
|
||||
*/
|
||||
public function verify(string $candidateToken): bool
|
||||
{
|
||||
return hash_equals($this->value, $candidateToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is of specific type
|
||||
*/
|
||||
public function isType(string $type): bool
|
||||
{
|
||||
return $this->type === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is in specific format
|
||||
*/
|
||||
public function isFormat(string $format): bool
|
||||
{
|
||||
return $this->format === $format;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is API key
|
||||
*/
|
||||
public function isApiKey(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is session token
|
||||
*/
|
||||
public function isSessionToken(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_SESSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is CSRF token
|
||||
*/
|
||||
public function isCsrfToken(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_CSRF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is verification token
|
||||
*/
|
||||
public function isVerificationToken(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_VERIFICATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is Bearer token
|
||||
*/
|
||||
public function isBearerToken(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_BEARER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is OTP
|
||||
*/
|
||||
public function isOtp(): bool
|
||||
{
|
||||
return $this->type === SecureTokenGenerator::TYPE_OTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is single-use
|
||||
*/
|
||||
public function isSingleUse(): bool
|
||||
{
|
||||
return $this->metadata['single_use'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is long-lived
|
||||
*/
|
||||
public function isLongLived(): bool
|
||||
{
|
||||
return $this->metadata['long_lived'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token age in seconds
|
||||
*/
|
||||
public function getAgeInSeconds(): int
|
||||
{
|
||||
return time() - $this->createdAt->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific metadata value
|
||||
*/
|
||||
public function getMetadataValue(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->metadata[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to array (for serialization)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'value' => $this->value,
|
||||
'type' => $this->type,
|
||||
'format' => $this->format,
|
||||
'length' => $this->length,
|
||||
'prefix' => $this->prefix,
|
||||
'raw_bytes' => base64_encode($this->rawBytes),
|
||||
'metadata' => $this->metadata,
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array (for deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$requiredFields = ['value', 'type', 'format', 'length', 'raw_bytes'];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (! isset($data[$field])) {
|
||||
throw new InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
$rawBytes = base64_decode($data['raw_bytes'], true);
|
||||
if ($rawBytes === false) {
|
||||
throw new InvalidArgumentException('Invalid Base64 raw bytes');
|
||||
}
|
||||
|
||||
$token = new self(
|
||||
value: $data['value'],
|
||||
type: $data['type'],
|
||||
format: $data['format'],
|
||||
length: (int)$data['length'],
|
||||
prefix: $data['prefix'] ?? null,
|
||||
rawBytes: $rawBytes,
|
||||
metadata: $data['metadata'] ?? []
|
||||
);
|
||||
|
||||
// Note: Cannot override readonly createdAt property after construction
|
||||
// The timestamp will be set to current time during construction
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get safe summary (excludes sensitive token value)
|
||||
*/
|
||||
public function getSafeSummary(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'format' => $this->format,
|
||||
'length' => $this->length,
|
||||
'prefix' => $this->prefix,
|
||||
'value_length' => $this->getValueLength(),
|
||||
'has_prefix' => $this->hasPrefix(),
|
||||
'is_single_use' => $this->isSingleUse(),
|
||||
'is_long_lived' => $this->isLongLived(),
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
'age_seconds' => $this->getAgeInSeconds(),
|
||||
'metadata_keys' => array_keys($this->metadata),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token fingerprint (for identification without exposing value)
|
||||
*/
|
||||
public function getFingerprint(): string
|
||||
{
|
||||
return hash('sha256', $this->value . $this->type . $this->createdAt->format('U.u'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short fingerprint (first 16 chars)
|
||||
*/
|
||||
public function getShortFingerprint(): string
|
||||
{
|
||||
return substr($this->getFingerprint(), 0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask token value for logging
|
||||
*/
|
||||
public function getMaskedValue(): string
|
||||
{
|
||||
if (strlen($this->value) <= 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
$start = substr($this->value, 0, 4);
|
||||
$end = substr($this->value, -4);
|
||||
$middle = str_repeat('*', max(3, strlen($this->value) - 8));
|
||||
|
||||
return $start . $middle . $end;
|
||||
}
|
||||
}
|
||||
409
src/Framework/Cryptography/SecureTokenGenerator.php
Normal file
409
src/Framework/Cryptography/SecureTokenGenerator.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Cryptography;
|
||||
|
||||
use App\Framework\Random\RandomGenerator;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Secure Token Generator Service
|
||||
*
|
||||
* Generates cryptographically secure tokens for various use cases including
|
||||
* API keys, session tokens, CSRF tokens, and verification tokens.
|
||||
*/
|
||||
final readonly class SecureTokenGenerator
|
||||
{
|
||||
// Token type constants
|
||||
public const string TYPE_API_KEY = 'api_key';
|
||||
public const string TYPE_SESSION = 'session';
|
||||
public const string TYPE_CSRF = 'csrf';
|
||||
public const string TYPE_VERIFICATION = 'verification';
|
||||
public const string TYPE_BEARER = 'bearer';
|
||||
public const string TYPE_REFRESH = 'refresh';
|
||||
public const string TYPE_OTP = 'otp';
|
||||
public const string TYPE_WEBHOOK = 'webhook';
|
||||
|
||||
// Encoding formats
|
||||
public const string FORMAT_BASE64 = 'base64';
|
||||
public const string FORMAT_BASE64_URL = 'base64url';
|
||||
public const string FORMAT_HEX = 'hex';
|
||||
public const string FORMAT_BASE32 = 'base32';
|
||||
public const string FORMAT_ALPHANUMERIC = 'alphanumeric';
|
||||
|
||||
public function __construct(
|
||||
private RandomGenerator $randomGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure token
|
||||
*/
|
||||
public function generate(
|
||||
string $type,
|
||||
int $length = 32,
|
||||
string $format = self::FORMAT_BASE64_URL,
|
||||
?string $prefix = null,
|
||||
?array $metadata = null
|
||||
): SecureToken {
|
||||
if ($length < 16) {
|
||||
throw new InvalidArgumentException('Token length must be at least 16 bytes');
|
||||
}
|
||||
|
||||
if ($length > 256) {
|
||||
throw new InvalidArgumentException('Token length cannot exceed 256 bytes');
|
||||
}
|
||||
|
||||
$supportedTypes = [
|
||||
self::TYPE_API_KEY, self::TYPE_SESSION, self::TYPE_CSRF,
|
||||
self::TYPE_VERIFICATION, self::TYPE_BEARER, self::TYPE_REFRESH,
|
||||
self::TYPE_OTP, self::TYPE_WEBHOOK,
|
||||
];
|
||||
|
||||
if (! in_array($type, $supportedTypes, true)) {
|
||||
throw new InvalidArgumentException('Unsupported token type');
|
||||
}
|
||||
|
||||
$supportedFormats = [
|
||||
self::FORMAT_BASE64, self::FORMAT_BASE64_URL, self::FORMAT_HEX,
|
||||
self::FORMAT_BASE32, self::FORMAT_ALPHANUMERIC,
|
||||
];
|
||||
|
||||
if (! in_array($format, $supportedFormats, true)) {
|
||||
throw new InvalidArgumentException('Unsupported format');
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
$randomBytes = $this->randomGenerator->bytes($length);
|
||||
|
||||
// Encode in specified format
|
||||
$encodedToken = $this->encodeToken($randomBytes, $format);
|
||||
|
||||
// Add prefix if specified
|
||||
if ($prefix !== null) {
|
||||
$encodedToken = $prefix . '_' . $encodedToken;
|
||||
}
|
||||
|
||||
return new SecureToken(
|
||||
value: $encodedToken,
|
||||
type: $type,
|
||||
format: $format,
|
||||
length: $length,
|
||||
prefix: $prefix,
|
||||
rawBytes: $randomBytes,
|
||||
metadata: $metadata ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API key with standard format
|
||||
*/
|
||||
public function generateApiKey(string $prefix = 'ak', int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_API_KEY,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
prefix: $prefix,
|
||||
metadata: ['purpose' => 'api_authentication']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session token
|
||||
*/
|
||||
public function generateSessionToken(int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_SESSION,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
metadata: ['purpose' => 'session_management', 'secure' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSRF token
|
||||
*/
|
||||
public function generateCsrfToken(int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_CSRF,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
metadata: ['purpose' => 'csrf_protection']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate verification token (email, phone, etc.)
|
||||
*/
|
||||
public function generateVerificationToken(string $purpose, int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_VERIFICATION,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
metadata: ['purpose' => $purpose, 'single_use' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Bearer token for OAuth/JWT-like usage
|
||||
*/
|
||||
public function generateBearerToken(int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_BEARER,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
metadata: ['purpose' => 'api_authorization']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
*/
|
||||
public function generateRefreshToken(int $length = 64): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_REFRESH,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
metadata: ['purpose' => 'token_refresh', 'long_lived' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OTP (One-Time Password) token
|
||||
*/
|
||||
public function generateOtpToken(int $digits = 6): SecureToken
|
||||
{
|
||||
if ($digits < 4 || $digits > 12) {
|
||||
throw new InvalidArgumentException('OTP digits must be between 4 and 12');
|
||||
}
|
||||
|
||||
// Generate numeric OTP
|
||||
$max = (int)str_repeat('9', $digits);
|
||||
$min = (int)str_pad('1', $digits, '0', STR_PAD_RIGHT);
|
||||
|
||||
$otpValue = (string)$this->randomGenerator->int($min, $max);
|
||||
|
||||
// Pad with leading zeros if necessary
|
||||
$otpValue = str_pad($otpValue, $digits, '0', STR_PAD_LEFT);
|
||||
|
||||
return new SecureToken(
|
||||
value: $otpValue,
|
||||
type: self::TYPE_OTP,
|
||||
format: 'numeric',
|
||||
length: $digits,
|
||||
prefix: null,
|
||||
rawBytes: $otpValue, // For OTP, raw bytes is the numeric string
|
||||
metadata: ['digits' => $digits, 'purpose' => 'otp', 'single_use' => true]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate webhook signature token
|
||||
*/
|
||||
public function generateWebhookToken(string $prefix = 'whsec', int $length = 32): SecureToken
|
||||
{
|
||||
return $this->generate(
|
||||
type: self::TYPE_WEBHOOK,
|
||||
length: $length,
|
||||
format: self::FORMAT_BASE64_URL,
|
||||
prefix: $prefix,
|
||||
metadata: ['purpose' => 'webhook_signature']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token with custom alphabet
|
||||
*/
|
||||
public function generateCustom(
|
||||
string $alphabet,
|
||||
int $length,
|
||||
string $type = 'custom'
|
||||
): SecureToken {
|
||||
if (strlen($alphabet) < 16) {
|
||||
throw new InvalidArgumentException('Alphabet must contain at least 16 characters');
|
||||
}
|
||||
|
||||
if ($length < 8) {
|
||||
throw new InvalidArgumentException('Token length must be at least 8 characters');
|
||||
}
|
||||
|
||||
// Remove duplicate characters from alphabet
|
||||
$alphabet = implode('', array_unique(str_split($alphabet)));
|
||||
$alphabetSize = strlen($alphabet);
|
||||
|
||||
$token = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$index = $this->randomGenerator->int(0, $alphabetSize - 1);
|
||||
$token .= $alphabet[$index];
|
||||
}
|
||||
|
||||
return new SecureToken(
|
||||
value: $token,
|
||||
type: $type,
|
||||
format: 'custom',
|
||||
length: $length,
|
||||
prefix: null,
|
||||
rawBytes: $token, // For custom format, store the token string
|
||||
metadata: ['alphabet' => $alphabet, 'alphabet_size' => $alphabetSize]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate batch of tokens
|
||||
*/
|
||||
public function generateBatch(
|
||||
string $type,
|
||||
int $count,
|
||||
int $length = 32,
|
||||
string $format = self::FORMAT_BASE64_URL,
|
||||
?string $prefix = null
|
||||
): array {
|
||||
if ($count <= 0) {
|
||||
throw new InvalidArgumentException('Count must be positive');
|
||||
}
|
||||
|
||||
if ($count > 1000) {
|
||||
throw new InvalidArgumentException('Batch size cannot exceed 1000');
|
||||
}
|
||||
|
||||
$tokens = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$tokens[] = $this->generate($type, $length, $format, $prefix);
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token format
|
||||
*/
|
||||
public function isValidFormat(string $token, string $format): bool
|
||||
{
|
||||
return match ($format) {
|
||||
self::FORMAT_BASE64 => $this->isValidBase64($token),
|
||||
self::FORMAT_BASE64_URL => $this->isValidBase64Url($token),
|
||||
self::FORMAT_HEX => $this->isValidHex($token),
|
||||
self::FORMAT_BASE32 => $this->isValidBase32($token),
|
||||
self::FORMAT_ALPHANUMERIC => $this->isValidAlphanumeric($token),
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token entropy in bits
|
||||
*/
|
||||
public function getEntropy(SecureToken $token): float
|
||||
{
|
||||
return match ($token->getFormat()) {
|
||||
self::FORMAT_BASE64 => $token->getLength() * log(64, 2),
|
||||
self::FORMAT_BASE64_URL => $token->getLength() * log(64, 2),
|
||||
self::FORMAT_HEX => $token->getLength() * log(16, 2),
|
||||
self::FORMAT_BASE32 => $token->getLength() * log(32, 2),
|
||||
self::FORMAT_ALPHANUMERIC => $token->getLength() * log(62, 2),
|
||||
'numeric' => $token->getLength() * log(10, 2),
|
||||
'custom' => $token->getLength() * log($token->getMetadata()['alphabet_size'] ?? 64, 2),
|
||||
default => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode raw bytes to specified format
|
||||
*/
|
||||
private function encodeToken(string $bytes, string $format): string
|
||||
{
|
||||
return match ($format) {
|
||||
self::FORMAT_BASE64 => base64_encode($bytes),
|
||||
self::FORMAT_BASE64_URL => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='),
|
||||
self::FORMAT_HEX => bin2hex($bytes),
|
||||
self::FORMAT_BASE32 => $this->base32Encode($bytes),
|
||||
self::FORMAT_ALPHANUMERIC => $this->alphanumericEncode($bytes),
|
||||
default => throw new InvalidArgumentException("Unsupported format: {$format}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 encoding
|
||||
*/
|
||||
private function base32Encode(string $data): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
$encoded = '';
|
||||
$bits = 0;
|
||||
$value = 0;
|
||||
|
||||
foreach (str_split($data) as $char) {
|
||||
$value = ($value << 8) | ord($char);
|
||||
$bits += 8;
|
||||
|
||||
while ($bits >= 5) {
|
||||
$encoded .= $alphabet[($value >> ($bits - 5)) & 31];
|
||||
$bits -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bits > 0) {
|
||||
$encoded .= $alphabet[($value << (5 - $bits)) & 31];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alphanumeric encoding (0-9, A-Z, a-z)
|
||||
*/
|
||||
private function alphanumericEncode(string $bytes): string
|
||||
{
|
||||
$alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
$encoded = '';
|
||||
|
||||
foreach (str_split($bytes) as $byte) {
|
||||
$value = ord($byte);
|
||||
$encoded .= $alphabet[$value % 62];
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation helpers
|
||||
*/
|
||||
private function isValidBase64(string $token): bool
|
||||
{
|
||||
return preg_match('/^[A-Za-z0-9+\/]+=*$/', $token) === 1;
|
||||
}
|
||||
|
||||
private function isValidBase64Url(string $token): bool
|
||||
{
|
||||
return preg_match('/^[A-Za-z0-9\-_]*$/', $token) === 1;
|
||||
}
|
||||
|
||||
private function isValidHex(string $token): bool
|
||||
{
|
||||
return preg_match('/^[0-9a-fA-F]+$/', $token) === 1;
|
||||
}
|
||||
|
||||
private function isValidBase32(string $token): bool
|
||||
{
|
||||
return preg_match('/^[A-Z2-7]+=*$/', $token) === 1;
|
||||
}
|
||||
|
||||
private function isValidAlphanumeric(string $token): bool
|
||||
{
|
||||
return preg_match('/^[0-9A-Za-z]+$/', $token) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method
|
||||
*/
|
||||
public static function create(RandomGenerator $randomGenerator): self
|
||||
{
|
||||
return new self($randomGenerator);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user