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,267 @@
<?php
declare(strict_types=1);
use App\Framework\Cryptography\CryptographicUtilities;
use App\Framework\Random\SecureRandomGenerator;
it('performs timing-safe string comparison', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$string1 = 'secret-value';
$string2 = 'secret-value';
$string3 = 'different-value';
expect($utils->timingSafeEquals($string1, $string2))->toBeTrue();
expect($utils->timingSafeEquals($string1, $string3))->toBeFalse();
});
it('performs timing-safe array comparison', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$array1 = ['key1' => 'value1', 'key2' => 'value2'];
$array2 = ['key2' => 'value2', 'key1' => 'value1']; // Different order
$array3 = ['key1' => 'value1', 'key2' => 'different'];
expect($utils->timingSafeArrayEquals($array1, $array2))->toBeTrue();
expect($utils->timingSafeArrayEquals($array1, $array3))->toBeFalse();
});
it('generates cryptographically secure nonce', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$nonce1 = $utils->generateNonce(32);
$nonce2 = $utils->generateNonce(32);
expect(strlen($nonce1))->toBe(32);
expect(strlen($nonce2))->toBe(32);
expect($nonce1)->not->toBe($nonce2); // Should be different
});
it('generates initialization vector', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$iv1 = $utils->generateIv(16);
$iv2 = $utils->generateIv(16);
expect(strlen($iv1))->toBe(16);
expect(strlen($iv2))->toBe(16);
expect($iv1)->not->toBe($iv2); // Should be different
});
it('validates entropy correctly', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
// Use known high-entropy data (256 unique bytes)
$highEntropyData = '';
for ($i = 0; $i < 256; $i++) {
$highEntropyData .= chr($i);
}
$lowEntropyData = str_repeat('a', 32); // Low entropy
$emptyData = '';
expect($utils->validateEntropy($highEntropyData, 6.0))->toBeTrue(); // Lower threshold for realistic testing
expect($utils->validateEntropy($lowEntropyData))->toBeFalse();
expect($utils->validateEntropy($emptyData))->toBeFalse();
});
it('calculates Shannon entropy', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$randomData = $utils->generateNonce(32);
$uniformData = str_repeat('a', 32);
$randomEntropy = $utils->calculateShannonEntropy($randomData);
$uniformEntropy = $utils->calculateShannonEntropy($uniformData);
expect($randomEntropy)->toBeGreaterThan($uniformEntropy);
expect($uniformEntropy)->toBe(0.0); // Uniform data has zero entropy
});
it('performs constant-time modular exponentiation if GMP available', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
if (! extension_loaded('gmp')) {
expect(fn () => $utils->constantTimeModPow('5', '3', '13'))
->toThrow(InvalidArgumentException::class, 'GMP extension required');
} else {
$result = $utils->constantTimeModPow('5', '3', '13'); // 5^3 mod 13 = 125 mod 13 = 8
expect($result)->toBe('8');
}
});
it('generates cryptographically secure UUID v4', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$uuid1 = $utils->generateUuid4();
$uuid2 = $utils->generateUuid4();
// Check UUID format
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
expect($uuid1)->toMatch($pattern);
expect($uuid2)->toMatch($pattern);
expect($uuid1)->not->toBe($uuid2); // Should be unique
});
it('stretches keys correctly', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$key = 'password';
$salt = 'salt1234';
$stretched1 = $utils->stretchKey($key, $salt, 10000, 32);
$stretched2 = $utils->stretchKey($key, $salt, 10000, 32); // Same parameters
$stretched3 = $utils->stretchKey($key, 'differentsalt1234', 10000, 32); // Different salt
expect(strlen($stretched1))->toBe(32);
expect($stretched1)->toBe($stretched2); // Same inputs = same output
expect($stretched1)->not->toBe($stretched3); // Different salt = different output
});
it('XORs strings correctly', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$str1 = "\x01\x02\x03\x04";
$str2 = "\xFF\xFE\xFD\xFC";
$result = $utils->xorStrings($str1, $str2);
expect($result)->toBe("\xFE\xFC\xFE\xF8"); // XOR result
expect(strlen($result))->toBe(4);
});
it('generates and removes PKCS#7 padding', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$data = 'Hello World!'; // 12 bytes
$blockSize = 16;
$padding = $utils->generatePadding($blockSize, strlen($data));
$paddedData = $data . $padding;
expect(strlen($paddedData) % $blockSize)->toBe(0); // Should be block-aligned
$unpaddedData = $utils->removePadding($paddedData);
expect($unpaddedData)->toBe($data);
});
it('generates bit string correctly', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$bitString = $utils->generateBitString(16); // 2 bytes = 16 bits
expect(strlen($bitString))->toBe(16);
expect($bitString)->toMatch('/^[01]+$/'); // Only 0s and 1s
});
it('converts bit string to bytes', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$bitString = '0110100001101001'; // "hi" in binary
$bytes = $utils->bitStringToBytes($bitString);
expect($bytes)->toBe('hi');
});
it('validates key strength', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
// Use known high-entropy data (64 different bytes)
$strongKey = '';
for ($i = 0; $i < 64; $i++) {
$strongKey .= chr($i);
}
$weakKey = str_repeat('a', 32); // No entropy
$shortKey = 'short'; // Too short
expect($utils->validateKeyStrength($strongKey, 128))->toBeTrue();
expect($utils->validateKeyStrength($weakKey, 128))->toBeFalse();
expect($utils->validateKeyStrength($shortKey, 128))->toBeFalse();
});
it('generates deterministic UUID v5', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$namespace = str_repeat("\x00", 16); // Null namespace
$name1 = 'test-name';
$name2 = 'test-name'; // Same name
$name3 = 'different-name';
$uuid1 = $utils->generateUuid5($namespace, $name1);
$uuid2 = $utils->generateUuid5($namespace, $name2);
$uuid3 = $utils->generateUuid5($namespace, $name3);
// Check UUID v5 format (version 5)
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
expect($uuid1)->toMatch($pattern);
expect($uuid1)->toBe($uuid2); // Same inputs = same UUID
expect($uuid1)->not->toBe($uuid3); // Different name = different UUID
});
it('performs constant-time array search', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$haystack = ['value1', 'value2', 'secret-value', 'value3'];
expect($utils->constantTimeArraySearch($haystack, 'secret-value'))->toBeTrue();
expect($utils->constantTimeArraySearch($haystack, 'not-found'))->toBeFalse();
});
it('securely wipes memory', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
$sensitiveData = 'very-secret-password';
$utils->secureWipe($sensitiveData);
expect($sensitiveData)->toBe(''); // Should be empty after wipe
});
it('throws exception for too short nonce', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->generateNonce(4))
->toThrow(InvalidArgumentException::class, 'Nonce length must be at least 8 bytes');
});
it('throws exception for too short IV', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->generateIv(4))
->toThrow(InvalidArgumentException::class, 'IV length must be at least 8 bytes');
});
it('throws exception for too few key stretching iterations', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->stretchKey('key', 'salt', 500))
->toThrow(InvalidArgumentException::class, 'Iterations must be at least 1000');
});
it('throws exception for invalid bit string length', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->generateBitString(12)) // Not divisible by 8
->toThrow(InvalidArgumentException::class, 'Bit length must be divisible by 8');
});
it('throws exception for invalid bit string characters', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->bitStringToBytes('01012345')) // Contains invalid characters
->toThrow(InvalidArgumentException::class, 'Bit string contains invalid characters');
});
it('throws exception for invalid UUID v5 namespace', function () {
$utils = new CryptographicUtilities(new SecureRandomGenerator());
expect(fn () => $utils->generateUuid5('short', 'name')) // Namespace too short
->toThrow(InvalidArgumentException::class, 'Namespace must be 16 bytes');
});
it('creates instance using factory method', function () {
$randomGen = new SecureRandomGenerator();
$utils = CryptographicUtilities::create($randomGen);
expect($utils)->toBeInstanceOf(CryptographicUtilities::class);
});

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
use App\Framework\Cryptography\DerivedKey;
it('creates derived key with valid parameters', function () {
$key = str_repeat('a', 32);
$salt = str_repeat('b', 32);
$derivedKey = new DerivedKey(
key: $key,
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
expect($derivedKey->getKey())->toBe($key);
expect($derivedKey->getSalt())->toBe($salt);
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
expect($derivedKey->getIterations())->toBe(100000);
expect($derivedKey->getKeyLength())->toBe(32);
});
it('throws exception for empty key', function () {
expect(fn () => new DerivedKey(
key: '',
salt: str_repeat('b', 32),
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
))->toThrow(InvalidArgumentException::class, 'Key cannot be empty');
});
it('throws exception for empty salt', function () {
expect(fn () => new DerivedKey(
key: str_repeat('a', 32),
salt: '',
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
))->toThrow(InvalidArgumentException::class, 'Salt cannot be empty');
});
it('throws exception for key length mismatch', function () {
expect(fn () => new DerivedKey(
key: str_repeat('a', 16), // 16 bytes
salt: str_repeat('b', 32),
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32 // Claims 32 bytes
))->toThrow(InvalidArgumentException::class, 'Key length does not match specified length');
});
it('provides hex representation', function () {
$key = "\x01\x02\x03\x04";
$salt = "\x05\x06\x07\x08";
$derivedKey = new DerivedKey(
key: $key,
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 4
);
expect($derivedKey->getKeyHex())->toBe('01020304');
expect($derivedKey->getSaltHex())->toBe('05060708');
});
it('provides base64 representation', function () {
$key = 'test-key-data';
$salt = 'test-salt-data';
$derivedKey = new DerivedKey(
key: $key,
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: strlen($key)
);
expect($derivedKey->getKeyBase64())->toBe(base64_encode($key));
expect($derivedKey->getSaltBase64())->toBe(base64_encode($salt));
});
it('checks equality correctly', function () {
$key = str_repeat('a', 32);
$salt = str_repeat('b', 32);
$derivedKey1 = new DerivedKey(
key: $key,
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
$derivedKey2 = new DerivedKey(
key: $key,
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
$derivedKey3 = new DerivedKey(
key: str_repeat('c', 32), // Different key
salt: $salt,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
expect($derivedKey1->equals($derivedKey2))->toBeTrue();
expect($derivedKey1->equals($derivedKey3))->toBeFalse();
});
it('exports to array correctly', function () {
$derivedKey = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'argon2id',
iterations: 4,
keyLength: 32,
memoryCost: 65536,
threads: 3
);
$array = $derivedKey->toArray();
expect($array)->toHaveKey('key');
expect($array)->toHaveKey('salt');
expect($array)->toHaveKey('algorithm');
expect($array)->toHaveKey('iterations');
expect($array)->toHaveKey('key_length');
expect($array)->toHaveKey('memory_cost');
expect($array)->toHaveKey('threads');
expect($array['algorithm'])->toBe('argon2id');
expect($array['memory_cost'])->toBe(65536);
expect($array['threads'])->toBe(3);
});
it('creates from array correctly', function () {
$originalKey = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
$array = $originalKey->toArray();
$restoredKey = DerivedKey::fromArray($array);
expect($restoredKey->equals($originalKey))->toBeTrue();
});
it('creates from hex correctly', function () {
$keyHex = '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20';
$saltHex = 'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef';
$derivedKey = DerivedKey::fromHex(
keyHex: $keyHex,
saltHex: $saltHex,
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
expect($derivedKey->getKeyHex())->toBe($keyHex);
expect($derivedKey->getSaltHex())->toBe($saltHex);
});
it('provides summary information', function () {
$derivedKey = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 16),
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
$summary = $derivedKey->getSummary();
expect($summary)->toHaveKey('algorithm');
expect($summary)->toHaveKey('iterations');
expect($summary)->toHaveKey('key_length');
expect($summary)->toHaveKey('salt_length');
expect($summary['salt_length'])->toBe(16);
});
it('identifies algorithm types correctly', function () {
$pbkdf2Key = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'pbkdf2-sha256',
iterations: 100000,
keyLength: 32
);
$argon2Key = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'argon2id',
iterations: 4,
keyLength: 32
);
$scryptKey = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'scrypt',
iterations: 16384,
keyLength: 32
);
expect($pbkdf2Key->isPbkdf2())->toBeTrue();
expect($pbkdf2Key->isArgon2())->toBeFalse();
expect($pbkdf2Key->isScrypt())->toBeFalse();
expect($argon2Key->isArgon2())->toBeTrue();
expect($argon2Key->isPbkdf2())->toBeFalse();
expect($scryptKey->isScrypt())->toBeTrue();
expect($scryptKey->isPbkdf2())->toBeFalse();
});
it('handles scrypt parameters correctly', function () {
$derivedKey = new DerivedKey(
key: str_repeat('a', 32),
salt: str_repeat('b', 32),
algorithm: 'scrypt',
iterations: 16384,
keyLength: 32,
blockSize: 8,
parallelization: 1
);
expect($derivedKey->getBlockSize())->toBe(8);
expect($derivedKey->getParallelization())->toBe(1);
});
it('throws exception for missing required fields in fromArray', function () {
$incompleteData = [
'key' => base64_encode(str_repeat('a', 32)),
'salt' => base64_encode(str_repeat('b', 32)),
'algorithm' => 'pbkdf2-sha256',
// Missing iterations and key_length
];
expect(fn () => DerivedKey::fromArray($incompleteData))
->toThrow(InvalidArgumentException::class, 'Missing required field');
});
it('throws exception for invalid base64 in fromArray', function () {
$invalidData = [
'key' => 'invalid-base64!@#',
'salt' => base64_encode(str_repeat('b', 32)),
'algorithm' => 'pbkdf2-sha256',
'iterations' => 100000,
'key_length' => 32,
];
expect(fn () => DerivedKey::fromArray($invalidData))
->toThrow(InvalidArgumentException::class, 'Failed to decode Base64 data');
});

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Framework\Cryptography\DerivedKey;
use App\Framework\Cryptography\KeyDerivationFunction;
use App\Framework\Random\SecureRandomGenerator;
it('generates salt with correct length', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$salt = $kdf->generateSalt(32);
expect(strlen($salt))->toBe(32);
});
it('throws exception for too short salt', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
expect(fn () => $kdf->generateSalt(8))
->toThrow(InvalidArgumentException::class, 'Salt length must be at least 16 bytes');
});
it('derives key using PBKDF2', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$salt = $kdf->generateSalt();
$derivedKey = $kdf->pbkdf2($password, $salt, 10000, 32);
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
expect($derivedKey->getKeyLength())->toBe(32);
expect($derivedKey->getIterations())->toBe(10000);
expect(strlen($derivedKey->getKey()))->toBe(32);
});
it('derives key using Argon2ID if available', function () {
if (! function_exists('sodium_crypto_pwhash') || ! defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID')) {
$this->markTestSkipped('Sodium extension or Argon2ID constant not available');
}
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$salt = str_repeat("\x00", 32); // Argon2ID needs exactly 32 bytes
$derivedKey = $kdf->argon2id($password, $salt, 65536, 4, 3, 32);
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
expect($derivedKey->getAlgorithm())->toBe('argon2id');
expect($derivedKey->getKeyLength())->toBe(32);
expect(strlen($derivedKey->getKey()))->toBe(32);
});
it('derives key using scrypt if available', function () {
if (! function_exists('scrypt')) {
$this->markTestSkipped('scrypt function not available');
}
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$salt = $kdf->generateSalt();
$derivedKey = $kdf->scrypt($password, $salt, 16384, 8, 1, 32);
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
expect($derivedKey->getAlgorithm())->toBe('scrypt');
expect($derivedKey->getKeyLength())->toBe(32);
expect(strlen($derivedKey->getKey()))->toBe(32);
});
it('verifies password correctly', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'correct-password';
$wrongPassword = 'wrong-password';
$salt = $kdf->generateSalt();
$derivedKey = $kdf->pbkdf2($password, $salt, 10000, 32);
expect($kdf->verify($password, $derivedKey))->toBeTrue();
expect($kdf->verify($wrongPassword, $derivedKey))->toBeFalse();
});
it('hashes password with automatic salt generation', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$derivedKey = $kdf->hashPassword($password, 'pbkdf2-sha256');
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
expect(strlen($derivedKey->getSalt()))->toBe(32);
expect($kdf->verify($password, $derivedKey))->toBeTrue();
});
it('throws exception for empty password', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$salt = $kdf->generateSalt();
expect(fn () => $kdf->pbkdf2('', $salt))
->toThrow(InvalidArgumentException::class, 'Password cannot be empty');
});
it('throws exception for short salt', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
expect(fn () => $kdf->pbkdf2('password', 'short'))
->toThrow(InvalidArgumentException::class, 'Salt must be at least 16 bytes');
});
it('throws exception for too few iterations', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$salt = $kdf->generateSalt();
expect(fn () => $kdf->pbkdf2('password', $salt, 5000))
->toThrow(InvalidArgumentException::class, 'Iterations must be at least 10,000');
});
it('supports different hash algorithms', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$salt = $kdf->generateSalt();
$sha256Key = $kdf->pbkdf2($password, $salt, 10000, 32, 'sha256');
$sha512Key = $kdf->pbkdf2($password, $salt, 10000, 32, 'sha512');
expect($sha256Key->getAlgorithm())->toBe('pbkdf2-sha256');
expect($sha512Key->getAlgorithm())->toBe('pbkdf2-sha512');
expect($sha256Key->getKey())->not->toBe($sha512Key->getKey());
});
it('provides recommended parameters', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$params = $kdf->getRecommendedParameters('pbkdf2-sha256', 'standard');
expect($params)->toHaveKey('iterations');
expect($params)->toHaveKey('key_length');
expect($params['iterations'])->toBe(100000);
expect($params['key_length'])->toBe(32);
});
it('throws exception for unsupported algorithm in recommendations', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
expect(fn () => $kdf->getRecommendedParameters('unsupported', 'standard'))
->toThrow(InvalidArgumentException::class);
});
it('creates instance using factory method', function () {
$randomGen = new SecureRandomGenerator();
$kdf = KeyDerivationFunction::create($randomGen);
expect($kdf)->toBeInstanceOf(KeyDerivationFunction::class);
});
it('generates different keys for same password with different salts', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'same-password';
$salt1 = $kdf->generateSalt();
$salt2 = $kdf->generateSalt();
$key1 = $kdf->pbkdf2($password, $salt1);
$key2 = $kdf->pbkdf2($password, $salt2);
expect($key1->getKey())->not->toBe($key2->getKey());
});
it('generates consistent keys for same inputs', function () {
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
$password = 'test-password';
$salt = str_repeat('a', 16);
$key1 = $kdf->pbkdf2($password, $salt, 10000, 32);
$key2 = $kdf->pbkdf2($password, $salt, 10000, 32);
expect($key1->getKey())->toBe($key2->getKey());
});

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
use App\Framework\Cryptography\SecureToken;
use App\Framework\Cryptography\SecureTokenGenerator;
use App\Framework\Random\SecureRandomGenerator;
it('generates secure token with default parameters', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generate('api_key');
expect($token)->toBeInstanceOf(SecureToken::class);
expect($token->getType())->toBe('api_key');
expect($token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64_URL);
expect($token->getLength())->toBe(32);
});
it('generates API key with prefix', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateApiKey('myapp');
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_API_KEY);
expect($token->getPrefix())->toBe('myapp');
expect($token->getValue())->toStartWith('myapp_');
});
it('generates session token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateSessionToken();
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_SESSION);
expect($token->getMetadataValue('purpose'))->toBe('session_management');
expect($token->getMetadataValue('secure'))->toBeTrue();
});
it('generates CSRF token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateCsrfToken();
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_CSRF);
expect($token->getMetadataValue('purpose'))->toBe('csrf_protection');
});
it('generates verification token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateVerificationToken('email_verification');
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_VERIFICATION);
expect($token->getMetadataValue('purpose'))->toBe('email_verification');
expect($token->getMetadataValue('single_use'))->toBeTrue();
});
it('generates OTP token with correct digits', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateOtpToken(6);
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_OTP);
expect($token->getValue())->toMatch('/^\d{6}$/');
expect($token->getLength())->toBe(6);
expect($token->getMetadataValue('digits'))->toBe(6);
});
it('generates webhook token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateWebhookToken();
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_WEBHOOK);
expect($token->getPrefix())->toBe('whsec');
expect($token->getValue())->toStartWith('whsec_');
});
it('generates bearer token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateBearerToken();
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_BEARER);
expect($token->getMetadataValue('purpose'))->toBe('api_authorization');
});
it('generates refresh token with longer length', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$token = $generator->generateRefreshToken();
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_REFRESH);
expect($token->getLength())->toBe(64);
expect($token->getMetadataValue('long_lived'))->toBeTrue();
});
it('generates tokens in different formats', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$base64Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE64);
$hexToken = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_HEX);
$base32Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE32);
expect($base64Token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64);
expect($hexToken->getFormat())->toBe(SecureTokenGenerator::FORMAT_HEX);
expect($base32Token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE32);
// Check format patterns
expect($hexToken->getValue())->toMatch('/^[0-9a-f]+$/');
expect($base32Token->getValue())->toMatch('/^[A-Z2-7]+$/');
});
it('generates custom tokens with alphabet', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$alphabet = '0123456789ABCDEF';
$token = $generator->generateCustom($alphabet, 16);
expect($token->getValue())->toMatch('/^[0-9A-F]{16}$/');
expect($token->getMetadataValue('alphabet'))->toBe($alphabet);
expect($token->getMetadataValue('alphabet_size'))->toBe(16);
});
it('generates batch of tokens', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$tokens = $generator->generateBatch('session', 5, 32);
expect($tokens)->toHaveCount(5);
expect($tokens[0])->toBeInstanceOf(SecureToken::class);
// All tokens should be unique
$values = array_map(fn ($token) => $token->getValue(), $tokens);
$uniqueValues = array_unique($values);
expect($uniqueValues)->toHaveCount(5);
});
it('validates token formats correctly', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect($generator->isValidFormat('dGVzdA', SecureTokenGenerator::FORMAT_BASE64))->toBeTrue();
expect($generator->isValidFormat('dGVzdA-_', SecureTokenGenerator::FORMAT_BASE64_URL))->toBeTrue();
expect($generator->isValidFormat('deadbeef', SecureTokenGenerator::FORMAT_HEX))->toBeTrue();
expect($generator->isValidFormat('MFRGG', SecureTokenGenerator::FORMAT_BASE32))->toBeTrue();
expect($generator->isValidFormat('Test123', SecureTokenGenerator::FORMAT_ALPHANUMERIC))->toBeTrue();
// Invalid formats
expect($generator->isValidFormat('invalid!@#', SecureTokenGenerator::FORMAT_BASE64_URL))->toBeFalse();
expect($generator->isValidFormat('invalidhex', SecureTokenGenerator::FORMAT_HEX))->toBeFalse();
});
it('calculates token entropy correctly', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$base64Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE64_URL);
$hexToken = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_HEX);
$base64Entropy = $generator->getEntropy($base64Token);
$hexEntropy = $generator->getEntropy($hexToken);
expect($base64Entropy)->toBeGreaterThan($hexEntropy); // Base64 has higher entropy per character
expect($base64Entropy)->toBeGreaterThan(100); // Should have significant entropy
});
it('throws exception for too short token length', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generate('test', 8))
->toThrow(InvalidArgumentException::class, 'Token length must be at least 16 bytes');
});
it('throws exception for too long token length', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generate('test', 512))
->toThrow(InvalidArgumentException::class, 'Token length cannot exceed 256 bytes');
});
it('throws exception for unsupported token type', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generate('unsupported_type'))
->toThrow(InvalidArgumentException::class, 'Unsupported token type');
});
it('throws exception for unsupported format', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generate('api_key', 32, 'unsupported_format'))
->toThrow(InvalidArgumentException::class, 'Unsupported format');
});
it('throws exception for invalid OTP digits', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generateOtpToken(2))
->toThrow(InvalidArgumentException::class, 'OTP digits must be between 4 and 12');
expect(fn () => $generator->generateOtpToken(15))
->toThrow(InvalidArgumentException::class, 'OTP digits must be between 4 and 12');
});
it('throws exception for too small custom alphabet', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generateCustom('ABC', 16))
->toThrow(InvalidArgumentException::class, 'Alphabet must contain at least 16 characters');
});
it('throws exception for too short custom token', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generateCustom('0123456789ABCDEF', 4))
->toThrow(InvalidArgumentException::class, 'Token length must be at least 8 characters');
});
it('throws exception for invalid batch count', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
expect(fn () => $generator->generateBatch('session', 0))
->toThrow(InvalidArgumentException::class, 'Count must be positive');
expect(fn () => $generator->generateBatch('session', 2000))
->toThrow(InvalidArgumentException::class, 'Batch size cannot exceed 1000');
});
it('creates instance using factory method', function () {
$randomGen = new SecureRandomGenerator();
$generator = SecureTokenGenerator::create($randomGen);
expect($generator)->toBeInstanceOf(SecureTokenGenerator::class);
});
it('removes duplicate characters from custom alphabet', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$alphabetWithDuplicates = '0123456789ABCDEFABCDEF'; // Has duplicate ABCDEF
$token = $generator->generateCustom($alphabetWithDuplicates, 8);
$metadata = $token->getMetadata();
$actualAlphabet = $metadata['alphabet'];
expect($actualAlphabet)->toBe('0123456789ABCDEF'); // Duplicates removed
expect($metadata['alphabet_size'])->toBe(16);
});
it('generates unique tokens in batch', function () {
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
$tokens = $generator->generateBatch('api_key', 10, 32);
$values = array_map(fn ($token) => $token->getValue(), $tokens);
$uniqueValues = array_unique($values);
expect($uniqueValues)->toHaveCount(10); // All should be unique
});

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
use App\Framework\Cryptography\SecureToken;
use App\Framework\Cryptography\SecureTokenGenerator;
it('creates secure token with valid parameters', function () {
$token = new SecureToken(
value: 'test-token-value',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: 'ak',
rawBytes: 'raw-bytes-data',
metadata: ['purpose' => 'testing']
);
expect($token->getValue())->toBe('test-token-value');
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_API_KEY);
expect($token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64_URL);
expect($token->getLength())->toBe(32);
expect($token->getPrefix())->toBe('ak');
expect($token->getRawBytes())->toBe('raw-bytes-data');
expect($token->getMetadata())->toBe(['purpose' => 'testing']);
});
it('throws exception for empty token value', function () {
expect(fn () => new SecureToken(
value: '',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
))->toThrow(InvalidArgumentException::class, 'Token value cannot be empty');
});
it('provides string representation', function () {
$tokenValue = 'test-token-value';
$token = new SecureToken(
value: $tokenValue,
type: SecureTokenGenerator::TYPE_SESSION,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
expect($token->toString())->toBe($tokenValue);
expect((string)$token)->toBe($tokenValue);
});
it('extracts value without prefix', function () {
$token = new SecureToken(
value: 'ak_abcd1234efgh5678',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: 'ak',
rawBytes: 'raw-bytes',
metadata: []
);
expect($token->getValueWithoutPrefix())->toBe('abcd1234efgh5678');
expect($token->hasPrefix())->toBeTrue();
});
it('handles token without prefix', function () {
$token = new SecureToken(
value: 'abcd1234efgh5678',
type: SecureTokenGenerator::TYPE_SESSION,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
expect($token->getValueWithoutPrefix())->toBe('abcd1234efgh5678');
expect($token->hasPrefix())->toBeFalse();
});
it('provides raw bytes in different formats', function () {
$rawBytes = "\x01\x02\x03\x04";
$token = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: $rawBytes,
metadata: []
);
expect($token->getRawBytesHex())->toBe('01020304');
expect($token->getRawBytesBase64())->toBe(base64_encode($rawBytes));
});
it('checks equality correctly with timing-safe comparison', function () {
$tokenValue = 'same-token-value';
$token1 = new SecureToken(
value: $tokenValue,
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
$token2 = new SecureToken(
value: $tokenValue,
type: SecureTokenGenerator::TYPE_SESSION,
format: SecureTokenGenerator::FORMAT_HEX,
length: 64,
prefix: 'test',
rawBytes: 'different-raw',
metadata: ['different' => 'metadata']
);
$token3 = new SecureToken(
value: 'different-token-value',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
expect($token1->equals($token2))->toBeTrue(); // Same value
expect($token1->equals($token3))->toBeFalse(); // Different value
});
it('verifies token value with timing-safe comparison', function () {
$tokenValue = 'secure-token-value';
$token = new SecureToken(
value: $tokenValue,
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
expect($token->verify($tokenValue))->toBeTrue();
expect($token->verify('wrong-token-value'))->toBeFalse();
});
it('identifies token types correctly', function () {
$apiKeyToken = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
$sessionToken = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_SESSION,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
expect($apiKeyToken->isApiKey())->toBeTrue();
expect($apiKeyToken->isSessionToken())->toBeFalse();
expect($sessionToken->isSessionToken())->toBeTrue();
expect($sessionToken->isApiKey())->toBeFalse();
});
it('checks token properties from metadata', function () {
$singleUseToken = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_VERIFICATION,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: ['single_use' => true, 'long_lived' => false]
);
$longLivedToken = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_REFRESH,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 64,
prefix: null,
rawBytes: 'raw-bytes',
metadata: ['single_use' => false, 'long_lived' => true]
);
expect($singleUseToken->isSingleUse())->toBeTrue();
expect($singleUseToken->isLongLived())->toBeFalse();
expect($longLivedToken->isLongLived())->toBeTrue();
expect($longLivedToken->isSingleUse())->toBeFalse();
});
it('gets specific metadata values', function () {
$token = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: ['purpose' => 'api_auth', 'expires' => 3600]
);
expect($token->getMetadataValue('purpose'))->toBe('api_auth');
expect($token->getMetadataValue('expires'))->toBe(3600);
expect($token->getMetadataValue('nonexistent', 'default'))->toBe('default');
});
it('calculates token age', function () {
$token = new SecureToken(
value: 'test-token',
type: SecureTokenGenerator::TYPE_SESSION,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
sleep(1);
$age = $token->getAgeInSeconds();
expect($age)->toBeGreaterThanOrEqual(1);
});
it('exports to and imports from array', function () {
$originalToken = new SecureToken(
value: 'test-token-value',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: 'ak',
rawBytes: 'raw-bytes-data',
metadata: ['purpose' => 'testing', 'scope' => 'read']
);
$array = $originalToken->toArray();
$restoredToken = SecureToken::fromArray($array);
expect($restoredToken->getValue())->toBe($originalToken->getValue());
expect($restoredToken->getType())->toBe($originalToken->getType());
expect($restoredToken->getFormat())->toBe($originalToken->getFormat());
expect($restoredToken->getLength())->toBe($originalToken->getLength());
expect($restoredToken->getPrefix())->toBe($originalToken->getPrefix());
expect($restoredToken->getRawBytes())->toBe($originalToken->getRawBytes());
expect($restoredToken->getMetadata())->toBe($originalToken->getMetadata());
});
it('provides safe summary without sensitive data', function () {
$token = new SecureToken(
value: 'very-secret-token-value',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: 'ak',
rawBytes: 'raw-bytes',
metadata: ['purpose' => 'api_auth', 'scope' => 'admin']
);
$summary = $token->getSafeSummary();
expect($summary)->toHaveKey('type');
expect($summary)->toHaveKey('format');
expect($summary)->toHaveKey('length');
expect($summary)->toHaveKey('prefix');
expect($summary)->toHaveKey('has_prefix');
expect($summary)->toHaveKey('metadata_keys');
expect($summary)->not->toHaveKey('value'); // Should not contain sensitive value
expect($summary)->not->toHaveKey('raw_bytes'); // Should not contain raw bytes
expect($summary['metadata_keys'])->toBe(['purpose', 'scope']);
});
it('generates fingerprint for identification', function () {
$token = new SecureToken(
value: 'test-token-value',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
$fingerprint = $token->getFingerprint();
$shortFingerprint = $token->getShortFingerprint();
expect($fingerprint)->toHaveLength(64); // SHA-256 hex
expect($shortFingerprint)->toHaveLength(16);
expect($fingerprint)->toStartWith($shortFingerprint);
});
it('masks token value for safe logging', function () {
$longToken = new SecureToken(
value: 'this-is-a-very-long-token-value-for-testing',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
$shortToken = new SecureToken(
value: 'short',
type: SecureTokenGenerator::TYPE_API_KEY,
format: SecureTokenGenerator::FORMAT_BASE64_URL,
length: 32,
prefix: null,
rawBytes: 'raw-bytes',
metadata: []
);
$longMasked = $longToken->getMaskedValue();
$shortMasked = $shortToken->getMaskedValue();
expect($longMasked)->toStartWith('this');
expect($longMasked)->toEndWith('ting');
expect($longMasked)->toContain('*');
expect($shortMasked)->toBe('***'); // Short tokens fully masked
});
it('throws exception for missing required fields in fromArray', function () {
$incompleteData = [
'value' => 'test-token',
'type' => SecureTokenGenerator::TYPE_API_KEY,
// Missing format, length, raw_bytes
];
expect(fn () => SecureToken::fromArray($incompleteData))
->toThrow(InvalidArgumentException::class, 'Missing required field');
});
it('throws exception for invalid base64 raw bytes in fromArray', function () {
$invalidData = [
'value' => 'test-token',
'type' => SecureTokenGenerator::TYPE_API_KEY,
'format' => SecureTokenGenerator::FORMAT_BASE64_URL,
'length' => 32,
'raw_bytes' => 'invalid-base64!@#',
];
expect(fn () => SecureToken::fromArray($invalidData))
->toThrow(InvalidArgumentException::class, 'Invalid Base64 raw bytes');
});