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:
267
tests/Unit/Framework/Cryptography/CryptographicUtilitiesTest.php
Normal file
267
tests/Unit/Framework/Cryptography/CryptographicUtilitiesTest.php
Normal 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);
|
||||
});
|
||||
269
tests/Unit/Framework/Cryptography/DerivedKeyTest.php
Normal file
269
tests/Unit/Framework/Cryptography/DerivedKeyTest.php
Normal 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');
|
||||
});
|
||||
178
tests/Unit/Framework/Cryptography/KeyDerivationFunctionTest.php
Normal file
178
tests/Unit/Framework/Cryptography/KeyDerivationFunctionTest.php
Normal 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());
|
||||
});
|
||||
244
tests/Unit/Framework/Cryptography/SecureTokenGeneratorTest.php
Normal file
244
tests/Unit/Framework/Cryptography/SecureTokenGeneratorTest.php
Normal 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
|
||||
});
|
||||
360
tests/Unit/Framework/Cryptography/SecureTokenTest.php
Normal file
360
tests/Unit/Framework/Cryptography/SecureTokenTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user