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/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/.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/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/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..489c794 100644 --- a/composer.json +++ b/composer.json @@ -1,24 +1,18 @@ { "name": "infocyph/epicrypt", - "description": "A Collection of useful PHP security functions.", - "type": "library", + "description": "Modern cryptography, token, password and data-protection toolkit for PHP.", "license": "MIT", + "type": "library", "authors": [ + { + "name": "Infocyph", + "email": "infocyph@gmail.com" + }, { "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 +20,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/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/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/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/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..446bc9f --- /dev/null +++ b/docs/jwt.rst @@ -0,0 +1,40 @@ +JWT +=== + +JWT is covered under :doc:`token`, but this page highlights the hardening and interoperability APIs. + +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. +- ``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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..5aff8b5 --- /dev/null +++ b/src/Certificate/OpenSSL/CertificateAuthority.php @@ -0,0 +1,44 @@ + $options->digestAlgorithm]; + $config['config'] = $tempConfigPath; + $config['x509_extensions'] = 'v3_req'; + + try { + return OpenSslCertificateSigner::signAndExport( + $csrPem, + $caCertificatePem, + $caKeyResource, + $options->days, + $config, + 'CA certificate signing failed.', + 'Signed certificate export failed.', + ); + } finally { + if (file_exists($tempConfigPath)) { + unlink($tempConfigPath); + } + } + } +} diff --git a/src/Certificate/OpenSSL/CertificateBuilder.php b/src/Certificate/OpenSSL/CertificateBuilder.php index fadb023..98847a1 100644 --- a/src/Certificate/OpenSSL/CertificateBuilder.php +++ b/src/Certificate/OpenSSL/CertificateBuilder.php @@ -4,7 +4,10 @@ namespace Infocyph\Epicrypt\Certificate\OpenSSL; +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; @@ -17,27 +20,41 @@ 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, $distinguishedName); + $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]; + + return OpenSslCertificateSigner::signAndExport( + $csr, + null, + $signingPrivateKey, + $requestedDays, + $signConfig, + 'Certificate signing failed.', + 'Certificate export failed.', + ); + } 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..866f0c8 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, $distinguishedName); + $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..42fd867 100644 --- a/src/Certificate/OpenSSL/KeyPairGenerator.php +++ b/src/Certificate/OpenSSL/KeyPairGenerator.php @@ -14,7 +14,7 @@ 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, ) {} @@ -39,7 +39,7 @@ public function generate(?string $passphrase = null, bool $asBase64Url = false): } $privateKey = null; - $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? ''); + $exported = openssl_pkey_export($resource, $privateKey, $passphrase ?? '', $config); if (!$exported || !is_string($privateKey) || $privateKey === '') { throw new ConfigurationException('Failed to export private key.'); } diff --git a/src/Certificate/OpenSSL/RsaCipher.php b/src/Certificate/OpenSSL/RsaCipher.php index 2f543ee..1fae944 100644 --- a/src/Certificate/OpenSSL/RsaCipher.php +++ b/src/Certificate/OpenSSL/RsaCipher.php @@ -8,7 +8,12 @@ use Infocyph\Epicrypt\Exception\Crypto\DecryptionException; use Infocyph\Epicrypt\Exception\Crypto\EncryptionException; 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 @@ -16,25 +21,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/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 new file mode 100644 index 0000000..e78f8d1 --- /dev/null +++ b/src/Certificate/OpenSSL/Support/OpenSslExtensionConfig.php @@ -0,0 +1,99 @@ + $distinguishedName + */ + public static function createTempConfig(CertificateOptions $options, array $distinguishedName = []): string + { + $commonName = self::resolveCommonName($distinguishedName); + + $lines = [ + '[req]', + 'distinguished_name=req_distinguished_name', + 'prompt=no', + 'req_extensions=v3_req', + 'x509_extensions=v3_req', + '', + '[req_distinguished_name]', + sprintf('CN=%s', $commonName), + '', + '[v3_req]', + ]; + + $sanEntries = []; + $dnsIndex = 1; + foreach ($options->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; + } + + /** + * @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/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 @@ + $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/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/SessionKeyExchange.php b/src/Certificate/Sodium/SessionKeyExchange.php index 4ce8718..936b13e 100644 --- a/src/Certificate/Sodium/SessionKeyExchange.php +++ b/src/Certificate/Sodium/SessionKeyExchange.php @@ -7,16 +7,17 @@ use Infocyph\Epicrypt\Certificate\Contract\KeyExchangeInterface; use Infocyph\Epicrypt\Exception\Crypto\InvalidKeyException; use Infocyph\Epicrypt\Internal\Base64Url; +use Infocyph\Epicrypt\Internal\BinaryKey; final class SessionKeyExchange implements KeyExchangeInterface { public function derive(string $privateKey, string $publicKey, bool $keysAreBinary = false): string { - $private = $keysAreBinary ? $privateKey : Base64Url::decode($privateKey); - $public = $keysAreBinary ? $publicKey : Base64Url::decode($publicKey); - - if (strlen($private) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES || strlen($public) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) { - throw new InvalidKeyException('Sodium key exchange requires valid curve25519 private/public keys.'); + try { + $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); } $secret = sodium_crypto_scalarmult($private, $public); 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..df7597c 100644 --- a/src/Crypto/AeadCipher.php +++ b/src/Crypto/AeadCipher.php @@ -4,13 +4,16 @@ namespace Infocyph\Epicrypt\Crypto; +use Infocyph\Epicrypt\Crypto\Context\AeadContext; use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; 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; +use Infocyph\Epicrypt\Internal\BinaryKey; use Infocyph\Epicrypt\Internal\Enum\EncryptedPayloadVersion; use Infocyph\Epicrypt\Internal\VersionedPayload; use Infocyph\Epicrypt\Security\Policy\SecurityProfile; @@ -30,22 +33,17 @@ public static function forProfile(SecurityProfile $profile = SecurityProfile::MO public function decrypt(string $ciphertext, mixed $key, array $context = []): string { $this->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.'); @@ -60,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 { @@ -80,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()) { @@ -110,50 +94,62 @@ private function assertAlgorithmAvailability(): void } } - /** - * @param array $context - */ - private function boolFromContext(array $context, string $key): bool + private function decodeKey(mixed $key, int $expectedLength, bool $isBinary, string $operation): string { - $value = $context[$key] ?? false; - if (!is_bool($value)) { - throw new CryptoException(sprintf('Context value "%s" must be boolean.', $key)); + try { + 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); } - - return $value; } - private function decodeKey(mixed $key, int $expectedLength, bool $isBinary, string $operation): string + private function decryptRaw(string $ciphertext, string $aad, string $nonce, string $key): string|false { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException(sprintf('%s key must be a non-empty string.', $operation)); - } + return $this->runRawOperation($ciphertext, $aad, $nonce, $key, true); + } - $decoded = $isBinary ? $key : Base64Url::decode($key); - if (strlen($decoded) !== $expectedLength) { - throw new InvalidKeyException(sprintf('%s key must be %d bytes.', $operation, $expectedLength)); + 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 $decoded; + return $result; } - private function decryptRaw(string $ciphertext, string $aad, string $nonce, string $key): string|false + 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_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), + 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), }; } - private function encryptRaw(string $plaintext, string $aad, string $nonce, string $key): string + /** + * @return array{string, string} + */ + private function splitPayload(string $ciphertext): array { - 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), - }; + $compactPayload = VersionedPayload::parseCompact($ciphertext, EncryptedPayloadVersion::V1->value); + if ($compactPayload === null) { + throw new DecryptionException('Invalid ciphertext format.'); + } + + if ($compactPayload->algorithm !== $this->algorithm->value) { + throw new DecryptionException(sprintf('Unsupported payload algorithm "%s".', $compactPayload->algorithm)); + } + + return [$compactPayload->nonce, $compactPayload->ciphertext]; } } diff --git a/src/Crypto/Context/AeadContext.php b/src/Crypto/Context/AeadContext.php new file mode 100644 index 0000000..204ccfc --- /dev/null +++ b/src/Crypto/Context/AeadContext.php @@ -0,0 +1,61 @@ + $context + */ + public static function fromArray(array $context): self + { + $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.'); + } + + return new self( + nonce: $nonce, + keyIsBinary: $baseFields['key_is_binary'], + nonceIsBinary: $baseFields['nonce_is_binary'], + aad: $baseFields['aad'], + keyId: $baseFields['key_id'], + ); + } + + /** + * @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/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/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..2eb4e17 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::fixedLength($key, $isBinary, SODIUM_CRYPTO_AUTH_KEYBYTES, '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 a3cd1fe..7317cba 100644 --- a/src/Crypto/PublicKeyBoxCipher.php +++ b/src/Crypto/PublicKeyBoxCipher.php @@ -6,8 +6,10 @@ use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; 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,22 +20,27 @@ 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, $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); } - $senderPublic = $this->decodeKey($key['sender_public'] ?? null, 'sender_public', $context); - $recipientPrivate = $this->decodeKey($key['recipient_private'] ?? null, 'recipient_private', $context); - $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), ); @@ -49,12 +56,18 @@ 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, $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); } - - $recipientPublic = $this->decodeKey($key['recipient_public'] ?? null, 'recipient_public', $context); - $senderPrivate = $this->decodeKey($key['sender_private'] ?? null, 'sender_private', $context); $nonce = random_bytes(SODIUM_CRYPTO_BOX_NONCEBYTES); $ciphertext = sodium_crypto_box( @@ -71,19 +84,35 @@ public function encrypt(string $plaintext, mixed $key, array $context = []): str } /** - * @param array $context + * @return array */ - private function decodeKey(mixed $value, string $name, array $context): string + private function normalizeKeyMaterial(mixed $key, string $invalidMessage): array { - if (!is_string($value) || $value === '') { - throw new InvalidKeyException(sprintf('%s must be a non-empty string.', $name)); + if (!is_array($key)) { + throw new InvalidKeyException($invalidMessage); } - $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)); + $normalized = []; + foreach ($key as $entryKey => $entryValue) { + if (!is_string($entryKey)) { + throw new InvalidKeyException($invalidMessage); + } + + $normalized[$entryKey] = $entryValue; } - return $decoded; + 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 78fe05d..1af167f 100644 --- a/src/Crypto/SealedBoxCipher.php +++ b/src/Crypto/SealedBoxCipher.php @@ -6,8 +6,10 @@ use Infocyph\Epicrypt\Crypto\Contract\CipherInterface; 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,22 +20,18 @@ 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.'); + try { + $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); } $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.'); } @@ -46,13 +44,10 @@ 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.'); + try { + $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); } $ciphertext = sodium_crypto_box_seal($plaintext, $publicKey); diff --git a/src/Crypto/SecretBoxCipher.php b/src/Crypto/SecretBoxCipher.php index 5fc9d5a..dccd89c 100644 --- a/src/Crypto/SecretBoxCipher.php +++ b/src/Crypto/SecretBoxCipher.php @@ -8,27 +8,25 @@ 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, ); @@ -46,30 +44,77 @@ 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 { - if (!is_string($key) || $key === '') { - throw new InvalidKeyException(sprintf('%s key must be a non-empty string.', $operation)); + try { + return BinaryKey::fixedLength( + $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) { + throw new DecryptionException('Invalid ciphertext format.'); } - $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)); + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new DecryptionException('Unsupported payload algorithm.'); } - return $decodedKey; + return [$compactPayload->nonce, $compactPayload->ciphertext]; } } diff --git a/src/Crypto/SecretStream.php b/src/Crypto/SecretStream.php index 41f232c..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 { @@ -71,20 +86,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 +113,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, @@ -151,29 +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 { - $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, &$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(); } @@ -186,23 +179,20 @@ 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; 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; @@ -217,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; } @@ -229,16 +218,27 @@ 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(); } } + 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 +248,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,11 +260,26 @@ private function incrementNonce(string $nonce): string return implode('', $bytes); } - private function writeBinary(SafeFileWriter $fileWriter, string $data): void + 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): 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 9198bba..8f637a6 100644 --- a/src/Crypto/Signature.php +++ b/src/Crypto/Signature.php @@ -8,6 +8,7 @@ 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,16 +17,13 @@ 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.'); + try { + $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); } - $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.'); - } - - $signature = sodium_crypto_sign_detached($message, $privateKey); + $signature = sodium_crypto_sign_detached($message, $this->requireNonEmptyKey($privateKey, 'Private key')); return Base64Url::encode($signature); } @@ -35,13 +33,10 @@ 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.'); + try { + $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); } $decodedSignature = Base64Url::decode($signature); @@ -49,6 +44,18 @@ public function verify(string $message, string $signature, mixed $key, array $co 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/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 @@ +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..495aee5 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::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); + } } 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,22 +50,49 @@ public function decryptWithAnyKey(string $ciphertext, iterable|KeyRing $keys, ar */ public function decryptWithAnyKeyResult(string $ciphertext, iterable|KeyRing $keys, array $context = []): StringUnprotectResult { - $normalized = ProtectionContext::normalize($context); - $lastException = null; + if ($keys instanceof KeyRing) { + return $this->decryptWithKeyRingResult($ciphertext, $keys, $context); + } + + $normalized = ProtectionContext::fromArray($context)->toArray(); + + return $this->decryptWithOrderedEntries($ciphertext, $normalized, $this->orderedKeyEntries($keys)); + } + + /** + * @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)); + } - foreach ($this->orderedKeyEntries($keys) as $entry) { try { return new StringUnprotectResult( - $this->decrypt($ciphertext, $entry['key'], $normalized), - $entry['id'], - !$entry['active'], + $this->decrypt($ciphertext, $key, $normalized), + $compactPayload->keyId, + false, ); } catch (Throwable $e) { - $lastException = $e; + throw new DecryptionException(sprintf('Unable to decrypt protected string with key id "%s".', $compactPayload->keyId), 0, $e); } } - throw new DecryptionException('Unable to decrypt protected string with any supplied key.', 0, $lastException); + return $this->decryptWithOrderedEntries($ciphertext, $normalized, $this->orderedKeyEntries($keyRing)); } /** @@ -70,7 +100,59 @@ public function decryptWithAnyKeyResult(string $ciphertext, iterable|KeyRing $ke */ 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) { + throw new DecryptionException('Invalid protected string format.'); + } + + return new StringProtectInspectResult(EncryptedPayloadVersion::V1->value, $payload->algorithm, $payload->keyId); + } + + 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); } /** @@ -96,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 9928783..13b5f50 100644 --- a/src/DataProtection/Support/ProtectionContext.php +++ b/src/DataProtection/Support/ProtectionContext.php @@ -5,37 +5,78 @@ namespace Infocyph\Epicrypt\DataProtection\Support; use Infocyph\Epicrypt\Exception\ConfigurationException; +use Infocyph\Epicrypt\Internal\BaseProtectionContext; +use Infocyph\Epicrypt\Internal\ContextValue; /** * @internal */ -final class ProtectionContext +final readonly class ProtectionContext extends BaseProtectionContext { + public function __construct( + 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 + { + $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( + purpose: $purpose, + keyIsBinary: $baseFields['key_is_binary'], + nonceIsBinary: $baseFields['nonce_is_binary'], + aad: $baseFields['aad'], + keyId: $baseFields['key_id'], + ); + } + /** * @param array $context * @return array */ public static function normalize(array $context): array { - $keyIsBinary = $context['key_is_binary'] ?? false; - if (!is_bool($keyIsBinary)) { - throw new ConfigurationException('Protection context key_is_binary must be a boolean.'); - } + return array_merge($context, self::fromArray($context)->toArray()); + } - $nonceIsBinary = $context['nonce_is_binary'] ?? false; - if (!is_bool($nonceIsBinary)) { - throw new ConfigurationException('Protection context nonce_is_binary must be a boolean.'); - } + /** + * @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, + ]; - $aad = $context['aad'] ?? ''; - if (!is_string($aad)) { - throw new ConfigurationException('Protection context aad must be a string.'); + if ($this->keyId !== null) { + $normalized['key_id'] = $this->keyId; } - $context['key_is_binary'] = $keyIsBinary; - $context['nonce_is_binary'] = $nonceIsBinary; - $context['aad'] = $aad; + 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/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/Internal/EcdsaSignatureConverter.php b/src/Internal/EcdsaSignatureConverter.php index 121a5f8..fa7ab4c 100644 --- a/src/Internal/EcdsaSignatureConverter.php +++ b/src/Internal/EcdsaSignatureConverter.php @@ -4,7 +4,7 @@ namespace Infocyph\Epicrypt\Internal; -use Exception; +use Infocyph\Epicrypt\Exception\Token\SignatureEncodingException; final class EcdsaSignatureConverter { @@ -25,7 +25,7 @@ final class EcdsaSignatureConverter /** * Convert ASN1 string to JOSE signature. * - * @throws Exception + * @throws SignatureEncodingException */ public function fromAsn1(string $signature, int $length): string { @@ -33,7 +33,7 @@ public function fromAsn1(string $signature, int $length): string $position = 0; if ($this->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..4f0d900 100644 --- a/src/Internal/VersionedPayload.php +++ b/src/Internal/VersionedPayload.php @@ -6,32 +6,61 @@ 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); 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); } - } - if (count($segments) === $partCount && self::allNonEmpty($segments)) { - return [false, $segments]; + return null; } 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 +68,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..64e93a6 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::fixedLength($masterSecret, $isBinary, SODIUM_CRYPTO_SECRETBOX_KEYBYTES, '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,23 @@ 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 { - $parsedPayload = VersionedPayload::parse($wrappedSecret, WrappedSecretVersion::V1->value, 2); - if ($parsedPayload === null) { + $compactPayload = VersionedPayload::parseCompact($wrappedSecret, WrappedSecretVersion::V1->value); + if ($compactPayload === null) { throw new SecretProtectionException('Invalid wrapped secret format.'); } - [, $parts] = $parsedPayload; - return [$parts[0], $parts[1]]; + if ($compactPayload->algorithm !== self::ALGORITHM_ID) { + throw new SecretProtectionException('Unsupported wrapped secret algorithm.'); + } + + return [ + 'nonce' => $compactPayload->nonce, + 'ciphertext' => $compactPayload->ciphertext, + 'key_id' => $compactPayload->keyId, + ]; } } diff --git a/src/Security/ActionToken.php b/src/Security/ActionToken.php index 7e2c5f4..0987dc7 100644 --- a/src/Security/ActionToken.php +++ b/src/Security/ActionToken.php @@ -4,56 +4,30 @@ 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 ActionToken +final readonly class ActionToken extends AbstractPurposeToken { - private SignedPayloadCodec $codec; - - public function __construct( - string $secret, - private int $ttlSeconds = 900, - ) { - $this->codec = new SignedPayloadCodec($secret); - } + protected const int DEFAULT_TTL_SECONDS = 900; /** * @param array $context */ 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/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 36c9d7f..d0e5aec 100644 --- a/src/Security/EmailVerificationToken.php +++ b/src/Security/EmailVerificationToken.php @@ -4,48 +4,20 @@ 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, - ) { - $this->codec = new SignedPayloadCodec($secret); - } + protected const int DEFAULT_TTL_SECONDS = 86400; 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/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/PasswordResetToken.php b/src/Security/PasswordResetToken.php index 082d988..7ae24f6 100644 --- a/src/Security/PasswordResetToken.php +++ b/src/Security/PasswordResetToken.php @@ -4,47 +4,24 @@ 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, - ) { - $this->codec = new SignedPayloadCodec($secret); - } + protected const int DEFAULT_TTL_SECONDS = 1800; 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..c39237f 100644 --- a/src/Security/RememberToken.php +++ b/src/Security/RememberToken.php @@ -4,52 +4,20 @@ 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, - ) { - $this->codec = new SignedPayloadCodec($secret); - } + protected const int DEFAULT_TTL_SECONDS = 1209600; 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..d097ee2 100644 --- a/src/Security/SignedUrl.php +++ b/src/Security/SignedUrl.php @@ -6,98 +6,306 @@ 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 { - $parts = parse_url($url); - if (!is_array($parts)) { - throw new ConfigurationException('Invalid URL provided for signing.'); - } + $options ??= $this->defaultOptions; - $existing = []; - if (isset($parts['query'])) { - parse_str($parts['query'], $existing); - } + [$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; + } - ksort($merged); - $basePath = $this->buildBasePath($parts); - $query = http_build_query($merged); + $merged = $this->normalizeQuery($merged, $options->allowArrayParameters); + if ($merged === null) { + throw new ConfigurationException('Signed URL query parameters contain unsupported values.'); + } - $signature = Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $query, $this->secret, true)); + $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 { - $parts = parse_url($signedUrl); - if (!is_array($parts)) { - return false; + 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 SignedUrlGuard::invalidSignatureResult(); + } + [$parts, $query] = $parsed; + if (!$this->assertUrlPolicy($parts, $options, throwOnFailure: false)) { + return SignedUrlGuard::invalidSignatureResult(); } - $query = []; - parse_str((string) ($parts['query'] ?? ''), $query); + $signatureData = SignedUrlGuard::extractSignatureData($query, $this->signatureParam, $this->versionParam); + if ($signatureData === null) { + return SignedUrlGuard::invalidSignatureResult(); + } + $givenSignature = $signatureData['signature']; + $version = $signatureData['version']; + + unset($query[$this->signatureParam]); + $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); + } + + $signatureBasePath = $this->buildSignatureBasePath($parts, $options); + $computed = $this->computeSignature($signatureBasePath, $normalized); + + $verified = 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, $host, $scheme, $port] = $this->pathComponents($parts); + + 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 buildSignatureBasePath(array $parts, SignedUrlOptions $options): string + { + [$path, $host, $scheme, $port] = $this->pathComponents($parts); + + if (!$options->bindHost && !$options->bindScheme) { + return $path; + } + + if (!$options->bindHost && $options->bindScheme) { + return $scheme . '://' . $path; + } - $givenSignature = $query[$this->signatureParam] ?? null; - if (!is_string($givenSignature) || $givenSignature === '') { - return false; + if ($options->bindHost && !$options->bindScheme) { + return '//' . $host . $port . $path; } - if (isset($query[$this->versionParam])) { - if (!is_numeric($query[$this->versionParam]) || (int) $query[$this->versionParam] !== SignedUrlVersion::V1->value) { - return false; + return $scheme . '://' . $host . $port . $path; + } + + /** + * @param QueryMap $query + */ + private function computeSignature(string $basePath, array $query): string + { + return Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $this->buildQueryString($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; } - unset($query[$this->signatureParam]); - if (isset($query[$this->expiresParam]) && time() > (int) $query[$this->expiresParam]) { - return false; + ksort($normalized); + + return $normalized; + } + + /** + * @param array $query + * @return QueryMap|null + */ + private function normalizeQuery(array $query, bool $allowArrays): ?array + { + $normalized = []; + + foreach ($query as $key => $value) { + if (!is_string($key) || $key === '') { + continue; + } + + 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}, QueryMap}|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); } - ksort($query); - $basePath = $this->buildBasePath($parts); - $normalized = http_build_query($query); - $computed = Base64Url::encode(hash_hmac('sha256', $basePath . '?' . $normalized, $this->secret, true)); + $normalizedQuery = $this->normalizeQuery($query, allowArrays: true); + if ($normalizedQuery === null) { + return null; + } - return SecureCompare::equals($computed, $givenSignature); + return [$parts, $normalizedQuery]; + } + + /** + * @return array{array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed}, QueryMap} + */ + private function parseUrlWithQueryOrFail(string $url): array + { + $parsed = $this->parseUrlWithQuery($url); + if ($parsed === null) { + throw new ConfigurationException('Invalid URL provided for signing.'); + } + + return $parsed; } /** * @param array{scheme?: mixed, host?: mixed, port?: mixed, path?: mixed} $parts + * @return array{string, string, string, string} */ - private function buildBasePath(array $parts): string + private function pathComponents(array $parts): array { - $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'] : '/'; - return $scheme . '://' . $host . $port . $path; + return [$path, $host, $scheme, $port]; + } + + /** + * @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 @@ +ttlSeconds = $ttlSeconds ?? static::DEFAULT_TTL_SECONDS; + $this->codec = new SignedPayloadCodec($secret, clock: $this->clock); + } + + /** + * @param array $claims + */ + protected function issueForPurpose(SecurityTokenPurpose $purpose, array $claims): string + { + $purposeValue = $purpose->value; + + return $this->codec->issue( + ['purpose' => $purposeValue] + $claims, + $this->clock->now() + $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/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/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..9e97ee7 100644 --- a/src/Token/Jwt/AsymmetricJwt.php +++ b/src/Token/Jwt/AsymmetricJwt.php @@ -4,306 +4,154 @@ 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\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; 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\AbstractJwt; use Infocyph\Epicrypt\Token\Jwt\Support\JwtToken; -use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidator; +use Infocyph\Epicrypt\Token\Jwt\Validation\ExpectedJwtClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; 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, - ) {} - - public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, ?RegisteredClaims $expectedClaims = null, ?string $passphrase = null): self - { - return new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims); + RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, + ?JwtValidationOptions $validationOptions = null, + ?ClockInterface $clock = null, + private EcdsaSignatureConverter $ecdsaSignatureConverter = new EcdsaSignatureConverter(), + ) { + parent::__construct('asymmetric', $expectedClaims, $validationOptions ?? new JwtValidationOptions(), $clock ?? new SystemClock()); } - public function decode(string $token, mixed $key): object + public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, ?string $passphrase = null, ?JwtValidationOptions $validationOptions = null, ?ClockInterface $clock = null): self { - $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 = 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 new self($passphrase, $profile->defaultAsymmetricJwtAlgorithm(), $expectedClaims, $validationOptions, $clock, new EcdsaSignatureConverter()); } /** - * @param iterable|KeyRing $keys + * @param array $jwks */ - public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object + public function decodeFromJwks(string $token, array $jwks): object { - $lastException = null; - foreach ($this->orderedKeys($keys) as $key) { - try { - return $this->decode($token, $key); - } catch (Throwable $e) { - $lastException = $e; - } + $result = $this->decodeFromJwksResult($token, $jwks); + if (!$result->verified) { + throw new InvalidTokenException('JWT verification failed.'); } - throw new InvalidTokenException('JWT verification failed for every supplied asymmetric key.', 0, $lastException); + return (object) $result->claims; } /** - * @param array $claims - * @param array $headers + * @param array $jwks */ - 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 { - $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 + public function decodeFromJwksResult(string $token, array $jwks): JwtVerificationResult { - try { - $this->decode($token, $key); + $kid = $this->tokenKid($token); + $publicKey = new Jwks()->resolvePublicKeyByKid($jwks, $kid); - return true; - } catch (Throwable) { - return false; - } + return $this->decodeResult($token, $publicKey); } /** - * @param iterable|KeyRing $keys + * @param array $jwks */ - public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool + public function verifyFromJwks(string $token, array $jwks): bool { - return $this->verifyWithAnyKeyResult($token, $keys)->verified; + return $this->verifyFromJwksResult($token, $jwks)->verified; } /** - * @param iterable|KeyRing $keys + * @param array $jwks */ - public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + public function verifyFromJwksResult(string $token, array $jwks): JwtVerificationResult { - 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->decodeFromJwksResult($token, $jwks); } - /** - * @param array $claims - * @return array{int, int} - */ - private function extractTemporalClaims(array $claims): array + protected function algorithmHeaderValue(mixed $algorithm): string { - if (!isset($claims['nbf'], $claims['exp'])) { - throw new InvalidClaimException('Required claims "nbf" and "exp" are missing.'); + if (!$algorithm instanceof AsymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - 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 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); - } + return $algorithm->value; } - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeys(iterable|KeyRing $keys): array + protected function configuredAlgorithm(): AsymmetricJwtAlgorithm { - return array_column($this->orderedKeyEntries($keys), 'key'); + return $this->algorithm; } - /** - * @param array $claims - * @return array - */ - private function removeReservedClaims(array $claims): array + protected function parseAlgorithmFromHeader(string $algorithm): AsymmetricJwtAlgorithm { - return array_diff_key($claims, array_flip(self::RESERVED_CLAIMS)); + return AsymmetricJwtAlgorithm::fromHeader($algorithm); } - /** - * @return string|array|ArrayAccess - */ - private function requireSupportedKeyType(mixed $key): string|array|ArrayAccess + protected function sign(string $input, string $resolvedKey): string { - if (is_string($key)) { - return $key; + $resource = openssl_pkey_get_private($resolvedKey, $this->passphrase ?? ''); + if ($resource === false) { + throw new TokenException('Unable to load private key for JWT signing.'); } - if ($key instanceof ArrayAccess) { - return $key; + $result = openssl_sign($input, $signature, $resource, $this->algorithm->opensslAlgorithm()); + if (!$result || !is_string($signature)) { + throw new TokenException('JWT signing failed.'); } - 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; + $ecdsaLength = $this->algorithm->ecdsaSignatureLength(); + if ($ecdsaLength !== null) { + $signature = $this->ecdsaSignatureConverter->fromAsn1($signature, $ecdsaLength); } - throw new TokenException('Key must be a string or key-set.'); + return $signature; } - private function sign(string $input, string $privateKey, AsymmetricJwtAlgorithm $algorithm): string + protected function verifySignature(string $input, string $signature, string $resolvedKey, mixed $algorithm): bool { - $resource = openssl_pkey_get_private($privateKey, $this->passphrase ?? ''); - if ($resource === false) { - throw new TokenException('Unable to load private key for JWT signing.'); + if (!$algorithm instanceof AsymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - $result = openssl_sign($input, $signature, $resource, $algorithm->opensslAlgorithm()); - if (!$result || !is_string($signature)) { - throw new TokenException('JWT signing failed.'); + $resource = openssl_pkey_get_public($resolvedKey); + if ($resource === false) { + throw new InvalidTokenException('Unable to load public key.'); } $ecdsaLength = $algorithm->ecdsaSignatureLength(); if ($ecdsaLength !== null) { - $signature = new EcdsaSignatureConverter()->fromAsn1($signature, $ecdsaLength); + $signature = $this->ecdsaSignatureConverter->toAsn1($signature, $ecdsaLength); } - return $signature; + return openssl_verify( + $input, + $signature, + $resource, + $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 new file mode 100644 index 0000000..9971e11 --- /dev/null +++ b/src/Token/Jwt/Jwks.php @@ -0,0 +1,324 @@ +>} + */ + 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 $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 + */ + 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 $jwks + */ + 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; + } + + 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 $this->byte($length); + } + + $result = ''; + while ($length > 0) { + $result = $this->byte($length & 0xFF) . $result; + $length >>= 8; + } + + return $this->byte(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 = $this->byte(($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 = $this->byte($value & 0x7F); + $value >>= 7; + + while ($value > 0) { + $segment = $this->byte(($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 + */ + 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 $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 + */ + 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 new file mode 100644 index 0000000..5e15e89 --- /dev/null +++ b/src/Token/Jwt/Support/AbstractJwt.php @@ -0,0 +1,338 @@ +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; + + abstract protected function configuredAlgorithm(): mixed; + + abstract protected function parseAlgorithmFromHeader(string $algorithm): mixed; + + 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); + + if ($this->expectedClaims === null) { + throw new TokenException('Expected claims are required for JWT decoding.'); + } + + try { + [$encodedHeader, $encodedPayload, $signature, $header, $payload] = JwtToken::parse($token); + $this->validateHeader($header); + + $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.'); + } + + $this->requireValidator()->validate($payload); + + 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); + } + } + + /** + * @param iterable|KeyRing $keys + */ + final public function decodeWithAnyKey(string $token, iterable|KeyRing $keys): object + { + $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; + } + + /** + * @param array $claims + * @param array $headers + */ + final public function encode(array $claims, mixed $key, array $headers = []): string + { + $key = $this->requireSupportedKeyType($key); + + $issueClaims = JwtIssueClaims::fromArray($claims); + $keyId = $claims['kid'] ?? null; + + try { + $resolvedKey = KeyResolver::resolve($key, $keyId); + + [$encodedHeader, $encodedPayload] = JwtToken::encodeSegments( + $this->buildHeader($keyId, $headers), + $this->buildPayload($issueClaims, $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 + { + return $this->verifyResult($token, $key)->verified; + } + + final public function verifyResult(string $token, mixed $key): JwtVerificationResult + { + return $this->decodeResult($token, $key); + } + + /** + * @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(JwtIssueClaims $issueClaims, array $claims): array + { + $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); + + return $payload + $this->removeReservedClaims($claims); + } + + private function requireValidator(): JwtValidator + { + if ($this->validator === null) { + throw new TokenException('Expected claims are required for JWT decoding.'); + } + + 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/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..95dc79d 100644 --- a/src/Token/Jwt/SymmetricJwt.php +++ b/src/Token/Jwt/SymmetricJwt.php @@ -4,279 +4,74 @@ 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\Internal\Clock\ClockInterface; +use Infocyph\Epicrypt\Internal\Clock\SystemClock; 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\ExpectedJwtClaims; +use Infocyph\Epicrypt\Token\Jwt\Validation\JwtValidationOptions; 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, - ) {} - - 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 - { - try { - $this->decode($token, $key); - - return true; - } catch (Throwable) { - return false; - } - } - - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKey(string $token, iterable|KeyRing $keys): bool - { - return $this->verifyWithAnyKeyResult($token, $keys)->verified; + RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, + ?JwtValidationOptions $validationOptions = null, + ?ClockInterface $clock = null, + ) { + parent::__construct('symmetric', $expectedClaims, $validationOptions ?? new JwtValidationOptions(), $clock ?? new SystemClock()); } - /** - * @param iterable|KeyRing $keys - */ - public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + public static function forProfile(SecurityProfile $profile = SecurityProfile::MODERN, RegisteredClaims|ExpectedJwtClaims|null $expectedClaims = null, ?JwtValidationOptions $validationOptions = null, ?ClockInterface $clock = null): self { - 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 new self($profile->defaultSymmetricJwtAlgorithm(), $expectedClaims, $validationOptions, $clock); } - /** - * @param array $claims - * @return array{int, int} - */ - private function extractTemporalClaims(array $claims): array + protected function algorithmHeaderValue(mixed $algorithm): string { - 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".'); + if (!$algorithm instanceof SymmetricJwtAlgorithm) { + throw new UnsupportedAlgorithmException('Invalid or unsupported algorithm.'); } - return [(int) $claims['nbf'], (int) $claims['exp']]; + return $algorithm->value; } - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeyEntries(iterable|KeyRing $keys): array + protected function configuredAlgorithm(): SymmetricJwtAlgorithm { - 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); - } + return $this->algorithm; } - /** - * @param iterable|KeyRing $keys - * @return list - */ - private function orderedKeys(iterable|KeyRing $keys): array + protected function parseAlgorithmFromHeader(string $algorithm): SymmetricJwtAlgorithm { - 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/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); } /** @@ -35,16 +40,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, + ), + ); } /** @@ -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,21 +91,41 @@ 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 verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + public function verifyWithAnyKeyDetailedResult(string $token, iterable|KeyRing $keys): SignedPayloadVerificationResult { + $lastResult = new SignedPayloadVerificationResult(false); foreach ($this->orderedKeyEntries($keys) as $entry) { - if ($this->verify($token, $entry['key'])) { - return new KeyVerificationResult(true, $entry['id'], !$entry['active']); + $result = $this->verifyResult($token, $entry['key']); + if ($result->verified) { + return new SignedPayloadVerificationResult( + true, + $result->claims, + $entry['id'], + !$entry['active'], + $result->expired, + ); } + $lastResult = $result; } - return new KeyVerificationResult(false); + return $lastResult; + } + + /** + * @param iterable|KeyRing $keys + */ + public function verifyWithAnyKeyResult(string $token, iterable|KeyRing $keys): KeyVerificationResult + { + return TokenAnyKey::verifyResult( + $this->orderedKeyEntries($keys), + fn(string $candidateKey): bool => $this->verify($token, $candidateKey), + ); } /** @@ -103,15 +134,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 +147,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/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/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..fbecc2d 100644 --- a/tests/Certificate/DomainTest.php +++ b/tests/Certificate/DomainTest.php @@ -1,6 +1,14 @@ generate(); @@ -33,10 +44,39 @@ 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(); - $cipher = new RsaCipher(); + $cipher = new RsaCipher; $encrypted = $cipher->encrypt('certificate-rsa-check', $keyPair['public']); $decrypted = $cipher->decrypt($encrypted, $keyPair['private']); @@ -61,9 +101,93 @@ 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, 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/Crypto/AeadContextTest.php b/tests/Crypto/AeadContextTest.php new file mode 100644 index 0000000..30db505 --- /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 71e9f72..2399e10 100644 --- a/tests/Crypto/CoreServicesTest.php +++ b/tests/Crypto/CoreServicesTest.php @@ -3,33 +3,73 @@ 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 () { - $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']); + $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); - $signatureService = new Signature(); + $signatureService = new Signature; $signature = $signatureService->sign('epicrypt-signature', $keys['private']); expect($signatureService->verify('epicrypt-signature', $signature, $keys['public']))->toBeTrue(); 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(); + $macService = new Mac; $key = $macService->generateKey(); $mac = $macService->generate('epicrypt-mac', $key); diff --git a/tests/Crypto/MatrixCoverageTest.php b/tests/Crypto/MatrixCoverageTest.php new file mode 100644 index 0000000..9dd1ed1 --- /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..8ac3895 --- /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..35d0bd8 --- /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 d40d288..b6face8 100644 --- a/tests/DataProtection/ServicesTest.php +++ b/tests/DataProtection/ServicesTest.php @@ -2,24 +2,31 @@ 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; 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); + $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'); }); it('supports key-ring decrypt and re-encryption for protected strings', function () { - $generator = new KeyMaterialGenerator(); + $generator = new KeyMaterialGenerator; $previousKey = $generator->forSecretBox(); $currentKey = $generator->forSecretBox(); @@ -41,21 +48,74 @@ 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); + $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); 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'); }); it('supports envelope re-encryption across key rotation', function () { - $generator = new KeyMaterialGenerator(); + $generator = new KeyMaterialGenerator; $previousMaster = $generator->forSecretBox(); $currentMaster = $generator->forSecretBox(); @@ -71,18 +131,60 @@ 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(); + $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'); @@ -103,17 +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..5156cca --- /dev/null +++ b/tests/Generate/KeyDerivationContextTest.php @@ -0,0 +1,52 @@ +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 92c23a3..08bfa3a 100644 --- a/tests/Generate/ServicesTest.php +++ b/tests/Generate/ServicesTest.php @@ -1,8 +1,9 @@ bytes(32)))->toBe(32); expect($random->string(40))->toHaveLength(40); @@ -27,9 +28,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', @@ -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 3dde228..c11bdbb 100644 --- a/tests/Integrity/ServicesTest.php +++ b/tests/Integrity/ServicesTest.php @@ -1,5 +1,6 @@ fingerprint('payload', ['b' => '2', 'a' => '1']); $fingerprintB = $fingerprinter->fingerprint('payload', ['a' => '1', 'b' => '2']); 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..f38562a --- /dev/null +++ b/tests/Internal/BinaryKeyTest.php @@ -0,0 +1,19 @@ +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::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 new file mode 100644 index 0000000..52fad2c --- /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..2a0f6ea --- /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..9701181 --- /dev/null +++ b/tests/Internal/VersionedPayloadTest.php @@ -0,0 +1,29 @@ +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('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(); +}); + +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 ee80718..3eace88 100644 --- a/tests/Password/ServicesTest.php +++ b/tests/Password/ServicesTest.php @@ -1,18 +1,23 @@ generate(16); - $hasher = new PasswordHasher(); + $hasher = new PasswordHasher; $hash = $hasher->hashPassword($password); expect($hasher->verifyPassword($password, $hash))->toBeTrue(); @@ -21,7 +26,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 +53,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); + $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]; - 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); @@ -84,3 +89,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..374dcd9 --- /dev/null +++ b/tests/Security/ClockIntegrationTest.php @@ -0,0 +1,65 @@ +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(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..6b5bc8d --- /dev/null +++ b/tests/Security/SignedUrlHardeningTest.php @@ -0,0 +1,52 @@ +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/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/AlgorithmMatrixTest.php b/tests/Token/Jwt/AlgorithmMatrixTest.php new file mode 100644 index 0000000..461dbee --- /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 5386d38..b61e010 100644 --- a/tests/Token/Jwt/AsymmetricServiceTest.php +++ b/tests/Token/Jwt/AsymmetricServiceTest.php @@ -1,9 +1,9 @@ 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..48de51c --- /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..3ab4149 --- /dev/null +++ b/tests/Token/Jwt/JwksTest.php @@ -0,0 +1,95 @@ + 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'); +}); + +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'); +}); diff --git a/tests/Token/Jwt/SymmetricServiceTest.php b/tests/Token/Jwt/SymmetricServiceTest.php index f892a1a..c39afc7 100644 --- a/tests/Token/Jwt/SymmetricServiceTest.php +++ b/tests/Token/Jwt/SymmetricServiceTest.php @@ -1,9 +1,9 @@ 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..e8806c4 --- /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); +});