From 8d911b335c9f5deaa4b121c5627ef415af90f0b8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sat, 4 Nov 2023 17:58:50 +0100 Subject: [PATCH] Initial commit --- .editorconfig | 26 + .gitattributes | 14 + .github/ISSUE_TEMPLATE/1_Bug_report.md | 20 + .github/ISSUE_TEMPLATE/2_Feature_request.md | 15 + .github/ISSUE_TEMPLATE/3_Support_question.md | 13 + .github/ISSUE_TEMPLATE/4_Security_issue.md | 14 + .github/PULL_REQUEST_TEMPLATE.md | 16 + .github/stale.yml | 17 + .github/workflows/ci.yaml | 157 +++++ .gitignore | 9 + .php-cs-fixer.dist.php | 30 + LICENSE | 21 + Makefile | 4 + README.md | 77 +++ .../translations/validators+intl-icu.en.yml | 26 + composer.json | 66 ++ docs/ca_resolver.md | 4 + docs/index.md | 225 +++++++ phpstan.neon | 16 + phpunit.dist.xml | 27 + src/CA.php | 24 + src/CAResolver.php | 20 + src/CAResolverImpl.php | 98 +++ src/CertificateValidator.php | 179 ++++++ src/KeyValidator.php | 105 ++++ src/OCSPValidator.php | 122 ++++ src/TranslatableArgument.php | 64 ++ src/Violation.php | 42 ++ src/Violation/CertificateHasExpired.php | 40 ++ src/Violation/CertificateIsRevoked.php | 96 +++ src/Violation/CertificateMismatch.php | 29 + src/Violation/ExpectedLeafCertificate.php | 29 + src/Violation/GlobalWildcard.php | 53 ++ src/Violation/KeyBitsTooLow.php | 43 ++ src/Violation/MissingCAExtension.php | 34 + src/Violation/PublicKeyMismatch.php | 29 + src/Violation/ToManyCAsProvided.php | 29 + src/Violation/UnableToResolveParent.php | 38 ++ src/Violation/UnprocessableKey.php | 29 + src/Violation/UnprocessablePEM.php | 38 ++ src/Violation/UnsupportedDomain.php | 44 ++ src/Violation/UnsupportedPurpose.php | 41 ++ src/Violation/WeakSignatureAlgorithm.php | 43 ++ src/X509DataExtractor.php | 148 +++++ src/X509Info.php | 58 ++ tests/CAResolverImplTest.php | 447 +++++++++++++ tests/CertificateValidatorTest.php | 587 ++++++++++++++++++ tests/KeyValidatorTest.php | 362 +++++++++++ tests/OCSPValidatorTest.php | 522 ++++++++++++++++ tests/TestLogger.php | 209 +++++++ 50 files changed, 4399 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/1_Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/2_Feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/3_Support_question.md create mode 100644 .github/ISSUE_TEMPLATE/4_Security_issue.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/stale.yml create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 Resources/translations/validators+intl-icu.en.yml create mode 100644 composer.json create mode 100644 docs/ca_resolver.md create mode 100644 docs/index.md create mode 100644 phpstan.neon create mode 100644 phpunit.dist.xml create mode 100644 src/CA.php create mode 100644 src/CAResolver.php create mode 100644 src/CAResolverImpl.php create mode 100644 src/CertificateValidator.php create mode 100644 src/KeyValidator.php create mode 100644 src/OCSPValidator.php create mode 100644 src/TranslatableArgument.php create mode 100644 src/Violation.php create mode 100644 src/Violation/CertificateHasExpired.php create mode 100644 src/Violation/CertificateIsRevoked.php create mode 100644 src/Violation/CertificateMismatch.php create mode 100644 src/Violation/ExpectedLeafCertificate.php create mode 100644 src/Violation/GlobalWildcard.php create mode 100644 src/Violation/KeyBitsTooLow.php create mode 100644 src/Violation/MissingCAExtension.php create mode 100644 src/Violation/PublicKeyMismatch.php create mode 100644 src/Violation/ToManyCAsProvided.php create mode 100644 src/Violation/UnableToResolveParent.php create mode 100644 src/Violation/UnprocessableKey.php create mode 100644 src/Violation/UnprocessablePEM.php create mode 100644 src/Violation/UnsupportedDomain.php create mode 100644 src/Violation/UnsupportedPurpose.php create mode 100644 src/Violation/WeakSignatureAlgorithm.php create mode 100644 src/X509DataExtractor.php create mode 100644 src/X509Info.php create mode 100644 tests/CAResolverImplTest.php create mode 100644 tests/CertificateValidatorTest.php create mode 100644 tests/KeyValidatorTest.php create mode 100644 tests/OCSPValidatorTest.php create mode 100644 tests/TestLogger.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6592f4d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +; top-most EditorConfig file +root = true + +; Unix-style newlines +[*] +end_of_line = LF + +[*.php] +indent_style = space +indent_size = 4 + +[*.js] +indent_style = space +indent_size = 4 + +[*.md] +indent_style = space +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 4 + +[*.xml] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..93aa015 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Always use LF +core.autocrlf=lf + +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore +.php-cs-fixer.dist.php export-ignore +CODE_OF_CONDUCT.md export-ignore +Makefile export-ignore +phpunit.xml.dist export-ignore +phpstan.neon export-ignore +phpstan-baseline.neon export-ignore +tests/ export-ignore diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md new file mode 100644 index 0000000..1e26723 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md @@ -0,0 +1,20 @@ +--- +name: "\U0001F41B Bug Report" +about: Report errors and problems +title: "[bug] " +labels: Potential Bug +assignees: '' + +--- + +**Description** + + +**How to reproduce** + + +**Possible Solution** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md new file mode 100644 index 0000000..c21e708 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_Feature_request.md @@ -0,0 +1,15 @@ +--- +name: "\U0001F680 Feature Request" +about: "I have a suggestion (and may want to implement it \U0001F642)!" +title: "[Feature] " +labels: Feature +assignees: '' + +--- + +**Description** + + +**Example** + diff --git a/.github/ISSUE_TEMPLATE/3_Support_question.md b/.github/ISSUE_TEMPLATE/3_Support_question.md new file mode 100644 index 0000000..f933bf2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_Support_question.md @@ -0,0 +1,13 @@ +--- +name: 👩‍🏫 Support Question +about: Questions about using this library +labels: Question / Support + +--- + +**Description** + diff --git a/.github/ISSUE_TEMPLATE/4_Security_issue.md b/.github/ISSUE_TEMPLATE/4_Security_issue.md new file mode 100644 index 0000000..ea75484 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4_Security_issue.md @@ -0,0 +1,14 @@ +--- +name: ⛔ Security Issue +about: Report security issues and problems (PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY) + +--- + +⚠ PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, SEE BELOW. + +If you have found a security issue in this project, please send the details to +security [at] rollerscapes.net and don't disclose it publicly until we can provide a +fix for it. + +**Note:** Please don't blindly send reports about automated tools, make sure the +reported issue is in fact exploitable. Thanks. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..da9f029 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +| Q | A +| ------------- | --- +| Bug fix? | yes/no +| New feature? | yes/no +| BC breaks? | yes/no +| Deprecations? | yes/no +| Fixed tickets | Fix #... +| License | MIT + + diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..cb1095a --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 30 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: Stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f9ec887 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,157 @@ +name: 'CI' + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + cs-fixer: + name: 'PHP CS Fixer' + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + php-version: + - '8.2' + + steps: + - + name: 'Check out' + uses: 'actions/checkout@v2' + + - + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' + with: + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + + - + name: 'Cache dependencies' + uses: 'actions/cache@v2' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' + run: 'composer install --no-progress' + + - + name: 'Check the code style' + run: 'make cs' + + phpstan: + name: 'PhpStan' + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + php-version: + - '8.2' + + steps: + - + name: 'Check out' + uses: 'actions/checkout@v2' + + - + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' + with: + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + + - + name: 'Cache dependencies' + uses: 'actions/cache@v2' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' + run: 'composer install --no-progress' + + - + name: 'Run PhpStan' + run: 'vendor/bin/phpstan analyze --no-progress' + + tests: + name: 'PHPUnit' + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + include: + - + php-version: '8.1' + composer-options: '--prefer-stable' + symfony-version: '6.3' + - + php-version: '8.2' + composer-options: '--prefer-stable' + symfony-version: '^6.4' + + - + php-version: '8.2' + composer-options: '--prefer-stable' + symfony-version: '^7.0' + + steps: + - + name: 'Check out' + uses: 'actions/checkout@v2' + + - + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' + with: + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + + - + name: 'Cache dependencies' + uses: 'actions/cache@v2' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' + env: + COMPOSER_OPTIONS: '${{ matrix.composer-options }}' + SYMFONY_REQUIRE: '${{ matrix.symfony-version }}' + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex + composer update --no-progress $COMPOSER_OPTIONS + + - + name: 'Run tests' + run: make phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2245b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +composer.lock +/vendor/ + +phpunit.xml +.phpunit.result.cache +.phpunit.cache/ +.phpunit + +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..3d269c7 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ + + +This source file is subject to the MIT license that is bundled +with this source code in the file LICENSE. +EOF; + +/** @var \Symfony\Component\Finder\Finder $finder */ +$finder = PhpCsFixer\Finder::create(); +$finder + ->in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + +$config = new PhpCsFixer\Config(); +$config + ->setRiskyAllowed(true) + ->setRules( + array_merge( + require __DIR__ . '/vendor/rollerscapes/standards/php-cs-fixer-rules.php', + ['header_comment' => ['header' => $header]]) + ) + ->setFinder($finder); + +return $config; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec2b95f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2034 Sebastiaan Stok + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2ba1948 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +include vendor/rollerscapes/standards/Makefile + +phpunit: + ./vendor/bin/phpunit diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d34f4b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +Rollerworks X509Validator +========================= + +This package provides X509 TLS certificate/private-key validators to +validate the following: + +* CA chain completeness +* PrivateKey bits length +* Signature algorithm +* OCSP Revocation status (requires internet access) +* Certificate purpose +* Certificate general validity (private-key compatibility, not expired, readable) +* Certificate hostname pattern supported, and protection + against global wildcards of public-suffix length violations + +**Tip:** Violations can be easily translated using the Symfony Translator +component and provided translations. + +Use the [X509Validator Symfony Constraints][x509-sf] (separate package) +to these validators with the Symfony Validator component. + +## Installation + +To install this package, add `rollerworks/x509-validator` to your composer.json: + +```bash +$ php composer.phar require rollerworks/x509-validator +``` + +Now, [Composer][composer] will automatically download all required files, +and install them for you. + +## Requirements + +You need at least PHP 8.1, internet access is required if you want to +validate a certificate's OCSP status. + +## Basic Usage + +The `CertificateValidator` and `KeyValidator` are to primary validators +for validating a certificate or private-key encoded in PEM (base64), +DER (binary) is not supported. + +See (documentation)[docs/index.md] for usage of all validators. + +## Versioning + +For transparency and insight into the release cycle, and for striving to +maintain backward compatibility, this package is maintained under the +Semantic Versioning guidelines as much as possible. + +Releases will be numbered with the following format: + +`..` + +And constructed with the following guidelines: + +* Breaking backward compatibility bumps the major (and resets the minor and patch) +* New additions without breaking backward compatibility bumps the minor (and resets the patch) +* Bug fixes and misc changes bumps the patch + +For more information on SemVer, please visit . + +## License + +This library is released under the [MIT license](LICENSE). + +## Contributing + +This is an open source project. If you'd like to contribute, +please read the [Contributing Guidelines][contributing]. If you're submitting +a pull request, please follow the guidelines in the [Submitting a Patch][patches] section. + +[composer]: https://getcomposer.org/doc/00-intro.md +[flex]: https://symfony.com/doc/current/setup/flex.html +[contributing]: https://contributing.rollerscapes.net/ +[patches]: https://contributing.rollerscapes.net/latest/patches.html diff --git a/Resources/translations/validators+intl-icu.en.yml b/Resources/translations/validators+intl-icu.en.yml new file mode 100644 index 0000000..451a95c --- /dev/null +++ b/Resources/translations/validators+intl-icu.en.yml @@ -0,0 +1,26 @@ +tls: + violation: + unprocessable_pem: Unable to process PEM X.509 data of certificate "{name}". Only PEM encoded X.509 files are supported. + unprocessable_key: Unable to process PEM X.509 data of private key "{name}". Only PEM encoded X.509 files are supported. + public_key_mismatch: The certificate public-key does not match with the private-key "public-key" data. + certificate_mismatch: The certificate does not match with the provided private-key. + weak_signature_algorithm: This certificate was signed using the weak "{provided}" algorithm. Expected at least algorithm "{expected}".". + key_bits_too_low: The private-key bits-size {provided} is too low. Expected at least {expected} bits. + certificate_is_revoked: 'The certificate with serial-number "{serial}" was marked as revoked on { revoked_on, date, short } with reason: ({reason_code}) {reason}.' + expected_leaf_certificate: The certificate with common-name "{common_name}" contains a CA extension. Expected a leaf certificate. + certificate_has_expired: 'This certificate has expired on { expired_on, date, short }.' + unsupported_domain: 'This certificate should support host pattern "{required_pattern}". But only the following patterns are supported: {supported}' + unsupported_purpose: 'This certificate does not support the purpose: {required_purpose}' + global_wildcard: The certificate host "{provided}" contains an invalid global-wildcard pattern. + public_suffix_wildcard: The certificate host "{provided}" contains an invalid public-suffix wildcard pattern "{suffix_pattern}". + unable_to_resolve_parent: Unable to resolve CA of certificate "{name}". + + revocation_reason: + unspecified: unspecified (no specific reason was given). + keyCompromise: the private key associated with the certificate has been compromised. + cACompromise: the CA's private key is has been compromised and is in the possession of an unauthorized individual. When a CA's private key is revoked, this results in all certificates issued by the CA that are signed using the private key associated with the revoked certificate being considered revoked. + affiliationChanged: the user has terminated their relationship with the organization indicated in the Distinguished Name attribute of the certificate. This revocation code is typically used when an individual is terminated or has resigned from an organization. + superseded: a replacement certificate has been issued to a user. + cessationOfOperation: the CA is decommissioned, no longer to be used. + privilegeWithdrawn: the certificate (public-key or attribute certificate) was revoked because a privilege contained within that certificate has been withdrawn. + aACompromise: it is known or suspected that aspects of the AA validated in the attribute certificate have been compromised. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..94da9d1 --- /dev/null +++ b/composer.json @@ -0,0 +1,66 @@ +{ + "name": "rollerworks/x509-validator", + "description": "X509 PEM-encoded validators. CA-Chain, OCSP, common-name compatibility, purpose", + "license": "MIT", + "type": "library", + "keywords": [ + "x509", + "tls", + "ssl", + "validator", + "ocsp" + ], + "authors": [ + { + "name": "Sebastiaan Stok", + "email": "s.stok@rollerscapes.net" + } + ], + "require": { + "php": "^8.1", + "ext-mbstring": "*", + "ext-openssl": "*", + "mlocati/ocsp": "^1.0", + "rollerworks/pdb-symfony-bridge": "^1.0", + "rollerworks/pdb-validator": "^1.0", + "symfony/translation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "paragonie/hidden-string": "^2.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^10.4.2", + "rollerscapes/standards": "^1.0", + "symfony/error-handler": "^6.3", + "symfony/http-client": "^6.3", + "symfony/phpunit-bridge": "^6.3 || ^7.0", + "symfony/translation": "^6.3 || ^7.0" + }, + "suggest": { + "paragonie/hidden-string": "Safely privide the private-key for validation, without leaking secrets", + "symfony/http-client": "To validate certificate rovocation status", + "symfony/translation": "To translate violation messages" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Rollerworks\\Component\\X509Validator\\": "src/" + }, + "exclude-from-classmap": [ + "tests/" + ] + }, + "autoload-dev": { + "psr-4": { + "Rollerworks\\Component\\X509Validator\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + } +} diff --git a/docs/ca_resolver.md b/docs/ca_resolver.md new file mode 100644 index 0000000..ad678e3 --- /dev/null +++ b/docs/ca_resolver.md @@ -0,0 +1,4 @@ +Custom CAResolver +================= + +TBD. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4adb357 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,225 @@ +Basic Usage +=========== + +This library consists of multiple validators, the `CertificateValidator` +being the main one. All other validators assume this validator is always +used prior to advanced-level validation. + +Composer autoloader is assumed for all examples. + +All validators except the `KeyValidator` use the `X509DataExtractor`, +while this argument is not required for the validators it's recommended +to create one instance pass this the validator constructors, as the +extractor keeps a single-cache of the last used certificate. + +Secondly a `CAResolver` instance is used, which can be replaced with +a custom implementation to either store a CA chain or trying to retrieve +from a global database. See [Custom CAResolver]([ca_resolver.md](ca_resolver.md)) +for more information. + +```php +use Rollerworks\Component\X509Validator\X509DataExtractor; + +$dataExtractor = new X509DataExtractor(); +``` + +In the case of a violation a specific exception which extends the +`Rollerworks\Component\X509Validator\Violation` class is thrown. + +See [Working with validation Violations] below. + +## Using the CertificateValidator + +Note that `CertificateValidator` requires a `Rollerworks\Component\PdbSfBridge\PdpManager` +instance, see https://github.com/rollerworks/PdbSfBridge to set-up a new instance. + +The `CertificateValidator` validates: + +* CA chain completeness; +* Signature algorithm; +* Certificate is not expired; +* Certificate hostname pattern contains no global wildcards + or public-suffix length violations; + +```php +use Rollerworks\Component\PdbSfBridge\PdpManager; +use Rollerworks\Component\X509Validator\CertificateValidator; + +/** @var PdpManager $pdbManager */ +$pdbManager = ...; + +$validator = new CertificateValidator($pdbManager, /*$dataExtractor*/); + + // PEM X509 encoded certificate string +$certificate = ''; + + // PEM X509 encoded CA string as value, key can be (file)name for reverence (order doesn't matter) +$caList = []; + +// WARNING the allowWeakAlgorithm argument allows to disable Algorithm validation! +// By default only sha256 or higher is considered safe. +$validator->validateCertificate($certificate, $caList, /*allowWeakAlgorithm: false)*/); +``` + +### Validating required purpose + +The `CertificateValidator` allows to validate if certificate can be used +for a specific purpose, like SSL-server, S/MIME encryption/signing. + +**Note:** This should be called _after_ `validateCertificate()`. + +```php +use Rollerworks\Component\X509Validator\CertificateValidator; + +/** @var CertificateValidator $validator */ +$validator = ...; + +// Any of the CertificateValidator::PURPOSE_* constants. +// CertificateValidator::PURPOSE_SMIME includes both signing and encryption +$validator->validateCertificatePurpose($certificate, CertificateValidator::PURPOSE_SSL_SERVER); + +// Multiple purpose names as variadic are allowed. +$validator->validateCertificatePurpose($certificate, CertificateValidator::PURPOSE_SSL_CLIENT, CertificateValidator::PURPOSE_SSL_SERVER)); +``` + +Throws `Rollerworks\Component\X509Validator\Violation\UnsupportedPurpose`. + +### Validating hosts compatibility + +The `CertificateValidator` allows to validate if certificate hostname(s) +are supported by a specific name or pattern. Including alt-names. + +**Note:** This should be called _after_ `validateCertificate()`. + +```php +use Rollerworks\Component\X509Validator\CertificateValidator; + +/** @var CertificateValidator $validator */ +$validator = ...; + +$validator->validateCertificateHost($certificate, 'example.com'); + +// Wildcard is supported +$validator->validateCertificateHost($certificate, '*.example.com'); +``` + +Throws `Rollerworks\Component\X509Validator\Violation\UnsupportedDomain` +with a list of supported hostnames. + +### Validating custom conditions + +The `CertificateValidator` allows to validate if certificate can be used +with specific condition that are too custom for regular validator. + +**Note:** This should be called _after_ `validateCertificate()`. + +```php +use Rollerworks\Component\X509Validator\CertificateValidator; +use Rollerworks\Component\X509Validator\Violation; +use Rollerworks\Component\X509Validator\X509Info; + +/** @var CertificateValidator $validator */ +$validator = ...; + +final class EmailFieldRequired extends Violation +{ + public function __construct() + { + parent::__construct('This certificate should contains an emails extension.'); + } + + public function getTranslatorMsg(): string + { + return 'This certificate should contains an emails extension.'; + } +} + +// The $info argument contains all the raw information extracted from +// the certificate. Field starting with underscore are validator specific. +$customValidator = function (X509Info $info, string $certificate, CertificateValidator $validator ) { + if (count($info->emails) === 0) { + throw new EmailFieldRequired(); + } +}; + +$validator->validateCertificateSupport($certificate, $customValidator); +``` + +## Using the KeyValidator + +The `KeyValidator` validates if the certificate is compatible with the provided +private-key, and if the private-key fulfills the minimum bit-length. + +```php +use Rollerworks\Component\X509Validator\KeyValidator; + +$validator = new KeyValidator(); + +// PEM X509 encoded private-key string +$privateKey = ''; + +// OR a ParagonIE HiddenString object to prevent leaking information +// into core-dumps or system logs. +// $privateKey = new \ParagonIE\HiddenString\HiddenString(''); + + // PEM X509 encoded certificate string +$certificate = ''; + +// minimumBitCount is 2048 but be increased or lowered at will +$validator->validate($privateKey, $certificate, /*minimumBitCount: KeyValidator::MINIMUM_BIT_COUNT*/); +``` + +**Throws:** + +* `Rollerworks\Component\X509Validator\Violation\UnprocessablePEM`: when the data cannot be parsed or processed +* `Rollerworks\Component\X509Validator\Violation\PublicKeyMismatch`: when the public-keys don't match +* `Rollerworks\Component\X509Validator\Violation\CertificateMismatch`: when the private doesn't match the certificate +* `Rollerworks\Component\X509Validator\Violation\KeyBitsTooLow`: when the private bits count is less than $minimumBitCount + +## Using the OCSPValidator + +The `OCSPValidator` validates the revocation status of a certificate, +for this to work internet access is required, and the certificate must +have a CA. + +First make sure the `` + +This validator should be called after general validation with the `CertificateValidator`. + +All arguments are optional, if not provided will be created as shown +in the example. + +```php +use Rollerworks\Component\X509Validator\OCSPValidator; + +$httpClient = \Symfony\Component\HttpClient\HttpClient::create(); +$logger = new \Psr\Log\NullLogger(); // Replace an actual logger for server errors +$caResolver = new \Rollerworks\Component\X509Validator\CAResolverImpl(); + +$validator = new OCSPValidator($httpClient, $logger, $caResolver); + + // PEM X509 encoded certificate string +$certificate = ''; + + // PEM X509 encoded CA string as value, key can be (file)name for reverence (order doesn't matter) +$caList = []; + +$validator->validateStatus($certificate, $caList); +``` + +Throws `Rollerworks\Component\X509Validator\Violation\CertificateIsRevoked` with +reason of revocation, and date (if available). + +## Working with validation Violations + +All violations contain a technical message which can be safely displayed +publicly like an API REST response without the need for redacting. + +To display a violation as user-friendly error message either use the +`getTranslatorMsg()` and `getParameters()` methods to render a message. + +Or call the `trans()` method with a `Symfony\Contracts\Translation\TranslatorInterface` +translator. Translations are provided in xliff. + +**Note:** Make sure the translator instance support ICU formatting and translates +`TranslatableInterface` implementing parameters. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..0e56097 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,16 @@ +includes: + - vendor/rollerscapes/standards/phpstan.neon + #- phpstan-baseline.neon + +parameters: + #reportUnmatchedIgnoredErrors: false + + paths: + - ./src + - ./tests + excludePaths: + - var/ + - templates/ + - translations/ + + #ignoreErrors: diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..61e4694 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,27 @@ + + + + + tests + + + + + + + + + src/ + + + vendor/ + tests/ + + + diff --git a/src/CA.php b/src/CA.php new file mode 100644 index 0000000..11ea19a --- /dev/null +++ b/src/CA.php @@ -0,0 +1,24 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +class CA +{ + public function __construct(private readonly string $contents) {} + + public function getContents(): string + { + return $this->contents; + } +} diff --git a/src/CAResolver.php b/src/CAResolver.php new file mode 100644 index 0000000..1111e78 --- /dev/null +++ b/src/CAResolver.php @@ -0,0 +1,20 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +interface CAResolver +{ + /** @param array $caList [identifiable-name => PEM-encoded-CA] */ + public function resolve(string $certificate, array $caList): ?CA; +} diff --git a/src/CAResolverImpl.php b/src/CAResolverImpl.php new file mode 100644 index 0000000..e599468 --- /dev/null +++ b/src/CAResolverImpl.php @@ -0,0 +1,98 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use Rollerworks\Component\X509Validator\Violation\MissingCAExtension; +use Rollerworks\Component\X509Validator\Violation\ToManyCAsProvided; +use Rollerworks\Component\X509Validator\Violation\UnableToResolveParent; + +class CAResolverImpl implements CAResolver +{ + private readonly X509DataExtractor $extractor; + + public function __construct() + { + $this->extractor = new X509DataExtractor(); + } + + /** + * @param array $caList [identifiable-name => PEM-encoded-CA] + * + * @return CA|null returns null when certificate is self-signed (regardless of provided caList) + */ + public function resolve(string $certificate, array $caList): ?CA + { + // Safety check to prevent DoS attacks + // Normally only two parents are used, more than three is exceptional + if (\count($caList) > 3) { + throw new ToManyCAsProvided(); + } + + $certData = $this->extractor->extractRawData($certificate, '', true); + + if ($this->isSignatureValid($certificate, $certData->pubKey)) { + return null; + } + + return $this->resolveCA($certificate, $caList); + } + + private function isSignatureValid(string $contents, string $pupKey): bool + { + $result = openssl_x509_verify($contents, $pupKey); + + if ($result === 1) { + return true; + } + + @openssl_error_string(); + + return false; + } + + /** @param array $caList */ + private function resolveCA(string $certificate, array $caList): CA + { + foreach ($caList as $index => $contents) { + $data = $this->extractor->extractRawData($contents, $index, true); + $this->validateCA($data); + + if (! $this->isSignatureValid($certificate, $data->pubKey)) { + continue; + } + + // Check if self signed, otherwise resolve it's parent + if (! $this->isSignatureValid($contents, $data->pubKey)) { + // THIS issuer cannot be the parent of another parent, so remove it + // from the list. This speeds-up the resolving process. + unset($caList[$index]); + + $this->resolveCA($contents, $caList); + } + + return new CA($contents); + } + + throw new UnableToResolveParent($this->extractor->extractRawData($certificate)->commonName); + } + + private function validateCA(X509Info $data): void + { + if (! isset($data->allFields['extensions']['basicConstraints']) + || mb_stripos((string) $data->allFields['extensions']['basicConstraints'], 'CA:TRUE') === false + ) { + throw new MissingCAExtension($data->allFields['subject']['commonName']); + } + } +} diff --git a/src/CertificateValidator.php b/src/CertificateValidator.php new file mode 100644 index 0000000..ef301d7 --- /dev/null +++ b/src/CertificateValidator.php @@ -0,0 +1,179 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use Pdp\Domain; +use Rollerworks\Component\PdbSfBridge\PdpManager as PublicSuffixManager; +use Rollerworks\Component\X509Validator\Violation\CertificateHasExpired; +use Rollerworks\Component\X509Validator\Violation\GlobalWildcard; +use Rollerworks\Component\X509Validator\Violation\UnsupportedDomain; +use Rollerworks\Component\X509Validator\Violation\UnsupportedPurpose; +use Rollerworks\Component\X509Validator\Violation\WeakSignatureAlgorithm; + +class CertificateValidator +{ + final public const PURPOSE_SMIME = 'S/MIME'; + final public const PURPOSE_SMIME_SIGNING = 'S/MIME signing'; + final public const PURPOSE_SMIME_ENCRYPTION = 'S/MIME encryption'; + + final public const PURPOSE_SSL_CLIENT = 'SSL client'; + final public const PURPOSE_SSL_SERVER = 'SSL server'; + + private readonly X509DataExtractor $extractor; + private readonly CAResolver $caResolver; + + /** + * @param X509DataExtractor|null $dataExtractor This should be reused by the validators + * to allow better caching + * @param CAResolver|null $caResolver Use a custom CAResolver that stores CAs + */ + public function __construct( + private readonly PublicSuffixManager $suffixManager, + X509DataExtractor $dataExtractor = null, + CAResolver $caResolver = null + ) { + $this->extractor = $dataExtractor ?? new X509DataExtractor(); + $this->caResolver = $caResolver ?? new CAResolverImpl(); + } + + /** + * @param array $caList + * + * @throws Violation + */ + public function validateCertificate(string $certificate, array $caList = [], bool $allowWeakAlgorithm = false): void + { + $data = $this->extractRawData($certificate); + + $this->validateNotExpired($data->validTo); + $this->validateDomainsWildcard($data->domains); + + if (! $allowWeakAlgorithm) { + $this->validateSignatureAlgorithm($data->allFields['signatureTypeLN']); + } + + // Don't skip this stop when the CA list is empty, as CA's should still be valid. + $this->caResolver->resolve($certificate, $caList); + } + + protected function extractRawData(string $contents): X509Info + { + return $this->extractor->extractRawData($contents); + } + + private function validateNotExpired(\DateTimeInterface $validTo): void + { + if ($validTo < $this->getNow()) { + throw new CertificateHasExpired($validTo); + } + } + + private function validateSignatureAlgorithm(string $signatureType): void + { + $normSignatureType = mb_strtolower((string) preg_replace('/(WithRSAEncryption$)|(^ecdsa-with-)/i', '', $signatureType)); + + // While sha224 is considered the same as sha256 it's no longer part of TLS 1.3 + if (\in_array($normSignatureType, ['none', 'md2', 'md5', 'sha1', 'sha224', ''], true)) { + throw new WeakSignatureAlgorithm('SHA256', $signatureType); + } + } + + /** @param array $domains */ + private function validateDomainsWildcard(array $domains): void + { + $rules = $this->suffixManager->getPublicSuffixList(); + + foreach ($domains as $domain) { + if (! str_contains($domain, '*')) { + continue; + } + + if ($domain === '*') { + throw new GlobalWildcard($domain, '*'); + } + + $domainInfo = $rules->resolve(Domain::fromIDNA2008($domain)); + + if (! $domainInfo->suffix()->isKnown()) { + return; + } + + $publicSuffix = $domainInfo->suffix()->toString(); + + if (rtrim(mb_substr($domainInfo->toString(), 0, -mb_strlen($publicSuffix)), '.') === '*') { + throw new GlobalWildcard($domain, $publicSuffix); + } + } + } + + /** @param self::PURPOSE_* ...$requiredPurpose */ + public function validateCertificatePurpose(string $certificate, string ...$requiredPurpose): void + { + $requiredPurposes = array_fill_keys($requiredPurpose, true); + + if (isset($requiredPurposes[self::PURPOSE_SMIME])) { + unset($requiredPurposes[self::PURPOSE_SMIME]); + + $requiredPurposes['S/MIME signing'] = true; + $requiredPurposes['S/MIME encryption'] = true; + } + + $purposes = []; + + foreach ($this->extractRawData($certificate)->allFields['purposes'] as $purpose) { + $purposes[$purpose[2]] = $purpose[0]; + } + + foreach ($requiredPurposes as $requirement => $v) { + if (($purposes[$requirement] ?? false) === false) { + throw new UnsupportedPurpose($requirement); + } + } + } + + public function validateCertificateHost(string $certificate, string $hostPattern): void + { + $this->validateCertificatePurpose($certificate, self::PURPOSE_SSL_SERVER); + + $data = $this->extractRawData($certificate); + + foreach ($data->domains as $value) { + if (preg_match('#^' . str_replace(['.', '*'], ['\.', '[^.]*'], (string) $value) . '$#', $hostPattern)) { + return; + } + } + + throw new UnsupportedDomain($hostPattern, ...$data->domains); + } + + /** + * Allows to use a custom callable validator which receives the Certificate + * information for advanced validation. + * + * Should throw an custom exception on failure. + * + * @param callable(X509Info, string, $this): void $validator {X509Info object, certificate, $this} + */ + public function validateCertificateSupport(string $certificate, callable $validator): void + { + $data = $this->extractRawData($certificate); + $validator($data, $certificate, $this); + } + + /** @internal used for testing */ + protected function getNow(): \DateTimeImmutable + { + return new \DateTimeImmutable(); + } +} diff --git a/src/KeyValidator.php b/src/KeyValidator.php new file mode 100644 index 0000000..7b6a713 --- /dev/null +++ b/src/KeyValidator.php @@ -0,0 +1,105 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use ParagonIE\HiddenString\HiddenString; +use Rollerworks\Component\X509Validator\Violation\CertificateMismatch; +use Rollerworks\Component\X509Validator\Violation\KeyBitsTooLow; +use Rollerworks\Component\X509Validator\Violation\PublicKeyMismatch; +use Rollerworks\Component\X509Validator\Violation\UnprocessableKey; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; + +class KeyValidator +{ + final public const MINIMUM_BIT_COUNT = 2048; + + /** + * Validates if the provided private and certificate pair match. + * + * Internally this check if the public-key of the private-key + * matches with the public key of the certificate. And Then performs + * an additional check to ensure the key was not tempered with. + * + * @param HiddenString|string $privateKey Private-key as PEM X509. Use HiddenString to prevent leaking + * sensitive information + * @param string $certificate Certificate as PEM X509 format string + * + * @throws UnprocessablePEM when the data cannot be parsed or processed + * @throws PublicKeyMismatch when the public-keys don't match + * @throws CertificateMismatch when the private doesn't match the certificate + * @throws KeyBitsTooLow when the private bits count is less than $minimumBitCount + */ + public function validate(HiddenString | string $privateKey, string $certificate, int $minimumBitCount = self::MINIMUM_BIT_COUNT): void + { + $certR = @openssl_x509_read($certificate); + + if ($certR === false) { + throw new UnprocessablePEM(''); + } + + $pupKey = openssl_pkey_get_public($certR); + + if (! $pupKey) { + throw new UnprocessableKey('Unable to encrypt data, invalid key provided?'); + } + + $key = $privateKey instanceof HiddenString ? $privateKey->getString() : $privateKey; + + try { + $privateR = @openssl_pkey_get_private($key); + + if ($privateR === false) { + throw new UnprocessableKey('Unable to read private key-data, invalid key provided?'); + } + + if (! @openssl_x509_check_private_key($certR, $privateR)) { + throw new PublicKeyMismatch(); + } + + // Note: technically it's rather difficult to replace the public-key + // in a private-key (if not impossible?) yet openssl_x509_check_private_key() does + // not provide full protection, so we use this additional check to prevent spoofing. + + // @codeCoverageIgnoreStart + + $original = "I just wanna tell you how I'm feeling\nGotta make you understand"; + $encrypted = ''; + + if (! @openssl_public_encrypt($original, $encrypted, $pupKey, \OPENSSL_PKCS1_OAEP_PADDING)) { + throw new UnprocessableKey('Unable to encrypt data, invalid key provided?'); + } + + if (! @openssl_private_decrypt($encrypted, $decrypted, $privateR, \OPENSSL_PKCS1_OAEP_PADDING) || $decrypted !== $original) { + throw new CertificateMismatch(); + } + + $details = @openssl_pkey_get_details($privateR); + + if ($details === false) { + throw new UnprocessableKey('Unable to read private key-data.'); + } + // @codeCoverageIgnoreEnd + + if ($details['bits'] < $minimumBitCount) { + throw new KeyBitsTooLow($minimumBitCount, $details['bits']); + } + } finally { + if ($privateKey instanceof HiddenString) { + sodium_memzero($key); + } + + unset($key, $privateR, $pupKey, $certR); + } + } +} diff --git a/src/OCSPValidator.php b/src/OCSPValidator.php new file mode 100644 index 0000000..f0769ef --- /dev/null +++ b/src/OCSPValidator.php @@ -0,0 +1,122 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use Ocsp\CertificateInfo; +use Ocsp\CertificateLoader; +use Ocsp\Exception\Exception as OcspException; +use Ocsp\Ocsp; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Rollerworks\Component\X509Validator\Violation\CertificateIsRevoked; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class OCSPValidator +{ + private readonly X509DataExtractor $extractor; + private readonly CAResolver $caResolver; + private readonly LoggerInterface $logger; + private readonly Ocsp $ocsp; + + private CertificateLoader $certificateLoader; + private CertificateInfo $certificateInfo; + + /** + * @param HttpClientInterface|null $httpClient If not provided will try to use the best possible + * HttpClient adapter + * @param LoggerInterface|null $logger A logger for recording warnings and errors + * @param CAResolver|null $caResolver Use a custom CAResolver that stores CAs + * @param X509DataExtractor|null $dataExtractor This should be reused by the validators + * to allow better caching + * @param Ocsp|null $ocsp Only injected for testing + */ + public function __construct( + HttpClientInterface $httpClient = null, + LoggerInterface $logger = null, + CAResolver $caResolver = null, + X509DataExtractor $dataExtractor = null, + Ocsp $ocsp = null + ) { + $this->extractor = $dataExtractor ?? new X509DataExtractor(); + $this->httpClient = $httpClient ?? HttpClient::create(); + $this->logger = $logger ?? new NullLogger(); + $this->caResolver = $caResolver ?? new CAResolverImpl(); + $this->ocsp = $ocsp ?? new Ocsp(); + + $this->certificateLoader = new CertificateLoader(); + $this->certificateInfo = new CertificateInfo(); + } + + /** + * @param array $caList + * + * @throws CertificateIsRevoked + */ + public function validateStatus(string $certificate, array $caList = []): void + { + $data = $this->extractor->extractRawData($certificate); + $ca = $this->caResolver->resolve($certificate, $caList); + + // If there is no CA, no point in validating the OCSP. + // Don't skip this stop when the CA list is empty, as CA's should still be valid. + if ($ca === null) { + return; + } + + $certificateSeq = $this->certificateLoader->fromString($certificate); + $issuerCertificate = $this->certificateLoader->fromString($ca->getContents()); + + $ocspResponderUrl = $this->certificateInfo->extractOcspResponderUrl($certificateSeq); + + if ($ocspResponderUrl === '') { + $this->logger->debug('No OCSP found for certificate.', ['data' => $data->allFields]); + + return; + } + + $requestInfo = $this->certificateInfo->extractRequestInfo($certificateSeq, $issuerCertificate); + $requestBody = $this->ocsp->buildOcspRequestBodySingle($requestInfo); + + try { + $response = $this->getResponse($ocspResponderUrl, $requestBody); + + if ($response->getStatusCode() !== 200 || $response->getHeaders()['content-type'][0] !== Ocsp::OCSP_RESPONSE_MEDIATYPE) { + $this->logger->warning('Unable to check OCSP status.', ['response' => $response]); + + return; + } + + $ocspResponse = $this->ocsp->decodeOcspResponseSingle($response->getContent()); + + if ($ocspResponse->isRevoked()) { + throw new CertificateIsRevoked($ocspResponse->getRevokedOn(), $ocspResponse->getRevocationReason(), $ocspResponse->getCertificateSerialNumber()); + } + } catch (OcspException | ExceptionInterface $exception) { + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + } + } + + private function getResponse(string $ocspResponderUrl, string $requestBody): ResponseInterface + { + return $this->httpClient->request('POST', $ocspResponderUrl, [ + 'body' => $requestBody, + 'headers' => [ + 'Content-Type' => Ocsp::OCSP_REQUEST_MEDIATYPE, + ], + ]); + } +} diff --git a/src/TranslatableArgument.php b/src/TranslatableArgument.php new file mode 100644 index 0000000..a3423a6 --- /dev/null +++ b/src/TranslatableArgument.php @@ -0,0 +1,64 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @internal + */ +final class TranslatableArgument implements TranslatableInterface +{ + /** @param array $parameters */ + public function __construct( + private string $message, + private array $parameters = [], + private string $domain = 'validators', + ) {} + + public function getMessage(): string + { + return $this->message; + } + + /** @return array */ + public function getParameters(): array + { + return $this->parameters; + } + + /** @return array */ + public function __serialize(): array + { + return [ + 'message' => $this->message, + 'parameters' => $this->parameters, + 'domain' => $this->domain, + ]; + } + + /** @param array $data */ + public function __unserialize(array $data): void + { + $this->message = $data['message']; + $this->parameters = $data['parameters']; + $this->domain = $data['domain']; + } + + public function trans(TranslatorInterface $translator, string $locale = null): string + { + return $translator->trans($this->getMessage(), $this->getParameters(), $this->domain, $locale); + } +} diff --git a/src/Violation.php b/src/Violation.php new file mode 100644 index 0000000..317dadf --- /dev/null +++ b/src/Violation.php @@ -0,0 +1,42 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +abstract class Violation extends \InvalidArgumentException implements TranslatableInterface +{ + abstract public function getTranslatorMsg(): string; + + /** @return array */ + public function getParameters(): array + { + return []; + } + + /** @return array */ + public function __debugInfo(): array + { + return [ + 'message' => $this->getTranslatorMsg(), + 'parameters' => $this->getParameters(), + ]; + } + + public function trans(TranslatorInterface $translator, string $locale = null): string + { + return $translator->trans($this->getMessage(), $this->getParameters(), 'validators', $locale); + } +} diff --git a/src/Violation/CertificateHasExpired.php b/src/Violation/CertificateHasExpired.php new file mode 100644 index 0000000..8beabc5 --- /dev/null +++ b/src/Violation/CertificateHasExpired.php @@ -0,0 +1,40 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class CertificateHasExpired extends Violation +{ + private readonly \DateTimeInterface $expiredOn; + + public function __construct(\DateTimeInterface $expiredOn) + { + parent::__construct(sprintf('The certificate has expired on "%s"', $expiredOn->format(\DATE_RFC3339))); + + $this->expiredOn = $expiredOn; + } + + public function getTranslatorMsg(): string + { + return 'This certificate has expired on { expired_on, date, short }.'; + } + + public function getParameters(): array + { + return [ + 'expired_on' => $this->expiredOn, + ]; + } +} diff --git a/src/Violation/CertificateIsRevoked.php b/src/Violation/CertificateIsRevoked.php new file mode 100644 index 0000000..ae10ef7 --- /dev/null +++ b/src/Violation/CertificateIsRevoked.php @@ -0,0 +1,96 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Ocsp\Response; +use Ocsp\Response as OscpResponse; +use Rollerworks\Component\X509Validator\TranslatableArgument; +use Rollerworks\Component\X509Validator\Violation; + +final class CertificateIsRevoked extends Violation +{ + // https://security.stackexchange.com/questions/174327/definitions-for-crl-reasons + // + // - unspecified: can be used to revoke certificates for reasons other than the specific codes. + // - keyCompromise: is used in revoking an end-entity certificate; it indicates that it is known or suspected that the subject's private key, or other aspects of the subject validated in the certificate, have been compromised. + // - cACompromise: is used in revoking a CA-certificate; it indicates that it is known or suspected that the subject's private key, or other aspects of the subject validated in the certificate, have been compromised. + // - affiliationChanged: indicates that the subject's name or other information in the certificate has been modified but there is no cause to suspect that the private key has been compromised. + // - superseded: indicates that the certificate has been superseded but there is no cause to suspect that the private key has been compromised. + // - cessationOfOperation: indicates that the certificate is no longer needed for the purpose for which it was issued but there is no cause to suspect that the private key has been compromised. + // - certificateHold: A temporary revocation that indicates that a CA will not vouch for a certificate at a specific point in time. Once a certificate is revoked with a CertificateHold reason code, the certificate can then be revoked with another Reason Code, or unrevoked and returned to use. + // - removeFromCRL: If a certificate is revoked with the CertificateHold reason code, it is possible to "unrevoke" a certificate. The unrevoking process still lists the certificate in the CRL, but with the reason code set to RemoveFromCRL. + // - privilegeWithdrawn: indicates that a certificate (public-key or attribute certificate) was revoked because a privilege contained within that certificate has been withdrawn. + // - aACompromise: indicates that it is known or suspected that aspects of the AA validated in the attribute certificate have been compromised. + + private const REVOCATION_REASON = [ + Response::REVOCATIONREASON_UNSPECIFIED => 'unspecified', + Response::REVOCATIONREASON_KEYCOMPROMISE => 'keyCompromise', + Response::REVOCATIONREASON_CACOMPROMISE => 'cACompromise', + Response::REVOCATIONREASON_AFFILIATIONCHANGED => 'affiliationChanged', + Response::REVOCATIONREASON_SUPERSEDED => 'superseded', + Response::REVOCATIONREASON_CESSATIONOFOPERATION => 'cessationOfOperation', + Response::REVOCATIONREASON_CERTIFICATEHOLD => 'certificateHold', + Response::REVOCATIONREASON_REMOVEFROMCRL => 'removeFromCRL', + Response::REVOCATIONREASON_PRIVILEGEWITHDRAWN => 'privilegeWithdrawn', + Response::REVOCATIONREASON_AACOMPROMISE => 'aACompromise', + ]; + + private const TRANSLATOR_ID = [ + 'unspecified' => 'unspecified (no specific reason was given).', + 'keyCompromise' => 'the private key associated with the certificate has been compromised.', + 'cACompromise' => 'the CA\'s private key is has been compromised and is in the possession of an unauthorized individual. When a CA\'s private key is revoked, this results in all certificates issued by the CA that are signed using the private key associated with the revoked certificate being considered revoked.', + 'affiliationChanged' => 'the user has terminated their relationship with the organization indicated in the Distinguished Name attribute of the certificate. This revocation code is typically used when an individual is terminated or has resigned from an organization.', + 'superseded' => 'a replacement certificate has been issued to a user.', + 'cessationOfOperation' => 'the CA is decommissioned, no longer to be used.', + 'certificateHold' => 'the certificate is currently on hold, try again later', + 'removeFromCRL' => 'certificate revocation is removed', // This might possible not be an error + 'privilegeWithdrawn' => 'the certificate (public-key or attribute certificate) was revoked because a privilege contained within that certificate has been withdrawn.', + 'aACompromise' => 'it is known or suspected that aspects of the AA validated in the attribute certificate have been compromised.', + ]; + + private readonly ?\DateTimeInterface $revokedOn; + private readonly ?int $reason; + private readonly string $serial; + + public function __construct(?\DateTimeInterface $revokedOn, ?int $reason, string $serialNumber, public ?OscpResponse $ocspResponse = null) + { + parent::__construct( + sprintf( + 'The certificate with serialNumber "%s" is revoked on "%s" due to reason "%s".', + $serialNumber, + $revokedOn ? $revokedOn->format(\DATE_RFC3339) : 'no-date', + $reason + ) + ); + + $this->revokedOn = $revokedOn; + $this->reason = $reason; + $this->serial = $serialNumber; + } + + public function getTranslatorMsg(): string + { + return 'The certificate with serial-number "{serial}" was marked as revoked on { revoked_on, date, short } with reason: ({reason_code}) {reason}.'; + } + + public function getParameters(): array + { + return [ + 'revoked_on' => $this->revokedOn, + 'reason_code' => (self::REVOCATION_REASON[$this->reason] ?? 'unspecified'), + 'reason' => new TranslatableArgument(self::TRANSLATOR_ID[self::REVOCATION_REASON[$this->reason] ?? 'unspecified']), + 'serial' => $this->serial, + ]; + } +} diff --git a/src/Violation/CertificateMismatch.php b/src/Violation/CertificateMismatch.php new file mode 100644 index 0000000..3ca08fe --- /dev/null +++ b/src/Violation/CertificateMismatch.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class CertificateMismatch extends Violation +{ + public function __construct() + { + parent::__construct('The certificate does not match with the provided private-key.'); + } + + public function getTranslatorMsg(): string + { + return 'The certificate does not match with the provided private-key.'; + } +} diff --git a/src/Violation/ExpectedLeafCertificate.php b/src/Violation/ExpectedLeafCertificate.php new file mode 100644 index 0000000..1a9acd8 --- /dev/null +++ b/src/Violation/ExpectedLeafCertificate.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class ExpectedLeafCertificate extends Violation +{ + public function __construct() + { + parent::__construct('The certificate is CA certificate where a leave (CA:false) certificate was expected.'); + } + + public function getTranslatorMsg(): string + { + return 'The certificate with common-name "{common_name}" contains a CA extension. Expected a leaf certificate.'; + } +} diff --git a/src/Violation/GlobalWildcard.php b/src/Violation/GlobalWildcard.php new file mode 100644 index 0000000..4de3a15 --- /dev/null +++ b/src/Violation/GlobalWildcard.php @@ -0,0 +1,53 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class GlobalWildcard extends Violation +{ + private readonly string $provided; + private readonly string $suffixPattern; + + public function __construct(string $provided, string $suffixPattern) + { + parent::__construct( + sprintf( + 'The certificate supported domain "%s" contains a global wildcard with suffix pattern "%s"', + $provided, + $suffixPattern + ) + ); + + $this->provided = $provided; + $this->suffixPattern = $suffixPattern; + } + + public function getTranslatorMsg(): string + { + if ($this->suffixPattern === '*') { + return 'The certificate host "{provided}" contains an invalid global-wildcard pattern.'; + } + + return 'The certificate host "{provided}" contains an invalid public-suffix wildcard pattern "{suffix_pattern}".'; + } + + public function getParameters(): array + { + return [ + 'provided' => $this->provided, + 'suffix_pattern' => $this->suffixPattern, + ]; + } +} diff --git a/src/Violation/KeyBitsTooLow.php b/src/Violation/KeyBitsTooLow.php new file mode 100644 index 0000000..80cf34f --- /dev/null +++ b/src/Violation/KeyBitsTooLow.php @@ -0,0 +1,43 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class KeyBitsTooLow extends Violation +{ + private readonly int $expected; + private readonly int $provided; + + public function __construct(int $expected, int $provided) + { + parent::__construct(sprintf('Private-key bits size %d lower than required %d.', $provided, $expected)); + + $this->expected = $expected; + $this->provided = $provided; + } + + public function getTranslatorMsg(): string + { + return 'The private-key bits-size {provided} is too low. Expected at least {expected} bits.'; + } + + public function getParameters(): array + { + return [ + 'expected' => $this->expected, + 'provided' => $this->provided, + ]; + } +} diff --git a/src/Violation/MissingCAExtension.php b/src/Violation/MissingCAExtension.php new file mode 100644 index 0000000..2f15c4f --- /dev/null +++ b/src/Violation/MissingCAExtension.php @@ -0,0 +1,34 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class MissingCAExtension extends Violation +{ + public function __construct(private readonly string $name) + { + parent::__construct('Certificate does not contain required "CA:true" in "extensions.basicExtension".'); + } + + public function getTranslatorMsg(): string + { + return 'tls.violation.certificate_is_ca'; + } + + public function getParameters(): array + { + return ['common_name' => $this->name]; + } +} diff --git a/src/Violation/PublicKeyMismatch.php b/src/Violation/PublicKeyMismatch.php new file mode 100644 index 0000000..2547224 --- /dev/null +++ b/src/Violation/PublicKeyMismatch.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class PublicKeyMismatch extends Violation +{ + public function __construct() + { + parent::__construct('The public-key of the certificate does not match with the provided private-key.'); + } + + public function getTranslatorMsg(): string + { + return 'The certificate public-key does not match with the private-key "public-key" data.'; + } +} diff --git a/src/Violation/ToManyCAsProvided.php b/src/Violation/ToManyCAsProvided.php new file mode 100644 index 0000000..b3f4eb9 --- /dev/null +++ b/src/Violation/ToManyCAsProvided.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class ToManyCAsProvided extends Violation +{ + public function __construct() + { + parent::__construct('To many CAs were provided. A maximum of 3 is accepted.'); + } + + public function getTranslatorMsg(): string + { + return 'tls.violation.to_many_ca_provided'; + } +} diff --git a/src/Violation/UnableToResolveParent.php b/src/Violation/UnableToResolveParent.php new file mode 100644 index 0000000..3296a03 --- /dev/null +++ b/src/Violation/UnableToResolveParent.php @@ -0,0 +1,38 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class UnableToResolveParent extends Violation +{ + private readonly string $name; + + public function __construct(string $name, int $code = 1) + { + parent::__construct(sprintf('Unable to resolve the parent CA of certificate "%s".', $name), $code); + + $this->name = $name; + } + + public function getTranslatorMsg(): string + { + return 'Unable to resolve the CA of certificate "{name}".'; + } + + public function getParameters(): array + { + return ['name' => $this->name]; + } +} diff --git a/src/Violation/UnprocessableKey.php b/src/Violation/UnprocessableKey.php new file mode 100644 index 0000000..636d1c6 --- /dev/null +++ b/src/Violation/UnprocessableKey.php @@ -0,0 +1,29 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class UnprocessableKey extends Violation +{ + public function __construct(string $message = '') + { + parent::__construct($message, 0, new \Error(openssl_error_string() ?: 'Unknown error', 1)); + } + + public function getTranslatorMsg(): string + { + return 'Unable to process PEM X.509 data of private key "{name}". Only PEM encoded X.509 files are supported.'; + } +} diff --git a/src/Violation/UnprocessablePEM.php b/src/Violation/UnprocessablePEM.php new file mode 100644 index 0000000..ee6ca16 --- /dev/null +++ b/src/Violation/UnprocessablePEM.php @@ -0,0 +1,38 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +/** + * This exception class is used for when the data cannot be processed or parse. + */ +final class UnprocessablePEM extends Violation +{ + public function __construct(private readonly string $certName, string $contents = '') + { + $previous = $contents !== '' ? new \InvalidArgumentException($contents) : null; + parent::__construct('', 0, new \Error((string) openssl_error_string(), 1, $previous)); + } + + public function getTranslatorMsg(): string + { + return 'Unable to process PEM X.509 data of certificate "{name}". Only PEM encoded X.509 files are supported.'; + } + + public function getParameters(): array + { + return ['name' => $this->certName]; + } +} diff --git a/src/Violation/UnsupportedDomain.php b/src/Violation/UnsupportedDomain.php new file mode 100644 index 0000000..a47ffba --- /dev/null +++ b/src/Violation/UnsupportedDomain.php @@ -0,0 +1,44 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class UnsupportedDomain extends Violation +{ + private readonly string $requiredPattern; + /** @var array */ + private readonly array $supported; + + public function __construct(string $requiredPattern, string ...$supported) + { + parent::__construct(sprintf("The provided domain-names are not supported by required pattern. Required: '%s'\nProvided: '%s'.", $requiredPattern, implode("', '", $supported))); + + $this->requiredPattern = $requiredPattern; + $this->supported = $supported; + } + + public function getTranslatorMsg(): string + { + return 'This certificate should support host pattern "{required_pattern}". But only the following patterns are supported: {supported}'; + } + + public function getParameters(): array + { + return [ + 'required_pattern' => $this->requiredPattern, + 'supported' => implode(', ', $this->supported), + ]; + } +} diff --git a/src/Violation/UnsupportedPurpose.php b/src/Violation/UnsupportedPurpose.php new file mode 100644 index 0000000..dfd893c --- /dev/null +++ b/src/Violation/UnsupportedPurpose.php @@ -0,0 +1,41 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\TranslatableArgument; +use Rollerworks\Component\X509Validator\Violation; + +final class UnsupportedPurpose extends Violation +{ + private readonly string $requiredPurpose; + + public function __construct(string $requiredPurpose) + { + parent::__construct(sprintf('Certificate does not support purpose: %s.', $requiredPurpose)); + + $this->requiredPurpose = $requiredPurpose; + } + + public function getTranslatorMsg(): string + { + return 'This certificate does not support the purpose: {required_purpose}'; + } + + public function getParameters(): array + { + return [ + 'required_purpose' => new TranslatableArgument($this->requiredPurpose, domain: 'messages'), + ]; + } +} diff --git a/src/Violation/WeakSignatureAlgorithm.php b/src/Violation/WeakSignatureAlgorithm.php new file mode 100644 index 0000000..0c6c13c --- /dev/null +++ b/src/Violation/WeakSignatureAlgorithm.php @@ -0,0 +1,43 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Violation; + +use Rollerworks\Component\X509Validator\Violation; + +final class WeakSignatureAlgorithm extends Violation +{ + private readonly string $expected; + private readonly string $provided; + + public function __construct(string $expected, string $provided) + { + parent::__construct(sprintf('Certificate signature is too weak, expected at least "%s" but got "%s"', $expected, $provided)); + + $this->expected = $expected; + $this->provided = $provided; + } + + public function getTranslatorMsg(): string + { + return 'This certificate was signed using the weak "{provided}" algorithm. Expected at least algorithm "{expected}".".'; + } + + public function getParameters(): array + { + return [ + 'expected' => $this->expected, + 'provided' => $this->provided, + ]; + } +} diff --git a/src/X509DataExtractor.php b/src/X509DataExtractor.php new file mode 100644 index 0000000..e29b490 --- /dev/null +++ b/src/X509DataExtractor.php @@ -0,0 +1,148 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +use ParagonIE\HiddenString\HiddenString; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; + +final class X509DataExtractor +{ + private ?string $hash = null; + private ?X509Info $fields = null; + + public function extractRawData(string $contents, string $name = '', bool $withPublicKey = false): X509Info + { + $hash = hash('sha256', $contents); + + // The same cert information is likely to be validated multiple times + // So keep a local cache to speed-up the parsing process a little. + if ($hash === $this->hash && isset($this->fields)) { + return $this->fields; + } + + $x509Read = @openssl_x509_read($contents); + + if ($x509Read === false) { + throw new UnprocessablePEM($name, $contents); + } + + // @codeCoverageIgnoreStart + $rawData = @openssl_x509_parse($x509Read, false); + + if ($rawData === false) { + throw new UnprocessablePEM($name, $contents); + } + + try { + $fingerprint = openssl_x509_fingerprint($x509Read, $rawData['signatureTypeSN']) ?: ''; + } catch (\Throwable) { + $fingerprint = ''; + } + + if ($withPublicKey) { + $pubKeyRead = openssl_pkey_get_public($x509Read); + + if ($pubKeyRead === false) { + throw new UnprocessablePEM($name, $contents); + } + + $pubKey = openssl_pkey_get_details($pubKeyRead) ?: []; + + unset($pubKeyRead, $x509Read); + } else { + $pubKey = []; + } + // @codeCoverageIgnoreEnd + + $altNames = $this->getAltNames($rawData); + $rawData += [ + '_commonName' => trim((string) $rawData['subject']['commonName']), + '_altNames' => $altNames, + '_emails' => $altNames['rfc822'] ?? [], + '_signatureAlgorithm' => $rawData['signatureTypeSN'], + '_fingerprint' => $fingerprint, + '_validTo' => new \DateTimeImmutable('@' . $rawData['validTo_time_t']), + '_validFrom' => new \DateTimeImmutable('@' . $rawData['validFrom_time_t']), + '_pubKey' => $pubKey['key'] ?? '', + ]; + + $rawData['_domains'] = array_merge($rawData['_altNames']['dns'] ?? [], $rawData['_altNames']['ip address'] ?? []); + $rawData['_alt_domains'] = $rawData['_domains']; + $rawData['_domains'][] = $rawData['_commonName']; + + // Remove any duplicates and ensure the keys are incremental. + $rawData['_domains'] = array_unique($rawData['_domains']); + + $this->hash = $hash; + $this->fields = new X509Info($rawData); + + return $this->fields; + } + + /** + * @param array $rawData + * + * @return array> + */ + private function getAltNames(array $rawData): array + { + if (! isset($rawData['extensions']['subjectAltName'])) { + return []; + } + + $altNames = []; + + foreach (explode(',', (string) $rawData['extensions']['subjectAltName']) as $altName) { + [$type, $value] = explode(':', trim($altName), 2); + $altNames[mb_strtolower($type)][] = $value; + } + + return $altNames; + } + + /** @return array */ + public function getPrivateKeyDetails(HiddenString | string $privateKey): array + { + $key = $privateKey instanceof HiddenString ? $privateKey->getString() : $privateKey; + + // @codeCoverageIgnoreStart + try { + $r = openssl_pkey_get_private($key); + + // Note that the KeyValidator will already check if the key is in-fact valid. + // This failure will only happen in exceptional situations. + if ($r === false) { + throw new \RuntimeException('Unable to read private key-data, invalid key provided?'); + } + + $details = openssl_pkey_get_details($r); + + if ($details === false) { + throw new \RuntimeException('Unable to read private key-data. Unknown error.'); + } + } finally { + unset($r); + + if ($privateKey instanceof HiddenString) { + sodium_memzero($key); + } + } + // @codeCoverageIgnoreEnd + + return [ + 'bits' => $details['bits'], + 'type' => $details['type'], + ]; + } +} diff --git a/src/X509Info.php b/src/X509Info.php new file mode 100644 index 0000000..a6344f1 --- /dev/null +++ b/src/X509Info.php @@ -0,0 +1,58 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator; + +final class X509Info +{ + /** @var array */ + public readonly array $altDomains; + /** @var array */ + public readonly array $altNames; + public readonly string $commonName; + /** @var array */ + public readonly array $domains; + /** @var array */ + public readonly array $emails; + public readonly string $fingerprint; + /** @var array */ + public readonly string $pubKey; + public readonly string $signatureAlgorithm; + public readonly \DateTimeImmutable $validFrom; + public readonly \DateTimeImmutable $validTo; + + /** + * Contains all fields, including 'raw' x509 data provided by `openssl_x509_read()`. + * + * Note: Extra fields begin with an underscore. + * + * @var array + */ + public readonly array $allFields; + + public function __construct(array $fields) + { + $this->altDomains = $fields['_alt_domains'] ?? []; + $this->altNames = $fields['_altNames'] ?? []; + $this->commonName = $fields['_commonName'] ?? ''; + $this->domains = $fields['_domains'] ?? []; + $this->emails = $fields['_emails'] ?? []; + $this->fingerprint = $fields['_fingerprint'] ?? ''; + $this->pubKey = $fields['_pubKey'] ?? ''; + $this->signatureAlgorithm = $fields['_signatureAlgorithm'] ?? ''; + $this->validFrom = $fields['_validFrom'] ?? new \DateTimeImmutable('-1 year'); + $this->validTo = $fields['_validTo'] ?? new \DateTimeImmutable('+1 year'); + + $this->allFields = $fields; + } +} diff --git a/tests/CAResolverImplTest.php b/tests/CAResolverImplTest.php new file mode 100644 index 0000000..9318995 --- /dev/null +++ b/tests/CAResolverImplTest.php @@ -0,0 +1,447 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Tests; + +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Rollerworks\Component\X509Validator\CA; +use Rollerworks\Component\X509Validator\CAResolverImpl as CAResolver; +use Rollerworks\Component\X509Validator\Violation\MissingCAExtension; +use Rollerworks\Component\X509Validator\Violation\ToManyCAsProvided; +use Rollerworks\Component\X509Validator\Violation\UnableToResolveParent; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; + +/** + * @internal + */ +final class CAResolverImplTest extends TestCase +{ + use ProphecyTrait; + + #[Test] + public function it_fails_with_incomplete_ca_list(): void + { + $resolver = new CAResolver(); + + $this->expectException(UnableToResolveParent::class); + + $resolver->resolve( + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT, + [ + 'ca1' => <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT, + ] + ); + } + + #[Test] + public function it_fails_with_to_many_cas_provided(): void + { + $resolver = new CAResolver(); + + $this->expectException(ToManyCAsProvided::class); + + $resolver->resolve( + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT, + [ + 'ca1' => <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT, + + 'ca2' => <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT, + 'ca3' => <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT, + 'ca4' => <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT, + ] + ); + } + + #[Test] + public function it_fails_with_invalid_pem(): void + { + $resolver = new CAResolver(); + + $this->expectException(UnprocessablePEM::class); + + $resolver->resolve( + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE666I+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + -----END CERTIFICATE----- + CERT, + [] + ); + } + + #[Test] + public function it_fails_with_invalid_ca(): void + { + $resolver = new CAResolver(); + + $this->expectException(MissingCAExtension::class); + + // Note. This works because validating a certificate signature against itself is always valid + // Even if signed by a CA. Which is why we always check if there is a parent. + $resolver->resolve( + $cert = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT, + [ + 'ca1' => $cert, + ] + ); + } + + #[Test] + public function it_resolves_a_valid_ca_list(): void + { + $ca1 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT; + $ca2 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT; + $certificate = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $resolver = new CAResolver(); + $ca = $resolver->resolve($certificate, [ + 'DigiCert Global Root CA' => $ca2, + 'DigiCert SHA2 Secure Server CA' => $ca1, + ]); + + $intermediateCA = new CA($ca1); + + self::assertEquals($intermediateCA, $ca); + } + + #[Test] + public function it_resolves_a_self_signed_ca(): void + { + $resolver = new CAResolver(); + + self::assertNull( + $resolver->resolve( + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT, + [] + ) + ); + } +} diff --git a/tests/CertificateValidatorTest.php b/tests/CertificateValidatorTest.php new file mode 100644 index 0000000..99d4d46 --- /dev/null +++ b/tests/CertificateValidatorTest.php @@ -0,0 +1,587 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Rollerworks\Component\PdbSfBridge\PdpManager; +use Rollerworks\Component\PdbSfBridge\PdpMockProvider; +use Rollerworks\Component\X509Validator\CertificateValidator; +use Rollerworks\Component\X509Validator\TranslatableArgument; +use Rollerworks\Component\X509Validator\Violation\CertificateHasExpired; +use Rollerworks\Component\X509Validator\Violation\GlobalWildcard; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; +use Rollerworks\Component\X509Validator\Violation\UnsupportedDomain; +use Rollerworks\Component\X509Validator\Violation\UnsupportedPurpose; +use Rollerworks\Component\X509Validator\Violation\WeakSignatureAlgorithm; +use Rollerworks\Component\X509Validator\X509Info; + +/** + * @internal + */ +final class CertificateValidatorTest extends TestCase +{ + private ?CertificateValidator $certificateValidator = null; + private ?PdpManager $pdpManager; + + protected function setUp(): void + { + $this->pdpManager = PdpMockProvider::getPdpManager(); + $this->certificateValidator = new class($this->pdpManager) extends CertificateValidator { + public ?string $now = '2013-05-29T14:12:14.000000+0000'; + + protected function getNow(): \DateTimeImmutable + { + if (isset($this->now)) { + return new \DateTimeImmutable($this->now); + } + + return parent::getNow(); + } + }; + } + + #[Test] + public function validate_certificate_is_actually_readable(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + CERT; + + try { + $this->certificateValidator->validateCertificate($certContents); + + self::fail('Exception was expected.'); + } catch (UnprocessablePEM $e) { + self::assertSame(['name' => ''], $e->getParameters()); + self::assertSame($certContents, $e->getPrevious()->getPrevious()->getMessage()); + } + } + + #[Test] + public function validate_certificate_is_expired(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT; + + $ca = <<<'CA' + -----BEGIN CERTIFICATE----- + MIIDezCCAmOgAwIBAgIJAJn2g4MHmUlvMA0GCSqGSIb3DQEBBQUAMFQxGjAYBgNV + BAMMEVJvbGxlcnNjYXBlcyBDQXYzMRUwEwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQ + BgNVBAcMCVJvdHRlcmRhbTELMAkGA1UEBhMCTkwwHhcNMTQwMzMwMTQzNjM5WhcN + MTgwMzI5MTQzNjM5WjBUMRowGAYDVQQDDBFSb2xsZXJzY2FwZXMgQ0F2MzEVMBMG + A1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQHDAlSb3R0ZXJkYW0xCzAJBgNVBAYT + Ak5MMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9G4MAqOU6tgIw2gJ + tZVul3Ef6W37fK2p8MooXJmFRNs6QGloy8bkbAG6rLrmPlOpG4LT6jDpiUOgk4IL + v0HZr8tSaJCEUaYWYQkc58HqZK0FfVrrzAQC8lVcO2Xl0HehEoPAtVrX+1h2F6/E + 38xzmlbUo2Ileiy6ur0KjCo+p22fd+NIEwvtbd1uySA5GsyzIq0vqpRHJzihgXXU + TIjxdxZqqHjGslT9Ei97XEYErjFrxlwk8lNFUvxE3u2Xhfhy6qNT1CpcPg8pRVHw + IdYqn0ApJPxLchfGjuVmcgmnDeTmBtbNGBPw1dsmswm/nvZC8CiDuqgn6PVIhpio + Eru22wIDAQABo1AwTjAdBgNVHQ4EFgQUAe/6RHDxw475z5c8niR0o4ZiYn0wHwYD + VR0jBBgwFoAUAe/6RHDxw475z5c8niR0o4ZiYn0wDAYDVR0TBAUwAwEB/zANBgkq + hkiG9w0BAQUFAAOCAQEA4VMyvK2I2naw+0rm4wu9rRWOoCYuRRchkE+CvFoUDnQq + CvWKaQApPA2qud4gA+S743GduzSf4jfAe8yGY3oA+bUAnqupF+8l19b6GcMfEop7 + LRkeiSxAVrK2hHxGYMdLXBFqBMS5PaG2LT/m1zjk+j5CJVKAtWHlO8sERSyCqa04 + 2wvjlA/ArnZkt8A56kOFeIK2UBOzTozYmW+D5ZkB41JtzquO7Rty/YhVpuOfCoLX + HuwXgPLW3fDUFmEFnIMqDCxZA5NEc+1QapjBkC8cU4xPKjIE3Ljm4Nhq0I67ipC1 + Jzgsmb7yKoigkH/BZ5sm/spdlz3/eXuEtcC6gLfsPA== + -----END CERTIFICATE----- + CA; + + try { + $this->certificateValidator->now = null; + $this->certificateValidator->validateCertificate($certContents, ['root' => $ca]); + + self::fail('Exception was expected.'); + } catch (CertificateHasExpired $e) { + self::assertEquals(['expired_on' => new \DateTimeImmutable('2018-07-26T13:02:33.000000+0000')], $e->getParameters()); + } + } + + /** @param array $domains */ + #[Test] + #[DataProvider('provideValidate_certificate_host_contains_global_wildcardCases')] + public function validate_certificate_host_contains_global_wildcard(array $domains, string $provided, string $suffixPattern): void + { + $this->certificateValidator = new FakedCertificateValidator($this->pdpManager); + $this->certificateValidator->setFields([ + '_domains' => $domains, + '_validTo' => new \DateTimeImmutable('+1 year'), + 'signatureTypeLN' => 'sha256WithEncryption', + ]); + + try { + $this->certificateValidator->validateCertificate('I am not a CERT', []); + + self::fail('Exception was expected.'); + } catch (GlobalWildcard $e) { + self::assertSame([ + 'provided' => $provided, + 'suffix_pattern' => $suffixPattern, + ], $e->getParameters()); + } + } + + /** @return \Generator, 1: string, 2: string}> */ + public static function provideValidate_certificate_host_contains_global_wildcardCases(): iterable + { + yield [['example.com', '*'], '*', '*']; + yield [['*.doodoodoodoodoodoo.com', '*.com'], '*.com', 'com']; + yield [['*.doodoodoodoodoodoo.com', '*.com'], '*.com', 'com']; + yield [['*.org.ae'], '*.org.ae', 'org.ae']; + yield [['*.org'], '*.org', 'org']; + yield [['*.qld.edu.au'], '*.qld.edu.au', 'qld.edu.au']; + } + + /** @param array $domains */ + #[Test] + #[DataProvider('provideValidate_certificate_host_wildcard_without_known_prefixCases')] + #[DoesNotPerformAssertions] + public function validate_certificate_host_wildcard_without_known_prefix_does_not_fail(array $domains): void + { + $this->certificateValidator = new FakedCertificateValidator($this->pdpManager); + $this->certificateValidator->setFields([ + '_domains' => $domains, + '_validTo' => new \DateTimeImmutable('+1 year'), + 'signatureTypeLN' => 'sha256WithEncryption', + ]); + + $cert = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT; + + $this->certificateValidator->validateCertificate($cert, []); + } + + /** @return \Generator> */ + public static function provideValidate_certificate_host_wildcard_without_known_prefixCases(): iterable + { + yield [['example.com', '*.doodoodooo']]; + yield [['example.com', '*.nope.net']]; + } + + #[Test] + public function validate_certificate_signature_algorithm(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT; + + $ca = <<<'CA' + -----BEGIN CERTIFICATE----- + MIIDezCCAmOgAwIBAgIJAJn2g4MHmUlvMA0GCSqGSIb3DQEBBQUAMFQxGjAYBgNV + BAMMEVJvbGxlcnNjYXBlcyBDQXYzMRUwEwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQ + BgNVBAcMCVJvdHRlcmRhbTELMAkGA1UEBhMCTkwwHhcNMTQwMzMwMTQzNjM5WhcN + MTgwMzI5MTQzNjM5WjBUMRowGAYDVQQDDBFSb2xsZXJzY2FwZXMgQ0F2MzEVMBMG + A1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQHDAlSb3R0ZXJkYW0xCzAJBgNVBAYT + Ak5MMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9G4MAqOU6tgIw2gJ + tZVul3Ef6W37fK2p8MooXJmFRNs6QGloy8bkbAG6rLrmPlOpG4LT6jDpiUOgk4IL + v0HZr8tSaJCEUaYWYQkc58HqZK0FfVrrzAQC8lVcO2Xl0HehEoPAtVrX+1h2F6/E + 38xzmlbUo2Ileiy6ur0KjCo+p22fd+NIEwvtbd1uySA5GsyzIq0vqpRHJzihgXXU + TIjxdxZqqHjGslT9Ei97XEYErjFrxlwk8lNFUvxE3u2Xhfhy6qNT1CpcPg8pRVHw + IdYqn0ApJPxLchfGjuVmcgmnDeTmBtbNGBPw1dsmswm/nvZC8CiDuqgn6PVIhpio + Eru22wIDAQABo1AwTjAdBgNVHQ4EFgQUAe/6RHDxw475z5c8niR0o4ZiYn0wHwYD + VR0jBBgwFoAUAe/6RHDxw475z5c8niR0o4ZiYn0wDAYDVR0TBAUwAwEB/zANBgkq + hkiG9w0BAQUFAAOCAQEA4VMyvK2I2naw+0rm4wu9rRWOoCYuRRchkE+CvFoUDnQq + CvWKaQApPA2qud4gA+S743GduzSf4jfAe8yGY3oA+bUAnqupF+8l19b6GcMfEop7 + LRkeiSxAVrK2hHxGYMdLXBFqBMS5PaG2LT/m1zjk+j5CJVKAtWHlO8sERSyCqa04 + 2wvjlA/ArnZkt8A56kOFeIK2UBOzTozYmW+D5ZkB41JtzquO7Rty/YhVpuOfCoLX + HuwXgPLW3fDUFmEFnIMqDCxZA5NEc+1QapjBkC8cU4xPKjIE3Ljm4Nhq0I67ipC1 + Jzgsmb7yKoigkH/BZ5sm/spdlz3/eXuEtcC6gLfsPA== + -----END CERTIFICATE----- + CA; + + try { + $this->certificateValidator->validateCertificate($certContents, ['root' => $ca]); + + self::fail('Exception was expected.'); + } catch (WeakSignatureAlgorithm $e) { + self::assertSame([ + 'expected' => 'SHA256', + 'provided' => 'sha1WithRSAEncryption', + ], $e->getParameters()); + } + } + + #[Test] + public function validate_certificate_data_is_readable(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + CERT; + + try { + $this->certificateValidator->validateCertificate($certContents); + + self::fail('Exception was expected.'); + } catch (UnprocessablePEM $e) { + self::assertSame($certContents, $e->getPrevious()->getPrevious()->getMessage()); + self::assertSame([ + 'name' => '', + ], $e->getParameters()); + } + } + + #[Test] + public function validate_certificate_purpose_is_not_supported(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + try { + $this->certificateValidator->validateCertificatePurpose($certContents, CertificateValidator::PURPOSE_SMIME); + + self::fail('Exception was expected.'); + } catch (UnsupportedPurpose $e) { + self::assertEquals(['required_purpose' => new TranslatableArgument('S/MIME signing', domain: 'messages')], $e->getParameters()); + } + } + + #[Test] + #[DoesNotPerformAssertions] + public function validate_certificate_purpose_with_supported_purpose_works(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT; + + $this->certificateValidator->validateCertificatePurpose($certContents, CertificateValidator::PURPOSE_SSL_SERVER); + } + + #[Test] + #[DataProvider('provideValidate_certificate_host_is_not_supportedCases')] + public function validate_certificate_host_is_not_supported(string $cert, string $hostPattern, string $supported): void + { + try { + $this->certificateValidator->validateCertificateHost($cert, $hostPattern); + + self::fail('Exception was expected.'); + } catch (UnsupportedDomain $e) { + self::assertSame(['required_pattern' => $hostPattern, 'supported' => $supported], $e->getParameters()); + } + } + + /** @return \Generator */ + public static function provideValidate_certificate_host_is_not_supportedCases(): iterable + { + $cert1 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $cert2 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT; + + yield [$cert1, 'example.com', 'slack.com, *.slack.com']; + yield [$cert1, '*.t.slack.com', 'slack.com, *.slack.com']; + yield [$cert2, 'example.com', 'bop.dev.rollerscapes.net']; + yield [$cert2, 'bob.rollerscapes.com', 'bop.dev.rollerscapes.net']; + yield [$cert2, '*.rollerscapes.com', 'bop.dev.rollerscapes.net']; + } + + #[Test] + #[DoesNotPerformAssertions] + public function validate_certificate_host_is_supported(): void + { + $cert = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $this->certificateValidator->validateCertificateHost($cert, '*.slack.com'); + $this->certificateValidator->validateCertificateHost($cert, 'test.slack.com'); + $this->certificateValidator->validateCertificateHost($cert, 'slack.com'); + } + + #[Test] + public function validate_certificate_support(): void + { + $cert = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT; + + $callback = static function (X509Info $fields, string $certContents, CertificateValidator $validator) use ($cert): never { + self::assertSame($cert, $certContents); + + self::assertArrayHasKey('subject', $fields->allFields); + self::assertArrayHasKey('_domains', $fields->allFields); + + throw new \RuntimeException('No, this is not valid. Or is it?'); + }; + + $this->expectExceptionObject(new \RuntimeException('No, this is not valid. Or is it?')); + + $this->certificateValidator->validateCertificateSupport($cert, $callback); + } +} + +/** + * @internal + */ +final class FakedCertificateValidator extends CertificateValidator +{ + private ?X509Info $fields; + + /** @param array $fields */ + public function setFields(array $fields): void + { + $this->fields = new X509Info($fields); + } + + protected function extractRawData(string $contents): X509Info + { + if (! isset($this->fields)) { + return parent::extractRawData($contents); + } + + return $this->fields; + } +} diff --git a/tests/KeyValidatorTest.php b/tests/KeyValidatorTest.php new file mode 100644 index 0000000..a9fd921 --- /dev/null +++ b/tests/KeyValidatorTest.php @@ -0,0 +1,362 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Tests; + +use ParagonIE\HiddenString\HiddenString; +use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Rollerworks\Component\X509Validator\KeyValidator; +use Rollerworks\Component\X509Validator\Violation\KeyBitsTooLow; +use Rollerworks\Component\X509Validator\Violation\PublicKeyMismatch; +use Rollerworks\Component\X509Validator\Violation\UnprocessableKey; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; + +/** + * @internal + */ +final class KeyValidatorTest extends TestCase +{ + #[Test] + public function it_fails_with_invalid_private_key(): void + { + $validator = new KeyValidator(); + + $this->expectException(UnprocessableKey::class); + $this->expectExceptionMessage('Unable to read private key-data'); + + $validator->validate( + new HiddenString('-----BEGIN RSA PRIVATE KEY-----NOPE NOPE-----END RSA PRIVATE KEY-----'), + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT + ); + } + + #[Test] + public function it_fails_with_invalid_cert(): void + { + $validator = new KeyValidator(); + + $this->expectException(UnprocessablePEM::class); + + $validator->validate( + new HiddenString( + $privateKey = <<<'PRIV_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAxTe++fCJwTyCBEP1aGK4/6aEdWxhHLqQAfkOvEdguWnORBkD + MGjgwqYT9UXrSxhyRPuIbLPRsyW+ei3y0isudNGZOkedM1FzQicL5DLzgx0zA8tM + TkYO7TG3t5C5xx/5W4Znlpl7eag8Wkto8eQNqShpqC0vLJmBZT0WdMoivFIMuZA1 + ghBQN9UyLBinKHzlnK/r3yex5bV2r4dVRWdp8yQvMSmqBUDuLmpHttuQmUPluMoS + MbuW3mPxV5YdjyGanda0IUC7leYCuMxpIzZDeg1Sfu8cJ2Ce8nlypaL8DDhCUBtS + x569eoBUUXBXlVBz2ZFuO0BjmIetJ6xvP/P5CwIDAQABAoIBAEZcy0A1N5C/28tV + y7rAbiyX5m5WipdLYJGzoDRAaxv7yeG14tNkt7v6sOgzV+1k/W/rJhNSXKDD+J9y + wU2Gpn57QWXvowBqMOsLL0zteL/wrQDPiZvrluu9b0SI2B9ZIwgqfc7XV5xiD5ZP + jVOv/8e4aWndJRWOdwH9t4NXkukI5Joc/l0JvLVlteBwJO22JvWp3skBiNBCwP/e + +tx9570QJederODEkf0wPpD4PSMM86GpP5x0+NGfO+fn0AD2adSmOSRnzO769AzH + l3R5Oh2tMFgnyxmLYpa/DL1XAgR6vIPkgJOVkcbg19yps+f35Mi1n9e63QDEB8lI + fkRFtAECgYEA6Wxvd9miW5ts02K34oxm/UWp6trZKthhWQ0J2JDn7dvO6KnyIzpw + cfEv6wRHxtSot/VkV1Qf6YwPKvl8KkYVDXbs9AZ4nzEXp6GSkf2SEGx2h2Gofiwq + DkWRnaI/1kM4ukzW16PiumTd8KQis6V7/2y9Kw1t9u2DyYUv6KfIUAsCgYEA2Era + 4jQ4VQMJBBY8pQN+gMpH+avytvGGHXl/tm6My7LevEZOq00LAhlsa/fwUxI1dXhH + yFXtQIILZw79a1bRWsbfFrkWiC9g0JgNDt/pzds2EsTltVS5OWRMaVcrL3glP8+U + ObW4qzTJiI6m6LKV7hnmaL1fR/NUjWk+fvc/mwECgYAMs3fFP7RT47siLWbwDs+z + zEyYmNvkNu3lGI6GmCvmh2VUx5qDTDS+Hm+LDCqTqRKdH98b2Vn7LUHOBtE4w6N1 + nhj6ljeOAe/VkTcWdoOyHRS9/RRb+S84o5RuzVtH31SA3pl6FlLJ7Z8d7vBscf6z + QUlxxENNglL/bh3TPP3rTQKBgC8LwSZ4s1QSb/CaoaBG7Uo4NYWiGA4g5MoedmAJ + Fcjs5DPRmyT5gg531zR43qZDDKu7eOmjfxKL9sz43rhtTuZO4ZGAutzuaUGWASke + HS3wo4dbmpdhkVRhc5lqI3OUz41cqmIPG9bpiXiRhs6QoboDmjFoF4R/8gE8RiK5 + xvUBAoGACrghAg+GlJZ/Aogx7wK6b1k8rfcpgIoHxOPiqIgyMgevTT6D6w8D0CqI + cEVTZ/fm+EaNuMZvxqSG5f19/obLus+VNXvnMYi3qwFAZ5NhKBen12YhIcaZpOh1 + ZSjeYozDCyRmv76q3sqcLrwxnULIcaK0l255ZczzwiUl39Bqe1o= + -----END RSA PRIVATE KEY----- + PRIV_KEY + ), + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + -----END CERTIFICATE----- + CERT + ); + } + + #[Test] + public function it_fails_with_key_mismatch(): void + { + $validator = new KeyValidator(); + + $this->expectException(PublicKeyMismatch::class); + + $validator->validate( + new HiddenString( + $privateKey = <<<'PRIV_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAxTe++fCJwTyCBEP1aGK4/6aEdWxhHLqQAfkOvEdguWnORBkD + MGjgwqYT9UXrSxhyRPuIbLPRsyW+ei3y0isudNGZOkedM1FzQicL5DLzgx0zA8tM + TkYO7TG3t5C5xx/5W4Znlpl7eag8Wkto8eQNqShpqC0vLJmBZT0WdMoivFIMuZA1 + ghBQN9UyLBinKHzlnK/r3yex5bV2r4dVRWdp8yQvMSmqBUDuLmpHttuQmUPluMoS + MbuW3mPxV5YdjyGanda0IUC7leYCuMxpIzZDeg1Sfu8cJ2Ce8nlypaL8DDhCUBtS + x569eoBUUXBXlVBz2ZFuO0BjmIetJ6xvP/P5CwIDAQABAoIBAEZcy0A1N5C/28tV + y7rAbiyX5m5WipdLYJGzoDRAaxv7yeG14tNkt7v6sOgzV+1k/W/rJhNSXKDD+J9y + wU2Gpn57QWXvowBqMOsLL0zteL/wrQDPiZvrluu9b0SI2B9ZIwgqfc7XV5xiD5ZP + jVOv/8e4aWndJRWOdwH9t4NXkukI5Joc/l0JvLVlteBwJO22JvWp3skBiNBCwP/e + +tx9570QJederODEkf0wPpD4PSMM86GpP5x0+NGfO+fn0AD2adSmOSRnzO769AzH + l3R5Oh2tMFgnyxmLYpa/DL1XAgR6vIPkgJOVkcbg19yps+f35Mi1n9e63QDEB8lI + fkRFtAECgYEA6Wxvd9miW5ts02K34oxm/UWp6trZKthhWQ0J2JDn7dvO6KnyIzpw + cfEv6wRHxtSot/VkV1Qf6YwPKvl8KkYVDXbs9AZ4nzEXp6GSkf2SEGx2h2Gofiwq + DkWRnaI/1kM4ukzW16PiumTd8KQis6V7/2y9Kw1t9u2DyYUv6KfIUAsCgYEA2Era + 4jQ4VQMJBBY8pQN+gMpH+avytvGGHXl/tm6My7LevEZOq00LAhlsa/fwUxI1dXhH + yFXtQIILZw79a1bRWsbfFrkWiC9g0JgNDt/pzds2EsTltVS5OWRMaVcrL3glP8+U + ObW4qzTJiI6m6LKV7hnmaL1fR/NUjWk+fvc/mwECgYAMs3fFP7RT47siLWbwDs+z + zEyYmNvkNu3lGI6GmCvmh2VUx5qDTDS+Hm+LDCqTqRKdH98b2Vn7LUHOBtE4w6N1 + nhj6ljeOAe/VkTcWdoOyHRS9/RRb+S84o5RuzVtH31SA3pl6FlLJ7Z8d7vBscf6z + QUlxxENNglL/bh3TPP3rTQKBgC8LwSZ4s1QSb/CaoaBG7Uo4NYWiGA4g5MoedmAJ + Fcjs5DPRmyT5gg531zR43qZDDKu7eOmjfxKL9sz43rhtTuZO4ZGAutzuaUGWASke + HS3wo4dbmpdhkVRhc5lqI3OUz41cqmIPG9bpiXiRhs6QoboDmjFoF4R/8gE8RiK5 + xvUBAoGACrghAg+GlJZ/Aogx7wK6b1k8rfcpgIoHxOPiqIgyMgevTT6D6w8D0CqI + cEVTZ/fm+EaNuMZvxqSG5f19/obLus+VNXvnMYi3qwFAZ5NhKBen12YhIcaZpOh1 + ZSjeYozDCyRmv76q3sqcLrwxnULIcaK0l255ZczzwiUl39Bqe1o= + -----END RSA PRIVATE KEY----- + PRIV_KEY + ), + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT + ); + } + + #[Test] + #[DoesNotPerformAssertions] + public function it_accepts_valid_data(): void + { + $validator = new KeyValidator(); + $validator->validate( + new HiddenString( + $privateKey = <<<'PRIV_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAxTe++fCJwTyCBEP1aGK4/6aEdWxhHLqQAfkOvEdguWnORBkD + MGjgwqYT9UXrSxhyRPuIbLPRsyW+ei3y0isudNGZOkedM1FzQicL5DLzgx0zA8tM + TkYO7TG3t5C5xx/5W4Znlpl7eag8Wkto8eQNqShpqC0vLJmBZT0WdMoivFIMuZA1 + ghBQN9UyLBinKHzlnK/r3yex5bV2r4dVRWdp8yQvMSmqBUDuLmpHttuQmUPluMoS + MbuW3mPxV5YdjyGanda0IUC7leYCuMxpIzZDeg1Sfu8cJ2Ce8nlypaL8DDhCUBtS + x569eoBUUXBXlVBz2ZFuO0BjmIetJ6xvP/P5CwIDAQABAoIBAEZcy0A1N5C/28tV + y7rAbiyX5m5WipdLYJGzoDRAaxv7yeG14tNkt7v6sOgzV+1k/W/rJhNSXKDD+J9y + wU2Gpn57QWXvowBqMOsLL0zteL/wrQDPiZvrluu9b0SI2B9ZIwgqfc7XV5xiD5ZP + jVOv/8e4aWndJRWOdwH9t4NXkukI5Joc/l0JvLVlteBwJO22JvWp3skBiNBCwP/e + +tx9570QJederODEkf0wPpD4PSMM86GpP5x0+NGfO+fn0AD2adSmOSRnzO769AzH + l3R5Oh2tMFgnyxmLYpa/DL1XAgR6vIPkgJOVkcbg19yps+f35Mi1n9e63QDEB8lI + fkRFtAECgYEA6Wxvd9miW5ts02K34oxm/UWp6trZKthhWQ0J2JDn7dvO6KnyIzpw + cfEv6wRHxtSot/VkV1Qf6YwPKvl8KkYVDXbs9AZ4nzEXp6GSkf2SEGx2h2Gofiwq + DkWRnaI/1kM4ukzW16PiumTd8KQis6V7/2y9Kw1t9u2DyYUv6KfIUAsCgYEA2Era + 4jQ4VQMJBBY8pQN+gMpH+avytvGGHXl/tm6My7LevEZOq00LAhlsa/fwUxI1dXhH + yFXtQIILZw79a1bRWsbfFrkWiC9g0JgNDt/pzds2EsTltVS5OWRMaVcrL3glP8+U + ObW4qzTJiI6m6LKV7hnmaL1fR/NUjWk+fvc/mwECgYAMs3fFP7RT47siLWbwDs+z + zEyYmNvkNu3lGI6GmCvmh2VUx5qDTDS+Hm+LDCqTqRKdH98b2Vn7LUHOBtE4w6N1 + nhj6ljeOAe/VkTcWdoOyHRS9/RRb+S84o5RuzVtH31SA3pl6FlLJ7Z8d7vBscf6z + QUlxxENNglL/bh3TPP3rTQKBgC8LwSZ4s1QSb/CaoaBG7Uo4NYWiGA4g5MoedmAJ + Fcjs5DPRmyT5gg531zR43qZDDKu7eOmjfxKL9sz43rhtTuZO4ZGAutzuaUGWASke + HS3wo4dbmpdhkVRhc5lqI3OUz41cqmIPG9bpiXiRhs6QoboDmjFoF4R/8gE8RiK5 + xvUBAoGACrghAg+GlJZ/Aogx7wK6b1k8rfcpgIoHxOPiqIgyMgevTT6D6w8D0CqI + cEVTZ/fm+EaNuMZvxqSG5f19/obLus+VNXvnMYi3qwFAZ5NhKBen12YhIcaZpOh1 + ZSjeYozDCyRmv76q3sqcLrwxnULIcaK0l255ZczzwiUl39Bqe1o= + -----END RSA PRIVATE KEY----- + PRIV_KEY + ), + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT + ); + } + + #[Test] + #[DoesNotPerformAssertions] + public function it_accepts_hidden_string_data(): void + { + $validator = new KeyValidator(); + $validator->validate( + new HiddenString( + $privateKey = <<<'PRIV_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAxTe++fCJwTyCBEP1aGK4/6aEdWxhHLqQAfkOvEdguWnORBkD + MGjgwqYT9UXrSxhyRPuIbLPRsyW+ei3y0isudNGZOkedM1FzQicL5DLzgx0zA8tM + TkYO7TG3t5C5xx/5W4Znlpl7eag8Wkto8eQNqShpqC0vLJmBZT0WdMoivFIMuZA1 + ghBQN9UyLBinKHzlnK/r3yex5bV2r4dVRWdp8yQvMSmqBUDuLmpHttuQmUPluMoS + MbuW3mPxV5YdjyGanda0IUC7leYCuMxpIzZDeg1Sfu8cJ2Ce8nlypaL8DDhCUBtS + x569eoBUUXBXlVBz2ZFuO0BjmIetJ6xvP/P5CwIDAQABAoIBAEZcy0A1N5C/28tV + y7rAbiyX5m5WipdLYJGzoDRAaxv7yeG14tNkt7v6sOgzV+1k/W/rJhNSXKDD+J9y + wU2Gpn57QWXvowBqMOsLL0zteL/wrQDPiZvrluu9b0SI2B9ZIwgqfc7XV5xiD5ZP + jVOv/8e4aWndJRWOdwH9t4NXkukI5Joc/l0JvLVlteBwJO22JvWp3skBiNBCwP/e + +tx9570QJederODEkf0wPpD4PSMM86GpP5x0+NGfO+fn0AD2adSmOSRnzO769AzH + l3R5Oh2tMFgnyxmLYpa/DL1XAgR6vIPkgJOVkcbg19yps+f35Mi1n9e63QDEB8lI + fkRFtAECgYEA6Wxvd9miW5ts02K34oxm/UWp6trZKthhWQ0J2JDn7dvO6KnyIzpw + cfEv6wRHxtSot/VkV1Qf6YwPKvl8KkYVDXbs9AZ4nzEXp6GSkf2SEGx2h2Gofiwq + DkWRnaI/1kM4ukzW16PiumTd8KQis6V7/2y9Kw1t9u2DyYUv6KfIUAsCgYEA2Era + 4jQ4VQMJBBY8pQN+gMpH+avytvGGHXl/tm6My7LevEZOq00LAhlsa/fwUxI1dXhH + yFXtQIILZw79a1bRWsbfFrkWiC9g0JgNDt/pzds2EsTltVS5OWRMaVcrL3glP8+U + ObW4qzTJiI6m6LKV7hnmaL1fR/NUjWk+fvc/mwECgYAMs3fFP7RT47siLWbwDs+z + zEyYmNvkNu3lGI6GmCvmh2VUx5qDTDS+Hm+LDCqTqRKdH98b2Vn7LUHOBtE4w6N1 + nhj6ljeOAe/VkTcWdoOyHRS9/RRb+S84o5RuzVtH31SA3pl6FlLJ7Z8d7vBscf6z + QUlxxENNglL/bh3TPP3rTQKBgC8LwSZ4s1QSb/CaoaBG7Uo4NYWiGA4g5MoedmAJ + Fcjs5DPRmyT5gg531zR43qZDDKu7eOmjfxKL9sz43rhtTuZO4ZGAutzuaUGWASke + HS3wo4dbmpdhkVRhc5lqI3OUz41cqmIPG9bpiXiRhs6QoboDmjFoF4R/8gE8RiK5 + xvUBAoGACrghAg+GlJZ/Aogx7wK6b1k8rfcpgIoHxOPiqIgyMgevTT6D6w8D0CqI + cEVTZ/fm+EaNuMZvxqSG5f19/obLus+VNXvnMYi3qwFAZ5NhKBen12YhIcaZpOh1 + ZSjeYozDCyRmv76q3sqcLrwxnULIcaK0l255ZczzwiUl39Bqe1o= + -----END RSA PRIVATE KEY----- + PRIV_KEY + ), + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + b2xsZXJzY2FwZXMgQ0F2MzEVMBMGA1UECgwMUm9sbGVyc2NhcGVzMRIwEAYDVQQH + DAlSb3R0ZXJkYW0xCzAJBgNVBAYTAk5MMB4XDTE0MDcyNzEzMDIzM1oXDTE4MDcy + NjEzMDIzM1owWzEhMB8GA1UEAwwYYm9wLmRldi5yb2xsZXJzY2FwZXMubmV0MRUw + EwYDVQQKDAxSb2xsZXJzY2FwZXMxEjAQBgNVBAcMCVJvdHRlcmRhbTELMAkGA1UE + BhMCTkwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFN7758InBPIIE + Q/VoYrj/poR1bGEcupAB+Q68R2C5ac5EGQMwaODCphP1RetLGHJE+4hss9GzJb56 + LfLSKy500Zk6R50zUXNCJwvkMvODHTMDy0xORg7tMbe3kLnHH/lbhmeWmXt5qDxa + S2jx5A2pKGmoLS8smYFlPRZ0yiK8Ugy5kDWCEFA31TIsGKcofOWcr+vfJ7HltXav + h1VFZ2nzJC8xKaoFQO4uake225CZQ+W4yhIxu5beY/FXlh2PIZqd1rQhQLuV5gK4 + zGkjNkN6DVJ+7xwnYJ7yeXKlovwMOEJQG1LHnr16gFRRcFeVUHPZkW47QGOYh60n + rG8/8/kLAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAKLWz2F2bJyhTlHxAORbdugm + esBbPxlhkCitdXp7uAkQh+0HeJ+jFb+CA0blmGyY3j15t54WV9ySMV8tQRSk5sXl + VVaJ4AF0uIvT5gbOvL8Vr2ZNiWp2S0Nqx28JVP/KNCAI3PBIWnDcQOON3gHQQi9O + qmL+vAuODEQ2UvgCd2GgFPqsu79Y1PRbqRIwqNiFasHt9pQNlpzRM6AjtUMldShG + rpz1WIZIIZuH+TC/iqD7UlSoLxJbe79a6dbBNw7bnWlo+HDl8YfmY6Ks3O6MCbYn + qVBRc3K9ywcUYPJNVuUazdXuY6FSiGB1iOLxRHppQapmWK5GdtQFXW3GlkXFYf4= + -----END CERTIFICATE----- + CERT + ); + } + + #[Test] + public function it_fails_with_low_bit_count(): void + { + $validator = new KeyValidator(); + + $this->expectException(KeyBitsTooLow::class); + + $validator->validate( + <<<'PRIV_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIICXQIBAAKBgQDGdG43UwH+zGYaPX6TxGglJh9/YRZx2H40nZ5Z05z5nkJXDI7k + rrcs/fBLJRgi9AIcdueLLaq1cSp2rwlMVOYOXpMg1MKnXb2fYm0s1/7283ZzoylL + 1872IJLPmm2Hw5wnNldqTHU9YfOlUEpqSHNV6WNsYr7GTjGfCSSec34d4wIDAQAB + AoGAMCZ9u2SjfkvflgxHkti7oA/Q4poO1Q5/CIsZqZfDZXk1hWNhpDCT9xGh5Mma + QpjLjlZ3NXieC6nqcKNlcSTEMFizRXpvfDnG7IANuBdp77eoZHhheTLY5yKxhzfN + ZBokcgAGcOKPNkCZ6ARb2sMvwm92KK8OhwGC38bq0YRu3bECQQDxskH4LqPw7px2 + 5UdNWvnAmBwpmW9GsIjs3W8lH003hdrZ67//qAI+tVgA2SkFM382PQ/mEjQ65B48 + N7Ct3/6ZAkEA0jMOZHWjjC8zwCOLn4FXL51YcclojEFQkThx+CMp7H+3Xhqa6KT4 + CZgYf9Llq9s0wA1bMhCpMZ7JEzTzlHF52wJBAMEn247S/1Ox7bsbGvOYLBaduYwJ + QiO1O4hIouWA8X3Y7IDR5jwTcc/Zrz3mTuEIObcH76fHjpQt8HfhbcJXS6kCQQCG + IyrGFQQ/S0f9DzHkogdfTUvJoTvkdTHS2nBwZxAz6fS8SsIcQFpA1RydRZpnJ0Xs + YRmXQ2aVUb0DUsE2M4wNAkAfJiHdKiDDwUU7YkONkpNLn1eFRyylDsEzdCB2VeSR + wGZWY+ujFtu9MYnSf9N14DdZInHfVen8xBpX+xDeKS9E + -----END RSA PRIVATE KEY----- + PRIV_KEY + , + <<<'CERT' + -----BEGIN CERTIFICATE----- + MIICEzCCAXwCCQCmaz9evnQZlzANBgkqhkiG9w0BAQsFADBOMRQwEgYDVQQDDAtl + eGFtcGxlLmNvbTEQMA4GA1UECgwHRXhhbXBsZTEXMBUGA1UEBwwOV2FzaGhpbmd0 + b24gQUMxCzAJBgNVBAYTAlVTMB4XDTIwMDUwMzEzMTMzNloXDTI0MDUwMjEzMTMz + NlowTjEUMBIGA1UEAwwLZXhhbXBsZS5jb20xEDAOBgNVBAoMB0V4YW1wbGUxFzAV + BgNVBAcMDldhc2hoaW5ndG9uIEFDMQswCQYDVQQGEwJVUzCBnzANBgkqhkiG9w0B + AQEFAAOBjQAwgYkCgYEAxnRuN1MB/sxmGj1+k8RoJSYff2EWcdh+NJ2eWdOc+Z5C + VwyO5K63LP3wSyUYIvQCHHbniy2qtXEqdq8JTFTmDl6TINTCp129n2JtLNf+9vN2 + c6MpS9fO9iCSz5pth8OcJzZXakx1PWHzpVBKakhzVeljbGK+xk4xnwkknnN+HeMC + AwEAATANBgkqhkiG9w0BAQsFAAOBgQCs+IaMmYEK9+/xcGjA0xAVCeLGurd1EFT/ + PinGAxLF+3xURpECwQhESASj5yDVzEz4A3RBLoel3mSFVqUqxJceo6X0gE0TwvtL + xKOFZRRuMCEI7mqHBykhMJIYWBesg3+Uh02u0olK7+CkdSrmSZd8CZtm+imCCaYr + jSHjiAFydw== + -----END CERTIFICATE----- + CERT + ); + } +} diff --git a/tests/OCSPValidatorTest.php b/tests/OCSPValidatorTest.php new file mode 100644 index 0000000..3676a22 --- /dev/null +++ b/tests/OCSPValidatorTest.php @@ -0,0 +1,522 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Tests; + +use Ocsp\Ocsp; +use Ocsp\Response as OcspResponse; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Rollerworks\Component\PdbSfBridge\PdpMockProvider; +use Rollerworks\Component\X509Validator\OCSPValidator; +use Rollerworks\Component\X509Validator\TranslatableArgument; +use Rollerworks\Component\X509Validator\Violation\CertificateIsRevoked; +use Rollerworks\Component\X509Validator\Violation\UnprocessablePEM; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\ErrorHandler\BufferingLogger; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @internal + */ +final class OCSPValidatorTest extends TestCase +{ + use ProphecyTrait; + + private ?OCSPValidator $certificateValidator = null; + private static ?Psr16Cache $cache; + + public static function setUpBeforeClass(): void + { + self::$cache = new Psr16Cache(new ArrayAdapter()); + } + + public static function tearDownAfterClass(): void + { + self::$cache = null; + } + + protected function setUp(): void + { + /** @var HttpClientInterface&LoggerAwareInterface $httpClient */ + $httpClient = HttpClient::create(); + $httpClient->setLogger(new BufferingLogger()); + + $this->pdpManager = PdpMockProvider::getPdpManager(); + + $this->certificateValidator = new OCSPValidator( + httpClient: $httpClient, + logger: $this->expectNoFailureLogs(), + ); + } + + #[Test] + public function validate_certificate_is_actually_readable(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + CERT; + + try { + $this->certificateValidator->validateStatus($certContents); + + self::fail('Exception was expected.'); + } catch (UnprocessablePEM $e) { + self::assertSame(['name' => ''], $e->getParameters()); + self::assertSame($certContents, $e->getPrevious()->getPrevious()->getMessage()); + } + } + + #[Test] + public function validate_certificate_data_is_readable(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDKzCCAhMCCQDZHE66hI+pmjANBgkqhkiG9w0BAQUFADBUMRowGAYDVQQDDBFS + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + CERT; + + try { + $this->certificateValidator->validateStatus($certContents); + + self::fail('Exception was expected.'); + } catch (UnprocessablePEM $e) { + self::assertSame($certContents, $e->getPrevious()->getPrevious()->getMessage()); + self::assertSame([ + 'name' => '', + ], $e->getParameters()); + } + } + + #[Test] + public function validate_certificate_is_revoked(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $ca1 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT; + + $ca2 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT; + + $responseFactory = static function ($method, $url, $options) { + self::assertSame('http://ocsp.digicert.com/', $url); + + return new MockResponse($options['body'], ['response_headers' => ['content-type' => Ocsp::OCSP_RESPONSE_MEDIATYPE]]); + }; + $httpClient = new MockHttpClient($responseFactory); + + $ocspProphecy = $this->prophesize(Ocsp::class); + $ocspProphecy->buildOcspRequestBodySingle(Argument::any())->willReturn('Revoked'); + $ocspProphecy->decodeOcspResponseSingle(Argument::any()) + ->willReturn( + OcspResponse::revoked( + new \DateTimeImmutable('now'), + '8130451905380357229031687250908825482', + $revokedOn = new \DateTimeImmutable('2020-01-29T14:12:14.000000+0000'), + OcspResponse::REVOCATIONREASON_PRIVILEGEWITHDRAWN + ) + ); + $ocsp = $ocspProphecy->reveal(); + + $this->certificateValidator = new OCSPValidator( + httpClient: $httpClient, + ocsp: $ocsp, + ); + + try { + $this->certificateValidator->validateStatus($certContents, [ + 'DigiCert Global Root CA' => $ca2, + 'DigiCert SHA2 Secure Server CA' => $ca1, + ]); + + self::fail('Exception was expected.'); + } catch (CertificateIsRevoked $e) { + self::assertEquals([ + 'revoked_on' => $revokedOn, + 'reason_code' => 'privilegeWithdrawn', + 'reason' => new TranslatableArgument('the certificate (public-key or attribute certificate) was revoked because a privilege contained within that certificate has been withdrawn.'), + 'serial' => '8130451905380357229031687250908825482', + ], $e->getParameters()); + } + } + + #[Test] + public function validate_certificate_revocation_status_in_wrong_format_only_logs(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $ca1 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT; + + $ca2 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT; + + $responseFactory = static function ($method, $url, $options) { + self::assertSame('http://ocsp.digicert.com/', $url); + + return new MockResponse($options['body'], ['response_headers' => ['content-type' => 'text/html']]); + }; + $httpClient = new MockHttpClient($responseFactory); + + $ocspProphecy = $this->prophesize(Ocsp::class); + $ocspProphecy->buildOcspRequestBodySingle(Argument::any())->willReturn('Valid'); + $ocspProphecy->decodeOcspResponseSingle(Argument::any())->shouldNotBeCalled(); + $ocsp = $ocspProphecy->reveal(); + + $logger = new TestLogger(); + + $this->certificateValidator = new OCSPValidator( + httpClient: $httpClient, + logger: $logger, + ocsp: $ocsp, + ); + + $this->certificateValidator->validateStatus($certContents, [ + 'DigiCert Global Root CA' => $ca2, + 'DigiCert SHA2 Secure Server CA' => $ca1, + ]); + + self::assertTrue($logger->hasWarningThatContains('Unable to check OCSP status.'), 'Should have failed checking'); + } + + #[Test] + public function validate_certificate_revocation_status_unavailable_only_logs(): void + { + $certContents = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIHGTCCBgGgAwIBAgIQBh3eOmYhdHQ4TTZVG+hHijANBgkqhkiG9w0BAQsFADBN + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E + aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwMjA4MDAwMDAwWhcN + MjEwMjEyMTIwMDAwWjBpMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNV + BAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoTGFNsYWNrIFRlY2hub2xvZ2llcywg + SW5jLjESMBAGA1UEAxMJc2xhY2suY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A + MIIBCgKCAQEAqb0QCgBUkwHwC1AUT1N1W6wfbKSUZGSQ9Pf7EovdVIt1f8hrq5KZ + OvVUaU/5qsS9UMm1GGqhjVrFqRKv//rZ/VaIThNaLVGQ3yTWCmnPxTZBvEOH1oLP + i2V+XgDcX2drRUUfFELQy8EZVABwQu5Y3FluB1S7Nv1EH2tOsug5koMIdtMetUo/ + nKPzpuVC/4C/8oPN3+37cSriAImfxrifrrSCLkMscnwh6VcSuajnlCgw/iVcQzEE + 0OGht+KmFgIvjTWmKLx44MvkKqPUnvBudKk4k+9V527g9uNM0rxCVXWb1hf5w08I + VvEC5/N78HrBl/q/e2oaygp95z/CQ5aJqQIDAQABo4ID1zCCA9MwHwYDVR0jBBgw + FoAUD4BhHIIxYdUvKOeNRji0LOHG2eIwHQYDVR0OBBYEFPla7+E8XELNsM7Mg46q + uGwJyd0tMCEGA1UdEQQaMBiCCXNsYWNrLmNvbYILKi5zbGFjay5jb20wDgYDVR0P + AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBrBgNVHR8E + ZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1zaGEyLWc2 + LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Etc2hhMi1n + Ni5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0 + cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYBBQUHAQEE + cDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wRgYIKwYB + BQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNIQTJT + ZWN1cmVTZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCCAfYGCisGAQQB1nkCBAIE + ggHmBIIB4gHgAHYApLkJkLQYWBSHuxOizGdwCjw1mAT5G9+443fNDsgN3BAAAAFh + d2Q95wAABAMARzBFAiEA42uacv79w94og76vu/L9nzZJAsU0398rJZuBAY8EY30C + IFCuAzawnV4AOtOEEp7ybdy/0SLBgZ7bBO3gs0EhkOYCAHYAh3W/51l8+IxDmV+9 + 827/Vo1HVjb/SrVgwbTq/16ggw8AAAFhd2Q9zQAABAMARzBFAiBIhbiWxOmsFEmC + 2I6ZBg8Qb+xSIv0AgqZTnIHSzaR0BwIhALoijpGV0JB2xBgW88noxeHdCeqWXQ/a + HPDAd/Q37M+WAHYAu9nfvB+KcbWTlCOXqpJ7RzhXlQqrUugakJZkNo4e0YUAAAFh + d2Q+IAAABAMARzBFAiEA0p6Cq67EzeVhxYSpNJYU8Ys7Pj9c4EQPmPaAvnLDL0wC + IBnOHO2DWoBi+LH6Z/uicH+4nbb4S15zV96NqFn9mXH0AHYAb1N2rDHwMRnYmQCk + URX/dxUcEdkCwQApBo2yCJo32RMAAAFhd2Q/4AAABAMARzBFAiEA2C3VUu67nO5T + e2Q8okaIkPftUdE+GHyKkZbqmJMg550CIBFZW53z4BUmtP4GDBEA85D/EnDBPOx2 + OC6cgoRW7sz/MA0GCSqGSIb3DQEBCwUAA4IBAQBUh0yybzRV4ednO+RM4uifnBkf + S/9r4IHqvFyYgyofd1hygwD3i/pT10V+yF2teqL/FuwsInbjrvGpwFH/uiuhGgzc + hJ5TOA0/+A/RYNo7sN7An9NBYvedJOlV0iDUhVuQpGefEY3VHqtg0qNu9YoAAl67 + pDCmmQQoNKHDdq2IFq8taF8ros+stqC+cPBipVLxXe9wAFnTkjq0VjB1VqKzLDQ+ + VGN9QV+gw0KI7opJ4K/UKOTnG7ON0zlKIqAK2pXUVsQa9Q5kMbakOk3930bGrkXW + dqEt/Oc2qDvj/OFnFvaAiKhWUmwhu3IJT4B+W15sPYYBAC4N4FhjP+aGv6IK + -----END CERTIFICATE----- + CERT; + + $ca1 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIElDCCA3ygAwIBAgIQAf2j627KdciIQ4tyS8+8kTANBgkqhkiG9w0BAQsFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0xMzAzMDgxMjAwMDBaFw0yMzAzMDgxMjAwMDBaME0xCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxJzAlBgNVBAMTHkRpZ2lDZXJ0IFNIQTIg + U2VjdXJlIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB + ANyuWJBNwcQwFZA1W248ghX1LFy949v/cUP6ZCWA1O4Yok3wZtAKc24RmDYXZK83 + nf36QYSvx6+M/hpzTc8zl5CilodTgyu5pnVILR1WN3vaMTIa16yrBvSqXUu3R0bd + KpPDkC55gIDvEwRqFDu1m5K+wgdlTvza/P96rtxcflUxDOg5B6TXvi/TC2rSsd9f + /ld0Uzs1gN2ujkSYs58O09rg1/RrKatEp0tYhG2SS4HD2nOLEpdIkARFdRrdNzGX + kujNVA075ME/OV4uuPNcfhCOhkEAjUVmR7ChZc6gqikJTvOX6+guqw9ypzAO+sf0 + /RR3w6RbKFfCs/mC/bdFWJsCAwEAAaOCAVowggFWMBIGA1UdEwEB/wQIMAYBAf8C + AQAwDgYDVR0PAQH/BAQDAgGGMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYY + aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMHsGA1UdHwR0MHIwN6A1oDOGMWh0dHA6 + Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RDQS5jcmwwN6A1 + oDOGMWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RD + QS5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v + d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwHQYDVR0OBBYEFA+AYRyCMWHVLyjnjUY4tCzh + xtniMB8GA1UdIwQYMBaAFAPeUDVW0Uy7ZvCj4hsbw5eyPdFVMA0GCSqGSIb3DQEB + CwUAA4IBAQAjPt9L0jFCpbZ+QlwaRMxp0Wi0XUvgBCFsS+JtzLHgl4+mUwnNqipl + 5TlPHoOlblyYoiQm5vuh7ZPHLgLGTUq/sELfeNqzqPlt/yGFUzZgTHbO7Djc1lGA + 8MXW5dRNJ2Srm8c+cftIl7gzbckTB+6WohsYFfZcTEDts8Ls/3HB40f/1LkAtDdC + 2iDJ6m6K7hQGrn2iWZiIqBtvLfTyyRRfJs8sjX7tN8Cp1Tm5gr8ZDOo0rwAhaPit + c+LJMto4JQtV05od8GiG7S5BNO98pVAdvzr508EIDObtHopYJeS4d60tbvVS3bR0 + j6tJLp07kzQoH3jOlOrHvdPJbRzeXDLz + -----END CERTIFICATE----- + CERT; + + $ca2 = <<<'CERT' + -----BEGIN CERTIFICATE----- + MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 + d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD + QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT + MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j + b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG + 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB + CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 + nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt + 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P + T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 + gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO + BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR + TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw + DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr + hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg + 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF + PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls + YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk + CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= + -----END CERTIFICATE----- + CERT; + + $responseFactory = static function ($method, $url, $options) { + self::assertSame('http://ocsp.digicert.com/', $url); + + return new MockResponse($options['body'], ['response_headers' => ['content-type' => Ocsp::OCSP_RESPONSE_MEDIATYPE], 'http_code' => 500]); + }; + $httpClient = new MockHttpClient($responseFactory); + + $ocspProphecy = $this->prophesize(Ocsp::class); + $ocspProphecy->buildOcspRequestBodySingle(Argument::any())->willReturn('Valid'); + $ocspProphecy->decodeOcspResponseSingle(Argument::any())->shouldNotBeCalled(); + $ocsp = $ocspProphecy->reveal(); + + $logger = new TestLogger(); + + $this->certificateValidator = new OCSPValidator( + httpClient: $httpClient, + logger: $logger, + ocsp: $ocsp, + ); + + $this->certificateValidator->validateStatus($certContents, [ + 'DigiCert Global Root CA' => $ca2, + 'DigiCert SHA2 Secure Server CA' => $ca1, + ]); + + self::assertTrue($logger->hasWarningThatContains('Unable to check OCSP status.'), 'Should have failed checking'); + } + + private function expectNoFailureLogs(): LoggerInterface + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::never())->method('error'); + $logger->expects(self::never())->method('warning'); + + return $logger; + } +} diff --git a/tests/TestLogger.php b/tests/TestLogger.php new file mode 100644 index 0000000..e0ba2e8 --- /dev/null +++ b/tests/TestLogger.php @@ -0,0 +1,209 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\X509Validator\Tests; + +use Psr\Log\AbstractLogger; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LogLevel; + +/** + * Used for testing purposes (copied from PHP-FIG repository). + * + * It records all records and gives you access to them for verification. + * + * @method bool hasEmergency(array $record) + * @method bool hasAlert(array $record) + * @method bool hasCritical(array $record) + * @method bool hasError(array $record) + * @method bool hasWarning(array $record) + * @method bool hasNotice(array $record) + * @method bool hasInfo(array $record) + * @method bool hasDebug(array $record) + * @method bool hasEmergencyRecords() + * @method bool hasAlertRecords() + * @method bool hasCriticalRecords() + * @method bool hasErrorRecords() + * @method bool hasWarningRecords() + * @method bool hasNoticeRecords() + * @method bool hasInfoRecords() + * @method bool hasDebugRecords() + * @method bool hasEmergencyThatContains(string $message) + * @method bool hasAlertThatContains(string $message) + * @method bool hasCriticalThatContains(string $message) + * @method bool hasErrorThatContains(string $message) + * @method bool hasWarningThatContains(string $message) + * @method bool hasNoticeThatContains(string $message) + * @method bool hasInfoThatContains(string $message) + * @method bool hasDebugThatContains(string $message) + * @method bool hasEmergencyThatMatches(string $message) + * @method bool hasAlertThatMatches(string $message) + * @method bool hasCriticalThatMatches(string $message) + * @method bool hasErrorThatMatches(string $message) + * @method bool hasWarningThatMatches(string $message) + * @method bool hasNoticeThatMatches(string $message) + * @method bool hasInfoThatMatches(string $message) + * @method bool hasDebugThatMatches(string $message) + * @method bool hasEmergencyThatPasses(string $message) + * @method bool hasAlertThatPasses(string $message) + * @method bool hasCriticalThatPasses(string $message) + * @method bool hasErrorThatPasses(string $message) + * @method bool hasWarningThatPasses(string $message) + * @method bool hasNoticeThatPasses(string $message) + * @method bool hasInfoThatPasses(string $message) + * @method bool hasDebugThatPasses(string $message) + * + * @internal + */ +final class TestLogger extends AbstractLogger +{ + public array $records = []; + public array $recordsByLevel = []; + + public function log($level, string | \Stringable $message, array $context = []): void + { + if (! \in_array($level, $this->getLogLevels(), true)) { + throw new InvalidArgumentException(sprintf('Log level "%1$s" is not valid', $level)); + } + + $record = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + + $this->recordsByLevel[$record['level']][] = $record; + $this->records[] = $record; + } + + public static function formatMessage(string $message, string $level, array $context): string + { + $message = self::interpolateContext($message, $context); + + return "{$level} {$message}"; + } + + private static function interpolateContext(string $message, array $context): string + { + return preg_replace_callback('!\{([^}\s]*)\}!', static function ($matches) use ($context) { + $key = $matches[1] ?? null; + + if (\array_key_exists($key, $context)) { + return $context[$key]; + } + + return $matches[0]; + }, $message); + } + + /** @return array */ + public function getLogLevels(): array + { + static $constants; + + if (isset($constants)) { + return $constants; + } + + $reflection = new \ReflectionClass(LogLevel::class); + $constants = $reflection->getConstants(); + + return $constants; + } + + /** @return array> */ + public function getRecords(): array + { + return $this->records; + } + + public function hasRecords(string $level): bool + { + return isset($this->recordsByLevel[$level]); + } + + /** @param string|array $record */ + public function hasRecord(array | string $record, string $level): bool + { + if (\is_string($record)) { + $record = ['message' => $record]; + } + + return $this->hasRecordThatPasses(static function ($rec) use ($record) { + if ($rec['message'] !== $record['message']) { + return false; + } + + if (isset($record['context']) && $rec['context'] !== $record['context']) { + return false; + } + + return true; + }, $level); + } + + public function hasRecordThatContains(string $message, string $level): bool + { + return $this->hasRecordThatPasses(static fn ($rec) => mb_strpos($rec['message'], $message) !== false, $level); + } + + public function hasRecordThatMatches(string $regex, string $level): bool + { + return $this->hasRecordThatPasses(static fn ($rec) => preg_match($regex, $rec['message']) > 0, $level); + } + + /** + * Determines whether the logger has logged matching records of the specified level. + * + * @param callable(array{level: \Psr\Log\LogLevel::*, message: string, context: array}, int): bool $predicate + * The function used to evaluate whether a record matches + * @param LogLevel::* $level The level of the record + */ + public function hasRecordThatPasses(callable $predicate, string $level): bool + { + if (! isset($this->recordsByLevel[$level])) { + return false; + } + + foreach ($this->recordsByLevel[$level] as $i => $rec) { + if ($predicate($rec, $i)) { + return true; + } + } + + return false; + } + + /** @param array $args */ + public function __call(string $method, array $args): mixed + { + if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) { + $genericMethod = $matches[1] . ($matches[3] !== 'Records' ? 'Record' : '') . $matches[3]; + $level = mb_strtolower($matches[2]); + + if (method_exists($this, $genericMethod)) { + $args[] = $level; + + return \call_user_func_array([$this, $genericMethod], $args); + } + } + + throw new \BadMethodCallException('Call to undefined method ' . self::class . '::' . $method . '()'); + } + + public function reset(): void + { + $this->records = []; + $this->recordsByLevel = []; + } +}