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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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();
}
}

View 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);
}
}
}

View 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);
}
}

View 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';
}
}

View 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);
}
}

View 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(),
];
}
}

View 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'
};
}
}

View 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);
}
}

View 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,
];
}
}

View 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;
}
}

View 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), '+/', '-_'), '=');
}
}

View 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;
}
}

View 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);
}
}