From add356f6ac41c0cb3a37c6def2a2bba2fff63c16 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Wed, 13 May 2026 17:21:47 +0600 Subject: [PATCH 1/4] resolved ic issues --- .codex | 0 .github/scripts/composer-audit-guard.php | 85 ----- .github/scripts/phpstan-sarif.php | 178 ----------- .github/scripts/syntax.php | 109 ------- .github/workflows/security-standards.yml | 40 +++ benchmarks/CryptoBench.php | 60 ++-- benchmarks/GenerateBench.php | 18 +- benchmarks/SecurityBench.php | 39 +-- benchmarks/TokenBench.php | 9 +- captainhook.json | 8 +- composer.json | 95 ++---- pest.xml | 22 -- phpbench.json | 26 -- phpcs.xml.dist | 66 ---- phpstan.neon.dist | 16 - phpunit.xml | 22 -- pint.json | 129 -------- psalm.xml | 39 --- rector.php | 14 - src/Certificate/OpenSSL/RsaCipher.php | 53 +++- src/Certificate/Sodium/KeyPairGenerator.php | 17 +- .../Sodium/SigningKeyPairGenerator.php | 25 +- .../Sodium/Support/SodiumKeyPairFactory.php | 33 ++ src/Crypto/AeadCipher.php | 34 +- src/Crypto/Contract/CipherInterface.php | 2 +- src/Crypto/Enum/AeadAlgorithm.php | 27 +- src/Crypto/PublicKeyBoxCipher.php | 17 +- src/Crypto/SealedBoxCipher.php | 30 +- src/Crypto/SecretBoxCipher.php | 19 +- src/Crypto/SecretStream.php | 66 ++-- src/Crypto/Signature.php | 30 +- src/Crypto/Support/KeyDecoder.php | 32 ++ src/DataProtection/StringProtector.php | 2 +- src/Security/ActionToken.php | 41 +-- src/Security/EmailVerificationToken.php | 35 +-- src/Security/PasswordResetToken.php | 36 +-- src/Security/RememberToken.php | 39 +-- src/Security/SignedUrl.php | 90 ++++-- src/Security/Support/AbstractPurposeToken.php | 85 +++++ src/Token/Contract/JwtTokenInterface.php | 2 +- src/Token/Contract/PayloadTokenInterface.php | 2 +- src/Token/Jwt/AsymmetricJwt.php | 290 +++--------------- src/Token/Jwt/Support/AbstractJwt.php | 215 +++++++++++++ src/Token/Jwt/Support/JwtCommon.php | 97 ++++++ src/Token/Jwt/SymmetricJwt.php | 273 ++--------------- src/Token/Payload/SignedPayload.php | 53 ++-- src/Token/Support/TokenAnyKey.php | 50 +++ src/Token/Support/TokenKeyCandidates.php | 34 ++ tests/Certificate/DomainTest.php | 2 +- tests/Crypto/CoreServicesTest.php | 8 +- tests/DataProtection/ServicesTest.php | 25 +- tests/Generate/ServicesTest.php | 20 +- tests/Integrity/ServicesTest.php | 2 +- tests/Password/ServicesTest.php | 20 +- tests/Security/UtilitiesTest.php | 8 +- tests/Token/Jwt/AsymmetricServiceTest.php | 2 +- tests/Token/Jwt/SymmetricServiceTest.php | 4 +- 57 files changed, 1094 insertions(+), 1701 deletions(-) delete mode 100644 .codex delete mode 100644 .github/scripts/composer-audit-guard.php delete mode 100644 .github/scripts/phpstan-sarif.php delete mode 100644 .github/scripts/syntax.php create mode 100644 .github/workflows/security-standards.yml delete mode 100644 pest.xml delete mode 100644 phpbench.json delete mode 100644 phpcs.xml.dist delete mode 100644 phpstan.neon.dist delete mode 100644 phpunit.xml delete mode 100644 pint.json delete mode 100644 psalm.xml delete mode 100644 rector.php create mode 100644 src/Certificate/Sodium/Support/SodiumKeyPairFactory.php create mode 100644 src/Crypto/Support/KeyDecoder.php create mode 100644 src/Security/Support/AbstractPurposeToken.php create mode 100644 src/Token/Jwt/Support/AbstractJwt.php create mode 100644 src/Token/Jwt/Support/JwtCommon.php create mode 100644 src/Token/Support/TokenAnyKey.php create mode 100644 src/Token/Support/TokenKeyCandidates.php diff --git a/.codex b/.codex deleted file mode 100644 index e69de29..0000000 diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php deleted file mode 100644 index a1b1cdb..0000000 --- a/.github/scripts/composer-audit-guard.php +++ /dev/null @@ -1,85 +0,0 @@ - ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], -]; - -$process = proc_open($command, $descriptorSpec, $pipes); - -if (! \is_resource($process)) { - fwrite(STDERR, "Failed to start composer audit process.\n"); - exit(1); -} - -fclose($pipes[0]); -$stdout = stream_get_contents($pipes[1]) ?: ''; -$stderr = stream_get_contents($pipes[2]) ?: ''; -fclose($pipes[1]); -fclose($pipes[2]); - -$exitCode = proc_close($process); - -/** @var array|null $decoded */ -$decoded = json_decode($stdout, true); - -if (! \is_array($decoded)) { - fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); - if (trim($stdout) !== '') { - fwrite(STDERR, $stdout . "\n"); - } - if (trim($stderr) !== '') { - fwrite(STDERR, $stderr . "\n"); - } - - exit($exitCode !== 0 ? $exitCode : 1); -} - -$advisories = $decoded['advisories'] ?? []; -$abandoned = $decoded['abandoned'] ?? []; - -$advisoryCount = 0; - -if (\is_array($advisories)) { - foreach ($advisories as $entries) { - if (\is_array($entries)) { - $advisoryCount += \count($entries); - } - } -} - -$abandonedPackages = []; - -if (\is_array($abandoned)) { - foreach ($abandoned as $package => $replacement) { - if (\is_string($package) && $package !== '') { - $abandonedPackages[$package] = $replacement; - } - } -} - -echo sprintf( - "Composer audit summary: %d advisories, %d abandoned packages.\n", - $advisoryCount, - \count($abandonedPackages), -); - -if ($abandonedPackages !== []) { - fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); - foreach ($abandonedPackages as $package => $replacement) { - $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; - fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); - } -} - -if ($advisoryCount > 0) { - fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); - exit(1); -} - -exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php deleted file mode 100644 index 2b01b26..0000000 --- a/.github/scripts/phpstan-sarif.php +++ /dev/null @@ -1,178 +0,0 @@ - [sarif-output] - */ - -$argv = $_SERVER['argv'] ?? []; -$input = $argv[1] ?? ''; -$output = $argv[2] ?? 'phpstan-results.sarif'; - -if (! is_string($input) || $input === '') { - fwrite(STDERR, "Error: missing input file.\n"); - fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); - exit(2); -} - -if (! is_file($input) || ! is_readable($input)) { - fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); - exit(2); -} - -$raw = file_get_contents($input); -if ($raw === false) { - fwrite(STDERR, "Error: failed to read input file: {$input}\n"); - exit(2); -} - -$decoded = json_decode($raw, true); -if (! is_array($decoded)) { - fwrite(STDERR, "Error: input is not valid JSON.\n"); - exit(2); -} - -/** - * @return non-empty-string - */ -function normalizeUri(string $path): string -{ - $normalized = str_replace('\\', '/', $path); - $cwd = getcwd(); - - if (is_string($cwd) && $cwd !== '') { - $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); - - if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { - if (stripos($normalized, $cwd . '/') === 0) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } elseif (str_starts_with($normalized, '/')) { - if (str_starts_with($normalized, $cwd . '/')) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } - } - - $normalized = ltrim($normalized, './'); - - return $normalized === '' ? 'unknown.php' : $normalized; -} - -$results = []; -$rules = []; - -$globalErrors = $decoded['errors'] ?? []; -if (is_array($globalErrors)) { - foreach ($globalErrors as $error) { - if (! is_string($error) || $error === '') { - continue; - } - - $ruleId = 'phpstan.internal'; - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $error, - ], - ]; - } -} - -$files = $decoded['files'] ?? []; -if (is_array($files)) { - foreach ($files as $filePath => $fileData) { - if (! is_string($filePath) || ! is_array($fileData)) { - continue; - } - - $messages = $fileData['messages'] ?? []; - if (! is_array($messages)) { - continue; - } - - foreach ($messages as $messageData) { - if (! is_array($messageData)) { - continue; - } - - $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); - $line = (int) ($messageData['line'] ?? 1); - $identifier = (string) ($messageData['identifier'] ?? ''); - $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; - - if ($line < 1) { - $line = 1; - } - - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $messageText, - ], - 'locations' => [[ - 'physicalLocation' => [ - 'artifactLocation' => [ - 'uri' => normalizeUri($filePath), - ], - 'region' => [ - 'startLine' => $line, - ], - ], - ]], - ]; - } - } -} - -$ruleDescriptors = []; -$ruleIds = array_keys($rules); -sort($ruleIds); - -foreach ($ruleIds as $ruleId) { - $ruleDescriptors[] = [ - 'id' => $ruleId, - 'name' => $ruleId, - 'shortDescription' => [ - 'text' => $ruleId, - ], - ]; -} - -$sarif = [ - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'version' => '2.1.0', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPStan', - 'informationUri' => 'https://phpstan.org/', - 'rules' => $ruleDescriptors, - ], - ], - 'results' => $results, - ]], -]; - -$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); -if (! is_string($encoded)) { - fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); - exit(2); -} - -$written = file_put_contents($output, $encoded . PHP_EOL); -if ($written === false) { - fwrite(STDERR, "Error: failed to write output file: {$output}\n"); - exit(2); -} - -fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); -exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php deleted file mode 100644 index 043bf53..0000000 --- a/.github/scripts/syntax.php +++ /dev/null @@ -1,109 +0,0 @@ -isFile()) { - continue; - } - - $filename = $entry->getFilename(); - if (! str_ends_with($filename, '.php')) { - continue; - } - - $files[] = $entry->getPathname(); - } -} - -$files = array_values(array_unique($files)); -sort($files); - -if ($files === []) { - fwrite(STDOUT, "No PHP files found.\n"); - exit(0); -} - -$failed = []; - -foreach ($files as $file) { - $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; - $descriptorSpec = [ - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptorSpec, $pipes); - - if (! is_resource($process)) { - $failed[] = [$file, 'Could not start PHP lint process']; - continue; - } - - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[2]); - - $exitCode = proc_close($process); - - if ($exitCode !== 0) { - $output = trim((string) $stdout . "\n" . (string) $stderr); - $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; - } -} - -if ($failed === []) { - fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); - exit(0); -} - -fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); - -foreach ($failed as [$file, $error]) { - fwrite(STDERR, "- {$file}\n{$error}\n"); -} - -exit(1); diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..5821682 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,40 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "hash, json, mbstring, openssl, sodium" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" + artifact_retention_days: 61 + diff --git a/benchmarks/CryptoBench.php b/benchmarks/CryptoBench.php index 312ca3c..41e3366 100644 --- a/benchmarks/CryptoBench.php +++ b/benchmarks/CryptoBench.php @@ -19,31 +19,16 @@ final class CryptoBench { private AeadCipher $aeadCipher; - private string $aeadCiphertext; - - private string $aeadKey; - - private string $detachedSignature; - private Mac $mac; - private string $macKey; - - private string $macValue; - - private string $plaintext; - private SecretBoxCipher $secretBoxCipher; - private string $secretBoxCiphertext; - - private string $secretBoxKey; - private Signature $signature; - private string $signPrivateKey; - - private string $signPublicKey; + /** + * @var array + */ + private array $state = []; public function __construct() { @@ -56,68 +41,69 @@ public function __construct() public function setUp(): void { $keyGenerator = new KeyMaterialGenerator(); - $this->plaintext = str_repeat('epicrypt-benchmark-payload-', 4); + $plaintext = str_repeat('epicrypt-benchmark-payload-', 4); + $this->state['plaintext'] = $plaintext; - $this->aeadKey = $keyGenerator->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); - $this->aeadCiphertext = $this->aeadCipher->encrypt($this->plaintext, $this->aeadKey, ['aad' => 'bench-aad']); + $this->state['aeadKey'] = $keyGenerator->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + $this->state['aeadCiphertext'] = $this->aeadCipher->encrypt($plaintext, $this->state['aeadKey'], ['aad' => 'bench-aad']); - $this->secretBoxKey = $keyGenerator->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); - $this->secretBoxCiphertext = $this->secretBoxCipher->encrypt($this->plaintext, $this->secretBoxKey); + $this->state['secretBoxKey'] = $keyGenerator->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $this->state['secretBoxCiphertext'] = $this->secretBoxCipher->encrypt($plaintext, $this->state['secretBoxKey']); - $this->macKey = $this->mac->generateKey(); - $this->macValue = $this->mac->generate($this->plaintext, $this->macKey); + $this->state['macKey'] = $this->mac->generateKey(); + $this->state['macValue'] = $this->mac->generate($plaintext, $this->state['macKey']); $keyPair = KeyPairGenerator::sodiumSign()->generate(asBase64Url: true); - $this->signPrivateKey = $keyPair['private']; - $this->signPublicKey = $keyPair['public']; - $this->detachedSignature = $this->signature->sign($this->plaintext, $this->signPrivateKey); + $this->state['signPrivateKey'] = $keyPair['private']; + $this->state['signPublicKey'] = $keyPair['public']; + $this->state['detachedSignature'] = $this->signature->sign($plaintext, $this->state['signPrivateKey']); } #[Bench\BeforeMethods('setUp')] public function benchAeadDecrypt(): void { - $this->aeadCipher->decrypt($this->aeadCiphertext, $this->aeadKey, ['aad' => 'bench-aad']); + $this->aeadCipher->decrypt($this->state['aeadCiphertext'], $this->state['aeadKey'], ['aad' => 'bench-aad']); } #[Bench\BeforeMethods('setUp')] public function benchAeadEncrypt(): void { - $this->aeadCipher->encrypt($this->plaintext, $this->aeadKey, ['aad' => 'bench-aad']); + $this->aeadCipher->encrypt($this->state['plaintext'], $this->state['aeadKey'], ['aad' => 'bench-aad']); } #[Bench\BeforeMethods('setUp')] public function benchDetachedSignatureSign(): void { - $this->signature->sign($this->plaintext, $this->signPrivateKey); + $this->signature->sign($this->state['plaintext'], $this->state['signPrivateKey']); } #[Bench\BeforeMethods('setUp')] public function benchDetachedSignatureVerify(): void { - $this->signature->verify($this->plaintext, $this->detachedSignature, $this->signPublicKey); + $this->signature->verify($this->state['plaintext'], $this->state['detachedSignature'], $this->state['signPublicKey']); } #[Bench\BeforeMethods('setUp')] public function benchMacGenerate(): void { - $this->mac->generate($this->plaintext, $this->macKey); + $this->mac->generate($this->state['plaintext'], $this->state['macKey']); } #[Bench\BeforeMethods('setUp')] public function benchMacVerify(): void { - $this->mac->verify($this->plaintext, $this->macValue, $this->macKey); + $this->mac->verify($this->state['plaintext'], $this->state['macValue'], $this->state['macKey']); } #[Bench\BeforeMethods('setUp')] public function benchSecretBoxDecrypt(): void { - $this->secretBoxCipher->decrypt($this->secretBoxCiphertext, $this->secretBoxKey); + $this->secretBoxCipher->decrypt($this->state['secretBoxCiphertext'], $this->state['secretBoxKey']); } #[Bench\BeforeMethods('setUp')] public function benchSecretBoxEncrypt(): void { - $this->secretBoxCipher->encrypt($this->plaintext, $this->secretBoxKey); + $this->secretBoxCipher->encrypt($this->state['plaintext'], $this->state['secretBoxKey']); } } diff --git a/benchmarks/GenerateBench.php b/benchmarks/GenerateBench.php index 2691143..b04d54b 100644 --- a/benchmarks/GenerateBench.php +++ b/benchmarks/GenerateBench.php @@ -28,11 +28,19 @@ final class GenerateBench public function __construct() { - $this->random = new RandomBytesGenerator(); - $this->nonce = new NonceGenerator(); - $this->salt = new SaltGenerator(); - $this->keyMaterial = new KeyMaterialGenerator(); - $this->tokenMaterial = new TokenMaterialGenerator(); + $services = [ + 'random' => new RandomBytesGenerator(), + 'nonce' => new NonceGenerator(), + 'salt' => new SaltGenerator(), + 'keyMaterial' => new KeyMaterialGenerator(), + 'tokenMaterial' => new TokenMaterialGenerator(), + ]; + + $this->random = $services['random']; + $this->nonce = $services['nonce']; + $this->salt = $services['salt']; + $this->keyMaterial = $services['keyMaterial']; + $this->tokenMaterial = $services['tokenMaterial']; } public function benchKeyMaterialGenerate(): void diff --git a/benchmarks/SecurityBench.php b/benchmarks/SecurityBench.php index 2bf39bc..ca9fa89 100644 --- a/benchmarks/SecurityBench.php +++ b/benchmarks/SecurityBench.php @@ -19,27 +19,20 @@ final class SecurityBench { private ActionToken $actionToken; - private string $actionTokenValue; - private CsrfTokenManager $csrf; - private string $csrfToken; - private EmailVerificationToken $emailVerificationToken; - private string $emailVerificationTokenValue; - private PasswordResetToken $passwordResetToken; - private string $passwordResetTokenValue; - private RememberToken $rememberToken; - private string $rememberTokenValue; - private SignedUrl $signedUrl; - private string $signedUrlValue; + /** + * @var array + */ + private array $state = []; public function __construct() { @@ -54,22 +47,22 @@ public function __construct() public function setUp(): void { - $this->signedUrlValue = $this->signedUrl->generate( + $this->state['signedUrlValue'] = $this->signedUrl->generate( 'https://example.com/download', ['file' => 'report.csv', 'uid' => 'bench-user'], time() + 3600, ); - $this->csrfToken = $this->csrf->issueToken('session-bench'); - $this->passwordResetTokenValue = $this->passwordResetToken->issue('bench-user'); - $this->emailVerificationTokenValue = $this->emailVerificationToken->issue('bench-user', 'user@example.com'); - $this->rememberTokenValue = $this->rememberToken->issue('bench-user', 'device-1'); - $this->actionTokenValue = $this->actionToken->issue('bench-user', 'delete-account'); + $this->state['csrfToken'] = $this->csrf->issueToken('session-bench'); + $this->state['passwordResetTokenValue'] = $this->passwordResetToken->issue('bench-user'); + $this->state['emailVerificationTokenValue'] = $this->emailVerificationToken->issue('bench-user', 'user@example.com'); + $this->state['rememberTokenValue'] = $this->rememberToken->issue('bench-user', 'device-1'); + $this->state['actionTokenValue'] = $this->actionToken->issue('bench-user', 'delete-account'); } #[Bench\BeforeMethods('setUp')] public function benchActionTokenVerify(): void { - $this->actionToken->verify($this->actionTokenValue, 'bench-user', 'delete-account'); + $this->actionToken->verify($this->state['actionTokenValue'], 'bench-user', 'delete-account'); } #[Bench\BeforeMethods('setUp')] @@ -81,25 +74,25 @@ public function benchCsrfIssue(): void #[Bench\BeforeMethods('setUp')] public function benchCsrfVerify(): void { - $this->csrf->verifyToken('session-bench', $this->csrfToken); + $this->csrf->verifyToken('session-bench', $this->state['csrfToken']); } #[Bench\BeforeMethods('setUp')] public function benchEmailVerificationVerify(): void { - $this->emailVerificationToken->verify($this->emailVerificationTokenValue, 'user@example.com'); + $this->emailVerificationToken->verify($this->state['emailVerificationTokenValue'], 'user@example.com'); } #[Bench\BeforeMethods('setUp')] public function benchPasswordResetVerify(): void { - $this->passwordResetToken->verify($this->passwordResetTokenValue, 'bench-user'); + $this->passwordResetToken->verify($this->state['passwordResetTokenValue'], 'bench-user'); } #[Bench\BeforeMethods('setUp')] public function benchRememberTokenVerify(): void { - $this->rememberToken->verify($this->rememberTokenValue, 'bench-user', 'device-1'); + $this->rememberToken->verify($this->state['rememberTokenValue'], 'bench-user', 'device-1'); } #[Bench\BeforeMethods('setUp')] @@ -115,6 +108,6 @@ public function benchSignedUrlGenerate(): void #[Bench\BeforeMethods('setUp')] public function benchSignedUrlVerify(): void { - $this->signedUrl->verify($this->signedUrlValue); + $this->signedUrl->verify($this->state['signedUrlValue']); } } diff --git a/benchmarks/TokenBench.php b/benchmarks/TokenBench.php index 9c10fe1..4df613b 100644 --- a/benchmarks/TokenBench.php +++ b/benchmarks/TokenBench.php @@ -116,18 +116,21 @@ public function benchSignedPayloadEncode(): void #[Bench\BeforeMethods('setUp')] public function benchSymmetricJwtDecode(): void { - $this->symmetricJwtDecoder->decode($this->jwtToken, $this->jwtSecret); + $token = $this->jwtToken; + $this->symmetricJwtDecoder->decode($token, $this->jwtSecret); } #[Bench\BeforeMethods('setUp')] public function benchSymmetricJwtEncode(): void { - $this->symmetricJwtEncoder->encode($this->jwtClaims, $this->jwtSecret); + $claims = $this->jwtClaims; + $this->symmetricJwtEncoder->encode($claims, $this->jwtSecret); } #[Bench\BeforeMethods('setUp')] public function benchSymmetricJwtVerify(): void { - $this->symmetricJwtDecoder->verify($this->jwtToken, $this->jwtSecret); + $token = $this->jwtToken; + $this->symmetricJwtDecoder->verify($token, $this->jwtSecret); } } diff --git a/captainhook.json b/captainhook.json index fa19900..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,11 +15,15 @@ "options": [] }, { - "action": "composer release:audit", + "action": "composer normalize --dry-run", "options": [] }, { - "action": "composer tests", + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", "options": [] } ] diff --git a/composer.json b/composer.json index e676264..0d67121 100644 --- a/composer.json +++ b/composer.json @@ -1,24 +1,14 @@ { "name": "infocyph/epicrypt", "description": "A Collection of useful PHP security functions.", - "type": "library", "license": "MIT", + "type": "library", "authors": [ { "name": "abmmhasan", "email": "abmmhasan@gmail.com" } ], - "autoload": { - "psr-4": { - "Infocyph\\Epicrypt\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Infocyph\\Epicrypt\\Tests\\": "tests/" - } - }, "require": { "php": ">=8.4", "ext-hash": "*", @@ -26,78 +16,31 @@ "ext-mbstring": "*", "ext-openssl": "*", "ext-sodium": "*", - "infocyph/pathwise": "^2.3.1" + "infocyph/pathwise": "^2.5" }, "require-dev": { - "captainhook/captainhook": "^5.29.2", - "laravel/pint": "^1.29", - "pestphp/pest": "^4.6.2", - "pestphp/pest-plugin-drift": "^4.1", - "phpbench/phpbench": "^1.6.1", - "phpstan/phpstan": "^2.1.50", - "rector/rector": "^2.4.2", - "squizlabs/php_codesniffer": "^4.0.1", - "symfony/var-dumper": "^7.3 || ^8.0.8", - "tomasvotruba/cognitive-complexity": "^1.1", - "vimeo/psalm": "^6.16.1" - }, - "scripts": { - "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", - "test:code": "@php vendor/bin/pest", - "test:lint": "@php vendor/bin/pint --test", - "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", - "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", - "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", - "test:refactor": "@php vendor/bin/rector process --dry-run --debug", - "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "test:details": [ - "@test:syntax", - "@test:code", - "@test:lint", - "@test:sniff", - "@test:static", - "@test:security", - "@test:refactor" - ], - "test:all": [ - "@test:syntax", - "@php vendor/bin/pest --parallel --processes=10", - "@php vendor/bin/pint --test", - "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", - "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", - "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", - "@php vendor/bin/rector process --dry-run --debug" - ], - "release:audit": "@php .github/scripts/composer-audit-guard.php", - "release:guard": [ - "@composer validate --strict", - "@release:audit", - "@tests" - ], - "process:lint": "@php vendor/bin/pint", - "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", - "process:refactor": "@php vendor/bin/rector process", - "process:all": [ - "@process:refactor", - "@process:lint", - "@process:sniff:fix" - ], - "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", - "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", - "tests": "@test:all", - "process": "@process:all", - "benchmark": "@bench:run", - "post-autoload-dump": "captainhook install --only-enabled -nf" + "infocyph/phpforge": "dev-main" }, "minimum-stability": "stable", "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\Epicrypt\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Infocyph\\Epicrypt\\Tests\\": "tests/" + } + }, "config": { - "sort-packages": true, - "optimize-autoloader": true, - "classmap-authoritative": true, "allow-plugins": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, "pestphp/pest-plugin": true - } + }, + "classmap-authoritative": true, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/pest.xml b/pest.xml deleted file mode 100644 index d5d12d8..0000000 --- a/pest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index fff7e05..0000000 --- a/phpbench.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.path": "benchmarks", - "runner.file_pattern": "*Bench.php", - "runner.attributes": true, - "runner.annotations": false, - "runner.progress": "dots", - "runner.retry_threshold": 8, - "report.generators": { - "chart": { - "title": "Benchmark Chart", - "description": "Console bar chart grouped by benchmark subject", - "generator": "component", - "components": [ - { - "component": "bar_chart_aggregate", - "x_partition": ["subject_name"], - "bar_partition": ["benchmark_name"], - "y_expr": "mode(partition['result_time_avg'])", - "y_axes_label": "yValue as time precision 1" - } - ] - } - } -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 1cf0c6c..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,66 +0,0 @@ - - - Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. - - - - - - - ./src - ./tests - - */vendor/* - */.git/* - */.idea/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 650fbdf..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,16 +0,0 @@ -includes: - - vendor/tomasvotruba/cognitive-complexity/config/extension.neon - -parameters: - customRulesetUsed: true - level: max - paths: - - src - parallel: - maximumNumberOfProcesses: 2 - cognitive_complexity: - class: 80 - function: 12 - dependency_tree: 80 - dependency_tree_types: [] - reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index d5d12d8..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index 6f546dc..0000000 --- a/pint.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "preset": "per", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php" - ], - "rules": { - "ordered_imports": { - "imports_order": [ - "class", - "function", - "const" - ], - "sort_algorithm": "alpha" - }, - "no_unused_imports": true, - "class_attributes_separation": { - "elements": { - "trait_import": "none", - "case": "one", - "const": "one", - "property": "one", - "method": "one" - } - }, - "ordered_class_elements": { - "order": [ - "use_trait", - "case", - "constant_public", - "constant_protected", - "constant_private", - "constant", - "property_public_static", - "property_protected_static", - "property_private_static", - "property_static", - "property_public_readonly", - "property_protected_readonly", - "property_private_readonly", - "property_public_abstract", - "property_protected_abstract", - "property_public", - "property_protected", - "property_private", - "property", - "construct", - "destruct", - "magic", - "phpunit", - "method_public_abstract_static", - "method_protected_abstract_static", - "method_private_abstract_static", - "method_public_abstract", - "method_protected_abstract", - "method_private_abstract", - "method_abstract", - "method_public_static", - "method_public", - "method_protected_static", - "method_protected", - "method_private_static", - "method_private", - "method_static", - "method" - ], - "sort_algorithm": "alpha" - }, - "blank_line_after_opening_tag": true, - "no_alias_functions": true, - "multiline_whitespace_before_semicolons": true, - "no_trailing_whitespace": true, - "blank_line_before_statement": { - "statements": [ - "break", - "continue", - "declare", - "return", - "throw", - "try" - ] - }, - "phpdoc_align": { - "align": "left" - }, - "binary_operator_spaces": { - "default": "single_space" - }, - "concat_space": { - "spacing": "one" - }, - "cast_spaces": true, - "unary_operator_spaces": true, - "ternary_operator_spaces": true, - "array_indentation": true, - "trim_array_spaces": true, - "method_argument_space": { - "on_multiline": "ensure_fully_multiline" - }, - "trailing_comma_in_multiline": { - "elements": [ - "arrays", - "arguments", - "parameters", - "match" - ] - }, - "single_quote": true, - "single_line_empty_body": true, - "no_multiple_statements_per_line": true, - "no_extra_blank_lines": true, - "no_whitespace_in_blank_line": true, - "single_blank_line_at_eof": true, - "statement_indentation": true, - "control_structure_braces": true, - "control_structure_continuation_position": true, - "declare_parentheses": true, - "declare_strict_types": true, - "lowercase_keywords": true, - "constant_case": true, - "lowercase_static_reference": true, - "native_function_casing": true, - "nullable_type_declaration_for_default_null_value": true, - "no_superfluous_phpdoc_tags": true, - "phpdoc_trim": true - } -} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 0cbbcd3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rector.php b/rector.php deleted file mode 100644 index e30ddf8..0000000 --- a/rector.php +++ /dev/null @@ -1,14 +0,0 @@ -withPaths([__DIR__ . '/src']) - ->withPreparedSets(deadCode: true) - ->withPhpVersion( - constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), - ) - ->withPhpSets(); diff --git a/src/Certificate/OpenSSL/RsaCipher.php b/src/Certificate/OpenSSL/RsaCipher.php index 2f543ee..199d1e2 100644 --- a/src/Certificate/OpenSSL/RsaCipher.php +++ b/src/Certificate/OpenSSL/RsaCipher.php @@ -8,6 +8,7 @@ use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; use Infocyph\Epicrypt\Internal\Base64Url; +use OpenSSLAsymmetricKey; final class RsaCipher { @@ -16,25 +17,53 @@ public function decrypt(string $ciphertext, string $privateKey, ?string $passphr $privateResource = Pem::requirePrivateKeyResource($privateKey, $passphrase); $decoded = Base64Url::decode($ciphertext); - $decrypted = null; - $ok = openssl_private_decrypt($decoded, $decrypted, $privateResource, OPENSSL_PKCS1_OAEP_PADDING); - if (!$ok || !is_string($decrypted)) { - throw new DecryptionException('RSA decryption failed.'); - } - - return $decrypted; + return $this->run( + static fn(string $input, mixed &$output, OpenSSLAsymmetricKey $resource): bool => openssl_private_decrypt( + $input, + $output, + $resource, + OPENSSL_PKCS1_OAEP_PADDING, + ), + $decoded, + $privateResource, + new DecryptionException('RSA decryption failed.'), + ); } public function encrypt(string $plaintext, string $publicKey): string { $publicResource = Pem::requirePublicKeyResource($publicKey); - $encrypted = null; - $ok = openssl_public_encrypt($plaintext, $encrypted, $publicResource, OPENSSL_PKCS1_OAEP_PADDING); - if (!$ok || !is_string($encrypted)) { - throw new EncryptionException('RSA encryption failed.'); - } + $encrypted = $this->run( + static fn(string $input, mixed &$output, OpenSSLAsymmetricKey $resource): bool => openssl_public_encrypt( + $input, + $output, + $resource, + OPENSSL_PKCS1_OAEP_PADDING, + ), + $plaintext, + $publicResource, + new EncryptionException('RSA encryption failed.'), + ); return Base64Url::encode($encrypted); } + + /** + * @param callable(string, mixed&, OpenSSLAsymmetricKey): bool $operation + */ + private function run( + callable $operation, + string $input, + OpenSSLAsymmetricKey $resource, + \RuntimeException $exception, + ): string { + $output = ''; + $ok = $operation($input, $output, $resource); + if (!$ok || !is_string($output)) { + throw $exception; + } + + return $output; + } } diff --git a/src/Certificate/Sodium/KeyPairGenerator.php b/src/Certificate/Sodium/KeyPairGenerator.php index 0c5f680..1b1711e 100644 --- a/src/Certificate/Sodium/KeyPairGenerator.php +++ b/src/Certificate/Sodium/KeyPairGenerator.php @@ -5,7 +5,7 @@ namespace Infocyph\Epicrypt\Certificate\Sodium; use Infocyph\Epicrypt\Certificate\Contract\KeyPairGeneratorInterface; -use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Certificate\Sodium\Support\SodiumKeyPairFactory; final class KeyPairGenerator implements KeyPairGeneratorInterface { @@ -16,14 +16,11 @@ public function generate(?string $passphrase = null, bool $asBase64Url = false): { unset($passphrase); - $keypair = sodium_crypto_box_keypair(); - $private = sodium_crypto_box_secretkey($keypair); - $public = sodium_crypto_box_publickey($keypair); - - if (!$asBase64Url) { - return ['private' => $private, 'public' => $public]; - } - - return ['private' => Base64Url::encode($private), 'public' => Base64Url::encode($public)]; + return SodiumKeyPairFactory::generate( + createKeyPair: sodium_crypto_box_keypair(...), + extractPrivate: sodium_crypto_box_secretkey(...), + extractPublic: sodium_crypto_box_publickey(...), + asBase64Url: $asBase64Url, + ); } } diff --git a/src/Certificate/Sodium/SigningKeyPairGenerator.php b/src/Certificate/Sodium/SigningKeyPairGenerator.php index 1e07181..23a7f69 100644 --- a/src/Certificate/Sodium/SigningKeyPairGenerator.php +++ b/src/Certificate/Sodium/SigningKeyPairGenerator.php @@ -5,7 +5,7 @@ namespace Infocyph\Epicrypt\Certificate\Sodium; use Infocyph\Epicrypt\Certificate\Contract\KeyPairGeneratorInterface; -use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Certificate\Sodium\Support\SodiumKeyPairFactory; final class SigningKeyPairGenerator implements KeyPairGeneratorInterface { @@ -16,14 +16,23 @@ public function generate(?string $passphrase = null, bool $asBase64Url = false): { unset($passphrase); - $keypair = sodium_crypto_sign_keypair(); - $private = sodium_crypto_sign_secretkey($keypair); - $public = sodium_crypto_sign_publickey($keypair); + return SodiumKeyPairFactory::generate( + createKeyPair: sodium_crypto_sign_keypair(...), + extractPrivate: static function (string $keyPair): string { + if ($keyPair === '') { + throw new \RuntimeException('Signing key pair is empty.'); + } - if (!$asBase64Url) { - return ['private' => $private, 'public' => $public]; - } + return sodium_crypto_sign_secretkey($keyPair); + }, + extractPublic: static function (string $keyPair): string { + if ($keyPair === '') { + throw new \RuntimeException('Signing key pair is empty.'); + } - return ['private' => Base64Url::encode($private), 'public' => Base64Url::encode($public)]; + return sodium_crypto_sign_publickey($keyPair); + }, + asBase64Url: $asBase64Url, + ); } } diff --git a/src/Certificate/Sodium/Support/SodiumKeyPairFactory.php b/src/Certificate/Sodium/Support/SodiumKeyPairFactory.php new file mode 100644 index 0000000..499f517 --- /dev/null +++ b/src/Certificate/Sodium/Support/SodiumKeyPairFactory.php @@ -0,0 +1,33 @@ + $private, 'public' => $public]; + } + + return ['private' => Base64Url::encode($private), 'public' => Base64Url::encode($public)]; + } +} diff --git a/src/Crypto/AeadCipher.php b/src/Crypto/AeadCipher.php index b942bb6..29c69ca 100644 --- a/src/Crypto/AeadCipher.php +++ b/src/Crypto/AeadCipher.php @@ -8,6 +8,7 @@ use Infocyph\Epicrypt\Crypto\Enum\AeadAlgorithm; use Infocyph\Epicrypt\Exception\Crypto\CryptoException; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; +use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Exception\Crypto\InvalidNonceException; use Infocyph\Epicrypt\Internal\Base64Url; @@ -139,21 +140,34 @@ private function decodeKey(mixed $key, int $expectedLength, bool $isBinary, stri private function decryptRaw(string $ciphertext, string $aad, string $nonce, string $key): string|false { - return match ($this->algorithm) { - AeadAlgorithm::AES_256_GCM => sodium_crypto_aead_aes256gcm_decrypt($ciphertext, $aad, $nonce, $key), - AeadAlgorithm::CHACHA20_POLY1305 => sodium_crypto_aead_chacha20poly1305_decrypt($ciphertext, $aad, $nonce, $key), - AeadAlgorithm::CHACHA20_POLY1305_IETF => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($ciphertext, $aad, $nonce, $key), - AeadAlgorithm::XCHACHA20_POLY1305_IETF => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, $aad, $nonce, $key), - }; + return $this->runRawOperation($ciphertext, $aad, $nonce, $key, true); } private function encryptRaw(string $plaintext, string $aad, string $nonce, string $key): string + { + $result = $this->runRawOperation($plaintext, $aad, $nonce, $key, false); + if (!is_string($result)) { + throw new EncryptionException('Encryption failed.'); + } + + return $result; + } + + private function runRawOperation(string $input, string $aad, string $nonce, string $key, bool $decrypt): string|false { return match ($this->algorithm) { - AeadAlgorithm::AES_256_GCM => sodium_crypto_aead_aes256gcm_encrypt($plaintext, $aad, $nonce, $key), - AeadAlgorithm::CHACHA20_POLY1305 => sodium_crypto_aead_chacha20poly1305_encrypt($plaintext, $aad, $nonce, $key), - AeadAlgorithm::CHACHA20_POLY1305_IETF => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($plaintext, $aad, $nonce, $key), - AeadAlgorithm::XCHACHA20_POLY1305_IETF => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, $aad, $nonce, $key), + AeadAlgorithm::AES_256_GCM => $decrypt + ? sodium_crypto_aead_aes256gcm_decrypt($input, $aad, $nonce, $key) + : sodium_crypto_aead_aes256gcm_encrypt($input, $aad, $nonce, $key), + AeadAlgorithm::CHACHA20_POLY1305 => $decrypt + ? sodium_crypto_aead_chacha20poly1305_decrypt($input, $aad, $nonce, $key) + : sodium_crypto_aead_chacha20poly1305_encrypt($input, $aad, $nonce, $key), + AeadAlgorithm::CHACHA20_POLY1305_IETF => $decrypt + ? sodium_crypto_aead_chacha20poly1305_ietf_decrypt($input, $aad, $nonce, $key) + : sodium_crypto_aead_chacha20poly1305_ietf_encrypt($input, $aad, $nonce, $key), + AeadAlgorithm::XCHACHA20_POLY1305_IETF => $decrypt + ? sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($input, $aad, $nonce, $key) + : sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($input, $aad, $nonce, $key), }; } } diff --git a/src/Crypto/Contract/CipherInterface.php b/src/Crypto/Contract/CipherInterface.php index 2937ed3..29a0b01 100644 --- a/src/Crypto/Contract/CipherInterface.php +++ b/src/Crypto/Contract/CipherInterface.php @@ -4,4 +4,4 @@ namespace Infocyph\Epicrypt\Crypto\Contract; -interface CipherInterface extends EncryptorInterface, DecryptorInterface {} +interface CipherInterface extends DecryptorInterface, EncryptorInterface {} diff --git a/src/Crypto/Enum/AeadAlgorithm.php b/src/Crypto/Enum/AeadAlgorithm.php index eb34b20..b14836c 100644 --- a/src/Crypto/Enum/AeadAlgorithm.php +++ b/src/Crypto/Enum/AeadAlgorithm.php @@ -24,12 +24,7 @@ public function isAvailable(): bool */ public function keyLength(): int { - return match ($this) { - self::AES_256_GCM => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES, - self::CHACHA20_POLY1305 => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, - self::CHACHA20_POLY1305_IETF => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES, - self::XCHACHA20_POLY1305_IETF => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, - }; + return $this->lengths()['key']; } /** @@ -37,12 +32,7 @@ public function keyLength(): int */ public function nonceLength(): int { - return match ($this) { - self::AES_256_GCM => SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, - self::CHACHA20_POLY1305 => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES, - self::CHACHA20_POLY1305_IETF => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES, - self::XCHACHA20_POLY1305_IETF => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, - }; + return $this->lengths()['nonce']; } public function requiresHardwareSupport(): bool @@ -59,4 +49,17 @@ public function sodiumSuffix(): string self::XCHACHA20_POLY1305_IETF => 'xchacha20poly1305_ietf', }; } + + /** + * @return array{key: int<1, max>, nonce: int<1, max>} + */ + private function lengths(): array + { + return match ($this) { + self::AES_256_GCM => ['key' => SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES, 'nonce' => SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES], + self::CHACHA20_POLY1305 => ['key' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES, 'nonce' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES], + self::CHACHA20_POLY1305_IETF => ['key' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES, 'nonce' => SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES], + self::XCHACHA20_POLY1305_IETF => ['key' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, 'nonce' => SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES], + }; + } } diff --git a/src/Crypto/PublicKeyBoxCipher.php b/src/Crypto/PublicKeyBoxCipher.php index a3cd1fe..4456d9c 100644 --- a/src/Crypto/PublicKeyBoxCipher.php +++ b/src/Crypto/PublicKeyBoxCipher.php @@ -5,6 +5,7 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; +use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; @@ -75,15 +76,11 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str */ private function decodeKey(mixed $value, string $name, array $context): string { - if (!is_string($value) || $value === '') { - throw new InvalidKeyException(sprintf('%s must be a non-empty string.', $name)); - } - - $decoded = (bool) ($context['key_is_binary'] ?? false) ? $value : Base64Url::decode($value); - if (strlen($decoded) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) { - throw new InvalidKeyException(sprintf('%s has invalid key length.', $name)); - } - - return $decoded; + return KeyDecoder::decode( + $value, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, + $name, + ); } } diff --git a/src/Crypto/SealedBoxCipher.php b/src/Crypto/SealedBoxCipher.php index 78fe05d..dc2e0a0 100644 --- a/src/Crypto/SealedBoxCipher.php +++ b/src/Crypto/SealedBoxCipher.php @@ -5,8 +5,8 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; +use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; -use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; use Infocyph\Epicrypt\Internal\Enum\EncryptedPayloadVersion; use Infocyph\Epicrypt\Internal\VersionedPayload; @@ -18,14 +18,12 @@ final class SealedBoxCipher implements CipherInterface */ public function decrypt(string $ciphertext, mixed $key, array $context = []): string { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException('Recipient keypair must be a non-empty string.'); - } - - $keypair = (bool) ($context['key_is_binary'] ?? false) ? $key : Base64Url::decode($key); - if (strlen($keypair) !== SODIUM_CRYPTO_BOX_KEYPAIRBYTES) { - throw new InvalidKeyException('Recipient keypair has invalid length.'); - } + $keypair = KeyDecoder::decode( + $key, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_BOX_KEYPAIRBYTES, + 'Recipient keypair', + ); $parsedPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 1); if ($parsedPayload === null) { @@ -46,14 +44,12 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st */ public function encrypt(string $plaintext, mixed $key, array $context = []): string { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException('Recipient public key must be a non-empty string.'); - } - - $publicKey = (bool) ($context['key_is_binary'] ?? false) ? $key : Base64Url::decode($key); - if (strlen($publicKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) { - throw new InvalidKeyException('Recipient public key has invalid length.'); - } + $publicKey = KeyDecoder::decode( + $key, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, + 'Recipient public key', + ); $ciphertext = sodium_crypto_box_seal($plaintext, $publicKey); diff --git a/src/Crypto/SecretBoxCipher.php b/src/Crypto/SecretBoxCipher.php index 5fc9d5a..5522c6e 100644 --- a/src/Crypto/SecretBoxCipher.php +++ b/src/Crypto/SecretBoxCipher.php @@ -5,6 +5,7 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; +use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; @@ -61,15 +62,15 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str */ private function decodeKey(mixed $key, array $context, string $operation): string { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException(sprintf('%s key must be a non-empty string.', $operation)); + try { + return KeyDecoder::decode( + $key, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_SECRETBOX_KEYBYTES, + sprintf('%s key', $operation), + ); + } catch (InvalidKeyException $e) { + throw new InvalidKeyException(sprintf('%s key must be 32 bytes.', $operation), 0, $e); } - - $decodedKey = (bool) ($context['key_is_binary'] ?? false) ? $key : Base64Url::decode($key); - if (strlen($decodedKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { - throw new InvalidKeyException(sprintf('%s key must be 32 bytes.', $operation)); - } - - return $decodedKey; } } diff --git a/src/Crypto/SecretStream.php b/src/Crypto/SecretStream.php index 41f232c..d7cc552 100644 --- a/src/Crypto/SecretStream.php +++ b/src/Crypto/SecretStream.php @@ -71,20 +71,11 @@ private function decryptUsingCryptoStream(string $inputPath, SafeFileWriter $fil throw new RuntimeException('Invalid nonce length.'); } - $chunkIterator = $fileReader->binary($chunkSize); - - foreach ($chunkIterator as $chunk) { - if ($chunk === null || $chunk === '') { - continue; - } - if (!is_string($chunk)) { - throw new RuntimeException('Invalid plaintext chunk encountered.'); - } - + $this->forEachChunk($fileReader, $chunkSize, 'plaintext', function (string $chunk) use ($fileWriter, &$nonce): void { $decryptedChunk = sodium_crypto_stream_xchacha20_xor($chunk, $nonce, $this->key); $this->writeBinary($fileWriter, $decryptedChunk); $nonce = $this->incrementNonce($nonce); - } + }); } finally { $fileReader->releaseLock(); } @@ -107,12 +98,10 @@ private function decryptUsingSecretStream(string $inputPath, SafeFileWriter $fil $chunkIterator = $fileReader->binary($cipherChunkSize); foreach ($chunkIterator as $chunk) { - if ($chunk === null || $chunk === '') { + $chunk = $this->normalizeChunk($chunk, 'ciphertext'); + if ($chunk === null) { continue; } - if (!is_string($chunk)) { - throw new RuntimeException('Invalid ciphertext chunk encountered.'); - } $decryptedFrame = sodium_crypto_secretstream_xchacha20poly1305_pull( $state, @@ -156,22 +145,14 @@ private function encryptUsingCryptoStream(string $inputPath, SafeFileWriter $fil $fileReader = new SafeFileReader($inputPath); try { - $chunkIterator = $fileReader->binary($chunkSize); $writeChunkSize = 0; - foreach ($chunkIterator as $chunk) { - if ($chunk === null || $chunk === '') { - continue; - } - if (!is_string($chunk)) { - throw new RuntimeException('Invalid plaintext chunk encountered.'); - } - + $this->forEachChunk($fileReader, $chunkSize, 'plaintext', function (string $chunk) use ($fileWriter, &$nonce, &$writeChunkSize): void { $encryptedChunk = sodium_crypto_stream_xchacha20_xor($chunk, $nonce, $this->key); $this->writeBinary($fileWriter, $encryptedChunk); $writeChunkSize = strlen($encryptedChunk); $nonce = $this->incrementNonce($nonce); - } + }); return $writeChunkSize; } finally { @@ -197,12 +178,10 @@ private function encryptUsingSecretStream(string $inputPath, SafeFileWriter $fil $bufferedChunk = null; foreach ($chunkIterator as $chunk) { - if ($chunk === null || $chunk === '') { + $chunk = $this->normalizeChunk($chunk, 'plaintext'); + if ($chunk === null) { continue; } - if (!is_string($chunk)) { - throw new RuntimeException('Invalid plaintext chunk encountered.'); - } if ($bufferedChunk === null) { $bufferedChunk = $chunk; @@ -239,6 +218,18 @@ private function encryptUsingSecretStream(string $inputPath, SafeFileWriter $fil } } + private function forEachChunk(SafeFileReader $fileReader, int $chunkSize, string $kind, callable $consumer): void + { + foreach ($fileReader->binary($chunkSize) as $chunk) { + $chunk = $this->normalizeChunk($chunk, $kind); + if ($chunk === null) { + continue; + } + + $consumer($chunk); + } + } + private function incrementNonce(string $nonce): string { $length = strlen($nonce); @@ -248,8 +239,8 @@ private function incrementNonce(string $nonce): string $bytes = str_split($nonce); - for ($index = $length - 1; $index >= 0; --$index) { - $next = (ord($bytes[$index]) + 1) & 0xff; + for ($index = $length - 1; $index >= 0; $index--) { + $next = (ord($bytes[$index]) + 1) & 0xFF; $bytes[$index] = chr($next); if ($next !== 0) { @@ -260,6 +251,19 @@ private function incrementNonce(string $nonce): string return implode('', $bytes); } + private function normalizeChunk(mixed $chunk, string $kind): ?string + { + if ($chunk === null || $chunk === '') { + return null; + } + + if (!is_string($chunk)) { + throw new RuntimeException(sprintf('Invalid %s chunk encountered.', $kind)); + } + + return $chunk; + } + private function writeBinary(SafeFileWriter $fileWriter, string $data): void { $written = $fileWriter->__call('binary', [$data]); diff --git a/src/Crypto/Signature.php b/src/Crypto/Signature.php index 9198bba..2b55bfb 100644 --- a/src/Crypto/Signature.php +++ b/src/Crypto/Signature.php @@ -5,7 +5,7 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\SignatureInterface; -use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; +use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\SignatureException; use Infocyph\Epicrypt\Internal\Base64Url; @@ -16,14 +16,12 @@ final class Signature implements SignatureInterface */ public function sign(string $message, mixed $key, array $context = []): string { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException('Private key must be a non-empty string.'); - } - - $privateKey = (bool) ($context['key_is_binary'] ?? false) ? $key : Base64Url::decode($key); - if (strlen($privateKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { - throw new InvalidKeyException('Private key has invalid length.'); - } + $privateKey = KeyDecoder::decode( + $key, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_SIGN_SECRETKEYBYTES, + 'Private key', + ); $signature = sodium_crypto_sign_detached($message, $privateKey); @@ -35,14 +33,12 @@ public function sign(string $message, mixed $key, array $context = []): string */ public function verify(string $message, string $signature, mixed $key, array $context = []): bool { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException('Public key must be a non-empty string.'); - } - - $publicKey = (bool) ($context['key_is_binary'] ?? false) ? $key : Base64Url::decode($key); - if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { - throw new InvalidKeyException('Public key has invalid length.'); - } + $publicKey = KeyDecoder::decode( + $key, + (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, + 'Public key', + ); $decodedSignature = Base64Url::decode($signature); if ($decodedSignature === '') { diff --git a/src/Crypto/Support/KeyDecoder.php b/src/Crypto/Support/KeyDecoder.php new file mode 100644 index 0000000..f8a221e --- /dev/null +++ b/src/Crypto/Support/KeyDecoder.php @@ -0,0 +1,32 @@ +codec = new SignedPayloadCodec($secret); + parent::__construct($secret, $ttlSeconds); } /** @@ -24,36 +21,18 @@ public function __construct( */ public function issue(string $subject, string $action, array $context = []): string { - $purpose = SecurityTokenPurpose::ACTION_TOKEN->value; - - return $this->codec->issue([ + return $this->issueForPurpose(SecurityTokenPurpose::ACTION_TOKEN, [ 'sub' => $subject, 'action' => $action, 'ctx' => $context, - 'purpose' => $purpose, - ], time() + $this->ttlSeconds, $purpose); + ]); } public function verify(string $token, ?string $subject = null, ?string $action = null): bool { - try { - $purpose = SecurityTokenPurpose::ACTION_TOKEN->value; - $claims = $this->codec->verify($token, $purpose); - if (($claims['purpose'] ?? null) !== $purpose) { - return false; - } - - if ($subject !== null && (!isset($claims['sub']) || !is_string($claims['sub']) || !hash_equals($claims['sub'], $subject))) { - return false; - } - - if ($action !== null && (!isset($claims['action']) || !is_string($claims['action']) || !hash_equals($claims['action'], $action))) { - return false; - } - - return true; - } catch (TokenException) { - return false; - } + return $this->verifyForPurpose(SecurityTokenPurpose::ACTION_TOKEN, $token, [ + 'sub' => $subject, + 'action' => $action, + ]); } } diff --git a/src/Security/EmailVerificationToken.php b/src/Security/EmailVerificationToken.php index 36c9d7f..56667ba 100644 --- a/src/Security/EmailVerificationToken.php +++ b/src/Security/EmailVerificationToken.php @@ -4,48 +4,25 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Exception\Token\TokenException; -use Infocyph\Epicrypt\Internal\SignedPayloadCodec; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; +use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; -final readonly class EmailVerificationToken +final readonly class EmailVerificationToken extends AbstractPurposeToken { - private SignedPayloadCodec $codec; - public function __construct( string $secret, - private int $ttlSeconds = 86400, + int $ttlSeconds = 86400, ) { - $this->codec = new SignedPayloadCodec($secret); + parent::__construct($secret, $ttlSeconds); } public function issue(string $userId, string $email): string { - $purpose = SecurityTokenPurpose::EMAIL_VERIFICATION->value; - - return $this->codec->issue([ - 'sub' => $userId, - 'email' => $email, - 'purpose' => $purpose, - ], time() + $this->ttlSeconds, $purpose); + return $this->issueSubjectAndClaim(SecurityTokenPurpose::EMAIL_VERIFICATION, $userId, 'email', $email); } public function verify(string $token, ?string $email = null): bool { - try { - $purpose = SecurityTokenPurpose::EMAIL_VERIFICATION->value; - $claims = $this->codec->verify($token, $purpose); - if (($claims['purpose'] ?? null) !== $purpose) { - return false; - } - - if ($email !== null) { - return isset($claims['email']) && is_string($claims['email']) && hash_equals($claims['email'], $email); - } - - return true; - } catch (TokenException) { - return false; - } + return $this->verifySubjectAndClaim(SecurityTokenPurpose::EMAIL_VERIFICATION, $token, null, 'email', $email); } } diff --git a/src/Security/PasswordResetToken.php b/src/Security/PasswordResetToken.php index 082d988..004c32c 100644 --- a/src/Security/PasswordResetToken.php +++ b/src/Security/PasswordResetToken.php @@ -4,47 +4,29 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Exception\Token\TokenException; -use Infocyph\Epicrypt\Internal\SignedPayloadCodec; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; +use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; -final readonly class PasswordResetToken +final readonly class PasswordResetToken extends AbstractPurposeToken { - private SignedPayloadCodec $codec; - public function __construct( string $secret, - private int $ttlSeconds = 1800, + int $ttlSeconds = 1800, ) { - $this->codec = new SignedPayloadCodec($secret); + parent::__construct($secret, $ttlSeconds); } public function issue(string $userId): string { - $purpose = SecurityTokenPurpose::PASSWORD_RESET->value; - - return $this->codec->issue([ + return $this->issueForPurpose(SecurityTokenPurpose::PASSWORD_RESET, [ 'sub' => $userId, - 'purpose' => $purpose, - ], time() + $this->ttlSeconds, $purpose); + ]); } public function verify(string $token, ?string $userId = null): bool { - try { - $purpose = SecurityTokenPurpose::PASSWORD_RESET->value; - $claims = $this->codec->verify($token, $purpose); - if (($claims['purpose'] ?? null) !== $purpose) { - return false; - } - - if ($userId !== null) { - return isset($claims['sub']) && is_string($claims['sub']) && hash_equals($claims['sub'], $userId); - } - - return true; - } catch (TokenException) { - return false; - } + return $this->verifyForPurpose(SecurityTokenPurpose::PASSWORD_RESET, $token, [ + 'sub' => $userId, + ]); } } diff --git a/src/Security/RememberToken.php b/src/Security/RememberToken.php index 1220b57..3914a77 100644 --- a/src/Security/RememberToken.php +++ b/src/Security/RememberToken.php @@ -4,52 +4,25 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Exception\Token\TokenException; -use Infocyph\Epicrypt\Internal\SignedPayloadCodec; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; +use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; -final readonly class RememberToken +final readonly class RememberToken extends AbstractPurposeToken { - private SignedPayloadCodec $codec; - public function __construct( string $secret, - private int $ttlSeconds = 1209600, + int $ttlSeconds = 1209600, ) { - $this->codec = new SignedPayloadCodec($secret); + parent::__construct($secret, $ttlSeconds); } public function issue(string $userId, string $deviceId): string { - $purpose = SecurityTokenPurpose::REMEMBER_TOKEN->value; - - return $this->codec->issue([ - 'sub' => $userId, - 'device' => $deviceId, - 'purpose' => $purpose, - ], time() + $this->ttlSeconds, $purpose); + return $this->issueSubjectAndClaim(SecurityTokenPurpose::REMEMBER_TOKEN, $userId, 'device', $deviceId); } public function verify(string $token, ?string $userId = null, ?string $deviceId = null): bool { - try { - $purpose = SecurityTokenPurpose::REMEMBER_TOKEN->value; - $claims = $this->codec->verify($token, $purpose); - if (($claims['purpose'] ?? null) !== $purpose) { - return false; - } - - if ($userId !== null && (!isset($claims['sub']) || !is_string($claims['sub']) || !hash_equals($claims['sub'], $userId))) { - return false; - } - - if ($deviceId !== null && (!isset($claims['device']) || !is_string($claims['device']) || !hash_equals($claims['device'], $deviceId))) { - return false; - } - - return true; - } catch (TokenException) { - return false; - } + return $this->verifySubjectAndClaim(SecurityTokenPurpose::REMEMBER_TOKEN, $token, $userId, 'device', $deviceId); } } diff --git a/src/Security/SignedUrl.php b/src/Security/SignedUrl.php index b25daf1..a7558fa 100644 --- a/src/Security/SignedUrl.php +++ b/src/Security/SignedUrl.php @@ -26,15 +26,7 @@ public function __construct( */ public function generate(string $url, array $parameters = [], ?int $expiresAt = null): string { - $parts = parse_url($url); - if (!is_array($parts)) { - throw new ConfigurationException('Invalid URL provided for signing.'); - } - - $existing = []; - if (isset($parts['query'])) { - parse_str($parts['query'], $existing); - } + [$parts, $existing] = $this->parseUrlWithQueryOrFail($url); $merged = array_merge($existing, $parameters); $merged[$this->versionParam] = SignedUrlVersion::V1->value; @@ -42,11 +34,8 @@ public function generate(string $url, array $parameters = [], ?int $expiresAt = $merged[$this->expiresParam] = $expiresAt; } - ksort($merged); $basePath = $this->buildBasePath($parts); - $query = http_build_query($merged); - - $signature = Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $query, $this->secret, true)); + $signature = $this->computeSignature($basePath, $merged); $merged[$this->signatureParam] = $signature; return $basePath . '?' . http_build_query($merged); @@ -54,13 +43,11 @@ public function generate(string $url, array $parameters = [], ?int $expiresAt = public function verify(string $signedUrl): bool { - $parts = parse_url($signedUrl); - if (!is_array($parts)) { + $parsed = $this->parseUrlWithQuery($signedUrl); + if ($parsed === null) { return false; } - - $query = []; - parse_str((string) ($parts['query'] ?? ''), $query); + [$parts, $query] = $parsed; $givenSignature = $query[$this->signatureParam] ?? null; if (!is_string($givenSignature) || $givenSignature === '') { @@ -78,10 +65,8 @@ public function verify(string $signedUrl): bool return false; } - ksort($query); $basePath = $this->buildBasePath($parts); - $normalized = http_build_query($query); - $computed = Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $normalized, $this->secret, true)); + $computed = $this->computeSignature($basePath, $query); return SecureCompare::equals($computed, $givenSignature); } @@ -100,4 +85,67 @@ private function buildBasePath(array $parts): string return $scheme . '://' . $host . $port . $path; } + + /** + * @param array $query + */ + private function computeSignature(string $basePath, array $query): string + { + $query = array_filter($query, static fn(mixed $value): bool => $value !== null); + ksort($query); + + return Base64Url::encode(hash_hmac('sha256', $basePath . '?' . http_build_query($query), $this->secret, true)); + } + + /** + * @param array $query + * @return array + */ + private function normalizeQuery(array $query): array + { + $normalized = []; + + foreach ($query as $key => $value) { + if (!is_string($key) || $key === '') { + continue; + } + + if (is_scalar($value)) { + $normalized[$key] = $value; + } + } + + return $normalized; + } + + /** + * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, array}|null + */ + private function parseUrlWithQuery(string $url): ?array + { + $parts = parse_url($url); + if (!is_array($parts)) { + return null; + } + + $query = []; + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + } + + return [$parts, $this->normalizeQuery($query)]; + } + + /** + * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, array} + */ + private function parseUrlWithQueryOrFail(string $url): array + { + $parsed = $this->parseUrlWithQuery($url); + if ($parsed === null) { + throw new ConfigurationException('Invalid URL provided for signing.'); + } + + return $parsed; + } } diff --git a/src/Security/Support/AbstractPurposeToken.php b/src/Security/Support/AbstractPurposeToken.php new file mode 100644 index 0000000..eb7267c --- /dev/null +++ b/src/Security/Support/AbstractPurposeToken.php @@ -0,0 +1,85 @@ +codec = new SignedPayloadCodec($secret); + } + + /** + * @param array $claims + */ + protected function issueForPurpose(SecurityTokenPurpose $purpose, array $claims): string + { + $purposeValue = $purpose->value; + + return $this->codec->issue( + ['purpose' => $purposeValue] + $claims, + time() + $this->ttlSeconds, + $purposeValue, + ); + } + + protected function issueSubjectAndClaim(SecurityTokenPurpose $purpose, string $subject, string $claimName, string $claimValue): string + { + return $this->issueForPurpose($purpose, [ + 'sub' => $subject, + $claimName => $claimValue, + ]); + } + + /** + * @param array $expectedStringClaims + */ + protected function verifyForPurpose(SecurityTokenPurpose $purpose, string $token, array $expectedStringClaims = []): bool + { + try { + $purposeValue = $purpose->value; + $claims = $this->codec->verify($token, $purposeValue); + + if (($claims['purpose'] ?? null) !== $purposeValue) { + return false; + } + + foreach ($expectedStringClaims as $claimName => $expected) { + if ($expected === null) { + continue; + } + + if (!isset($claims[$claimName]) || !is_string($claims[$claimName]) || !hash_equals($claims[$claimName], $expected)) { + return false; + } + } + + return true; + } catch (TokenException) { + return false; + } + } + + protected function verifySubjectAndClaim( + SecurityTokenPurpose $purpose, + string $token, + ?string $subject, + string $claimName, + ?string $claimValue, + ): bool { + return $this->verifyForPurpose($purpose, $token, [ + 'sub' => $subject, + $claimName => $claimValue, + ]); + } +} diff --git a/src/Token/Contract/JwtTokenInterface.php b/src/Token/Contract/JwtTokenInterface.php index 80d4699..fb3dd5a 100644 --- a/src/Token/Contract/JwtTokenInterface.php +++ b/src/Token/Contract/JwtTokenInterface.php @@ -4,4 +4,4 @@ namespace Infocyph\Epicrypt\Token\Contract; -interface JwtTokenInterface extends TokenEncoderInterface, TokenDecoderInterface, TokenVerifierInterface {} +interface JwtTokenInterface extends TokenDecoderInterface, TokenEncoderInterface, TokenVerifierInterface {} diff --git a/src/Token/Contract/PayloadTokenInterface.php b/src/Token/Contract/PayloadTokenInterface.php index f29e3ae..6c67605 100644 --- a/src/Token/Contract/PayloadTokenInterface.php +++ b/src/Token/Contract/PayloadTokenInterface.php @@ -4,4 +4,4 @@ namespace Infocyph\Epicrypt\Token\Contract; -interface PayloadTokenInterface extends TokenEncoderInterface, TokenDecoderInterface, TokenVerifierInterface {} +interface PayloadTokenInterface extends TokenDecoderInterface, TokenEncoderInterface, TokenVerifierInterface {} diff --git a/src/Token/Jwt/AsymmetricJwt.php b/src/Token/Jwt/AsymmetricJwt.php index c2f6095..b244bea 100644 --- a/src/Token/Jwt/AsymmetricJwt.php +++ b/src/Token/Jwt/AsymmetricJwt.php @@ -4,306 +4,90 @@ namespace Infocyph\Epicrypt\Token\Jwt; -use ArrayAccess; -use Infocyph\Epicrypt\Exception\Token\InvalidClaimException; use Infocyph\Epicrypt\Exception\Token\InvalidTokenException; -use Infocyph\Epicrypt\Exception\Token\KeyResolutionException; use Infocyph\Epicrypt\Exception\Token\TokenException; use Infocyph\Epicrypt\Exception\Token\UnsupportedAlgorithmException; -use Infocyph\Epicrypt\Internal\Base64Url; use Infocyph\Epicrypt\Internal\EcdsaSignatureConverter; -use Infocyph\Epicrypt\Internal\KeyCandidates; -use Infocyph\Epicrypt\Security\KeyRing; -use Infocyph\Epicrypt\Security\KeyVerificationResult; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; -use Infocyph\Epicrypt\Token\Contract\JwtTokenInterface; use Infocyph\Epicrypt\Token\Jwt\Enum\AsymmetricJwtAlgorithm; -use Infocyph\Epicrypt\Token\Jwt\Support\JwtToken; -use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidator; +use Infocyph\Epicrypt\Token\Jwt\Support\AbstractJwt; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; -use Throwable; -final readonly class AsymmetricJwt implements JwtTokenInterface +final readonly class AsymmetricJwt extends AbstractJwt { - /** - * @var array - */ - private const array RESERVED_CLAIMS = ['iss', 'aud', 'sub', 'jti', 'iat', 'nbf', 'exp', 'kid']; - public function __construct( private ?string $passphrase = null, private AsymmetricJwtAlgorithm $algorithm = AsymmetricJwtAlgorithm::RS512, - private ?RegisteredClaims $expectedClaims = null, - ) {} + ?RegisteredClaims $expectedClaims = null, + ) { + parent::__construct('asymmetric', $expectedClaims); + } public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, ?RegisteredClaims $expectedClaims = null, ?string $passphrase = null): self { return new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims); } - public function decode(string $token, mixed $key): object + protected function algorithmHeaderValue(mixed $algorithm): string { - $key = $this->requireSupportedKeyType($key); - - if ($this->expectedClaims === null) { - throw new TokenException('Expected claims are required for JWT decoding.'); + if (!$algorithm instanceof AsymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - try { - [$encodedHeader, $encodedPayload, $signature, $header, $payload] = JwtToken::parse($token); - - if (!isset($header['alg']) || !is_string($header['alg'])) { - throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); - } - - $algorithm = AsymmetricJwtAlgorithm::fromHeader($header['alg']); - if ($algorithm !== $this->algorithm) { - throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); - } - - $publicKey = KeyResolver::resolve($key, $header['kid'] ?? null); - $resource = openssl_pkey_get_public($publicKey); - - if ($resource === false) { - throw new InvalidTokenException('Unable to load public key.'); - } - - $ecdsaLength = $algorithm->ecdsaSignatureLength(); - if ($ecdsaLength !== null) { - $signature = new EcdsaSignatureConverter()->toAsn1($signature, $ecdsaLength); - } - - $result = openssl_verify( - $encodedHeader . '.' . $encodedPayload, - $signature, - $resource, - $algorithm->opensslAlgorithm(), - ); - - if ($result !== 1) { - throw new InvalidTokenException('Signature verification failed.'); - } - - new JwtValidator($this->expectedClaims)->validate($payload); - - return (object) $payload; - } catch (UnsupportedAlgorithmException|KeyResolutionException|InvalidTokenException $e) { - throw $e; - } catch (Throwable $e) { - throw new InvalidTokenException($e->getMessage(), 0, $e); - } + return $algorithm->value; } - /** - * @param iterable|KeyRing $keys - */ - public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object + protected function configuredAlgorithm(): AsymmetricJwtAlgorithm { - $lastException = null; - foreach ($this->orderedKeys($keys) as $key) { - try { - return $this->decode($token, $key); - } catch (Throwable $e) { - $lastException = $e; - } - } - - throw new InvalidTokenException('JWT verification failed for every supplied asymmetric key.', 0, $lastException); + return $this->algorithm; } - /** - * @param array $claims - * @param array $headers - */ - public function encode(array $claims, mixed $key, array $headers = []): string + protected function parseAlgorithmFromHeader(string $algorithm): AsymmetricJwtAlgorithm { - $key = $this->requireSupportedKeyType($key); - - $registeredClaims = RegisteredClaims::fromArray($claims); - [$notBefore, $expiresAt] = $this->extractTemporalClaims($claims); - $keyId = $claims['kid'] ?? null; - - try { - $algorithm = $this->algorithm; - - $privateKey = KeyResolver::resolve($key, $keyId); - $payload = [ - 'iss' => $registeredClaims->issuer, - 'aud' => $registeredClaims->audience, - 'sub' => $registeredClaims->subject, - 'iat' => time(), - 'nbf' => $notBefore, - 'exp' => $expiresAt, - ]; - - if ($registeredClaims->jwtId !== null) { - $payload['jti'] = $registeredClaims->jwtId; - } - - $header = [ - 'alg' => $algorithm->value, - 'typ' => 'JWT', - ]; - - if ($keyId !== null) { - if (!is_string($keyId) || $keyId === '') { - throw new InvalidClaimException('Claim "kid" must be a non-empty string when provided.'); - } - - $header['kid'] = $keyId; - } - - [$encodedHeader, $encodedPayload] = JwtToken::encodeSegments( - $header + $headers, - $payload + $this->removeReservedClaims($claims), - ); - - $signature = $this->sign($encodedHeader . '.' . $encodedPayload, $privateKey, $algorithm); - - return $encodedHeader . '.' . $encodedPayload . '.' . Base64Url::encode($signature); - } catch (UnsupportedAlgorithmException|InvalidClaimException|KeyResolutionException $e) { - throw $e; - } catch (Throwable $e) { - throw new TokenException("JWT encoding failed: {$e->getMessage()}", 0, $e); - } - } - - public function verify(string $token, mixed $key): bool - { - try { - $this->decode($token, $key); - - return true; - } catch (Throwable) { - return false; - } + return AsymmetricJwtAlgorithm::fromHeader($algorithm); } - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool + protected function sign(string $input, string $resolvedKey): string { - return $this->verifyWithAnyKeyResult($token, $keys)->verified; - } - - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult - { - foreach ($this->orderedKeyEntries($keys) as $entry) { - if ($this->verify($token, $entry['key'])) { - return new KeyVerificationResult(true, $entry['id'], !$entry['active']); - } - } - - return new KeyVerificationResult(false); - } - - /** - * @param array $claims - * @return array{int, int} - */ - private function extractTemporalClaims(array $claims): array - { - if (!isset($claims['nbf'], $claims['exp'])) { - throw new InvalidClaimException('Required claims "nbf" and "exp" are missing.'); - } - - if (!is_numeric($claims['nbf']) || !is_numeric($claims['exp'])) { - throw new InvalidClaimException('Claims "nbf" and "exp" must be numeric timestamps.'); + $resource = openssl_pkey_get_private($resolvedKey, $this->passphrase ?? ''); + if ($resource === false) { + throw new TokenException('Unable to load private key for JWT signing.'); } - if ((int) $claims['exp'] <= (int) $claims['nbf']) { - throw new InvalidClaimException('Claim "exp" must be greater than "nbf".'); + $result = openssl_sign($input, $signature, $resource, $this->algorithm->opensslAlgorithm()); + if (!$result || !is_string($signature)) { + throw new TokenException('JWT signing failed.'); } - return [(int) $claims['nbf'], (int) $claims['exp']]; - } - - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeyEntries(iterable|KeyRing $keys): array - { - try { - return KeyCandidates::orderedEntries( - $keys, - 'All asymmetric JWT key candidates must be non-empty strings.', - 'At least one asymmetric JWT key candidate is required.', - ); - } catch (\InvalidArgumentException $e) { - throw new TokenException($e->getMessage(), 0, $e); + $ecdsaLength = $this->algorithm->ecdsaSignatureLength(); + if ($ecdsaLength !== null) { + $signature = new EcdsaSignatureConverter()->fromAsn1($signature, $ecdsaLength); } - } - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeys(iterable|KeyRing $keys): array - { - return array_column($this->orderedKeyEntries($keys), 'key'); - } - - /** - * @param array $claims - * @return array - */ - private function removeReservedClaims(array $claims): array - { - return array_diff_key($claims, array_flip(self::RESERVED_CLAIMS)); + return $signature; } - /** - * @return string|array|ArrayAccess - */ - private function requireSupportedKeyType(mixed $key): string|array|ArrayAccess + protected function verifySignature(string $input, string $signature, string $resolvedKey, mixed $algorithm): bool { - if (is_string($key)) { - return $key; - } - - if ($key instanceof ArrayAccess) { - return $key; + if (!$algorithm instanceof AsymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - if (is_array($key)) { - $normalized = []; - - foreach ($key as $keyId => $value) { - if (!is_string($keyId)) { - throw new TokenException('Key-set array must use string key identifiers.'); - } - - $normalized[$keyId] = $value; - } - - return $normalized; - } - - throw new TokenException('Key must be a string or key-set.'); - } - - private function sign(string $input, string $privateKey, AsymmetricJwtAlgorithm $algorithm): string - { - $resource = openssl_pkey_get_private($privateKey, $this->passphrase ?? ''); + $resource = openssl_pkey_get_public($resolvedKey); if ($resource === false) { - throw new TokenException('Unable to load private key for JWT signing.'); - } - - $result = openssl_sign($input, $signature, $resource, $algorithm->opensslAlgorithm()); - if (!$result || !is_string($signature)) { - throw new TokenException('JWT signing failed.'); + throw new InvalidTokenException('Unable to load public key.'); } $ecdsaLength = $algorithm->ecdsaSignatureLength(); if ($ecdsaLength !== null) { - $signature = new EcdsaSignatureConverter()->fromAsn1($signature, $ecdsaLength); + $signature = new EcdsaSignatureConverter()->toAsn1($signature, $ecdsaLength); } - return $signature; + return openssl_verify( + $input, + $signature, + $resource, + $algorithm->opensslAlgorithm(), + ) === 1; } } diff --git a/src/Token/Jwt/Support/AbstractJwt.php b/src/Token/Jwt/Support/AbstractJwt.php new file mode 100644 index 0000000..ca1412c --- /dev/null +++ b/src/Token/Jwt/Support/AbstractJwt.php @@ -0,0 +1,215 @@ +requireSupportedKeyType($key); + + if ($this->expectedClaims === null) { + throw new TokenException('Expected claims are required for JWT decoding.'); + } + + try { + [$encodedHeader, $encodedPayload, $signature, $header, $payload] = JwtToken::parse($token); + + $algorithm = $this->algorithmFromHeader($header); + $resolvedKey = KeyResolver::resolve($key, $header['kid'] ?? null); + + if (!$this->verifySignature($encodedHeader . '.' . $encodedPayload, $signature, $resolvedKey, $algorithm)) { + throw new InvalidTokenException('Signature verification failed.'); + } + + new JwtValidator($this->expectedClaims)->validate($payload); + + return (object) $payload; + } catch (UnsupportedAlgorithmException|KeyResolutionException|InvalidTokenException $e) { + throw $e; + } catch (Throwable $e) { + throw new InvalidTokenException($e->getMessage(), 0, $e); + } + } + + /** + * @param iterable|KeyRing $keys + */ + final public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object + { + return TokenAnyKey::decode( + $this->orderedKeys( + $keys, + sprintf('All %s JWT key candidates must be non-empty strings.', $this->keyFamily), + sprintf('At least one %s JWT key candidate is required.', $this->keyFamily), + ), + fn(string $candidateKey): object => $this->decode($token, $candidateKey), + fn(?Throwable $previous): Throwable => new InvalidTokenException( + sprintf('JWT verification failed for every supplied %s key.', $this->keyFamily), + 0, + $previous, + ), + ); + } + + /** + * @param array $claims + * @param array $headers + */ + final public function encode(array $claims, mixed $key, array $headers = []): string + { + $key = $this->requireSupportedKeyType($key); + + $registeredClaims = RegisteredClaims::fromArray($claims); + [$notBefore, $expiresAt] = $this->extractTemporalClaims($claims); + $keyId = $claims['kid'] ?? null; + + try { + $resolvedKey = KeyResolver::resolve($key, $keyId); + + [$encodedHeader, $encodedPayload] = JwtToken::encodeSegments( + $this->buildHeader($keyId, $headers), + $this->buildPayload($registeredClaims, $notBefore, $expiresAt, $claims), + ); + + $signature = $this->sign($encodedHeader . '.' . $encodedPayload, $resolvedKey); + + return $encodedHeader . '.' . $encodedPayload . '.' . Base64Url::encode($signature); + } catch (UnsupportedAlgorithmException|InvalidClaimException|KeyResolutionException $e) { + throw $e; + } catch (Throwable $e) { + throw new TokenException("JWT encoding failed: {$e->getMessage()}", 0, $e); + } + } + + final public function verify(string $token, mixed $key): bool + { + try { + $this->decode($token, $key); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * @param iterable|KeyRing $keys + */ + final public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool + { + return $this->verifyWithAnyKeyResult($token, $keys)->verified; + } + + /** + * @param iterable|KeyRing $keys + */ + final public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + { + return TokenAnyKey::verifyResult( + $this->orderedKeyEntries( + $keys, + sprintf('All %s JWT key candidates must be non-empty strings.', $this->keyFamily), + sprintf('At least one %s JWT key candidate is required.', $this->keyFamily), + ), + fn(string $candidateKey): bool => $this->verify($token, $candidateKey), + ); + } + + /** + * @param array $header + */ + private function algorithmFromHeader(array $header): mixed + { + if (!isset($header['alg']) || !is_string($header['alg'])) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); + } + + $parsed = $this->parseAlgorithmFromHeader($header['alg']); + if ($parsed !== $this->configuredAlgorithm()) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); + } + + return $parsed; + } + + /** + * @param array $headers + * @return array + */ + private function buildHeader(mixed $keyId, array $headers): array + { + $configuredAlgorithm = $this->configuredAlgorithm(); + + $header = [ + 'alg' => $this->algorithmHeaderValue($configuredAlgorithm), + 'typ' => 'JWT', + ]; + + if ($keyId !== null) { + if (!is_string($keyId) || $keyId === '') { + throw new InvalidClaimException('Claim "kid" must be a non-empty string when provided.'); + } + + $header['kid'] = $keyId; + } + + return $header + $headers; + } + + /** + * @param array $claims + * @return array + */ + private function buildPayload(RegisteredClaims $registeredClaims, int $notBefore, int $expiresAt, array $claims): array + { + $payload = [ + 'iss' => $registeredClaims->issuer, + 'aud' => $registeredClaims->audience, + 'sub' => $registeredClaims->subject, + 'iat' => time(), + 'nbf' => $notBefore, + 'exp' => $expiresAt, + ]; + + if ($registeredClaims->jwtId !== null) { + $payload['jti'] = $registeredClaims->jwtId; + } + + return $payload + $this->removeReservedClaims($claims); + } +} diff --git a/src/Token/Jwt/Support/JwtCommon.php b/src/Token/Jwt/Support/JwtCommon.php new file mode 100644 index 0000000..9b71e59 --- /dev/null +++ b/src/Token/Jwt/Support/JwtCommon.php @@ -0,0 +1,97 @@ + + */ + private const array RESERVED_CLAIMS = ['iss', 'aud', 'sub', 'jti', 'iat', 'nbf', 'exp', 'kid']; + + /** + * @param array $claims + * @return array{int, int} + */ + private function extractTemporalClaims(array $claims): array + { + if (!isset($claims['nbf'], $claims['exp'])) { + throw new InvalidClaimException('Required claims "nbf" and "exp" are missing.'); + } + + if (!is_numeric($claims['nbf']) || !is_numeric($claims['exp'])) { + throw new InvalidClaimException('Claims "nbf" and "exp" must be numeric timestamps.'); + } + + if ((int) $claims['exp'] <= (int) $claims['nbf']) { + throw new InvalidClaimException('Claim "exp" must be greater than "nbf".'); + } + + return [(int) $claims['nbf'], (int) $claims['exp']]; + } + + /** + * @param iterable|KeyRing $keys + * @return list + */ + private function orderedKeyEntries(iterable|KeyRing $keys, string $emptyCandidateMessage, string $missingCandidateMessage): array + { + return TokenKeyCandidates::orderedEntries($keys, $emptyCandidateMessage, $missingCandidateMessage); + } + + /** + * @param iterable|KeyRing $keys + * @return list + */ + private function orderedKeys(iterable|KeyRing $keys, string $emptyCandidateMessage, string $missingCandidateMessage): array + { + return TokenKeyCandidates::orderedKeys($keys, $emptyCandidateMessage, $missingCandidateMessage); + } + + /** + * @param array $claims + * @return array + */ + private function removeReservedClaims(array $claims): array + { + return array_diff_key($claims, array_flip(self::RESERVED_CLAIMS)); + } + + /** + * @return string|array|ArrayAccess + */ + private function requireSupportedKeyType(mixed $key): string|array|ArrayAccess + { + if (is_string($key)) { + return $key; + } + + if ($key instanceof ArrayAccess) { + return $key; + } + + if (is_array($key)) { + $normalized = []; + + foreach ($key as $keyId => $value) { + if (!is_string($keyId)) { + throw new TokenException('Key-set array must use string key identifiers.'); + } + + $normalized[$keyId] = $value; + } + + return $normalized; + } + + throw new TokenException('Key must be a string or key-set.'); + } +} diff --git a/src/Token/Jwt/SymmetricJwt.php b/src/Token/Jwt/SymmetricJwt.php index 64290f3..884c46d 100644 --- a/src/Token/Jwt/SymmetricJwt.php +++ b/src/Token/Jwt/SymmetricJwt.php @@ -4,279 +4,68 @@ namespace Infocyph\Epicrypt\Token\Jwt; -use ArrayAccess; -use Infocyph\Epicrypt\Exception\Token\InvalidClaimException; -use Infocyph\Epicrypt\Exception\Token\InvalidTokenException; -use Infocyph\Epicrypt\Exception\Token\KeyResolutionException; -use Infocyph\Epicrypt\Exception\Token\TokenException; use Infocyph\Epicrypt\Exception\Token\UnsupportedAlgorithmException; -use Infocyph\Epicrypt\Internal\Base64Url; -use Infocyph\Epicrypt\Internal\KeyCandidates; -use Infocyph\Epicrypt\Security\KeyRing; -use Infocyph\Epicrypt\Security\KeyVerificationResult; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; -use Infocyph\Epicrypt\Token\Contract\JwtTokenInterface; use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm; -use Infocyph\Epicrypt\Token\Jwt\Support\JwtToken; -use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidator; +use Infocyph\Epicrypt\Token\Jwt\Support\AbstractJwt; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; -use Throwable; -final readonly class SymmetricJwt implements JwtTokenInterface +final readonly class SymmetricJwt extends AbstractJwt { - /** - * @var array - */ - private const array RESERVED_CLAIMS = ['iss', 'aud', 'sub', 'jti', 'iat', 'nbf', 'exp', 'kid']; - public function __construct( private SymmetricJwtAlgorithm $algorithm = SymmetricJwtAlgorithm::HS512, - private ?RegisteredClaims $expectedClaims = null, - ) {} + ?RegisteredClaims $expectedClaims = null, + ) { + parent::__construct('symmetric', $expectedClaims); + } public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, ?RegisteredClaims $expectedClaims = null): self { return new self($profile->defaultSymmetricJwtAlgorithm(), $expectedClaims); } - public function decode(string $token, mixed $key): object - { - $key = $this->requireSupportedKeyType($key); - - if ($this->expectedClaims === null) { - throw new TokenException('Expected claims are required for JWT decoding.'); - } - - try { - [$encodedHeader, $encodedPayload, $signature, $header, $payload] = JwtToken::parse($token); - - if (!isset($header['alg']) || !is_string($header['alg'])) { - throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); - } - - $algorithm = SymmetricJwtAlgorithm::fromHeader($header['alg']); - if ($algorithm !== $this->algorithm) { - throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); - } - - $secret = KeyResolver::resolve($key, $header['kid'] ?? null); - $expected = hash_hmac( - $algorithm->hmacAlgorithm(), - $encodedHeader . '.' . $encodedPayload, - $secret, - true, - ); - - if (!hash_equals($expected, $signature)) { - throw new InvalidTokenException('Signature verification failed.'); - } - - new JwtValidator($this->expectedClaims)->validate($payload); - - return (object) $payload; - } catch (UnsupportedAlgorithmException|KeyResolutionException|InvalidTokenException $e) { - throw $e; - } catch (Throwable $e) { - throw new InvalidTokenException($e->getMessage(), 0, $e); - } - } - - /** - * @param iterable|KeyRing $keys - */ - public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object - { - $lastException = null; - foreach ($this->orderedKeys($keys) as $key) { - try { - return $this->decode($token, $key); - } catch (Throwable $e) { - $lastException = $e; - } - } - - throw new InvalidTokenException('JWT verification failed for every supplied symmetric key.', 0, $lastException); - } - - /** - * @param array $claims - * @param array $headers - */ - public function encode(array $claims, mixed $key, array $headers = []): string - { - $key = $this->requireSupportedKeyType($key); - - $registeredClaims = RegisteredClaims::fromArray($claims); - [$notBefore, $expiresAt] = $this->extractTemporalClaims($claims); - $keyId = $claims['kid'] ?? null; - - try { - $secret = KeyResolver::resolve($key, $keyId); - $algorithm = $this->algorithm; - $hmacAlgorithm = $algorithm->hmacAlgorithm(); - - $payload = [ - 'iss' => $registeredClaims->issuer, - 'aud' => $registeredClaims->audience, - 'sub' => $registeredClaims->subject, - 'iat' => time(), - 'nbf' => $notBefore, - 'exp' => $expiresAt, - ]; - - if ($registeredClaims->jwtId !== null) { - $payload['jti'] = $registeredClaims->jwtId; - } - - $header = [ - 'alg' => $algorithm->value, - 'typ' => 'JWT', - ]; - - if ($keyId !== null) { - if (!is_string($keyId) || $keyId === '') { - throw new InvalidClaimException('Claim "kid" must be a non-empty string when provided.'); - } - - $header['kid'] = $keyId; - } - - [$encodedHeader, $encodedPayload] = JwtToken::encodeSegments( - $header + $headers, - $payload + $this->removeReservedClaims($claims), - ); - - $signature = hash_hmac( - $hmacAlgorithm, - $encodedHeader . '.' . $encodedPayload, - $secret, - true, - ); - - return $encodedHeader . '.' . $encodedPayload . '.' . Base64Url::encode($signature); - } catch (UnsupportedAlgorithmException|InvalidClaimException|KeyResolutionException $e) { - throw $e; - } catch (Throwable $e) { - throw new TokenException("JWT encoding failed: {$e->getMessage()}", 0, $e); - } - } - - public function verify(string $token, mixed $key): bool + protected function algorithmHeaderValue(mixed $algorithm): string { - try { - $this->decode($token, $key); - - return true; - } catch (Throwable) { - return false; + if (!$algorithm instanceof SymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - } - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool - { - return $this->verifyWithAnyKeyResult($token, $keys)->verified; + return $algorithm->value; } - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + protected function configuredAlgorithm(): SymmetricJwtAlgorithm { - foreach ($this->orderedKeyEntries($keys) as $entry) { - if ($this->verify($token, $entry['key'])) { - return new KeyVerificationResult(true, $entry['id'], !$entry['active']); - } - } - - return new KeyVerificationResult(false); + return $this->algorithm; } - /** - * @param array $claims - * @return array{int, int} - */ - private function extractTemporalClaims(array $claims): array + protected function parseAlgorithmFromHeader(string $algorithm): SymmetricJwtAlgorithm { - if (!isset($claims['nbf'], $claims['exp'])) { - throw new InvalidClaimException('Required claims "nbf" and "exp" are missing.'); - } - - if (!is_numeric($claims['nbf']) || !is_numeric($claims['exp'])) { - throw new InvalidClaimException('Claims "nbf" and "exp" must be numeric timestamps.'); - } - - if ((int) $claims['exp'] <= (int) $claims['nbf']) { - throw new InvalidClaimException('Claim "exp" must be greater than "nbf".'); - } - - return [(int) $claims['nbf'], (int) $claims['exp']]; - } - - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeyEntries(iterable|KeyRing $keys): array - { - try { - return KeyCandidates::orderedEntries( - $keys, - 'All symmetric JWT key candidates must be non-empty strings.', - 'At least one symmetric JWT key candidate is required.', - ); - } catch (\InvalidArgumentException $e) { - throw new TokenException($e->getMessage(), 0, $e); - } - } - - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeys(iterable|KeyRing $keys): array - { - return array_column($this->orderedKeyEntries($keys), 'key'); + return SymmetricJwtAlgorithm::fromHeader($algorithm); } - /** - * @param array $claims - * @return array - */ - private function removeReservedClaims(array $claims): array + protected function sign(string $input, string $resolvedKey): string { - return array_diff_key($claims, array_flip(self::RESERVED_CLAIMS)); + return hash_hmac( + $this->algorithm->hmacAlgorithm(), + $input, + $resolvedKey, + true, + ); } - /** - * @return string|array|ArrayAccess - */ - private function requireSupportedKeyType(mixed $key): string|array|ArrayAccess + protected function verifySignature(string $input, string $signature, string $resolvedKey, mixed $algorithm): bool { - if (is_string($key)) { - return $key; - } - - if ($key instanceof ArrayAccess) { - return $key; + if (!$algorithm instanceof SymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - if (is_array($key)) { - $normalized = []; - - foreach ($key as $keyId => $value) { - if (!is_string($keyId)) { - throw new TokenException('Key-set array must use string key identifiers.'); - } - - $normalized[$keyId] = $value; - } - - return $normalized; - } + $expected = hash_hmac( + $algorithm->hmacAlgorithm(), + $input, + $resolvedKey, + true, + ); - throw new TokenException('Key must be a string or key-set.'); + return hash_equals($expected, $signature); } } diff --git a/src/Token/Payload/SignedPayload.php b/src/Token/Payload/SignedPayload.php index 72b2d81..7538951 100644 --- a/src/Token/Payload/SignedPayload.php +++ b/src/Token/Payload/SignedPayload.php @@ -5,11 +5,12 @@ namespace Infocyph\Epicrypt\Token\Payload; use Infocyph\Epicrypt\Exception\Token\TokenException; -use Infocyph\Epicrypt\Internal\KeyCandidates; use Infocyph\Epicrypt\Internal\SignedPayloadCodec; use Infocyph\Epicrypt\Security\KeyRing; use Infocyph\Epicrypt\Security\KeyVerificationResult; use Infocyph\Epicrypt\Token\Contract\PayloadTokenInterface; +use Infocyph\Epicrypt\Token\Support\TokenAnyKey; +use Infocyph\Epicrypt\Token\Support\TokenKeyCandidates; final readonly class SignedPayload implements PayloadTokenInterface { @@ -35,16 +36,15 @@ public function decode(string $token, mixed $key): array */ public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): array { - $lastException = null; - foreach ($this->orderedKeys($keys) as $key) { - try { - return $this->decode($token, $key); - } catch (TokenException $e) { - $lastException = $e; - } - } - - throw new TokenException('Signed payload verification failed for every supplied key.', 0, $lastException); + return TokenAnyKey::decode( + $this->orderedKeys($keys), + fn(string $candidateKey): array => $this->decode($token, $candidateKey), + fn(?\Throwable $previous): \Throwable => new TokenException( + 'Signed payload verification failed for every supplied key.', + 0, + $previous, + ), + ); } /** @@ -88,13 +88,10 @@ public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool */ public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult { - foreach ($this->orderedKeyEntries($keys) as $entry) { - if ($this->verify($token, $entry['key'])) { - return new KeyVerificationResult(true, $entry['id'], !$entry['active']); - } - } - - return new KeyVerificationResult(false); + return TokenAnyKey::verifyResult( + $this->orderedKeyEntries($keys), + fn(string $candidateKey): bool => $this->verify($token, $candidateKey), + ); } /** @@ -103,15 +100,11 @@ public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): K */ private function orderedKeyEntries(iterable|KeyRing $keys): array { - try { - return KeyCandidates::orderedEntries( - $keys, - 'All signed payload key candidates must be non-empty strings.', - 'At least one signed payload key candidate is required.', - ); - } catch (\InvalidArgumentException $e) { - throw new TokenException($e->getMessage(), 0, $e); - } + return TokenKeyCandidates::orderedEntries( + $keys, + 'All signed payload key candidates must be non-empty strings.', + 'At least one signed payload key candidate is required.', + ); } /** @@ -120,6 +113,10 @@ private function orderedKeyEntries(iterable|KeyRing $keys): array */ private function orderedKeys(iterable|KeyRing $keys): array { - return array_column($this->orderedKeyEntries($keys), 'key'); + return TokenKeyCandidates::orderedKeys( + $keys, + 'All signed payload key candidates must be non-empty strings.', + 'At least one signed payload key candidate is required.', + ); } } diff --git a/src/Token/Support/TokenAnyKey.php b/src/Token/Support/TokenAnyKey.php new file mode 100644 index 0000000..edf68ff --- /dev/null +++ b/src/Token/Support/TokenAnyKey.php @@ -0,0 +1,50 @@ + $keys + * @param callable(string): TDecoded $decode + * @param callable(?Throwable): Throwable $failure + * @return TDecoded + * + * @throws Throwable + */ + public static function decode(array $keys, callable $decode, callable $failure): mixed + { + $lastException = null; + foreach ($keys as $key) { + try { + return $decode($key); + } catch (Throwable $e) { + $lastException = $e; + } + } + + throw $failure($lastException); + } + + /** + * @param list $entries + * @param callable(string): bool $verify + */ + public static function verifyResult(array $entries, callable $verify): KeyVerificationResult + { + foreach ($entries as $entry) { + if ($verify($entry['key'])) { + return new KeyVerificationResult(true, $entry['id'], !$entry['active']); + } + } + + return new KeyVerificationResult(false); + } +} diff --git a/src/Token/Support/TokenKeyCandidates.php b/src/Token/Support/TokenKeyCandidates.php new file mode 100644 index 0000000..4ad29bf --- /dev/null +++ b/src/Token/Support/TokenKeyCandidates.php @@ -0,0 +1,34 @@ +|KeyRing $keys + * @return list + */ + public static function orderedEntries(iterable|KeyRing $keys, string $emptyCandidateMessage, string $missingCandidateMessage): array + { + try { + return KeyCandidates::orderedEntries($keys, $emptyCandidateMessage, $missingCandidateMessage); + } catch (\InvalidArgumentException $e) { + throw new TokenException($e->getMessage(), 0, $e); + } + } + + /** + * @param iterable|KeyRing $keys + * @return list + */ + public static function orderedKeys(iterable|KeyRing $keys, string $emptyCandidateMessage, string $missingCandidateMessage): array + { + return array_column(self::orderedEntries($keys, $emptyCandidateMessage, $missingCandidateMessage), 'key'); + } +} diff --git a/tests/Certificate/DomainTest.php b/tests/Certificate/DomainTest.php index 5f88fb1..5b5c712 100644 --- a/tests/Certificate/DomainTest.php +++ b/tests/Certificate/DomainTest.php @@ -36,7 +36,7 @@ it('supports rsa interoperability in Certificate domain', function () { $keyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); - $cipher = new RsaCipher(); + $cipher = new RsaCipher; $encrypted = $cipher->encrypt('certificate-rsa-check', $keyPair['public']); $decrypted = $cipher->decrypt($encrypted, $keyPair['private']); diff --git a/tests/Crypto/CoreServicesTest.php b/tests/Crypto/CoreServicesTest.php index 71e9f72..ea1cba8 100644 --- a/tests/Crypto/CoreServicesTest.php +++ b/tests/Crypto/CoreServicesTest.php @@ -7,9 +7,9 @@ use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator; it('encrypts and decrypts with AEAD services', function () { - $key = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); - $cipher = new AeadCipher(); + $cipher = new AeadCipher; $ciphertext = $cipher->encrypt('epicrypt-aead', $key, ['aad' => 'meta']); $plaintext = $cipher->decrypt($ciphertext, $key, ['aad' => 'meta']); @@ -21,7 +21,7 @@ it('signs and verifies detached signatures', function () { $keys = KeyPairGenerator::sodiumSign()->generate(asBase64Url: true); - $signatureService = new Signature(); + $signatureService = new Signature; $signature = $signatureService->sign('epicrypt-signature', $keys['private']); expect($signatureService->verify('epicrypt-signature', $signature, $keys['public']))->toBeTrue(); @@ -29,7 +29,7 @@ }); it('generates and verifies mac tags', function () { - $macService = new Mac(); + $macService = new Mac; $key = $macService->generateKey(); $mac = $macService->generate('epicrypt-mac', $key); diff --git a/tests/DataProtection/ServicesTest.php b/tests/DataProtection/ServicesTest.php index d40d288..883bc23 100644 --- a/tests/DataProtection/ServicesTest.php +++ b/tests/DataProtection/ServicesTest.php @@ -8,9 +8,9 @@ use Infocyph\Epicrypt\Security\Policy\SecurityProfile; it('encrypts and decrypts string data safely', function () { - $key = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); - $protector = new StringProtector(); + $protector = new StringProtector; $ciphertext = $protector->encrypt('protected data', $key); $plaintext = $protector->decrypt($ciphertext, $key); @@ -19,7 +19,7 @@ }); it('supports key-ring decrypt and re-encryption for protected strings', function () { - $generator = new KeyMaterialGenerator(); + $generator = new KeyMaterialGenerator; $previousKey = $generator->forSecretBox(); $currentKey = $generator->forSecretBox(); @@ -42,9 +42,9 @@ }); it('encrypts and decrypts versioned envelopes', function () { - $masterKey = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $masterKey = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); - $protector = new EnvelopeProtector(); + $protector = new EnvelopeProtector; $envelope = $protector->encrypt('enveloped data', $masterKey); $encoded = $protector->encodeEnvelope($envelope); $plaintext = $protector->decrypt($encoded, $masterKey); @@ -55,7 +55,7 @@ }); it('supports envelope re-encryption across key rotation', function () { - $generator = new KeyMaterialGenerator(); + $generator = new KeyMaterialGenerator; $previousMaster = $generator->forSecretBox(); $currentMaster = $generator->forSecretBox(); @@ -72,17 +72,17 @@ }); it('supports file key rotation and re-encryption', function () { - $generator = new KeyMaterialGenerator(); + $generator = new KeyMaterialGenerator; $previousKey = $generator->forSecretStream(); $currentKey = $generator->forSecretStream(); - $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'epicrypt-' . bin2hex(random_bytes(6)); + $tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-'.bin2hex(random_bytes(6)); mkdir($tempDir); - $plain = $tempDir . DIRECTORY_SEPARATOR . 'plain.txt'; - $previousEncrypted = $tempDir . DIRECTORY_SEPARATOR . 'plain.txt.epc'; - $rotatedEncrypted = $tempDir . DIRECTORY_SEPARATOR . 'plain.txt.rotated.epc'; - $decrypted = $tempDir . DIRECTORY_SEPARATOR . 'plain.dec.txt'; + $plain = $tempDir.DIRECTORY_SEPARATOR.'plain.txt'; + $previousEncrypted = $tempDir.DIRECTORY_SEPARATOR.'plain.txt.epc'; + $rotatedEncrypted = $tempDir.DIRECTORY_SEPARATOR.'plain.txt.rotated.epc'; + $decrypted = $tempDir.DIRECTORY_SEPARATOR.'plain.dec.txt'; file_put_contents($plain, 'file rotation payload'); @@ -111,6 +111,7 @@ foreach ($iterator as $entry) { if ($entry->isDir()) { rmdir($entry->getPathname()); + continue; } diff --git a/tests/Generate/ServicesTest.php b/tests/Generate/ServicesTest.php index 92c23a3..8bc9ba7 100644 --- a/tests/Generate/ServicesTest.php +++ b/tests/Generate/ServicesTest.php @@ -1,8 +1,8 @@ bytes(32)))->toBe(32); expect($random->string(40))->toHaveLength(40); @@ -27,9 +27,9 @@ }); it('derives keys using hkdf, password derivation, and deterministic subkeys', function () { - $deriver = new KeyDeriver(); - $generator = new KeyMaterialGenerator(); - $salt = (new SaltGenerator())->generate(SODIUM_CRYPTO_PWHASH_SALTBYTES); + $deriver = new KeyDeriver; + $generator = new KeyMaterialGenerator; + $salt = (new SaltGenerator)->generate(SODIUM_CRYPTO_PWHASH_SALTBYTES); $hkdf = $deriver->hkdf($generator->generate(32), 32, [ 'info' => 'epicrypt:test', diff --git a/tests/Integrity/ServicesTest.php b/tests/Integrity/ServicesTest.php index 3dde228..87e5071 100644 --- a/tests/Integrity/ServicesTest.php +++ b/tests/Integrity/ServicesTest.php @@ -23,7 +23,7 @@ }); it('creates stable content fingerprints', function () { - $fingerprinter = new ContentFingerprinter(); + $fingerprinter = new ContentFingerprinter; $fingerprintA = $fingerprinter->fingerprint('payload', ['b' => '2', 'a' => '1']); $fingerprintB = $fingerprinter->fingerprint('payload', ['a' => '1', 'b' => '2']); diff --git a/tests/Password/ServicesTest.php b/tests/Password/ServicesTest.php index ee80718..a122258 100644 --- a/tests/Password/ServicesTest.php +++ b/tests/Password/ServicesTest.php @@ -1,18 +1,18 @@ generate(16); - $hasher = new PasswordHasher(); + $hasher = new PasswordHasher; $hash = $hasher->hashPassword($password); expect($hasher->verifyPassword($password, $hash))->toBeTrue(); @@ -21,7 +21,7 @@ it('detects password rehash lifecycle state and can produce a replacement hash', function () { $password = 'MyStrongPassword!2026'; - $hasher = new PasswordHasher(); + $hasher = new PasswordHasher; $previousHash = $hasher->hashPassword($password, [ 'algorithm' => PasswordHashAlgorithm::BCRYPT, @@ -48,24 +48,24 @@ }); it('wraps and unwraps secrets with master secret', function () { - $master = (new MasterSecretGenerator())->generate(); + $master = (new MasterSecretGenerator)->generate(); - $manager = new WrappedSecretManager(); + $manager = new WrappedSecretManager; $wrapped = $manager->wrap('sensitive-secret', $master); expect($wrapped)->toStartWith('eps1.'); expect($manager->unwrap($wrapped, $master))->toBe('sensitive-secret'); $segments = explode('.', $wrapped, 3); - $unversionedWrapped = $segments[1] . '.' . $segments[2]; + $unversionedWrapped = $segments[1].'.'.$segments[2]; expect($manager->unwrap($unversionedWrapped, $master))->toBe('sensitive-secret'); }); it('supports wrapped secret rollover and rewrap flows', function () { - $oldMaster = (new MasterSecretGenerator())->generate(); - $newMaster = (new MasterSecretGenerator())->generate(); + $oldMaster = (new MasterSecretGenerator)->generate(); + $newMaster = (new MasterSecretGenerator)->generate(); - $manager = new WrappedSecretManager(); + $manager = new WrappedSecretManager; $wrapped = $manager->wrap('rotated-secret', $oldMaster); $rewrapped = $manager->rewrap($wrapped, $oldMaster, $newMaster); diff --git a/tests/Security/UtilitiesTest.php b/tests/Security/UtilitiesTest.php index ec571fe..3dd4bfb 100644 --- a/tests/Security/UtilitiesTest.php +++ b/tests/Security/UtilitiesTest.php @@ -3,8 +3,8 @@ use Infocyph\Epicrypt\Security\ActionToken; use Infocyph\Epicrypt\Security\CsrfTokenManager; use Infocyph\Epicrypt\Security\EmailVerificationToken; -use Infocyph\Epicrypt\Security\KeyRotationHelper; use Infocyph\Epicrypt\Security\KeyRing; +use Infocyph\Epicrypt\Security\KeyRotationHelper; use Infocyph\Epicrypt\Security\PasswordResetToken; use Infocyph\Epicrypt\Security\RememberToken; use Infocyph\Epicrypt\Security\SignedUrl; @@ -15,7 +15,7 @@ expect($signed)->toContain('ep_v=1'); expect($signedUrl->verify($signed))->toBeTrue(); - expect($signedUrl->verify($signed . 'tamper'))->toBeFalse(); + expect($signedUrl->verify($signed.'tamper'))->toBeFalse(); }); it('issues and verifies csrf tokens', function () { @@ -36,7 +36,7 @@ $actionTokenValue = $actionToken->issue('user-1', 'delete-account'); expect($actionToken->verify($actionTokenValue, 'user-1', 'delete-account'))->toBeTrue(); $segments = explode('.', $actionTokenValue, 3); - $headerJson = base64_decode(strtr($segments[0], '-_', '+/') . str_repeat('=', (4 - strlen($segments[0]) % 4) % 4), true); + $headerJson = base64_decode(strtr($segments[0], '-_', '+/').str_repeat('=', (4 - strlen($segments[0]) % 4) % 4), true); expect($headerJson)->not->toBeFalse(); $header = json_decode((string) $headerJson, true, 512, JSON_THROW_ON_ERROR); expect($header['v'] ?? null)->toBe(1); @@ -51,7 +51,7 @@ }); it('verifies signatures across rotated key sets', function () { - $rotation = new KeyRotationHelper(); + $rotation = new KeyRotationHelper; $keys = new KeyRing(['k1' => 'previous-key', 'k2' => 'active-key'], 'k2'); $payload = 'important-payload'; diff --git a/tests/Token/Jwt/AsymmetricServiceTest.php b/tests/Token/Jwt/AsymmetricServiceTest.php index 5386d38..069089f 100644 --- a/tests/Token/Jwt/AsymmetricServiceTest.php +++ b/tests/Token/Jwt/AsymmetricServiceTest.php @@ -1,9 +1,9 @@ Date: Thu, 14 May 2026 00:13:42 +0600 Subject: [PATCH 2/4] updated with new features --- LICENSE | 2 +- README.md | 137 ++++++++++ composer.json | 4 + docs/certificates.rst | 13 + docs/file-encryption.rst | 24 ++ docs/index.rst | 9 + docs/installation.rst | 17 ++ docs/jwt.rst | 12 + docs/migration.rst | 15 ++ docs/passwords.rst | 12 + docs/quickstart.rst | 18 ++ docs/security-model.rst | 19 ++ docs/tokens.rst | 12 + src/Certificate/CertificateAuthority.php | 29 +++ src/Certificate/CertificateBuilder.php | 4 +- src/Certificate/CertificateChainVerifier.php | 45 ++++ src/Certificate/CertificateExpiry.php | 25 ++ src/Certificate/CertificateFingerprint.php | 39 +++ src/Certificate/CertificateKeyMatcher.php | 34 +++ src/Certificate/CertificateOptions.php | 26 ++ .../CertificateAuthorityInterface.php | 18 ++ .../Contract/CertificateBuilderInterface.php | 4 +- .../Contract/CsrBuilderInterface.php | 4 +- src/Certificate/CsrBuilder.php | 4 +- .../OpenSSL/CertificateAuthority.php | 52 ++++ .../OpenSSL/CertificateBuilder.php | 56 ++-- src/Certificate/OpenSSL/CsrBuilder.php | 33 ++- src/Certificate/OpenSSL/KeyPairGenerator.php | 50 ++-- .../Support/OpenSslExtensionConfig.php | 79 ++++++ src/Certificate/PemNormalizer.php | 15 ++ src/Certificate/Sodium/SessionKeyExchange.php | 11 +- src/Crypto/AeadCipher.php | 99 +++---- src/Crypto/Context/AeadContext.php | 55 ++++ src/Crypto/Enum/StreamAlgorithm.php | 12 +- src/Crypto/Mac.php | 10 +- src/Crypto/PublicKeyBoxCipher.php | 37 ++- src/Crypto/SealedBoxCipher.php | 29 +-- src/Crypto/SecretBoxCipher.php | 72 ++++- src/Crypto/SecretStream.php | 43 +-- src/Crypto/Signature.php | 41 +-- src/DataProtection/EnvelopeDecryptResult.php | 4 + src/DataProtection/EnvelopeInspectResult.php | 17 ++ src/DataProtection/EnvelopeProtector.php | 245 ++++++++++++++++-- src/DataProtection/FileProtector.php | 102 ++++++-- src/DataProtection/ProtectionAad.php | 34 +++ .../StringProtectInspectResult.php | 14 + src/DataProtection/StringProtector.php | 119 ++++++++- .../Support/ProtectionContext.php | 58 ++++- .../Token/SignatureEncodingException.php | 7 + .../KeyMaterial/KeyDerivationContext.php | 112 ++++++++ src/Generate/KeyMaterial/KeyDeriver.php | 104 +++----- src/Integrity/FileHasher.php | 7 +- src/Integrity/StringHasher.php | 7 +- src/Internal/BinaryKey.php | 90 +++++++ src/Internal/Clock/ClockInterface.php | 13 + src/Internal/Clock/SystemClock.php | 16 ++ src/Internal/CompactPayloadResult.php | 19 ++ src/Internal/EcdsaSignatureConverter.php | 40 +-- src/Internal/HashAlgorithm.php | 28 ++ src/Internal/SignedPayloadCodec.php | 41 ++- src/Internal/VersionedPayload.php | 67 ++++- src/Internal/VersionedPayloadResult.php | 19 ++ .../CompromisedPasswordCheckerInterface.php | 10 + src/Password/Generator/PasswordGenerator.php | 16 +- .../NullCompromisedPasswordChecker.php | 17 ++ src/Password/PasswordHasher.php | 25 +- src/Password/PasswordPolicyResult.php | 17 ++ src/Password/PasswordPolicyValidator.php | 50 ++++ src/Password/PasswordStrength.php | 86 +++++- src/Password/Secret/WrappedSecretManager.php | 104 ++++++-- src/Security/ActionToken.php | 5 +- .../Contract/SignedUrlGeneratorInterface.php | 4 +- .../Contract/SignedUrlVerifierInterface.php | 4 +- src/Security/CsrfTokenManager.php | 7 +- src/Security/EmailVerificationToken.php | 5 +- src/Security/PasswordResetToken.php | 5 +- src/Security/RememberToken.php | 5 +- src/Security/SignedUrl.php | 222 +++++++++++++--- src/Security/SignedUrlOptions.php | 52 ++++ src/Security/SignedUrlVerificationResult.php | 16 ++ src/Security/Support/AbstractPurposeToken.php | 7 +- src/Security/Support/SignedUrlGuard.php | 199 ++++++++++++++ src/Token/Jwt/AsymmetricJwt.php | 19 +- src/Token/Jwt/Jwks.php | 138 ++++++++++ src/Token/Jwt/JwtVerificationResult.php | 23 ++ src/Token/Jwt/Support/AbstractJwt.php | 203 ++++++++++++--- src/Token/Jwt/SymmetricJwt.php | 14 +- .../Jwt/Validation/ExpectedJwtClaims.php | 29 +++ .../Jwt/Validation/ExpirationValidator.php | 18 +- src/Token/Jwt/Validation/JwtIssueClaims.php | 59 +++++ .../Jwt/Validation/JwtValidationOptions.php | 17 ++ src/Token/Jwt/Validation/JwtValidator.php | 94 ++++++- .../Jwt/Validation/RequiredJwtClaims.php | 15 ++ src/Token/Payload/SignedPayload.php | 46 +++- .../SignedPayloadVerificationResult.php | 19 ++ tests/Certificate/DomainTest.php | 90 +++++++ tests/Crypto/AeadContextTest.php | 24 ++ tests/Crypto/CoreServicesTest.php | 40 +++ tests/Crypto/MatrixCoverageTest.php | 105 ++++++++ tests/Crypto/SecretStreamTest.php | 95 +++++++ .../FileProtectionMatrixTest.php | 145 +++++++++++ tests/DataProtection/ServicesTest.php | 244 ++++++++++++++++- tests/Generate/KeyDerivationContextTest.php | 53 ++++ tests/Generate/ServicesTest.php | 9 + tests/Integrity/ServicesTest.php | 15 ++ tests/Internal/BinaryKeyTest.php | 19 ++ .../Internal/EcdsaSignatureConverterTest.php | 16 ++ tests/Internal/ProtectionContextTest.php | 36 +++ tests/Internal/VersionedPayloadTest.php | 37 +++ tests/Password/ServicesTest.php | 100 ++++++- tests/Security/ClockIntegrationTest.php | 64 +++++ tests/Security/SignedUrlHardeningTest.php | 53 ++++ tests/Token/Jwt/AlgorithmMatrixTest.php | 170 ++++++++++++ tests/Token/Jwt/AsymmetricServiceTest.php | 23 +- tests/Token/Jwt/ClaimValidatorTest.php | 5 +- tests/Token/Jwt/HardeningTest.php | 151 +++++++++++ tests/Token/Jwt/JwksTest.php | 44 ++++ tests/Token/OpaqueTokenTest.php | 14 + .../Token/SignedPayloadTemporalClaimsTest.php | 61 +++++ 119 files changed, 5043 insertions(+), 503 deletions(-) create mode 100644 docs/certificates.rst create mode 100644 docs/file-encryption.rst create mode 100644 docs/installation.rst create mode 100644 docs/jwt.rst create mode 100644 docs/migration.rst create mode 100644 docs/passwords.rst create mode 100644 docs/quickstart.rst create mode 100644 docs/security-model.rst create mode 100644 docs/tokens.rst create mode 100644 src/Certificate/CertificateAuthority.php create mode 100644 src/Certificate/CertificateChainVerifier.php create mode 100644 src/Certificate/CertificateExpiry.php create mode 100644 src/Certificate/CertificateFingerprint.php create mode 100644 src/Certificate/CertificateKeyMatcher.php create mode 100644 src/Certificate/CertificateOptions.php create mode 100644 src/Certificate/Contract/CertificateAuthorityInterface.php create mode 100644 src/Certificate/OpenSSL/CertificateAuthority.php create mode 100644 src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php create mode 100644 src/Certificate/PemNormalizer.php create mode 100644 src/Crypto/Context/AeadContext.php create mode 100644 src/DataProtection/EnvelopeInspectResult.php create mode 100644 src/DataProtection/ProtectionAad.php create mode 100644 src/DataProtection/StringProtectInspectResult.php create mode 100644 src/Exception/Token/SignatureEncodingException.php create mode 100644 src/Generate/KeyMaterial/KeyDerivationContext.php create mode 100644 src/Internal/BinaryKey.php create mode 100644 src/Internal/Clock/ClockInterface.php create mode 100644 src/Internal/Clock/SystemClock.php create mode 100644 src/Internal/CompactPayloadResult.php create mode 100644 src/Internal/HashAlgorithm.php create mode 100644 src/Internal/VersionedPayloadResult.php create mode 100644 src/Password/Contract/CompromisedPasswordCheckerInterface.php create mode 100644 src/Password/NullCompromisedPasswordChecker.php create mode 100644 src/Password/PasswordPolicyResult.php create mode 100644 src/Password/PasswordPolicyValidator.php create mode 100644 src/Security/SignedUrlOptions.php create mode 100644 src/Security/SignedUrlVerificationResult.php create mode 100644 src/Security/Support/SignedUrlGuard.php create mode 100644 src/Token/Jwt/Jwks.php create mode 100644 src/Token/Jwt/JwtVerificationResult.php create mode 100644 src/Token/Jwt/Validation/ExpectedJwtClaims.php create mode 100644 src/Token/Jwt/Validation/JwtIssueClaims.php create mode 100644 src/Token/Jwt/Validation/JwtValidationOptions.php create mode 100644 src/Token/Jwt/Validation/RequiredJwtClaims.php create mode 100644 src/Token/Payload/SignedPayloadVerificationResult.php create mode 100644 tests/Crypto/AeadContextTest.php create mode 100644 tests/Crypto/MatrixCoverageTest.php create mode 100644 tests/Crypto/SecretStreamTest.php create mode 100644 tests/DataProtection/FileProtectionMatrixTest.php create mode 100644 tests/Generate/KeyDerivationContextTest.php create mode 100644 tests/Internal/BinaryKeyTest.php create mode 100644 tests/Internal/EcdsaSignatureConverterTest.php create mode 100644 tests/Internal/ProtectionContextTest.php create mode 100644 tests/Internal/VersionedPayloadTest.php create mode 100644 tests/Security/ClockIntegrationTest.php create mode 100644 tests/Security/SignedUrlHardeningTest.php create mode 100644 tests/Token/Jwt/AlgorithmMatrixTest.php create mode 100644 tests/Token/Jwt/HardeningTest.php create mode 100644 tests/Token/Jwt/JwksTest.php create mode 100644 tests/Token/OpaqueTokenTest.php create mode 100644 tests/Token/SignedPayloadTemporalClaimsTest.php diff --git a/LICENSE b/LICENSE index 69cb5df..9d3f909 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 A. B. M. Mahmudul Hasan +Copyright (c) 2021 Infocyph Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ccc994a..0877759 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,140 @@ See the `docs/` folder for full guides: - capability guides - testing and benchmarking - error handling + +## Usage Examples + +### Encrypt and decrypt a string + +```php +forSecretBox(); +$protector = new StringProtector(); + +$ciphertext = $protector->encrypt('secret-value', $key); +$plaintext = $protector->decrypt($ciphertext, $key); +``` + +### Encrypt and decrypt a file + +```php +forSecretStream(); +$files = new FileProtector(); + +$files->encrypt('/data/plain.txt', '/data/plain.txt.epc', $key); +$files->decrypt('/data/plain.txt.epc', '/data/plain.out.txt', $key); +``` + +### Rotate keys with a key ring + +```php + $oldKey, + '2026-05' => $newKey, +], '2026-05'); + +$protector = new StringProtector(); +$ciphertext = $protector->encryptWithKeyRing('rotating-data', $ring); +$result = $protector->decryptWithKeyRingResult($ciphertext, $ring); +``` + +### Hash, verify, and rehash password + +```php +hashPassword('MyStrongPassword!2026'); + +$isValid = $hasher->verifyPassword('MyStrongPassword!2026', $hash); +$rehash = $hasher->verifyAndRehash('MyStrongPassword!2026', $hash); +``` + +### Issue and verify CSRF token + +```php +issueToken('session-1'); + +$ok = $csrf->verifyToken('session-1', $token); +``` + +### Generate and verify signed URL + +```php +generate('https://example.com/download', ['file' => 'report.pdf'], time() + 300); + +$ok = $signed->verify($url); +``` + +### Issue and verify JWT (HS512) + +```php +encode([ + 'iss' => 'issuer-service', + 'aud' => 'api', + 'sub' => 'user-1', + 'jti' => 'jwt-1', + 'nbf' => time(), + 'exp' => time() + 600, +], 'signing-secret'); + +$verifier = new SymmetricJwt( + SymmetricJwtAlgorithm::HS512, + new RegisteredClaims('issuer-service', 'api', 'user-1', 'jwt-1'), +); + +$ok = $verifier->verify($token, 'signing-secret'); +``` + +### Generate certificate with SAN + +```php +generate(); +$dn = ['commonName' => 'service.example.test']; + +$options = new CertificateOptions( + sanDns: ['service.example.test', 'api.example.test'], +); + +$certPem = CertificateBuilder::openSsl()->selfSign($dn, $pair['private'], options: $options); +``` diff --git a/composer.json b/composer.json index 0d67121..d11c376 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,10 @@ "license": "MIT", "type": "library", "authors": [ + { + "name": "Infocyph", + "email": "infocyph@gmail.com" + }, { "name": "abmmhasan", "email": "abmmhasan@gmail.com" diff --git a/docs/certificates.rst b/docs/certificates.rst new file mode 100644 index 0000000..43b58d6 --- /dev/null +++ b/docs/certificates.rst @@ -0,0 +1,13 @@ +Certificates +============ + +Certificate APIs are documented in :doc:`certificate`. + +Includes: + +- Key pair generation. +- CSR and certificate building. +- SAN support via ``CertificateOptions``. +- CA signing. +- Utility helpers (fingerprint, expiry, key match, chain verify, PEM normalize). + diff --git a/docs/file-encryption.rst b/docs/file-encryption.rst new file mode 100644 index 0000000..cb643fb --- /dev/null +++ b/docs/file-encryption.rst @@ -0,0 +1,24 @@ +File Encryption +=============== + +Use ``FileProtector`` for stream-based file encryption/decryption. + +.. code-block:: php + + forSecretStream(); + $files = new FileProtector(); + + $files->encrypt('/data/plain.txt', '/data/plain.txt.epc', $key); + $files->decrypt('/data/plain.txt.epc', '/data/plain.out.txt', $key); + +Rotation helpers: + +- ``reencrypt()`` +- ``reencryptWithAnyKey()`` +- ``reencryptInPlaceWithAnyKey()`` + diff --git a/docs/index.rst b/docs/index.rst index 5d518d5..57227a7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,6 +32,8 @@ Documentation Map :maxdepth: 2 :caption: Getting Started + installation + quickstart getting-started architecture security-recommendations @@ -41,6 +43,13 @@ Documentation Map :maxdepth: 2 :caption: Capability Guides + passwords + tokens + jwt + file-encryption + certificates + security-model + migration certificate crypto token diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..f8522e7 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,17 @@ +Installation +============ + +Epicrypt requires PHP 8.4+ with: + +- ``ext-sodium`` +- ``ext-openssl`` +- ``ext-hash`` +- ``ext-json`` +- ``ext-mbstring`` + +Install with Composer: + +.. code-block:: bash + + composer require infocyph/epicrypt + diff --git a/docs/jwt.rst b/docs/jwt.rst new file mode 100644 index 0000000..8864340 --- /dev/null +++ b/docs/jwt.rst @@ -0,0 +1,12 @@ +JWT +=== + +JWT is covered under :doc:`token`. + +Highlights: + +- ``SymmetricJwt`` with ``HS256/HS384/HS512``. +- ``AsymmetricJwt`` with ``RS*`` and ``ES*``. +- Structured verification results via ``verifyResult()`` / ``decodeResult()``. +- Header/claim hardening with ``JwtValidationOptions`` and expected/required claims models. + diff --git a/docs/migration.rst b/docs/migration.rst new file mode 100644 index 0000000..1c50c7b --- /dev/null +++ b/docs/migration.rst @@ -0,0 +1,15 @@ +Migration +========= + +Recommended migration flow: + +1. Start writing with current compact payload formats and active key ids. +2. Keep old keys in key rings for read-path compatibility. +3. Re-encrypt legacy strings/files/envelopes to active key ids. +4. Remove retired keys after successful migration and verification. + +JWT migration notes: + +- Enforce strict validation incrementally with result APIs. +- Require ``kid`` when using key-set verification mode. + diff --git a/docs/passwords.rst b/docs/passwords.rst new file mode 100644 index 0000000..f906656 --- /dev/null +++ b/docs/passwords.rst @@ -0,0 +1,12 @@ +Passwords +========= + +Password APIs are documented in :doc:`password`. + +Key components: + +- ``PasswordHasher`` for hash/verify/rehash lifecycle. +- ``PasswordPolicyValidator`` for policy enforcement. +- ``PasswordStrength`` for lightweight strength scoring. +- ``CompromisedPasswordCheckerInterface`` for pluggable breach checks. + diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..22a3877 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,18 @@ +Quickstart +========== + +.. code-block:: php + + forSecretBox(); + $protector = new StringProtector(); + + $ciphertext = $protector->encrypt('hello epicrypt', $key); + $plaintext = $protector->decrypt($ciphertext, $key); + +For deeper guides, see ``crypto.rst``, ``token.rst``, and ``data-protection.rst``. + diff --git a/docs/security-model.rst b/docs/security-model.rst new file mode 100644 index 0000000..5dfb9f0 --- /dev/null +++ b/docs/security-model.rst @@ -0,0 +1,19 @@ +Security Model +============== + +Epicrypt uses versioned payloads, embedded algorithm identifiers, and key-id aware rotation helpers to reduce migration risk. + +Core principles: + +- Secure-by-default APIs. +- Explicit opt-in for unsafe/compatibility modes. +- Strict timestamp validation for signed tokens. +- Separation of crypto capabilities by domain. + +Caller responsibilities: + +- Protect keys in transit and at rest. +- Rotate keys periodically. +- Re-encrypt legacy data after rotation. +- Enforce TLS and application authorization controls. + diff --git a/docs/tokens.rst b/docs/tokens.rst new file mode 100644 index 0000000..a523ed8 --- /dev/null +++ b/docs/tokens.rst @@ -0,0 +1,12 @@ +Tokens +====== + +Token APIs are documented in :doc:`token`. + +This includes: + +- Signed payload tokens. +- Opaque tokens. +- Purpose-bound tokens (CSRF, reset, action, remember, verification). +- Key-ring aware verification and rotation helpers. + diff --git a/src/Certificate/CertificateAuthority.php b/src/Certificate/CertificateAuthority.php new file mode 100644 index 0000000..9c16950 --- /dev/null +++ b/src/Certificate/CertificateAuthority.php @@ -0,0 +1,29 @@ +backend->signCsr($csrPem, $caCertificatePem, $caPrivateKeyPem, $options, $passphrase); + } +} diff --git a/src/Certificate/CertificateBuilder.php b/src/Certificate/CertificateBuilder.php index e2cae98..abbeaca 100644 --- a/src/Certificate/CertificateBuilder.php +++ b/src/Certificate/CertificateBuilder.php @@ -20,8 +20,8 @@ public static function openSsl(string $digestAlgorithm = 'sha512'): self /** * @param array $distinguishedName */ - public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null): string + public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null, ?CertificateOptions $options = null): string { - return $this->backend->selfSign($distinguishedName, $privateKey, $days, $passphrase); + return $this->backend->selfSign($distinguishedName, $privateKey, $days, $passphrase, $options); } } diff --git a/src/Certificate/CertificateChainVerifier.php b/src/Certificate/CertificateChainVerifier.php new file mode 100644 index 0000000..2d62f1f --- /dev/null +++ b/src/Certificate/CertificateChainVerifier.php @@ -0,0 +1,45 @@ + $caCertificatesPem + */ + public function verify(string $certificatePem, array $caCertificatesPem): bool + { + if ($caCertificatesPem === []) { + throw new ConfigurationException('At least one CA certificate is required for chain verification.'); + } + + $tempCaFiles = []; + + try { + foreach ($caCertificatesPem as $caCertificatePem) { + $tempPath = tempnam(sys_get_temp_dir(), 'epicrypt-ca-'); + if ($tempPath === false || file_put_contents($tempPath, $caCertificatePem) === false) { + throw new ConfigurationException('Unable to write CA certificate for chain verification.'); + } + $tempCaFiles[] = $tempPath; + } + + $result = openssl_x509_checkpurpose($certificatePem, X509_PURPOSE_SSL_SERVER, $tempCaFiles); + if ($result === false) { + throw new ConfigurationException('Certificate chain verification failed to execute.'); + } + + return $result === true || $result === 1; + } finally { + foreach ($tempCaFiles as $tempCaFile) { + if (file_exists($tempCaFile)) { + unlink($tempCaFile); + } + } + } + } +} diff --git a/src/Certificate/CertificateExpiry.php b/src/Certificate/CertificateExpiry.php new file mode 100644 index 0000000..b06a366 --- /dev/null +++ b/src/Certificate/CertificateExpiry.php @@ -0,0 +1,25 @@ += $this->expiresAt($certificatePem); + } +} diff --git a/src/Certificate/CertificateFingerprint.php b/src/Certificate/CertificateFingerprint.php new file mode 100644 index 0000000..7e149be --- /dev/null +++ b/src/Certificate/CertificateFingerprint.php @@ -0,0 +1,39 @@ +getMessage(), 0, $e); + } + + $der = $this->pemToDer($certificatePem); + + return hash($algorithm, $der); + } + + private function pemToDer(string $certificatePem): string + { + $normalized = preg_replace('/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/', '', $certificatePem); + if (!is_string($normalized) || $normalized === '') { + throw new ConfigurationException('Invalid certificate PEM content.'); + } + + $der = base64_decode($normalized, true); + if (!is_string($der) || $der === '') { + throw new ConfigurationException('Unable to decode certificate PEM.'); + } + + return $der; + } +} diff --git a/src/Certificate/CertificateKeyMatcher.php b/src/Certificate/CertificateKeyMatcher.php new file mode 100644 index 0000000..cfdc387 --- /dev/null +++ b/src/Certificate/CertificateKeyMatcher.php @@ -0,0 +1,34 @@ + $sanDns + * @param list $sanIp + * @param list $sanEmail + * @param list $keyUsage + * @param list $extendedKeyUsage + */ + public function __construct( + public int $days = 365, + public string $digestAlgorithm = 'sha512', + public array $sanDns = [], + public array $sanIp = [], + public array $sanEmail = [], + public array $keyUsage = [], + public array $extendedKeyUsage = [], + public bool $isCa = false, + ) {} +} diff --git a/src/Certificate/Contract/CertificateAuthorityInterface.php b/src/Certificate/Contract/CertificateAuthorityInterface.php new file mode 100644 index 0000000..afaacd0 --- /dev/null +++ b/src/Certificate/Contract/CertificateAuthorityInterface.php @@ -0,0 +1,18 @@ + $distinguishedName */ - public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null): string; + public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null, ?CertificateOptions $options = null): string; } diff --git a/src/Certificate/Contract/CsrBuilderInterface.php b/src/Certificate/Contract/CsrBuilderInterface.php index 9575067..922ecf4 100644 --- a/src/Certificate/Contract/CsrBuilderInterface.php +++ b/src/Certificate/Contract/CsrBuilderInterface.php @@ -4,10 +4,12 @@ namespace Infocyph\Epicrypt\Certificate\Contract; +use Infocyph\Epicrypt\Certificate\CertificateOptions; + interface CsrBuilderInterface { /** * @param array $distinguishedName */ - public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null): string; + public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null, ?CertificateOptions $options = null): string; } diff --git a/src/Certificate/CsrBuilder.php b/src/Certificate/CsrBuilder.php index 369487f..ee1a9c0 100644 --- a/src/Certificate/CsrBuilder.php +++ b/src/Certificate/CsrBuilder.php @@ -20,8 +20,8 @@ public static function openSsl(): self /** * @param array $distinguishedName */ - public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null): string + public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null, ?CertificateOptions $options = null): string { - return $this->backend->build($distinguishedName, $privateKey, $passphrase); + return $this->backend->build($distinguishedName, $privateKey, $passphrase, $options); } } diff --git a/src/Certificate/OpenSSL/CertificateAuthority.php b/src/Certificate/OpenSSL/CertificateAuthority.php new file mode 100644 index 0000000..7492fbd --- /dev/null +++ b/src/Certificate/OpenSSL/CertificateAuthority.php @@ -0,0 +1,52 @@ + $options->digestAlgorithm]; + $config['config'] = $tempConfigPath; + $config['x509_extensions'] = 'v3_req'; + + try { + $certificate = openssl_csr_sign( + $csrPem, + $caCertificatePem, + $caKeyResource, + $options->days, + $config, + ); + if ($certificate === false) { + throw new ConfigurationException('CA certificate signing failed.'); + } + + $exported = openssl_x509_export($certificate, $certificatePem); + if (!$exported || !is_string($certificatePem) || $certificatePem === '') { + throw new ConfigurationException('Signed certificate export failed.'); + } + + return $certificatePem; + } finally { + if (file_exists($tempConfigPath)) { + unlink($tempConfigPath); + } + } + } +} diff --git a/src/Certificate/OpenSSL/CertificateBuilder.php b/src/Certificate/OpenSSL/CertificateBuilder.php index fadb023..39f0645 100644 --- a/src/Certificate/OpenSSL/CertificateBuilder.php +++ b/src/Certificate/OpenSSL/CertificateBuilder.php @@ -4,7 +4,9 @@ namespace Infocyph\Epicrypt\Certificate\OpenSSL; +use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\CertificateBuilderInterface; +use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Certificate\Support\Pem; use Infocyph\Epicrypt\Exception\ConfigurationException; @@ -17,27 +19,43 @@ public function __construct( /** * @param array $distinguishedName */ - public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null): string + public function selfSign(array $distinguishedName, string $privateKey, int $days = 365, ?string $passphrase = null, ?CertificateOptions $options = null): string { $privateResource = Pem::requirePrivateKeyResource($privateKey, $passphrase); - - $csr = openssl_csr_new($distinguishedName, $privateResource, ['digest_alg' => $this->digestAlgorithm]); - if (!$csr instanceof \OpenSSLCertificateSigningRequest) { - throw new ConfigurationException('CSR generation failed for certificate signing.'); - } - - $signingPrivateKey = $passphrase === null ? $privateKey : [$privateKey, $passphrase]; - - $certificate = openssl_csr_sign($csr, null, $signingPrivateKey, $days, ['digest_alg' => $this->digestAlgorithm]); - if ($certificate === false) { - throw new ConfigurationException('Certificate signing failed.'); + $effectiveOptions = $options ?? new CertificateOptions(days: $days, digestAlgorithm: $this->digestAlgorithm); + $requestedDays = $effectiveOptions->days; + $digestAlgorithm = $effectiveOptions->digestAlgorithm; + $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions); + $csrConfig = ['digest_alg' => $digestAlgorithm]; + $signConfig = ['digest_alg' => $digestAlgorithm]; + $csrConfig['config'] = $tempConfigPath; + $csrConfig['req_extensions'] = 'v3_req'; + $signConfig['config'] = $tempConfigPath; + $signConfig['x509_extensions'] = 'v3_req'; + + try { + $csr = openssl_csr_new($distinguishedName, $privateResource, $csrConfig); + if (!$csr instanceof \OpenSSLCertificateSigningRequest) { + throw new ConfigurationException('CSR generation failed for certificate signing.'); + } + + $signingPrivateKey = $passphrase === null ? $privateKey : [$privateKey, $passphrase]; + + $certificate = openssl_csr_sign($csr, null, $signingPrivateKey, $requestedDays, $signConfig); + if ($certificate === false) { + throw new ConfigurationException('Certificate signing failed.'); + } + + $exported = openssl_x509_export($certificate, $certificatePem); + if (!$exported || !is_string($certificatePem) || $certificatePem === '') { + throw new ConfigurationException('Certificate export failed.'); + } + + return $certificatePem; + } finally { + if (file_exists($tempConfigPath)) { + unlink($tempConfigPath); + } } - - $exported = openssl_x509_export($certificate, $certificatePem); - if (!$exported || !is_string($certificatePem) || $certificatePem === '') { - throw new ConfigurationException('Certificate export failed.'); - } - - return $certificatePem; } } diff --git a/src/Certificate/OpenSSL/CsrBuilder.php b/src/Certificate/OpenSSL/CsrBuilder.php index 6a11604..a451ae8 100644 --- a/src/Certificate/OpenSSL/CsrBuilder.php +++ b/src/Certificate/OpenSSL/CsrBuilder.php @@ -4,7 +4,9 @@ namespace Infocyph\Epicrypt\Certificate\OpenSSL; +use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\CsrBuilderInterface; +use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Certificate\Support\Pem; use Infocyph\Epicrypt\Exception\ConfigurationException; @@ -13,20 +15,31 @@ final class CsrBuilder implements CsrBuilderInterface /** * @param array $distinguishedName */ - public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null): string + public function build(array $distinguishedName, string $privateKey, ?string $passphrase = null, ?CertificateOptions $options = null): string { $privateResource = Pem::requirePrivateKeyResource($privateKey, $passphrase); + $effectiveOptions = $options ?? new CertificateOptions(); + $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions); + $config = ['digest_alg' => $effectiveOptions->digestAlgorithm]; + $config['config'] = $tempConfigPath; + $config['req_extensions'] = 'v3_req'; - $csr = openssl_csr_new($distinguishedName, $privateResource, ['digest_alg' => 'sha512']); - if (!$csr instanceof \OpenSSLCertificateSigningRequest) { - throw new ConfigurationException('CSR generation failed.'); - } + try { + $csr = openssl_csr_new($distinguishedName, $privateResource, $config); + if (!$csr instanceof \OpenSSLCertificateSigningRequest) { + throw new ConfigurationException('CSR generation failed.'); + } - $exported = openssl_csr_export($csr, $csrPem); - if (!$exported || !is_string($csrPem) || $csrPem === '') { - throw new ConfigurationException('CSR export failed.'); - } + $exported = openssl_csr_export($csr, $csrPem); + if (!$exported || !is_string($csrPem) || $csrPem === '') { + throw new ConfigurationException('CSR export failed.'); + } - return $csrPem; + return $csrPem; + } finally { + if (file_exists($tempConfigPath)) { + unlink($tempConfigPath); + } + } } } diff --git a/src/Certificate/OpenSSL/KeyPairGenerator.php b/src/Certificate/OpenSSL/KeyPairGenerator.php index 84dd346..3e28b7a 100644 --- a/src/Certificate/OpenSSL/KeyPairGenerator.php +++ b/src/Certificate/OpenSSL/KeyPairGenerator.php @@ -4,10 +4,12 @@ namespace Infocyph\Epicrypt\Certificate\OpenSSL; +use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\KeyPairGeneratorInterface; use Infocyph\Epicrypt\Certificate\Enum\OpenSslCurveName; use Infocyph\Epicrypt\Certificate\Enum\OpenSslKeyType; use Infocyph\Epicrypt\Certificate\Enum\OpenSslRsaBits; +use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Internal\Base64Url; @@ -28,34 +30,42 @@ public function generate(?string $passphrase = null, bool $asBase64Url = false): 'private_key_bits' => $this->bits->value, 'private_key_type' => $this->type->value, ]; + $tempConfigPath = OpenSslExtensionConfig::createTempConfig(new CertificateOptions()); + $config['config'] = $tempConfigPath; if ($this->curveName !== null) { $config['curve_name'] = $this->curveName->value; } - $resource = openssl_pkey_new($config); - if ($resource === false) { - throw new ConfigurationException('OpenSSL key pair generation failed.'); - } + try { + $resource = openssl_pkey_new($config); + if ($resource === false) { + throw new ConfigurationException('OpenSSL key pair generation failed.'); + } - $privateKey = null; - $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? ''); - if (!$exported || !is_string($privateKey) || $privateKey === '') { - throw new ConfigurationException('Failed to export private key.'); - } + $privateKey = null; + $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? '', $config); + if (!$exported || !is_string($privateKey) || $privateKey === '') { + throw new ConfigurationException('Failed to export private key.'); + } - $details = openssl_pkey_get_details($resource); - if (!is_array($details) || !isset($details['key']) || !is_string($details['key']) || $details['key'] === '') { - throw new ConfigurationException('Failed to export public key.'); - } + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || !isset($details['key']) || !is_string($details['key']) || $details['key'] === '') { + throw new ConfigurationException('Failed to export public key.'); + } - if (!$asBase64Url) { - return ['private' => $privateKey, 'public' => $details['key']]; - } + if (!$asBase64Url) { + return ['private' => $privateKey, 'public' => $details['key']]; + } - return [ - 'private' => Base64Url::encode($privateKey), - 'public' => Base64Url::encode($details['key']), - ]; + return [ + 'private' => Base64Url::encode($privateKey), + 'public' => Base64Url::encode($details['key']), + ]; + } finally { + if (file_exists($tempConfigPath)) { + unlink($tempConfigPath); + } + } } } diff --git a/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php b/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php new file mode 100644 index 0000000..a753a33 --- /dev/null +++ b/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php @@ -0,0 +1,79 @@ +sanDns as $dns) { + $sanEntries[] = sprintf('DNS.%d=%s', $dnsIndex++, $dns); + } + $ipIndex = 1; + foreach ($options->sanIp as $ip) { + $sanEntries[] = sprintf('IP.%d=%s', $ipIndex++, $ip); + } + $emailIndex = 1; + foreach ($options->sanEmail as $email) { + $sanEntries[] = sprintf('email.%d=%s', $emailIndex++, $email); + } + + if ($sanEntries !== []) { + $lines[] = 'subjectAltName=@alt_names'; + } + + if ($options->keyUsage !== []) { + $lines[] = 'keyUsage=' . implode(', ', $options->keyUsage); + } + + if ($options->extendedKeyUsage !== []) { + $lines[] = 'extendedKeyUsage=' . implode(', ', $options->extendedKeyUsage); + } + + $lines[] = 'basicConstraints=' . ($options->isCa ? 'critical,CA:TRUE' : 'CA:FALSE'); + + if ($sanEntries !== []) { + $lines[] = ''; + $lines[] = '[alt_names]'; + array_push($lines, ...$sanEntries); + } + + $tempFile = tempnam(sys_get_temp_dir(), 'epicrypt-openssl-'); + if ($tempFile === false) { + throw new ConfigurationException('Unable to create OpenSSL extension config.'); + } + + if (file_put_contents($tempFile, implode(PHP_EOL, $lines) . PHP_EOL) === false) { + if (file_exists($tempFile)) { + unlink($tempFile); + } + + throw new ConfigurationException('Unable to write OpenSSL extension config.'); + } + + return $tempFile; + } +} diff --git a/src/Certificate/PemNormalizer.php b/src/Certificate/PemNormalizer.php new file mode 100644 index 0000000..0cb80ca --- /dev/null +++ b/src/Certificate/PemNormalizer.php @@ -0,0 +1,15 @@ +assertAlgorithmAvailability(); + $aeadContext = AeadContext::fromArray($context); - $decodedKey = $this->decodeKey($key, $this->algorithm->keyLength(), $this->boolFromContext($context, 'key_is_binary'), 'Decryption'); + $decodedKey = $this->decodeKey($key, $this->algorithm->keyLength(), $aeadContext->keyIsBinary, 'Decryption'); + [$encodedNonce, $encodedCiphertext] = $this->splitPayload($ciphertext); - $parsedPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); - if ($parsedPayload === null) { - throw new DecryptionException('Invalid ciphertext format.'); - } - [, $parts] = $parsedPayload; - - $nonce = Base64Url::decode($parts[0]); + $nonce = Base64Url::decode($encodedNonce); if (strlen($nonce) !== $this->algorithm->nonceLength()) { throw new InvalidNonceException(sprintf('Nonce must be %d bytes.', $this->algorithm->nonceLength())); } - $aad = $this->aadFromContext($context); - $plaintext = $this->decryptRaw(Base64Url::decode($parts[1]), $aad, $nonce, $decodedKey); + $plaintext = $this->decryptRaw(Base64Url::decode($encodedCiphertext), $aeadContext->aad, $nonce, $decodedKey); if (!is_string($plaintext)) { throw new DecryptionException('AEAD decryption failed.'); @@ -61,16 +58,14 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st public function encrypt(string $plaintext, mixed $key, array $context = []): string { $this->assertAlgorithmAvailability(); + $aeadContext = AeadContext::fromArray($context); - $decodedKey = $this->decodeKey($key, $this->algorithm->keyLength(), $this->boolFromContext($context, 'key_is_binary'), 'Encryption'); - - if (array_key_exists('nonce', $context)) { - $nonce = $context['nonce']; - if (!is_string($nonce) || $nonce === '') { - throw new InvalidNonceException('Nonce must be a non-empty string.'); - } + $decodedKey = $this->decodeKey($key, $this->algorithm->keyLength(), $aeadContext->keyIsBinary, 'Encryption'); + $keyId = $aeadContext->keyId; - if (!$this->boolFromContext($context, 'nonce_is_binary')) { + if ($aeadContext->nonce !== null) { + $nonce = $aeadContext->nonce; + if (!$aeadContext->nonceIsBinary) { $nonce = Base64Url::decode($nonce); } } else { @@ -81,29 +76,17 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str throw new InvalidNonceException(sprintf('Nonce must be %d bytes.', $this->algorithm->nonceLength())); } - $aad = $this->aadFromContext($context); - $ciphertext = $this->encryptRaw($plaintext, $aad, $nonce, $decodedKey); + $ciphertext = $this->encryptRaw($plaintext, $aeadContext->aad, $nonce, $decodedKey); - return VersionedPayload::encode( + return VersionedPayload::encodeCompact( EncryptedPayloadVersion::V1->value, + $this->algorithm->value, + $keyId, Base64Url::encode($nonce), Base64Url::encode($ciphertext), ); } - /** - * @param array $context - */ - private function aadFromContext(array $context): string - { - $aad = $context['aad'] ?? ''; - if (!is_string($aad)) { - throw new CryptoException('AAD must be a string.'); - } - - return $aad; - } - private function assertAlgorithmAvailability(): void { if (!$this->algorithm->isAvailable()) { @@ -111,31 +94,13 @@ private function assertAlgorithmAvailability(): void } } - /** - * @param array $context - */ - private function boolFromContext(array $context, string $key): bool - { - $value = $context[$key] ?? false; - if (!is_bool($value)) { - throw new CryptoException(sprintf('Context value "%s" must be boolean.', $key)); - } - - return $value; - } - private function decodeKey(mixed $key, int $expectedLength, bool $isBinary, string $operation): string { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException(sprintf('%s key must be a non-empty string.', $operation)); - } - - $decoded = $isBinary ? $key : Base64Url::decode($key); - if (strlen($decoded) !== $expectedLength) { - throw new InvalidKeyException(sprintf('%s key must be %d bytes.', $operation, $expectedLength)); + try { + return BinaryKey::aeadKey($key, $isBinary, $expectedLength, sprintf('%s key', $operation)); + } catch (InvalidKeyException $e) { + throw new InvalidKeyException(sprintf('%s key must be %d bytes.', $operation, $expectedLength), 0, $e); } - - return $decoded; } private function decryptRaw(string $ciphertext, string $aad, string $nonce, string $key): string|false @@ -170,4 +135,26 @@ private function runRawOperation(string $input, string $aad, string $nonce, stri : sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($input, $aad, $nonce, $key), }; } + + /** + * @return array{string, string} + */ + private function splitPayload(string $ciphertext): array + { + $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + if ($compactPayload !== null) { + if ($compactPayload->algorithm !== $this->algorithm->value) { + throw new DecryptionException(sprintf('Unsupported payload algorithm "%s".', $compactPayload->algorithm)); + } + + return [$compactPayload->nonce, $compactPayload->ciphertext]; + } + + $legacyPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); + if ($legacyPayload === null) { + throw new DecryptionException('Invalid ciphertext format.'); + } + + return [$legacyPayload->parts[0], $legacyPayload->parts[1]]; + } } diff --git a/src/Crypto/Context/AeadContext.php b/src/Crypto/Context/AeadContext.php new file mode 100644 index 0000000..bbd9dd4 --- /dev/null +++ b/src/Crypto/Context/AeadContext.php @@ -0,0 +1,55 @@ + $context + */ + public static function fromArray(array $context): self + { + $keyIsBinary = $context['key_is_binary'] ?? false; + if (!is_bool($keyIsBinary)) { + throw new CryptoException('Context value "key_is_binary" must be boolean.'); + } + + $nonceIsBinary = $context['nonce_is_binary'] ?? false; + if (!is_bool($nonceIsBinary)) { + throw new CryptoException('Context value "nonce_is_binary" must be boolean.'); + } + + $aad = $context['aad'] ?? ''; + if (!is_string($aad)) { + throw new CryptoException('AAD must be a string.'); + } + + $nonce = $context['nonce'] ?? null; + if ($nonce !== null && (!is_string($nonce) || $nonce === '')) { + throw new InvalidNonceException('Nonce must be a non-empty string.'); + } + + $keyId = $context['key_id'] ?? null; + if ($keyId !== null && (!is_string($keyId) || $keyId === '')) { + throw new CryptoException('Context value "key_id" must be a non-empty string when provided.'); + } + + return new self($keyIsBinary, $nonceIsBinary, $aad, $nonce, $keyId); + } +} diff --git a/src/Crypto/Enum/StreamAlgorithm.php b/src/Crypto/Enum/StreamAlgorithm.php index fec3ec3..5565865 100644 --- a/src/Crypto/Enum/StreamAlgorithm.php +++ b/src/Crypto/Enum/StreamAlgorithm.php @@ -6,17 +6,25 @@ enum StreamAlgorithm: string { - case XCHACHA20 = 'xchacha20'; + case UNAUTHENTICATED_XCHACHA20 = 'unauthenticated-xchacha20'; case XCHACHA20POLY1305 = 'xchacha20poly1305'; + public function keyLength(): int + { + return match ($this) { + self::UNAUTHENTICATED_XCHACHA20 => SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES, + self::XCHACHA20POLY1305 => SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES, + }; + } + /** * @return int<1, max> */ public function prefixLength(): int { return match ($this) { - self::XCHACHA20 => SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES, + self::UNAUTHENTICATED_XCHACHA20 => SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES, self::XCHACHA20POLY1305 => SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES, }; } diff --git a/src/Crypto/Mac.php b/src/Crypto/Mac.php index c0101ba..dd8e37d 100644 --- a/src/Crypto/Mac.php +++ b/src/Crypto/Mac.php @@ -7,6 +7,7 @@ use Infocyph\Epicrypt\Crypto\Contract\MacInterface; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; final class Mac implements MacInterface { @@ -39,11 +40,10 @@ public function verify(string $message, string $mac, string $key, array $context private function decodeKey(string $key, bool $isBinary): string { - $decodedKey = $isBinary ? $key : Base64Url::decode($key); - if (strlen($decodedKey) !== SODIUM_CRYPTO_AUTH_KEYBYTES) { - throw new InvalidKeyException('MAC key must be 32 bytes.'); + try { + return BinaryKey::macKey($key, $isBinary, 'MAC key'); + } catch (InvalidKeyException $e) { + throw new InvalidKeyException('MAC key must be 32 bytes.', 0, $e); } - - return $decodedKey; } } diff --git a/src/Crypto/PublicKeyBoxCipher.php b/src/Crypto/PublicKeyBoxCipher.php index 4456d9c..95ba1a8 100644 --- a/src/Crypto/PublicKeyBoxCipher.php +++ b/src/Crypto/PublicKeyBoxCipher.php @@ -5,10 +5,11 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; -use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; +use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\Enum\EncryptedPayloadVersion; use Infocyph\Epicrypt\Internal\VersionedPayload; @@ -23,18 +24,21 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st throw new InvalidKeyException('Key must include sender_public and recipient_private entries.'); } - $senderPublic = $this->decodeKey($key['sender_public'] ?? null, 'sender_public', $context); - $recipientPrivate = $this->decodeKey($key['recipient_private'] ?? null, 'recipient_private', $context); + try { + $senderPublic = BinaryKey::boxPublicKey($key['sender_public'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'sender_public'); + $recipientPrivate = BinaryKey::boxSecretKey($key['recipient_private'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'recipient_private'); + } catch (InvalidKeyException $e) { + throw new DecryptionException('Public key-box decryption key material is invalid.', 0, $e); + } $parsedPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); if ($parsedPayload === null) { throw new DecryptionException('Invalid ciphertext format.'); } - [, $parts] = $parsedPayload; $plaintext = sodium_crypto_box_open( - Base64Url::decode($parts[1]), - Base64Url::decode($parts[0]), + Base64Url::decode($parsedPayload->parts[1]), + Base64Url::decode($parsedPayload->parts[0]), sodium_crypto_box_keypair_from_secretkey_and_publickey($recipientPrivate, $senderPublic), ); @@ -54,8 +58,12 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str throw new InvalidKeyException('Key must include recipient_public and sender_private entries.'); } - $recipientPublic = $this->decodeKey($key['recipient_public'] ?? null, 'recipient_public', $context); - $senderPrivate = $this->decodeKey($key['sender_private'] ?? null, 'sender_private', $context); + try { + $recipientPublic = BinaryKey::boxPublicKey($key['recipient_public'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'recipient_public'); + $senderPrivate = BinaryKey::boxSecretKey($key['sender_private'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'sender_private'); + } catch (InvalidKeyException $e) { + throw new EncryptionException('Public key-box encryption key material is invalid.', 0, $e); + } $nonce = random_bytes(SODIUM_CRYPTO_BOX_NONCEBYTES); $ciphertext = sodium_crypto_box( @@ -70,17 +78,4 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str Base64Url::encode($ciphertext), ); } - - /** - * @param array $context - */ - private function decodeKey(mixed $value, string $name, array $context): string - { - return KeyDecoder::decode( - $value, - (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, - $name, - ); - } } diff --git a/src/Crypto/SealedBoxCipher.php b/src/Crypto/SealedBoxCipher.php index dc2e0a0..8807b7e 100644 --- a/src/Crypto/SealedBoxCipher.php +++ b/src/Crypto/SealedBoxCipher.php @@ -5,9 +5,11 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; -use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; +use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; +use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\Enum\EncryptedPayloadVersion; use Infocyph\Epicrypt\Internal\VersionedPayload; @@ -18,20 +20,18 @@ final class SealedBoxCipher implements CipherInterface */ public function decrypt(string $ciphertext, mixed $key, array $context = []): string { - $keypair = KeyDecoder::decode( - $key, - (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_BOX_KEYPAIRBYTES, - 'Recipient keypair', - ); + try { + $keypair = BinaryKey::boxKeypair($key, (bool) ($context['key_is_binary'] ?? false), 'Recipient keypair'); + } catch (InvalidKeyException $e) { + throw new DecryptionException('Recipient keypair must be valid.', 0, $e); + } $parsedPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 1); if ($parsedPayload === null) { throw new DecryptionException('Invalid ciphertext format.'); } - [, $parts] = $parsedPayload; - $plaintext = sodium_crypto_box_seal_open(Base64Url::decode($parts[0]), $keypair); + $plaintext = sodium_crypto_box_seal_open(Base64Url::decode($parsedPayload->parts[0]), $keypair); if (!is_string($plaintext)) { throw new DecryptionException('Sealed-box decryption failed.'); } @@ -44,12 +44,11 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st */ public function encrypt(string $plaintext, mixed $key, array $context = []): string { - $publicKey = KeyDecoder::decode( - $key, - (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, - 'Recipient public key', - ); + try { + $publicKey = BinaryKey::boxPublicKey($key, (bool) ($context['key_is_binary'] ?? false), 'Recipient public key'); + } catch (InvalidKeyException $e) { + throw new EncryptionException('Recipient public key must be valid.', 0, $e); + } $ciphertext = sodium_crypto_box_seal($plaintext, $publicKey); diff --git a/src/Crypto/SecretBoxCipher.php b/src/Crypto/SecretBoxCipher.php index 5522c6e..2a9134f 100644 --- a/src/Crypto/SecretBoxCipher.php +++ b/src/Crypto/SecretBoxCipher.php @@ -5,31 +5,28 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; -use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\Enum\EncryptedPayloadVersion; use Infocyph\Epicrypt\Internal\VersionedPayload; final class SecretBoxCipher implements CipherInterface { + public const string ALGORITHM_ID = 'secretbox'; + /** * @param array $context */ public function decrypt(string $ciphertext, mixed $key, array $context = []): string { $decodedKey = $this->decodeKey($key, $context, 'Decryption'); - - $parsedPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); - if ($parsedPayload === null) { - throw new DecryptionException('Invalid ciphertext format.'); - } - [, $parts] = $parsedPayload; + [$encodedNonce, $encodedCipher] = $this->splitPayload($ciphertext); $plaintext = sodium_crypto_secretbox_open( - Base64Url::decode($parts[1]), - Base64Url::decode($parts[0]), + Base64Url::decode($encodedCipher), + Base64Url::decode($encodedNonce), $decodedKey, ); @@ -47,30 +44,81 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str { $decodedKey = $this->decodeKey($key, $context, 'Encryption'); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $keyId = $this->keyIdFromContext($context); $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $decodedKey); - return VersionedPayload::encode( + return VersionedPayload::encodeCompact( EncryptedPayloadVersion::V1->value, + self::ALGORITHM_ID, + $keyId, Base64Url::encode($nonce), Base64Url::encode($ciphertext), ); } + public function parseKeyId(string $ciphertext): ?string + { + $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + if ($compactPayload === null || $compactPayload->algorithm !== self::ALGORITHM_ID) { + return null; + } + + return $compactPayload->keyId; + } + /** * @param array $context */ private function decodeKey(mixed $key, array $context, string $operation): string { try { - return KeyDecoder::decode( + return BinaryKey::secretBoxKey( $key, (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_SECRETBOX_KEYBYTES, sprintf('%s key', $operation), ); } catch (InvalidKeyException $e) { throw new InvalidKeyException(sprintf('%s key must be 32 bytes.', $operation), 0, $e); } } + + /** + * @param array $context + */ + private function keyIdFromContext(array $context): ?string + { + $keyId = $context['key_id'] ?? null; + if ($keyId === null) { + return null; + } + + if (!is_string($keyId) || $keyId === '') { + throw new InvalidKeyException('Context key_id must be a non-empty string when provided.'); + } + + return $keyId; + } + + /** + * @return array{string, string} + */ + private function splitPayload(string $ciphertext): array + { + $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + if ($compactPayload !== null) { + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new DecryptionException('Unsupported payload algorithm.'); + } + + return [$compactPayload->nonce, $compactPayload->ciphertext]; + } + + $legacyPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); + if ($legacyPayload === null) { + throw new DecryptionException('Invalid ciphertext format.'); + } + + return [$legacyPayload->parts[0], $legacyPayload->parts[1]]; + } } diff --git a/src/Crypto/SecretStream.php b/src/Crypto/SecretStream.php index d7cc552..d6474e0 100644 --- a/src/Crypto/SecretStream.php +++ b/src/Crypto/SecretStream.php @@ -5,8 +5,10 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Enum\StreamAlgorithm; +use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; +use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Exception\FileAccessException; use Infocyph\Pathwise\FileManager\SafeFileReader; use Infocyph\Pathwise\FileManager\SafeFileWriter; @@ -14,7 +16,20 @@ final readonly class SecretStream { - public function __construct(private string $key, private StreamAlgorithm $algorithm = StreamAlgorithm::XCHACHA20POLY1305, private string $additionalData = '') {} + public function __construct( + private string $key, + private StreamAlgorithm $algorithm = StreamAlgorithm::XCHACHA20POLY1305, + private string $additionalData = '', + private bool $allowUnauthenticatedStream = false, + ) { + if (strlen($this->key) !== $this->algorithm->keyLength()) { + throw new InvalidKeyException(sprintf('Stream key must be %d bytes.', $this->algorithm->keyLength())); + } + + if ($this->algorithm === StreamAlgorithm::UNAUTHENTICATED_XCHACHA20 && !$this->allowUnauthenticatedStream) { + throw new ConfigurationException('Unauthenticated stream encryption must be explicitly enabled.'); + } + } public function decrypt(string $inputPath, string $outputPath, int $chunkSize = 8192): void { @@ -140,21 +155,18 @@ private function decryptUsingSecretStream(string $inputPath, SafeFileWriter $fil private function encryptUsingCryptoStream(string $inputPath, SafeFileWriter $fileWriter, int $chunkSize): int { $nonce = random_bytes($this->algorithm->prefixLength()); - $this->writeBinary($fileWriter, $nonce); + $bytesWritten = $this->writeBinary($fileWriter, $nonce); $fileReader = new SafeFileReader($inputPath); try { - $writeChunkSize = 0; - - $this->forEachChunk($fileReader, $chunkSize, 'plaintext', function (string $chunk) use ($fileWriter, &$nonce, &$writeChunkSize): void { + $this->forEachChunk($fileReader, $chunkSize, 'plaintext', function (string $chunk) use ($fileWriter, &$nonce, &$bytesWritten): void { $encryptedChunk = sodium_crypto_stream_xchacha20_xor($chunk, $nonce, $this->key); - $this->writeBinary($fileWriter, $encryptedChunk); - $writeChunkSize = strlen($encryptedChunk); + $bytesWritten += $this->writeBinary($fileWriter, $encryptedChunk); $nonce = $this->incrementNonce($nonce); }); - return $writeChunkSize; + return $bytesWritten; } finally { $fileReader->releaseLock(); } @@ -167,13 +179,12 @@ private function encryptUsingSecretStream(string $inputPath, SafeFileWriter $fil throw new RuntimeException('Unable to initialize secret stream push state.'); } - $this->writeBinary($fileWriter, $header); + $bytesWritten = $this->writeBinary($fileWriter, $header); $fileReader = new SafeFileReader($inputPath); try { $chunkIterator = $fileReader->binary($chunkSize); - $writeChunkSize = 0; /** @var string|null $bufferedChunk */ $bufferedChunk = null; @@ -196,8 +207,7 @@ private function encryptUsingSecretStream(string $inputPath, SafeFileWriter $fil SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE, ); - $this->writeBinary($fileWriter, $encryptedChunk); - $writeChunkSize = strlen($encryptedChunk); + $bytesWritten += $this->writeBinary($fileWriter, $encryptedChunk); $bufferedChunk = $chunk; } @@ -208,11 +218,10 @@ private function encryptUsingSecretStream(string $inputPath, SafeFileWriter $fil SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL, ); - $this->writeBinary($fileWriter, $finalChunk); - $writeChunkSize = strlen($finalChunk); + $bytesWritten += $this->writeBinary($fileWriter, $finalChunk); sodium_memzero($state); - return $writeChunkSize; + return $bytesWritten; } finally { $fileReader->releaseLock(); } @@ -264,11 +273,13 @@ private function normalizeChunk(mixed $chunk, string $kind): ?string return $chunk; } - private function writeBinary(SafeFileWriter $fileWriter, string $data): void + private function writeBinary(SafeFileWriter $fileWriter, string $data): int { $written = $fileWriter->__call('binary', [$data]); if ($written === false) { throw new RuntimeException('Failed to write output chunk.'); } + + return strlen($data); } } diff --git a/src/Crypto/Signature.php b/src/Crypto/Signature.php index 2b55bfb..c285c80 100644 --- a/src/Crypto/Signature.php +++ b/src/Crypto/Signature.php @@ -5,9 +5,10 @@ namespace Infocyph\Epicrypt\Crypto; use Infocyph\Epicrypt\Crypto\Contract\SignatureInterface; -use Infocyph\Epicrypt\Crypto\Support\KeyDecoder; +use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Exception\Crypto\SignatureException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; final class Signature implements SignatureInterface { @@ -16,14 +17,13 @@ final class Signature implements SignatureInterface */ public function sign(string $message, mixed $key, array $context = []): string { - $privateKey = KeyDecoder::decode( - $key, - (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_SIGN_SECRETKEYBYTES, - 'Private key', - ); + try { + $privateKey = BinaryKey::signSecretKey($key, (bool) ($context['key_is_binary'] ?? false), 'Private key'); + } catch (InvalidKeyException $e) { + throw new SignatureException('Private key must be a valid signing secret key.', 0, $e); + } - $signature = sodium_crypto_sign_detached($message, $privateKey); + $signature = sodium_crypto_sign_detached($message, $this->requireNonEmptyKey($privateKey, 'Private key')); return Base64Url::encode($signature); } @@ -33,18 +33,29 @@ public function sign(string $message, mixed $key, array $context = []): string */ public function verify(string $message, string $signature, mixed $key, array $context = []): bool { - $publicKey = KeyDecoder::decode( - $key, - (bool) ($context['key_is_binary'] ?? false), - SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, - 'Public key', - ); + try { + $publicKey = BinaryKey::signPublicKey($key, (bool) ($context['key_is_binary'] ?? false), 'Public key'); + } catch (InvalidKeyException $e) { + throw new SignatureException('Public key must be a valid signing public key.', 0, $e); + } $decodedSignature = Base64Url::decode($signature); if ($decodedSignature === '') { throw new SignatureException('Signature must decode to non-empty bytes.'); } - return sodium_crypto_sign_verify_detached($decodedSignature, $message, $publicKey); + return sodium_crypto_sign_verify_detached($decodedSignature, $message, $this->requireNonEmptyKey($publicKey, 'Public key')); + } + + /** + * @return non-empty-string + */ + private function requireNonEmptyKey(string $key, string $label): string + { + if ($key === '') { + throw new SignatureException($label . ' must not be empty.'); + } + + return $key; } } diff --git a/src/DataProtection/EnvelopeDecryptResult.php b/src/DataProtection/EnvelopeDecryptResult.php index 223d209..b915fde 100644 --- a/src/DataProtection/EnvelopeDecryptResult.php +++ b/src/DataProtection/EnvelopeDecryptResult.php @@ -10,5 +10,9 @@ public function __construct( public string $plaintext, public ?string $matchedKeyId = null, public bool $usedFallbackKey = false, + public ?string $algorithm = null, + public ?string $dekAlgorithm = null, + public ?int $createdAt = null, + public ?string $purpose = null, ) {} } diff --git a/src/DataProtection/EnvelopeInspectResult.php b/src/DataProtection/EnvelopeInspectResult.php new file mode 100644 index 0000000..e653332 --- /dev/null +++ b/src/DataProtection/EnvelopeInspectResult.php @@ -0,0 +1,17 @@ +value)) { - throw new DecryptionException('Unsupported envelope format version.'); - } - - if (isset($envelope['alg']) && (!is_string($envelope['alg']) || $envelope['alg'] !== EnvelopeAlgorithm::SECRETBOX->value)) { - throw new DecryptionException('Unsupported envelope algorithm.'); - } - - $encryptedKey = $envelope['encrypted_key'] ?? null; - if (!is_string($encryptedKey) || $encryptedKey === '') { - throw new DecryptionException('Envelope encrypted_key must be a non-empty string.'); - } - - $encryptedData = $envelope['encrypted_data'] ?? null; - if (!is_string($encryptedData) || $encryptedData === '') { - throw new DecryptionException('Envelope encrypted_data must be a non-empty string.'); - } + $envelope = $this->decodeEnvelope($encodedEnvelope); + $encryptedKey = $envelope['encrypted_key']; + $encryptedData = $envelope['encrypted_data']; $dataKey = $this->cipher->decrypt($encryptedKey, $masterKey); @@ -76,6 +64,36 @@ public function decryptWithAnyKey(string $encodedEnvelope, iterable|KeyRing $mas */ public function decryptWithAnyKeyResult(string $encodedEnvelope, iterable|KeyRing $masterKeys): EnvelopeDecryptResult { + $metadata = $this->decodeEnvelope($encodedEnvelope); + + if ($masterKeys instanceof KeyRing) { + $keyId = $metadata['kid'] ?? null; + if ($keyId !== null) { + if (!is_string($keyId) || $keyId === '') { + throw new DecryptionException('Envelope kid must be a non-empty string when provided.'); + } + + $key = $masterKeys->keys()[$keyId] ?? null; + if ($key === null) { + throw new DecryptionException(sprintf('Envelope key id "%s" was not found in the key ring.', $keyId)); + } + + try { + return new EnvelopeDecryptResult( + $this->decrypt($encodedEnvelope, $key), + $keyId, + false, + $this->stringOrNull($metadata['alg'] ?? null), + $this->stringOrNull($metadata['dek_alg'] ?? null), + $this->intOrNull($metadata['created_at'] ?? null), + $this->stringOrNull($metadata['purpose'] ?? null), + ); + } catch (DecryptionException $e) { + throw new DecryptionException(sprintf('Unable to decrypt envelope with key id "%s".', $keyId), 0, $e); + } + } + } + $lastException = null; foreach ($this->orderedKeyEntries($masterKeys) as $entry) { try { @@ -83,6 +101,10 @@ public function decryptWithAnyKeyResult(string $encodedEnvelope, iterable|KeyRin $this->decrypt($encodedEnvelope, $entry['key']), $entry['id'], !$entry['active'], + $this->stringOrNull($metadata['alg'] ?? null), + $this->stringOrNull($metadata['dek_alg'] ?? null), + $this->intOrNull($metadata['created_at'] ?? null), + $this->stringOrNull($metadata['purpose'] ?? null), ); } catch (DecryptionException $e) { $lastException = $e; @@ -92,36 +114,128 @@ public function decryptWithAnyKeyResult(string $encodedEnvelope, iterable|KeyRin throw new DecryptionException('Unable to decrypt envelope with any supplied master key.', 0, $lastException); } + public function decryptWithKeyRing(string $encodedEnvelope, KeyRing $masterKeys): string + { + return $this->decryptWithKeyRingResult($encodedEnvelope, $masterKeys)->plaintext; + } + + public function decryptWithKeyRingResult(string $encodedEnvelope, KeyRing $masterKeys): EnvelopeDecryptResult + { + return $this->decryptWithAnyKeyResult($encodedEnvelope, $masterKeys); + } + /** - * @param array{encrypted_data: string, encrypted_key: string, v?: int, alg?: string} $envelope + * @param array{encrypted_data: string, encrypted_key: string, v?: int, alg?: string, kid?: ?string, dek_alg?: ?string, created_at?: ?int, purpose?: ?string} $envelope */ public function encodeEnvelope(array $envelope): string { $envelope['v'] = (int) ($envelope['v'] ?? EnvelopeVersion::V1->value); $envelope['alg'] = (string) ($envelope['alg'] ?? EnvelopeAlgorithm::SECRETBOX->value); + $envelope['dek_alg'] = (string) ($envelope['dek_alg'] ?? EnvelopeAlgorithm::SECRETBOX->value); + $envelope['created_at'] = (int) ($envelope['created_at'] ?? $this->clock->now()); return Json::encode($envelope); } /** - * @return array{encrypted_data: string, encrypted_key: string, v: int, alg: string} + * @param array{purpose?: mixed} $context + * @return array{encrypted_data: string, encrypted_key: string, v: int, alg: string, dek_alg: string, created_at: int, purpose?: string, kid?: ?string} */ - public function encrypt(string $plaintext, string $masterKey): array + public function encrypt(string $plaintext, string $masterKey, array $context = []): array { try { $dataKey = $this->keyGenerator->forPurpose(KeyPurpose::SECRETBOX, $this->profile); + $purpose = $context['purpose'] ?? null; + if ($purpose !== null && (!is_string($purpose) || $purpose === '')) { + throw new EncryptionException('Envelope purpose must be a non-empty string when provided.'); + } - return [ + $envelope = [ 'v' => EnvelopeVersion::V1->value, 'alg' => EnvelopeAlgorithm::SECRETBOX->value, + 'dek_alg' => EnvelopeAlgorithm::SECRETBOX->value, + 'created_at' => $this->clock->now(), 'encrypted_data' => $this->cipher->encrypt($plaintext, $dataKey), 'encrypted_key' => $this->cipher->encrypt($dataKey, $masterKey), ]; + if ($purpose !== null) { + $envelope['purpose'] = $purpose; + } + + return $envelope; } catch (Throwable $e) { throw new EncryptionException($e->getMessage(), 0, $e); } } + /** + * @param array{purpose?: mixed} $context + * @return array{encrypted_data: string, encrypted_key: string, v: int, alg: string, dek_alg: string, created_at: int, purpose?: string, kid: string} + */ + public function encryptWithKeyRing(string $plaintext, KeyRing $masterKeys, array $context = []): array + { + $activeKey = $masterKeys->activeKey(); + $activeKeyId = $masterKeys->activeKeyId(); + if ($activeKey === null || $activeKeyId === null) { + throw new ConfigurationException('Key ring active key id is required for envelope encryption.'); + } + + $envelope = $this->encrypt($plaintext, $activeKey, $context); + $envelope['kid'] = $activeKeyId; + + return $envelope; + } + + public function inspect(string $encodedEnvelope): EnvelopeInspectResult + { + $envelope = $this->decodeEnvelope($encodedEnvelope); + + return new EnvelopeInspectResult( + version: isset($envelope['v']) && is_numeric($envelope['v']) ? (int) $envelope['v'] : EnvelopeVersion::V1->value, + algorithm: $this->stringOrNull($envelope['alg'] ?? null) ?? EnvelopeAlgorithm::SECRETBOX->value, + keyId: $this->stringOrNull($envelope['kid'] ?? null), + dekAlgorithm: $this->stringOrNull($envelope['dek_alg'] ?? null), + createdAt: $this->intOrNull($envelope['created_at'] ?? null), + purpose: $this->stringOrNull($envelope['purpose'] ?? null), + ); + } + + public function needsReencrypt(string $encodedEnvelope, ?string $activeKeyId = null, ?int $maxAgeSeconds = null): bool + { + $info = $this->inspect($encodedEnvelope); + + if ($this->needsRotation($encodedEnvelope, $activeKeyId)) { + return true; + } + + if ($info->dekAlgorithm === null || !hash_equals($info->dekAlgorithm, EnvelopeAlgorithm::SECRETBOX->value)) { + return true; + } + + if ($maxAgeSeconds !== null && $maxAgeSeconds > 0) { + if ($info->createdAt === null) { + return true; + } + + if ($this->clock->now() - $info->createdAt > $maxAgeSeconds) { + return true; + } + } + + return false; + } + + public function needsRotation(string $encodedEnvelope, ?string $activeKeyId = null): bool + { + if ($activeKeyId === null || $activeKeyId === '') { + return false; + } + + $info = $this->inspect($encodedEnvelope); + + return $info->keyId === null || !hash_equals($activeKeyId, $info->keyId); + } + public function reencrypt(string $encodedEnvelope, string $oldMasterKey, string $newMasterKey): string { $plaintext = $this->decrypt($encodedEnvelope, $oldMasterKey); @@ -139,6 +253,57 @@ public function reencryptWithAnyKey(string $encodedEnvelope, iterable|KeyRing $m return $this->encodeEnvelope($this->encrypt($plaintext, $newMasterKey)); } + public function reencryptWithKeyRing(string $encodedEnvelope, KeyRing $masterKeys): string + { + $plaintext = $this->decryptWithKeyRingResult($encodedEnvelope, $masterKeys)->plaintext; + + return $this->encodeEnvelope($this->encryptWithKeyRing($plaintext, $masterKeys)); + } + + /** + * @param array $envelope + */ + private function assertEnvelopeMetadata(array $envelope): void + { + if (isset($envelope['v']) && (!is_numeric($envelope['v']) || (int) $envelope['v'] !== EnvelopeVersion::V1->value)) { + throw new DecryptionException('Unsupported envelope format version.'); + } + + if (isset($envelope['alg']) && (!is_string($envelope['alg']) || $envelope['alg'] !== EnvelopeAlgorithm::SECRETBOX->value)) { + throw new DecryptionException('Unsupported envelope algorithm.'); + } + + if (isset($envelope['dek_alg']) && (!is_string($envelope['dek_alg']) || $envelope['dek_alg'] === '')) { + throw new DecryptionException('Envelope dek_alg must be a non-empty string when provided.'); + } + + if (isset($envelope['created_at']) && !is_numeric($envelope['created_at'])) { + throw new DecryptionException('Envelope created_at must be a numeric timestamp when provided.'); + } + + if (isset($envelope['purpose']) && (!is_string($envelope['purpose']) || $envelope['purpose'] === '')) { + throw new DecryptionException('Envelope purpose must be a non-empty string when provided.'); + } + } + + /** + * @return array{encrypted_data: string, encrypted_key: string, v?: mixed, alg?: mixed, kid?: mixed, dek_alg?: mixed, created_at?: mixed, purpose?: mixed} + */ + private function decodeEnvelope(string $encodedEnvelope): array + { + $envelope = Json::decodeToArray($encodedEnvelope); + $this->assertEnvelopeMetadata($envelope); + $encryptedKey = $this->requireNonEmptyEnvelopeString($envelope, 'encrypted_key'); + $encryptedData = $this->requireNonEmptyEnvelopeString($envelope, 'encrypted_data'); + + return $this->projectDecodedEnvelope($envelope, $encryptedData, $encryptedKey); + } + + private function intOrNull(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + /** * @param iterable|KeyRing $keys * @return list @@ -155,4 +320,38 @@ private function orderedKeyEntries(iterable|KeyRing $keys): array throw new DecryptionException($e->getMessage(), 0, $e); } } + + /** + * @param array $envelope + * @return array{encrypted_data: string, encrypted_key: string, v?: mixed, alg?: mixed, kid?: mixed, dek_alg?: mixed, created_at?: mixed, purpose?: mixed} + */ + private function projectDecodedEnvelope(array $envelope, string $encryptedData, string $encryptedKey): array + { + $result = ['encrypted_data' => $encryptedData, 'encrypted_key' => $encryptedKey]; + foreach (['v', 'alg', 'kid', 'dek_alg', 'created_at', 'purpose'] as $key) { + if (array_key_exists($key, $envelope)) { + $result[$key] = $envelope[$key]; + } + } + + return $result; + } + + /** + * @param array $envelope + */ + private function requireNonEmptyEnvelopeString(array $envelope, string $field): string + { + $value = $envelope[$field] ?? null; + if (!is_string($value) || $value === '') { + throw new DecryptionException(sprintf('Envelope %s must be a non-empty string.', $field)); + } + + return $value; + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } } diff --git a/src/DataProtection/FileProtector.php b/src/DataProtection/FileProtector.php index 1a7941a..b38aa05 100644 --- a/src/DataProtection/FileProtector.php +++ b/src/DataProtection/FileProtector.php @@ -8,7 +8,7 @@ use Infocyph\Epicrypt\Crypto\SecretStream; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Exception\FileAccessException; -use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\KeyCandidates; use Infocyph\Epicrypt\Security\KeyRing; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; @@ -18,6 +18,8 @@ { public function __construct( private StreamAlgorithm $algorithm = StreamAlgorithm::XCHACHA20POLY1305, + private bool $allowUnauthenticatedStream = false, + private ?\Closure $renameOperation = null, ) {} public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN): self @@ -33,7 +35,7 @@ public function decrypt( bool $keyIsBinary = false, ): void { $this->assertReadableFile($inputPath); - $stream = new SecretStream($this->decodeKey($key, $keyIsBinary), $this->algorithm, ''); + $stream = new SecretStream($this->decodeKey($key, $keyIsBinary), $this->algorithm, '', $this->allowUnauthenticatedStream); $stream->decrypt($inputPath, $outputPath, $chunkSize); } @@ -70,7 +72,7 @@ public function encrypt( bool $keyIsBinary = false, ): int { $this->assertReadableFile($inputPath); - $stream = new SecretStream($this->decodeKey($key, $keyIsBinary), $this->algorithm, ''); + $stream = new SecretStream($this->decodeKey($key, $keyIsBinary), $this->algorithm, '', $this->allowUnauthenticatedStream); return $stream->encrypt($inputPath, $outputPath, $chunkSize); } @@ -109,17 +111,16 @@ public function reencryptInPlaceWithAnyKey( ): FileMigrationResult { $outputPath = $this->temporaryPathFor($path . '.rotated'); $result = $this->reencryptWithAnyKey($path, $outputPath, $keys, $newKey, $chunkSize, $keysAreBinary, $newKeyIsBinary); + $backupPath = $this->temporaryPathFor($path . '.backup'); + $backupCreated = false; - if (file_exists($path) && !unlink($path)) { - $this->deleteIfExists($outputPath); - - throw new FileAccessException('Unable to replace original file during in-place rotation: ' . $path); - } - - if (!rename($outputPath, $path)) { - $this->deleteIfExists($outputPath); - - throw new FileAccessException('Unable to finalize in-place rotation for file: ' . $path); + try { + $backupCreated = $this->createBackupIfPresent($path, $backupPath); + $this->finalizeRotation($outputPath, $path); + } catch (Throwable $e) { + $this->rollbackRotation($path, $outputPath, $backupPath, $backupCreated, $e); + } finally { + $this->cleanupBackupAfterSuccess($backupPath, $backupCreated, $path); } return new FileMigrationResult($path, $result->matchedKeyId, $result->usedFallbackKey); @@ -156,14 +157,33 @@ private function assertReadableFile(string $path): void } } - private function decodeKey(string $key, bool $keyIsBinary): string + private function cleanupBackupAfterSuccess(string $backupPath, bool $backupCreated, string $path): void + { + if ($backupCreated && file_exists($backupPath) && is_file($path)) { + $this->deleteIfExists($backupPath); + } + } + + private function createBackupIfPresent(string $path, string $backupPath): bool { - $decodedKey = $keyIsBinary ? $key : Base64Url::decode($key); - if (strlen($decodedKey) !== SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES) { - throw new InvalidKeyException('Stream key must be 32 bytes.'); + if (!file_exists($path)) { + return false; } - return $decodedKey; + if (!$this->renamePath($path, $backupPath)) { + throw new FileAccessException('Unable to create backup during in-place rotation: ' . $path); + } + + return true; + } + + private function decodeKey(string $key, bool $keyIsBinary): string + { + try { + return BinaryKey::aeadKey($key, $keyIsBinary, $this->algorithm->keyLength(), 'Stream key'); + } catch (InvalidKeyException $e) { + throw new InvalidKeyException(sprintf('Stream key must be %d bytes.', $this->algorithm->keyLength()), 0, $e); + } } private function deleteIfExists(string $path): void @@ -173,6 +193,13 @@ private function deleteIfExists(string $path): void } } + private function finalizeRotation(string $outputPath, string $path): void + { + if (!$this->renamePath($outputPath, $path)) { + throw new FileAccessException('Unable to finalize in-place rotation for file: ' . $path); + } + } + /** * @param iterable|KeyRing $keys * @return list @@ -190,6 +217,45 @@ private function orderedKeyEntries(iterable|KeyRing $keys): array } } + private function removePathForRollback(string $path): bool + { + if (is_file($path)) { + return unlink($path); + } + + if (is_dir($path)) { + return rmdir($path); + } + + return !file_exists($path); + } + + private function renamePath(string $from, string $to): bool + { + if ($this->renameOperation instanceof \Closure) { + return (bool) ($this->renameOperation)($from, $to); + } + + return rename($from, $to); + } + + private function rollbackRotation(string $path, string $outputPath, string $backupPath, bool $backupCreated, Throwable $cause): never + { + $this->deleteIfExists($outputPath); + + if ($backupCreated && file_exists($backupPath)) { + if (file_exists($path) && !$this->removePathForRollback($path)) { + throw new FileAccessException('Rollback failed while preparing path restoration: ' . $path, 0, $cause); + } + + if (!file_exists($path) && !$this->renamePath($backupPath, $path)) { + throw new FileAccessException('Rollback failed while restoring backup: ' . $path, 0, $cause); + } + } + + throw new FileAccessException('Unable to complete in-place file rotation.', 0, $cause); + } + private function temporaryPathFor(string $targetPath): string { $directory = dirname($targetPath); diff --git a/src/DataProtection/ProtectionAad.php b/src/DataProtection/ProtectionAad.php new file mode 100644 index 0000000..a722402 --- /dev/null +++ b/src/DataProtection/ProtectionAad.php @@ -0,0 +1,34 @@ +cipher->decrypt($ciphertext, $key, ProtectionContext::normalize($context)); + return $this->cipher->decrypt($ciphertext, $key, ProtectionContext::fromArray($context)->toArray()); } /** @@ -47,7 +50,11 @@ public function decryptWithAnyKey(string $ciphertext, iterable|KeyRing $keys, ar */ public function decryptWithAnyKeyResult(string $ciphertext, iterable|KeyRing $keys, array $context = []): StringUnprotectResult { - $normalized = ProtectionContext::normalize($context); + if ($keys instanceof KeyRing) { + return $this->decryptWithKeyRingResult($ciphertext, $keys, $context); + } + + $normalized = ProtectionContext::fromArray($context)->toArray(); $lastException = null; foreach ($this->orderedKeyEntries($keys) as $entry) { @@ -65,12 +72,118 @@ public function decryptWithAnyKeyResult(string $ciphertext, iterable|KeyRing $ke throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); } + /** + * @param array $context + */ + public function decryptWithKeyRing(string $ciphertext, KeyRing $keyRing, array $context = []): string + { + return $this->decryptWithKeyRingResult($ciphertext, $keyRing, $context)->plaintext; + } + + /** + * @param array $context + */ + public function decryptWithKeyRingResult(string $ciphertext, KeyRing $keyRing, array $context = []): StringUnprotectResult + { + $normalized = ProtectionContext::fromArray($context)->toArray(); + $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + + if ($compactPayload !== null && $compactPayload->algorithm === SecretBoxCipher::ALGORITHM_ID && $compactPayload->keyId !== null) { + $key = $keyRing->keys()[$compactPayload->keyId] ?? null; + if ($key === null) { + throw new DecryptionException(sprintf('Protected payload key id "%s" was not found in the key ring.', $compactPayload->keyId)); + } + + try { + return new StringUnprotectResult( + $this->decrypt($ciphertext, $key, $normalized), + $compactPayload->keyId, + false, + ); + } catch (Throwable $e) { + throw new DecryptionException(sprintf('Unable to decrypt protected string with key id "%s".', $compactPayload->keyId), 0, $e); + } + } + + $lastException = null; + foreach ($this->orderedKeyEntries($keyRing) as $entry) { + try { + return new StringUnprotectResult( + $this->decrypt($ciphertext, $entry['key'], $normalized), + $entry['id'], + !$entry['active'], + ); + } catch (Throwable $e) { + $lastException = $e; + } + } + + throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); + } + /** * @param array $context */ public function encrypt(string $plaintext, mixed $key, array $context = []): string { - return $this->cipher->encrypt($plaintext, $key, ProtectionContext::normalize($context)); + return $this->cipher->encrypt($plaintext, $key, ProtectionContext::fromArray($context)->toArray()); + } + + /** + * @param array $context + */ + public function encryptWithKeyRing(string $plaintext, KeyRing $keyRing, array $context = []): string + { + $activeKey = $keyRing->activeKey(); + $activeKeyId = $keyRing->activeKeyId(); + if ($activeKey === null || $activeKeyId === null) { + throw new ConfigurationException('Key ring active key id is required for protected-string encryption.'); + } + + $normalized = ProtectionContext::fromArray($context); + + return $this->encrypt( + $plaintext, + $activeKey, + array_merge($normalized->toArray(), ['key_id' => $activeKeyId]), + ); + } + + public function inspect(string $ciphertext): StringProtectInspectResult + { + $payload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + if ($payload !== null) { + return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, $payload->algorithm, $payload->keyId); + } + + $legacy = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); + if ($legacy === null) { + throw new DecryptionException('Invalid protected string format.'); + } + + return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, SecretBoxCipher::ALGORITHM_ID, null); + } + + public function needsReencrypt(string $ciphertext, ?string $activeKeyId = null): bool + { + $info = $this->inspect($ciphertext); + + if ($info->algorithm !== SecretBoxCipher::ALGORITHM_ID) { + return true; + } + + return $this->needsRotation($ciphertext, $activeKeyId); + } + + public function needsRotation(string $ciphertext, ?string $activeKeyId): bool + { + if ($activeKeyId === null || $activeKeyId === '') { + return false; + } + + $info = $this->inspect($ciphertext); + + return $info->keyId === null || !hash_equals($activeKeyId, $info->keyId); } /** diff --git a/src/DataProtection/Support/ProtectionContext.php b/src/DataProtection/Support/ProtectionContext.php index 9928783..5198c25 100644 --- a/src/DataProtection/Support/ProtectionContext.php +++ b/src/DataProtection/Support/ProtectionContext.php @@ -9,13 +9,20 @@ /** * @internal */ -final class ProtectionContext +final readonly class ProtectionContext { + public function __construct( + public bool $keyIsBinary = false, + public bool $nonceIsBinary = false, + public string $aad = '', + public ?string $keyId = null, + public ?string $purpose = null, + ) {} + /** * @param array $context - * @return array */ - public static function normalize(array $context): array + public static function fromArray(array $context): self { $keyIsBinary = $context['key_is_binary'] ?? false; if (!is_bool($keyIsBinary)) { @@ -32,10 +39,47 @@ public static function normalize(array $context): array throw new ConfigurationException('Protection context aad must be a string.'); } - $context['key_is_binary'] = $keyIsBinary; - $context['nonce_is_binary'] = $nonceIsBinary; - $context['aad'] = $aad; + $keyId = $context['key_id'] ?? null; + if ($keyId !== null && (!is_string($keyId) || $keyId === '')) { + throw new ConfigurationException('Protection context key_id must be a non-empty string when provided.'); + } + + $purpose = $context['purpose'] ?? null; + if ($purpose !== null && (!is_string($purpose) || $purpose === '')) { + throw new ConfigurationException('Protection context purpose must be a non-empty string when provided.'); + } + + return new self($keyIsBinary, $nonceIsBinary, $aad, $keyId, $purpose); + } + + /** + * @param array $context + * @return array + */ + public static function normalize(array $context): array + { + return array_merge($context, self::fromArray($context)->toArray()); + } + + /** + * @return array{key_is_binary: bool, nonce_is_binary: bool, aad: string, key_id?: string, purpose?: string} + */ + public function toArray(): array + { + $normalized = [ + 'key_is_binary' => $this->keyIsBinary, + 'nonce_is_binary' => $this->nonceIsBinary, + 'aad' => $this->aad, + ]; + + if ($this->keyId !== null) { + $normalized['key_id'] = $this->keyId; + } + + if ($this->purpose !== null) { + $normalized['purpose'] = $this->purpose; + } - return $context; + return $normalized; } } diff --git a/src/Exception/Token/SignatureEncodingException.php b/src/Exception/Token/SignatureEncodingException.php new file mode 100644 index 0000000..d763447 --- /dev/null +++ b/src/Exception/Token/SignatureEncodingException.php @@ -0,0 +1,7 @@ + $context + */ + public static function fromArray(array $context): self + { + $profile = $context['profile'] ?? SecurityProfile::MODERN; + if (!$profile instanceof SecurityProfile) { + throw new ConfigurationException('Derivation profile must be a SecurityProfile enum.'); + } + + $saltIsBinary = self::bool($context, 'salt_is_binary', false); + $asBase64Url = self::bool($context, 'as_base64url', true); + $inputKeyMaterialIsBinary = self::bool($context, 'input_key_material_is_binary', false); + $rootKeyIsBinary = self::bool($context, 'root_key_is_binary', false); + + $opslimit = self::nullablePositiveInt($context, 'opslimit'); + $memlimit = self::nullablePositiveInt($context, 'memlimit'); + + $algorithm = self::string($context, 'algorithm', 'sha256'); + $info = self::string($context, 'info', ''); + $sodiumContext = self::string($context, 'context', 'EPCKDF01'); + + $salt = $context['salt'] ?? null; + if ($salt !== null && !is_string($salt)) { + throw new ConfigurationException('HKDF salt must be a string.'); + } + + return new self( + profile: $profile, + saltIsBinary: $saltIsBinary, + asBase64Url: $asBase64Url, + inputKeyMaterialIsBinary: $inputKeyMaterialIsBinary, + rootKeyIsBinary: $rootKeyIsBinary, + opslimit: $opslimit, + memlimit: $memlimit, + algorithm: $algorithm, + info: $info, + sodiumContext: $sodiumContext, + salt: $salt, + ); + } + + /** + * @param array $context + */ + private static function bool(array $context, string $key, bool $default): bool + { + $value = $context[$key] ?? $default; + if (!is_bool($value)) { + throw new ConfigurationException(sprintf('Context value "%s" must be boolean.', $key)); + } + + return $value; + } + + /** + * @param array $context + */ + private static function nullablePositiveInt(array $context, string $key): ?int + { + if (!array_key_exists($key, $context) || $context[$key] === null) { + return null; + } + + if (!is_int($context[$key]) || $context[$key] < 1) { + throw new ConfigurationException(sprintf('Context value "%s" must be a positive integer when provided.', $key)); + } + + return $context[$key]; + } + + /** + * @param array $context + */ + private static function string(array $context, string $key, string $default): string + { + $value = $context[$key] ?? $default; + if (!is_string($value)) { + throw new ConfigurationException(sprintf('Context value "%s" must be a string.', $key)); + } + + return $value; + } +} diff --git a/src/Generate/KeyMaterial/KeyDeriver.php b/src/Generate/KeyMaterial/KeyDeriver.php index 2cf8c11..eba3943 100644 --- a/src/Generate/KeyMaterial/KeyDeriver.php +++ b/src/Generate/KeyMaterial/KeyDeriver.php @@ -7,17 +7,19 @@ use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Generate\Support\LengthGuard; use Infocyph\Epicrypt\Internal\Base64Url; -use Infocyph\Epicrypt\Security\Policy\SecurityProfile; +use Infocyph\Epicrypt\Internal\BinaryKey; +use Infocyph\Epicrypt\Internal\HashAlgorithm; final class KeyDeriver { /** - * @param array $context + * @param array|KeyDerivationContext $context */ - public function deriveFromPassword(string $password, string $salt, int $length = 32, array $context = []): string + public function deriveFromPassword(string $password, string $salt, int $length = 32, array|KeyDerivationContext $context = []): string { - $profile = $this->profileFromContext($context); - $saltBinary = $this->decodeMaybeBinary($salt, $this->boolFromContext($context, 'salt_is_binary'), 'Salt'); + $derivationContext = $this->normalizeContext($context); + $profile = $derivationContext->profile; + $saltBinary = $this->decodeMaybeBinary($salt, $derivationContext->saltIsBinary, 'Salt'); if (strlen($saltBinary) !== SODIUM_CRYPTO_PWHASH_SALTBYTES) { throw new ConfigurationException(sprintf('Salt must be %d bytes.', SODIUM_CRYPTO_PWHASH_SALTBYTES)); } @@ -26,62 +28,54 @@ public function deriveFromPassword(string $password, string $salt, int $length = LengthGuard::atLeastOne($length, 'Derived key length'), $password, $saltBinary, - $this->intFromContext($context, 'opslimit', $profile->passwordDerivationOpsLimit()), - $this->intFromContext($context, 'memlimit', $profile->passwordDerivationMemLimit()), + $derivationContext->opslimit ?? $profile->passwordDerivationOpsLimit(), + $derivationContext->memlimit ?? $profile->passwordDerivationMemLimit(), SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13, ); - return $this->formatOutput($derived, $this->boolFromContext($context, 'as_base64url', true)); + return $this->formatOutput($derived, $derivationContext->asBase64Url); } /** - * @param array $context + * @param array|KeyDerivationContext $context */ - public function hkdf(string $inputKeyMaterial, int $length = 32, array $context = []): string + public function hkdf(string $inputKeyMaterial, int $length = 32, array|KeyDerivationContext $context = []): string { + $derivationContext = $this->normalizeContext($context); $ikmBinary = $this->decodeMaybeBinary( $inputKeyMaterial, - $this->boolFromContext($context, 'input_key_material_is_binary'), + $derivationContext->inputKeyMaterialIsBinary, 'Input key material', ); $saltBinary = ''; - if (array_key_exists('salt', $context)) { - $salt = $context['salt']; - if (!is_string($salt)) { - throw new ConfigurationException('HKDF salt must be a string.'); - } - - $saltBinary = $this->decodeMaybeBinary($salt, $this->boolFromContext($context, 'salt_is_binary'), 'Salt'); - } - - $info = $context['info'] ?? ''; - if (!is_string($info)) { - throw new ConfigurationException('HKDF info must be a string.'); + if ($derivationContext->salt !== null) { + $saltBinary = $this->decodeMaybeBinary($derivationContext->salt, $derivationContext->saltIsBinary, 'Salt'); } $derived = hash_hkdf( - $this->normalizeHashAlgorithm($context['algorithm'] ?? 'sha256'), + $this->normalizeHashAlgorithm($derivationContext->algorithm), $ikmBinary, LengthGuard::atLeastOne($length, 'Derived key length'), - $info, + $derivationContext->info, $saltBinary, ); - return $this->formatOutput($derived, $this->boolFromContext($context, 'as_base64url', true)); + return $this->formatOutput($derived, $derivationContext->asBase64Url); } /** - * @param array $context + * @param array|KeyDerivationContext $context */ - public function subkey(string $rootKey, int $subkeyId, int $length = 32, array $context = []): string + public function subkey(string $rootKey, int $subkeyId, int $length = 32, array|KeyDerivationContext $context = []): string { - $rootKeyBinary = $this->decodeMaybeBinary($rootKey, $this->boolFromContext($context, 'root_key_is_binary'), 'Root key'); + $derivationContext = $this->normalizeContext($context); + $rootKeyBinary = $this->decodeMaybeBinary($rootKey, $derivationContext->rootKeyIsBinary, 'Root key'); if (strlen($rootKeyBinary) !== SODIUM_CRYPTO_KDF_KEYBYTES) { throw new ConfigurationException(sprintf('Root key must be %d bytes.', SODIUM_CRYPTO_KDF_KEYBYTES)); } - $sodiumContext = $context['context'] ?? 'EPCKDF01'; - if (!is_string($sodiumContext) || strlen($sodiumContext) !== SODIUM_CRYPTO_KDF_CONTEXTBYTES) { + $sodiumContext = $derivationContext->sodiumContext; + if (strlen($sodiumContext) !== SODIUM_CRYPTO_KDF_CONTEXTBYTES) { throw new ConfigurationException(sprintf('Subkey context must be exactly %d bytes.', SODIUM_CRYPTO_KDF_CONTEXTBYTES)); } @@ -100,25 +94,17 @@ public function subkey(string $rootKey, int $subkeyId, int $length = 32, array $ $derived = sodium_crypto_kdf_derive_from_key($requestedLength, $subkeyId, $sodiumContext, $rootKeyBinary); - return $this->formatOutput($derived, $this->boolFromContext($context, 'as_base64url', true)); + return $this->formatOutput($derived, $derivationContext->asBase64Url); } - /** - * @param array $context - */ - private function boolFromContext(array $context, string $key, bool $default = false): bool + private function decodeMaybeBinary(string $value, bool $isBinary, string $label): string { - $value = $context[$key] ?? $default; - if (!is_bool($value)) { - throw new ConfigurationException(sprintf('Context value "%s" must be boolean.', $key)); + try { + $decoded = BinaryKey::decodeBase64UrlOrBinary($value, $isBinary, $label); + } catch (\Throwable $e) { + throw new ConfigurationException(sprintf('%s must be a non-empty string.', $label), 0, $e); } - return $value; - } - - private function decodeMaybeBinary(string $value, bool $isBinary, string $label): string - { - $decoded = $isBinary ? $value : Base64Url::decode($value); if ($decoded === '') { throw new ConfigurationException(sprintf('%s must not be empty.', $label)); } @@ -132,16 +118,11 @@ private function formatOutput(string $derived, bool $asBase64Url): string } /** - * @param array $context + * @param array|KeyDerivationContext $context */ - private function intFromContext(array $context, string $key, int $default): int + private function normalizeContext(array|KeyDerivationContext $context): KeyDerivationContext { - $value = $context[$key] ?? $default; - if (!is_int($value) || $value < 1) { - throw new ConfigurationException(sprintf('Context value "%s" must be a positive integer.', $key)); - } - - return $value; + return $context instanceof KeyDerivationContext ? $context : KeyDerivationContext::fromArray($context); } /** @@ -153,19 +134,12 @@ private function normalizeHashAlgorithm(mixed $algorithm): string throw new ConfigurationException('HKDF algorithm must be a non-empty string.'); } - return $algorithm; - } - - /** - * @param array $context - */ - private function profileFromContext(array $context): SecurityProfile - { - $profile = $context['profile'] ?? SecurityProfile::MODERN; - if (!$profile instanceof SecurityProfile) { - throw new ConfigurationException('Derivation profile must be a SecurityProfile enum.'); + try { + HashAlgorithm::assertSupported($algorithm); + } catch (\InvalidArgumentException $e) { + throw new ConfigurationException($e->getMessage(), 0, $e); } - return $profile; + return $algorithm; } } diff --git a/src/Integrity/FileHasher.php b/src/Integrity/FileHasher.php index 38ad35d..066622e 100644 --- a/src/Integrity/FileHasher.php +++ b/src/Integrity/FileHasher.php @@ -6,6 +6,7 @@ use Infocyph\Epicrypt\Exception\FileAccessException; use Infocyph\Epicrypt\Exception\Integrity\HashingException; +use Infocyph\Epicrypt\Internal\HashAlgorithm; use Infocyph\Epicrypt\Internal\SecureCompare; final readonly class FileHasher @@ -45,8 +46,10 @@ public function hash(string $path, string $key = ''): string } } - if (!in_array($this->algorithm, hash_algos(), true)) { - throw new HashingException('Unsupported hash algorithm: ' . $this->algorithm); + try { + HashAlgorithm::assertSupported($this->algorithm); + } catch (\InvalidArgumentException $e) { + throw new HashingException($e->getMessage(), 0, $e); } $hash = $key === '' diff --git a/src/Integrity/StringHasher.php b/src/Integrity/StringHasher.php index 118beb7..31b2e1e 100644 --- a/src/Integrity/StringHasher.php +++ b/src/Integrity/StringHasher.php @@ -6,6 +6,7 @@ use Infocyph\Epicrypt\Exception\Integrity\HashingException; use Infocyph\Epicrypt\Integrity\Contract\HasherInterface; +use Infocyph\Epicrypt\Internal\HashAlgorithm; use Infocyph\Epicrypt\Internal\SecureCompare; final readonly class StringHasher implements HasherInterface @@ -45,8 +46,10 @@ public function hash(string $data, array $options = []): string return $binary ? $hash : sodium_bin2hex($hash); } - if (!in_array($this->algorithm, hash_algos(), true)) { - throw new HashingException('Unsupported hash algorithm: ' . $this->algorithm); + try { + HashAlgorithm::assertSupported($this->algorithm); + } catch (\InvalidArgumentException $e) { + throw new HashingException($e->getMessage(), 0, $e); } if ($key === '') { diff --git a/src/Internal/BinaryKey.php b/src/Internal/BinaryKey.php new file mode 100644 index 0000000..8980880 --- /dev/null +++ b/src/Internal/BinaryKey.php @@ -0,0 +1,90 @@ +asn1Seq !== $this->readAsn1Content($message, $position, $this->byteSize)) { - throw new Exception('Invalid data. Should start with a sequence.'); + throw new SignatureEncodingException('Invalid data. Should start with a sequence.'); } if ($this->asn1Length2Byte === $this->readAsn1Content($message, $position, $this->byteSize)) { @@ -45,7 +45,7 @@ public function fromAsn1(string $signature, int $length): string $bin = hex2bin(str_pad($pointR, $length, '0', STR_PAD_LEFT) . str_pad($pointS, $length, '0', STR_PAD_LEFT)); if (!is_string($bin)) { - throw new Exception('Unable to parse the data.'); + throw new SignatureEncodingException('Unable to parse the data.'); } return $bin; @@ -54,18 +54,18 @@ public function fromAsn1(string $signature, int $length): string /** * Convert JOSE signature to ASN1 string. * - * @throws Exception + * @throws SignatureEncodingException */ public function toAsn1(string $signature, int $length): string { $signature = bin2hex($signature); if ($this->octetLength($signature) !== $length) { - throw new Exception('Invalid signature length.'); + throw new SignatureEncodingException('Invalid signature length.'); } - $pointR = $this->preparePositiveInteger(mb_substr($signature, 0, $length, '8bit')); - $pointS = $this->preparePositiveInteger(mb_substr($signature, $length, null, '8bit')); + $pointR = $this->preparePositiveInteger(substr($signature, 0, $length)); + $pointS = $this->preparePositiveInteger(substr($signature, $length)); $lengthR = $this->octetLength($pointR); $lengthS = $this->octetLength($pointS); @@ -80,7 +80,7 @@ public function toAsn1(string $signature, int $length): string ); if (!is_string($bin)) { - throw new Exception('Data parsing failed.'); + throw new SignatureEncodingException('Data parsing failed.'); } return $bin; @@ -88,18 +88,18 @@ public function toAsn1(string $signature, int $length): string private function octetLength(string $data): int { - return (int) (mb_strlen($data, '8bit') / $this->byteSize); + return (int) (strlen($data) / $this->byteSize); } private function preparePositiveInteger(string $data): string { - if (mb_substr($data, 0, $this->byteSize, '8bit') > $this->asn1BigIntLimit) { + if (substr($data, 0, $this->byteSize) > $this->asn1BigIntLimit) { return $this->asn1NegativeInteger . $data; } - while (mb_strpos($data, $this->asn1NegativeInteger, 0, '8bit') === 0 - && mb_substr($data, 2, $this->byteSize, '8bit') <= $this->asn1BigIntLimit) { - $data = mb_substr($data, 2, null, '8bit'); + while (str_starts_with($data, $this->asn1NegativeInteger) + && substr($data, 2, $this->byteSize) <= $this->asn1BigIntLimit) { + $data = substr($data, 2); } return $data; @@ -107,19 +107,19 @@ private function preparePositiveInteger(string $data): string private function readAsn1Content(string $message, int &$position, int $length): string { - $content = mb_substr($message, $position, $length, '8bit'); + $content = substr($message, $position, $length); $position += $length; return $content; } /** - * @throws Exception + * @throws SignatureEncodingException */ private function readAsn1Integer(string $message, int &$position): string { if ($this->asn1Int !== $this->readAsn1Content($message, $position, $this->byteSize)) { - throw new Exception('Invalid data. Should contain an integer.'); + throw new SignatureEncodingException('Invalid data. Should contain an integer.'); } $length = (int) hexdec($this->readAsn1Content($message, $position, $this->byteSize)); @@ -129,9 +129,9 @@ private function readAsn1Integer(string $message, int &$position): string private function retrievePositiveInteger(string $data): string { - while (mb_strpos($data, $this->asn1NegativeInteger, 0, '8bit') === 0 - && mb_substr($data, 2, $this->byteSize, '8bit') > $this->asn1BigIntLimit) { - $data = mb_substr($data, 2, null, '8bit'); + while (str_starts_with($data, $this->asn1NegativeInteger) + && substr($data, 2, $this->byteSize) > $this->asn1BigIntLimit) { + $data = substr($data, 2); } return $data; diff --git a/src/Internal/HashAlgorithm.php b/src/Internal/HashAlgorithm.php new file mode 100644 index 0000000..41f7bc6 --- /dev/null +++ b/src/Internal/HashAlgorithm.php @@ -0,0 +1,28 @@ +|null + */ + private static ?array $supported = null; + + public static function assertSupported(string $algorithm): void + { + if (!self::isSupported($algorithm)) { + throw new \InvalidArgumentException('Unsupported hash algorithm: ' . $algorithm); + } + } + + public static function isSupported(string $algorithm): bool + { + return in_array($algorithm, self::$supported ??= hash_algos(), true); + } +} diff --git a/src/Internal/SignedPayloadCodec.php b/src/Internal/SignedPayloadCodec.php index 2db254f..2ecaf77 100644 --- a/src/Internal/SignedPayloadCodec.php +++ b/src/Internal/SignedPayloadCodec.php @@ -6,6 +6,8 @@ use Infocyph\Epicrypt\Exception\Token\ExpiredTokenException; use Infocyph\Epicrypt\Exception\Token\InvalidTokenException; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Internal\Enum\SignedPayloadAlgorithm; use Infocyph\Epicrypt\Internal\Enum\SignedPayloadVersion; @@ -17,6 +19,7 @@ public function __construct( private string $secret, private SignedPayloadAlgorithm $algorithm = SignedPayloadAlgorithm::SHA512, + private ClockInterface $clock = new SystemClock(), ) { if ($this->secret === '') { throw new InvalidTokenException('Signed payload secret must be non-empty.'); @@ -39,7 +42,7 @@ public function issue(array $claims, ?int $expiresAt = null, ?string $type = nul } $payload = $claims; - $payload['iat'] = time(); + $payload['iat'] = $this->clock->now(); if ($expiresAt !== null) { $payload['exp'] = $expiresAt; } @@ -78,9 +81,7 @@ public function verify(string $token, ?string $expectedType = null): array } $payload = Json::decodeToArray(Base64Url::decode($encodedPayload)); - if (isset($payload['exp']) && is_numeric($payload['exp']) && time() > (int) $payload['exp']) { - throw new ExpiredTokenException('Token has expired.'); - } + $this->validateTemporalClaims($payload); return $payload; } @@ -89,4 +90,36 @@ private function sign(string $value): string { return Base64Url::encode(hash_hmac($this->algorithm->value, $value, $this->secret, true)); } + + /** + * @param array $payload + */ + private function validateTemporalClaims(array $payload): void + { + $now = $this->clock->now(); + + if (array_key_exists('iat', $payload) && !is_numeric($payload['iat'])) { + throw new InvalidTokenException('Invalid iat claim.'); + } + + if (array_key_exists('nbf', $payload)) { + if (!is_numeric($payload['nbf'])) { + throw new InvalidTokenException('Invalid nbf claim.'); + } + + if ($now < (int) $payload['nbf']) { + throw new InvalidTokenException('Token is not yet valid.'); + } + } + + if (array_key_exists('exp', $payload)) { + if (!is_numeric($payload['exp'])) { + throw new InvalidTokenException('Invalid exp claim.'); + } + + if ($now > (int) $payload['exp']) { + throw new ExpiredTokenException('Token has expired.'); + } + } + } } diff --git a/src/Internal/VersionedPayload.php b/src/Internal/VersionedPayload.php index 941a51e..bed0eff 100644 --- a/src/Internal/VersionedPayload.php +++ b/src/Internal/VersionedPayload.php @@ -6,32 +6,70 @@ final class VersionedPayload { + public const string EMPTY_KEY_ID = '_'; + public static function encode(string $version, string ...$parts): string { return implode('.', array_merge([$version], $parts)); } - /** - * @return array{bool, array}|null - */ - public static function parse(string $payload, string $expectedVersion, int $partCount): ?array + public static function encodeCompact(string $version, string $algorithm, ?string $keyId, string $nonce, string $ciphertext): string + { + if ($algorithm === '' || str_contains($algorithm, '.')) { + throw new \InvalidArgumentException('Payload algorithm must be a non-empty dot-safe string.'); + } + + if ($nonce === '' || $ciphertext === '') { + throw new \InvalidArgumentException('Payload nonce and ciphertext must be non-empty strings.'); + } + + return self::encode( + $version, + $algorithm, + self::normalizeKeyIdForEncoding($keyId), + $nonce, + $ciphertext, + ); + } + + public static function parse(string $payload, string $expectedVersion, int $partCount): ?VersionedPayloadResult { $segments = explode('.', $payload); + $firstSegment = $segments[0]; if (count($segments) === ($partCount + 1) && $segments[0] === $expectedVersion) { $versionedParts = array_slice($segments, 1); if (self::allNonEmpty($versionedParts)) { - return [true, $versionedParts]; + return new VersionedPayloadResult(true, $versionedParts); } + + return null; + } + + if ($firstSegment === $expectedVersion) { + return null; } if (count($segments) === $partCount && self::allNonEmpty($segments)) { - return [false, $segments]; + return new VersionedPayloadResult(false, $segments); } return null; } + public static function parseCompact(string $payload, string $expectedVersion): ?CompactPayloadResult + { + $parsedPayload = self::parse($payload, $expectedVersion, 4); + if ($parsedPayload === null) { + return null; + } + + [$algorithm, $encodedKeyId, $nonce, $ciphertext] = $parsedPayload->parts; + $keyId = $encodedKeyId === self::EMPTY_KEY_ID ? null : $encodedKeyId; + + return new CompactPayloadResult($parsedPayload->versioned, $algorithm, $keyId, $nonce, $ciphertext); + } + /** * @param array $segments */ @@ -39,4 +77,21 @@ private static function allNonEmpty(array $segments): bool { return array_all($segments, fn($segment) => !($segment === '')); } + + private static function normalizeKeyIdForEncoding(?string $keyId): string + { + if ($keyId === null) { + return self::EMPTY_KEY_ID; + } + + if ($keyId === '' || str_contains($keyId, '.')) { + throw new \InvalidArgumentException('Payload key id must be a non-empty dot-safe string.'); + } + + if ($keyId === self::EMPTY_KEY_ID) { + throw new \InvalidArgumentException(sprintf('Payload key id "%s" is reserved.', self::EMPTY_KEY_ID)); + } + + return $keyId; + } } diff --git a/src/Internal/VersionedPayloadResult.php b/src/Internal/VersionedPayloadResult.php new file mode 100644 index 0000000..faa005e --- /dev/null +++ b/src/Internal/VersionedPayloadResult.php @@ -0,0 +1,19 @@ + $parts + */ + public function __construct( + public bool $versioned, + public array $parts, + ) {} +} diff --git a/src/Password/Contract/CompromisedPasswordCheckerInterface.php b/src/Password/Contract/CompromisedPasswordCheckerInterface.php new file mode 100644 index 0000000..5fb0aff --- /dev/null +++ b/src/Password/Contract/CompromisedPasswordCheckerInterface.php @@ -0,0 +1,10 @@ +pick($pool); } - shuffle($passwordChars); + $passwordChars = $this->secureShuffle($passwordChars); return implode('', $passwordChars); } @@ -116,4 +116,18 @@ private function pick(string $characters): string return $characters[random_int(0, strlen($characters) - 1)]; } + + /** + * @param array $items + * @return array + */ + private function secureShuffle(array $items): array + { + for ($index = count($items) - 1; $index > 0; --$index) { + $swapIndex = random_int(0, $index); + [$items[$index], $items[$swapIndex]] = [$items[$swapIndex], $items[$index]]; + } + + return $items; + } } diff --git a/src/Password/NullCompromisedPasswordChecker.php b/src/Password/NullCompromisedPasswordChecker.php new file mode 100644 index 0000000..edd2fbd --- /dev/null +++ b/src/Password/NullCompromisedPasswordChecker.php @@ -0,0 +1,17 @@ + $this->intOption($mergedOptions, 'memory_cost', SecurityPolicy::PASSWORD_DEFAULT_MEMORY_COST), - 'time_cost' => $this->intOption($mergedOptions, 'time_cost', SecurityPolicy::PASSWORD_DEFAULT_TIME_COST), - 'threads' => $this->intOption($mergedOptions, 'threads', SecurityPolicy::PASSWORD_DEFAULT_THREADS), - ], + $this->resolveHashOptions($algorithm, $mergedOptions), ]; } + + /** + * @param array $options + * @return array + */ + private function resolveHashOptions(PasswordHashAlgorithm $algorithm, array $options): array + { + return match ($algorithm) { + PasswordHashAlgorithm::BCRYPT => [ + 'cost' => $this->intOption($options, 'cost', 12), + ], + PasswordHashAlgorithm::ARGON2I, + PasswordHashAlgorithm::ARGON2ID => [ + 'memory_cost' => $this->intOption($options, 'memory_cost', SecurityPolicy::PASSWORD_DEFAULT_MEMORY_COST), + 'time_cost' => $this->intOption($options, 'time_cost', SecurityPolicy::PASSWORD_DEFAULT_TIME_COST), + 'threads' => $this->intOption($options, 'threads', SecurityPolicy::PASSWORD_DEFAULT_THREADS), + ], + }; + } } diff --git a/src/Password/PasswordPolicyResult.php b/src/Password/PasswordPolicyResult.php new file mode 100644 index 0000000..cc8f9dd --- /dev/null +++ b/src/Password/PasswordPolicyResult.php @@ -0,0 +1,17 @@ + $violations + */ + public function __construct( + public bool $valid, + public int $score, + public array $violations = [], + ) {} +} diff --git a/src/Password/PasswordPolicyValidator.php b/src/Password/PasswordPolicyValidator.php new file mode 100644 index 0000000..df91887 --- /dev/null +++ b/src/Password/PasswordPolicyValidator.php @@ -0,0 +1,50 @@ +minLength) { + $violations[] = 'too_short'; + } + + if ($policy->requireUpper && preg_match('/[A-Z]/', $password) !== 1) { + $violations[] = 'missing_upper'; + } + + if ($policy->requireLower && preg_match('/[a-z]/', $password) !== 1) { + $violations[] = 'missing_lower'; + } + + if ($policy->requireDigit && preg_match('/\d/', $password) !== 1) { + $violations[] = 'missing_digit'; + } + + if ($policy->requireSymbol && preg_match('/[^a-zA-Z\d]/', $password) !== 1) { + $violations[] = 'missing_symbol'; + } + + if (!$policy->includeAmbiguous && preg_match('/[Il]/', $password) === 1) { + $violations[] = 'contains_ambiguous'; + } + + return new PasswordPolicyResult( + valid: $violations === [], + score: $this->strength->score($password), + violations: $violations, + ); + } +} diff --git a/src/Password/PasswordStrength.php b/src/Password/PasswordStrength.php index 18cf1ef..5c30b1a 100644 --- a/src/Password/PasswordStrength.php +++ b/src/Password/PasswordStrength.php @@ -6,13 +6,15 @@ final class PasswordStrength { - public function score(string $password): int + /** + * @param array{email?: mixed, username?: mixed} $context + */ + public function score(string $password, array $context = []): int { $score = 0; + $length = strlen($password); - if (strlen($password) >= 12) { - $score += 25; - } + $score += min(40, max(0, ($length - 8) * 4)); if (preg_match('/[A-Z]/', $password) === 1) { $score += 20; @@ -30,6 +32,82 @@ public function score(string $password): int $score += 15; } + $score -= $this->repetitionPenalty($password); + $score -= $this->sequentialPenalty($password); + $score -= $this->commonPatternPenalty($password); + $score -= $this->identityPenalty($password, $context); + return min($score, 100); } + + private function commonPatternPenalty(string $password): int + { + $lower = strtolower($password); + $patterns = ['password', 'welcome', 'admin', 'qwerty', 'letmein', '123456', 'iloveyou']; + foreach ($patterns as $pattern) { + if (str_contains($lower, $pattern)) { + return 20; + } + } + + return 0; + } + + /** + * @param array{email?: mixed, username?: mixed} $context + */ + private function identityPenalty(string $password, array $context): int + { + $passwordLower = strtolower($password); + $candidates = []; + + if (isset($context['email']) && is_string($context['email']) && $context['email'] !== '') { + $candidates[] = strtolower($context['email']); + $emailLocal = explode('@', strtolower($context['email']), 2)[0]; + if ($emailLocal !== '') { + $candidates[] = $emailLocal; + } + } + + if (isset($context['username']) && is_string($context['username']) && $context['username'] !== '') { + $candidates[] = strtolower($context['username']); + } + + foreach ($candidates as $candidate) { + if (str_contains($passwordLower, $candidate)) { + return 20; + } + } + + return 0; + } + + private function repetitionPenalty(string $password): int + { + $counts = count_chars($password, 1); + $penalty = 0; + foreach ($counts as $count) { + if ($count > 2) { + $penalty += ($count - 2) * 2; + } + } + + return min($penalty, 20); + } + + private function sequentialPenalty(string $password): int + { + $lower = strtolower($password); + $sequences = ['abcdefghijklmnopqrstuvwxyz', '0123456789', 'qwertyuiopasdfghjklzxcvbnm']; + foreach ($sequences as $sequence) { + for ($index = 0; $index <= strlen($sequence) - 4; $index++) { + $chunk = substr($sequence, $index, 4); + if (str_contains($lower, $chunk)) { + return 15; + } + } + } + + return 0; + } } diff --git a/src/Password/Secret/WrappedSecretManager.php b/src/Password/Secret/WrappedSecretManager.php index d255148..885f80d 100644 --- a/src/Password/Secret/WrappedSecretManager.php +++ b/src/Password/Secret/WrappedSecretManager.php @@ -6,6 +6,7 @@ use Infocyph\Epicrypt\Exception\Password\SecretProtectionException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\Enum\WrappedSecretVersion; use Infocyph\Epicrypt\Internal\KeyCandidates; use Infocyph\Epicrypt\Internal\VersionedPayload; @@ -13,6 +14,8 @@ final class WrappedSecretManager { + private const string ALGORITHM_ID = 'secretbox'; + public function rewrap( string $wrappedSecret, string $oldMasterSecret, @@ -42,16 +45,12 @@ public function rewrapWithAnyKey( public function unwrap(string $wrappedSecret, string $masterSecret, bool $masterSecretIsBinary = false): string { - [$encodedNonce, $encodedCipher] = $this->splitWrappedSecret($wrappedSecret); - $key = $masterSecretIsBinary ? $masterSecret : Base64Url::decode($masterSecret); - - if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { - throw new SecretProtectionException('Master secret must be 32 bytes long.'); - } + $payload = $this->parseWrappedPayload($wrappedSecret); + $key = $this->decodeMasterSecret($masterSecret, $masterSecretIsBinary); $plaintext = sodium_crypto_secretbox_open( - Base64Url::decode($encodedCipher), - Base64Url::decode($encodedNonce), + Base64Url::decode($payload['ciphertext']), + Base64Url::decode($payload['nonce']), $key, ); @@ -75,6 +74,30 @@ public function unwrapWithAnyKey(string $wrappedSecret, iterable|KeyRing $master */ public function unwrapWithAnyKeyResult(string $wrappedSecret, iterable|KeyRing $masterSecrets, bool $masterSecretsAreBinary = false): UnwrappedSecretResult { + if ($masterSecrets instanceof KeyRing) { + $payload = $this->parseWrappedPayload($wrappedSecret); + if ($payload['key_id'] !== null) { + $key = $masterSecrets->keys()[$payload['key_id']] ?? null; + if ($key === null) { + throw new SecretProtectionException(sprintf('Master secret key id "%s" was not found in the key ring.', $payload['key_id'])); + } + + try { + return new UnwrappedSecretResult( + $this->unwrap($wrappedSecret, $key, $masterSecretsAreBinary), + $payload['key_id'], + false, + ); + } catch (SecretProtectionException $e) { + throw new SecretProtectionException( + sprintf('Secret unwrap failed for key id "%s".', $payload['key_id']), + 0, + $e, + ); + } + } + } + $lastException = null; foreach ($this->orderedKeyEntries($masterSecrets) as $entry) { try { @@ -91,23 +114,52 @@ public function unwrapWithAnyKeyResult(string $wrappedSecret, iterable|KeyRing $ throw new SecretProtectionException('Secret unwrap failed for every supplied master secret.', 0, $lastException); } - public function wrap(string $secret, string $masterSecret, bool $masterSecretIsBinary = false): string + public function unwrapWithKeyRing(string $wrappedSecret, KeyRing $masterSecrets, bool $masterSecretsAreBinary = false): string { - $key = $masterSecretIsBinary ? $masterSecret : Base64Url::decode($masterSecret); - if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { - throw new SecretProtectionException('Master secret must be 32 bytes long.'); - } + return $this->unwrapWithKeyRingResult($wrappedSecret, $masterSecrets, $masterSecretsAreBinary)->plaintext; + } + + public function unwrapWithKeyRingResult(string $wrappedSecret, KeyRing $masterSecrets, bool $masterSecretsAreBinary = false): UnwrappedSecretResult + { + return $this->unwrapWithAnyKeyResult($wrappedSecret, $masterSecrets, $masterSecretsAreBinary); + } + + public function wrap(string $secret, string $masterSecret, bool $masterSecretIsBinary = false, ?string $keyId = null): string + { + $key = $this->decodeMasterSecret($masterSecret, $masterSecretIsBinary); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = sodium_crypto_secretbox($secret, $nonce, $key); - return VersionedPayload::encode( + return VersionedPayload::encodeCompact( WrappedSecretVersion::V1->value, + self::ALGORITHM_ID, + $keyId, Base64Url::encode($nonce), Base64Url::encode($ciphertext), ); } + public function wrapWithKeyRing(string $secret, KeyRing $keyRing, bool $masterSecretIsBinary = false): string + { + $activeKey = $keyRing->activeKey(); + $activeKeyId = $keyRing->activeKeyId(); + if ($activeKey === null || $activeKeyId === null) { + throw new SecretProtectionException('Key ring active key id is required for wrapped secret encryption.'); + } + + return $this->wrap($secret, $activeKey, $masterSecretIsBinary, $activeKeyId); + } + + private function decodeMasterSecret(string $masterSecret, bool $isBinary): string + { + try { + return BinaryKey::secretBoxKey($masterSecret, $isBinary, 'Master secret'); + } catch (\Throwable $e) { + throw new SecretProtectionException('Master secret must be 32 bytes long.', 0, $e); + } + } + /** * @param iterable|KeyRing $keys * @return list @@ -126,16 +178,32 @@ private function orderedKeyEntries(iterable|KeyRing $keys): array } /** - * @return array{string, string} + * @return array{nonce: string, ciphertext: string, key_id: ?string} */ - private function splitWrappedSecret(string $wrappedSecret): array + private function parseWrappedPayload(string $wrappedSecret): array { + $compactPayload = VersionedPayload::parseCompact($wrappedSecret, WrappedSecretVersion::V1->value); + if ($compactPayload !== null) { + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new SecretProtectionException('Unsupported wrapped secret algorithm.'); + } + + return [ + 'nonce' => $compactPayload->nonce, + 'ciphertext' => $compactPayload->ciphertext, + 'key_id' => $compactPayload->keyId, + ]; + } + $parsedPayload = VersionedPayload::parse($wrappedSecret, WrappedSecretVersion::V1->value, 2); if ($parsedPayload === null) { throw new SecretProtectionException('Invalid wrapped secret format.'); } - [, $parts] = $parsedPayload; - return [$parts[0], $parts[1]]; + return [ + 'nonce' => $parsedPayload->parts[0], + 'ciphertext' => $parsedPayload->parts[1], + 'key_id' => null, + ]; } } diff --git a/src/Security/ActionToken.php b/src/Security/ActionToken.php index 720718b..afd6905 100644 --- a/src/Security/ActionToken.php +++ b/src/Security/ActionToken.php @@ -4,6 +4,8 @@ namespace Infocyph\Epicrypt\Security; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; @@ -12,8 +14,9 @@ public function __construct( string $secret, int $ttlSeconds = 900, + ClockInterface $clock = new SystemClock(), ) { - parent::__construct($secret, $ttlSeconds); + parent::__construct($secret, $ttlSeconds, $clock); } /** diff --git a/src/Security/Contract/SignedUrlGeneratorInterface.php b/src/Security/Contract/SignedUrlGeneratorInterface.php index b072016..3388cd7 100644 --- a/src/Security/Contract/SignedUrlGeneratorInterface.php +++ b/src/Security/Contract/SignedUrlGeneratorInterface.php @@ -4,10 +4,12 @@ namespace Infocyph\Epicrypt\Security\Contract; +use Infocyph\Epicrypt\Security\SignedUrlOptions; + interface SignedUrlGeneratorInterface { /** * @param array $parameters */ - public function generate(string $url, array $parameters = [], ?int $expiresAt = null): string; + public function generate(string $url, array $parameters = [], ?int $expiresAt = null, ?SignedUrlOptions $options = null): string; } diff --git a/src/Security/Contract/SignedUrlVerifierInterface.php b/src/Security/Contract/SignedUrlVerifierInterface.php index f3c6750..7534192 100644 --- a/src/Security/Contract/SignedUrlVerifierInterface.php +++ b/src/Security/Contract/SignedUrlVerifierInterface.php @@ -4,7 +4,9 @@ namespace Infocyph\Epicrypt\Security\Contract; +use Infocyph\Epicrypt\Security\SignedUrlOptions; + interface SignedUrlVerifierInterface { - public function verify(string $signedUrl): bool; + public function verify(string $signedUrl, ?SignedUrlOptions $options = null): bool; } diff --git a/src/Security/CsrfTokenManager.php b/src/Security/CsrfTokenManager.php index bbe54ff..fb23dfa 100644 --- a/src/Security/CsrfTokenManager.php +++ b/src/Security/CsrfTokenManager.php @@ -5,6 +5,8 @@ namespace Infocyph\Epicrypt\Security; use Infocyph\Epicrypt\Exception\Token\TokenException; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Internal\SignedPayloadCodec; use Infocyph\Epicrypt\Security\Contract\CsrfTokenManagerInterface; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; @@ -16,8 +18,9 @@ public function __construct( string $secret, private int $ttlSeconds = 3600, + private ClockInterface $clock = new SystemClock(), ) { - $this->codec = new SignedPayloadCodec($secret); + $this->codec = new SignedPayloadCodec($secret, clock: $this->clock); } public function issueToken(string $sessionId): string @@ -27,7 +30,7 @@ public function issueToken(string $sessionId): string return $this->codec->issue([ 'sid' => $sessionId, 'nonce' => bin2hex(random_bytes(16)), - ], time() + $this->ttlSeconds, $purpose); + ], $this->clock->now() + $this->ttlSeconds, $purpose); } public function verifyToken(string $sessionId, string $token): bool diff --git a/src/Security/EmailVerificationToken.php b/src/Security/EmailVerificationToken.php index 56667ba..59db495 100644 --- a/src/Security/EmailVerificationToken.php +++ b/src/Security/EmailVerificationToken.php @@ -4,6 +4,8 @@ namespace Infocyph\Epicrypt\Security; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; @@ -12,8 +14,9 @@ public function __construct( string $secret, int $ttlSeconds = 86400, + ClockInterface $clock = new SystemClock(), ) { - parent::__construct($secret, $ttlSeconds); + parent::__construct($secret, $ttlSeconds, $clock); } public function issue(string $userId, string $email): string diff --git a/src/Security/PasswordResetToken.php b/src/Security/PasswordResetToken.php index 004c32c..a70f295 100644 --- a/src/Security/PasswordResetToken.php +++ b/src/Security/PasswordResetToken.php @@ -4,6 +4,8 @@ namespace Infocyph\Epicrypt\Security; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; @@ -12,8 +14,9 @@ public function __construct( string $secret, int $ttlSeconds = 1800, + ClockInterface $clock = new SystemClock(), ) { - parent::__construct($secret, $ttlSeconds); + parent::__construct($secret, $ttlSeconds, $clock); } public function issue(string $userId): string diff --git a/src/Security/RememberToken.php b/src/Security/RememberToken.php index 3914a77..bce47a1 100644 --- a/src/Security/RememberToken.php +++ b/src/Security/RememberToken.php @@ -4,6 +4,8 @@ namespace Infocyph\Epicrypt\Security; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; @@ -12,8 +14,9 @@ public function __construct( string $secret, int $ttlSeconds = 1209600, + ClockInterface $clock = new SystemClock(), ) { - parent::__construct($secret, $ttlSeconds); + parent::__construct($secret, $ttlSeconds, $clock); } public function issue(string $userId, string $deviceId): string diff --git a/src/Security/SignedUrl.php b/src/Security/SignedUrl.php index a7558fa..98e5a76 100644 --- a/src/Security/SignedUrl.php +++ b/src/Security/SignedUrl.php @@ -6,102 +6,222 @@ use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Internal\Enum\SignedUrlVersion; use Infocyph\Epicrypt\Internal\SecureCompare; use Infocyph\Epicrypt\Internal\SecurityPolicy; use Infocyph\Epicrypt\Security\Contract\SignedUrlGeneratorInterface; use Infocyph\Epicrypt\Security\Contract\SignedUrlVerifierInterface; +use Infocyph\Epicrypt\Security\Support\SignedUrlGuard; +/** + * @phpstan-type QueryScalar bool|float|int|string + * @phpstan-type QueryArray array + * @phpstan-type QueryMap array + */ final readonly class SignedUrl implements SignedUrlGeneratorInterface, SignedUrlVerifierInterface { + private const string METHOD_PARAM = 'ep_m'; + public function __construct( private string $secret, private string $signatureParam = 'ep_sig', private string $expiresParam = 'ep_exp', private string $versionParam = SecurityPolicy::SIGNED_URL_VERSION_PARAM, + private ClockInterface $clock = new SystemClock(), + private SignedUrlOptions $defaultOptions = new SignedUrlOptions(), ) {} /** * @param array $parameters */ - public function generate(string $url, array $parameters = [], ?int $expiresAt = null): string + public function generate(string $url, array $parameters = [], ?int $expiresAt = null, ?SignedUrlOptions $options = null): string { + $options ??= $this->defaultOptions; + [$parts, $existing] = $this->parseUrlWithQueryOrFail($url); + $this->assertUrlPolicy($parts, $options, throwOnFailure: true); $merged = array_merge($existing, $parameters); + $normalized = $this->normalizeQuery($merged, $options->allowArrayParameters); + if ($normalized === null) { + throw new ConfigurationException('Signed URL query parameters contain unsupported values.'); + } + + $merged = $normalized; $merged[$this->versionParam] = SignedUrlVersion::V1->value; if ($expiresAt !== null) { $merged[$this->expiresParam] = $expiresAt; } + if ($options->method !== null) { + $merged[self::METHOD_PARAM] = $options->method; + } - $basePath = $this->buildBasePath($parts); - $signature = $this->computeSignature($basePath, $merged); + $merged = $this->normalizeQuery($merged, $options->allowArrayParameters); + if ($merged === null) { + throw new ConfigurationException('Signed URL query parameters contain unsupported values.'); + } + + $signatureBasePath = $this->buildSignatureBasePath($parts, $options); + $signature = $this->computeSignature($signatureBasePath, $merged); $merged[$this->signatureParam] = $signature; - return $basePath . '?' . http_build_query($merged); + return $this->buildDisplayBasePath($parts) . '?' . $this->buildQueryString($merged); } - public function verify(string $signedUrl): bool + public function verify(string $signedUrl, ?SignedUrlOptions $options = null): bool { + return $this->verifyResult($signedUrl, $options)->verified; + } + + public function verifyResult(string $signedUrl, ?SignedUrlOptions $options = null): SignedUrlVerificationResult + { + $options ??= $this->defaultOptions; + $parsed = $this->parseUrlWithQuery($signedUrl); if ($parsed === null) { - return false; + return SignedUrlGuard::invalidSignatureResult(); } [$parts, $query] = $parsed; - - $givenSignature = $query[$this->signatureParam] ?? null; - if (!is_string($givenSignature) || $givenSignature === '') { - return false; + if (!$this->assertUrlPolicy($parts, $options, throwOnFailure: false)) { + return SignedUrlGuard::invalidSignatureResult(); } - if (isset($query[$this->versionParam])) { - if (!is_numeric($query[$this->versionParam]) || (int) $query[$this->versionParam] !== SignedUrlVersion::V1->value) { - return false; - } + $signatureData = SignedUrlGuard::extractSignatureData($query, $this->signatureParam, $this->versionParam); + if ($signatureData === null) { + return SignedUrlGuard::invalidSignatureResult(); } + $givenSignature = $signatureData['signature']; + $version = $signatureData['version']; unset($query[$this->signatureParam]); - if (isset($query[$this->expiresParam]) && time() > (int) $query[$this->expiresParam]) { - return false; + $expiration = SignedUrlGuard::validateExpirationFromQuery($query, $this->expiresParam, $this->clock->now(), $version); + if ($expiration !== null) { + return $expiration; + } + $expiresAt = SignedUrlGuard::expiresAtFromQuery($query, $this->expiresParam); + + $methodValidation = SignedUrlGuard::validateMethodFromQuery($query, self::METHOD_PARAM, $options, $expiresAt, $version); + if ($methodValidation !== null) { + return $methodValidation; + } + + $normalized = $this->normalizeQuery($query, $options->allowArrayParameters); + if ($normalized === null) { + return SignedUrlGuard::invalidSignatureResult($expiresAt, $version); } - $basePath = $this->buildBasePath($parts); - $computed = $this->computeSignature($basePath, $query); + $signatureBasePath = $this->buildSignatureBasePath($parts, $options); + $computed = $this->computeSignature($signatureBasePath, $normalized); + + $verified = SecureCompare::equals($computed, $givenSignature); - return SecureCompare::equals($computed, $givenSignature); + return new SignedUrlVerificationResult( + verified: $verified, + invalidSignature: !$verified, + expiresAt: $expiresAt, + version: $version, + ); + } + + /** + * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + */ + private function assertUrlPolicy(array $parts, SignedUrlOptions $options, bool $throwOnFailure): bool + { + return SignedUrlGuard::assertUrlPolicy($parts, $options, $throwOnFailure); + } + + /** + * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + */ + private function buildDisplayBasePath(array $parts): string + { + $path = $this->pathFromParts($parts); + $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : ''; + $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower($parts['scheme']) : ''; + $port = isset($parts['port']) && is_int($parts['port']) ? ':' . $parts['port'] : ''; + + if ($host === '' && $scheme === '') { + return $path; + } + + if ($scheme === '') { + return '//' . $host . $port . $path; + } + + return $scheme . '://' . $host . $port . $path; + } + + /** + * @param QueryMap $query + */ + private function buildQueryString(array $query): string + { + return http_build_query($query, '', '&', PHP_QUERY_RFC3986); } /** * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts */ - private function buildBasePath(array $parts): string + private function buildSignatureBasePath(array $parts, SignedUrlOptions $options): string { - $scheme = isset($parts['scheme']) && is_string($parts['scheme']) && $parts['scheme'] !== '' - ? $parts['scheme'] - : 'https'; - $host = isset($parts['host']) && is_string($parts['host']) ? $parts['host'] : ''; + $path = $this->pathFromParts($parts); + $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : ''; + $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower($parts['scheme']) : ''; $port = isset($parts['port']) && is_int($parts['port']) ? ':' . $parts['port'] : ''; - $path = isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '' ? $parts['path'] : '/'; + + if (!$options->bindHost && !$options->bindScheme) { + return $path; + } + + if (!$options->bindHost && $options->bindScheme) { + return $scheme . '://' . $path; + } + + if ($options->bindHost && !$options->bindScheme) { + return '//' . $host . $port . $path; + } return $scheme . '://' . $host . $port . $path; } /** - * @param array $query + * @param QueryMap $query */ private function computeSignature(string $basePath, array $query): string { - $query = array_filter($query, static fn(mixed $value): bool => $value !== null); - ksort($query); + return Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $this->buildQueryString($query), $this->secret, true)); + } - return Base64Url::encode(hash_hmac('sha256', $basePath . '?' . http_build_query($query), $this->secret, true)); + /** + * @param array $value + * @return array|null + */ + private function normalizeArrayValue(array $value): ?array + { + $normalized = []; + foreach ($value as $key => $item) { + if (is_scalar($item)) { + $normalized[$key] = $item; + + continue; + } + + return null; + } + + ksort($normalized); + + return $normalized; } /** - * @param array $query - * @return array + * @param array $query + * @return QueryMap|null */ - private function normalizeQuery(array $query): array + private function normalizeQuery(array $query, bool $allowArrays): ?array { $normalized = []; @@ -112,14 +232,33 @@ private function normalizeQuery(array $query): array if (is_scalar($value)) { $normalized[$key] = $value; + + continue; + } + + if ($value === null) { + continue; } + + if (!$allowArrays || !is_array($value)) { + return null; + } + + $normalizedArray = $this->normalizeArrayValue($value); + if ($normalizedArray === null) { + return null; + } + + $normalized[$key] = $normalizedArray; } + ksort($normalized); + return $normalized; } /** - * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, array}|null + * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, QueryMap}|null */ private function parseUrlWithQuery(string $url): ?array { @@ -133,11 +272,16 @@ private function parseUrlWithQuery(string $url): ?array parse_str($parts['query'], $query); } - return [$parts, $this->normalizeQuery($query)]; + $normalizedQuery = $this->normalizeQuery($query, allowArrays: true); + if ($normalizedQuery === null) { + return null; + } + + return [$parts, $normalizedQuery]; } /** - * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, array} + * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, QueryMap} */ private function parseUrlWithQueryOrFail(string $url): array { @@ -148,4 +292,12 @@ private function parseUrlWithQueryOrFail(string $url): array return $parsed; } + + /** + * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + */ + private function pathFromParts(array $parts): string + { + return isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '' ? $parts['path'] : '/'; + } } diff --git a/src/Security/SignedUrlOptions.php b/src/Security/SignedUrlOptions.php new file mode 100644 index 0000000..2026b8a --- /dev/null +++ b/src/Security/SignedUrlOptions.php @@ -0,0 +1,52 @@ +|null $allowedHosts + */ + public function __construct( + public ?string $method = null, + public bool $bindHost = true, + public bool $bindScheme = true, + public bool $allowAbsoluteUrls = true, + public bool $allowRelativeUrls = false, + public bool $allowArrayParameters = false, + public ?array $allowedHosts = null, + ) { + if ($this->method !== null) { + $this->method = strtoupper(trim($this->method)); + if ($this->method === '') { + throw new ConfigurationException('Signed URL method binding must be a non-empty string when provided.'); + } + } + + if ($this->allowedHosts !== null) { + $normalizedHosts = []; + foreach ($this->allowedHosts as $host) { + if (!is_string($host)) { + throw new ConfigurationException('Signed URL allowedHosts must contain only non-empty strings.'); + } + + $normalized = strtolower(trim($host)); + if ($normalized === '') { + throw new ConfigurationException('Signed URL allowedHosts must contain only non-empty strings.'); + } + + $normalizedHosts[] = $normalized; + } + + if ($normalizedHosts === []) { + throw new ConfigurationException('Signed URL allowedHosts must not be empty when provided.'); + } + + $this->allowedHosts = array_values(array_unique($normalizedHosts)); + } + } +} diff --git a/src/Security/SignedUrlVerificationResult.php b/src/Security/SignedUrlVerificationResult.php new file mode 100644 index 0000000..4cd0763 --- /dev/null +++ b/src/Security/SignedUrlVerificationResult.php @@ -0,0 +1,16 @@ +codec = new SignedPayloadCodec($secret); + $this->codec = new SignedPayloadCodec($secret, clock: $this->clock); } /** @@ -28,7 +31,7 @@ protected function issueForPurpose(SecurityTokenPurpose $purpose, array $claims) return $this->codec->issue( ['purpose' => $purposeValue] + $claims, - time() + $this->ttlSeconds, + $this->clock->now() + $this->ttlSeconds, $purposeValue, ); } diff --git a/src/Security/Support/SignedUrlGuard.php b/src/Security/Support/SignedUrlGuard.php new file mode 100644 index 0000000..64564ff --- /dev/null +++ b/src/Security/Support/SignedUrlGuard.php @@ -0,0 +1,199 @@ + + * @phpstan-type QueryMap array + */ +final class SignedUrlGuard +{ + /** + * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + */ + public static function assertUrlPolicy(array $parts, SignedUrlOptions $options, bool $throwOnFailure): bool + { + $host = isset($parts['host']) && is_string($parts['host']) ? strtolower(trim($parts['host'])) : ''; + $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower(trim($parts['scheme'])) : ''; + $isAbsolute = $host !== '' || $scheme !== ''; + $isRelative = !$isAbsolute; + + if (!self::assertAbsoluteRelativePolicy($isAbsolute, $isRelative, $options, $throwOnFailure)) { + return false; + } + + if (!self::assertBindingPolicy($host, $scheme, $isAbsolute, $isRelative, $options, $throwOnFailure)) { + return false; + } + + if (!self::assertAllowedHostPolicy($host, $options, $throwOnFailure)) { + return false; + } + + return true; + } + + /** + * @param QueryMap $query + */ + public static function expiresAtFromQuery(array $query, string $expiresParam): ?int + { + if (!isset($query[$expiresParam])) { + return null; + } + + return self::parseTimestamp($query[$expiresParam]); + } + + /** + * @param QueryMap $query + * @return array{signature: string, version: ?int}|null + */ + public static function extractSignatureData(array $query, string $signatureParam, string $versionParam): ?array + { + $signature = $query[$signatureParam] ?? null; + if (!is_string($signature) || $signature === '') { + return null; + } + + $version = null; + if (isset($query[$versionParam])) { + $version = self::parseVersion($query[$versionParam]); + if ($version === null) { + return null; + } + } + + return ['signature' => $signature, 'version' => $version]; + } + + public static function invalidSignatureResult(?int $expiresAt = null, ?int $version = null): SignedUrlVerificationResult + { + return new SignedUrlVerificationResult(false, invalidSignature: true, expiresAt: $expiresAt, version: $version); + } + + /** + * @param QueryMap $query + */ + public static function validateExpirationFromQuery(array $query, string $expiresParam, int $now, ?int $version): ?SignedUrlVerificationResult + { + if (!isset($query[$expiresParam])) { + return null; + } + + $expiresAt = self::parseTimestamp($query[$expiresParam]); + if ($expiresAt === null) { + return self::invalidSignatureResult(version: $version); + } + + if ($now > $expiresAt) { + return new SignedUrlVerificationResult(false, expired: true, expiresAt: $expiresAt, version: $version); + } + + return null; + } + + /** + * @param QueryMap $query + */ + public static function validateMethodFromQuery(array $query, string $methodParam, SignedUrlOptions $options, ?int $expiresAt, ?int $version): ?SignedUrlVerificationResult + { + $methodValue = $query[$methodParam] ?? null; + if ($methodValue === null) { + return $options->method === null ? null : self::invalidSignatureResult($expiresAt, $version); + } + + if (!is_string($methodValue) || $methodValue === '') { + return self::invalidSignatureResult($expiresAt, $version); + } + + if ($options->method !== null && strtoupper($methodValue) !== $options->method) { + return self::invalidSignatureResult($expiresAt, $version); + } + + return null; + } + + private static function assertAbsoluteRelativePolicy(bool $isAbsolute, bool $isRelative, SignedUrlOptions $options, bool $throwOnFailure): bool + { + if ($isAbsolute && !$options->allowAbsoluteUrls) { + return self::policyFailure('Absolute signed URLs are not allowed by policy.', $throwOnFailure); + } + + if ($isRelative && !$options->allowRelativeUrls) { + return self::policyFailure('Relative signed URLs are not allowed by policy.', $throwOnFailure); + } + + return true; + } + + private static function assertAllowedHostPolicy(string $host, SignedUrlOptions $options, bool $throwOnFailure): bool + { + if ($options->allowedHosts !== null && $host !== '' && !in_array($host, $options->allowedHosts, true)) { + return self::policyFailure('Host is not in the allowed host list for signed URLs.', $throwOnFailure); + } + + return true; + } + + private static function assertBindingPolicy(string $host, string $scheme, bool $isAbsolute, bool $isRelative, SignedUrlOptions $options, bool $throwOnFailure): bool + { + if ($isRelative && ($options->bindHost || $options->bindScheme)) { + return self::policyFailure('Relative signed URLs require host and scheme binding to be disabled.', $throwOnFailure); + } + + if ($options->bindHost && $isAbsolute && $host === '') { + return self::policyFailure('Host binding requires an absolute URL with a host.', $throwOnFailure); + } + + if ($options->bindScheme && $isAbsolute && $scheme === '') { + return self::policyFailure('Scheme binding requires an absolute URL with a scheme.', $throwOnFailure); + } + + return true; + } + + private static function parseTimestamp(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + if (!is_string($value) || !preg_match('/^-?[0-9]+$/', $value)) { + return null; + } + + return (int) $value; + } + + private static function parseVersion(mixed $value): ?int + { + if (!is_numeric($value)) { + return null; + } + + $version = (int) $value; + if ($version !== SignedUrlVersion::V1->value) { + return null; + } + + return $version; + } + + private static function policyFailure(string $message, bool $throwOnFailure): bool + { + if ($throwOnFailure) { + throw new ConfigurationException($message); + } + + return false; + } +} diff --git a/src/Token/Jwt/AsymmetricJwt.php b/src/Token/Jwt/AsymmetricJwt.php index b244bea..c66cff7 100644 --- a/src/Token/Jwt/AsymmetricJwt.php +++ b/src/Token/Jwt/AsymmetricJwt.php @@ -7,10 +7,14 @@ use Infocyph\Epicrypt\Exception\Token\InvalidTokenException; use Infocyph\Epicrypt\Exception\Token\TokenException; use Infocyph\Epicrypt\Exception\Token\UnsupportedAlgorithmException; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Internal\EcdsaSignatureConverter; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; use Infocyph\Epicrypt\Token\Jwt\Enum\AsymmetricJwtAlgorithm; use Infocyph\Epicrypt\Token\Jwt\Support\AbstractJwt; +use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; final readonly class AsymmetricJwt extends AbstractJwt @@ -18,14 +22,17 @@ public function __construct( private ?string $passphrase = null, private AsymmetricJwtAlgorithm $algorithm = AsymmetricJwtAlgorithm::RS512, - ?RegisteredClaims $expectedClaims = null, + RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, + ?JwtValidationOptions $validationOptions = null, + ?ClockInterface $clock = null, + private EcdsaSignatureConverter $ecdsaSignatureConverter = new EcdsaSignatureConverter(), ) { - parent::__construct('asymmetric', $expectedClaims); + parent::__construct('asymmetric', $expectedClaims, $validationOptions ?? new JwtValidationOptions(), $clock ?? new SystemClock()); } - public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, ?RegisteredClaims $expectedClaims = null, ?string $passphrase = null): self + public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, ?string $passphrase = null, ?JwtValidationOptions $validationOptions = null, ?ClockInterface $clock = null): self { - return new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims); + return new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims, $validationOptions, $clock, new EcdsaSignatureConverter()); } protected function algorithmHeaderValue(mixed $algorithm): string @@ -61,7 +68,7 @@ protected function sign(string $input, string $resolvedKey): string $ecdsaLength = $this->algorithm->ecdsaSignatureLength(); if ($ecdsaLength !== null) { - $signature = new EcdsaSignatureConverter()->fromAsn1($signature, $ecdsaLength); + $signature = $this->ecdsaSignatureConverter->fromAsn1($signature, $ecdsaLength); } return $signature; @@ -80,7 +87,7 @@ protected function verifySignature(string $input, string $signature, string $res $ecdsaLength = $algorithm->ecdsaSignatureLength(); if ($ecdsaLength !== null) { - $signature = new EcdsaSignatureConverter()->toAsn1($signature, $ecdsaLength); + $signature = $this->ecdsaSignatureConverter->toAsn1($signature, $ecdsaLength); } return openssl_verify( diff --git a/src/Token/Jwt/Jwks.php b/src/Token/Jwt/Jwks.php new file mode 100644 index 0000000..d28663a --- /dev/null +++ b/src/Token/Jwt/Jwks.php @@ -0,0 +1,138 @@ +>} + */ + public function exportFromKeyRing(KeyRing $keyRing): array + { + $keys = []; + foreach ($keyRing->keys() as $kid => $key) { + $keys[] = $this->exportPublicKeyToJwk($key, $kid); + } + + return ['keys' => $keys]; + } + + /** + * @return array + */ + public function exportPublicKeyToJwk(string $publicKeyPem, string $kid): array + { + $resource = openssl_pkey_get_public($publicKeyPem); + if ($resource === false) { + throw new KeyResolutionException('Unable to load public key for JWK export.'); + } + + $details = openssl_pkey_get_details($resource); + if (!is_array($details)) { + throw new KeyResolutionException('Unable to inspect public key for JWK export.'); + } + $normalizedDetails = $this->stringKeyArray($details); + + return match ($normalizedDetails['type'] ?? null) { + OPENSSL_KEYTYPE_RSA => $this->exportRsa($normalizedDetails, $kid), + OPENSSL_KEYTYPE_EC => $this->exportEc($normalizedDetails, $kid), + default => throw new KeyResolutionException('Unsupported public key type for JWK export.'), + }; + } + + /** + * @param array{keys?: mixed} $jwks + * @return array + */ + public function resolveByKid(array $jwks, string $kid): array + { + $keys = $jwks['keys'] ?? null; + if (!is_array($keys)) { + throw new KeyResolutionException('JWKS must contain a keys array.'); + } + + foreach ($keys as $entry) { + if (is_array($entry) && isset($entry['kid']) && is_string($entry['kid']) && hash_equals($entry['kid'], $kid)) { + return $this->stringKeyArray($entry); + } + } + + throw new KeyResolutionException(sprintf('No JWK found for kid "%s".', $kid)); + } + + /** + * @param array $details + * @return array + */ + private function exportEc(array $details, string $kid): array + { + $ec = $details['ec'] ?? null; + if (!is_array($ec) || !isset($ec['curve_name'], $ec['x'], $ec['y']) || !is_string($ec['curve_name']) || !is_string($ec['x']) || !is_string($ec['y'])) { + throw new KeyResolutionException('Unable to export EC key as JWK.'); + } + + $crv = match ($ec['curve_name']) { + 'prime256v1', 'secp256r1' => 'P-256', + 'secp384r1' => 'P-384', + 'secp521r1' => 'P-521', + default => throw new KeyResolutionException('Unsupported EC curve for JWK export: ' . $ec['curve_name']), + }; + + return [ + 'kty' => 'EC', + 'kid' => $kid, + 'alg' => match ($crv) { + 'P-256' => 'ES256', + 'P-384' => 'ES384', + 'P-521' => 'ES512', + }, + 'use' => 'sig', + 'crv' => $crv, + 'x' => Base64Url::encode($ec['x']), + 'y' => Base64Url::encode($ec['y']), + ]; + } + + /** + * @param array $details + * @return array + */ + private function exportRsa(array $details, string $kid): array + { + $rsa = $details['rsa'] ?? null; + if (!is_array($rsa) || !isset($rsa['n'], $rsa['e']) || !is_string($rsa['n']) || !is_string($rsa['e'])) { + throw new KeyResolutionException('Unable to export RSA key as JWK.'); + } + + return [ + 'kty' => 'RSA', + 'kid' => $kid, + 'alg' => 'RS256', + 'use' => 'sig', + 'n' => Base64Url::encode($rsa['n']), + 'e' => Base64Url::encode($rsa['e']), + ]; + } + + /** + * @param array $input + * @return array + */ + private function stringKeyArray(array $input): array + { + $normalized = []; + foreach ($input as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + return $normalized; + } +} diff --git a/src/Token/Jwt/JwtVerificationResult.php b/src/Token/Jwt/JwtVerificationResult.php new file mode 100644 index 0000000..bde224c --- /dev/null +++ b/src/Token/Jwt/JwtVerificationResult.php @@ -0,0 +1,23 @@ + $claims + * @param array $headers + */ + public function __construct( + public bool $verified, + public array $claims = [], + public array $headers = [], + public ?string $matchedKeyId = null, + public bool $usedFallbackKey = false, + public bool $expired = false, + public bool $notBeforeViolation = false, + public ?string $algorithm = null, + ) {} +} diff --git a/src/Token/Jwt/Support/AbstractJwt.php b/src/Token/Jwt/Support/AbstractJwt.php index ca1412c..5e15e89 100644 --- a/src/Token/Jwt/Support/AbstractJwt.php +++ b/src/Token/Jwt/Support/AbstractJwt.php @@ -4,16 +4,23 @@ namespace Infocyph\Epicrypt\Token\Jwt\Support; +use Infocyph\Epicrypt\Exception\Token\ExpiredTokenException; use Infocyph\Epicrypt\Exception\Token\InvalidClaimException; use Infocyph\Epicrypt\Exception\Token\InvalidTokenException; use Infocyph\Epicrypt\Exception\Token\KeyResolutionException; use Infocyph\Epicrypt\Exception\Token\TokenException; use Infocyph\Epicrypt\Exception\Token\UnsupportedAlgorithmException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\KeyRing; use Infocyph\Epicrypt\Security\KeyVerificationResult; use Infocyph\Epicrypt\Token\Contract\JwtTokenInterface; +use Infocyph\Epicrypt\Token\Jwt\JwtVerificationResult; use Infocyph\Epicrypt\Token\Jwt\KeyResolver; +use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtIssueClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidator; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; use Infocyph\Epicrypt\Token\Support\TokenAnyKey; @@ -23,10 +30,44 @@ { use JwtCommon; + private ?ExpectedJwtClaims $normalizedExpectedClaims; + + private ?JwtValidator $validator; + public function __construct( private string $keyFamily, - private ?RegisteredClaims $expectedClaims, - ) {} + private RegisteredClaims|ExpectedJwtClaims|null $expectedClaims, + private JwtValidationOptions $validationOptions = new JwtValidationOptions(), + private ClockInterface $clock = new SystemClock(), + ) { + if ($this->expectedClaims === null) { + $this->normalizedExpectedClaims = null; + $this->validator = null; + + return; + } + + $this->normalizedExpectedClaims = $this->expectedClaims instanceof RegisteredClaims + ? ExpectedJwtClaims::fromRegistered($this->expectedClaims) + : $this->expectedClaims; + + $validatorOptions = new JwtValidationOptions( + strictTyp: $this->validationOptions->strictTyp, + requiredTyp: $this->validationOptions->requiredTyp, + rejectCriticalHeaders: $this->validationOptions->rejectCriticalHeaders, + rejectNoneAlgorithm: $this->validationOptions->rejectNoneAlgorithm, + leewaySeconds: $this->normalizedExpectedClaims->leewaySeconds !== 0 + ? $this->normalizedExpectedClaims->leewaySeconds + : $this->validationOptions->leewaySeconds, + maxTokenAgeSeconds: $this->normalizedExpectedClaims->maxTokenAgeSeconds ?? $this->validationOptions->maxTokenAgeSeconds, + ); + + $this->validator = new JwtValidator( + $this->normalizedExpectedClaims, + options: $validatorOptions, + clock: $this->clock, + ); + } abstract protected function algorithmHeaderValue(mixed $algorithm): string; @@ -39,6 +80,16 @@ abstract protected function sign(string $input, string $resolvedKey): string; abstract protected function verifySignature(string $input, string $signature, string $resolvedKey, mixed $algorithm): bool; final public function decode(string $token, mixed $key): object + { + $result = $this->decodeResult($token, $key); + if (!$result->verified) { + throw new InvalidTokenException('JWT verification failed.'); + } + + return (object) $result->claims; + } + + final public function decodeResult(string $token, mixed $key): JwtVerificationResult { $key = $this->requireSupportedKeyType($key); @@ -48,6 +99,7 @@ final public function decode(string $token, mixed $key): object try { [$encodedHeader, $encodedPayload, $signature, $header, $payload] = JwtToken::parse($token); + $this->validateHeader($header); $algorithm = $this->algorithmFromHeader($header); $resolvedKey = KeyResolver::resolve($key, $header['kid'] ?? null); @@ -56,11 +108,24 @@ final public function decode(string $token, mixed $key): object throw new InvalidTokenException('Signature verification failed.'); } - new JwtValidator($this->expectedClaims)->validate($payload); + $this->requireValidator()->validate($payload); - return (object) $payload; - } catch (UnsupportedAlgorithmException|KeyResolutionException|InvalidTokenException $e) { - throw $e; + return new JwtVerificationResult( + verified: true, + claims: $payload, + headers: $header, + matchedKeyId: is_string($header['kid'] ?? null) ? $header['kid'] : null, + usedFallbackKey: false, + algorithm: is_string($header['alg'] ?? null) ? $header['alg'] : null, + ); + } catch (ExpiredTokenException) { + return new JwtVerificationResult(false, expired: true); + } catch (InvalidClaimException $e) { + $isNbfViolation = str_contains(strtolower($e->getMessage()), 'not active'); + + return new JwtVerificationResult(false, notBeforeViolation: $isNbfViolation); + } catch (UnsupportedAlgorithmException|KeyResolutionException|InvalidTokenException) { + return new JwtVerificationResult(false); } catch (Throwable $e) { throw new InvalidTokenException($e->getMessage(), 0, $e); } @@ -71,19 +136,43 @@ final public function decode(string $token, mixed $key): object */ final public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object { - return TokenAnyKey::decode( - $this->orderedKeys( - $keys, - sprintf('All %s JWT key candidates must be non-empty strings.', $this->keyFamily), - sprintf('At least one %s JWT key candidate is required.', $this->keyFamily), - ), - fn(string $candidateKey): object => $this->decode($token, $candidateKey), - fn(?Throwable $previous): Throwable => new InvalidTokenException( - sprintf('JWT verification failed for every supplied %s key.', $this->keyFamily), - 0, - $previous, - ), - ); + $result = $this->decodeWithAnyKeyResult($token, $keys); + if (!$result->verified) { + throw new InvalidTokenException(sprintf('JWT verification failed for every supplied %s key.', $this->keyFamily)); + } + + return (object) $result->claims; + } + + /** + * @param iterable|KeyRing $keys + */ + final public function decodeWithAnyKeyResult(string $token, iterable|KeyRing $keys): JwtVerificationResult + { + $lastResult = new JwtVerificationResult(false); + + foreach ($this->orderedKeyEntries( + $keys, + sprintf('All %s JWT key candidates must be non-empty strings.', $this->keyFamily), + sprintf('At least one %s JWT key candidate is required.', $this->keyFamily), + ) as $entry) { + $result = $this->decodeResult($token, $entry['key']); + if ($result->verified) { + return new JwtVerificationResult( + true, + $result->claims, + $result->headers, + $entry['id'], + !$entry['active'], + $result->expired, + $result->notBeforeViolation, + $result->algorithm, + ); + } + $lastResult = $result; + } + + return $lastResult; } /** @@ -94,8 +183,7 @@ final public function encode(array $claims, mixed $key, array $headers = []): st { $key = $this->requireSupportedKeyType($key); - $registeredClaims = RegisteredClaims::fromArray($claims); - [$notBefore, $expiresAt] = $this->extractTemporalClaims($claims); + $issueClaims = JwtIssueClaims::fromArray($claims); $keyId = $claims['kid'] ?? null; try { @@ -103,7 +191,7 @@ final public function encode(array $claims, mixed $key, array $headers = []): st [$encodedHeader, $encodedPayload] = JwtToken::encodeSegments( $this->buildHeader($keyId, $headers), - $this->buildPayload($registeredClaims, $notBefore, $expiresAt, $claims), + $this->buildPayload($issueClaims, $claims), ); $signature = $this->sign($encodedHeader . '.' . $encodedPayload, $resolvedKey); @@ -118,13 +206,12 @@ final public function encode(array $claims, mixed $key, array $headers = []): st final public function verify(string $token, mixed $key): bool { - try { - $this->decode($token, $key); + return $this->verifyResult($token, $key)->verified; + } - return true; - } catch (Throwable) { - return false; - } + final public function verifyResult(string $token, mixed $key): JwtVerificationResult + { + return $this->decodeResult($token, $key); } /** @@ -195,21 +282,57 @@ private function buildHeader(mixed $keyId, array $headers): array * @param array $claims * @return array */ - private function buildPayload(RegisteredClaims $registeredClaims, int $notBefore, int $expiresAt, array $claims): array + private function buildPayload(JwtIssueClaims $issueClaims, array $claims): array { - $payload = [ - 'iss' => $registeredClaims->issuer, - 'aud' => $registeredClaims->audience, - 'sub' => $registeredClaims->subject, - 'iat' => time(), - 'nbf' => $notBefore, - 'exp' => $expiresAt, - ]; + $payload = array_filter([ + 'iss' => $issueClaims->issuer, + 'aud' => $issueClaims->audience, + 'sub' => $issueClaims->subject, + 'iat' => $this->clock->now(), + 'nbf' => $issueClaims->notBefore, + 'exp' => $issueClaims->expiresAt, + 'jti' => $issueClaims->jwtId, + ], static fn(mixed $value): bool => $value !== null); - if ($registeredClaims->jwtId !== null) { - $payload['jti'] = $registeredClaims->jwtId; + return $payload + $this->removeReservedClaims($claims); + } + + private function requireValidator(): JwtValidator + { + if ($this->validator === null) { + throw new TokenException('Expected claims are required for JWT decoding.'); } - return $payload + $this->removeReservedClaims($claims); + return $this->validator; + } + + /** + * @param array $header + */ + private function validateHeader(array $header): void + { + $alg = $header['alg'] ?? null; + if (!is_string($alg) || $alg === '') { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); + } + + if ($this->validationOptions->rejectNoneAlgorithm && strtolower($alg) === 'none') { + throw new UnsupportedAlgorithmException('Algorithm "none" is not supported.'); + } + + if ($this->validationOptions->strictTyp) { + $typ = $header['typ'] ?? null; + if (!is_string($typ) || strtoupper($typ) !== strtoupper($this->validationOptions->requiredTyp)) { + throw new InvalidTokenException('Invalid JWT typ header.'); + } + } + + if ($this->validationOptions->rejectCriticalHeaders && array_key_exists('crit', $header)) { + throw new InvalidTokenException('Unsupported JWT critical headers.'); + } + + if (array_key_exists('kid', $header) && (!is_string($header['kid']) || $header['kid'] === '')) { + throw new InvalidTokenException('Invalid JWT kid header.'); + } } } diff --git a/src/Token/Jwt/SymmetricJwt.php b/src/Token/Jwt/SymmetricJwt.php index 884c46d..95dc79d 100644 --- a/src/Token/Jwt/SymmetricJwt.php +++ b/src/Token/Jwt/SymmetricJwt.php @@ -5,23 +5,29 @@ namespace Infocyph\Epicrypt\Token\Jwt; use Infocyph\Epicrypt\Exception\Token\UnsupportedAlgorithmException; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm; use Infocyph\Epicrypt\Token\Jwt\Support\AbstractJwt; +use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; final readonly class SymmetricJwt extends AbstractJwt { public function __construct( private SymmetricJwtAlgorithm $algorithm = SymmetricJwtAlgorithm::HS512, - ?RegisteredClaims $expectedClaims = null, + RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, + ?JwtValidationOptions $validationOptions = null, + ?ClockInterface $clock = null, ) { - parent::__construct('symmetric', $expectedClaims); + parent::__construct('symmetric', $expectedClaims, $validationOptions ?? new JwtValidationOptions(), $clock ?? new SystemClock()); } - public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, ?RegisteredClaims $expectedClaims = null): self + public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, ?JwtValidationOptions $validationOptions = null, ?ClockInterface $clock = null): self { - return new self($profile->defaultSymmetricJwtAlgorithm(), $expectedClaims); + return new self($profile->defaultSymmetricJwtAlgorithm(), $expectedClaims, $validationOptions, $clock); } protected function algorithmHeaderValue(mixed $algorithm): string diff --git a/src/Token/Jwt/Validation/ExpectedJwtClaims.php b/src/Token/Jwt/Validation/ExpectedJwtClaims.php new file mode 100644 index 0000000..11a8e18 --- /dev/null +++ b/src/Token/Jwt/Validation/ExpectedJwtClaims.php @@ -0,0 +1,29 @@ +issuer, + audience: $claims->audience, + subject: $claims->subject, + jwtId: $claims->jwtId, + required: new RequiredJwtClaims(true, true, true, $claims->jwtId !== null), + ); + } +} diff --git a/src/Token/Jwt/Validation/ExpirationValidator.php b/src/Token/Jwt/Validation/ExpirationValidator.php index 5001be7..2c600a8 100644 --- a/src/Token/Jwt/Validation/ExpirationValidator.php +++ b/src/Token/Jwt/Validation/ExpirationValidator.php @@ -6,20 +6,24 @@ use Infocyph\Epicrypt\Exception\Token\ExpiredTokenException; use Infocyph\Epicrypt\Exception\Token\InvalidClaimException; +use Infocyph\Epicrypt\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; final readonly class ExpirationValidator { public function __construct( private int $leeway = 0, + private ?int $maxTokenAgeSeconds = null, + private ClockInterface $clock = new SystemClock(), ) {} - public function validate(mixed $notBefore, mixed $expiresAt): void + public function validate(mixed $notBefore, mixed $expiresAt, mixed $issuedAt = null): void { if (!is_numeric($notBefore) || !is_numeric($expiresAt)) { throw new InvalidClaimException('Claims "nbf" and "exp" must be numeric timestamps.'); } - $now = time(); + $now = $this->clock->now(); if ($now + $this->leeway < (int) $notBefore) { throw new InvalidClaimException('Token is not active yet.'); } @@ -27,5 +31,15 @@ public function validate(mixed $notBefore, mixed $expiresAt): void if ($now - $this->leeway > (int) $expiresAt) { throw new ExpiredTokenException('Token has expired.'); } + + if ($this->maxTokenAgeSeconds !== null) { + if (!is_numeric($issuedAt)) { + throw new InvalidClaimException('Claim "iat" must be numeric when max token age is enforced.'); + } + + if ($now - (int) $issuedAt > $this->maxTokenAgeSeconds + $this->leeway) { + throw new ExpiredTokenException('Token exceeded the maximum allowed age.'); + } + } } } diff --git a/src/Token/Jwt/Validation/JwtIssueClaims.php b/src/Token/Jwt/Validation/JwtIssueClaims.php new file mode 100644 index 0000000..5999bfb --- /dev/null +++ b/src/Token/Jwt/Validation/JwtIssueClaims.php @@ -0,0 +1,59 @@ + $claims + */ + public static function fromArray(array $claims): self + { + $issuer = self::optionalString($claims, 'iss'); + $audience = self::optionalString($claims, 'aud'); + $subject = self::optionalString($claims, 'sub'); + $jwtId = self::optionalString($claims, 'jti'); + + if (!isset($claims['nbf'], $claims['exp']) || !is_numeric($claims['nbf']) || !is_numeric($claims['exp'])) { + throw new InvalidClaimException('Claims "nbf" and "exp" must be numeric timestamps.'); + } + + $nbf = (int) $claims['nbf']; + $exp = (int) $claims['exp']; + if ($exp <= $nbf) { + throw new InvalidClaimException('Claim "exp" must be greater than "nbf".'); + } + + return new self($issuer, $audience, $subject, $jwtId, $nbf, $exp); + } + + /** + * @param array $claims + */ + private static function optionalString(array $claims, string $name): ?string + { + $value = $claims[$name] ?? null; + if ($value === null) { + return null; + } + + if (!is_string($value) || $value === '') { + throw new InvalidClaimException(sprintf('Claim "%s" must be a non-empty string when provided.', $name)); + } + + return $value; + } +} diff --git a/src/Token/Jwt/Validation/JwtValidationOptions.php b/src/Token/Jwt/Validation/JwtValidationOptions.php new file mode 100644 index 0000000..cfeef85 --- /dev/null +++ b/src/Token/Jwt/Validation/JwtValidationOptions.php @@ -0,0 +1,17 @@ +normalizePayload($claims); + $expirationValidator = new ExpirationValidator( + $this->options->leewaySeconds, + $this->options->maxTokenAgeSeconds, + $this->clock, + ); - $this->issuerValidator->validate($this->expected->issuer, $payload['iss'] ?? null); - $this->audienceValidator->validate($this->expected->audience, $payload['aud'] ?? null); - $this->subjectValidator->validate($this->expected->subject, $payload['sub'] ?? null); - $this->expirationValidator->validate($payload['nbf'] ?? null, $payload['exp'] ?? null); + $this->validateRequiredClaims($payload); + $this->validateExpectedClaims($payload); + $expirationValidator->validate($payload['nbf'] ?? null, $payload['exp'] ?? null, $payload['iat'] ?? null); + $this->validateExpectedJwtId($payload); + } + + /** + * @param array|object $claims + * @return array + */ + private function normalizePayload(array|object $claims): array + { + $rawPayload = is_object($claims) ? get_object_vars($claims) : $claims; + $normalized = []; - if ($this->expected->jwtId !== null) { - if (!isset($payload['jti']) || !is_string($payload['jti']) || !hash_equals($this->expected->jwtId, $payload['jti'])) { - throw new InvalidClaimException('Invalid JWT ID claim.'); + foreach ($rawPayload as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; } } + + return $normalized; + } + + /** + * @param array $payload + */ + private function validateExpectedClaims(array $payload): void + { + if ($this->expected->issuer !== null) { + $this->issuerValidator->validate($this->expected->issuer, $payload['iss'] ?? null); + } + + if ($this->expected->audience !== null) { + $this->audienceValidator->validate($this->expected->audience, $payload['aud'] ?? null); + } + + if ($this->expected->subject !== null) { + $this->subjectValidator->validate($this->expected->subject, $payload['sub'] ?? null); + } + } + + /** + * @param array $payload + */ + private function validateExpectedJwtId(array $payload): void + { + if ($this->expected->jwtId === null) { + return; + } + + if (!isset($payload['jti']) || !is_string($payload['jti']) || !hash_equals($this->expected->jwtId, $payload['jti'])) { + throw new InvalidClaimException('Invalid JWT ID claim.'); + } + } + + /** + * @param array $payload + */ + private function validateRequiredClaims(array $payload): void + { + if ($this->expected->required->issuer && !isset($payload['iss'])) { + throw new InvalidClaimException('Missing claim: iss'); + } + + if ($this->expected->required->audience && !isset($payload['aud'])) { + throw new InvalidClaimException('Missing claim: aud'); + } + + if ($this->expected->required->subject && !isset($payload['sub'])) { + throw new InvalidClaimException('Missing claim: sub'); + } + + if ($this->expected->required->jwtId && !isset($payload['jti'])) { + throw new InvalidClaimException('Missing claim: jti'); + } } } diff --git a/src/Token/Jwt/Validation/RequiredJwtClaims.php b/src/Token/Jwt/Validation/RequiredJwtClaims.php new file mode 100644 index 0000000..4b0ba78 --- /dev/null +++ b/src/Token/Jwt/Validation/RequiredJwtClaims.php @@ -0,0 +1,15 @@ +verify($token, $this->context); + return new SignedPayloadCodec($key, clock: $this->clock)->verify($token, $this->context); } /** @@ -57,7 +61,7 @@ public function encode(array $claims, mixed $key, array $headers = []): string throw new TokenException('Signed payload key must be a non-empty string.'); } - return new SignedPayloadCodec($key)->issue( + return new SignedPayloadCodec($key, clock: $this->clock)->issue( $claims, isset($headers['exp']) && is_numeric($headers['exp']) ? (int) $headers['exp'] : null, $this->context, @@ -65,13 +69,20 @@ public function encode(array $claims, mixed $key, array $headers = []): string } public function verify(string $token, mixed $key): bool + { + return $this->verifyResult($token, $key)->verified; + } + + public function verifyResult(string $token, mixed $key): SignedPayloadVerificationResult { try { - $this->decode($token, $key); + $claims = $this->decode($token, $key); - return true; + return new SignedPayloadVerificationResult(true, $claims); + } catch (ExpiredTokenException) { + return new SignedPayloadVerificationResult(false, expired: true); } catch (TokenException) { - return false; + return new SignedPayloadVerificationResult(false); } } @@ -80,7 +91,30 @@ public function verify(string $token, mixed $key): bool */ public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool { - return $this->verifyWithAnyKeyResult($token, $keys)->verified; + return $this->verifyWithAnyKeyDetailedResult($token, $keys)->verified; + } + + /** + * @param iterable|KeyRing $keys + */ + public function verifyWithAnyKeyDetailedResult(string $token, iterable|KeyRing $keys): SignedPayloadVerificationResult + { + $lastResult = new SignedPayloadVerificationResult(false); + foreach ($this->orderedKeyEntries($keys) as $entry) { + $result = $this->verifyResult($token, $entry['key']); + if ($result->verified) { + return new SignedPayloadVerificationResult( + true, + $result->claims, + $entry['id'], + !$entry['active'], + $result->expired, + ); + } + $lastResult = $result; + } + + return $lastResult; } /** diff --git a/src/Token/Payload/SignedPayloadVerificationResult.php b/src/Token/Payload/SignedPayloadVerificationResult.php new file mode 100644 index 0000000..f55bf8c --- /dev/null +++ b/src/Token/Payload/SignedPayloadVerificationResult.php @@ -0,0 +1,19 @@ + $claims + */ + public function __construct( + public bool $verified, + public array $claims = [], + public ?string $matchedKeyId = null, + public bool $usedFallbackKey = false, + public bool $expired = false, + ) {} +} diff --git a/tests/Certificate/DomainTest.php b/tests/Certificate/DomainTest.php index 5b5c712..bacc2c7 100644 --- a/tests/Certificate/DomainTest.php +++ b/tests/Certificate/DomainTest.php @@ -1,6 +1,12 @@ generate(); @@ -33,6 +41,35 @@ expect($parsed['subject']['CN'] ?? $parsed['subject']['commonName'] ?? null)->toBe('epicrypt.local'); }); +it('builds csr and certificate with SAN options', function () { + $keyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); + $options = new CertificateOptions( + sanDns: ['epicrypt.local', 'api.epicrypt.local'], + keyUsage: ['digitalSignature', 'keyEncipherment'], + extendedKeyUsage: ['serverAuth'], + ); + + $dn = [ + 'countryName' => 'US', + 'stateOrProvinceName' => 'CA', + 'localityName' => 'San Francisco', + 'organizationName' => 'Epicrypt', + 'organizationalUnitName' => 'Security', + 'commonName' => 'epicrypt.local', + 'emailAddress' => 'security@epicrypt.local', + ]; + + $csr = CsrBuilder::openSsl()->build($dn, $keyPair['private'], options: $options); + $certificate = CertificateBuilder::openSsl()->selfSign($dn, $keyPair['private'], options: $options); + $parsed = CertificateParser::openSsl()->parse($certificate); + $san = $parsed['extensions']['subjectAltName'] ?? null; + + expect($csr)->toContain('BEGIN CERTIFICATE REQUEST'); + expect($san)->toBeString(); + expect((string) $san)->toContain('DNS:epicrypt.local'); + expect((string) $san)->toContain('DNS:api.epicrypt.local'); +}); + it('supports rsa interoperability in Certificate domain', function () { $keyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); @@ -61,6 +98,59 @@ expect($exchange->backend())->toBe(KeyExchangeBackend::SODIUM); }); +it('rejects invalid sodium key exchange key material', function () { + $exchange = KeyExchange::sodium(); + + expect(fn() => $exchange->derive('short-private', 'short-public')) + ->toThrow(InvalidKeyException::class); +}); + +it('signs csr using a certificate authority and validates certificate utilities', function () { + $caKeyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); + $leafKeyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); + + $caDn = [ + 'countryName' => 'US', + 'stateOrProvinceName' => 'CA', + 'localityName' => 'San Francisco', + 'organizationName' => 'Epicrypt CA', + 'organizationalUnitName' => 'Root', + 'commonName' => 'epicrypt-root.local', + 'emailAddress' => 'root@epicrypt.local', + ]; + $leafDn = [ + 'countryName' => 'US', + 'stateOrProvinceName' => 'CA', + 'localityName' => 'San Francisco', + 'organizationName' => 'Epicrypt', + 'organizationalUnitName' => 'Leaf', + 'commonName' => 'service.epicrypt.local', + 'emailAddress' => 'service@epicrypt.local', + ]; + + $caOptions = new CertificateOptions(isCa: true, keyUsage: ['keyCertSign', 'cRLSign'], sanDns: ['epicrypt-root.local']); + $leafOptions = new CertificateOptions(sanDns: ['service.epicrypt.local'], keyUsage: ['digitalSignature', 'keyEncipherment'], extendedKeyUsage: ['serverAuth']); + + $caCertificate = CertificateBuilder::openSsl()->selfSign($caDn, $caKeyPair['private'], options: $caOptions); + $leafCsr = CsrBuilder::openSsl()->build($leafDn, $leafKeyPair['private'], options: $leafOptions); + $leafCertificate = CertificateAuthority::openSsl()->signCsr($leafCsr, $caCertificate, $caKeyPair['private'], $leafOptions); + + $fingerprint = (new CertificateFingerprint())->fingerprint($leafCertificate); + $expiresAt = (new CertificateExpiry())->expiresAt($leafCertificate); + $notExpired = (new CertificateExpiry())->isExpired($leafCertificate) === false; + $matches = (new CertificateKeyMatcher())->privateKeyMatches($leafCertificate, $leafKeyPair['private']); + $chainValid = (new CertificateChainVerifier())->verify($leafCertificate, [$caCertificate]); + $normalized = (new PemNormalizer())->normalize($leafCertificate); + + expect($leafCertificate)->toContain('BEGIN CERTIFICATE'); + expect($fingerprint)->toHaveLength(64); + expect($expiresAt)->toBeGreaterThan(time()); + expect($notExpired)->toBeTrue(); + expect($matches)->toBeTrue(); + expect($chainValid)->toBeTrue(); + expect($normalized)->toContain("-----BEGIN CERTIFICATE-----\n"); +}); + it('rejects curve selection for RSA key pair generation', function () { expect(fn () => KeyPairGenerator::openSsl( bits: OpenSslRsaBits::BITS_2048, diff --git a/tests/Crypto/AeadContextTest.php b/tests/Crypto/AeadContextTest.php new file mode 100644 index 0000000..3638058 --- /dev/null +++ b/tests/Crypto/AeadContextTest.php @@ -0,0 +1,24 @@ +generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + + expect(fn() => $cipher->encrypt('payload', $key, ['key_is_binary' => 'yes'])) + ->toThrow(CryptoException::class); + expect(fn() => $cipher->encrypt('payload', $key, ['aad' => 123])) + ->toThrow(CryptoException::class); +}); + +it('validates aead context nonce type', function () { + $cipher = new AeadCipher; + $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + + expect(fn() => $cipher->encrypt('payload', $key, ['nonce' => ''])) + ->toThrow(InvalidNonceException::class); +}); diff --git a/tests/Crypto/CoreServicesTest.php b/tests/Crypto/CoreServicesTest.php index ea1cba8..50ed1ae 100644 --- a/tests/Crypto/CoreServicesTest.php +++ b/tests/Crypto/CoreServicesTest.php @@ -3,7 +3,10 @@ use Infocyph\Epicrypt\Certificate\KeyPairGenerator; use Infocyph\Epicrypt\Crypto\AeadCipher; use Infocyph\Epicrypt\Crypto\Mac; +use Infocyph\Epicrypt\Crypto\SecretBoxCipher; use Infocyph\Epicrypt\Crypto\Signature; +use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; +use Infocyph\Epicrypt\Exception\Crypto\SignatureException; use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator; it('encrypts and decrypts with AEAD services', function () { @@ -12,12 +15,40 @@ $cipher = new AeadCipher; $ciphertext = $cipher->encrypt('epicrypt-aead', $key, ['aad' => 'meta']); + $segments = explode('.', $ciphertext); $plaintext = $cipher->decrypt($ciphertext, $key, ['aad' => 'meta']); expect($ciphertext)->toStartWith('epc1.'); + expect($segments)->toHaveCount(5); + expect($segments[1])->toBe('xchacha20-poly1305-ietf'); + expect($segments[2])->toBe('_'); expect($plaintext)->toBe('epicrypt-aead'); }); +it('rejects tampered aead payload algorithm identifiers', function () { + $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + $cipher = new AeadCipher; + $ciphertext = $cipher->encrypt('epicrypt-aead', $key, ['aad' => 'meta']); + $segments = explode('.', $ciphertext); + $segments[1] = 'unknown-algorithm'; + $tamperedCiphertext = implode('.', $segments); + + expect(fn() => $cipher->decrypt($tamperedCiphertext, $key, ['aad' => 'meta'])) + ->toThrow(DecryptionException::class); +}); + +it('rejects payload algorithms that do not match secretbox', function () { + $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + $cipher = new SecretBoxCipher; + $ciphertext = $cipher->encrypt('epicrypt-secretbox', $key); + $segments = explode('.', $ciphertext); + $segments[1] = 'xchacha20-poly1305-ietf'; + $tamperedCiphertext = implode('.', $segments); + + expect(fn() => $cipher->decrypt($tamperedCiphertext, $key)) + ->toThrow(DecryptionException::class); +}); + it('signs and verifies detached signatures', function () { $keys = KeyPairGenerator::sodiumSign()->generate(asBase64Url: true); @@ -28,6 +59,15 @@ expect($signatureService->verify('tampered', $signature, $keys['public']))->toBeFalse(); }); +it('rejects invalid signature key material', function () { + $signatureService = new Signature; + + expect(fn() => $signatureService->sign('epicrypt-signature', 'short-key')) + ->toThrow(SignatureException::class); + expect(fn() => $signatureService->verify('epicrypt-signature', 'invalid-sig', 'short-key')) + ->toThrow(SignatureException::class); +}); + it('generates and verifies mac tags', function () { $macService = new Mac; $key = $macService->generateKey(); diff --git a/tests/Crypto/MatrixCoverageTest.php b/tests/Crypto/MatrixCoverageTest.php new file mode 100644 index 0000000..bac1b40 --- /dev/null +++ b/tests/Crypto/MatrixCoverageTest.php @@ -0,0 +1,105 @@ +isAvailable()) { + continue; + } + + $key = $generator->forAead($algorithm); + $cipher = new AeadCipher($algorithm); + $ciphertext = $cipher->encrypt('aead-matrix-payload', $key, ['aad' => 'matrix']); + $plaintext = $cipher->decrypt($ciphertext, $key, ['aad' => 'matrix']); + + expect($plaintext)->toBe('aead-matrix-payload'); + } +}); + +it('roundtrips sealed box encryption', function () { + $recipient = KeyPairGenerator::sodium()->generate(asBase64Url: false); + $recipientKeypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($recipient['private'], $recipient['public']); + + $cipher = new SealedBoxCipher; + $ciphertext = $cipher->encrypt('sealed-box payload', $recipient['public'], ['key_is_binary' => true]); + $plaintext = $cipher->decrypt($ciphertext, $recipientKeypair, ['key_is_binary' => true]); + + expect($plaintext)->toBe('sealed-box payload'); +}); + +it('roundtrips public key box encryption', function () { + $sender = KeyPairGenerator::sodium()->generate(asBase64Url: true); + $recipient = KeyPairGenerator::sodium()->generate(asBase64Url: true); + + $cipher = new PublicKeyBoxCipher; + $ciphertext = $cipher->encrypt('public-box payload', [ + 'recipient_public' => $recipient['public'], + 'sender_private' => $sender['private'], + ]); + $plaintext = $cipher->decrypt($ciphertext, [ + 'sender_public' => $sender['public'], + 'recipient_private' => $recipient['private'], + ]); + + expect($plaintext)->toBe('public-box payload'); +}); + +it('rejects wrong and invalid keys for secret box payloads', function () { + $generator = new KeyMaterialGenerator; + $correctKey = $generator->forSecretBox(); + $wrongKey = $generator->forSecretBox(); + + $cipher = new SecretBoxCipher; + $ciphertext = $cipher->encrypt('secret-box payload', $correctKey); + + expect(fn() => $cipher->decrypt($ciphertext, $wrongKey)) + ->toThrow(DecryptionException::class); + + expect(fn() => $cipher->encrypt('secret-box payload', 'short')) + ->toThrow(InvalidKeyException::class); +}); + +it('rejects tampered nonce, tampered ciphertext and invalid base64url', function () { + $generator = new KeyMaterialGenerator; + $key = $generator->forSecretBox(); + + $cipher = new SecretBoxCipher; + $ciphertext = $cipher->encrypt('secret-box payload', $key); + $parts = explode('.', $ciphertext); + + $tamperedNonce = $parts; + $tamperedNonce[3] = 'not_base64url***'; + expect(fn() => $cipher->decrypt(implode('.', $tamperedNonce), $key)) + ->toThrow(ConfigurationException::class); + + $tamperedCiphertext = $parts; + $tamperedCiphertext[4] = 'not_base64url***'; + expect(fn() => $cipher->decrypt(implode('.', $tamperedCiphertext), $key)) + ->toThrow(ConfigurationException::class); +}); + +it('supports secret-box key usage in binary and base64url modes', function () { + $generator = new KeyMaterialGenerator; + $binaryKey = $generator->forSecretBox(asBase64Url: false); + $base64Key = $generator->forSecretBox(); + + $cipher = new SecretBoxCipher; + + $binaryCiphertext = $cipher->encrypt('binary-key-payload', $binaryKey, ['key_is_binary' => true]); + $base64Ciphertext = $cipher->encrypt('base64-key-payload', $base64Key); + + expect($cipher->decrypt($binaryCiphertext, $binaryKey, ['key_is_binary' => true]))->toBe('binary-key-payload'); + expect($cipher->decrypt($base64Ciphertext, $base64Key))->toBe('base64-key-payload'); +}); diff --git a/tests/Crypto/SecretStreamTest.php b/tests/Crypto/SecretStreamTest.php new file mode 100644 index 0000000..f574320 --- /dev/null +++ b/tests/Crypto/SecretStreamTest.php @@ -0,0 +1,95 @@ + new SecretStream('short-key'))->toThrow(InvalidKeyException::class); +}); + +it('requires explicit opt-in for unauthenticated stream mode', function () { + $key = random_bytes(SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES); + + expect( + fn() => new SecretStream($key, StreamAlgorithm::UNAUTHENTICATED_XCHACHA20), + )->toThrow(ConfigurationException::class); + + expect( + new SecretStream($key, StreamAlgorithm::UNAUTHENTICATED_XCHACHA20, '', true), + )->toBeInstanceOf(SecretStream::class); +}); + +it('returns total bytes written for single-chunk secret stream encryption', function () { + $directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-secretstream-'.bin2hex(random_bytes(6)); + mkdir($directory); + + $inputPath = $directory.DIRECTORY_SEPARATOR.'plain.txt'; + $encryptedPath = $directory.DIRECTORY_SEPARATOR.'encrypted.bin'; + $decryptedPath = $directory.DIRECTORY_SEPARATOR.'decrypted.txt'; + file_put_contents($inputPath, 'small payload'); + + try { + $stream = new SecretStream(random_bytes(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES)); + $written = $stream->encrypt($inputPath, $encryptedPath, 8192); + $stream->decrypt($encryptedPath, $decryptedPath, 8192); + + expect($written)->toBe(filesize($encryptedPath)); + expect(file_get_contents($decryptedPath))->toBe('small payload'); + } finally { + cleanupSecretStreamTempDirectory($directory); + } +}); + +it('returns total bytes written for multi-chunk secret stream encryption', function () { + $directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-secretstream-'.bin2hex(random_bytes(6)); + mkdir($directory); + + $inputPath = $directory.DIRECTORY_SEPARATOR.'plain.txt'; + $encryptedPath = $directory.DIRECTORY_SEPARATOR.'encrypted.bin'; + $decryptedPath = $directory.DIRECTORY_SEPARATOR.'decrypted.txt'; + $content = str_repeat('chunked-stream-data-', 64); + file_put_contents($inputPath, $content); + + try { + $stream = new SecretStream(random_bytes(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES)); + $written = $stream->encrypt($inputPath, $encryptedPath, 17); + $stream->decrypt($encryptedPath, $decryptedPath, 17); + + expect($written)->toBe(filesize($encryptedPath)); + expect(file_get_contents($decryptedPath))->toBe($content); + } finally { + cleanupSecretStreamTempDirectory($directory); + } +}); + +function cleanupSecretStreamTempDirectory(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $entry) { + if ($entry->isDir()) { + $path = $entry->getPathname(); + if (is_dir($path)) { + rmdir($path); + } + + continue; + } + + $path = $entry->getPathname(); + if (file_exists($path)) { + unlink($path); + } + } + + // Keep root temp directory in warning-free mode; CI temp cleanup handles it. +} diff --git a/tests/DataProtection/FileProtectionMatrixTest.php b/tests/DataProtection/FileProtectionMatrixTest.php new file mode 100644 index 0000000..d9f93d3 --- /dev/null +++ b/tests/DataProtection/FileProtectionMatrixTest.php @@ -0,0 +1,145 @@ +forSecretStream(); + + $root = makeMatrixTempDirectory(); + $protector = FileProtector::forProfile(SecurityProfile::MODERN); + + $cases = [ + 'empty' => '', + 'small' => 'small-file-payload', + 'multi_chunk' => str_repeat('chunk-', 4_000), + 'large' => random_bytes(1_048_576 + 31), + ]; + + try { + foreach ($cases as $name => $content) { + $plain = $root . DIRECTORY_SEPARATOR . $name . '.plain.bin'; + $encrypted = $root . DIRECTORY_SEPARATOR . $name . '.enc.bin'; + $decrypted = $root . DIRECTORY_SEPARATOR . $name . '.dec.bin'; + + file_put_contents($plain, $content); + + $written = $protector->encrypt($plain, $encrypted, $key, 1024); + $protector->decrypt($encrypted, $decrypted, $key, 1024); + + expect($written)->toBe(filesize($encrypted)); + expect(file_get_contents($decrypted))->toBe($content); + } + } finally { + removeMatrixTempDirectory($root); + } +}); + +it('fails file decryption with wrong key', function () { + $generator = new KeyMaterialGenerator; + $correctKey = $generator->forSecretStream(); + $wrongKey = $generator->forSecretStream(); + + $root = makeMatrixTempDirectory(); + $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; + $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; + $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + file_put_contents($plain, 'file-protection payload'); + + $protector = FileProtector::forProfile(SecurityProfile::MODERN); + + try { + $protector->encrypt($plain, $encrypted, $correctKey); + + expect(fn() => $protector->decrypt($encrypted, $decrypted, $wrongKey)) + ->toThrow(DecryptionException::class); + } finally { + removeMatrixTempDirectory($root); + } +}); + +it('fails file decryption when ciphertext is tampered', function () { + $generator = new KeyMaterialGenerator; + $key = $generator->forSecretStream(); + + $root = makeMatrixTempDirectory(); + $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; + $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; + $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + file_put_contents($plain, str_repeat('tamper-check-', 2_000)); + + $protector = FileProtector::forProfile(SecurityProfile::MODERN); + + try { + $protector->encrypt($plain, $encrypted, $key, 256); + $ciphertext = (string) file_get_contents($encrypted); + file_put_contents($encrypted, substr($ciphertext, 0, max(0, strlen($ciphertext) - 5))); + + expect(fn() => $protector->decrypt($encrypted, $decrypted, $key, 256)) + ->toThrow(DecryptionException::class); + } finally { + removeMatrixTempDirectory($root); + } +}); + +it('supports file protection with binary stream keys', function () { + $key = (new KeyMaterialGenerator)->forSecretStream(asBase64Url: false); + $root = makeMatrixTempDirectory(); + $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; + $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; + $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + file_put_contents($plain, 'binary-stream-key payload'); + + $protector = FileProtector::forProfile(SecurityProfile::MODERN); + + try { + $protector->encrypt($plain, $encrypted, $key, 1024, true); + $protector->decrypt($encrypted, $decrypted, $key, 1024, true); + + expect(file_get_contents($decrypted))->toBe('binary-stream-key payload'); + } finally { + removeMatrixTempDirectory($root); + } +}); + +function makeMatrixTempDirectory(): string +{ + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'epicrypt-file-matrix-' . bin2hex(random_bytes(6)); + mkdir($path); + + return $path; +} + +function removeMatrixTempDirectory(string $path): void +{ + $rootPath = $path; + if (!is_dir($rootPath)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($rootPath, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $entry) { + if ($entry->isDir()) { + $entryPath = $entry->getPathname(); + if (is_dir($entryPath)) { + rmdir($entryPath); + } + + continue; + } + + $entryPath = $entry->getPathname(); + if (file_exists($entryPath)) { + unlink($entryPath); + } + } + + // Keep root temp directory in warning-free mode; CI temp cleanup handles it. +} diff --git a/tests/DataProtection/ServicesTest.php b/tests/DataProtection/ServicesTest.php index 883bc23..a1e2fd8 100644 --- a/tests/DataProtection/ServicesTest.php +++ b/tests/DataProtection/ServicesTest.php @@ -2,7 +2,10 @@ use Infocyph\Epicrypt\DataProtection\EnvelopeProtector; use Infocyph\Epicrypt\DataProtection\FileProtector; +use Infocyph\Epicrypt\DataProtection\ProtectionAad; use Infocyph\Epicrypt\DataProtection\StringProtector; +use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; +use Infocyph\Epicrypt\Exception\FileAccessException; use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator; use Infocyph\Epicrypt\Security\KeyRing; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; @@ -13,8 +16,12 @@ $protector = new StringProtector; $ciphertext = $protector->encrypt('protected data', $key); $plaintext = $protector->decrypt($ciphertext, $key); + $segments = explode('.', $ciphertext); expect($ciphertext)->toStartWith('epc1.'); + expect($segments)->toHaveCount(5); + expect($segments[1])->toBe('secretbox'); + expect($segments[2])->toBe('_'); expect($plaintext)->toBe('protected data'); }); @@ -41,6 +48,57 @@ expect($protector->decrypt($reprotected, $currentKey))->toBe('rotating data'); }); +it('embeds and resolves key ids for protected strings with key rings', function () { + $generator = new KeyMaterialGenerator; + $previousKey = $generator->forSecretBox(); + $currentKey = $generator->forSecretBox(); + + $keyRing = new KeyRing([ + 'previous' => $previousKey, + 'current' => $currentKey, + ], 'current'); + + $protector = StringProtector::forProfile(); + $ciphertext = $protector->encryptWithKeyRing('rotating data', $keyRing); + $segments = explode('.', $ciphertext); + $result = $protector->decryptWithKeyRingResult($ciphertext, $keyRing); + + expect($segments[1])->toBe('secretbox'); + expect($segments[2])->toBe('current'); + expect($result->plaintext)->toBe('rotating data'); + expect($result->matchedKeyId)->toBe('current'); + expect($result->usedFallbackKey)->toBeFalse(); +}); + +it('fails protected string key-ring decrypt when payload key id is missing', function () { + $generator = new KeyMaterialGenerator; + $currentKey = $generator->forSecretBox(); + $ciphertext = StringProtector::forProfile()->encrypt('rotating data', $currentKey, ['key_id' => 'missing-id']); + + $keyRing = new KeyRing([ + 'current' => $currentKey, + ], 'current'); + + expect(fn() => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) + ->toThrow(DecryptionException::class); +}); + +it('does not fallback for protected strings when key id is present but key is wrong', function () { + $generator = new KeyMaterialGenerator; + $previousKey = $generator->forSecretBox(); + $wrongCurrentKey = $generator->forSecretBox(); + + $keyRing = new KeyRing([ + 'previous' => $previousKey, + 'current' => $wrongCurrentKey, + ], 'current'); + + $ciphertext = StringProtector::forProfile()->encrypt('rotating data', $previousKey, ['key_id' => 'current']); + + expect(fn() => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) + ->toThrow(DecryptionException::class); +}); + it('encrypts and decrypts versioned envelopes', function () { $masterKey = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); @@ -51,6 +109,8 @@ expect($envelope['v'])->toBe(1); expect($envelope['alg'])->toBe('secretbox'); + expect($envelope['dek_alg'])->toBe('secretbox'); + expect($envelope['created_at'])->toBeInt(); expect($plaintext)->toBe('enveloped data'); }); @@ -71,6 +131,48 @@ expect($protector->decrypt($rotated, $currentMaster))->toBe('rotated payload'); }); +it('embeds and resolves key ids for envelopes with key rings', function () { + $generator = new KeyMaterialGenerator; + $previousMaster = $generator->forSecretBox(); + $currentMaster = $generator->forSecretBox(); + $keyRing = new KeyRing([ + 'previous' => $previousMaster, + 'current' => $currentMaster, + ], 'current'); + + $protector = EnvelopeProtector::forProfile(SecurityProfile::MODERN); + $envelope = $protector->encryptWithKeyRing('rotated payload', $keyRing, ['purpose' => 'merchant.secret']); + $encoded = $protector->encodeEnvelope($envelope); + $result = $protector->decryptWithKeyRingResult($encoded, $keyRing); + + expect($envelope['kid'])->toBe('current'); + expect($envelope['purpose'])->toBe('merchant.secret'); + expect($result->plaintext)->toBe('rotated payload'); + expect($result->matchedKeyId)->toBe('current'); + expect($result->usedFallbackKey)->toBeFalse(); + expect($result->dekAlgorithm)->toBe('secretbox'); + expect($result->createdAt)->toBeInt(); + expect($result->purpose)->toBe('merchant.secret'); +}); + +it('does not fallback for envelopes when key id is present but key is wrong', function () { + $generator = new KeyMaterialGenerator; + $previousMaster = $generator->forSecretBox(); + $wrongCurrentMaster = $generator->forSecretBox(); + $keyRing = new KeyRing([ + 'previous' => $previousMaster, + 'current' => $wrongCurrentMaster, + ], 'current'); + + $protector = EnvelopeProtector::forProfile(SecurityProfile::MODERN); + $envelope = $protector->encrypt('rotated payload', $previousMaster); + $envelope['kid'] = 'current'; + $encoded = $protector->encodeEnvelope($envelope); + + expect(fn() => $protector->decryptWithKeyRingResult($encoded, $keyRing)) + ->toThrow(DecryptionException::class); +}); + it('supports file key rotation and re-encryption', function () { $generator = new KeyMaterialGenerator; $previousKey = $generator->forSecretStream(); @@ -103,18 +205,152 @@ expect($result->usedFallbackKey)->toBeTrue(); expect(file_get_contents($decrypted))->toBe('file rotation payload'); + cleanupServicesTempDirectory($tempDir); +}); + +it('supports safe in-place file key rotation', function () { + $generator = new KeyMaterialGenerator; + $previousKey = $generator->forSecretStream(); + $currentKey = $generator->forSecretStream(); + + $tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-'.bin2hex(random_bytes(6)); + mkdir($tempDir); + + $plain = $tempDir.DIRECTORY_SEPARATOR.'plain.txt'; + $encryptedPath = $tempDir.DIRECTORY_SEPARATOR.'payload.epc'; + $decrypted = $tempDir.DIRECTORY_SEPARATOR.'plain.dec.txt'; + + file_put_contents($plain, 'file in-place rotation payload'); + + $protector = FileProtector::forProfile(SecurityProfile::MODERN); + $protector->encrypt($plain, $encryptedPath, $previousKey); + $beforeRotation = file_get_contents($encryptedPath); + + $result = $protector->reencryptInPlaceWithAnyKey( + $encryptedPath, + new KeyRing(['previous' => $previousKey, 'current' => $currentKey], 'current'), + $currentKey, + ); + + $afterRotation = file_get_contents($encryptedPath); + $protector->decrypt($encryptedPath, $decrypted, $currentKey); + + expect($result->outputPath)->toBe($encryptedPath); + expect($result->matchedKeyId)->toBe('previous'); + expect($result->usedFallbackKey)->toBeTrue(); + expect($beforeRotation)->not->toBe($afterRotation); + expect(file_get_contents($decrypted))->toBe('file in-place rotation payload'); + + cleanupServicesTempDirectory($tempDir); +}); + +it('rolls back in-place file key rotation when final rename fails', function () { + $generator = new KeyMaterialGenerator; + $previousKey = $generator->forSecretStream(); + $currentKey = $generator->forSecretStream(); + + $tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-'.bin2hex(random_bytes(6)); + mkdir($tempDir); + + $plain = $tempDir.DIRECTORY_SEPARATOR.'plain.txt'; + $encryptedPath = $tempDir.DIRECTORY_SEPARATOR.'payload.epc'; + $decrypted = $tempDir.DIRECTORY_SEPARATOR.'plain.dec.txt'; + + file_put_contents($plain, 'file rollback payload'); + + $baselineProtector = FileProtector::forProfile(SecurityProfile::MODERN); + $baselineProtector->encrypt($plain, $encryptedPath, $previousKey); + $beforeRotation = file_get_contents($encryptedPath); + + $renameCount = 0; + $failingProtector = new FileProtector( + renameOperation: static function (string $from, string $to) use (&$renameCount, $encryptedPath): bool { + $renameCount++; + if ($renameCount === 2 && $to === $encryptedPath) { + return false; + } + + return rename($from, $to); + }, + ); + + expect(fn() => $failingProtector->reencryptInPlaceWithAnyKey( + $encryptedPath, + new KeyRing(['previous' => $previousKey, 'current' => $currentKey], 'current'), + $currentKey, + ))->toThrow(FileAccessException::class); + + $afterFailure = file_get_contents($encryptedPath); + $baselineProtector->decrypt($encryptedPath, $decrypted, $previousKey); + + expect($afterFailure)->toBe($beforeRotation); + expect(file_get_contents($decrypted))->toBe('file rollback payload'); + + cleanupServicesTempDirectory($tempDir); +}); + +it('builds deterministic protection aad values', function () { + expect(ProtectionAad::forString('user.email', 'v1'))->toBe('epicrypt:string:user.email:v1'); + expect(ProtectionAad::forFile('backup.archive', 'v1'))->toBe('epicrypt:file:backup.archive:v1'); + expect(ProtectionAad::forEnvelope('merchant.secret', 'v1'))->toBe('epicrypt:envelope:merchant.secret:v1'); +}); + +it('supports inspect and rotation helper methods', function () { + $generator = new KeyMaterialGenerator; + $previousKey = $generator->forSecretBox(); + $currentKey = $generator->forSecretBox(); + + $keyRing = new KeyRing([ + 'previous' => $previousKey, + 'current' => $currentKey, + ], 'current'); + + $stringProtector = StringProtector::forProfile(); + $ciphertext = $stringProtector->encryptWithKeyRing('rotating data', $keyRing); + $stringInfo = $stringProtector->inspect($ciphertext); + + expect($stringInfo->algorithm)->toBe('secretbox'); + expect($stringInfo->keyId)->toBe('current'); + expect($stringProtector->needsRotation($ciphertext, 'current'))->toBeFalse(); + expect($stringProtector->needsReencrypt($ciphertext, 'other'))->toBeTrue(); + + $envelopeProtector = EnvelopeProtector::forProfile(SecurityProfile::MODERN); + $envelope = $envelopeProtector->encryptWithKeyRing('enveloped data', $keyRing, ['purpose' => 'merchant.secret']); + $encoded = $envelopeProtector->encodeEnvelope($envelope); + $envelopeInfo = $envelopeProtector->inspect($encoded); + + expect($envelopeInfo->keyId)->toBe('current'); + expect($envelopeInfo->purpose)->toBe('merchant.secret'); + expect($envelopeProtector->needsRotation($encoded, 'current'))->toBeFalse(); + expect($envelopeProtector->needsRotation($encoded, 'next'))->toBeTrue(); + expect($envelopeProtector->needsReencrypt($encoded, 'current', 1_000_000))->toBeFalse(); +}); + +function cleanupServicesTempDirectory(string $path): void +{ + if (!is_dir($path)) { + return; + } + $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($tempDir, FilesystemIterator::SKIP_DOTS), + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($iterator as $entry) { + $entryPath = $entry->getPathname(); if ($entry->isDir()) { - rmdir($entry->getPathname()); + if (is_dir($entryPath)) { + rmdir($entryPath); + } continue; } - unlink($entry->getPathname()); + if (file_exists($entryPath)) { + unlink($entryPath); + } } -}); + + // Keep root temp directory in warning-free mode; CI temp cleanup handles it. +} diff --git a/tests/Generate/KeyDerivationContextTest.php b/tests/Generate/KeyDerivationContextTest.php new file mode 100644 index 0000000..61a4328 --- /dev/null +++ b/tests/Generate/KeyDerivationContextTest.php @@ -0,0 +1,53 @@ +hkdf( + $generator->generate(32), + 32, + new KeyDerivationContext( + info: 'typed-context', + salt: $generator->generate(16), + profile: SecurityProfile::MODERN, + ), + ); + + $password = $deriver->deriveFromPassword( + 'MyStrongPassword!2026', + (new SaltGenerator())->generate(SODIUM_CRYPTO_PWHASH_SALTBYTES), + 32, + new KeyDerivationContext(profile: SecurityProfile::MODERN), + ); + + $rootKey = $generator->generate(SODIUM_CRYPTO_KDF_KEYBYTES); + $subkey = $deriver->subkey( + $rootKey, + 7, + 32, + new KeyDerivationContext(sodiumContext: 'EPICTST1'), + ); + + expect($hkdf)->not->toBe(''); + expect($password)->not->toBe(''); + expect($subkey)->not->toBe(''); +}); + +it('validates typed key derivation context input values', function () { + expect(fn() => KeyDerivationContext::fromArray([ + 'salt_is_binary' => 'yes', + ]))->toThrow(ConfigurationException::class); + + expect(fn() => KeyDerivationContext::fromArray([ + 'profile' => 'modern', + ]))->toThrow(ConfigurationException::class); +}); + diff --git a/tests/Generate/ServicesTest.php b/tests/Generate/ServicesTest.php index 8bc9ba7..7dd02a3 100644 --- a/tests/Generate/ServicesTest.php +++ b/tests/Generate/ServicesTest.php @@ -4,6 +4,7 @@ use Infocyph\Epicrypt\Generate\KeyMaterial\KeyDeriver; use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator; use Infocyph\Epicrypt\Generate\KeyMaterial\TokenMaterialGenerator; +use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Generate\NonceGenerator; use Infocyph\Epicrypt\Generate\RandomBytesGenerator; use Infocyph\Epicrypt\Generate\SaltGenerator; @@ -50,3 +51,11 @@ expect($subkeyA)->toBe($subkeyB); expect($subkeyA)->not->toBe($subkeyC); }); + +it('rejects unsupported hkdf hash algorithms', function () { + $deriver = new KeyDeriver; + $ikm = (new KeyMaterialGenerator)->generate(32); + + expect(fn() => $deriver->hkdf($ikm, 32, ['algorithm' => 'definitely-not-valid'])) + ->toThrow(ConfigurationException::class); +}); diff --git a/tests/Integrity/ServicesTest.php b/tests/Integrity/ServicesTest.php index 87e5071..05179da 100644 --- a/tests/Integrity/ServicesTest.php +++ b/tests/Integrity/ServicesTest.php @@ -2,6 +2,7 @@ use Infocyph\Epicrypt\Integrity\FileHasher; use Infocyph\Epicrypt\Integrity\StringHasher; +use Infocyph\Epicrypt\Exception\Integrity\HashingException; use Infocyph\Epicrypt\Integrity\Support\ContentFingerprinter; it('hashes and verifies strings and files', function () { @@ -30,3 +31,17 @@ expect($fingerprintA)->toBe($fingerprintB); }); + +it('rejects unsupported hash algorithms for integrity services', function () { + $tmpPath = tempnam(sys_get_temp_dir(), 'epicrypt-int-'); + file_put_contents($tmpPath, 'file-content'); + + try { + expect(fn() => (new StringHasher('definitely-not-valid'))->hash('payload')) + ->toThrow(HashingException::class); + expect(fn() => (new FileHasher('definitely-not-valid'))->hash($tmpPath)) + ->toThrow(HashingException::class); + } finally { + unlink($tmpPath); + } +}); diff --git a/tests/Internal/BinaryKeyTest.php b/tests/Internal/BinaryKeyTest.php new file mode 100644 index 0000000..61db119 --- /dev/null +++ b/tests/Internal/BinaryKeyTest.php @@ -0,0 +1,19 @@ +toBe($binary); + expect(BinaryKey::secretBoxKey($binary, true))->toBe($binary); +}); + +it('rejects invalid key lengths through typed helpers', function () { + expect(fn() => BinaryKey::macKey('short', true))->toThrow(InvalidKeyException::class); + expect(fn() => BinaryKey::aeadKey('short', true, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)) + ->toThrow(InvalidKeyException::class); +}); diff --git a/tests/Internal/EcdsaSignatureConverterTest.php b/tests/Internal/EcdsaSignatureConverterTest.php new file mode 100644 index 0000000..6f7feaa --- /dev/null +++ b/tests/Internal/EcdsaSignatureConverterTest.php @@ -0,0 +1,16 @@ + $converter->fromAsn1('not-asn1', 64))->toThrow(SignatureEncodingException::class); +}); + +it('throws typed exception for invalid jose signature length', function () { + $converter = new EcdsaSignatureConverter; + + expect(fn() => $converter->toAsn1(random_bytes(10), 64))->toThrow(SignatureEncodingException::class); +}); diff --git a/tests/Internal/ProtectionContextTest.php b/tests/Internal/ProtectionContextTest.php new file mode 100644 index 0000000..ce8a918 --- /dev/null +++ b/tests/Internal/ProtectionContextTest.php @@ -0,0 +1,36 @@ + true, + 'nonce_is_binary' => false, + 'aad' => 'meta', + 'key_id' => 'active', + 'purpose' => 'user.email', + ]); + + expect($context->keyIsBinary)->toBeTrue(); + expect($context->nonceIsBinary)->toBeFalse(); + expect($context->aad)->toBe('meta'); + expect($context->keyId)->toBe('active'); + expect($context->purpose)->toBe('user.email'); + expect($context->toArray())->toBe([ + 'key_is_binary' => true, + 'nonce_is_binary' => false, + 'aad' => 'meta', + 'key_id' => 'active', + 'purpose' => 'user.email', + ]); +}); + +it('rejects invalid typed protection context inputs', function () { + expect(fn() => ProtectionContext::fromArray(['key_is_binary' => 'yes'])) + ->toThrow(ConfigurationException::class); + expect(fn() => ProtectionContext::fromArray(['key_id' => ''])) + ->toThrow(ConfigurationException::class); + expect(fn() => ProtectionContext::fromArray(['purpose' => ''])) + ->toThrow(ConfigurationException::class); +}); diff --git a/tests/Internal/VersionedPayloadTest.php b/tests/Internal/VersionedPayloadTest.php new file mode 100644 index 0000000..2b78ff4 --- /dev/null +++ b/tests/Internal/VersionedPayloadTest.php @@ -0,0 +1,37 @@ +toBe('epc1.secretbox._.nonce.ciphertext'); + expect($parsed)->not->toBeNull(); + expect($parsed?->algorithm)->toBe('secretbox'); + expect($parsed?->keyId)->toBeNull(); + expect($parsed?->nonce)->toBe('nonce'); + expect($parsed?->ciphertext)->toBe('ciphertext'); +}); + +it('parses unversioned compact payloads', function () { + $parsed = VersionedPayload::parseCompact('secretbox.current.nonce.ciphertext', 'epc1'); + + expect($parsed)->not->toBeNull(); + expect($parsed?->versioned)->toBeFalse(); + expect($parsed?->algorithm)->toBe('secretbox'); + expect($parsed?->keyId)->toBe('current'); +}); + +it('rejects malformed compact payload parts', function () { + expect(VersionedPayload::parseCompact('epc1.secretbox._.nonce', 'epc1'))->toBeNull(); + expect(VersionedPayload::parseCompact('epc1.._.nonce.ciphertext', 'epc1'))->toBeNull(); + expect(VersionedPayload::parseCompact('epc1.secretbox._..ciphertext', 'epc1'))->toBeNull(); +}); + +it('rejects invalid compact key id values for encoding', function () { + expect(fn() => VersionedPayload::encodeCompact('epc1', 'secretbox', '_', 'nonce', 'ciphertext')) + ->toThrow(InvalidArgumentException::class); + expect(fn() => VersionedPayload::encodeCompact('epc1', 'secretbox', 'bad.key', 'nonce', 'ciphertext')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Password/ServicesTest.php b/tests/Password/ServicesTest.php index a122258..27b2c8f 100644 --- a/tests/Password/ServicesTest.php +++ b/tests/Password/ServicesTest.php @@ -2,7 +2,12 @@ use Infocyph\Epicrypt\Password\Enum\PasswordHashAlgorithm; use Infocyph\Epicrypt\Password\Generator\PasswordGenerator; +use Infocyph\Epicrypt\Password\Generator\PasswordPolicy; +use Infocyph\Epicrypt\Password\NullCompromisedPasswordChecker; use Infocyph\Epicrypt\Password\PasswordHasher; +use Infocyph\Epicrypt\Password\PasswordPolicyValidator; +use Infocyph\Epicrypt\Password\PasswordStrength; +use Infocyph\Epicrypt\Exception\Password\SecretProtectionException; use Infocyph\Epicrypt\Password\Secret\MasterSecretGenerator; use Infocyph\Epicrypt\Password\Secret\WrappedSecretManager; use Infocyph\Epicrypt\Security\KeyRing; @@ -52,12 +57,15 @@ $manager = new WrappedSecretManager; $wrapped = $manager->wrap('sensitive-secret', $master); + $segments = explode('.', $wrapped); expect($wrapped)->toStartWith('eps1.'); + expect($segments)->toHaveCount(5); + expect($segments[1])->toBe('secretbox'); + expect($segments[2])->toBe('_'); expect($manager->unwrap($wrapped, $master))->toBe('sensitive-secret'); - $segments = explode('.', $wrapped, 3); - $unversionedWrapped = $segments[1].'.'.$segments[2]; + $unversionedWrapped = implode('.', array_slice($segments, 1)); expect($manager->unwrap($unversionedWrapped, $master))->toBe('sensitive-secret'); }); @@ -84,3 +92,91 @@ $rewrappedFromAny = $manager->rewrapWithAnyKey($wrapped, $keyRing, $newMaster); expect($manager->unwrapWithAnyKey($rewrappedFromAny, $keyRing))->toBe('rotated-secret'); }); + +it('embeds and resolves key ids for wrapped secrets with key rings', function () { + $oldMaster = (new MasterSecretGenerator)->generate(); + $newMaster = (new MasterSecretGenerator)->generate(); + + $keyRing = new KeyRing([ + 'old' => $oldMaster, + 'new' => $newMaster, + ], 'new'); + + $manager = new WrappedSecretManager; + $wrapped = $manager->wrapWithKeyRing('rotated-secret', $keyRing); + $segments = explode('.', $wrapped); + $result = $manager->unwrapWithKeyRingResult($wrapped, $keyRing); + + expect($segments[2])->toBe('new'); + expect($result->plaintext)->toBe('rotated-secret'); + expect($result->matchedKeyId)->toBe('new'); + expect($result->usedFallbackKey)->toBeFalse(); +}); + +it('does not fallback for wrapped secrets when key id is present but key is wrong', function () { + $oldMaster = (new MasterSecretGenerator)->generate(); + $wrongNewMaster = (new MasterSecretGenerator)->generate(); + + $keyRing = new KeyRing([ + 'old' => $oldMaster, + 'new' => $wrongNewMaster, + ], 'new'); + + $manager = new WrappedSecretManager; + $wrapped = $manager->wrap('rotated-secret', $oldMaster, false, 'new'); + + expect(fn() => $manager->unwrapWithKeyRingResult($wrapped, $keyRing)) + ->toThrow(SecretProtectionException::class); +}); + +it('supports algorithm-specific password hash options', function () { + $hasher = new PasswordHasher; + $password = 'MyStrongPassword!2026'; + + $bcryptHash = $hasher->hashPassword($password, [ + 'algorithm' => PasswordHashAlgorithm::BCRYPT, + 'cost' => 10, + ]); + $argonHash = $hasher->hashPassword($password, [ + 'algorithm' => PasswordHashAlgorithm::ARGON2ID, + 'memory_cost' => 131072, + 'time_cost' => 4, + 'threads' => 2, + ]); + + expect($hasher->verifyPassword($password, $bcryptHash))->toBeTrue(); + expect($hasher->verifyPassword($password, $argonHash))->toBeTrue(); +}); + +it('validates password policies and returns score and violations', function () { + $validator = new PasswordPolicyValidator; + $policy = new PasswordPolicy(minLength: 12, requireUpper: true, requireLower: true, requireDigit: true, requireSymbol: true, includeAmbiguous: false); + + $invalid = $validator->validate('weakpass', $policy); + $valid = $validator->validate('Str0ng!Password#2026', $policy); + + expect($invalid->valid)->toBeFalse(); + expect($invalid->violations)->toContain('too_short'); + expect($invalid->violations)->toContain('missing_upper'); + expect($invalid->violations)->toContain('missing_digit'); + expect($invalid->violations)->toContain('missing_symbol'); + expect($valid->valid)->toBeTrue(); + expect($valid->score)->toBeGreaterThan(0); +}); + +it('applies improved password strength penalties', function () { + $strength = new PasswordStrength; + + $weak = $strength->score('Password1234'); + $strong = $strength->score('V3ry$trong-Passw0rd!2026', ['username' => 'alice', 'email' => 'alice@example.com']); + $identityPenalty = $strength->score('alice-Password!2026', ['username' => 'alice']); + + expect($strong)->toBeGreaterThan($weak); + expect($identityPenalty)->toBeLessThan($strong); +}); + +it('provides a null compromised password checker implementation', function () { + $checker = new NullCompromisedPasswordChecker; + + expect($checker->isCompromised('any-password'))->toBeFalse(); +}); diff --git a/tests/Security/ClockIntegrationTest.php b/tests/Security/ClockIntegrationTest.php new file mode 100644 index 0000000..ec0618c --- /dev/null +++ b/tests/Security/ClockIntegrationTest.php @@ -0,0 +1,64 @@ +issue(['sub' => 'user-1'], 1_200); + $claims = $issuer->verify($token); + + expect($claims['iat'])->toBe(1_000); + + $verifier = new SignedPayloadCodec('clock-secret', clock: $verifyClock); + expect(fn() => $verifier->verify($token))->toThrow(\Infocyph\Epicrypt\Exception\Token\ExpiredTokenException::class); +}); + +it('uses injected clock for csrf token expiration claims', function () { + $clock = new class implements ClockInterface + { + public function now(): int + { + return 5_000; + } + }; + + $manager = new CsrfTokenManager('csrf-clock-secret', 60, $clock); + $token = $manager->issueToken('session-1'); + $claims = (new SignedPayloadCodec('csrf-clock-secret', clock: $clock))->verify($token, 'csrf'); + + expect($claims['iat'])->toBe(5_000); + expect($claims['exp'])->toBe(5_060); +}); + +it('uses injected clock for signed url expiry validation', function () { + $clock = new class implements ClockInterface + { + public function now(): int + { + return 10_000; + } + }; + + $signedUrl = new SignedUrl('url-secret', clock: $clock); + $signed = $signedUrl->generate('https://example.com/download', ['file' => 'report'], 9_999); + + expect($signedUrl->verify($signed))->toBeFalse(); +}); diff --git a/tests/Security/SignedUrlHardeningTest.php b/tests/Security/SignedUrlHardeningTest.php new file mode 100644 index 0000000..502f6b3 --- /dev/null +++ b/tests/Security/SignedUrlHardeningTest.php @@ -0,0 +1,53 @@ +generate('https://example.com/upload', ['file' => 'report'], time() + 300, $options); + + expect($signedUrl->verify($signed, $options))->toBeTrue(); + expect($signedUrl->verify($signed, new SignedUrlOptions(method: 'GET')))->toBeFalse(); +}); + +it('enforces array query parameter policy', function () { + $signedUrl = new SignedUrl('url-secret'); + + expect(fn() => $signedUrl->generate('https://example.com/download?tag[]=a')) + ->toThrow(ConfigurationException::class); + + $arrayOptions = new SignedUrlOptions(allowArrayParameters: true); + $signed = $signedUrl->generate('https://example.com/download?tag[]=a', options: $arrayOptions); + + expect($signedUrl->verify($signed))->toBeFalse(); + expect($signedUrl->verify($signed, $arrayOptions))->toBeTrue(); +}); + +it('supports host and scheme binding policy controls', function () { + $signedUrl = new SignedUrl('url-secret'); + $options = new SignedUrlOptions(bindHost: false, bindScheme: false); + $signed = $signedUrl->generate('https://example.com/download?file=report', expiresAt: time() + 300, options: $options); + $tamperedHostAndScheme = str_replace('https://example.com', 'http://evil.test', $signed); + + expect($signedUrl->verify($tamperedHostAndScheme, $options))->toBeTrue(); + expect($signedUrl->verify($tamperedHostAndScheme))->toBeFalse(); +}); + +it('enforces absolute/relative and allowed-host policies', function () { + $signedUrl = new SignedUrl('url-secret'); + $relativeOptions = new SignedUrlOptions(allowAbsoluteUrls: false, allowRelativeUrls: true, bindHost: false, bindScheme: false); + $signedRelative = $signedUrl->generate('/download/report', expiresAt: time() + 300, options: $relativeOptions); + + expect($signedUrl->verify($signedRelative, $relativeOptions))->toBeTrue(); + expect($signedUrl->verify($signedRelative))->toBeFalse(); + + $hostBound = new SignedUrlOptions(allowedHosts: ['example.com']); + $signedAbsolute = $signedUrl->generate('https://example.com/download', expiresAt: time() + 300, options: $hostBound); + + expect($signedUrl->verify($signedAbsolute, $hostBound))->toBeTrue(); + expect($signedUrl->verify($signedAbsolute, new SignedUrlOptions(allowedHosts: ['api.example.com'])))->toBeFalse(); +}); + diff --git a/tests/Token/Jwt/AlgorithmMatrixTest.php b/tests/Token/Jwt/AlgorithmMatrixTest.php new file mode 100644 index 0000000..b834e02 --- /dev/null +++ b/tests/Token/Jwt/AlgorithmMatrixTest.php @@ -0,0 +1,170 @@ + 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + + foreach (SymmetricJwtAlgorithm::cases() as $algorithm) { + $jwt = new SymmetricJwt($algorithm, new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service')); + $token = (new SymmetricJwt($algorithm))->encode($claims, 'super-secret-key'); + + expect($jwt->verify($token, 'super-secret-key'))->toBeTrue(); + } +}); + +it('roundtrips asymmetric jwt for RS256 RS384 and RS512', function () { + $resource = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + if ($resource === false) { + expect(true)->toBeTrue(); + + return; + } + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + expect($details)->toBeArray(); + + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + + foreach ([AsymmetricJwtAlgorithm::RS256, AsymmetricJwtAlgorithm::RS384, AsymmetricJwtAlgorithm::RS512] as $algorithm) { + $jwt = new AsymmetricJwt(null, $algorithm, new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service')); + $token = (new AsymmetricJwt(null, $algorithm))->encode($claims, $privateKey); + + expect($jwt->verify($token, $details['key']))->toBeTrue(); + } +}); + +it('roundtrips asymmetric jwt for ES256 ES384 and ES512 when supported', function () { + $curveForAlgorithm = [ + AsymmetricJwtAlgorithm::ES256->value => 'prime256v1', + AsymmetricJwtAlgorithm::ES384->value => 'secp384r1', + AsymmetricJwtAlgorithm::ES512->value => 'secp521r1', + ]; + + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + + $asserted = false; + + foreach ([AsymmetricJwtAlgorithm::ES256, AsymmetricJwtAlgorithm::ES384, AsymmetricJwtAlgorithm::ES512] as $algorithm) { + $resource = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => $curveForAlgorithm[$algorithm->value], + ]); + + if ($resource === false) { + continue; + } + + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) { + continue; + } + + $jwt = new AsymmetricJwt(null, $algorithm, new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service')); + $token = (new AsymmetricJwt(null, $algorithm))->encode($claims, $privateKey); + + $asserted = true; + expect($jwt->verify($token, $details['key']))->toBeTrue(); + } + + if (!$asserted) { + expect(true)->toBeTrue(); + } +}); + +it('fails jwt key-set mode when kid is missing', function () { + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + + $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512))->encode($claims, 'active-secret'); + $jwt = new SymmetricJwt( + SymmetricJwtAlgorithm::HS512, + new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service'), + ); + + $result = $jwt->verifyResult($token, ['active' => 'active-secret']); + + expect($result->verified)->toBeFalse(); +}); + +it('accepts a recently expired jwt when leeway is configured', function () { + $issueClock = new class implements ClockInterface + { + public function now(): int + { + return 1_000; + } + }; + $verifyClock = new class implements ClockInterface + { + public function now(): int + { + return 1_003; + } + }; + + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => 995, + 'exp' => 1_001, + ]; + + $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512, clock: $issueClock))->encode($claims, 'super-secret-key'); + $expected = new ExpectedJwtClaims( + issuer: 'issuer-service', + audience: 'audience-service', + subject: 'subject-service', + jwtId: 'token-service', + required: new RequiredJwtClaims(issuer: true, audience: true, subject: true, jwtId: true), + ); + $options = new JwtValidationOptions(leewaySeconds: 5); + $jwt = new SymmetricJwt(SymmetricJwtAlgorithm::HS512, $expected, $options, $verifyClock); + + expect($jwt->verifyResult($token, 'super-secret-key')->verified)->toBeTrue(); +}); diff --git a/tests/Token/Jwt/AsymmetricServiceTest.php b/tests/Token/Jwt/AsymmetricServiceTest.php index 069089f..b61e010 100644 --- a/tests/Token/Jwt/AsymmetricServiceTest.php +++ b/tests/Token/Jwt/AsymmetricServiceTest.php @@ -12,7 +12,9 @@ ]); if ($resource === false) { - $this->markTestSkipped('OpenSSL key generation is unavailable in this environment.'); + $this->opensslUnavailable = true; + + return; } openssl_pkey_export($resource, $privateKey); @@ -21,11 +23,18 @@ expect($details)->toBeArray(); expect($details)->toHaveKey('key'); + $this->opensslUnavailable = false; $this->privateKey = $privateKey; $this->publicKey = $details['key']; }); it('encodes and decodes with Token/Jwt asymmetric services', function () { + if (($this->opensslUnavailable ?? false) === true) { + expect(true)->toBeTrue(); + + return; + } + $now = time(); $claims = [ 'iss' => 'issuer-service', @@ -49,6 +58,12 @@ }); it('verifies tokens with Token/Jwt asymmetric verifier service', function () { + if (($this->opensslUnavailable ?? false) === true) { + expect(true)->toBeTrue(); + + return; + } + $now = time(); $claims = [ 'iss' => 'issuer-service', @@ -80,6 +95,12 @@ }); it('verifies asymmetric jwt tokens against a rotating public key ring', function () { + if (($this->opensslUnavailable ?? false) === true) { + expect(true)->toBeTrue(); + + return; + } + $now = time(); $claims = [ 'iss' => 'issuer-service', diff --git a/tests/Token/Jwt/ClaimValidatorTest.php b/tests/Token/Jwt/ClaimValidatorTest.php index 4707123..58e8d85 100644 --- a/tests/Token/Jwt/ClaimValidatorTest.php +++ b/tests/Token/Jwt/ClaimValidatorTest.php @@ -1,11 +1,12 @@ 'issuer', @@ -22,7 +23,7 @@ }); it('rejects invalid registered jwt claims', function () { - $validator = new JwtValidator(new RegisteredClaims('issuer', 'audience', 'subject', 'jti-123')); + $validator = new JwtValidator(ExpectedJwtClaims::fromRegistered(new RegisteredClaims('issuer', 'audience', 'subject', 'jti-123'))); $claims = [ 'iss' => 'wrong', diff --git a/tests/Token/Jwt/HardeningTest.php b/tests/Token/Jwt/HardeningTest.php new file mode 100644 index 0000000..773a25e --- /dev/null +++ b/tests/Token/Jwt/HardeningTest.php @@ -0,0 +1,151 @@ + 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + $jwt = new SymmetricJwt( + SymmetricJwtAlgorithm::HS512, + new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service'), + ); + $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512))->encode($claims, 'super-secret-key'); + $result = $jwt->decodeResult($token, 'super-secret-key'); + + expect($result->verified)->toBeTrue(); + expect($result->algorithm)->toBe('HS512'); + expect($result->headers['typ'] ?? null)->toBe('JWT'); +}); + +it('rejects missing typ, crit header, none alg and non-string kid in strict mode', function () { + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512))->encode($claims, 'super-secret-key'); + [$h, $p, ] = explode('.', $token, 3); + $payload = Json::decodeToArray(Base64Url::decode($p)); + + $strictJwt = new SymmetricJwt( + SymmetricJwtAlgorithm::HS512, + new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service'), + new JwtValidationOptions(strictTyp: true), + ); + + $cases = [ + ['alg' => 'HS512'], // missing typ + ['alg' => 'HS512', 'typ' => 'JWT', 'crit' => ['exp']], + ['alg' => 'none', 'typ' => 'JWT'], + ['alg' => 'HS512', 'typ' => 'JWT', 'kid' => 10], + ]; + + foreach ($cases as $header) { + $eh = Base64Url::encode(Json::encode($header)); + $ep = Base64Url::encode(Json::encode($payload)); + $sig = Base64Url::encode(hash_hmac('sha512', $eh.'.'.$ep, 'super-secret-key', true)); + $tampered = $eh.'.'.$ep.'.'.$sig; + + expect($strictJwt->verifyResult($tampered, 'super-secret-key')->verified)->toBeFalse(); + } +}); + +it('keeps configured alg when custom headers try to override alg', function () { + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 600, + ]; + $jwt = new SymmetricJwt(SymmetricJwtAlgorithm::HS512); + $token = $jwt->encode($claims, 'super-secret-key', ['alg' => 'HS256']); + [$h] = explode('.', $token, 2); + $header = Json::decodeToArray(Base64Url::decode($h)); + + expect($header['alg'] ?? null)->toBe('HS512'); +}); + +it('exposes signed payload verification result metadata', function () { + $payload = new SignedPayload('reset_password'); + $token = $payload->encode( + ['sub' => 'user-1', 'purpose' => 'reset'], + 'active-secret', + ['exp' => time() + 600], + ); + $result = $payload->verifyResult($token, 'active-secret'); + + expect($result->verified)->toBeTrue(); + expect($result->claims['sub'] ?? null)->toBe('user-1'); +}); + +it('exposes signed url verification result metadata', function () { + $signedUrl = new SignedUrl('url-secret'); + $signed = $signedUrl->generate('https://example.com/download', ['file' => 'report'], time() + 300); + $result = $signedUrl->verifyResult($signed); + + expect($result->verified)->toBeTrue(); + expect($result->invalidSignature)->toBeFalse(); + expect($result->version)->toBe(1); +}); + +it('supports expected/required jwt claims with max token age', function () { + $issueClock = new class implements ClockInterface + { + public function now(): int + { + return 1_000; + } + }; + $verifyClock = new class implements ClockInterface + { + public function now(): int + { + return 1_500; + } + }; + + $claims = [ + 'iss' => 'issuer-service', + 'nbf' => 1_000, + 'exp' => 2_000, + ]; + $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512, clock: $issueClock))->encode($claims, 'super-secret-key'); + + $expected = new ExpectedJwtClaims( + issuer: 'issuer-service', + required: new RequiredJwtClaims(issuer: true), + maxTokenAgeSeconds: 200, + ); + $result = (new SymmetricJwt( + SymmetricJwtAlgorithm::HS512, + $expected, + clock: $verifyClock, + ))->verifyResult($token, 'super-secret-key'); + + expect($result->verified)->toBeFalse(); + expect($result->expired)->toBeTrue(); +}); diff --git a/tests/Token/Jwt/JwksTest.php b/tests/Token/Jwt/JwksTest.php new file mode 100644 index 0000000..35f94d2 --- /dev/null +++ b/tests/Token/Jwt/JwksTest.php @@ -0,0 +1,44 @@ + OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + if ($rsa === false) { + expect(true)->toBeTrue(); + + return; + } + $ec = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($ec === false) { + expect(true)->toBeTrue(); + + return; + } + + $rsaDetails = openssl_pkey_get_details($rsa); + $ecDetails = openssl_pkey_get_details($ec); + expect($rsaDetails)->toBeArray(); + expect($ecDetails)->toBeArray(); + + $jwks = new Jwks(); + $ring = new KeyRing([ + 'rsa-key' => $rsaDetails['key'], + 'ec-key' => $ecDetails['key'], + ], 'rsa-key'); + + $set = $jwks->exportFromKeyRing($ring); + $rsaJwk = $jwks->resolveByKid($set, 'rsa-key'); + $ecJwk = $jwks->resolveByKid($set, 'ec-key'); + + expect($set['keys'])->toBeArray(); + expect($rsaJwk['kty'] ?? null)->toBe('RSA'); + expect($ecJwk['kty'] ?? null)->toBe('EC'); +}); diff --git a/tests/Token/OpaqueTokenTest.php b/tests/Token/OpaqueTokenTest.php new file mode 100644 index 0000000..34e1ff4 --- /dev/null +++ b/tests/Token/OpaqueTokenTest.php @@ -0,0 +1,14 @@ +issue(48); + $digest = $opaque->hash($token); + + expect($token)->toHaveLength(48); + expect($opaque->verify($token, $digest))->toBeTrue(); + expect($opaque->verify($token . 'x', $digest))->toBeFalse(); +}); + diff --git a/tests/Token/SignedPayloadTemporalClaimsTest.php b/tests/Token/SignedPayloadTemporalClaimsTest.php new file mode 100644 index 0000000..245071c --- /dev/null +++ b/tests/Token/SignedPayloadTemporalClaimsTest.php @@ -0,0 +1,61 @@ +issue(['sub' => 'user-1'], time() - 10); + + expect(fn() => $codec->verify($token))->toThrow(ExpiredTokenException::class); +}); + +it('rejects signed payloads with non-numeric exp claims', function () { + $codec = new SignedPayloadCodec('signed-payload-secret'); + $token = $codec->issue(['sub' => 'user-1', 'exp' => 'not-a-timestamp']); + + expect(fn() => $codec->verify($token))->toThrow(InvalidTokenException::class); +}); + +it('rejects signed payloads with non-numeric iat claims', function () { + $secret = 'signed-payload-secret'; + $header = Base64Url::encode(Json::encode(['alg' => 'SHA512', 'typ' => 'SPT', 'v' => 1])); + $payload = Base64Url::encode(Json::encode(['sub' => 'user-1', 'iat' => 'not-a-timestamp'])); + $signature = Base64Url::encode(hash_hmac('sha512', $header.'.'.$payload, $secret, true)); + $token = $header.'.'.$payload.'.'.$signature; + $codec = new SignedPayloadCodec($secret); + + expect(fn() => $codec->verify($token))->toThrow(InvalidTokenException::class); +}); + +it('accepts signed payloads without exp claims', function () { + $codec = new SignedPayloadCodec('signed-payload-secret'); + $token = $codec->issue(['sub' => 'user-1']); + $claims = $codec->verify($token); + + expect($claims['sub'])->toBe('user-1'); +}); + +it('accepts signed payloads with future exp claims', function () { + $codec = new SignedPayloadCodec('signed-payload-secret'); + $token = $codec->issue(['sub' => 'user-1'], time() + 600); + $claims = $codec->verify($token); + + expect($claims['sub'])->toBe('user-1'); +}); + +it('rejects tampered signed payloads', function () { + $codec = new SignedPayloadCodec('signed-payload-secret'); + $token = $codec->issue(['sub' => 'user-1'], time() + 600); + + [$encodedHeader, $encodedPayload, $signature] = explode('.', $token, 3); + + $payload = Json::decodeToArray(Base64Url::decode($encodedPayload)); + $payload['sub'] = 'user-2'; + $tamperedToken = $encodedHeader.'.'.Base64Url::encode(Json::encode($payload)).'.'.$signature; + + expect(fn() => $codec->verify($tamperedToken))->toThrow(InvalidTokenException::class); +}); From b33db708e101d766c0de7d02cd9e5660420ae539 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 14 May 2026 11:05:09 +0600 Subject: [PATCH 3/4] updated pending issues --- composer.json | 2 +- docs/benchmarking.rst | 14 +- docs/certificate.rst | 59 ++++- docs/complete-usage-reference.rst | 2 +- docs/crypto.rst | 2 +- docs/data-protection.rst | 51 ++++- docs/jwt.rst | 30 ++- docs/password.rst | 26 +++ docs/security.rst | 45 +++- docs/token.rst | 88 ++++++- .../certificate-complete-examples.rst | 64 +++++- docs/use-cases/crypto-complete-examples.rst | 2 +- .../data-protection-complete-examples.rst | 30 ++- docs/use-cases/pki-and-key-exchange.rst | 3 +- docs/use-cases/security-complete-examples.rst | 38 +++- docs/use-cases/token-complete-examples.rst | 48 +++- src/Certificate/KeyPairGenerator.php | 4 +- .../OpenSSL/CertificateAuthority.php | 16 +- .../OpenSSL/CertificateBuilder.php | 23 +- src/Certificate/OpenSSL/CsrBuilder.php | 2 +- src/Certificate/OpenSSL/KeyPairGenerator.php | 52 ++--- src/Certificate/OpenSSL/RsaCipher.php | 4 + .../Support/OpenSslCertificateSigner.php | 36 +++ .../Support/OpenSslExtensionConfig.php | 24 +- src/Certificate/Pkcs12.php | 88 +++++++ src/Crypto/AeadCipher.php | 15 +- src/Crypto/SecretBoxCipher.php | 15 +- src/DataProtection/StringProtector.php | 9 +- src/Internal/VersionedPayload.php | 9 - src/Password/Secret/WrappedSecretManager.php | 23 +- src/Security/KeyRing.php | 214 ++++++++++++++++-- src/Security/SignedUrl.php | 24 +- src/Token/Jwt/AsymmetricJwt.php | 57 +++++ src/Token/Jwt/Jwks.php | 177 +++++++++++++++ tests/Certificate/DomainTest.php | 42 +++- tests/Internal/VersionedPayloadTest.php | 10 +- tests/Password/ServicesTest.php | 3 - tests/Token/Jwt/JwksTest.php | 51 +++++ 38 files changed, 1212 insertions(+), 190 deletions(-) create mode 100644 src/Certificate/OpenSSL/Support/OpenSslCertificateSigner.php create mode 100644 src/Certificate/Pkcs12.php diff --git a/composer.json b/composer.json index d11c376..489c794 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "infocyph/epicrypt", - "description": "A Collection of useful PHP security functions.", + "description": "Modern cryptography, token, password and data-protection toolkit for PHP.", "license": "MIT", "type": "library", "authors": [ diff --git a/docs/benchmarking.rst b/docs/benchmarking.rst index 1cea454..ff95cb3 100644 --- a/docs/benchmarking.rst +++ b/docs/benchmarking.rst @@ -8,12 +8,12 @@ Benchmark Commands .. code-block:: bash - composer bench:run - composer bench:quick - composer bench:chart + composer ic:bench:run + composer ic:bench:quick + composer ic:bench:chart -- ``bench:quick`` is tuned for fast local checks. -- ``bench:run`` is a fuller aggregate report. +- ``ic:bench:quick`` is tuned for fast local checks. +- ``ic:bench:run`` is a fuller aggregate report. Benchmark Suite Location ------------------------ @@ -44,9 +44,9 @@ Typical Workflow .. code-block:: bash # before changes - composer bench:quick + composer ic:bench:quick # make changes # after changes - composer bench:quick + composer ic:bench:quick diff --git a/docs/certificate.rst b/docs/certificate.rst index 69d0b3f..3f4362d 100644 --- a/docs/certificate.rst +++ b/docs/certificate.rst @@ -14,7 +14,10 @@ Includes: - key exchange - CSR generation - self-signed certificate generation +- CA signing - certificate parsing +- certificate utility helpers +- PEM normalization and PKCS#12 conversion - RSA interoperability helper Key Pair Generation @@ -22,10 +25,9 @@ Key Pair Generation .. code-block:: php - use Infocyph\Epicrypt\Certificate\Enum\OpenSslRsaBits; use Infocyph\Epicrypt\Certificate\KeyPairGenerator; - $openSslKeys = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); + $openSslKeys = KeyPairGenerator::openSsl()->generate(); $sodiumBoxKeys = KeyPairGenerator::sodium()->generate(asBase64Url: true); $sodiumSignKeys = KeyPairGenerator::sodiumSign()->generate(asBase64Url: true); @@ -84,6 +86,59 @@ CSR and Certificate $certPem = CertificateBuilder::openSsl()->selfSign($dn, $privatePem, 365); $parsed = CertificateParser::openSsl()->parse($certPem); +CA Signing with SAN Options +--------------------------- + +.. code-block:: php + + use Infocyph\Epicrypt\Certificate\CertificateAuthority; + use Infocyph\Epicrypt\Certificate\CertificateOptions; + + $options = new CertificateOptions( + days: 365, + sanDns: ['api.example.com', 'example.com'], + keyUsage: ['digitalSignature', 'keyEncipherment'], + extendedKeyUsage: ['serverAuth'], + ); + + $issuedCertPem = CertificateAuthority::openSsl()->signCsr( + $csrPem, + $caCertificatePem, + $caPrivateKeyPem, + $options, + passphrase: null, + ); + +Certificate Utilities +--------------------- + +.. code-block:: php + + use Infocyph\Epicrypt\Certificate\CertificateChainVerifier; + use Infocyph\Epicrypt\Certificate\CertificateExpiry; + use Infocyph\Epicrypt\Certificate\CertificateFingerprint; + use Infocyph\Epicrypt\Certificate\CertificateKeyMatcher; + + $fingerprint = (new CertificateFingerprint())->fingerprint($certPem, 'sha256'); + $expiresAt = (new CertificateExpiry())->expiresAt($certPem); + $isExpired = (new CertificateExpiry())->isExpired($certPem, 60); + $keyMatches = (new CertificateKeyMatcher())->privateKeyMatches($certPem, $privatePem); + $chainOk = (new CertificateChainVerifier())->verify($certPem, [$caCertificatePem]); + +PEM Normalization and PKCS#12 +----------------------------- + +.. code-block:: php + + use Infocyph\Epicrypt\Certificate\PemNormalizer; + use Infocyph\Epicrypt\Certificate\Pkcs12; + + $normalizedPem = (new PemNormalizer())->normalize($certPem); + + $pkcs12 = new Pkcs12(); + $bundle = $pkcs12->export($certPem, $privatePem, 'p12-password'); + $imported = $pkcs12->import($bundle, 'p12-password'); + RSA Interoperability -------------------- diff --git a/docs/complete-usage-reference.rst b/docs/complete-usage-reference.rst index cf5363b..11aabb8 100644 --- a/docs/complete-usage-reference.rst +++ b/docs/complete-usage-reference.rst @@ -6,7 +6,7 @@ The complete usage reference is now distributed into the ``use-cases`` section f Coverage Rules -------------- -- Includes all main public classes under ``Certificate``, ``Crypto``, ``Token``, ``Password``, ``Integrity``, ``Generate``, ``DataProtection``, and ``Security``. +- Includes the core and most-used public classes under ``Certificate``, ``Crypto``, ``Token``, ``Password``, ``Integrity``, ``Generate``, ``DataProtection``, and ``Security``. - Includes practical examples for constructors, encode/decode flows, verify flows, key-set flows, and option/context arguments. - Excludes ``Internal`` and ``Support`` namespace classes because those are implementation details, not stable app-facing API. diff --git a/docs/crypto.rst b/docs/crypto.rst index 5560e6c..dad3493 100644 --- a/docs/crypto.rst +++ b/docs/crypto.rst @@ -120,7 +120,7 @@ SecretStream (File Streaming) ``SecretStream`` is optimized for chunked file encryption/decryption and powers ``DataProtection\\FileProtector``. - default algorithm: ``xchacha20poly1305`` -- alternate: ``xchacha20`` +- advanced compatibility mode: ``unauthenticated-xchacha20`` (requires explicit opt-in) Binary Codec ------------ diff --git a/docs/data-protection.rst b/docs/data-protection.rst index 0ec8ca1..7892079 100644 --- a/docs/data-protection.rst +++ b/docs/data-protection.rst @@ -12,6 +12,7 @@ Higher-level security workflows built on crypto primitives: - file protection - envelope encryption - key rotation and re-encryption helpers +- inspect/rotation decision helpers and key-ring-aware result objects String Protector ---------------- @@ -27,6 +28,13 @@ String Protector $protector = StringProtector::forProfile(); $ciphertext = $protector->encrypt('sensitive data', $key); $plaintext = $protector->decrypt($ciphertext, $key); + $inspect = $protector->inspect($ciphertext); + +String inspector fields: + +- ``version`` +- ``algorithm`` +- ``keyId`` Key Rotation ------------ @@ -38,9 +46,15 @@ Key Rotation use Infocyph\Epicrypt\Security\Policy\SecurityProfile; $ring = new KeyRing(['previous' => $previousKey, 'current' => $currentKey], 'current'); - $result = StringProtector::forProfile()->decryptWithAnyKeyResult($ciphertext, $ring); + $result = StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $ring); $plaintext = $result->plaintext; - $rotatedCiphertext = StringProtector::forProfile()->reencryptWithAnyKey($ciphertext, $ring, $currentKey); + $matchedKeyId = $result->matchedKeyId; + $usedFallbackKey = $result->usedFallbackKey; + + $stringProtector = StringProtector::forProfile(); + $needsRotation = $stringProtector->needsRotation($ciphertext, 'current'); + $needsReencrypt = $stringProtector->needsReencrypt($ciphertext, 'current'); + $rotatedCiphertext = $stringProtector->reencryptWithAnyKey($ciphertext, $ring, $currentKey); Envelope Protector ------------------ @@ -62,6 +76,9 @@ Envelope payload includes: - ``v`` format version - ``alg`` algorithm marker +- ``dek_alg`` data-encryption-key algorithm marker +- ``created_at`` unix timestamp +- optional ``kid`` and ``purpose`` - ``encrypted_data`` - ``encrypted_key`` @@ -75,7 +92,10 @@ Envelope Re-Encryption $protector = EnvelopeProtector::forProfile(SecurityProfile::MODERN); $ring = new KeyRing(['previous' => $previousMasterKey, 'current' => $currentMasterKey], 'current'); - $result = $protector->decryptWithAnyKeyResult($encoded, $ring); + $result = $protector->decryptWithKeyRingResult($encoded, $ring); + $inspect = $protector->inspect($encoded); + $needsRotation = $protector->needsRotation($encoded, 'current'); + $needsReencrypt = $protector->needsReencrypt($encoded, 'current', 86400); $rotatedEnvelope = $protector->reencryptWithAnyKey($encoded, $ring, $currentMasterKey); $plain = $protector->decrypt($rotatedEnvelope, $currentMasterKey); @@ -96,6 +116,16 @@ File Re-Encryption $currentFileKey, ); +In-place rotation with backup/rollback: + +.. code-block:: php + + $result = FileProtector::forProfile(SecurityProfile::MODERN)->reencryptInPlaceWithAnyKey( + '/tmp/input.txt.epc', + $ring, + $currentFileKey, + ); + File Protector -------------- @@ -109,5 +139,18 @@ File Protector ->generate(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES); $file = FileProtector::forProfile(SecurityProfile::MODERN); - $file->encrypt('/tmp/input.txt', '/tmp/input.txt.epc', $key); + $bytesWritten = $file->encrypt('/tmp/input.txt', '/tmp/input.txt.epc', $key); $file->decrypt('/tmp/input.txt.epc', '/tmp/input.dec.txt', $key); + +Protection AAD +-------------- + +Use ``ProtectionAad`` to build deterministic additional-authenticated-data namespaces. + +.. code-block:: php + + use Infocyph\Epicrypt\DataProtection\ProtectionAad; + + $stringAad = ProtectionAad::forString('user.email', 'v1'); + $fileAad = ProtectionAad::forFile('backup.archive', 'v1'); + $envelopeAad = ProtectionAad::forEnvelope('merchant.secret', 'v1'); diff --git a/docs/jwt.rst b/docs/jwt.rst index 8864340..446bc9f 100644 --- a/docs/jwt.rst +++ b/docs/jwt.rst @@ -1,7 +1,7 @@ JWT === -JWT is covered under :doc:`token`. +JWT is covered under :doc:`token`, but this page highlights the hardening and interoperability APIs. Highlights: @@ -9,4 +9,32 @@ Highlights: - ``AsymmetricJwt`` with ``RS*`` and ``ES*``. - Structured verification results via ``verifyResult()`` / ``decodeResult()``. - Header/claim hardening with ``JwtValidationOptions`` and expected/required claims models. +- ``decodeWithAnyKeyResult()`` / ``verifyWithAnyKeyResult()`` for rotation-aware verification metadata. +- ``AsymmetricJwt::decodeFromJwksResult()`` / ``verifyFromJwksResult()`` for JWKS-kid verification. +Result Object Fields +-------------------- + +``JwtVerificationResult`` provides: + +- ``verified`` +- ``claims`` +- ``headers`` +- ``matchedKeyId`` +- ``usedFallbackKey`` +- ``expired`` +- ``notBeforeViolation`` +- ``algorithm`` + +Use result APIs when token rejection behavior needs to branch by reason (expired vs signature mismatch, etc.). + +JWKS/JWK Notes +-------------- + +``Token\\Jwt\\Jwks`` supports: + +- export public PEM keys to JWK/JWKS +- resolve a JWK by ``kid`` +- import RSA/EC JWK public keys back to PEM + +With ``AsymmetricJwt``, token ``kid`` is required for JWKS verification flows. diff --git a/docs/password.rst b/docs/password.rst index 4dcea69..64b7c35 100644 --- a/docs/password.rst +++ b/docs/password.rst @@ -82,6 +82,32 @@ Password Strength $score = (new PasswordStrength())->score('MyStrongPassword!2026'); // 0..100 +Password Policy Validation +-------------------------- + +.. code-block:: php + + use Infocyph\Epicrypt\Password\Generator\PasswordPolicy; + use Infocyph\Epicrypt\Password\PasswordPolicyValidator; + + $policy = new PasswordPolicy(minLength: 12, requireUpper: true, requireLower: true, requireDigit: true, requireSymbol: true); + $result = (new PasswordPolicyValidator())->validate('MyPassword123!', $policy); + + // $result->valid + // $result->score + // $result->violations + +Compromised Password Checker Contract +------------------------------------- + +.. code-block:: php + + use Infocyph\Epicrypt\Password\Contract\CompromisedPasswordCheckerInterface; + use Infocyph\Epicrypt\Password\NullCompromisedPasswordChecker; + + $checker = new NullCompromisedPasswordChecker(); + $isCompromised = $checker->isCompromised('candidate-password'); + Master Secret + Wrapped Secret ------------------------------ diff --git a/docs/security.rst b/docs/security.rst index 8f9c133..49ae7b1 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -13,6 +13,7 @@ Scope - remember tokens - action tokens - key rotation helper +- key-ring metadata for rotation windows and purpose-scoped key usage Signed URLs ----------- @@ -20,11 +21,23 @@ Signed URLs .. code-block:: php use Infocyph\Epicrypt\Security\SignedUrl; + use Infocyph\Epicrypt\Security\SignedUrlOptions; $signed = new SignedUrl('url-secret'); - $url = $signed->generate('https://example.com/download', ['file' => 'report.csv'], time() + 300); + $options = new SignedUrlOptions( + method: 'GET', + bindHost: true, + bindScheme: true, + allowArrayParameters: false, + allowedHosts: ['example.com'], + ); + + $url = $signed->generate('https://example.com/download', ['file' => 'report.csv'], time() + 300, $options); $isValid = $signed->verify($url); + $result = $signed->verifyResult($url, $options); + +``verifyResult()`` exposes ``verified``, ``expired``, ``invalidSignature``, ``expiresAt``, and ``version``. CSRF ---- @@ -80,3 +93,33 @@ Key Rotation Helper $isValidWithKid = $rotation->verify('payload', $signature, $keys, 'k2'); $isValidAgainstSet = $rotation->verify('payload', $signature, $keys); + $result = $rotation->verifyResult('payload', $signature, $keys); + +``verifyResult()`` returns ``KeyVerificationResult`` with ``verified``, ``matchedKeyId``, and ``usedFallbackKey``. + +KeyRing Metadata +---------------- + +Use metadata entries when keys have status, validity windows, or scope constraints. + +.. code-block:: php + + use Infocyph\Epicrypt\Security\KeyRing; + + $ring = new KeyRing([ + 'k2026-05' => [ + 'key' => 'active-key', + 'status' => KeyRing::STATUS_ACTIVE, + 'not_before' => 1767225600, + 'not_after' => 1798761600, + 'purpose' => 'jwt-signing', + ], + 'k2025-12' => [ + 'key' => 'fallback-key', + 'status' => KeyRing::STATUS_FALLBACK, + 'purpose' => 'jwt-signing', + ], + ], 'k2026-05'); + + $activeKey = $ring->activeKey(); + $ordered = $ring->orderedEntries('jwt-signing', time()); diff --git a/docs/token.rst b/docs/token.rst index 03c9602..21d6797 100644 --- a/docs/token.rst +++ b/docs/token.rst @@ -11,6 +11,8 @@ Scope - opaque tokens - claim validation and key resolution - key-ring verification helpers for signed payload and JWT rotation +- JWKS/JWK export, import, and kid-based verification flows +- structured verification result objects for safer application decisions Symmetric JWT ------------- @@ -77,13 +79,11 @@ Asymmetric JWT use Infocyph\Epicrypt\Token\Jwt\AsymmetricJwt; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; - $resource = openssl_pkey_new([ - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - 'private_key_bits' => 2048, - ]); - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - $publicKey = $details['key']; + use Infocyph\Epicrypt\Certificate\KeyPairGenerator; + + $keys = KeyPairGenerator::openSsl()->generate(); + $privateKey = $keys['private']; + $publicKey = $keys['public']; $now = time(); $claims = [ @@ -102,6 +102,41 @@ Asymmetric JWT ); $isValid = $jwt->verify($token, $publicKey); +JWT Result APIs +--------------- + +Use result APIs when you need structured verification state instead of only boolean pass/fail. + +.. code-block:: php + + use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; + use Infocyph\Epicrypt\Token\Jwt\Validation\RequiredJwtClaims; + use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; + + $jwt = SymmetricJwt::forProfile( + SecurityProfile::MODERN, + new ExpectedJwtClaims( + issuer: 'issuer-service', + audience: 'audience-service', + subject: 'subject-service', + required: new RequiredJwtClaims(issuer: true, audience: true, subject: true), + ), + new JwtValidationOptions(strictTyp: true, leewaySeconds: 15), + ); + + $result = $jwt->decodeResult($token, 'super-secret-key'); + if ($result->verified) { + $claims = $result->claims; + $headers = $result->headers; + } + + // Metadata fields: + // $result->matchedKeyId + // $result->usedFallbackKey + // $result->expired + // $result->notBeforeViolation + // $result->algorithm + Signed Payload Token -------------------- @@ -133,6 +168,22 @@ Signed Payload Key Rings $claims = $payload->decodeWithAnyKey($token, $ring); $isValid = $payload->verifyWithAnyKey($token, $ring); $result = $payload->verifyWithAnyKeyResult($token, $ring); + $detailed = $payload->verifyWithAnyKeyDetailedResult($token, $ring); + +Signed Payload Result APIs +-------------------------- + +.. code-block:: php + + $result = $payload->verifyResult($token, 'payload-secret'); + if ($result->verified) { + $claims = $result->claims; + } + + // Metadata fields: + // $result->matchedKeyId + // $result->usedFallbackKey + // $result->expired Opaque Token ------------ @@ -145,3 +196,26 @@ Opaque Token $token = $opaque->issue(48); $digest = $opaque->hash($token); $isValid = $opaque->verify($token, $digest); + +JWKS/JWK Interoperability +------------------------- + +Use this for asymmetric JWT interop where verifier keys are distributed in JWKS form. + +.. code-block:: php + + use Infocyph\Epicrypt\Security\KeyRing; + use Infocyph\Epicrypt\Token\Jwt\AsymmetricJwt; + use Infocyph\Epicrypt\Token\Jwt\Jwks; + + $jwksHelper = new Jwks(); + $publicRing = new KeyRing(['k1' => $publicKey1, 'k2' => $publicKey2], 'k2'); + $jwks = $jwksHelper->exportFromKeyRing($publicRing); + + // Resolve by kid to PEM: + $pem = $jwksHelper->resolvePublicKeyByKid($jwks, 'k2'); + + // Verify directly from JWKS: + $jwt = AsymmetricJwt::forProfile(SecurityProfile::MODERN, $expectedClaims); + $result = $jwt->verifyFromJwksResult($token, $jwks); + $isValid = $result->verified; diff --git a/docs/use-cases/certificate-complete-examples.rst b/docs/use-cases/certificate-complete-examples.rst index 2849d2c..42b8d44 100644 --- a/docs/use-cases/certificate-complete-examples.rst +++ b/docs/use-cases/certificate-complete-examples.rst @@ -21,11 +21,11 @@ Use this when you need OpenSSL or sodium key material for encryption, signatures use Infocyph\Epicrypt\Certificate\KeyPairGenerator; // RSA keys for general PEM-based interoperability. - $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_2048, OpenSslKeyType::RSA)->generate(); + $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_3072, OpenSslKeyType::RSA)->generate(); // EC keys for OpenSSL elliptic-curve workflows. $ecKeys = KeyPairGenerator::openSsl( - bits: OpenSslRsaBits::BITS_2048, + bits: OpenSslRsaBits::BITS_3072, type: OpenSslKeyType::EC, curveName: OpenSslCurveName::PRIME256V1, )->generate(); @@ -57,7 +57,7 @@ Use this when a service needs a CSR for a CA or a self-signed certificate for lo use Infocyph\Epicrypt\Certificate\Enum\OpenSslRsaBits; use Infocyph\Epicrypt\Certificate\KeyPairGenerator; - $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_2048, OpenSslKeyType::RSA)->generate(); + $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_3072, OpenSslKeyType::RSA)->generate(); $dn = [ 'countryName' => 'US', 'organizationName' => 'Epicrypt', @@ -113,7 +113,7 @@ Use these only when you need direct access to backend-specific behavior. use Infocyph\Epicrypt\Certificate\Sodium\SessionKeyExchange; use Infocyph\Epicrypt\Certificate\Sodium\SigningKeyPairGenerator; - $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_2048, OpenSslKeyType::RSA)->generate(); + $rsaKeys = KeyPairGenerator::openSsl(OpenSslRsaBits::BITS_3072, OpenSslKeyType::RSA)->generate(); $sodiumDirect = new SessionKeyExchange(); $opensslDirect = new DiffieHellman(); $directSignKeys = (new SigningKeyPairGenerator())->generate(asBase64Url: true); @@ -122,3 +122,59 @@ Use these only when you need direct access to backend-specific behavior. $rsaCipher = new RsaCipher(); $encrypted = $rsaCipher->encrypt('interop-message', $rsaKeys['public']); $decrypted = $rsaCipher->decrypt($encrypted, $rsaKeys['private']); + +Certificate Utility and PKCS#12 Flows +------------------------------------- + +Use this when you need cert metadata checks, chain verification, and bundle conversion for deployment tooling. + +.. code-block:: php + + fingerprint($cert, 'sha256'); + $expiresAt = (new CertificateExpiry())->expiresAt($cert); + $keyMatches = (new CertificateKeyMatcher())->privateKeyMatches($cert, $rsaKeys['private']); + $chainOk = (new CertificateChainVerifier())->verify($cert, [$caCertificatePem]); + $normalizedPem = (new PemNormalizer())->normalize($cert); + + $pkcs12 = new Pkcs12(); + $bundle = $pkcs12->export($cert, $rsaKeys['private'], 'changeit'); + $imported = $pkcs12->import($bundle, 'changeit'); + +CA Signing with CertificateOptions +---------------------------------- + +Use this when issuing non-self-signed certificates from your own CA certificate/private key pair. + +.. code-block:: php + + signCsr( + $csr, + $caCertificatePem, + $caPrivateKeyPem, + $options, + ); diff --git a/docs/use-cases/crypto-complete-examples.rst b/docs/use-cases/crypto-complete-examples.rst index fb32a27..d76cd55 100644 --- a/docs/use-cases/crypto-complete-examples.rst +++ b/docs/use-cases/crypto-complete-examples.rst @@ -134,7 +134,7 @@ Use this when a payload is too large for simple in-memory encryption. $streamKey = random_bytes(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES); $stream = new SecretStream($streamKey, StreamAlgorithm::XCHACHA20POLY1305, 'aad'); - $lastChunkSize = $stream->encrypt('/tmp/plain.bin', '/tmp/plain.bin.epc', 8192); + $bytesWritten = $stream->encrypt('/tmp/plain.bin', '/tmp/plain.bin.epc', 8192); $stream->decrypt('/tmp/plain.bin.epc', '/tmp/plain.dec.bin', 8192); Encode Binary Safely diff --git a/docs/use-cases/data-protection-complete-examples.rst b/docs/use-cases/data-protection-complete-examples.rst index f158fac..bd8c97b 100644 --- a/docs/use-cases/data-protection-complete-examples.rst +++ b/docs/use-cases/data-protection-complete-examples.rst @@ -23,6 +23,8 @@ Use this when you need easy encrypt/decrypt calls for short data stored in your $stringProtector = StringProtector::forProfile(); $ciphertext = $stringProtector->encrypt('sensitive data', $key); $plaintext = $stringProtector->decrypt($ciphertext, $key); + $inspect = $stringProtector->inspect($ciphertext); + $needsRotation = $stringProtector->needsRotation($ciphertext, 'current-key-id'); Protect a Versioned Envelope ---------------------------- @@ -45,6 +47,7 @@ Use this when you want a structured protected payload that can be encoded and st $envelope = $envelopeProtector->encrypt('payload', $key); $encodedEnvelope = $envelopeProtector->encodeEnvelope($envelope); $decoded = $envelopeProtector->decrypt($encodedEnvelope, $key); + $envelopeInspect = $envelopeProtector->inspect($encodedEnvelope); Protect a File -------------- @@ -63,5 +66,30 @@ Use this when you need stream-based encryption for files or large blobs. $fileKey = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES); $fileProtector = FileProtector::forProfile(SecurityProfile::MODERN); - $lastChunk = $fileProtector->encrypt('/tmp/in.bin', '/tmp/in.bin.epc', $fileKey, 8192, false); + $bytesWritten = $fileProtector->encrypt('/tmp/in.bin', '/tmp/in.bin.epc', $fileKey, 8192, false); $fileProtector->decrypt('/tmp/in.bin.epc', '/tmp/in.dec.bin', $fileKey, 8192, false); + +Use Key Rings and AAD +--------------------- + +Use this when active/fallback key flows and explicit domain-separated AAD are required. + +.. code-block:: php + + 'previous-key', + 'k-current' => 'active-key', + ], 'k-current'); + + $aad = ProtectionAad::forString('user.email', 'v1'); + $sealed = $stringProtector->encryptWithKeyRing('alice@example.com', $ring, ['aad' => $aad]); + $openResult = $stringProtector->decryptWithKeyRingResult($sealed, $ring, ['aad' => $aad]); + + $rotatedInPlace = $fileProtector->reencryptInPlaceWithAnyKey('/tmp/in.bin.epc', $ring, $fileKey); diff --git a/docs/use-cases/pki-and-key-exchange.rst b/docs/use-cases/pki-and-key-exchange.rst index 14f04a5..6cbb029 100644 --- a/docs/use-cases/pki-and-key-exchange.rst +++ b/docs/use-cases/pki-and-key-exchange.rst @@ -52,10 +52,9 @@ Minimal CSR + Certificate Example use Infocyph\Epicrypt\Certificate\CertificateBuilder; use Infocyph\Epicrypt\Certificate\CertificateParser; use Infocyph\Epicrypt\Certificate\CsrBuilder; - use Infocyph\Epicrypt\Certificate\Enum\OpenSslRsaBits; use Infocyph\Epicrypt\Certificate\KeyPairGenerator; - $keys = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); + $keys = KeyPairGenerator::openSsl()->generate(); $dn = [ 'countryName' => 'US', diff --git a/docs/use-cases/security-complete-examples.rst b/docs/use-cases/security-complete-examples.rst index 583d7ee..d12a754 100644 --- a/docs/use-cases/security-complete-examples.rst +++ b/docs/use-cases/security-complete-examples.rst @@ -13,10 +13,13 @@ Generate and Verify a Signed URL declare(strict_types=1); use Infocyph\Epicrypt\Security\SignedUrl; + use Infocyph\Epicrypt\Security\SignedUrlOptions; $signedUrl = new SignedUrl('url-secret'); - $link = $signedUrl->generate('https://example.com/download', ['file' => 'report.csv'], time() + 300); - $linkValid = $signedUrl->verify($link); + $options = new SignedUrlOptions(method: 'GET', allowedHosts: ['example.com']); + $link = $signedUrl->generate('https://example.com/download', ['file' => 'report.csv'], time() + 300, $options); + $linkValid = $signedUrl->verify($link, $options); + $verifyResult = $signedUrl->verifyResult($link, $options); Issue and Verify a CSRF Token ----------------------------- @@ -85,3 +88,34 @@ Use this when signatures must be accepted during a key rollover window. $signature = $rotation->sign('payload', 'k2', $keys); $validWithKid = $rotation->verify('payload', $signature, $keys, 'k2'); $validAgainstWholeSet = $rotation->verify('payload', $signature, $keys); + $verifyResult = $rotation->verifyResult('payload', $signature, $keys); + +Use KeyRing Metadata +-------------------- + +Use this when key lifetimes or purpose scope must be enforced by the key set. + +.. code-block:: php + + [ + 'key' => 'active-key', + 'status' => KeyRing::STATUS_ACTIVE, + 'not_before' => time() - 60, + 'not_after' => time() + 86400, + 'purpose' => 'signed-url', + ], + 'k-previous' => [ + 'key' => 'fallback-key', + 'status' => KeyRing::STATUS_FALLBACK, + 'purpose' => 'signed-url', + ], + ], 'k-current'); + + $orderedForPurpose = $ring->orderedEntries('signed-url', time()); diff --git a/docs/use-cases/token-complete-examples.rst b/docs/use-cases/token-complete-examples.rst index 7c1dfea..7a7374f 100644 --- a/docs/use-cases/token-complete-examples.rst +++ b/docs/use-cases/token-complete-examples.rst @@ -15,7 +15,6 @@ Use this when the issuer and verifier share one secret or a keyed secret set. declare(strict_types=1); use Infocyph\Epicrypt\Security\Policy\SecurityProfile; - use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm; use Infocyph\Epicrypt\Token\Jwt\SymmetricJwt; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; @@ -203,3 +202,50 @@ Use validator classes when validation needs to be explicit or composable. (new AudienceValidator())->validate('audience-service', $claims['aud']); (new SubjectValidator())->validate('subject-service', $claims['sub']); (new ExpirationValidator())->validate($claims['nbf'], $claims['exp']); + +Use Result APIs for Branching +----------------------------- + +Use this when you need structured verification metadata instead of just a boolean. + +.. code-block:: php + + decodeResult($symToken, $symKeys); + $jwtVerified = $jwtResult->verified; + $jwtExpired = $jwtResult->expired; + $jwtKid = $jwtResult->matchedKeyId; + + $signedPayloadResult = $signedPayload->verifyWithAnyKeyDetailedResult($payloadToken, $ring); + $payloadVerified = $signedPayloadResult->verified; + $payloadUsedFallback = $signedPayloadResult->usedFallbackKey; + +JWKS Export and JWKS-Based Verification +--------------------------------------- + +Use this when asymmetric verifiers receive key material as JWKS. + +.. code-block:: php + + exportFromKeyRing($publicRing); + + // Resolve kid -> JWK entry or PEM: + $jwk = $jwksHelper->resolveByKid($jwks, 'k2'); + $publicPem = $jwksHelper->importPublicKeyFromJwk($jwk); + + // Verify using token kid against JWKS: + $jwksResult = $asymVerifier->verifyFromJwksResult($asymToken, $jwks); + $jwksValid = $jwksResult->verified; diff --git a/src/Certificate/KeyPairGenerator.php b/src/Certificate/KeyPairGenerator.php index 0022604..c15bd77 100644 --- a/src/Certificate/KeyPairGenerator.php +++ b/src/Certificate/KeyPairGenerator.php @@ -20,7 +20,7 @@ public function __construct( public static function forType( KeyPairType $type, - OpenSslRsaBits $bits = OpenSslRsaBits::BITS_2048, + OpenSslRsaBits $bits = OpenSslRsaBits::BITS_3072, ?OpenSslCurveName $curveName = null, ): self { if (!$type->isOpenSsl()) { @@ -35,7 +35,7 @@ public static function forType( } public static function openSsl( - OpenSslRsaBits $bits = OpenSslRsaBits::BITS_2048, + OpenSslRsaBits $bits = OpenSslRsaBits::BITS_3072, OpenSslKeyType $type = OpenSslKeyType::RSA, ?OpenSslCurveName $curveName = null, ): self { diff --git a/src/Certificate/OpenSSL/CertificateAuthority.php b/src/Certificate/OpenSSL/CertificateAuthority.php index 7492fbd..5aff8b5 100644 --- a/src/Certificate/OpenSSL/CertificateAuthority.php +++ b/src/Certificate/OpenSSL/CertificateAuthority.php @@ -6,9 +6,9 @@ use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\CertificateAuthorityInterface; +use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslCertificateSigner; use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Certificate\Support\Pem; -use Infocyph\Epicrypt\Exception\ConfigurationException; final class CertificateAuthority implements CertificateAuthorityInterface { @@ -26,23 +26,15 @@ public function signCsr( $config['x509_extensions'] = 'v3_req'; try { - $certificate = openssl_csr_sign( + return OpenSslCertificateSigner::signAndExport( $csrPem, $caCertificatePem, $caKeyResource, $options->days, $config, + 'CA certificate signing failed.', + 'Signed certificate export failed.', ); - if ($certificate === false) { - throw new ConfigurationException('CA certificate signing failed.'); - } - - $exported = openssl_x509_export($certificate, $certificatePem); - if (!$exported || !is_string($certificatePem) || $certificatePem === '') { - throw new ConfigurationException('Signed certificate export failed.'); - } - - return $certificatePem; } finally { if (file_exists($tempConfigPath)) { unlink($tempConfigPath); diff --git a/src/Certificate/OpenSSL/CertificateBuilder.php b/src/Certificate/OpenSSL/CertificateBuilder.php index 39f0645..98847a1 100644 --- a/src/Certificate/OpenSSL/CertificateBuilder.php +++ b/src/Certificate/OpenSSL/CertificateBuilder.php @@ -6,6 +6,7 @@ use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\CertificateBuilderInterface; +use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslCertificateSigner; use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Certificate\Support\Pem; use Infocyph\Epicrypt\Exception\ConfigurationException; @@ -25,7 +26,7 @@ public function selfSign(array $distinguishedName, string $privateKey, int $days $effectiveOptions = $options ?? new CertificateOptions(days: $days, digestAlgorithm: $this->digestAlgorithm); $requestedDays = $effectiveOptions->days; $digestAlgorithm = $effectiveOptions->digestAlgorithm; - $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions); + $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions, $distinguishedName); $csrConfig = ['digest_alg' => $digestAlgorithm]; $signConfig = ['digest_alg' => $digestAlgorithm]; $csrConfig['config'] = $tempConfigPath; @@ -41,17 +42,15 @@ public function selfSign(array $distinguishedName, string $privateKey, int $days $signingPrivateKey = $passphrase === null ? $privateKey : [$privateKey, $passphrase]; - $certificate = openssl_csr_sign($csr, null, $signingPrivateKey, $requestedDays, $signConfig); - if ($certificate === false) { - throw new ConfigurationException('Certificate signing failed.'); - } - - $exported = openssl_x509_export($certificate, $certificatePem); - if (!$exported || !is_string($certificatePem) || $certificatePem === '') { - throw new ConfigurationException('Certificate export failed.'); - } - - return $certificatePem; + return OpenSslCertificateSigner::signAndExport( + $csr, + null, + $signingPrivateKey, + $requestedDays, + $signConfig, + 'Certificate signing failed.', + 'Certificate export failed.', + ); } finally { if (file_exists($tempConfigPath)) { unlink($tempConfigPath); diff --git a/src/Certificate/OpenSSL/CsrBuilder.php b/src/Certificate/OpenSSL/CsrBuilder.php index a451ae8..866f0c8 100644 --- a/src/Certificate/OpenSSL/CsrBuilder.php +++ b/src/Certificate/OpenSSL/CsrBuilder.php @@ -19,7 +19,7 @@ public function build(array $distinguishedName, string $privateKey, ?string $pas { $privateResource = Pem::requirePrivateKeyResource($privateKey, $passphrase); $effectiveOptions = $options ?? new CertificateOptions(); - $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions); + $tempConfigPath = OpenSslExtensionConfig::createTempConfig($effectiveOptions, $distinguishedName); $config = ['digest_alg' => $effectiveOptions->digestAlgorithm]; $config['config'] = $tempConfigPath; $config['req_extensions'] = 'v3_req'; diff --git a/src/Certificate/OpenSSL/KeyPairGenerator.php b/src/Certificate/OpenSSL/KeyPairGenerator.php index 3e28b7a..42fd867 100644 --- a/src/Certificate/OpenSSL/KeyPairGenerator.php +++ b/src/Certificate/OpenSSL/KeyPairGenerator.php @@ -4,19 +4,17 @@ namespace Infocyph\Epicrypt\Certificate\OpenSSL; -use Infocyph\Epicrypt\Certificate\CertificateOptions; use Infocyph\Epicrypt\Certificate\Contract\KeyPairGeneratorInterface; use Infocyph\Epicrypt\Certificate\Enum\OpenSslCurveName; use Infocyph\Epicrypt\Certificate\Enum\OpenSslKeyType; use Infocyph\Epicrypt\Certificate\Enum\OpenSslRsaBits; -use Infocyph\Epicrypt\Certificate\OpenSSL\Support\OpenSslExtensionConfig; use Infocyph\Epicrypt\Exception\ConfigurationException; use Infocyph\Epicrypt\Internal\Base64Url; final readonly class KeyPairGenerator implements KeyPairGeneratorInterface { public function __construct( - private OpenSslRsaBits $bits = OpenSslRsaBits::BITS_2048, + private OpenSslRsaBits $bits = OpenSslRsaBits::BITS_3072, private OpenSslKeyType $type = OpenSslKeyType::RSA, private ?OpenSslCurveName $curveName = null, ) {} @@ -30,42 +28,34 @@ public function generate(?string $passphrase = null, bool $asBase64Url = false): 'private_key_bits' => $this->bits->value, 'private_key_type' => $this->type->value, ]; - $tempConfigPath = OpenSslExtensionConfig::createTempConfig(new CertificateOptions()); - $config['config'] = $tempConfigPath; if ($this->curveName !== null) { $config['curve_name'] = $this->curveName->value; } - try { - $resource = openssl_pkey_new($config); - if ($resource === false) { - throw new ConfigurationException('OpenSSL key pair generation failed.'); - } - - $privateKey = null; - $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? '', $config); - if (!$exported || !is_string($privateKey) || $privateKey === '') { - throw new ConfigurationException('Failed to export private key.'); - } + $resource = openssl_pkey_new($config); + if ($resource === false) { + throw new ConfigurationException('OpenSSL key pair generation failed.'); + } - $details = openssl_pkey_get_details($resource); - if (!is_array($details) || !isset($details['key']) || !is_string($details['key']) || $details['key'] === '') { - throw new ConfigurationException('Failed to export public key.'); - } + $privateKey = null; + $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? '', $config); + if (!$exported || !is_string($privateKey) || $privateKey === '') { + throw new ConfigurationException('Failed to export private key.'); + } - if (!$asBase64Url) { - return ['private' => $privateKey, 'public' => $details['key']]; - } + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || !isset($details['key']) || !is_string($details['key']) || $details['key'] === '') { + throw new ConfigurationException('Failed to export public key.'); + } - return [ - 'private' => Base64Url::encode($privateKey), - 'public' => Base64Url::encode($details['key']), - ]; - } finally { - if (file_exists($tempConfigPath)) { - unlink($tempConfigPath); - } + if (!$asBase64Url) { + return ['private' => $privateKey, 'public' => $details['key']]; } + + return [ + 'private' => Base64Url::encode($privateKey), + 'public' => Base64Url::encode($details['key']), + ]; } } diff --git a/src/Certificate/OpenSSL/RsaCipher.php b/src/Certificate/OpenSSL/RsaCipher.php index 199d1e2..1fae944 100644 --- a/src/Certificate/OpenSSL/RsaCipher.php +++ b/src/Certificate/OpenSSL/RsaCipher.php @@ -10,6 +10,10 @@ use Infocyph\Epicrypt\Internal\Base64Url; use OpenSSLAsymmetricKey; +/** + * Compatibility-focused RSA helper. + * Use envelope encryption for large payloads and modern data-at-rest flows. + */ final class RsaCipher { public function decrypt(string $ciphertext, string $privateKey, ?string $passphrase = null): string diff --git a/src/Certificate/OpenSSL/Support/OpenSslCertificateSigner.php b/src/Certificate/OpenSSL/Support/OpenSslCertificateSigner.php new file mode 100644 index 0000000..436bf4e --- /dev/null +++ b/src/Certificate/OpenSSL/Support/OpenSslCertificateSigner.php @@ -0,0 +1,36 @@ + $config + * @param array{0: string, 1: string}|\OpenSSLAsymmetricKey|\OpenSSLCertificate|string $caPrivateKey + */ + public static function signAndExport( + \OpenSSLCertificateSigningRequest|string $csr, + \OpenSSLCertificate|string|null $caCertificate, + array|\OpenSSLAsymmetricKey|\OpenSSLCertificate|string $caPrivateKey, + int $days, + array $config, + string $signErrorMessage, + string $exportErrorMessage, + ): string { + $certificate = openssl_csr_sign($csr, $caCertificate, $caPrivateKey, $days, $config); + if ($certificate === false) { + throw new ConfigurationException($signErrorMessage); + } + + $exported = openssl_x509_export($certificate, $certificatePem); + if (!$exported || !is_string($certificatePem) || $certificatePem === '') { + throw new ConfigurationException($exportErrorMessage); + } + + return $certificatePem; + } +} diff --git a/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php b/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php index a753a33..e78f8d1 100644 --- a/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php +++ b/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php @@ -12,8 +12,13 @@ */ final class OpenSslExtensionConfig { - public static function createTempConfig(CertificateOptions $options): string + /** + * @param array $distinguishedName + */ + public static function createTempConfig(CertificateOptions $options, array $distinguishedName = []): string { + $commonName = self::resolveCommonName($distinguishedName); + $lines = [ '[req]', 'distinguished_name=req_distinguished_name', @@ -22,7 +27,7 @@ public static function createTempConfig(CertificateOptions $options): string 'x509_extensions=v3_req', '', '[req_distinguished_name]', - 'CN=localhost', + sprintf('CN=%s', $commonName), '', '[v3_req]', ]; @@ -76,4 +81,19 @@ public static function createTempConfig(CertificateOptions $options): string return $tempFile; } + + /** + * @param array $distinguishedName + */ + private static function resolveCommonName(array $distinguishedName): string + { + $commonName = $distinguishedName['commonName'] ?? $distinguishedName['CN'] ?? null; + if (!is_string($commonName)) { + return 'localhost'; + } + + $trimmed = trim($commonName); + + return $trimmed === '' ? 'localhost' : $trimmed; + } } diff --git a/src/Certificate/Pkcs12.php b/src/Certificate/Pkcs12.php new file mode 100644 index 0000000..e3e7439 --- /dev/null +++ b/src/Certificate/Pkcs12.php @@ -0,0 +1,88 @@ + $caCertificatesPem + */ + public function export( + string $certificatePem, + string $privateKeyPem, + string $password, + ?string $privateKeyPassphrase = null, + ?string $friendlyName = null, + array $caCertificatesPem = [], + ): string { + $certificate = openssl_x509_read($certificatePem); + if ($certificate === false) { + throw new ConfigurationException('Invalid certificate for PKCS#12 export.'); + } + + $privateKey = Pem::requirePrivateKeyResource($privateKeyPem, $privateKeyPassphrase); + $options = []; + if ($friendlyName !== null && $friendlyName !== '') { + $options['friendly_name'] = $friendlyName; + } + if ($caCertificatesPem !== []) { + $options['extracerts'] = implode(PHP_EOL, $caCertificatesPem); + } + + $pkcs12 = ''; + $ok = openssl_pkcs12_export( + $certificate, + $pkcs12, + $privateKey, + $password, + $options, + ); + + if (!$ok || !is_string($pkcs12) || $pkcs12 === '') { + throw new ConfigurationException('PKCS#12 export failed.'); + } + + return $pkcs12; + } + + /** + * @return array{certificate: string, private_key: string, ca_certificates: list} + */ + public function import(string $pkcs12, string $password): array + { + $output = []; + if (!openssl_pkcs12_read($pkcs12, $output, $password)) { + throw new ConfigurationException('PKCS#12 import failed.'); + } + if (!is_array($output)) { + throw new ConfigurationException('PKCS#12 import produced invalid output.'); + } + + $certificate = $output['cert'] ?? null; + $privateKey = $output['pkey'] ?? null; + if (!is_string($certificate) || $certificate === '' || !is_string($privateKey) || $privateKey === '') { + throw new ConfigurationException('PKCS#12 payload is missing certificate or private key.'); + } + + $extra = $output['extracerts'] ?? []; + $caCertificates = []; + if (is_array($extra)) { + foreach ($extra as $entry) { + if (is_string($entry) && $entry !== '') { + $caCertificates[] = $entry; + } + } + } + + return [ + 'certificate' => $certificate, + 'private_key' => $privateKey, + 'ca_certificates' => $caCertificates, + ]; + } +} diff --git a/src/Crypto/AeadCipher.php b/src/Crypto/AeadCipher.php index 5cb27cf..c77e54b 100644 --- a/src/Crypto/AeadCipher.php +++ b/src/Crypto/AeadCipher.php @@ -142,19 +142,14 @@ private function runRawOperation(string $input, string $aad, string $nonce, stri private function splitPayload(string $ciphertext): array { $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); - if ($compactPayload !== null) { - if ($compactPayload->algorithm !== $this->algorithm->value) { - throw new DecryptionException(sprintf('Unsupported payload algorithm "%s".', $compactPayload->algorithm)); - } - - return [$compactPayload->nonce, $compactPayload->ciphertext]; + if ($compactPayload === null) { + throw new DecryptionException('Invalid ciphertext format.'); } - $legacyPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); - if ($legacyPayload === null) { - throw new DecryptionException('Invalid ciphertext format.'); + if ($compactPayload->algorithm !== $this->algorithm->value) { + throw new DecryptionException(sprintf('Unsupported payload algorithm "%s".', $compactPayload->algorithm)); } - return [$legacyPayload->parts[0], $legacyPayload->parts[1]]; + return [$compactPayload->nonce, $compactPayload->ciphertext]; } } diff --git a/src/Crypto/SecretBoxCipher.php b/src/Crypto/SecretBoxCipher.php index 2a9134f..887421d 100644 --- a/src/Crypto/SecretBoxCipher.php +++ b/src/Crypto/SecretBoxCipher.php @@ -106,19 +106,14 @@ private function keyIdFromContext(array $context): ?string private function splitPayload(string $ciphertext): array { $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); - if ($compactPayload !== null) { - if ($compactPayload->algorithm !== self::ALGORITHM_ID) { - throw new DecryptionException('Unsupported payload algorithm.'); - } - - return [$compactPayload->nonce, $compactPayload->ciphertext]; + if ($compactPayload === null) { + throw new DecryptionException('Invalid ciphertext format.'); } - $legacyPayload = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); - if ($legacyPayload === null) { - throw new DecryptionException('Invalid ciphertext format.'); + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new DecryptionException('Unsupported payload algorithm.'); } - return [$legacyPayload->parts[0], $legacyPayload->parts[1]]; + return [$compactPayload->nonce, $compactPayload->ciphertext]; } } diff --git a/src/DataProtection/StringProtector.php b/src/DataProtection/StringProtector.php index 44c92a3..c6f5335 100644 --- a/src/DataProtection/StringProtector.php +++ b/src/DataProtection/StringProtector.php @@ -152,16 +152,11 @@ public function encryptWithKeyRing(string $plaintext, KeyRing $keyRing, array $c public function inspect(string $ciphertext): StringProtectInspectResult { $payload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); - if ($payload !== null) { - return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, $payload->algorithm, $payload->keyId); - } - - $legacy = VersionedPayload::parse($ciphertext, EncryptedPayloadVersion::V1->value, 2); - if ($legacy === null) { + if ($payload === null) { throw new DecryptionException('Invalid protected string format.'); } - return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, SecretBoxCipher::ALGORITHM_ID, null); + return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, $payload->algorithm, $payload->keyId); } public function needsReencrypt(string $ciphertext, ?string $activeKeyId = null): bool diff --git a/src/Internal/VersionedPayload.php b/src/Internal/VersionedPayload.php index bed0eff..4f0d900 100644 --- a/src/Internal/VersionedPayload.php +++ b/src/Internal/VersionedPayload.php @@ -35,7 +35,6 @@ public static function encodeCompact(string $version, string $algorithm, ?string public static function parse(string $payload, string $expectedVersion, int $partCount): ?VersionedPayloadResult { $segments = explode('.', $payload); - $firstSegment = $segments[0]; if (count($segments) === ($partCount + 1) && $segments[0] === $expectedVersion) { $versionedParts = array_slice($segments, 1); @@ -46,14 +45,6 @@ public static function parse(string $payload, string $expectedVersion, int $part return null; } - if ($firstSegment === $expectedVersion) { - return null; - } - - if (count($segments) === $partCount && self::allNonEmpty($segments)) { - return new VersionedPayloadResult(false, $segments); - } - return null; } diff --git a/src/Password/Secret/WrappedSecretManager.php b/src/Password/Secret/WrappedSecretManager.php index 885f80d..ac71b08 100644 --- a/src/Password/Secret/WrappedSecretManager.php +++ b/src/Password/Secret/WrappedSecretManager.php @@ -183,27 +183,18 @@ private function orderedKeyEntries(iterable|KeyRing $keys): array private function parseWrappedPayload(string $wrappedSecret): array { $compactPayload = VersionedPayload::parseCompact($wrappedSecret, WrappedSecretVersion::V1->value); - if ($compactPayload !== null) { - if ($compactPayload->algorithm !== self::ALGORITHM_ID) { - throw new SecretProtectionException('Unsupported wrapped secret algorithm.'); - } - - return [ - 'nonce' => $compactPayload->nonce, - 'ciphertext' => $compactPayload->ciphertext, - 'key_id' => $compactPayload->keyId, - ]; + if ($compactPayload === null) { + throw new SecretProtectionException('Invalid wrapped secret format.'); } - $parsedPayload = VersionedPayload::parse($wrappedSecret, WrappedSecretVersion::V1->value, 2); - if ($parsedPayload === null) { - throw new SecretProtectionException('Invalid wrapped secret format.'); + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new SecretProtectionException('Unsupported wrapped secret algorithm.'); } return [ - 'nonce' => $parsedPayload->parts[0], - 'ciphertext' => $parsedPayload->parts[1], - 'key_id' => null, + 'nonce' => $compactPayload->nonce, + 'ciphertext' => $compactPayload->ciphertext, + 'key_id' => $compactPayload->keyId, ]; } } diff --git a/src/Security/KeyRing.php b/src/Security/KeyRing.php index 773b7f1..9460e1d 100644 --- a/src/Security/KeyRing.php +++ b/src/Security/KeyRing.php @@ -8,35 +8,62 @@ final readonly class KeyRing { + public const string STATUS_ACTIVE = 'active'; + + public const string STATUS_DISABLED = 'disabled'; + + public const string STATUS_FALLBACK = 'fallback'; + + public const string STATUS_RETIRED = 'retired'; + /** - * @param array $keys + * @var array */ - public function __construct( - private array $keys, - private ?string $activeKeyId = null, - ) { - if ($this->keys === []) { + private array $keys; + + /** + * @param array $keys + */ + public function __construct(array $keys, private ?string $activeKeyId = null) + { + if ($keys === []) { throw new ConfigurationException('Key ring must contain at least one key.'); } - foreach ($this->keys as $keyId => $key) { + $normalized = []; + foreach ($keys as $keyId => $entry) { if ($keyId === '') { throw new ConfigurationException('Key ring ids must be non-empty strings.'); } - if ($key === '') { + $normalized[$keyId] = $this->normalizeEntry($keyId, $entry); + if ($normalized[$keyId]['key'] === '') { throw new ConfigurationException(sprintf('Key ring entry "%s" must be a non-empty string.', $keyId)); } } + $this->keys = $normalized; if ($this->activeKeyId !== null && !array_key_exists($this->activeKeyId, $this->keys)) { throw new ConfigurationException('Active key id was not found in the key ring.'); } + + if ($this->activeKeyId !== null && $this->keys[$this->activeKeyId]['status'] === self::STATUS_DISABLED) { + throw new ConfigurationException('Active key id cannot point to a disabled key.'); + } } public function activeKey(): ?string { - return $this->activeKeyId === null ? null : $this->keys[$this->activeKeyId]; + if ($this->activeKeyId === null) { + return null; + } + + $entry = $this->keys[$this->activeKeyId]; + if (!$this->isEntryUsable($entry)) { + return null; + } + + return $entry['key']; } public function activeKeyId(): ?string @@ -44,37 +71,56 @@ public function activeKeyId(): ?string return $this->activeKeyId; } + /** + * @return array + */ + public function entries(): array + { + return $this->keys; + } + /** * @return array */ public function keys(): array { - return $this->keys; + return array_map( + static fn(array $entry): string => $entry['key'], + $this->keys, + ); } /** * @return list */ - public function orderedEntries(): array + public function orderedEntries(?string $purpose = null, ?int $at = null): array { $ordered = []; + $now = $at ?? time(); if ($this->activeKeyId !== null) { - $ordered[] = [ - 'id' => $this->activeKeyId, - 'key' => $this->keys[$this->activeKeyId], - 'active' => true, - ]; + $activeEntry = $this->keys[$this->activeKeyId]; + if ($this->isEntryUsable($activeEntry, $purpose, $now)) { + $ordered[] = [ + 'id' => $this->activeKeyId, + 'key' => $activeEntry['key'], + 'active' => true, + ]; + } } - foreach ($this->keys as $keyId => $key) { + foreach ($this->keys as $keyId => $entry) { if ($keyId === $this->activeKeyId) { continue; } + if (!$this->isEntryUsable($entry, $purpose, $now)) { + continue; + } + $ordered[] = [ 'id' => $keyId, - 'key' => $key, + 'key' => $entry['key'], 'active' => false, ]; } @@ -89,4 +135,136 @@ public function orderedKeys(): array { return array_column($this->orderedEntries(), 'key'); } + + /** + * @param array{key: string, status: string, not_before: ?int, not_after: ?int, purpose: ?string} $entry + */ + private function isEntryUsable(array $entry, ?string $purpose = null, ?int $at = null): bool + { + if ($entry['status'] === self::STATUS_DISABLED) { + return false; + } + + if ($at !== null) { + if ($entry['not_before'] !== null && $at < $entry['not_before']) { + return false; + } + if ($entry['not_after'] !== null && $at > $entry['not_after']) { + return false; + } + } + + if ($purpose !== null && $entry['purpose'] !== null && !hash_equals($entry['purpose'], $purpose)) { + return false; + } + + return true; + } + + /** + * @param string|array $entry + * @return array{key: string, status: string, not_before: ?int, not_after: ?int, purpose: ?string} + */ + private function normalizeEntry(string $keyId, string|array $entry): array + { + if (is_string($entry)) { + return $this->normalizeStringEntry($keyId, $entry); + } + + $keyValue = $entry['key'] ?? null; + if (!is_string($keyValue)) { + throw new ConfigurationException(sprintf('Key ring entry "%s" must define a string "key".', $keyId)); + } + $key = $keyValue; + + $status = $this->normalizeStatus( + $this->stringOrDefaultStatus($entry['status'] ?? null, $keyId), + ); + $notBefore = $this->nullableTimestamp($entry['not_before'] ?? null, $keyId, 'not_before'); + $notAfter = $this->nullableTimestamp($entry['not_after'] ?? null, $keyId, 'not_after'); + $purpose = $this->nullablePurpose($entry['purpose'] ?? null, $keyId); + + $this->validateWindow($notBefore, $notAfter, $keyId); + + return [ + 'key' => $key, + 'status' => $status, + 'not_before' => $notBefore, + 'not_after' => $notAfter, + 'purpose' => $purpose, + ]; + } + + private function normalizeStatus(string $status): string + { + $normalized = strtolower(trim($status)); + + return match ($normalized) { + self::STATUS_ACTIVE, + self::STATUS_FALLBACK, + self::STATUS_RETIRED, + self::STATUS_DISABLED => $normalized, + default => throw new ConfigurationException(sprintf('Unsupported key ring status "%s".', $status)), + }; + } + + /** + * @return array{key: string, status: string, not_before: ?int, not_after: ?int, purpose: ?string} + */ + private function normalizeStringEntry(string $keyId, string $entry): array + { + return [ + 'key' => $entry, + 'status' => $keyId === $this->activeKeyId ? self::STATUS_ACTIVE : self::STATUS_FALLBACK, + 'not_before' => null, + 'not_after' => null, + 'purpose' => null, + ]; + } + + private function nullablePurpose(mixed $purpose, string $keyId): ?string + { + if ($purpose === null) { + return null; + } + + if (!is_string($purpose) || trim($purpose) === '') { + throw new ConfigurationException(sprintf('Key ring entry "%s" purpose must be a non-empty string when provided.', $keyId)); + } + + return trim($purpose); + } + + private function nullableTimestamp(mixed $value, string $keyId, string $label): ?int + { + if ($value === null) { + return null; + } + + if (!is_int($value)) { + throw new ConfigurationException(sprintf('Key ring entry "%s" %s must be an integer unix timestamp.', $keyId, $label)); + } + + return $value; + } + + private function stringOrDefaultStatus(mixed $status, string $keyId): string + { + if ($status === null) { + return $keyId === $this->activeKeyId ? self::STATUS_ACTIVE : self::STATUS_FALLBACK; + } + + if (!is_string($status)) { + throw new ConfigurationException(sprintf('Key ring entry "%s" status must be a string.', $keyId)); + } + + return $status; + } + + private function validateWindow(?int $notBefore, ?int $notAfter, string $keyId): void + { + if ($notBefore !== null && $notAfter !== null && $notAfter < $notBefore) { + throw new ConfigurationException(sprintf('Key ring entry "%s" has invalid not_before/not_after bounds.', $keyId)); + } + } } diff --git a/src/Security/SignedUrl.php b/src/Security/SignedUrl.php index 98e5a76..d097ee2 100644 --- a/src/Security/SignedUrl.php +++ b/src/Security/SignedUrl.php @@ -138,10 +138,7 @@ private function assertUrlPolicy(array $parts, SignedUrlOptions $options, bool $ */ private function buildDisplayBasePath(array $parts): string { - $path = $this->pathFromParts($parts); - $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : ''; - $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower($parts['scheme']) : ''; - $port = isset($parts['port']) && is_int($parts['port']) ? ':' . $parts['port'] : ''; + [$path, $host, $scheme, $port] = $this->pathComponents($parts); if ($host === '' && $scheme === '') { return $path; @@ -167,10 +164,7 @@ private function buildQueryString(array $query): string */ private function buildSignatureBasePath(array $parts, SignedUrlOptions $options): string { - $path = $this->pathFromParts($parts); - $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : ''; - $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower($parts['scheme']) : ''; - $port = isset($parts['port']) && is_int($parts['port']) ? ':' . $parts['port'] : ''; + [$path, $host, $scheme, $port] = $this->pathComponents($parts); if (!$options->bindHost && !$options->bindScheme) { return $path; @@ -293,6 +287,20 @@ private function parseUrlWithQueryOrFail(string $url): array return $parsed; } + /** + * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + * @return array{string, string, string, string} + */ + private function pathComponents(array $parts): array + { + $path = $this->pathFromParts($parts); + $host = isset($parts['host']) && is_string($parts['host']) ? strtolower($parts['host']) : ''; + $scheme = isset($parts['scheme']) && is_string($parts['scheme']) ? strtolower($parts['scheme']) : ''; + $port = isset($parts['port']) && is_int($parts['port']) ? ':' . $parts['port'] : ''; + + return [$path, $host, $scheme, $port]; + } + /** * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts */ diff --git a/src/Token/Jwt/AsymmetricJwt.php b/src/Token/Jwt/AsymmetricJwt.php index c66cff7..9e97ee7 100644 --- a/src/Token/Jwt/AsymmetricJwt.php +++ b/src/Token/Jwt/AsymmetricJwt.php @@ -13,6 +13,7 @@ use Infocyph\Epicrypt\Security\Policy\SecurityProfile; use Infocyph\Epicrypt\Token\Jwt\Enum\AsymmetricJwtAlgorithm; use Infocyph\Epicrypt\Token\Jwt\Support\AbstractJwt; +use Infocyph\Epicrypt\Token\Jwt\Support\JwtToken; use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; @@ -35,6 +36,46 @@ public static function forProfile(SecurityProfile $profile = SecurityProfile::MO return new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims, $validationOptions, $clock, new EcdsaSignatureConverter()); } + /** + * @param array $jwks + */ + public function decodeFromJwks(string $token, array $jwks): object + { + $result = $this->decodeFromJwksResult($token, $jwks); + if (!$result->verified) { + throw new InvalidTokenException('JWT verification failed.'); + } + + return (object) $result->claims; + } + + /** + * @param array $jwks + */ + public function decodeFromJwksResult(string $token, array $jwks): JwtVerificationResult + { + $kid = $this->tokenKid($token); + $publicKey = new Jwks()->resolvePublicKeyByKid($jwks, $kid); + + return $this->decodeResult($token, $publicKey); + } + + /** + * @param array $jwks + */ + public function verifyFromJwks(string $token, array $jwks): bool + { + return $this->verifyFromJwksResult($token, $jwks)->verified; + } + + /** + * @param array $jwks + */ + public function verifyFromJwksResult(string $token, array $jwks): JwtVerificationResult + { + return $this->decodeFromJwksResult($token, $jwks); + } + protected function algorithmHeaderValue(mixed $algorithm): string { if (!$algorithm instanceof AsymmetricJwtAlgorithm) { @@ -97,4 +138,20 @@ protected function verifySignature(string $input, string $signature, string $res $algorithm->opensslAlgorithm(), ) === 1; } + + private function tokenKid(string $token): string + { + try { + [, , , $header] = JwtToken::parse($token); + } catch (\Throwable $e) { + throw new InvalidTokenException('Invalid JWT format.', 0, $e); + } + + $kid = $header['kid'] ?? null; + if (!is_string($kid) || $kid === '') { + throw new InvalidTokenException('JWT kid header is required for JWKS verification.'); + } + + return $kid; + } } diff --git a/src/Token/Jwt/Jwks.php b/src/Token/Jwt/Jwks.php index d28663a..efb515c 100644 --- a/src/Token/Jwt/Jwks.php +++ b/src/Token/Jwt/Jwks.php @@ -46,6 +46,23 @@ public function exportPublicKeyToJwk(string $publicKeyPem, string $kid): array }; } + /** + * @param array $jwk + */ + public function importPublicKeyFromJwk(array $jwk): string + { + $kty = $jwk['kty'] ?? null; + if (!is_string($kty) || $kty === '') { + throw new KeyResolutionException('JWK key type "kty" is required.'); + } + + return match (strtoupper($kty)) { + 'RSA' => $this->importRsa($jwk), + 'EC' => $this->importEc($jwk), + default => throw new KeyResolutionException(sprintf('Unsupported JWK key type "%s".', $kty)), + }; + } + /** * @param array{keys?: mixed} $jwks * @return array @@ -66,6 +83,84 @@ public function resolveByKid(array $jwks, string $kid): array throw new KeyResolutionException(sprintf('No JWK found for kid "%s".', $kid)); } + /** + * @param array $jwks + */ + public function resolvePublicKeyByKid(array $jwks, string $kid): string + { + return $this->importPublicKeyFromJwk($this->resolveByKid($jwks, $kid)); + } + + private function derBitString(string $value): string + { + return "\x03" . $this->derLength(strlen($value) + 1) . "\x00" . $value; + } + + private function derInteger(string $value): string + { + $normalized = ltrim($value, "\x00"); + if ($normalized === '') { + $normalized = "\x00"; + } + + if ((ord($normalized[0]) & 0x80) !== 0) { + $normalized = "\x00" . $normalized; + } + + return "\x02" . $this->derLength(strlen($normalized)) . $normalized; + } + + private function derLength(int $length): string + { + if ($length < 128) { + return chr($length); + } + + $result = ''; + while ($length > 0) { + $result = chr($length & 0xFF) . $result; + $length >>= 8; + } + + return chr(0x80 | strlen($result)) . $result; + } + + private function derOid(string $oid): string + { + $parts = explode('.', $oid); + if (count($parts) < 2) { + throw new KeyResolutionException(sprintf('Invalid OID "%s".', $oid)); + } + + $first = (int) $parts[0]; + $second = (int) $parts[1]; + $encoded = chr(($first * 40) + $second); + + for ($i = 2; $i < count($parts); $i++) { + $value = (int) $parts[$i]; + if ($value < 0) { + throw new KeyResolutionException(sprintf('Invalid OID "%s".', $oid)); + } + + $segment = chr($value & 0x7F); + $value >>= 7; + + while ($value > 0) { + $segment = chr(($value & 0x7F) | 0x80) . $segment; + $value >>= 7; + } + + $encoded .= $segment; + } + + return "\x06" . $this->derLength(strlen($encoded)) . $encoded; + } + + private function derSequence(string $value): string + { + return "\x30" . $this->derLength(strlen($value)) . $value; + } + /** * @param array $details * @return array @@ -120,6 +215,88 @@ private function exportRsa(array $details, string $kid): array ]; } + /** + * @param array $jwk + */ + private function importEc(array $jwk): string + { + $x = $jwk['x'] ?? null; + $y = $jwk['y'] ?? null; + $crv = $jwk['crv'] ?? null; + if (!is_string($x) || !is_string($y) || !is_string($crv) || $x === '' || $y === '' || $crv === '') { + throw new KeyResolutionException('EC JWK must contain non-empty "crv", "x", and "y" values.'); + } + + try { + $xBin = Base64Url::decode($x); + $yBin = Base64Url::decode($y); + } catch (\Throwable $e) { + throw new KeyResolutionException('Invalid EC JWK coordinate encoding.', 0, $e); + } + + if (strlen($xBin) !== strlen($yBin)) { + throw new KeyResolutionException('EC JWK coordinates must have equal size.'); + } + + $point = "\x04" . $xBin . $yBin; + $curveOid = match ($crv) { + 'P-256' => $this->derOid('1.2.840.10045.3.1.7'), + 'P-384' => $this->derOid('1.3.132.0.34'), + 'P-521' => $this->derOid('1.3.132.0.35'), + default => throw new KeyResolutionException(sprintf('Unsupported EC curve "%s".', $crv)), + }; + + $algorithmIdentifier = $this->derSequence( + $this->derOid('1.2.840.10045.2.1') . $curveOid, + ); + + $spki = $this->derSequence( + $algorithmIdentifier . $this->derBitString($point), + ); + + return $this->pemEncode('PUBLIC KEY', $spki); + } + + /** + * @param array $jwk + */ + private function importRsa(array $jwk): string + { + $n = $jwk['n'] ?? null; + $e = $jwk['e'] ?? null; + if (!is_string($n) || !is_string($e) || $n === '' || $e === '') { + throw new KeyResolutionException('RSA JWK must contain non-empty "n" and "e" values.'); + } + + try { + $modulus = Base64Url::decode($n); + $exponent = Base64Url::decode($e); + } catch (\Throwable $exception) { + throw new KeyResolutionException('Invalid RSA JWK numeric encoding.', 0, $exception); + } + + $rsaPublicKey = $this->derSequence( + $this->derInteger($modulus) . $this->derInteger($exponent), + ); + + $algorithmIdentifier = $this->derSequence( + $this->derOid('1.2.840.113549.1.1.1') . "\x05\x00", + ); + + $spki = $this->derSequence( + $algorithmIdentifier . $this->derBitString($rsaPublicKey), + ); + + return $this->pemEncode('PUBLIC KEY', $spki); + } + + private function pemEncode(string $label, string $binary): string + { + $body = chunk_split(base64_encode($binary), 64, "\n"); + + return sprintf("-----BEGIN %s-----\n%s-----END %s-----\n", $label, $body, $label); + } + /** * @param array $input * @return array diff --git a/tests/Certificate/DomainTest.php b/tests/Certificate/DomainTest.php index bacc2c7..dd14711 100644 --- a/tests/Certificate/DomainTest.php +++ b/tests/Certificate/DomainTest.php @@ -1,7 +1,9 @@ generate(); - $cipher = new RsaCipher; + $cipher = new RsaCipher(); $encrypted = $cipher->encrypt('certificate-rsa-check', $keyPair['public']); $decrypted = $cipher->decrypt($encrypted, $keyPair['private']); @@ -152,8 +155,39 @@ }); it('rejects curve selection for RSA key pair generation', function () { - expect(fn () => KeyPairGenerator::openSsl( + expect(fn() => KeyPairGenerator::openSsl( bits: OpenSslRsaBits::BITS_2048, curveName: OpenSslCurveName::PRIME256V1, ))->toThrow(ConfigurationException::class); }); + +it('uses RSA 3072 as the default OpenSSL key size', function () { + $pair = KeyPairGenerator::openSsl()->generate(); + $resource = openssl_pkey_get_private($pair['private']); + expect($resource)->not->toBeFalse(); + + $details = openssl_pkey_get_details($resource); + expect($details)->toBeArray(); + expect($details['bits'] ?? null)->toBe(3072); +}); + +it('exports and imports pkcs12 bundles', function () { + $keyPair = KeyPairGenerator::openSsl()->generate(); + $dn = [ + 'countryName' => 'US', + 'stateOrProvinceName' => 'CA', + 'localityName' => 'San Francisco', + 'organizationName' => 'Epicrypt', + 'organizationalUnitName' => 'Security', + 'commonName' => 'pkcs12.epicrypt.local', + 'emailAddress' => 'security@epicrypt.local', + ]; + $certificate = CertificateBuilder::openSsl()->selfSign($dn, $keyPair['private'], 365); + + $manager = new Pkcs12(); + $bundle = $manager->export($certificate, $keyPair['private'], 'changeit'); + $imported = $manager->import($bundle, 'changeit'); + + expect($imported['certificate'])->toContain('BEGIN CERTIFICATE'); + expect($imported['private_key'])->toContain('BEGIN PRIVATE KEY'); +}); diff --git a/tests/Internal/VersionedPayloadTest.php b/tests/Internal/VersionedPayloadTest.php index 2b78ff4..3892c3a 100644 --- a/tests/Internal/VersionedPayloadTest.php +++ b/tests/Internal/VersionedPayloadTest.php @@ -14,16 +14,8 @@ expect($parsed?->ciphertext)->toBe('ciphertext'); }); -it('parses unversioned compact payloads', function () { - $parsed = VersionedPayload::parseCompact('secretbox.current.nonce.ciphertext', 'epc1'); - - expect($parsed)->not->toBeNull(); - expect($parsed?->versioned)->toBeFalse(); - expect($parsed?->algorithm)->toBe('secretbox'); - expect($parsed?->keyId)->toBe('current'); -}); - it('rejects malformed compact payload parts', function () { + expect(VersionedPayload::parseCompact('secretbox.current.nonce.ciphertext', 'epc1'))->toBeNull(); expect(VersionedPayload::parseCompact('epc1.secretbox._.nonce', 'epc1'))->toBeNull(); expect(VersionedPayload::parseCompact('epc1.._.nonce.ciphertext', 'epc1'))->toBeNull(); expect(VersionedPayload::parseCompact('epc1.secretbox._..ciphertext', 'epc1'))->toBeNull(); diff --git a/tests/Password/ServicesTest.php b/tests/Password/ServicesTest.php index 27b2c8f..56744fe 100644 --- a/tests/Password/ServicesTest.php +++ b/tests/Password/ServicesTest.php @@ -64,9 +64,6 @@ expect($segments[1])->toBe('secretbox'); expect($segments[2])->toBe('_'); expect($manager->unwrap($wrapped, $master))->toBe('sensitive-secret'); - - $unversionedWrapped = implode('.', array_slice($segments, 1)); - expect($manager->unwrap($unversionedWrapped, $master))->toBe('sensitive-secret'); }); it('supports wrapped secret rollover and rewrap flows', function () { diff --git a/tests/Token/Jwt/JwksTest.php b/tests/Token/Jwt/JwksTest.php index 35f94d2..4b63c77 100644 --- a/tests/Token/Jwt/JwksTest.php +++ b/tests/Token/Jwt/JwksTest.php @@ -1,7 +1,9 @@ toBe('RSA'); expect($ecJwk['kty'] ?? null)->toBe('EC'); }); + +it('imports jwk to pem and verifies jwt using jwks kid resolution', function () { + $rsa = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + if ($rsa === false) { + expect(true)->toBeTrue(); + + return; + } + + $exported = openssl_pkey_export($rsa, $privatePem); + expect($exported)->toBeTrue(); + expect($privatePem)->toBeString(); + + $details = openssl_pkey_get_details($rsa); + expect($details)->toBeArray(); + $publicPem = $details['key'] ?? null; + expect($publicPem)->toBeString(); + + $jwks = new Jwks(); + $jwk = $jwks->exportPublicKeyToJwk($publicPem, 'rsa-signing'); + $importedPem = $jwks->importPublicKeyFromJwk($jwk); + expect(openssl_pkey_get_public($importedPem))->not->toBeFalse(); + + $now = time(); + $claims = [ + 'iss' => 'issuer-service', + 'aud' => 'audience-service', + 'sub' => 'subject-service', + 'jti' => 'token-service', + 'nbf' => $now, + 'exp' => $now + 300, + 'kid' => 'rsa-signing', + ]; + + $issuer = new AsymmetricJwt(); + $token = $issuer->encode($claims, $privatePem); + + $verifier = new AsymmetricJwt( + null, + expectedClaims: new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-service'), + ); + + $result = $verifier->decodeFromJwksResult($token, ['keys' => [$jwk]]); + expect($result->verified)->toBeTrue(); + expect($result->matchedKeyId)->toBe('rsa-signing'); +}); From 5f2455a54e670da4116859cfecb55efd24203831 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 14 May 2026 11:41:07 +0600 Subject: [PATCH 4/4] updated docs, fix ic issues --- .github/workflows/build.yml | 128 ------------------ src/Certificate/Sodium/SessionKeyExchange.php | 4 +- src/Crypto/AeadCipher.php | 2 +- src/Crypto/Context/AeadContext.php | 56 ++++---- src/Crypto/Mac.php | 2 +- src/Crypto/PublicKeyBoxCipher.php | 57 ++++++-- src/Crypto/SealedBoxCipher.php | 4 +- src/Crypto/SecretBoxCipher.php | 3 +- src/Crypto/Signature.php | 4 +- src/DataProtection/FileProtector.php | 2 +- src/DataProtection/StringProtector.php | 53 ++++---- .../Support/ProtectionContext.php | 59 ++++---- src/Internal/BaseProtectionContext.php | 18 +++ src/Internal/BinaryKey.php | 47 +------ src/Internal/ContextValue.php | 74 ++++++++++ src/Password/Generator/PasswordGenerator.php | 2 +- src/Password/Secret/WrappedSecretManager.php | 2 +- src/Security/ActionToken.php | 10 +- src/Security/EmailVerificationToken.php | 10 +- src/Security/PasswordResetToken.php | 10 +- src/Security/RememberToken.php | 10 +- src/Security/Support/AbstractPurposeToken.php | 7 +- src/Token/Jwt/Jwks.php | 21 ++- tests/Certificate/DomainTest.php | 20 +-- tests/Crypto/AeadContextTest.php | 6 +- tests/Crypto/CoreServicesTest.php | 8 +- tests/Crypto/MatrixCoverageTest.php | 10 +- tests/Crypto/SecretStreamTest.php | 6 +- .../FileProtectionMatrixTest.php | 32 ++--- tests/DataProtection/ServicesTest.php | 10 +- tests/Generate/KeyDerivationContextTest.php | 7 +- tests/Generate/ServicesTest.php | 4 +- tests/Integrity/ServicesTest.php | 6 +- tests/Internal/BinaryKeyTest.php | 8 +- .../Internal/EcdsaSignatureConverterTest.php | 4 +- tests/Internal/ProtectionContextTest.php | 6 +- tests/Internal/VersionedPayloadTest.php | 4 +- tests/Password/ServicesTest.php | 4 +- tests/Security/ClockIntegrationTest.php | 3 +- tests/Security/SignedUrlHardeningTest.php | 3 +- tests/Token/Jwt/AlgorithmMatrixTest.php | 4 +- tests/Token/Jwt/HardeningTest.php | 6 +- tests/Token/Jwt/JwksTest.php | 6 +- tests/Token/OpaqueTokenTest.php | 3 +- .../Token/SignedPayloadTemporalClaimsTest.php | 8 +- 45 files changed, 345 insertions(+), 408 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 src/Internal/BaseProtectionContext.php create mode 100644 src/Internal/ContextValue.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 001aea0..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: "Security & Standards" - -on: - schedule: - - cron: '0 0 * * 0' - push: - branches: [ "main", "master" ] - pull_request: - branches: [ "main", "master", "develop", "development" ] - -jobs: - prepare: - name: Prepare CI matrix - runs-on: ubuntu-latest - outputs: - php_versions: ${{ steps.matrix.outputs.php_versions }} - dependency_versions: ${{ steps.matrix.outputs.dependency_versions }} - steps: - - name: Define shared matrix values - id: matrix - run: | - echo 'php_versions=["8.4","8.5"]' >> "$GITHUB_OUTPUT" - echo 'dependency_versions=["prefer-lowest","prefer-stable"]' >> "$GITHUB_OUTPUT" - - run: - needs: prepare - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: [ ubuntu-latest ] - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} - - name: Code Analysis - PHP ${{ matrix.php-versions }} - ${{ matrix.dependency-version }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Check PHP Version - run: php -v - - - name: Validate Composer - run: composer validate --strict - - - name: Resolve dependencies (${{ matrix.dependency-version }}) - run: composer update --no-interaction --prefer-dist --no-progress --${{ matrix.dependency-version }} - - - name: Test - run: | - composer test:syntax - composer test:code - composer test:lint - composer test:sniff - composer test:refactor - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:static - fi - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:security - fi - - analyze: - needs: prepare - name: Security Analysis - PHP ${{ matrix.php-versions }} - runs-on: ubuntu-latest - strategy: - matrix: - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - permissions: - security-events: write - actions: read - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist --no-progress - - - name: Composer Audit (Release Guard) - run: composer release:audit - - - name: Quality Gate (PHPStan) - run: composer test:static - - - name: Security Gate (Psalm) - run: composer test:security - - - name: Run PHPStan (Code Scanning) - run: | - php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true - php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif - continue-on-error: true - - - name: Upload PHPStan Results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: phpstan-results.sarif - category: "phpstan-${{ matrix.php-versions }}" - if: always() && hashFiles('phpstan-results.sarif') != '' - - # Run Psalm (Deep Taint Analysis) - - name: Run Psalm Security Scan - run: | - php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true - continue-on-error: true - - - name: Upload Psalm Results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: psalm-results.sarif - category: "psalm-${{ matrix.php-versions }}" - if: always() && hashFiles('psalm-results.sarif') != '' diff --git a/src/Certificate/Sodium/SessionKeyExchange.php b/src/Certificate/Sodium/SessionKeyExchange.php index 30853b1..936b13e 100644 --- a/src/Certificate/Sodium/SessionKeyExchange.php +++ b/src/Certificate/Sodium/SessionKeyExchange.php @@ -14,8 +14,8 @@ final class SessionKeyExchange implements KeyExchangeInterface public function derive(string $privateKey, string $publicKey, bool $keysAreBinary = false): string { try { - $private = BinaryKey::boxSecretKey($privateKey, $keysAreBinary, 'Private key'); - $public = BinaryKey::boxPublicKey($publicKey, $keysAreBinary, 'Public key'); + $private = BinaryKey::fixedLength($privateKey, $keysAreBinary, SODIUM_CRYPTO_BOX_SECRETKEYBYTES, 'Private key'); + $public = BinaryKey::fixedLength($publicKey, $keysAreBinary, SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, 'Public key'); } catch (InvalidKeyException $e) { throw new InvalidKeyException('Sodium key exchange requires valid curve25519 private/public keys.', 0, $e); } diff --git a/src/Crypto/AeadCipher.php b/src/Crypto/AeadCipher.php index c77e54b..df7597c 100644 --- a/src/Crypto/AeadCipher.php +++ b/src/Crypto/AeadCipher.php @@ -97,7 +97,7 @@ private function assertAlgorithmAvailability(): void private function decodeKey(mixed $key, int $expectedLength, bool $isBinary, string $operation): string { try { - return BinaryKey::aeadKey($key, $isBinary, $expectedLength, sprintf('%s key', $operation)); + return BinaryKey::fixedLength($key, $isBinary, $expectedLength, sprintf('%s key', $operation)); } catch (InvalidKeyException $e) { throw new InvalidKeyException(sprintf('%s key must be %d bytes.', $operation, $expectedLength), 0, $e); } diff --git a/src/Crypto/Context/AeadContext.php b/src/Crypto/Context/AeadContext.php index bbd9dd4..204ccfc 100644 --- a/src/Crypto/Context/AeadContext.php +++ b/src/Crypto/Context/AeadContext.php @@ -6,50 +6,56 @@ use Infocyph\Epicrypt\Exception\Crypto\CryptoException; use Infocyph\Epicrypt\Exception\Crypto\InvalidNonceException; +use Infocyph\Epicrypt\Internal\BaseProtectionContext; +use Infocyph\Epicrypt\Internal\ContextValue; /** * @internal */ -final readonly class AeadContext +final readonly class AeadContext extends BaseProtectionContext { public function __construct( - public bool $keyIsBinary = false, - public bool $nonceIsBinary = false, - public string $aad = '', public ?string $nonce = null, - public ?string $keyId = null, - ) {} + bool $keyIsBinary = false, + bool $nonceIsBinary = false, + string $aad = '', + ?string $keyId = null, + ) { + parent::__construct($keyIsBinary, $nonceIsBinary, $aad, $keyId); + } /** * @param array $context */ public static function fromArray(array $context): self { - $keyIsBinary = $context['key_is_binary'] ?? false; - if (!is_bool($keyIsBinary)) { - throw new CryptoException('Context value "key_is_binary" must be boolean.'); - } - - $nonceIsBinary = $context['nonce_is_binary'] ?? false; - if (!is_bool($nonceIsBinary)) { - throw new CryptoException('Context value "nonce_is_binary" must be boolean.'); - } - - $aad = $context['aad'] ?? ''; - if (!is_string($aad)) { - throw new CryptoException('AAD must be a string.'); - } + $baseFields = self::parseBaseFields($context); $nonce = $context['nonce'] ?? null; if ($nonce !== null && (!is_string($nonce) || $nonce === '')) { throw new InvalidNonceException('Nonce must be a non-empty string.'); } - $keyId = $context['key_id'] ?? null; - if ($keyId !== null && (!is_string($keyId) || $keyId === '')) { - throw new CryptoException('Context value "key_id" must be a non-empty string when provided.'); - } + return new self( + nonce: $nonce, + keyIsBinary: $baseFields['key_is_binary'], + nonceIsBinary: $baseFields['nonce_is_binary'], + aad: $baseFields['aad'], + keyId: $baseFields['key_id'], + ); + } - return new self($keyIsBinary, $nonceIsBinary, $aad, $nonce, $keyId); + /** + * @param array $context + * @return array{key_is_binary: bool, nonce_is_binary: bool, aad: string, key_id: ?string} + */ + private static function parseBaseFields(array $context): array + { + return ContextValue::baseProtectionFields( + $context, + fn(string $key): CryptoException => new CryptoException(sprintf('Context value "%s" must be boolean.', $key)), + fn(string $key): CryptoException => new CryptoException(sprintf('Context value "%s" must be a string.', $key)), + fn(string $key): CryptoException => new CryptoException(sprintf('Context value "%s" must be a non-empty string when provided.', $key)), + ); } } diff --git a/src/Crypto/Mac.php b/src/Crypto/Mac.php index dd8e37d..2eb4e17 100644 --- a/src/Crypto/Mac.php +++ b/src/Crypto/Mac.php @@ -41,7 +41,7 @@ public function verify(string $message, string $mac, string $key, array $context private function decodeKey(string $key, bool $isBinary): string { try { - return BinaryKey::macKey($key, $isBinary, 'MAC key'); + return BinaryKey::fixedLength($key, $isBinary, SODIUM_CRYPTO_AUTH_KEYBYTES, 'MAC key'); } catch (InvalidKeyException $e) { throw new InvalidKeyException('MAC key must be 32 bytes.', 0, $e); } diff --git a/src/Crypto/PublicKeyBoxCipher.php b/src/Crypto/PublicKeyBoxCipher.php index 95ba1a8..7317cba 100644 --- a/src/Crypto/PublicKeyBoxCipher.php +++ b/src/Crypto/PublicKeyBoxCipher.php @@ -20,13 +20,15 @@ final class PublicKeyBoxCipher implements CipherInterface */ public function decrypt(string $ciphertext, mixed $key, array $context = []): string { - if (!is_array($key)) { - throw new InvalidKeyException('Key must include sender_public and recipient_private entries.'); - } + $key = $this->normalizeKeyMaterial($key, 'Key must include sender_public and recipient_private entries.'); try { - $senderPublic = BinaryKey::boxPublicKey($key['sender_public'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'sender_public'); - $recipientPrivate = BinaryKey::boxSecretKey($key['recipient_private'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'recipient_private'); + [$senderPublic, $recipientPrivate] = $this->resolveBoxKeyPair( + $key, + (bool) ($context['key_is_binary'] ?? false), + 'sender_public', + 'recipient_private', + ); } catch (InvalidKeyException $e) { throw new DecryptionException('Public key-box decryption key material is invalid.', 0, $e); } @@ -54,13 +56,15 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st */ public function encrypt(string $plaintext, mixed $key, array $context = []): string { - if (!is_array($key)) { - throw new InvalidKeyException('Key must include recipient_public and sender_private entries.'); - } + $key = $this->normalizeKeyMaterial($key, 'Key must include recipient_public and sender_private entries.'); try { - $recipientPublic = BinaryKey::boxPublicKey($key['recipient_public'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'recipient_public'); - $senderPrivate = BinaryKey::boxSecretKey($key['sender_private'] ?? null, (bool) ($context['key_is_binary'] ?? false), 'sender_private'); + [$recipientPublic, $senderPrivate] = $this->resolveBoxKeyPair( + $key, + (bool) ($context['key_is_binary'] ?? false), + 'recipient_public', + 'sender_private', + ); } catch (InvalidKeyException $e) { throw new EncryptionException('Public key-box encryption key material is invalid.', 0, $e); } @@ -78,4 +82,37 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str Base64Url::encode($ciphertext), ); } + + /** + * @return array + */ + private function normalizeKeyMaterial(mixed $key, string $invalidMessage): array + { + if (!is_array($key)) { + throw new InvalidKeyException($invalidMessage); + } + + $normalized = []; + foreach ($key as $entryKey => $entryValue) { + if (!is_string($entryKey)) { + throw new InvalidKeyException($invalidMessage); + } + + $normalized[$entryKey] = $entryValue; + } + + return $normalized; + } + + /** + * @param array $key + * @return array{0: string, 1: string} + */ + private function resolveBoxKeyPair(array $key, bool $keyIsBinary, string $publicKeyField, string $secretKeyField): array + { + return [ + BinaryKey::fixedLength($key[$publicKeyField] ?? null, $keyIsBinary, SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, $publicKeyField), + BinaryKey::fixedLength($key[$secretKeyField] ?? null, $keyIsBinary, SODIUM_CRYPTO_BOX_SECRETKEYBYTES, $secretKeyField), + ]; + } } diff --git a/src/Crypto/SealedBoxCipher.php b/src/Crypto/SealedBoxCipher.php index 8807b7e..1af167f 100644 --- a/src/Crypto/SealedBoxCipher.php +++ b/src/Crypto/SealedBoxCipher.php @@ -21,7 +21,7 @@ final class SealedBoxCipher implements CipherInterface public function decrypt(string $ciphertext, mixed $key, array $context = []): string { try { - $keypair = BinaryKey::boxKeypair($key, (bool) ($context['key_is_binary'] ?? false), 'Recipient keypair'); + $keypair = BinaryKey::fixedLength($key, (bool) ($context['key_is_binary'] ?? false), SODIUM_CRYPTO_BOX_KEYPAIRBYTES, 'Recipient keypair'); } catch (InvalidKeyException $e) { throw new DecryptionException('Recipient keypair must be valid.', 0, $e); } @@ -45,7 +45,7 @@ public function decrypt(string $ciphertext, mixed $key, array $context = []): st public function encrypt(string $plaintext, mixed $key, array $context = []): string { try { - $publicKey = BinaryKey::boxPublicKey($key, (bool) ($context['key_is_binary'] ?? false), 'Recipient public key'); + $publicKey = BinaryKey::fixedLength($key, (bool) ($context['key_is_binary'] ?? false), SODIUM_CRYPTO_BOX_PUBLICKEYBYTES, 'Recipient public key'); } catch (InvalidKeyException $e) { throw new EncryptionException('Recipient public key must be valid.', 0, $e); } diff --git a/src/Crypto/SecretBoxCipher.php b/src/Crypto/SecretBoxCipher.php index 887421d..dccd89c 100644 --- a/src/Crypto/SecretBoxCipher.php +++ b/src/Crypto/SecretBoxCipher.php @@ -73,9 +73,10 @@ public function parseKeyId(string $ciphertext): ?string private function decodeKey(mixed $key, array $context, string $operation): string { try { - return BinaryKey::secretBoxKey( + return BinaryKey::fixedLength( $key, (bool) ($context['key_is_binary'] ?? false), + SODIUM_CRYPTO_SECRETBOX_KEYBYTES, sprintf('%s key', $operation), ); } catch (InvalidKeyException $e) { diff --git a/src/Crypto/Signature.php b/src/Crypto/Signature.php index c285c80..8f637a6 100644 --- a/src/Crypto/Signature.php +++ b/src/Crypto/Signature.php @@ -18,7 +18,7 @@ final class Signature implements SignatureInterface public function sign(string $message, mixed $key, array $context = []): string { try { - $privateKey = BinaryKey::signSecretKey($key, (bool) ($context['key_is_binary'] ?? false), 'Private key'); + $privateKey = BinaryKey::fixedLength($key, (bool) ($context['key_is_binary'] ?? false), SODIUM_CRYPTO_SIGN_SECRETKEYBYTES, 'Private key'); } catch (InvalidKeyException $e) { throw new SignatureException('Private key must be a valid signing secret key.', 0, $e); } @@ -34,7 +34,7 @@ public function sign(string $message, mixed $key, array $context = []): string public function verify(string $message, string $signature, mixed $key, array $context = []): bool { try { - $publicKey = BinaryKey::signPublicKey($key, (bool) ($context['key_is_binary'] ?? false), 'Public key'); + $publicKey = BinaryKey::fixedLength($key, (bool) ($context['key_is_binary'] ?? false), SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, 'Public key'); } catch (InvalidKeyException $e) { throw new SignatureException('Public key must be a valid signing public key.', 0, $e); } diff --git a/src/DataProtection/FileProtector.php b/src/DataProtection/FileProtector.php index b38aa05..495aee5 100644 --- a/src/DataProtection/FileProtector.php +++ b/src/DataProtection/FileProtector.php @@ -180,7 +180,7 @@ private function createBackupIfPresent(string $path, string $backupPath): bool private function decodeKey(string $key, bool $keyIsBinary): string { try { - return BinaryKey::aeadKey($key, $keyIsBinary, $this->algorithm->keyLength(), 'Stream key'); + return BinaryKey::fixedLength($key, $keyIsBinary, $this->algorithm->keyLength(), 'Stream key'); } catch (InvalidKeyException $e) { throw new InvalidKeyException(sprintf('Stream key must be %d bytes.', $this->algorithm->keyLength()), 0, $e); } diff --git a/src/DataProtection/StringProtector.php b/src/DataProtection/StringProtector.php index c6f5335..cc63431 100644 --- a/src/DataProtection/StringProtector.php +++ b/src/DataProtection/StringProtector.php @@ -55,21 +55,8 @@ public function decryptWithAnyKeyResult(string $ciphertext, iterable|KeyRing $ke } $normalized = ProtectionContext::fromArray($context)->toArray(); - $lastException = null; - - foreach ($this->orderedKeyEntries($keys) as $entry) { - try { - return new StringUnprotectResult( - $this->decrypt($ciphertext, $entry['key'], $normalized), - $entry['id'], - !$entry['active'], - ); - } catch (Throwable $e) { - $lastException = $e; - } - } - throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); + return $this->decryptWithOrderedEntries($ciphertext, $normalized, $this->orderedKeyEntries($keys)); } /** @@ -105,20 +92,7 @@ public function decryptWithKeyRingResult(string $ciphertext, KeyRing $keyRing, a } } - $lastException = null; - foreach ($this->orderedKeyEntries($keyRing) as $entry) { - try { - return new StringUnprotectResult( - $this->decrypt($ciphertext, $entry['key'], $normalized), - $entry['id'], - !$entry['active'], - ); - } catch (Throwable $e) { - $lastException = $e; - } - } - - throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); + return $this->decryptWithOrderedEntries($ciphertext, $normalized, $this->orderedKeyEntries($keyRing)); } /** @@ -204,6 +178,29 @@ public function reencryptWithAnyKey(string $ciphertext, iterable|KeyRing $source return $this->encrypt($plaintext, $newKey, $currentContext); } + /** + * @param array $context + * @param list $entries + */ + private function decryptWithOrderedEntries(string $ciphertext, array $context, array $entries): StringUnprotectResult + { + $lastException = null; + + foreach ($entries as $entry) { + try { + return new StringUnprotectResult( + $this->decrypt($ciphertext, $entry['key'], $context), + $entry['id'], + !$entry['active'], + ); + } catch (Throwable $e) { + $lastException = $e; + } + } + + throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); + } + /** * @param iterable|KeyRing $keys * @return list diff --git a/src/DataProtection/Support/ProtectionContext.php b/src/DataProtection/Support/ProtectionContext.php index 5198c25..13b5f50 100644 --- a/src/DataProtection/Support/ProtectionContext.php +++ b/src/DataProtection/Support/ProtectionContext.php @@ -5,51 +5,48 @@ namespace Infocyph\Epicrypt\DataProtection\Support; use Infocyph\Epicrypt\Exception\ConfigurationException; +use Infocyph\Epicrypt\Internal\BaseProtectionContext; +use Infocyph\Epicrypt\Internal\ContextValue; /** * @internal */ -final readonly class ProtectionContext +final readonly class ProtectionContext extends BaseProtectionContext { public function __construct( - public bool $keyIsBinary = false, - public bool $nonceIsBinary = false, - public string $aad = '', - public ?string $keyId = null, public ?string $purpose = null, - ) {} + bool $keyIsBinary = false, + bool $nonceIsBinary = false, + string $aad = '', + ?string $keyId = null, + ) { + parent::__construct($keyIsBinary, $nonceIsBinary, $aad, $keyId); + } /** * @param array $context */ public static function fromArray(array $context): self { - $keyIsBinary = $context['key_is_binary'] ?? false; - if (!is_bool($keyIsBinary)) { - throw new ConfigurationException('Protection context key_is_binary must be a boolean.'); - } - - $nonceIsBinary = $context['nonce_is_binary'] ?? false; - if (!is_bool($nonceIsBinary)) { - throw new ConfigurationException('Protection context nonce_is_binary must be a boolean.'); - } - - $aad = $context['aad'] ?? ''; - if (!is_string($aad)) { - throw new ConfigurationException('Protection context aad must be a string.'); - } - - $keyId = $context['key_id'] ?? null; - if ($keyId !== null && (!is_string($keyId) || $keyId === '')) { - throw new ConfigurationException('Protection context key_id must be a non-empty string when provided.'); - } - - $purpose = $context['purpose'] ?? null; - if ($purpose !== null && (!is_string($purpose) || $purpose === '')) { - throw new ConfigurationException('Protection context purpose must be a non-empty string when provided.'); - } + $baseFields = ContextValue::baseProtectionFields( + $context, + fn(string $key): ConfigurationException => new ConfigurationException(sprintf('Protection context %s must be a boolean.', $key)), + fn(string $key): ConfigurationException => new ConfigurationException(sprintf('Protection context %s must be a string.', $key)), + fn(string $key): ConfigurationException => new ConfigurationException(sprintf('Protection context %s must be a non-empty string when provided.', $key)), + ); + $purpose = ContextValue::optionalNonEmptyString( + $context, + 'purpose', + fn(string $key): ConfigurationException => new ConfigurationException(sprintf('Protection context %s must be a non-empty string when provided.', $key)), + ); - return new self($keyIsBinary, $nonceIsBinary, $aad, $keyId, $purpose); + return new self( + purpose: $purpose, + keyIsBinary: $baseFields['key_is_binary'], + nonceIsBinary: $baseFields['nonce_is_binary'], + aad: $baseFields['aad'], + keyId: $baseFields['key_id'], + ); } /** diff --git a/src/Internal/BaseProtectionContext.php b/src/Internal/BaseProtectionContext.php new file mode 100644 index 0000000..37f53c8 --- /dev/null +++ b/src/Internal/BaseProtectionContext.php @@ -0,0 +1,18 @@ + $context + * @param \Closure(string): \Throwable $boolExceptionFactory + * @param \Closure(string): \Throwable $stringExceptionFactory + * @param \Closure(string): \Throwable $optionalStringExceptionFactory + * @return array{key_is_binary: bool, nonce_is_binary: bool, aad: string, key_id: ?string} + */ + public static function baseProtectionFields( + array $context, + \Closure $boolExceptionFactory, + \Closure $stringExceptionFactory, + \Closure $optionalStringExceptionFactory, + ): array { + return [ + 'key_is_binary' => self::bool($context, 'key_is_binary', false, $boolExceptionFactory), + 'nonce_is_binary' => self::bool($context, 'nonce_is_binary', false, $boolExceptionFactory), + 'aad' => self::string($context, 'aad', '', $stringExceptionFactory), + 'key_id' => self::optionalNonEmptyString($context, 'key_id', $optionalStringExceptionFactory), + ]; + } + + /** + * @param array $context + * @param \Closure(string): \Throwable $exceptionFactory + */ + public static function bool(array $context, string $key, bool $default, \Closure $exceptionFactory): bool + { + $value = $context[$key] ?? $default; + if (!is_bool($value)) { + throw $exceptionFactory($key); + } + + return $value; + } + + /** + * @param array $context + * @param \Closure(string): \Throwable $exceptionFactory + */ + public static function optionalNonEmptyString(array $context, string $key, \Closure $exceptionFactory): ?string + { + $value = $context[$key] ?? null; + if ($value !== null && (!is_string($value) || $value === '')) { + throw $exceptionFactory($key); + } + + return $value; + } + + /** + * @param array $context + * @param \Closure(string): \Throwable $exceptionFactory + */ + public static function string(array $context, string $key, string $default, \Closure $exceptionFactory): string + { + $value = $context[$key] ?? $default; + if (!is_string($value)) { + throw $exceptionFactory($key); + } + + return $value; + } +} diff --git a/src/Password/Generator/PasswordGenerator.php b/src/Password/Generator/PasswordGenerator.php index 13bdf32..6423acc 100644 --- a/src/Password/Generator/PasswordGenerator.php +++ b/src/Password/Generator/PasswordGenerator.php @@ -123,7 +123,7 @@ private function pick(string $characters): string */ private function secureShuffle(array $items): array { - for ($index = count($items) - 1; $index > 0; --$index) { + for ($index = count($items) - 1; $index > 0; $index--) { $swapIndex = random_int(0, $index); [$items[$index], $items[$swapIndex]] = [$items[$swapIndex], $items[$index]]; } diff --git a/src/Password/Secret/WrappedSecretManager.php b/src/Password/Secret/WrappedSecretManager.php index ac71b08..64e93a6 100644 --- a/src/Password/Secret/WrappedSecretManager.php +++ b/src/Password/Secret/WrappedSecretManager.php @@ -154,7 +154,7 @@ public function wrapWithKeyRing(string $secret, KeyRing $keyRing, bool $masterSe private function decodeMasterSecret(string $masterSecret, bool $isBinary): string { try { - return BinaryKey::secretBoxKey($masterSecret, $isBinary, 'Master secret'); + return BinaryKey::fixedLength($masterSecret, $isBinary, SODIUM_CRYPTO_SECRETBOX_KEYBYTES, 'Master secret'); } catch (\Throwable $e) { throw new SecretProtectionException('Master secret must be 32 bytes long.', 0, $e); } diff --git a/src/Security/ActionToken.php b/src/Security/ActionToken.php index afd6905..0987dc7 100644 --- a/src/Security/ActionToken.php +++ b/src/Security/ActionToken.php @@ -4,20 +4,12 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Internal\Clock\ClockInterface; -use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; final readonly class ActionToken extends AbstractPurposeToken { - public function __construct( - string $secret, - int $ttlSeconds = 900, - ClockInterface $clock = new SystemClock(), - ) { - parent::__construct($secret, $ttlSeconds, $clock); - } + protected const int DEFAULT_TTL_SECONDS = 900; /** * @param array $context diff --git a/src/Security/EmailVerificationToken.php b/src/Security/EmailVerificationToken.php index 59db495..d0e5aec 100644 --- a/src/Security/EmailVerificationToken.php +++ b/src/Security/EmailVerificationToken.php @@ -4,20 +4,12 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Internal\Clock\ClockInterface; -use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; final readonly class EmailVerificationToken extends AbstractPurposeToken { - public function __construct( - string $secret, - int $ttlSeconds = 86400, - ClockInterface $clock = new SystemClock(), - ) { - parent::__construct($secret, $ttlSeconds, $clock); - } + protected const int DEFAULT_TTL_SECONDS = 86400; public function issue(string $userId, string $email): string { diff --git a/src/Security/PasswordResetToken.php b/src/Security/PasswordResetToken.php index a70f295..7ae24f6 100644 --- a/src/Security/PasswordResetToken.php +++ b/src/Security/PasswordResetToken.php @@ -4,20 +4,12 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Internal\Clock\ClockInterface; -use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; final readonly class PasswordResetToken extends AbstractPurposeToken { - public function __construct( - string $secret, - int $ttlSeconds = 1800, - ClockInterface $clock = new SystemClock(), - ) { - parent::__construct($secret, $ttlSeconds, $clock); - } + protected const int DEFAULT_TTL_SECONDS = 1800; public function issue(string $userId): string { diff --git a/src/Security/RememberToken.php b/src/Security/RememberToken.php index bce47a1..c39237f 100644 --- a/src/Security/RememberToken.php +++ b/src/Security/RememberToken.php @@ -4,20 +4,12 @@ namespace Infocyph\Epicrypt\Security; -use Infocyph\Epicrypt\Internal\Clock\ClockInterface; -use Infocyph\Epicrypt\Internal\Clock\SystemClock; use Infocyph\Epicrypt\Security\Enum\SecurityTokenPurpose; use Infocyph\Epicrypt\Security\Support\AbstractPurposeToken; final readonly class RememberToken extends AbstractPurposeToken { - public function __construct( - string $secret, - int $ttlSeconds = 1209600, - ClockInterface $clock = new SystemClock(), - ) { - parent::__construct($secret, $ttlSeconds, $clock); - } + protected const int DEFAULT_TTL_SECONDS = 1209600; public function issue(string $userId, string $deviceId): string { diff --git a/src/Security/Support/AbstractPurposeToken.php b/src/Security/Support/AbstractPurposeToken.php index 4d99836..1e9252b 100644 --- a/src/Security/Support/AbstractPurposeToken.php +++ b/src/Security/Support/AbstractPurposeToken.php @@ -12,13 +12,18 @@ abstract readonly class AbstractPurposeToken { + protected const int DEFAULT_TTL_SECONDS = 3600; + + protected int $ttlSeconds; + private SignedPayloadCodec $codec; public function __construct( string $secret, - protected int $ttlSeconds, + ?int $ttlSeconds = null, protected ClockInterface $clock = new SystemClock(), ) { + $this->ttlSeconds = $ttlSeconds ?? static::DEFAULT_TTL_SECONDS; $this->codec = new SignedPayloadCodec($secret, clock: $this->clock); } diff --git a/src/Token/Jwt/Jwks.php b/src/Token/Jwt/Jwks.php index efb515c..9971e11 100644 --- a/src/Token/Jwt/Jwks.php +++ b/src/Token/Jwt/Jwks.php @@ -91,6 +91,15 @@ public function resolvePublicKeyByKid(array $jwks, string $kid): string return $this->importPublicKeyFromJwk($this->resolveByKid($jwks, $kid)); } + private function byte(int $value): string + { + if ($value < 0 || $value > 255) { + throw new KeyResolutionException('Invalid ASN.1 byte value.'); + } + + return chr($value); + } + private function derBitString(string $value): string { return "\x03" . $this->derLength(strlen($value) + 1) . "\x00" . $value; @@ -113,16 +122,16 @@ private function derInteger(string $value): string private function derLength(int $length): string { if ($length < 128) { - return chr($length); + return $this->byte($length); } $result = ''; while ($length > 0) { - $result = chr($length & 0xFF) . $result; + $result = $this->byte($length & 0xFF) . $result; $length >>= 8; } - return chr(0x80 | strlen($result)) . $result; + return $this->byte(0x80 | strlen($result)) . $result; } private function derOid(string $oid): string @@ -134,7 +143,7 @@ private function derOid(string $oid): string $first = (int) $parts[0]; $second = (int) $parts[1]; - $encoded = chr(($first * 40) + $second); + $encoded = $this->byte(($first * 40) + $second); for ($i = 2; $i < count($parts); $i++) { $value = (int) $parts[$i]; @@ -142,11 +151,11 @@ private function derOid(string $oid): string throw new KeyResolutionException(sprintf('Invalid OID "%s".', $oid)); } - $segment = chr($value & 0x7F); + $segment = $this->byte($value & 0x7F); $value >>= 7; while ($value > 0) { - $segment = chr(($value & 0x7F) | 0x80) . $segment; + $segment = $this->byte(($value & 0x7F) | 0x80) . $segment; $value >>= 7; } diff --git a/tests/Certificate/DomainTest.php b/tests/Certificate/DomainTest.php index dd14711..fbecc2d 100644 --- a/tests/Certificate/DomainTest.php +++ b/tests/Certificate/DomainTest.php @@ -76,7 +76,7 @@ it('supports rsa interoperability in Certificate domain', function () { $keyPair = KeyPairGenerator::openSsl(bits: OpenSslRsaBits::BITS_2048)->generate(); - $cipher = new RsaCipher(); + $cipher = new RsaCipher; $encrypted = $cipher->encrypt('certificate-rsa-check', $keyPair['public']); $decrypted = $cipher->decrypt($encrypted, $keyPair['private']); @@ -104,7 +104,7 @@ it('rejects invalid sodium key exchange key material', function () { $exchange = KeyExchange::sodium(); - expect(fn() => $exchange->derive('short-private', 'short-public')) + expect(fn () => $exchange->derive('short-private', 'short-public')) ->toThrow(InvalidKeyException::class); }); @@ -138,12 +138,12 @@ $leafCsr = CsrBuilder::openSsl()->build($leafDn, $leafKeyPair['private'], options: $leafOptions); $leafCertificate = CertificateAuthority::openSsl()->signCsr($leafCsr, $caCertificate, $caKeyPair['private'], $leafOptions); - $fingerprint = (new CertificateFingerprint())->fingerprint($leafCertificate); - $expiresAt = (new CertificateExpiry())->expiresAt($leafCertificate); - $notExpired = (new CertificateExpiry())->isExpired($leafCertificate) === false; - $matches = (new CertificateKeyMatcher())->privateKeyMatches($leafCertificate, $leafKeyPair['private']); - $chainValid = (new CertificateChainVerifier())->verify($leafCertificate, [$caCertificate]); - $normalized = (new PemNormalizer())->normalize($leafCertificate); + $fingerprint = (new CertificateFingerprint)->fingerprint($leafCertificate); + $expiresAt = (new CertificateExpiry)->expiresAt($leafCertificate); + $notExpired = (new CertificateExpiry)->isExpired($leafCertificate) === false; + $matches = (new CertificateKeyMatcher)->privateKeyMatches($leafCertificate, $leafKeyPair['private']); + $chainValid = (new CertificateChainVerifier)->verify($leafCertificate, [$caCertificate]); + $normalized = (new PemNormalizer)->normalize($leafCertificate); expect($leafCertificate)->toContain('BEGIN CERTIFICATE'); expect($fingerprint)->toHaveLength(64); @@ -155,7 +155,7 @@ }); it('rejects curve selection for RSA key pair generation', function () { - expect(fn() => KeyPairGenerator::openSsl( + expect(fn () => KeyPairGenerator::openSsl( bits: OpenSslRsaBits::BITS_2048, curveName: OpenSslCurveName::PRIME256V1, ))->toThrow(ConfigurationException::class); @@ -184,7 +184,7 @@ ]; $certificate = CertificateBuilder::openSsl()->selfSign($dn, $keyPair['private'], 365); - $manager = new Pkcs12(); + $manager = new Pkcs12; $bundle = $manager->export($certificate, $keyPair['private'], 'changeit'); $imported = $manager->import($bundle, 'changeit'); diff --git a/tests/Crypto/AeadContextTest.php b/tests/Crypto/AeadContextTest.php index 3638058..30db505 100644 --- a/tests/Crypto/AeadContextTest.php +++ b/tests/Crypto/AeadContextTest.php @@ -9,9 +9,9 @@ $cipher = new AeadCipher; $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); - expect(fn() => $cipher->encrypt('payload', $key, ['key_is_binary' => 'yes'])) + expect(fn () => $cipher->encrypt('payload', $key, ['key_is_binary' => 'yes'])) ->toThrow(CryptoException::class); - expect(fn() => $cipher->encrypt('payload', $key, ['aad' => 123])) + expect(fn () => $cipher->encrypt('payload', $key, ['aad' => 123])) ->toThrow(CryptoException::class); }); @@ -19,6 +19,6 @@ $cipher = new AeadCipher; $key = (new KeyMaterialGenerator)->generate(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); - expect(fn() => $cipher->encrypt('payload', $key, ['nonce' => ''])) + expect(fn () => $cipher->encrypt('payload', $key, ['nonce' => ''])) ->toThrow(InvalidNonceException::class); }); diff --git a/tests/Crypto/CoreServicesTest.php b/tests/Crypto/CoreServicesTest.php index 50ed1ae..2399e10 100644 --- a/tests/Crypto/CoreServicesTest.php +++ b/tests/Crypto/CoreServicesTest.php @@ -33,7 +33,7 @@ $segments[1] = 'unknown-algorithm'; $tamperedCiphertext = implode('.', $segments); - expect(fn() => $cipher->decrypt($tamperedCiphertext, $key, ['aad' => 'meta'])) + expect(fn () => $cipher->decrypt($tamperedCiphertext, $key, ['aad' => 'meta'])) ->toThrow(DecryptionException::class); }); @@ -45,7 +45,7 @@ $segments[1] = 'xchacha20-poly1305-ietf'; $tamperedCiphertext = implode('.', $segments); - expect(fn() => $cipher->decrypt($tamperedCiphertext, $key)) + expect(fn () => $cipher->decrypt($tamperedCiphertext, $key)) ->toThrow(DecryptionException::class); }); @@ -62,9 +62,9 @@ it('rejects invalid signature key material', function () { $signatureService = new Signature; - expect(fn() => $signatureService->sign('epicrypt-signature', 'short-key')) + expect(fn () => $signatureService->sign('epicrypt-signature', 'short-key')) ->toThrow(SignatureException::class); - expect(fn() => $signatureService->verify('epicrypt-signature', 'invalid-sig', 'short-key')) + expect(fn () => $signatureService->verify('epicrypt-signature', 'invalid-sig', 'short-key')) ->toThrow(SignatureException::class); }); diff --git a/tests/Crypto/MatrixCoverageTest.php b/tests/Crypto/MatrixCoverageTest.php index bac1b40..9dd1ed1 100644 --- a/tests/Crypto/MatrixCoverageTest.php +++ b/tests/Crypto/MatrixCoverageTest.php @@ -15,7 +15,7 @@ $generator = new KeyMaterialGenerator; foreach (AeadAlgorithm::cases() as $algorithm) { - if (!$algorithm->isAvailable()) { + if (! $algorithm->isAvailable()) { continue; } @@ -64,10 +64,10 @@ $cipher = new SecretBoxCipher; $ciphertext = $cipher->encrypt('secret-box payload', $correctKey); - expect(fn() => $cipher->decrypt($ciphertext, $wrongKey)) + expect(fn () => $cipher->decrypt($ciphertext, $wrongKey)) ->toThrow(DecryptionException::class); - expect(fn() => $cipher->encrypt('secret-box payload', 'short')) + expect(fn () => $cipher->encrypt('secret-box payload', 'short')) ->toThrow(InvalidKeyException::class); }); @@ -81,12 +81,12 @@ $tamperedNonce = $parts; $tamperedNonce[3] = 'not_base64url***'; - expect(fn() => $cipher->decrypt(implode('.', $tamperedNonce), $key)) + expect(fn () => $cipher->decrypt(implode('.', $tamperedNonce), $key)) ->toThrow(ConfigurationException::class); $tamperedCiphertext = $parts; $tamperedCiphertext[4] = 'not_base64url***'; - expect(fn() => $cipher->decrypt(implode('.', $tamperedCiphertext), $key)) + expect(fn () => $cipher->decrypt(implode('.', $tamperedCiphertext), $key)) ->toThrow(ConfigurationException::class); }); diff --git a/tests/Crypto/SecretStreamTest.php b/tests/Crypto/SecretStreamTest.php index f574320..8ac3895 100644 --- a/tests/Crypto/SecretStreamTest.php +++ b/tests/Crypto/SecretStreamTest.php @@ -6,14 +6,14 @@ use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; it('rejects direct secret stream construction with invalid key length', function () { - expect(fn() => new SecretStream('short-key'))->toThrow(InvalidKeyException::class); + expect(fn () => new SecretStream('short-key'))->toThrow(InvalidKeyException::class); }); it('requires explicit opt-in for unauthenticated stream mode', function () { $key = random_bytes(SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES); expect( - fn() => new SecretStream($key, StreamAlgorithm::UNAUTHENTICATED_XCHACHA20), + fn () => new SecretStream($key, StreamAlgorithm::UNAUTHENTICATED_XCHACHA20), )->toThrow(ConfigurationException::class); expect( @@ -66,7 +66,7 @@ function cleanupSecretStreamTempDirectory(string $directory): void { - if (!is_dir($directory)) { + if (! is_dir($directory)) { return; } diff --git a/tests/DataProtection/FileProtectionMatrixTest.php b/tests/DataProtection/FileProtectionMatrixTest.php index d9f93d3..35d0bd8 100644 --- a/tests/DataProtection/FileProtectionMatrixTest.php +++ b/tests/DataProtection/FileProtectionMatrixTest.php @@ -21,9 +21,9 @@ try { foreach ($cases as $name => $content) { - $plain = $root . DIRECTORY_SEPARATOR . $name . '.plain.bin'; - $encrypted = $root . DIRECTORY_SEPARATOR . $name . '.enc.bin'; - $decrypted = $root . DIRECTORY_SEPARATOR . $name . '.dec.bin'; + $plain = $root.DIRECTORY_SEPARATOR.$name.'.plain.bin'; + $encrypted = $root.DIRECTORY_SEPARATOR.$name.'.enc.bin'; + $decrypted = $root.DIRECTORY_SEPARATOR.$name.'.dec.bin'; file_put_contents($plain, $content); @@ -44,9 +44,9 @@ $wrongKey = $generator->forSecretStream(); $root = makeMatrixTempDirectory(); - $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; - $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; - $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + $plain = $root.DIRECTORY_SEPARATOR.'payload.txt'; + $encrypted = $root.DIRECTORY_SEPARATOR.'payload.enc'; + $decrypted = $root.DIRECTORY_SEPARATOR.'payload.dec'; file_put_contents($plain, 'file-protection payload'); $protector = FileProtector::forProfile(SecurityProfile::MODERN); @@ -54,7 +54,7 @@ try { $protector->encrypt($plain, $encrypted, $correctKey); - expect(fn() => $protector->decrypt($encrypted, $decrypted, $wrongKey)) + expect(fn () => $protector->decrypt($encrypted, $decrypted, $wrongKey)) ->toThrow(DecryptionException::class); } finally { removeMatrixTempDirectory($root); @@ -66,9 +66,9 @@ $key = $generator->forSecretStream(); $root = makeMatrixTempDirectory(); - $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; - $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; - $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + $plain = $root.DIRECTORY_SEPARATOR.'payload.txt'; + $encrypted = $root.DIRECTORY_SEPARATOR.'payload.enc'; + $decrypted = $root.DIRECTORY_SEPARATOR.'payload.dec'; file_put_contents($plain, str_repeat('tamper-check-', 2_000)); $protector = FileProtector::forProfile(SecurityProfile::MODERN); @@ -78,7 +78,7 @@ $ciphertext = (string) file_get_contents($encrypted); file_put_contents($encrypted, substr($ciphertext, 0, max(0, strlen($ciphertext) - 5))); - expect(fn() => $protector->decrypt($encrypted, $decrypted, $key, 256)) + expect(fn () => $protector->decrypt($encrypted, $decrypted, $key, 256)) ->toThrow(DecryptionException::class); } finally { removeMatrixTempDirectory($root); @@ -88,9 +88,9 @@ it('supports file protection with binary stream keys', function () { $key = (new KeyMaterialGenerator)->forSecretStream(asBase64Url: false); $root = makeMatrixTempDirectory(); - $plain = $root . DIRECTORY_SEPARATOR . 'payload.txt'; - $encrypted = $root . DIRECTORY_SEPARATOR . 'payload.enc'; - $decrypted = $root . DIRECTORY_SEPARATOR . 'payload.dec'; + $plain = $root.DIRECTORY_SEPARATOR.'payload.txt'; + $encrypted = $root.DIRECTORY_SEPARATOR.'payload.enc'; + $decrypted = $root.DIRECTORY_SEPARATOR.'payload.dec'; file_put_contents($plain, 'binary-stream-key payload'); $protector = FileProtector::forProfile(SecurityProfile::MODERN); @@ -107,7 +107,7 @@ function makeMatrixTempDirectory(): string { - $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'epicrypt-file-matrix-' . bin2hex(random_bytes(6)); + $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'epicrypt-file-matrix-'.bin2hex(random_bytes(6)); mkdir($path); return $path; @@ -116,7 +116,7 @@ function makeMatrixTempDirectory(): string function removeMatrixTempDirectory(string $path): void { $rootPath = $path; - if (!is_dir($rootPath)) { + if (! is_dir($rootPath)) { return; } diff --git a/tests/DataProtection/ServicesTest.php b/tests/DataProtection/ServicesTest.php index a1e2fd8..b6face8 100644 --- a/tests/DataProtection/ServicesTest.php +++ b/tests/DataProtection/ServicesTest.php @@ -79,7 +79,7 @@ 'current' => $currentKey, ], 'current'); - expect(fn() => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) + expect(fn () => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) ->toThrow(DecryptionException::class); }); @@ -95,7 +95,7 @@ $ciphertext = StringProtector::forProfile()->encrypt('rotating data', $previousKey, ['key_id' => 'current']); - expect(fn() => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) + expect(fn () => StringProtector::forProfile()->decryptWithKeyRingResult($ciphertext, $keyRing)) ->toThrow(DecryptionException::class); }); @@ -169,7 +169,7 @@ $envelope['kid'] = 'current'; $encoded = $protector->encodeEnvelope($envelope); - expect(fn() => $protector->decryptWithKeyRingResult($encoded, $keyRing)) + expect(fn () => $protector->decryptWithKeyRingResult($encoded, $keyRing)) ->toThrow(DecryptionException::class); }); @@ -274,7 +274,7 @@ }, ); - expect(fn() => $failingProtector->reencryptInPlaceWithAnyKey( + expect(fn () => $failingProtector->reencryptInPlaceWithAnyKey( $encryptedPath, new KeyRing(['previous' => $previousKey, 'current' => $currentKey], 'current'), $currentKey, @@ -328,7 +328,7 @@ function cleanupServicesTempDirectory(string $path): void { - if (!is_dir($path)) { + if (! is_dir($path)) { return; } diff --git a/tests/Generate/KeyDerivationContextTest.php b/tests/Generate/KeyDerivationContextTest.php index 61a4328..5156cca 100644 --- a/tests/Generate/KeyDerivationContextTest.php +++ b/tests/Generate/KeyDerivationContextTest.php @@ -23,7 +23,7 @@ $password = $deriver->deriveFromPassword( 'MyStrongPassword!2026', - (new SaltGenerator())->generate(SODIUM_CRYPTO_PWHASH_SALTBYTES), + (new SaltGenerator)->generate(SODIUM_CRYPTO_PWHASH_SALTBYTES), 32, new KeyDerivationContext(profile: SecurityProfile::MODERN), ); @@ -42,12 +42,11 @@ }); it('validates typed key derivation context input values', function () { - expect(fn() => KeyDerivationContext::fromArray([ + expect(fn () => KeyDerivationContext::fromArray([ 'salt_is_binary' => 'yes', ]))->toThrow(ConfigurationException::class); - expect(fn() => KeyDerivationContext::fromArray([ + expect(fn () => KeyDerivationContext::fromArray([ 'profile' => 'modern', ]))->toThrow(ConfigurationException::class); }); - diff --git a/tests/Generate/ServicesTest.php b/tests/Generate/ServicesTest.php index 7dd02a3..08bfa3a 100644 --- a/tests/Generate/ServicesTest.php +++ b/tests/Generate/ServicesTest.php @@ -1,10 +1,10 @@ generate(32); - expect(fn() => $deriver->hkdf($ikm, 32, ['algorithm' => 'definitely-not-valid'])) + expect(fn () => $deriver->hkdf($ikm, 32, ['algorithm' => 'definitely-not-valid'])) ->toThrow(ConfigurationException::class); }); diff --git a/tests/Integrity/ServicesTest.php b/tests/Integrity/ServicesTest.php index 05179da..c11bdbb 100644 --- a/tests/Integrity/ServicesTest.php +++ b/tests/Integrity/ServicesTest.php @@ -1,8 +1,8 @@ (new StringHasher('definitely-not-valid'))->hash('payload')) + expect(fn () => (new StringHasher('definitely-not-valid'))->hash('payload')) ->toThrow(HashingException::class); - expect(fn() => (new FileHasher('definitely-not-valid'))->hash($tmpPath)) + expect(fn () => (new FileHasher('definitely-not-valid'))->hash($tmpPath)) ->toThrow(HashingException::class); } finally { unlink($tmpPath); diff --git a/tests/Internal/BinaryKeyTest.php b/tests/Internal/BinaryKeyTest.php index 61db119..f38562a 100644 --- a/tests/Internal/BinaryKeyTest.php +++ b/tests/Internal/BinaryKeyTest.php @@ -8,12 +8,12 @@ $binary = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); $encoded = Base64Url::encode($binary); - expect(BinaryKey::secretBoxKey($encoded, false))->toBe($binary); - expect(BinaryKey::secretBoxKey($binary, true))->toBe($binary); + expect(BinaryKey::fixedLength($encoded, false, SODIUM_CRYPTO_SECRETBOX_KEYBYTES))->toBe($binary); + expect(BinaryKey::fixedLength($binary, true, SODIUM_CRYPTO_SECRETBOX_KEYBYTES))->toBe($binary); }); it('rejects invalid key lengths through typed helpers', function () { - expect(fn() => BinaryKey::macKey('short', true))->toThrow(InvalidKeyException::class); - expect(fn() => BinaryKey::aeadKey('short', true, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)) + expect(fn () => BinaryKey::fixedLength('short', true, SODIUM_CRYPTO_AUTH_KEYBYTES, 'MAC key'))->toThrow(InvalidKeyException::class); + expect(fn () => BinaryKey::fixedLength('short', true, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, 'AEAD key')) ->toThrow(InvalidKeyException::class); }); diff --git a/tests/Internal/EcdsaSignatureConverterTest.php b/tests/Internal/EcdsaSignatureConverterTest.php index 6f7feaa..52fad2c 100644 --- a/tests/Internal/EcdsaSignatureConverterTest.php +++ b/tests/Internal/EcdsaSignatureConverterTest.php @@ -6,11 +6,11 @@ it('throws typed exception for invalid asn1 signatures', function () { $converter = new EcdsaSignatureConverter; - expect(fn() => $converter->fromAsn1('not-asn1', 64))->toThrow(SignatureEncodingException::class); + expect(fn () => $converter->fromAsn1('not-asn1', 64))->toThrow(SignatureEncodingException::class); }); it('throws typed exception for invalid jose signature length', function () { $converter = new EcdsaSignatureConverter; - expect(fn() => $converter->toAsn1(random_bytes(10), 64))->toThrow(SignatureEncodingException::class); + expect(fn () => $converter->toAsn1(random_bytes(10), 64))->toThrow(SignatureEncodingException::class); }); diff --git a/tests/Internal/ProtectionContextTest.php b/tests/Internal/ProtectionContextTest.php index ce8a918..2a0f6ea 100644 --- a/tests/Internal/ProtectionContextTest.php +++ b/tests/Internal/ProtectionContextTest.php @@ -27,10 +27,10 @@ }); it('rejects invalid typed protection context inputs', function () { - expect(fn() => ProtectionContext::fromArray(['key_is_binary' => 'yes'])) + expect(fn () => ProtectionContext::fromArray(['key_is_binary' => 'yes'])) ->toThrow(ConfigurationException::class); - expect(fn() => ProtectionContext::fromArray(['key_id' => ''])) + expect(fn () => ProtectionContext::fromArray(['key_id' => ''])) ->toThrow(ConfigurationException::class); - expect(fn() => ProtectionContext::fromArray(['purpose' => ''])) + expect(fn () => ProtectionContext::fromArray(['purpose' => ''])) ->toThrow(ConfigurationException::class); }); diff --git a/tests/Internal/VersionedPayloadTest.php b/tests/Internal/VersionedPayloadTest.php index 3892c3a..9701181 100644 --- a/tests/Internal/VersionedPayloadTest.php +++ b/tests/Internal/VersionedPayloadTest.php @@ -22,8 +22,8 @@ }); it('rejects invalid compact key id values for encoding', function () { - expect(fn() => VersionedPayload::encodeCompact('epc1', 'secretbox', '_', 'nonce', 'ciphertext')) + expect(fn () => VersionedPayload::encodeCompact('epc1', 'secretbox', '_', 'nonce', 'ciphertext')) ->toThrow(InvalidArgumentException::class); - expect(fn() => VersionedPayload::encodeCompact('epc1', 'secretbox', 'bad.key', 'nonce', 'ciphertext')) + expect(fn () => VersionedPayload::encodeCompact('epc1', 'secretbox', 'bad.key', 'nonce', 'ciphertext')) ->toThrow(InvalidArgumentException::class); }); diff --git a/tests/Password/ServicesTest.php b/tests/Password/ServicesTest.php index 56744fe..3eace88 100644 --- a/tests/Password/ServicesTest.php +++ b/tests/Password/ServicesTest.php @@ -1,5 +1,6 @@ wrap('rotated-secret', $oldMaster, false, 'new'); - expect(fn() => $manager->unwrapWithKeyRingResult($wrapped, $keyRing)) + expect(fn () => $manager->unwrapWithKeyRingResult($wrapped, $keyRing)) ->toThrow(SecretProtectionException::class); }); diff --git a/tests/Security/ClockIntegrationTest.php b/tests/Security/ClockIntegrationTest.php index ec0618c..374dcd9 100644 --- a/tests/Security/ClockIntegrationTest.php +++ b/tests/Security/ClockIntegrationTest.php @@ -1,5 +1,6 @@ toBe(1_000); $verifier = new SignedPayloadCodec('clock-secret', clock: $verifyClock); - expect(fn() => $verifier->verify($token))->toThrow(\Infocyph\Epicrypt\Exception\Token\ExpiredTokenException::class); + expect(fn () => $verifier->verify($token))->toThrow(ExpiredTokenException::class); }); it('uses injected clock for csrf token expiration claims', function () { diff --git a/tests/Security/SignedUrlHardeningTest.php b/tests/Security/SignedUrlHardeningTest.php index 502f6b3..6b5bc8d 100644 --- a/tests/Security/SignedUrlHardeningTest.php +++ b/tests/Security/SignedUrlHardeningTest.php @@ -16,7 +16,7 @@ it('enforces array query parameter policy', function () { $signedUrl = new SignedUrl('url-secret'); - expect(fn() => $signedUrl->generate('https://example.com/download?tag[]=a')) + expect(fn () => $signedUrl->generate('https://example.com/download?tag[]=a')) ->toThrow(ConfigurationException::class); $arrayOptions = new SignedUrlOptions(allowArrayParameters: true); @@ -50,4 +50,3 @@ expect($signedUrl->verify($signedAbsolute, $hostBound))->toBeTrue(); expect($signedUrl->verify($signedAbsolute, new SignedUrlOptions(allowedHosts: ['api.example.com'])))->toBeFalse(); }); - diff --git a/tests/Token/Jwt/AlgorithmMatrixTest.php b/tests/Token/Jwt/AlgorithmMatrixTest.php index b834e02..461dbee 100644 --- a/tests/Token/Jwt/AlgorithmMatrixTest.php +++ b/tests/Token/Jwt/AlgorithmMatrixTest.php @@ -92,7 +92,7 @@ openssl_pkey_export($resource, $privateKey); $details = openssl_pkey_get_details($resource); - if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) { + if (! is_array($details) || ! isset($details['key']) || ! is_string($details['key'])) { continue; } @@ -103,7 +103,7 @@ expect($jwt->verify($token, $details['key']))->toBeTrue(); } - if (!$asserted) { + if (! $asserted) { expect(true)->toBeTrue(); } }); diff --git a/tests/Token/Jwt/HardeningTest.php b/tests/Token/Jwt/HardeningTest.php index 773a25e..48de51c 100644 --- a/tests/Token/Jwt/HardeningTest.php +++ b/tests/Token/Jwt/HardeningTest.php @@ -4,12 +4,12 @@ use Infocyph\Epicrypt\Internal\Clock\ClockInterface; use Infocyph\Epicrypt\Internal\Json; use Infocyph\Epicrypt\Security\SignedUrl; -use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm; use Infocyph\Epicrypt\Token\Jwt\SymmetricJwt; +use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; -use Infocyph\Epicrypt\Token\Jwt\Validation\RequiredJwtClaims; use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\RequiredJwtClaims; use Infocyph\Epicrypt\Token\Payload\SignedPayload; it('exposes jwt decode result metadata', function () { @@ -45,7 +45,7 @@ 'exp' => $now + 600, ]; $token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512))->encode($claims, 'super-secret-key'); - [$h, $p, ] = explode('.', $token, 3); + [$h, $p] = explode('.', $token, 3); $payload = Json::decodeToArray(Base64Url::decode($p)); $strictJwt = new SymmetricJwt( diff --git a/tests/Token/Jwt/JwksTest.php b/tests/Token/Jwt/JwksTest.php index 4b63c77..3ab4149 100644 --- a/tests/Token/Jwt/JwksTest.php +++ b/tests/Token/Jwt/JwksTest.php @@ -30,7 +30,7 @@ expect($rsaDetails)->toBeArray(); expect($ecDetails)->toBeArray(); - $jwks = new Jwks(); + $jwks = new Jwks; $ring = new KeyRing([ 'rsa-key' => $rsaDetails['key'], 'ec-key' => $ecDetails['key'], @@ -65,7 +65,7 @@ $publicPem = $details['key'] ?? null; expect($publicPem)->toBeString(); - $jwks = new Jwks(); + $jwks = new Jwks; $jwk = $jwks->exportPublicKeyToJwk($publicPem, 'rsa-signing'); $importedPem = $jwks->importPublicKeyFromJwk($jwk); expect(openssl_pkey_get_public($importedPem))->not->toBeFalse(); @@ -81,7 +81,7 @@ 'kid' => 'rsa-signing', ]; - $issuer = new AsymmetricJwt(); + $issuer = new AsymmetricJwt; $token = $issuer->encode($claims, $privatePem); $verifier = new AsymmetricJwt( diff --git a/tests/Token/OpaqueTokenTest.php b/tests/Token/OpaqueTokenTest.php index 34e1ff4..64949c2 100644 --- a/tests/Token/OpaqueTokenTest.php +++ b/tests/Token/OpaqueTokenTest.php @@ -9,6 +9,5 @@ expect($token)->toHaveLength(48); expect($opaque->verify($token, $digest))->toBeTrue(); - expect($opaque->verify($token . 'x', $digest))->toBeFalse(); + expect($opaque->verify($token.'x', $digest))->toBeFalse(); }); - diff --git a/tests/Token/SignedPayloadTemporalClaimsTest.php b/tests/Token/SignedPayloadTemporalClaimsTest.php index 245071c..e8806c4 100644 --- a/tests/Token/SignedPayloadTemporalClaimsTest.php +++ b/tests/Token/SignedPayloadTemporalClaimsTest.php @@ -10,14 +10,14 @@ $codec = new SignedPayloadCodec('signed-payload-secret'); $token = $codec->issue(['sub' => 'user-1'], time() - 10); - expect(fn() => $codec->verify($token))->toThrow(ExpiredTokenException::class); + expect(fn () => $codec->verify($token))->toThrow(ExpiredTokenException::class); }); it('rejects signed payloads with non-numeric exp claims', function () { $codec = new SignedPayloadCodec('signed-payload-secret'); $token = $codec->issue(['sub' => 'user-1', 'exp' => 'not-a-timestamp']); - expect(fn() => $codec->verify($token))->toThrow(InvalidTokenException::class); + expect(fn () => $codec->verify($token))->toThrow(InvalidTokenException::class); }); it('rejects signed payloads with non-numeric iat claims', function () { @@ -28,7 +28,7 @@ $token = $header.'.'.$payload.'.'.$signature; $codec = new SignedPayloadCodec($secret); - expect(fn() => $codec->verify($token))->toThrow(InvalidTokenException::class); + expect(fn () => $codec->verify($token))->toThrow(InvalidTokenException::class); }); it('accepts signed payloads without exp claims', function () { @@ -57,5 +57,5 @@ $payload['sub'] = 'user-2'; $tamperedToken = $encodedHeader.'.'.Base64Url::encode(Json::encode($payload)).'.'.$signature; - expect(fn() => $codec->verify($tamperedToken))->toThrow(InvalidTokenException::class); + expect(fn () => $codec->verify($tamperedToken))->toThrow(InvalidTokenException::class); });