From 752ba1ed1e7eb667c48a4257c0c322e63b42446c Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 16 Jun 2026 13:25:51 +0300 Subject: [PATCH 01/18] NFC-169 Fix PHP auth cert chain, format, SSRF, cookie and OCSP checks Signed-off-by: Sander Kondratjev --- example/public/index.php | 10 +++++ src/certificate/CertificateValidator.php | 4 ++ src/validator/ocsp/OcspClientImpl.php | 4 +- .../AuthTokenVersion11Validator.php | 19 ++++++++- .../AuthTokenVersion1Validator.php | 6 +-- .../AuthTokenVersion11ValidatorTest.php | 39 ++++++++++++++++++- .../AuthTokenVersion1ValidatorTest.php | 4 +- 7 files changed, 76 insertions(+), 10 deletions(-) diff --git a/example/public/index.php b/example/public/index.php index b8494a8..2cf4dda 100644 --- a/example/public/index.php +++ b/example/public/index.php @@ -26,6 +26,16 @@ ini_set('display_errors', '0'); +session_name('__Host-PHPSESSID'); + +session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', +]); + session_start(); // Uncomment following line to define the custom log location (by default the server log is used) diff --git a/src/certificate/CertificateValidator.php b/src/certificate/CertificateValidator.php index c680f85..16fa9e1 100644 --- a/src/certificate/CertificateValidator.php +++ b/src/certificate/CertificateValidator.php @@ -73,6 +73,10 @@ public static function validateIsValidAndSignedByTrustedCA( $now = DefaultClock::getInstance()->now(); self::certificateIsValidOnDate($certificate, $now, "User"); + // Prevent SSRF via CA Issuers URI from user-provided certificate AIA. + // All trusted/intermediate CA certificates must be provided by configuration. + X509::disableURLFetch(); + foreach ($trustedCertificates->getCertificates() as $trustedCertificate) { $certificate->loadCA( $trustedCertificate->saveX509($trustedCertificate->getCurrentCert(), X509::FORMAT_PEM) diff --git a/src/validator/ocsp/OcspClientImpl.php b/src/validator/ocsp/OcspClientImpl.php index 4da3852..700eaa1 100644 --- a/src/validator/ocsp/OcspClientImpl.php +++ b/src/validator/ocsp/OcspClientImpl.php @@ -66,7 +66,9 @@ public function request(Uri $uri, string $encodedOcspRequest): OcspResponse $info = curl_getinfo($curl); if ($info["http_code"] !== 200) { - throw new UserCertificateOCSPCheckFailedException("OCSP request was not successful, response: " + $result); + throw new UserCertificateOCSPCheckFailedException( + "OCSP request was not successful, response: " . (is_string($result) ? $result : '') + ); } $response = new OcspResponse($result); diff --git a/src/validator/versionvalidators/AuthTokenVersion11Validator.php b/src/validator/versionvalidators/AuthTokenVersion11Validator.php index c0bd652..da02c2f 100644 --- a/src/validator/versionvalidators/AuthTokenVersion11Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion11Validator.php @@ -57,8 +57,7 @@ class AuthTokenVersion11Validator extends AuthTokenVersion1Validator public function supports(?string $format): bool { - return $format !== null && - str_starts_with($format, self::V11_SUPPORTED_TOKEN_FORMAT_PREFIX); + return $format === self::V11_SUPPORTED_TOKEN_FORMAT_PREFIX; } /** @@ -84,6 +83,7 @@ public function validate( $this->validateSameIssuer($subjectCertificate, $signingCertificate); $this->validateSigningCertificateValidity($signingCertificate); $this->validateKeyUsage($signingCertificate); + $this->validateSigningCertificateChain($signingCertificate); } return $subjectCertificate; @@ -239,6 +239,21 @@ private function validateKeyUsage(X509 $signingCertificate): void } } + /** + * @throws AuthTokenParseException + */ + private function validateSigningCertificateChain(X509 $signingCertificate): void + { + try { + $this->buildTrustValidatorBatch()->executeFor($signingCertificate); + } catch (AuthTokenException $e) { + throw new AuthTokenParseException( + "Signing certificate chain validation failed", + $e, + ); + } + } + /** * @throws AuthTokenException */ diff --git a/src/validator/versionvalidators/AuthTokenVersion1Validator.php b/src/validator/versionvalidators/AuthTokenVersion1Validator.php index 8e44a1e..f4689bd 100644 --- a/src/validator/versionvalidators/AuthTokenVersion1Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion1Validator.php @@ -73,8 +73,8 @@ public function __construct( public function supports(?string $format): bool { - return $format !== null && - str_starts_with($format, self::V1_SUPPORTED_TOKEN_FORMAT_PREFIX); + return $format === self::V1_SUPPORTED_TOKEN_FORMAT_PREFIX || + $format === "web-eid:1.0"; } public function validate( @@ -135,7 +135,7 @@ public function validate( return $subjectCertificate; } - private function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch + protected function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch { $trustedValidator = new SubjectCertificateTrustedValidator( $this->trustedCACertificates, diff --git a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php index 4367738..a6cbf54 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php @@ -72,8 +72,6 @@ public static function validFormats(): array { return [ ['web-eid:1.1'], - ['web-eid:1.1.0'], - ['web-eid:1.10'], ]; } @@ -96,6 +94,8 @@ public static function invalidFormats(): array ['web-eid:1.0'], ['web-eid:2'], ['webauthn:1.1'], + ['web-eid:1.1.0'], + ['web-eid:1.10'], ]; } @@ -241,4 +241,39 @@ public function testMissingSupportedAlgorithmsFails(): void $spy->validate($token, 'nonce'); } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testSigningCertificateChainValidationFails(): void + { + $certPath = __DIR__ . '/../../_resources/TEST_of_ESTEID2018.cer'; + $der = file_get_contents($certPath); + $this->assertIsString($der, "Certificate missing at: $certPath"); + + $signingCertificate = new X509(); + $this->assertNotFalse($signingCertificate->loadX509($der)); + + $config = new AuthTokenValidationConfiguration(); + $config->setUserCertificateRevocationCheckWithOcspDisabled(); + + $validator = new class( + $this->createMock(SubjectCertificateValidatorBatch::class), + CertificateValidator::buildTrustFromCertificates([]), + $this->createMock(AuthTokenSignatureValidator::class), + $config, + null, + null + ) extends AuthTokenVersion11Validator { + public function assertChainFails(X509 $certificate): void + { + $this->buildTrustValidatorBatch()->executeFor($certificate); + } + }; + + $this->expectException(AuthTokenException::class); + + $validator->assertChainFails($signingCertificate); + } } diff --git a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php index c5065cb..e7dc913 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php @@ -68,8 +68,6 @@ public static function validFormats(): array return [ ['web-eid:1'], ['web-eid:1.0'], - ['web-eid:1.1'], - ['web-eid:1.10'], ]; } @@ -92,6 +90,8 @@ public static function invalidFormats(): array ['web-eid:0.9'], ['web-eid:2'], ['webauthn:1'], + ['web-eid:1.1'], + ['web-eid:1.10'], ]; } From 3789b0ec98307252a612d33c9223bde490c75a81 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 16 Jun 2026 13:48:56 +0300 Subject: [PATCH 02/18] NFC-169 composer lock Signed-off-by: Sander Kondratjev --- composer.lock | 183 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 170 insertions(+), 13 deletions(-) diff --git a/composer.lock b/composer.lock index b05355e..da36393 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,29 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "06a60bf5c664ea446c1588bc886b8552", + "content-hash": "f27b570b2569391348e4efe27ec77138", "packages": [ { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/640e2897bbee822dbc8af761d49e1a29b1f2a6b1", + "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -32,9 +34,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -105,7 +107,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.11.1" }, "funding": [ { @@ -121,7 +123,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-06-12T21:50:12+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -553,6 +555,161 @@ "source": "https://github.com/ralouphie/getallheaders/tree/develop" }, "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [ @@ -2307,12 +2464,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.3" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } From ff9bfbfa19552b8ac368e90843f353c7df72c702 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Fri, 26 Jun 2026 16:35:34 +0300 Subject: [PATCH 03/18] NFC-169 Review findings Signed-off-by: Sander Kondratjev --- .../SubjectCertificateNotRevokedValidator.php | 4 ++-- .../versionvalidators/AuthTokenVersion11Validator.php | 5 +++-- .../versionvalidators/AuthTokenVersion1Validator.php | 5 +++-- tests/certificate/CertificateValidatorTest.php | 1 - .../versionvalidators/AuthTokenVersion11ValidatorTest.php | 7 ++++--- .../versionvalidators/AuthTokenVersion1ValidatorTest.php | 8 ++++++-- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php index 9d761c7..c478afb 100644 --- a/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php +++ b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php @@ -56,8 +56,8 @@ public function __construct( OcspServiceProvider $ocspServiceProvider, int $allowedOcspResponseTimeSkew, int $maxOcspResponseThisUpdateAge, - ?LoggerInterface $logger = null) - { + ?LoggerInterface $logger = null + ) { $this->logger = $logger; $this->trustValidator = $trustValidator; $this->ocspClient = $ocspClient; diff --git a/src/validator/versionvalidators/AuthTokenVersion11Validator.php b/src/validator/versionvalidators/AuthTokenVersion11Validator.php index da02c2f..61622f1 100644 --- a/src/validator/versionvalidators/AuthTokenVersion11Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion11Validator.php @@ -36,7 +36,7 @@ class AuthTokenVersion11Validator extends AuthTokenVersion1Validator { - private const V11_SUPPORTED_TOKEN_FORMAT_PREFIX = "web-eid:1.1"; + private const V11_SUPPORTED_TOKEN_FORMAT_PATTERN = '/^web-eid:1\.1$/'; private const SUPPORTED_SIGNING_CRYPTO_ALGORITHMS = ["ECC", "RSA"]; private const SUPPORTED_SIGNING_PADDING_SCHEMES = [ @@ -57,7 +57,8 @@ class AuthTokenVersion11Validator extends AuthTokenVersion1Validator public function supports(?string $format): bool { - return $format === self::V11_SUPPORTED_TOKEN_FORMAT_PREFIX; + return $format !== null && + preg_match(self::V11_SUPPORTED_TOKEN_FORMAT_PATTERN, $format) === 1; } /** diff --git a/src/validator/versionvalidators/AuthTokenVersion1Validator.php b/src/validator/versionvalidators/AuthTokenVersion1Validator.php index f4689bd..679cc28 100644 --- a/src/validator/versionvalidators/AuthTokenVersion1Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion1Validator.php @@ -44,6 +44,7 @@ class AuthTokenVersion1Validator implements AuthTokenVersionValidator { private const V1_SUPPORTED_TOKEN_FORMAT_PREFIX = "web-eid:1"; + private const V1_SUPPORTED_TOKEN_FORMAT_PATTERN = '/^web-eid:1(?:\.\d+)?$/'; private SubjectCertificateValidatorBatch $simpleSubjectCertificateValidators; private TrustedCertificates $trustedCACertificates; @@ -73,8 +74,8 @@ public function __construct( public function supports(?string $format): bool { - return $format === self::V1_SUPPORTED_TOKEN_FORMAT_PREFIX || - $format === "web-eid:1.0"; + return $format !== null && + preg_match(self::V1_SUPPORTED_TOKEN_FORMAT_PATTERN, $format) === 1; } public function validate( diff --git a/tests/certificate/CertificateValidatorTest.php b/tests/certificate/CertificateValidatorTest.php index 53f0cae..957908b 100644 --- a/tests/certificate/CertificateValidatorTest.php +++ b/tests/certificate/CertificateValidatorTest.php @@ -36,7 +36,6 @@ class CertificateValidatorTest extends TestCase { - protected function tearDown(): void { Dates::resetMockedCertificateValidatorDate(); diff --git a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php index a6cbf54..47d6324 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php @@ -92,10 +92,11 @@ public static function invalidFormats(): array [''], ['web-eid:1'], ['web-eid:1.0'], - ['web-eid:2'], - ['webauthn:1.1'], ['web-eid:1.1.0'], ['web-eid:1.10'], + ['web-eid:1.2'], + ['web-eid:2'], + ['webauthn:1.1'], ]; } @@ -258,7 +259,7 @@ public function testSigningCertificateChainValidationFails(): void $config = new AuthTokenValidationConfiguration(); $config->setUserCertificateRevocationCheckWithOcspDisabled(); - $validator = new class( + $validator = new class ( $this->createMock(SubjectCertificateValidatorBatch::class), CertificateValidator::buildTrustFromCertificates([]), $this->createMock(AuthTokenSignatureValidator::class), diff --git a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php index e7dc913..98a4719 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php @@ -68,6 +68,9 @@ public static function validFormats(): array return [ ['web-eid:1'], ['web-eid:1.0'], + ['web-eid:1.1'], + ['web-eid:1.10'], + ['web-eid:1.999'], ]; } @@ -87,11 +90,12 @@ public static function invalidFormats(): array [null], [''], ['web-eid'], + ['web-eid:1.'], + ['web-eid:1.0TEST'], + ['web-eid:1.1.0'], ['web-eid:0.9'], ['web-eid:2'], ['webauthn:1'], - ['web-eid:1.1'], - ['web-eid:1.10'], ]; } From 35fc2da79172b3a2d0017f50df8e01519425232f Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Fri, 3 Jul 2026 14:37:13 +0300 Subject: [PATCH 04/18] NFC-169 Versioning improvement Signed-off-by: Sander Kondratjev --- .../versionvalidators/AuthTokenVersion1Validator.php | 5 ++--- .../versionvalidators/AuthTokenVersion1ValidatorTest.php | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/validator/versionvalidators/AuthTokenVersion1Validator.php b/src/validator/versionvalidators/AuthTokenVersion1Validator.php index 679cc28..f4689bd 100644 --- a/src/validator/versionvalidators/AuthTokenVersion1Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion1Validator.php @@ -44,7 +44,6 @@ class AuthTokenVersion1Validator implements AuthTokenVersionValidator { private const V1_SUPPORTED_TOKEN_FORMAT_PREFIX = "web-eid:1"; - private const V1_SUPPORTED_TOKEN_FORMAT_PATTERN = '/^web-eid:1(?:\.\d+)?$/'; private SubjectCertificateValidatorBatch $simpleSubjectCertificateValidators; private TrustedCertificates $trustedCACertificates; @@ -74,8 +73,8 @@ public function __construct( public function supports(?string $format): bool { - return $format !== null && - preg_match(self::V1_SUPPORTED_TOKEN_FORMAT_PATTERN, $format) === 1; + return $format === self::V1_SUPPORTED_TOKEN_FORMAT_PREFIX || + $format === "web-eid:1.0"; } public function validate( diff --git a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php index 98a4719..0a5a798 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php @@ -68,9 +68,6 @@ public static function validFormats(): array return [ ['web-eid:1'], ['web-eid:1.0'], - ['web-eid:1.1'], - ['web-eid:1.10'], - ['web-eid:1.999'], ]; } @@ -92,7 +89,11 @@ public static function invalidFormats(): array ['web-eid'], ['web-eid:1.'], ['web-eid:1.0TEST'], + ['web-eid:1.1'], ['web-eid:1.1.0'], + ['web-eid:1.2'], + ['web-eid:1.10'], + ['web-eid:1.999'], ['web-eid:0.9'], ['web-eid:2'], ['webauthn:1'], From b86e2ad3eae88745e6bfc4dd106a0c0d87bda1fb Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Mon, 6 Jul 2026 09:59:55 +0300 Subject: [PATCH 05/18] NFC-128 Handle missing token format as parse error --- .../versionvalidators/AuthTokenVersionValidator.php | 5 +++-- .../AuthTokenVersionValidatorFactory.php | 7 ++++--- tests/validator/AuthTokenStructureTest.php | 8 ++++++++ .../AuthTokenVersionValidatorFactoryTest.php | 10 ++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/validator/versionvalidators/AuthTokenVersionValidator.php b/src/validator/versionvalidators/AuthTokenVersionValidator.php index 3cc084f..fe9ec5f 100644 --- a/src/validator/versionvalidators/AuthTokenVersionValidator.php +++ b/src/validator/versionvalidators/AuthTokenVersionValidator.php @@ -35,10 +35,11 @@ interface AuthTokenVersionValidator /** * Returns whether this validator supports validation of the given token format. * - * @param string $format the format string from the Web eID authentication token (e.g. "web-eid:1.0", "web-eid:1.1") + * @param string|null $format the format string from the Web eID authentication token + * (e.g. "web-eid:1.0", "web-eid:1.1") * @return true if this validator can handle the given format, false otherwise */ - public function supports(string $format): bool; + public function supports(?string $format): bool; /** * Validates the Web eID authentication token signed by the subject and returns diff --git a/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php b/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php index 6c6e1ee..93521f6 100644 --- a/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php +++ b/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php @@ -52,7 +52,7 @@ public function __construct(array $validators) $this->validators = $validators; } - public function supports(string $format): bool + public function supports(?string $format): bool { foreach ($this->validators as $validator) { if ($validator->supports($format)) { @@ -65,7 +65,7 @@ public function supports(string $format): bool /** * @throws AuthTokenParseException */ - public function getValidatorFor(string $format): AuthTokenVersionValidator + public function getValidatorFor(?string $format): AuthTokenVersionValidator { foreach ($this->validators as $validator) { if ($validator->supports($format)) { @@ -73,8 +73,9 @@ public function getValidatorFor(string $format): AuthTokenVersionValidator } } + $formatLabel = $format ?? "null"; throw new AuthTokenParseException( - "Token format version '{$format}' is currently not supported" + "Token format version '{$formatLabel}' is currently not supported" ); } diff --git a/tests/validator/AuthTokenStructureTest.php b/tests/validator/AuthTokenStructureTest.php index 83b088c..a75ceaf 100644 --- a/tests/validator/AuthTokenStructureTest.php +++ b/tests/validator/AuthTokenStructureTest.php @@ -58,4 +58,12 @@ public function testWhenUnknownTokenVersionThenParsingFails(): void $this->expectExceptionMessage("Token format version 'invalid' is currently not supported"); $this->validator->validate($token, ""); } + + public function testWhenTokenFormatIsMissingThenValidationFailsWithParseException(): void + { + $token = $this->removeTokenField(self::VALID_AUTH_TOKEN, "format"); + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage("Token format version 'null' is currently not supported"); + $this->validator->validate($token, ""); + } } diff --git a/tests/validator/versionvalidators/AuthTokenVersionValidatorFactoryTest.php b/tests/validator/versionvalidators/AuthTokenVersionValidatorFactoryTest.php index addb6d4..6d72d6a 100644 --- a/tests/validator/versionvalidators/AuthTokenVersionValidatorFactoryTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersionValidatorFactoryTest.php @@ -75,6 +75,16 @@ public static function unsupportedFormats(): array ]; } + public function testWhenFormatIsNullThenGetValidatorForThrowsParseException(): void + { + $factory = new AuthTokenVersionValidatorFactory([]); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage("Token format version 'null' is currently not supported"); + + $factory->getValidatorFor(null); + } + /** * @throws AuthTokenParseException */ From 171cc5eb663523431cd8d7b4b0eadb9fc9bb97c2 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:30 +0300 Subject: [PATCH 06/18] NFC-128 Increase maximum allowed token size limit --- src/validator/AuthTokenValidatorImpl.php | 2 +- tests/validator/AuthTokenStructureTest.php | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/validator/AuthTokenValidatorImpl.php b/src/validator/AuthTokenValidatorImpl.php index 829313b..67f947f 100644 --- a/src/validator/AuthTokenValidatorImpl.php +++ b/src/validator/AuthTokenValidatorImpl.php @@ -38,7 +38,7 @@ final class AuthTokenValidatorImpl implements AuthTokenValidator { private const TOKEN_MIN_LENGTH = 100; - private const TOKEN_MAX_LENGTH = 10000; + private const TOKEN_MAX_LENGTH = 65536; private AuthTokenVersionValidatorFactory $tokenValidatorFactory; private ?LoggerInterface $logger; diff --git a/tests/validator/AuthTokenStructureTest.php b/tests/validator/AuthTokenStructureTest.php index a75ceaf..68f5078 100644 --- a/tests/validator/AuthTokenStructureTest.php +++ b/tests/validator/AuthTokenStructureTest.php @@ -48,7 +48,23 @@ public function testWhenTokenTooLongThenParsingFails(): void { $this->expectException(AuthTokenParseException::class); $this->expectExceptionMessage("Auth token is too long"); - $this->validator->parse(str_repeat("1", 10001)); + $this->validator->parse(str_repeat("1", 65537)); + } + + public function testWhenTokenLongerThanPreviousLimitThenParsingSucceeds(): void + { + $token = $this->validator->parse( + self::VALID_AUTH_TOKEN . str_repeat(" ", 10001 - strlen(self::VALID_AUTH_TOKEN)) + ); + $this->assertNotNull($token->getUnverifiedCertificate()); + } + + public function testWhenTokenAtMaximumLengthThenParsingSucceeds(): void + { + $token = $this->validator->parse( + self::VALID_AUTH_TOKEN . str_repeat(" ", 65536 - strlen(self::VALID_AUTH_TOKEN)) + ); + $this->assertNotNull($token->getUnverifiedCertificate()); } public function testWhenUnknownTokenVersionThenParsingFails(): void From 56ff0a258568215ee5b3dfaa32d2412d93948c3e Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:31 +0300 Subject: [PATCH 07/18] NFC-128 Preserve the cause when certificate decoding fails --- src/certificate/CertificateLoader.php | 1 - src/exceptions/CertificateDecodingException.php | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/certificate/CertificateLoader.php b/src/certificate/CertificateLoader.php index defc25c..34835c3 100644 --- a/src/certificate/CertificateLoader.php +++ b/src/certificate/CertificateLoader.php @@ -81,7 +81,6 @@ public static function decodeCertificateFromBase64( } catch (Exception $e) { throw new CertificateDecodingException( "'{$fieldName}' decode failed", - 0, $e, ); } diff --git a/src/exceptions/CertificateDecodingException.php b/src/exceptions/CertificateDecodingException.php index 12abb57..1f08add 100644 --- a/src/exceptions/CertificateDecodingException.php +++ b/src/exceptions/CertificateDecodingException.php @@ -24,10 +24,12 @@ namespace web_eid\web_eid_authtoken_validation_php\exceptions; +use Throwable; + class CertificateDecodingException extends AuthTokenException { - public function __construct(string $resource) + public function __construct(string $resource, ?Throwable $cause = null) { - parent::__construct("Certificate decoding from Base64 or parsing failed for " . $resource); + parent::__construct("Certificate decoding from Base64 or parsing failed for " . $resource, $cause); } } From afb96d52c887be3b9d342a605e750729c21fdccf Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:32 +0300 Subject: [PATCH 08/18] NFC-128 Add intermediate certificates to auth token model --- .../UnverifiedSigningCertificate.php | 49 ++++++++ src/authtoken/WebEidAuthToken.php | 58 ++++++++- src/certificate/CertificateLoader.php | 28 +++++ tests/authtoken/WebEidAuthTokenTest.php | 70 +++++++++++ tests/certificate/CertificateLoaderTest.php | 117 ++++++++++++++++++ 5 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 tests/certificate/CertificateLoaderTest.php diff --git a/src/authtoken/UnverifiedSigningCertificate.php b/src/authtoken/UnverifiedSigningCertificate.php index 96723dc..17780f7 100644 --- a/src/authtoken/UnverifiedSigningCertificate.php +++ b/src/authtoken/UnverifiedSigningCertificate.php @@ -38,6 +38,14 @@ class UnverifiedSigningCertificate /** @var SupportedSignatureAlgorithm[] */ private array $supportedSignatureAlgorithms = []; + /** + * Unverified intermediate CA certificates of the signing certificate, + * used only as candidate certificates during certification path building. + * + * @var string[]|null + */ + private ?array $intermediateCertificates = null; + public static function fromArray(array $data): self { $result = new self(); @@ -46,6 +54,20 @@ public static function fromArray(array $data): self $result->certificate = self::filterString('certificate', $data['certificate']); } + if (isset($data['intermediateCertificates'])) { + if (!is_array($data['intermediateCertificates'])) { + $type = gettype($data['intermediateCertificates']); + throw new UnexpectedValueException( + "Error parsing Web eID authentication token: " . + "'intermediateCertificates' is {$type}, array expected" + ); + } + + $result->intermediateCertificates = self::parseIntermediateCertificates( + $data['intermediateCertificates'] + ); + } + if (isset($data['supportedSignatureAlgorithms'])) { if (!is_array($data['supportedSignatureAlgorithms'])) { $type = gettype($data['supportedSignatureAlgorithms']); @@ -73,6 +95,14 @@ public function getSupportedSignatureAlgorithms(): array return $this->supportedSignatureAlgorithms; } + /** + * @return string[]|null + */ + public function getIntermediateCertificates(): ?array + { + return $this->intermediateCertificates; + } + private static function filterString(string $key, $data): string { $type = gettype($data); @@ -85,6 +115,25 @@ private static function filterString(string $key, $data): string return $data; } + /** + * Entries are kept as-is (including null and empty strings); their content is + * validated by the version validators with format-specific error messages. + */ + private static function parseIntermediateCertificates(array $list): array + { + foreach ($list as $item) { + if ($item !== null && !is_string($item)) { + $type = gettype($item); + throw new UnexpectedValueException( + "Error parsing Web eID authentication token: " . + "'intermediateCertificates' entry is {$type}, string expected" + ); + } + } + + return array_values($list); + } + private static function parseSupportedSignatureAlgorithms(array $list): array { $result = []; diff --git a/src/authtoken/WebEidAuthToken.php b/src/authtoken/WebEidAuthToken.php index 2737162..3e40d62 100644 --- a/src/authtoken/WebEidAuthToken.php +++ b/src/authtoken/WebEidAuthToken.php @@ -48,9 +48,16 @@ class WebEidAuthToken */ private ?string $format = null; /** - * @var UnverifiedSigningCertificate[] + * @var UnverifiedSigningCertificate[]|null */ - private array $unverifiedSigningCertificates = []; + private ?array $unverifiedSigningCertificates = null; + /** + * Unverified intermediate CA certificates of the authentication certificate, + * used only as candidate certificates during certification path building. + * + * @var string[]|null + */ + private ?array $unverifiedIntermediateCertificates = null; public function __construct(string $authenticationTokenJSON) { @@ -93,6 +100,21 @@ public function __construct(string $authenticationTokenJSON) $jsonDecoded['unverifiedSigningCertificates'] ); } + + // unverifiedIntermediateCertificates + if (isset($jsonDecoded['unverifiedIntermediateCertificates'])) { + if (!is_array($jsonDecoded['unverifiedIntermediateCertificates'])) { + $type = gettype($jsonDecoded['unverifiedIntermediateCertificates']); + throw new UnexpectedValueException( + "Error parsing Web eID authentication token: " . + "'unverifiedIntermediateCertificates' is {$type}, array expected" + ); + } + + $this->unverifiedIntermediateCertificates = $this->parseIntermediateCertificates( + $jsonDecoded['unverifiedIntermediateCertificates'] + ); + } } public function getUnverifiedCertificate(): ?string @@ -115,11 +137,22 @@ public function getFormat(): ?string return $this->format; } - public function getUnverifiedSigningCertificates(): array + /** + * @return UnverifiedSigningCertificate[]|null + */ + public function getUnverifiedSigningCertificates(): ?array { return $this->unverifiedSigningCertificates; } + /** + * @return string[]|null + */ + public function getUnverifiedIntermediateCertificates(): ?array + { + return $this->unverifiedIntermediateCertificates; + } + private function filterString(string $key, $data): string { $type = gettype($data); @@ -148,4 +181,23 @@ private function parseUnverifiedSigningCertificates(array $list): array return $result; } + + /** + * Entries are kept as-is (including null and empty strings); their content is + * validated by the version validators with format-specific error messages. + */ + private function parseIntermediateCertificates(array $list): array + { + foreach ($list as $item) { + if ($item !== null && !is_string($item)) { + $type = gettype($item); + throw new UnexpectedValueException( + "Error parsing Web eID authentication token: " . + "'unverifiedIntermediateCertificates' entry is {$type}, string expected" + ); + } + } + + return array_values($list); + } } diff --git a/src/certificate/CertificateLoader.php b/src/certificate/CertificateLoader.php index 34835c3..a66279f 100644 --- a/src/certificate/CertificateLoader.php +++ b/src/certificate/CertificateLoader.php @@ -87,4 +87,32 @@ public static function decodeCertificateFromBase64( return $cert; } + + /** + * Decodes a list of base64-encoded DER certificates. + * A null or empty input list yields an empty array. + * + * @param string[]|null $certificatesInBase64 + * @return X509[] + * @throws AuthTokenParseException + * @throws CertificateDecodingException + */ + public static function decodeCertificatesFromBase64( + ?array $certificatesInBase64, + string $fieldName = "certificate", + ): array { + if ($certificatesInBase64 === null || $certificatesInBase64 === []) { + return []; + } + + $decodedCertificates = []; + foreach ($certificatesInBase64 as $certificateInBase64) { + $decodedCertificates[] = self::decodeCertificateFromBase64( + $certificateInBase64, + $fieldName, + ); + } + + return $decodedCertificates; + } } diff --git a/tests/authtoken/WebEidAuthTokenTest.php b/tests/authtoken/WebEidAuthTokenTest.php index 1e44734..b1a9fb8 100644 --- a/tests/authtoken/WebEidAuthTokenTest.php +++ b/tests/authtoken/WebEidAuthTokenTest.php @@ -53,4 +53,74 @@ public function testWhenNotAuthToken(): void $this->expectException(AuthTokenParseException::class); new WebEidAuthToken("somestring"); } + + public function testWhenSigningAndIntermediateCertificateFieldsAreAbsentThenGettersReturnNull(): void + { + $authTokenJson = '{"unverifiedCertificate": "MIIFozCCA4ugAwIBAgIQHFpdK-zCQsFW4","algorithm": "RS256","signature": "HBjNXIaUskXbfhzYQHvwjKDUWfNu4yxXZh","format": "web-eid:1.0"}'; + $authToken = new WebEidAuthToken($authTokenJson); + $this->assertNull($authToken->getUnverifiedSigningCertificates()); + $this->assertNull($authToken->getUnverifiedIntermediateCertificates()); + } + + public function testWhenUnverifiedIntermediateCertificatesArePresentThenTheyAreParsed(): void + { + $authTokenJson = '{"unverifiedCertificate": "MIIFozCCA4ugAwIBAgIQHFpdK-zCQsFW4","format": "web-eid:1.1","unverifiedIntermediateCertificates": ["MIICintermediate1", "MIICintermediate2"]}'; + $authToken = new WebEidAuthToken($authTokenJson); + $this->assertSame( + ["MIICintermediate1", "MIICintermediate2"], + $authToken->getUnverifiedIntermediateCertificates() + ); + } + + public function testWhenUnverifiedIntermediateCertificatesContainNullAndEmptyEntriesThenTheyAreKept(): void + { + $authTokenJson = '{"format": "web-eid:1.1","unverifiedIntermediateCertificates": ["MIICintermediate1", null, ""]}'; + $authToken = new WebEidAuthToken($authTokenJson); + $this->assertSame( + ["MIICintermediate1", null, ""], + $authToken->getUnverifiedIntermediateCertificates() + ); + } + + public function testWhenUnverifiedIntermediateCertificatesEntryIsNotStringThenParsingFails(): void + { + $authTokenJson = '{"format": "web-eid:1.1","unverifiedIntermediateCertificates": [123]}'; + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' entry is integer, string expected"); + new WebEidAuthToken($authTokenJson); + } + + public function testWhenUnverifiedIntermediateCertificatesIsNotArrayThenParsingFails(): void + { + $authTokenJson = '{"format": "web-eid:1.1","unverifiedIntermediateCertificates": "MIICintermediate1"}'; + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' is string, array expected"); + new WebEidAuthToken($authTokenJson); + } + + public function testWhenSigningCertificateIntermediateCertificatesArePresentThenTheyAreParsed(): void + { + $authTokenJson = '{"format": "web-eid:1.1","unverifiedSigningCertificates": [' . + '{"certificate": "MIIDsigning1","intermediateCertificates": ["MIICintermediate1", "MIICintermediate2"]},' . + '{"certificate": "MIIDsigning2"}' . + ']}'; + $authToken = new WebEidAuthToken($authTokenJson); + $signingCertificates = $authToken->getUnverifiedSigningCertificates(); + $this->assertCount(2, $signingCertificates); + $this->assertSame( + ["MIICintermediate1", "MIICintermediate2"], + $signingCertificates[0]->getIntermediateCertificates() + ); + $this->assertNull($signingCertificates[1]->getIntermediateCertificates()); + } + + public function testWhenSigningCertificateIntermediateCertificatesEntryIsNotStringThenParsingFails(): void + { + $authTokenJson = '{"format": "web-eid:1.1","unverifiedSigningCertificates": [' . + '{"certificate": "MIIDsigning1","intermediateCertificates": [true]}' . + ']}'; + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("'intermediateCertificates' entry is boolean, string expected"); + new WebEidAuthToken($authTokenJson); + } } diff --git a/tests/certificate/CertificateLoaderTest.php b/tests/certificate/CertificateLoaderTest.php new file mode 100644 index 0000000..cb3ab8e --- /dev/null +++ b/tests/certificate/CertificateLoaderTest.php @@ -0,0 +1,117 @@ +assertSame( + [], + CertificateLoader::decodeCertificatesFromBase64(null, "unverifiedIntermediateCertificates") + ); + } + + public function testWhenCertificateListIsEmptyThenEmptyArrayIsReturned(): void + { + $this->assertSame( + [], + CertificateLoader::decodeCertificatesFromBase64([], "unverifiedIntermediateCertificates") + ); + } + + public function testWhenCertificateListContainsValidCertificatesThenTheyAreDecodedInOrder(): void + { + $certificates = CertificateLoader::decodeCertificatesFromBase64( + [ + self::getCertificateInBase64("TEST_of_ESTEID2018.cer"), + self::getCertificateInBase64("ESTEID2018.cer"), + ], + "unverifiedIntermediateCertificates" + ); + + $this->assertCount(2, $certificates); + $this->assertSame( + ["TEST of ESTEID2018"], + $certificates[0]->getSubjectDNProp("id-at-commonName") + ); + $this->assertSame( + ["ESTEID2018"], + $certificates[1]->getSubjectDNProp("id-at-commonName") + ); + } + + public function testWhenCertificateListContainsNonBase64EntryThenDecodingFails(): void + { + $this->expectException(CertificateDecodingException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' decode failed"); + CertificateLoader::decodeCertificatesFromBase64( + ["not-valid-base64!!!"], + "unverifiedIntermediateCertificates" + ); + } + + public function testWhenCertificateListContainsBase64ThatIsNotCertificateThenDecodingFails(): void + { + $this->expectException(CertificateDecodingException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' decode failed"); + CertificateLoader::decodeCertificatesFromBase64( + [base64_encode("this is definitely not a DER-encoded certificate")], + "unverifiedIntermediateCertificates" + ); + } + + public function testWhenCertificateListContainsNullEntryThenParsingFails(): void + { + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' field is missing, null or empty"); + CertificateLoader::decodeCertificatesFromBase64( + [null], + "unverifiedIntermediateCertificates" + ); + } + + public function testWhenCertificateListContainsEmptyEntryThenParsingFails(): void + { + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' field is missing, null or empty"); + CertificateLoader::decodeCertificatesFromBase64( + [""], + "unverifiedIntermediateCertificates" + ); + } + + private static function getCertificateInBase64(string $resourceName): string + { + $der = file_get_contents(__DIR__ . "/../_resources/" . $resourceName); + return base64_encode($der); + } +} From 57218a0a78b5fddd44228302fcb9f582da5a9ebe Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:33 +0300 Subject: [PATCH 09/18] NFC-128 Build certification paths with token-supplied intermediate certificates --- src/certificate/CertificateValidator.php | 180 ++++++++- .../certificate/CertificateValidatorTest.php | 207 ++++++++++ tests/testutil/TestPkiBuilder.php | 359 ++++++++++++++++++ tests/testutil/TestPkiCredential.php | 84 ++++ 4 files changed, 812 insertions(+), 18 deletions(-) create mode 100644 tests/testutil/TestPkiBuilder.php create mode 100644 tests/testutil/TestPkiCredential.php diff --git a/src/certificate/CertificateValidator.php b/src/certificate/CertificateValidator.php index 16fa9e1..111a5d9 100644 --- a/src/certificate/CertificateValidator.php +++ b/src/certificate/CertificateValidator.php @@ -1,7 +1,7 @@ validateDate($date)) { $validity = $subjectCertificate->getCurrentCert()['tbsCertificate']['validity']; - $notBefore = new DateTime($validity['notBefore']['utcTime']); - $notAfter = new DateTime($validity['notAfter']['utcTime']); + $notBefore = new DateTime($validity['notBefore']['utcTime'] ?? $validity['notBefore']['generalTime']); + $notAfter = new DateTime($validity['notAfter']['utcTime'] ?? $validity['notAfter']['generalTime']); if ($date < $notBefore) { throw new CertificateNotYetValidException($subject); @@ -64,39 +70,177 @@ public static function certificateIsValidOnDate(X509 $subjectCertificate, DateTi } /** + * Validates that the certificate is currently valid and chains to a configured trusted CA, + * optionally using token-supplied intermediate CA certificates as untrusted path-building + * candidates. The built path must always terminate at a configured trust anchor. + * + * @param string $certificateSubject leaf certificate role label used in validity error + * messages, e.g. "User", "Signing" or "AIA OCSP responder" + * @param X509[] $additionalIntermediateCertificates untrusted candidate certificates + * @return X509 the certificate that directly issued the given certificate; + * the trust anchor when the anchor is the direct issuer + * * @copyright 2022 Petr Muzikant pmuzikant@email.cz */ public static function validateIsValidAndSignedByTrustedCA( X509 $certificate, TrustedCertificates $trustedCertificates, + string $certificateSubject = "User", + array $additionalIntermediateCertificates = [], + ?DateTime $now = null, ): X509 { - $now = DefaultClock::getInstance()->now(); - self::certificateIsValidOnDate($certificate, $now, "User"); + $now = $now ?? DefaultClock::getInstance()->now(); + self::certificateIsValidOnDate($certificate, $now, $certificateSubject); // Prevent SSRF via CA Issuers URI from user-provided certificate AIA. - // All trusted/intermediate CA certificates must be provided by configuration. + // All CA certificates must come from configuration or from the token itself. X509::disableURLFetch(); + [$path, $trustAnchor] = self::buildCertificationPath( + $certificate, + $trustedCertificates, + $additionalIntermediateCertificates, + $now, + ); + + // Verify that the trust anchor is presently valid; it is not covered by the checks above. + self::certificateIsValidOnDate($trustAnchor, $now, "Trusted CA"); + + // The path is ordered from the subject towards the anchor and excludes the anchor, + // so index 1 (when present) is the subject's direct issuer; otherwise the subject + // was issued directly by the trust anchor. + return count($path) > 1 ? $path[1] : $trustAnchor; + } + + public static function buildTrustFromCertificates(array $certificates): TrustedCertificates + { + return new TrustedCertificates($certificates); + } + + /** + * Builds a certification path from the given certificate to a configured trust anchor, + * verifying the signature of every link. Token-supplied intermediates are candidates + * only; a path that does not terminate at a trust anchor is rejected. + * + * @param X509[] $additionalIntermediateCertificates + * @return array{0: X509[], 1: X509} the path ordered from the subject towards the anchor, + * excluding the anchor, and the trust anchor itself + */ + private static function buildCertificationPath( + X509 $certificate, + TrustedCertificates $trustedCertificates, + array $additionalIntermediateCertificates, + DateTime $now, + ): array { + $path = [$certificate]; + $result = self::findCertificationPath( + $certificate, + $trustedCertificates, + $additionalIntermediateCertificates, + $path, + $now, + ); + if ($result !== null) { + return $result; + } + + throw new CertificateNotTrustedException($certificate); + } + + /** + * @param X509[] $candidates + * @param X509[] $path + */ + private static function findCertificationPath( + X509 $current, + TrustedCertificates $trustedCertificates, + array $candidates, + array $path, + DateTime $now, + ): ?array { + if (count($path) > self::MAX_PATH_LENGTH) { + return null; + } + + // Prefer terminating the path at a configured trust anchor. foreach ($trustedCertificates->getCertificates() as $trustedCertificate) { - $certificate->loadCA( - $trustedCertificate->saveX509($trustedCertificate->getCurrentCert(), X509::FORMAT_PEM) - ); + if (self::isSignedBy($current, $trustedCertificate)) { + return [$path, $trustedCertificate]; + } } - if ($certificate->validateSignature()) { - $chain = $certificate->getChain(); - $trustedCACert = next($chain); + foreach ($candidates as $candidate) { + // Loop protection: a certificate must not appear in the path twice. + foreach ($path as $pathCertificate) { + if ($candidate->getCurrentCert() == $pathCertificate->getCurrentCert()) { + continue 2; + } + } + // An expired or not yet valid CA certificate cannot be part of a valid path. + if (!$candidate->validateDate($now)) { + continue; + } + if (!self::isCertificateAuthority($candidate)) { + continue; + } + if (self::isSignedBy($current, $candidate)) { + $result = self::findCertificationPath( + $candidate, + $trustedCertificates, + $candidates, + [...$path, $candidate], + $now, + ); + if ($result !== null) { + return $result; + } + } + } + return null; + } - // Verify that the trusted CA cert is presently valid before returning the result. - self::certificateIsValidOnDate($trustedCACert, $now, "Trusted CA"); - return $trustedCACert; + /** + * Returns whether a token-supplied issuer candidate is permitted to issue certificates. + * A CA certificate must assert basicConstraints cA=true and, when keyUsage is present, + * it must permit certificate signing. + */ + private static function isCertificateAuthority(X509 $certificate): bool + { + $basicConstraints = $certificate->getExtension("id-ce-basicConstraints"); + if (!is_array($basicConstraints) || ($basicConstraints["cA"] ?? false) !== true) { + return false; } - throw new CertificateNotTrustedException($certificate); + $keyUsage = $certificate->getExtension("id-ce-keyUsage"); + return $keyUsage === false || + (is_array($keyUsage) && in_array("keyCertSign", $keyUsage, true)); } - public static function buildTrustFromCertificates(array $certificates): TrustedCertificates + /** + * Verifies a single certification path link: the issuer candidate's subject must match + * the certificate's issuer and the certificate's signature must verify against the + * candidate's public key. + * + * The verifier is cloned from the original certificate because phpseclib verifies the + * signature against the certificate bytes captured at load time; re-encoding a + * certificate is not guaranteed to be byte-identical. Cloning also isolates the CA + * certificates loaded for each candidate path. + */ + private static function isSignedBy(X509 $certificate, X509 $issuerCandidate): bool { - return new TrustedCertificates($certificates); + try { + // Avoid accumulating issuer candidates on the source certificate while + // alternative certification paths are explored. + $verifier = clone $certificate; + $issuerCandidatePem = $issuerCandidate->saveX509($issuerCandidate->getCurrentCert(), X509::FORMAT_PEM); + if (!$verifier->loadCA($issuerCandidatePem)) { + return false; + } + return $verifier->validateSignature() === true; + } catch (\Throwable $e) { + // An unsupported signature algorithm or malformed candidate cannot verify the link. + return false; + } } + } diff --git a/tests/certificate/CertificateValidatorTest.php b/tests/certificate/CertificateValidatorTest.php index 957908b..43bac15 100644 --- a/tests/certificate/CertificateValidatorTest.php +++ b/tests/certificate/CertificateValidatorTest.php @@ -28,6 +28,8 @@ use phpseclib3\File\X509; use web_eid\web_eid_authtoken_validation_php\testutil\Certificates; use web_eid\web_eid_authtoken_validation_php\testutil\Dates; +use web_eid\web_eid_authtoken_validation_php\testutil\TestPkiBuilder; +use web_eid\web_eid_authtoken_validation_php\testutil\TestPkiCredential; use web_eid\web_eid_authtoken_validation_php\util\TrustedCertificates; use PHPUnit\Framework\TestCase; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateExpiredException; @@ -36,6 +38,25 @@ class CertificateValidatorTest extends TestCase { + // A synthetic certification chain "root -> C -> B -> A -> leaf" for the + // token-supplied intermediate CA certificate (NFC-128) tests. + private static TestPkiBuilder $pki; + private static TestPkiCredential $root; + private static TestPkiCredential $caC; + private static TestPkiCredential $caB; + private static TestPkiCredential $caA; + private static TestPkiCredential $chainLeaf; + + public static function setUpBeforeClass(): void + { + self::$pki = new TestPkiBuilder(); + self::$root = self::$pki->buildRootCa("Test Root CA"); + self::$caC = self::$pki->buildIntermediateCa("Test CA C", self::$root); + self::$caB = self::$pki->buildIntermediateCa("Test CA B", self::$caC); + self::$caA = self::$pki->buildIntermediateCa("Test CA A", self::$caB); + self::$chainLeaf = self::$pki->buildLeaf("Test Leaf", self::$caA); + } + protected function tearDown(): void { Dates::resetMockedCertificateValidatorDate(); @@ -117,6 +138,192 @@ public function testWhenCertNotTrustedThenThrows(): void ); } + public function testWhenTokenSuppliedIntermediatesCompleteChainThenReturnsDirectIssuer(): void + { + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + null, + new DateTime() + ); + + $this->assertCertificateEquals(self::$caA, $result); + } + + public function testWhenLeafIssuedDirectlyByTrustAnchorThenReturnsAnchor(): void + { + $directLeaf = self::$pki->buildLeaf("Direct Leaf", self::$root); + + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + $directLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [], + null, + new DateTime() + ); + + $this->assertCertificateEquals(self::$root, $result); + } + + public function testWhenMidChainTrustAnchorThenPathTerminatesAtAnchor(): void + { + // The path "leaf -> A -> B" must terminate at the mid-chain anchor C even though + // the anchor itself is also offered as a token-supplied candidate. + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$caC->getCertificate()]), + "User", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + null, + new DateTime() + ); + + $this->assertCertificateEquals(self::$caA, $result); + } + + public function testWhenIntermediatesAreMissingThenThrows(): void + { + $this->expectException(CertificateNotTrustedException::class); + $this->expectExceptionMessage("Certificate CN=Test Leaf is not trusted"); + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [], + null, + new DateTime() + ); + } + + public function testWhenIntermediatesInWrongOrderThenPathIsStillBuilt(): void + { + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [self::$caC->getCertificate(), self::$caB->getCertificate(), self::$caA->getCertificate()], + null, + new DateTime() + ); + + $this->assertCertificateEquals(self::$caA, $result); + } + + public function testWhenIssuerCandidateIsNotCaThenThrows(): void + { + $endEntity = self::$pki->buildLeaf("Trusted End Entity", self::$root); + $forgedLeaf = self::$pki->buildLeaf("Forged Leaf", $endEntity); + + $this->expectException(CertificateNotTrustedException::class); + $this->expectExceptionMessage("Certificate CN=Forged Leaf is not trusted"); + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + $forgedLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [$endEntity->getCertificate()], + null, + new DateTime() + ); + } + + public function testWhenFirstIssuerCandidateReachesDeadEndThenTriesAlternativePath(): void + { + $untrustedRoot = self::$pki->buildRootCa("Untrusted Root CA"); + $untrustedIntermediate = self::$pki->buildIntermediateCa("Cross-certified CA", $untrustedRoot); + $trustedIntermediate = self::$pki->buildCrossCertificate($untrustedIntermediate, self::$root); + $leaf = self::$pki->buildLeaf("Cross-certified Leaf", $untrustedIntermediate); + + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + $leaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [$untrustedIntermediate->getCertificate(), $trustedIntermediate->getCertificate()], + null, + new DateTime() + ); + + $this->assertCertificateEquals($trustedIntermediate, $result); + } + + public function testWhenIntermediateIsExpiredThenThrows(): void + { + $expiredCaB = self::$pki->buildIntermediateCa("Expired Test CA B", self::$caC, [ + "notBefore" => new DateTime("-2 years"), + "notAfter" => new DateTime("-1 year"), + ]); + $caA = self::$pki->buildIntermediateCa("Test CA A2", $expiredCaB); + $leaf = self::$pki->buildLeaf("Test Leaf 2", $caA); + + $this->expectException(CertificateNotTrustedException::class); + $this->expectExceptionMessage("Certificate CN=Test Leaf 2 is not trusted"); + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + $leaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [$caA->getCertificate(), $expiredCaB->getCertificate(), self::$caC->getCertificate()], + null, + new DateTime() + ); + } + + public function testWhenLeafExpiredThenErrorMessageUsesCertificateSubjectLabel(): void + { + $expiredLeaf = self::$pki->buildLeaf("Expired Leaf", self::$caA, [ + "notBefore" => new DateTime("-2 years"), + "notAfter" => new DateTime("-1 year"), + ]); + + $this->expectException(CertificateExpiredException::class); + $this->expectExceptionMessage("Signing certificate has expired"); + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + $expiredLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "Signing", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + null, + new DateTime() + ); + } + + public function testWhenTrustAnchorIsExpiredThenThrowsCertificateExpiredException(): void + { + // The trust anchor is not part of the built certification path, so its validity is + // not checked during path building; the explicit anchor validity check must reject it + // while the leaf itself is currently valid. + $expiredRoot = self::$pki->buildRootCa("Expired Root CA", [ + "notBefore" => new DateTime("-2 days"), + "notAfter" => new DateTime("-1 day"), + ]); + $currentLeaf = self::$pki->buildLeaf("Current Leaf", $expiredRoot); + + $this->expectException(CertificateExpiredException::class); + $this->expectExceptionMessage("Trusted CA certificate has expired"); + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + $currentLeaf->getCertificate(), + new TrustedCertificates([$expiredRoot->getCertificate()]), + "User", + [], + null, + new DateTime() + ); + } + + private function assertCertificateEquals(TestPkiCredential $expected, X509 $actual): void + { + $this->assertEquals( + $expected->getCertificatePem(), + $actual->saveX509($actual->getCurrentCert(), X509::FORMAT_PEM) + ); + } + private function freshJaakKristjanCert(): X509 { // Load a fresh instance so that loadCA() calls from previous tests don't accumulate diff --git a/tests/testutil/TestPkiBuilder.php b/tests/testutil/TestPkiBuilder.php new file mode 100644 index 0000000..792fea5 --- /dev/null +++ b/tests/testutil/TestPkiBuilder.php @@ -0,0 +1,359 @@ +issueCertificate($commonName, null, true, $options); + } + + public function buildIntermediateCa( + string $commonName, + TestPkiCredential $issuer, + array $options = [], + ): TestPkiCredential { + return $this->issueCertificate($commonName, $issuer, true, $options); + } + + public function buildLeaf( + string $commonName, + TestPkiCredential $issuer, + array $options = [], + ): TestPkiCredential { + return $this->issueCertificate($commonName, $issuer, false, $options); + } + + public function buildOcspResponder( + string $commonName, + TestPkiCredential $issuer, + bool $withOcspSigningExtendedKeyUsage = true, + array $options = [], + ): TestPkiCredential { + if ($withOcspSigningExtendedKeyUsage) { + $options["extendedKeyUsage"] = ["id-kp-OCSPSigning"]; + } + return $this->issueCertificate($commonName, $issuer, false, $options); + } + + /** + * Builds a cross-certificate: same subject DN and same public key as the original + * certificate, but a different serial number and a different issuer. + */ + public function buildCrossCertificate( + TestPkiCredential $original, + TestPkiCredential $newIssuer, + array $options = [], + ): TestPkiCredential { + $options["keyPair"] = $original->getPrivateKey(); + return $this->issueCertificate( + self::getCommonName($original), + $newIssuer, + true, + $options, + ); + } + + /** + * Builds an impostor CA certificate: same subject DN as the original certificate, + * but a newly generated, different key pair. + */ + public function buildImpostorCa( + TestPkiCredential $original, + TestPkiCredential $issuer, + array $options = [], + ): TestPkiCredential { + return $this->issueCertificate( + self::getCommonName($original), + $issuer, + true, + $options, + ); + } + + /** + * Builds a CRL signed by the given CA that revokes the given serial numbers. + * + * @param string[] $revokedSerialNumbers decimal serial number strings + * @return string the CRL in PEM format + */ + public function buildCrl( + TestPkiCredential $issuerCa, + array $revokedSerialNumbers, + ?DateTime $thisUpdate = null, + ?DateTime $nextUpdate = null, + ): string { + $authority = $this->toSigningAuthority($issuerCa); + + // Sign an empty CRL first; X509::revoke() requires an already loaded CRL. + $signer = new X509(); + $signer->setStartDate($thisUpdate ?? new DateTime("-1 hour")); + $signer->setEndDate($nextUpdate ?? new DateTime("+1 day")); + $signed = $signer->signCRL($authority, new X509()); + if ($signed === false) { + throw new RuntimeException("Signing the test CRL failed"); + } + + if ($revokedSerialNumbers === []) { + return $signer->saveCRL($signed); + } + + $crl = new X509(); + $crl->loadCRL($signer->saveCRL($signed)); + foreach ($revokedSerialNumbers as $serialNumber) { + if (!$crl->revoke($serialNumber)) { + throw new RuntimeException("Revoking serial " . $serialNumber . " in the test CRL failed"); + } + } + + $resigner = new X509(); + $resigner->setStartDate($thisUpdate ?? new DateTime("-1 hour")); + $resigner->setEndDate($nextUpdate ?? new DateTime("+1 day")); + $resigned = $resigner->signCRL($authority, $crl); + if ($resigned === false) { + throw new RuntimeException("Re-signing the test CRL failed"); + } + return $resigner->saveCRL($resigned); + } + + /** + * Builds a DER-encoded OCSP response for the given certificate ID, signed by the + * given responder. + * + * @param array $certificateId a CertID structure as produced by Ocsp::generateCertificateId() + * @param string|array $certStatus "good", "unknown" or ["revoked" => ["revokedTime" => ...]]; + * the string "revoked" produces a revocation at the current time + */ + public function buildOcspResponseDer( + array $certificateId, + TestPkiCredential $responder, + string|array $certStatus = "good", + ?DateTime $producedAt = null, + ?DateTime $thisUpdate = null, + ?DateTime $nextUpdate = null, + ): string { + AsnUtil::loadOIDs(); + + if ($certStatus === "good") { + $certStatus = ["good" => ""]; + } elseif ($certStatus === "unknown") { + $certStatus = ["unknown" => ""]; + } elseif ($certStatus === "revoked") { + $certStatus = ["revoked" => ["revokedTime" => self::toAsnTime(new DateTime("-1 hour"))]]; + } + + $responderCertificateArray = self::toRawCertificateArray($responder->getCertificatePem()); + + $singleResponse = [ + "certID" => $certificateId, + "certStatus" => $certStatus, + "thisUpdate" => self::toAsnTime($thisUpdate ?? new DateTime("-1 minute")), + ]; + if ($nextUpdate !== null) { + $singleResponse["nextUpdate"] = self::toAsnTime($nextUpdate); + } + + $tbsResponseData = [ + "responderID" => [ + "byName" => $responderCertificateArray["tbsCertificate"]["subject"], + ], + "producedAt" => self::toAsnTime($producedAt ?? new DateTime()), + "responses" => [$singleResponse], + ]; + + $tbsResponseDataDer = ASN1::encodeDER( + $tbsResponseData, + OcspBasicResponseMap::MAP["children"]["tbsResponseData"] + ); + + $signature = $responder->getPrivateKey()->sign($tbsResponseDataDer); + + $basicResponse = [ + "tbsResponseData" => $tbsResponseData, + "signatureAlgorithm" => ["algorithm" => "ecdsa-with-SHA256"], + // The first octet of a BIT STRING value is the unused bits count. + "signature" => "\0" . $signature, + "certs" => [$responderCertificateArray], + ]; + $basicResponseDer = ASN1::encodeDER($basicResponse, OcspBasicResponseMap::MAP); + + return ASN1::encodeDER( + [ + "responseStatus" => "successful", + "responseBytes" => [ + "responseType" => "id-pkix-ocsp-basic", + "response" => $basicResponseDer, + ], + ], + OcspResponseMap::MAP + ); + } + + private function issueCertificate( + string $commonName, + ?TestPkiCredential $issuer, + bool $isCa, + array $options, + ): TestPkiCredential { + $keyPair = $options["keyPair"] ?? EC::createKey("nistP256"); + $publicKey = $keyPair->getPublicKey(); + + $subject = new X509(); + $subject->setDN(["id-at-commonName" => $commonName]); + $subject->setPublicKey($publicKey); + // computeKeyIdentifier() does not accept public key objects, so pass PEM. + $subject->setKeyIdentifier($subject->computeKeyIdentifier($publicKey->toString("PKCS8"))); + + $authority = $issuer === null + ? $this->toSelfSigningAuthority($commonName, $keyPair, $subject) + : $this->toSigningAuthority($issuer); + + $signer = new X509(); + if ($isCa) { + // Adds cA=true basicConstraints and keyCertSign+cRLSign keyUsage during signing. + $signer->makeCA(); + } else { + $signer->setExtensionValue("id-ce-keyUsage", ["digitalSignature"]); + } + $signer->setStartDate($options["notBefore"] ?? new DateTime("-1 day")); + $signer->setEndDate($options["notAfter"] ?? new DateTime("+1 year")); + $signer->setSerialNumber((string) $this->nextSerialNumber++, 10); + + if (isset($options["aiaOcspUrl"])) { + $signer->setExtensionValue("id-pe-authorityInfoAccess", [ + [ + "accessMethod" => "id-ad-ocsp", + "accessLocation" => ["uniformResourceIdentifier" => $options["aiaOcspUrl"]], + ], + ]); + } + if (isset($options["crlDistributionPointUrl"])) { + $signer->setExtensionValue("id-ce-cRLDistributionPoints", [ + [ + "distributionPoint" => [ + "fullName" => [ + ["uniformResourceIdentifier" => $options["crlDistributionPointUrl"]], + ], + ], + ], + ]); + } + if (isset($options["extendedKeyUsage"])) { + $signer->setExtensionValue("id-ce-extKeyUsage", $options["extendedKeyUsage"]); + } + + $issued = $signer->sign($authority, $subject); + if ($issued === false) { + throw new RuntimeException("Signing the test certificate " . $commonName . " failed"); + } + + // Save to PEM; TestPkiCredential always reloads it into a fresh X509 object so that + // signatureSubject is captured from the actual certificate bytes. + return new TestPkiCredential($signer->saveX509($issued), $keyPair); + } + + private function toSigningAuthority(TestPkiCredential $issuer): X509 + { + // Loading the certificate sets the issuing DN and the current key identifier + // (used for the authority key identifier extension) from the certificate. + $authority = $issuer->getCertificate(); + $authority->setPrivateKey($issuer->getPrivateKey()); + return $authority; + } + + private function toSelfSigningAuthority(string $commonName, PrivateKey $keyPair, X509 $subject): X509 + { + $authority = new X509(); + $authority->setDN(["id-at-commonName" => $commonName]); + $authority->setPrivateKey($keyPair); + $authority->setKeyIdentifier( + $subject->computeKeyIdentifier($keyPair->getPublicKey()->toString("PKCS8")) + ); + return $authority; + } + + private static function getCommonName(TestPkiCredential $credential): string + { + $commonName = $credential->getCertificate()->getSubjectDNProp("id-at-commonName"); + if (!is_array($commonName) || !isset($commonName[0])) { + throw new RuntimeException("The certificate does not have a common name"); + } + return $commonName[0]; + } + + /** + * Parses a certificate into the raw ASN.1-mapped array form that the OCSP basic + * response "certs" field uses (extension values remain unmapped octet strings). + */ + private static function toRawCertificateArray(string $certificatePem): array + { + $der = ASN1::extractBER($certificatePem); + $decoded = ASN1::decodeBER($der); + $certificate = ASN1::asn1map($decoded[0], Certificate::MAP); + if (!is_array($certificate)) { + throw new RuntimeException("Decoding the test certificate failed"); + } + return $certificate; + } + + private static function toAsnTime(DateTime $dateTime): string + { + return $dateTime->format("D, d M Y H:i:s O"); + } +} diff --git a/tests/testutil/TestPkiCredential.php b/tests/testutil/TestPkiCredential.php new file mode 100644 index 0000000..848e646 --- /dev/null +++ b/tests/testutil/TestPkiCredential.php @@ -0,0 +1,84 @@ +certificatePem = $certificatePem; + $this->privateKey = $privateKey; + } + + /** + * Returns a freshly loaded certificate object. + * + * A new instance is returned on every call because phpseclib X509 objects accumulate + * state: validateSignature() verifies against the CA certificates that loadCA() calls + * have collected on the object, so sharing instances between tests could hide failures. + * Loading from PEM also guarantees that signatureSubject is captured from the actual + * certificate bytes, which phpseclib requires for signature verification. + */ + public function getCertificate(): X509 + { + $certificate = new X509(); + $certificate->loadX509($this->certificatePem); + return $certificate; + } + + public function getCertificatePem(): string + { + return $this->certificatePem; + } + + public function getPrivateKey(): PrivateKey + { + return $this->privateKey; + } + + /** Returns the decimal string representation of the certificate serial number. */ + public function getSerialNumber(): string + { + return $this->getCertificate() + ->getCurrentCert()["tbsCertificate"]["serialNumber"] + ->toString(); + } + + public function getSubjectDN(): string + { + return $this->getCertificate()->getSubjectDN(X509::DN_STRING); + } +} From 1ce18f558c0e0556a6ba2c44c3d554bc84b1b93d Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:34 +0300 Subject: [PATCH 10/18] NFC-128 Add revocation checking for token-supplied intermediate CA certificates --- src/certificate/CertificateValidator.php | 42 +++++++++ .../IntermediateRevocationChecker.php | 50 +++++++++++ ...tificateRevocationCheckFailedException.php | 38 ++++++++ .../CertificateRevokedException.php | 38 ++++++++ .../certificate/CertificateValidatorTest.php | 87 +++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 src/certificate/IntermediateRevocationChecker.php create mode 100644 src/exceptions/CertificateRevocationCheckFailedException.php create mode 100644 src/exceptions/CertificateRevokedException.php diff --git a/src/certificate/CertificateValidator.php b/src/certificate/CertificateValidator.php index 111a5d9..9add111 100644 --- a/src/certificate/CertificateValidator.php +++ b/src/certificate/CertificateValidator.php @@ -30,6 +30,7 @@ use web_eid\web_eid_authtoken_validation_php\util\TrustedCertificates; use BadFunctionCallException; use DateTime; +use web_eid\web_eid_authtoken_validation_php\exceptions\AuthTokenException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateExpiredException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotYetValidException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotTrustedException; @@ -74,6 +75,10 @@ public static function certificateIsValidOnDate(X509 $subjectCertificate, DateTi * optionally using token-supplied intermediate CA certificates as untrusted path-building * candidates. The built path must always terminate at a configured trust anchor. * + * When an intermediate revocation checker is given, every non-anchor intermediate + * certificate of the built path is checked for revocation; an intermediate that is + * revoked or whose status cannot be established fails the validation. + * * @param string $certificateSubject leaf certificate role label used in validity error * messages, e.g. "User", "Signing" or "AIA OCSP responder" * @param X509[] $additionalIntermediateCertificates untrusted candidate certificates @@ -87,6 +92,7 @@ public static function validateIsValidAndSignedByTrustedCA( TrustedCertificates $trustedCertificates, string $certificateSubject = "User", array $additionalIntermediateCertificates = [], + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ?DateTime $now = null, ): X509 { $now = $now ?? DefaultClock::getInstance()->now(); @@ -103,6 +109,15 @@ public static function validateIsValidAndSignedByTrustedCA( $now, ); + if ($intermediateRevocationChecker !== null) { + self::validateIntermediateCertificatesNotRevoked( + $path, + $trustAnchor, + $additionalIntermediateCertificates, + $intermediateRevocationChecker, + ); + } + // Verify that the trust anchor is presently valid; it is not covered by the checks above. self::certificateIsValidOnDate($trustAnchor, $now, "Trusted CA"); @@ -243,4 +258,31 @@ private static function isSignedBy(X509 $certificate, X509 $issuerCandidate): bo } } + /** + * Checks the revocation status of the non-anchor intermediate certificates of the built + * path, i.e. everything except the leaf, whose revocation policy is role-specific and + * applied by the caller, and the trust anchor, which is not part of the built path. + * + * @param X509[] $path + * @param X509[] $additionalIntermediateCertificates + */ + private static function validateIntermediateCertificatesNotRevoked( + array $path, + X509 $trustAnchor, + array $additionalIntermediateCertificates, + IntermediateRevocationChecker $intermediateRevocationChecker, + ): void { + for ($i = 1; $i < count($path); $i++) { + $issuer = $path[$i + 1] ?? $trustAnchor; + try { + $intermediateRevocationChecker->validateNotRevoked( + $path[$i], + $issuer, + $additionalIntermediateCertificates, + ); + } catch (AuthTokenException $e) { + throw new CertificateNotTrustedException($path[$i], $e); + } + } + } } diff --git a/src/certificate/IntermediateRevocationChecker.php b/src/certificate/IntermediateRevocationChecker.php new file mode 100644 index 0000000..4800143 --- /dev/null +++ b/src/certificate/IntermediateRevocationChecker.php @@ -0,0 +1,50 @@ +getSubjectDN(X509::DN_STRING) === "CN=Test CA B") { + throw $this->exception; + } + } + }; + + try { + CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + $checker, + new DateTime() + ); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + // The exception must name the offending intermediate, not the leaf. + $this->assertSame("Certificate CN=Test CA B is not trusted", $exception->getMessage()); + $this->assertSame($checkerException, $exception->getPrevious()); + } + } + + public function testWhenCheckerGivenThenItIsCalledOncePerNonAnchorIntermediateWithDirectIssuer(): void + { + $checker = new class implements IntermediateRevocationChecker { + public array $calls = []; + + public function validateNotRevoked( + X509 $certificate, + X509 $issuerCertificate, + array $additionalIntermediateCertificates, + ): void { + $this->calls[] = [ + $certificate->getSubjectDN(X509::DN_STRING), + $issuerCertificate->getSubjectDN(X509::DN_STRING), + ]; + } + }; + + CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + $checker, + new DateTime() + ); + + // Neither the leaf nor the trust anchor is checked, each intermediate is + // checked exactly once and is paired with its direct issuer. + $this->assertSame([ + ["CN=Test CA A", "CN=Test CA B"], + ["CN=Test CA B", "CN=Test CA C"], + ["CN=Test CA C", "CN=Test Root CA"], + ], $checker->calls); + } + + public function testWhenCheckerIsNullThenNoRevocationCheckingIsDone(): void + { + $result = CertificateValidator::validateIsValidAndSignedByTrustedCA( + self::$chainLeaf->getCertificate(), + new TrustedCertificates([self::$root->getCertificate()]), + "User", + [self::$caA->getCertificate(), self::$caB->getCertificate(), self::$caC->getCertificate()], + null, + new DateTime() + ); + + $this->assertCertificateEquals(self::$caA, $result); + } + public function testWhenLeafExpiredThenErrorMessageUsesCertificateSubjectLabel(): void { $expiredLeaf = self::$pki->buildLeaf("Expired Leaf", self::$caA, [ From 1f594f24b8faaab3e8bbb58a7ef7f327313f412a Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:35 +0300 Subject: [PATCH 11/18] NFC-128 Authorize AIA OCSP responders against the subject certificate issuer --- .../SubjectCertificateNotRevokedValidator.php | 19 +++++- src/validator/ocsp/OcspResponseValidator.php | 28 +++++++++ src/validator/ocsp/OcspServiceProvider.php | 20 ++++-- src/validator/ocsp/service/AiaOcspService.php | 62 +++++++++++++++++-- ...jectCertificateNotRevokedValidatorTest.php | 35 +++++++++++ .../ocsp/OcspServiceProviderTest.php | 24 +++++-- 6 files changed, 171 insertions(+), 17 deletions(-) diff --git a/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php index c478afb..f4e71de 100644 --- a/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php +++ b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php @@ -49,14 +49,21 @@ final class SubjectCertificateNotRevokedValidator implements private OcspServiceProvider $ocspServiceProvider; private int $allowedOcspResponseTimeSkew; private int $maxOcspResponseThisUpdateAge; + /** @var X509[] */ + private array $additionalIntermediateCertificates; + /** + * @param X509[] $additionalIntermediateCertificates token-supplied untrusted intermediate CA + * certificates, offered as candidates when validating the OCSP responder certificate chain + */ public function __construct( SubjectCertificateTrustedValidator $trustValidator, OcspClient $ocspClient, OcspServiceProvider $ocspServiceProvider, int $allowedOcspResponseTimeSkew, int $maxOcspResponseThisUpdateAge, - ?LoggerInterface $logger = null + ?LoggerInterface $logger = null, + array $additionalIntermediateCertificates = [] ) { $this->logger = $logger; $this->trustValidator = $trustValidator; @@ -64,6 +71,7 @@ public function __construct( $this->ocspServiceProvider = $ocspServiceProvider; $this->allowedOcspResponseTimeSkew = $allowedOcspResponseTimeSkew; $this->maxOcspResponseThisUpdateAge = $maxOcspResponseThisUpdateAge; + $this->additionalIntermediateCertificates = $additionalIntermediateCertificates; } public function validate(X509 $subjectCertificate): void @@ -71,6 +79,8 @@ public function validate(X509 $subjectCertificate): void try { $ocspService = $this->ocspServiceProvider->getService( $subjectCertificate, + $this->trustValidator->getSubjectCertificateIssuerCertificate(), + $this->additionalIntermediateCertificates, ); if (!$ocspService->doesSupportNonce()) { @@ -139,7 +149,12 @@ private function verifyOcspResponse( ); } - if ($requestCertificateId != $basicResponse->getCertID()) { + if ( + !OcspResponseValidator::certificateIdsMatch( + $requestCertificateId, + $basicResponse->getCertID(), + ) + ) { throw new UserCertificateOCSPCheckFailedException( "OCSP responded with certificate ID that differs from the requested ID", ); diff --git a/src/validator/ocsp/OcspResponseValidator.php b/src/validator/ocsp/OcspResponseValidator.php index 57d03d0..a6c60d8 100644 --- a/src/validator/ocsp/OcspResponseValidator.php +++ b/src/validator/ocsp/OcspResponseValidator.php @@ -28,6 +28,7 @@ use DateInterval; use web_eid\web_eid_authtoken_validation_php\exceptions\OCSPCertificateException; use phpseclib3\File\X509; +use phpseclib3\Math\BigInteger; use web_eid\web_eid_authtoken_validation_php\ocsp\OcspBasicResponse; use web_eid\web_eid_authtoken_validation_php\ocsp\OcspResponse; use web_eid\web_eid_authtoken_validation_php\exceptions\UserCertificateOCSPCheckFailedException; @@ -65,6 +66,33 @@ public static function validateHasSigningExtension(X509 $certificate): void } } + /** + * Compares two OCSP CertID structures by their significant components. + * + * The hash algorithm parameters field may be absent on one side and an explicit + * ASN.1 NULL on the other, so a plain structural comparison would reject + * equivalent CertIDs. + */ + public static function certificateIdsMatch(array $requestCertificateId, array $responseCertificateId): bool + { + return $requestCertificateId["hashAlgorithm"]["algorithm"] + === $responseCertificateId["hashAlgorithm"]["algorithm"] + && $requestCertificateId["issuerNameHash"] === $responseCertificateId["issuerNameHash"] + && $requestCertificateId["issuerKeyHash"] === $responseCertificateId["issuerKeyHash"] + && self::serialNumbersMatch( + $requestCertificateId["serialNumber"], + $responseCertificateId["serialNumber"] + ); + } + + private static function serialNumbersMatch($first, $second): bool + { + if ($first instanceof BigInteger && $second instanceof BigInteger) { + return $first->equals($second); + } + return $first == $second; + } + public static function validateResponseSignature(OcspBasicResponse $basicResponse, X509 $responderCert): void { // get public key from responder certificate in order to verify signature on response diff --git a/src/validator/ocsp/OcspServiceProvider.php b/src/validator/ocsp/OcspServiceProvider.php index 5efec0d..79e9eb2 100644 --- a/src/validator/ocsp/OcspServiceProvider.php +++ b/src/validator/ocsp/OcspServiceProvider.php @@ -55,15 +55,27 @@ public function __construct( * A static factory method that returns either the designated or AIA OCSP service instance depending on whether * the designated OCSP service is configured and supports the issuer of the certificate. * - * @param certificate subject certificate that is to be checked with OCSP + * @param X509 $certificate subject certificate that is to be checked with OCSP + * @param X509 $certificateIssuerCertificate the certificate that directly issued the subject certificate + * @param X509[] $additionalIntermediateCertificates token-supplied untrusted intermediate CA certificates * @return OcspService either the designated or AIA OCSP service instance */ - public function getService(X509 $certificate): OcspService - { + public function getService( + X509 $certificate, + X509 $certificateIssuerCertificate, + array $additionalIntermediateCertificates = [] + ): OcspService { if (!is_null($this->designatedOcspService) && $this->designatedOcspService->supportsIssuerOf($certificate)) { + // The designated responder is pinned by equality, so the subject issuer and token-supplied + // intermediate certificates are not needed for its validation. return $this->designatedOcspService; } - return new AiaOcspService($this->aiaOcspServiceConfiguration, $certificate); + return new AiaOcspService( + $this->aiaOcspServiceConfiguration, + $certificate, + $certificateIssuerCertificate, + $additionalIntermediateCertificates + ); } } diff --git a/src/validator/ocsp/service/AiaOcspService.php b/src/validator/ocsp/service/AiaOcspService.php index 87434e6..a519f1a 100644 --- a/src/validator/ocsp/service/AiaOcspService.php +++ b/src/validator/ocsp/service/AiaOcspService.php @@ -1,7 +1,7 @@ url = self::getOcspAiaUrlFromCertificate($certificate); $this->trustedCACertificates = $configuration->getTrustedCACertificates(); + $this->certificateIssuerCertificate = $certificateIssuerCertificate; + $this->additionalIntermediateCertificates = $additionalIntermediateCertificates; $this->supportsNonce = !in_array( $this->url->jsonSerialize(), @@ -73,10 +90,43 @@ public function getAccessLocation(): Uri public function validateResponderCertificate(X509 $cert, DateTime $now): void { - CertificateValidator::certificateIsValidOnDate($cert, $now, "AIA OCSP responder"); - // Trusted certificates' validity has been already verified in validateCertificateExpiry(). + // Certification path validation includes the date-validity check of the responder + // certificate. Token-supplied intermediates are offered as path-building candidates. + // Responder certificates are deliberately not revocation-checked (RFC 6960 4.2.2.2.1 + // id-pkix-ocsp-nocheck convention; querying an OCSP service about its own signer + // certificate would be circular). + $responderIssuerCertificate = CertificateValidator::validateIsValidAndSignedByTrustedCA( + $cert, + $this->trustedCACertificates, + "AIA OCSP responder", + $this->additionalIntermediateCertificates, + null, + $now, + ); + OcspResponseValidator::validateHasSigningExtension($cert); - CertificateValidator::validateIsValidAndSignedByTrustedCA($cert, $this->trustedCACertificates); + + // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the + // subject certificate or by a responder directly delegated by it. CA identity is + // compared by subject and public key so that equivalent cross-certificates for the + // same CA are accepted. + if (self::representsSameCA($cert, $this->certificateIssuerCertificate)) { + // The response is signed by the issuing CA itself. + return; + } + + if (!self::representsSameCA($responderIssuerCertificate, $this->certificateIssuerCertificate)) { + throw new CertificateNotTrustedException( + $cert, + new OCSPCertificateException("OCSP responder is not authorized by the subject certificate issuer"), + ); + } + } + + private static function representsSameCA(X509 $first, X509 $second): bool + { + return $first->getSubjectDN(X509::DN_STRING) === $second->getSubjectDN(X509::DN_STRING) + && $first->getPublicKey()->toString('PKCS8') === $second->getPublicKey()->toString('PKCS8'); } private static function getOcspAiaUrlFromCertificate(X509 $certificate): Uri diff --git a/tests/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.php b/tests/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.php index e9a7d3a..d029597 100644 --- a/tests/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.php +++ b/tests/validator/certvalidators/SubjectCertificateNotRevokedValidatorTest.php @@ -372,6 +372,41 @@ public function testWhenNonceDiffersThenThrows(): void $validator->validate($this->estEid2018Cert); } + public function testWhenAdditionalIntermediateCertificatesProvidedThenForwardsThemToOcspServiceProvider(): void + { + $issuerCertificate = $this->trustedValidator->getSubjectCertificateIssuerCertificate(); + $additionalIntermediateCertificates = [Certificates::getTestEsteid2018CA()]; + + $ocspServiceProvider = $this->createMock(OcspServiceProvider::class); + $ocspServiceProvider->expects($this->once()) + ->method("getService") + ->with( + $this->identicalTo($this->estEid2018Cert), + $this->identicalTo($issuerCertificate), + $this->identicalTo($additionalIntermediateCertificates), + ) + ->willThrowException( + new UserCertificateOCSPCheckFailedException("stop after service selection"), + ); + + $validator = new SubjectCertificateNotRevokedValidator( + $this->trustedValidator, + self::$ocspClient, + $ocspServiceProvider, + $this->configuration->getAllowedOcspResponseTimeSkew(), + $this->configuration->getMaxOcspResponseThisUpdateAge(), + null, + $additionalIntermediateCertificates, + ); + + $this->expectException(UserCertificateOCSPCheckFailedException::class); + $this->expectExceptionMessage( + "User certificate revocation check has failed: stop after service selection", + ); + + $validator->validate($this->estEid2018Cert); + } + private function getSubjectCertificateNotRevokedValidatorWithAiaOcspUsingResponse( $response, ): SubjectCertificateNotRevokedValidator { diff --git a/tests/validator/ocsp/OcspServiceProviderTest.php b/tests/validator/ocsp/OcspServiceProviderTest.php index 8f83afd..204f91b 100644 --- a/tests/validator/ocsp/OcspServiceProviderTest.php +++ b/tests/validator/ocsp/OcspServiceProviderTest.php @@ -39,7 +39,10 @@ class OcspServiceProviderTest extends TestCase public function testWhenDesignatedOcspServiceConfigurationProvidedThenCreatesDesignatedOcspService(): void { $ocspServiceProvider = OcspServiceMaker::getDesignatedOcspServiceProvider(); - $service = $ocspServiceProvider->getService(Certificates::getJaakKristjanEsteid2018Cert()); + $service = $ocspServiceProvider->getService( + Certificates::getJaakKristjanEsteid2018Cert(), + Certificates::getTestEsteid2018CA() + ); $this->assertEquals($service->getAccessLocation(), new Uri("http://demo.sk.ee/ocsp")); $this->assertTrue($service->doesSupportNonce()); @@ -59,7 +62,10 @@ public function testWhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspSer $ocspServiceProvider = OcspServiceMaker::getAiaOcspServiceProvider(); - $service2018 = $ocspServiceProvider->getService(Certificates::getJaakKristjanEsteid2018Cert()); + $service2018 = $ocspServiceProvider->getService( + Certificates::getJaakKristjanEsteid2018Cert(), + Certificates::getTestEsteid2018CA() + ); $this->assertEquals($service2018->getAccessLocation()->jsonSerialize(), (new Uri("http://aia.demo.sk.ee/esteid2018"))->jsonSerialize()); $this->assertTrue($service2018->doesSupportNonce()); @@ -67,7 +73,10 @@ public function testWhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspSer $service2018->validateResponderCertificate(Certificates::getTestEsteid2018CA(), new DateTime('Thursday, August 26, 2021 5:46:40 PM')); // Responder certificate issuer is not in trusted certificates - $service2015 = $ocspServiceProvider->getService(Certificates::getMariLiisEsteid2015Cert()); + $service2015 = $ocspServiceProvider->getService( + Certificates::getMariLiisEsteid2015Cert(), + Certificates::getTestEsteid2015CA() + ); $this->assertEquals($service2015->getAccessLocation()->jsonSerialize(), (new Uri("http://aia.demo.sk.ee/esteid2015"))->jsonSerialize()); $this->assertFalse($service2015->doesSupportNonce()); @@ -79,10 +88,15 @@ public function testWhenAiaOcspServiceConfigurationProvidedThenCreatesAiaOcspSer public function testWhenAiaOcspServiceConfigurationDoesNotHaveResponderCertTrustedCaThenThrows(): void { $ocspServiceProvider = OcspServiceMaker::getAiaOcspServiceProvider(); - $service2018 = $ocspServiceProvider->getService(Certificates::getJaakKristjanEsteid2018Cert()); + $service2018 = $ocspServiceProvider->getService( + Certificates::getJaakKristjanEsteid2018Cert(), + Certificates::getTestEsteid2018CA() + ); + // The responder certificate is not signed by a trusted CA, so certification path + // validation fails before the delegated responder checks are reached. $wrongResponderCert = Certificates::getMariliisEsteid2015Cert(); - $this->expectException(OCSPCertificateException::class); + $this->expectException(CertificateNotTrustedException::class); $service2018->validateResponderCertificate($wrongResponderCert, new DateTime("Thursday, August 26, 2021 5:46:40 PM")); } From 66b5ea7c6f5fa4dab41a5b40c07450ccf6b851ce Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:36 +0300 Subject: [PATCH 12/18] NFC-128 Require the OCSP-signing extended key usage only for delegated responder certificates --- src/validator/ocsp/OcspResponseValidator.php | 2 +- src/validator/ocsp/service/AiaOcspService.php | 7 +- .../ocsp/service/AiaOcspServiceTest.php | 217 ++++++++++++++++++ 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 tests/validator/ocsp/service/AiaOcspServiceTest.php diff --git a/src/validator/ocsp/OcspResponseValidator.php b/src/validator/ocsp/OcspResponseValidator.php index a6c60d8..a6e45fb 100644 --- a/src/validator/ocsp/OcspResponseValidator.php +++ b/src/validator/ocsp/OcspResponseValidator.php @@ -61,7 +61,7 @@ public static function validateHasSigningExtension(X509 $certificate): void throw new OCSPCertificateException( "Certificate " . $certificate->getSubjectDN(X509::DN_STRING) . - " does not contain the key usage extension for OCSP response signing" + " does not contain the extended key usage extension value for OCSP response signing" ); } } diff --git a/src/validator/ocsp/service/AiaOcspService.php b/src/validator/ocsp/service/AiaOcspService.php index a519f1a..e1a564f 100644 --- a/src/validator/ocsp/service/AiaOcspService.php +++ b/src/validator/ocsp/service/AiaOcspService.php @@ -104,17 +104,18 @@ public function validateResponderCertificate(X509 $cert, DateTime $now): void $now, ); - OcspResponseValidator::validateHasSigningExtension($cert); - // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the // subject certificate or by a responder directly delegated by it. CA identity is // compared by subject and public key so that equivalent cross-certificates for the // same CA are accepted. if (self::representsSameCA($cert, $this->certificateIssuerCertificate)) { - // The response is signed by the issuing CA itself. + // The response is signed by the issuing CA itself; the OCSP-signing extended key + // usage is required only for delegated responder certificates. return; } + OcspResponseValidator::validateHasSigningExtension($cert); + if (!self::representsSameCA($responderIssuerCertificate, $this->certificateIssuerCertificate)) { throw new CertificateNotTrustedException( $cert, diff --git a/tests/validator/ocsp/service/AiaOcspServiceTest.php b/tests/validator/ocsp/service/AiaOcspServiceTest.php new file mode 100644 index 0000000..4aa4ac8 --- /dev/null +++ b/tests/validator/ocsp/service/AiaOcspServiceTest.php @@ -0,0 +1,217 @@ +buildRootCa("Test Root CA"); + self::$intermediate = self::$pki->buildIntermediateCa("Test Intermediate CA", self::$root); + self::$leaf = self::$pki->buildLeaf("Test Leaf", self::$intermediate, [ + "aiaOcspUrl" => "http://aia.example.com/ocsp", + ]); + self::$delegatedResponder = self::$pki->buildOcspResponder("Test OCSP Responder", self::$intermediate); + self::$siblingCa = self::$pki->buildIntermediateCa("Test Sibling CA", self::$root); + self::$siblingResponder = self::$pki->buildOcspResponder("Test Sibling OCSP Responder", self::$siblingCa); + // Same subject DN and public key as the intermediate CA, different serial and issuer. + self::$crossIntermediate = self::$pki->buildCrossCertificate( + self::$intermediate, + self::$pki->buildRootCa("Other Root CA") + ); + // Same subject DN as the intermediate CA, different key. + self::$impostorIntermediate = self::$pki->buildImpostorCa(self::$intermediate, self::$root); + self::$rootDelegatedResponder = self::$pki->buildOcspResponder("Root Delegated OCSP Responder", self::$root); + self::$responderWithoutEku = self::$pki->buildOcspResponder( + "No EKU OCSP Responder", + self::$intermediate, + false + ); + } + + public function testWhenResponderIsDelegatedByIssuerAndIntermediateIsSuppliedThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + $service = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [self::$intermediate->getCertificate()] + ); + $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); + } + + public function testWhenIntermediateIsNotSuppliedThenResponderPathBuildingFails(): void + { + $this->expectException(CertificateNotTrustedException::class); + $this->expectExceptionMessage("Certificate CN=Test OCSP Responder is not trusted"); + + $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), []); + $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); + } + + public function testWhenIssuerIsEquivalentCrossCertificateThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + // The subject certificate issuer is represented by an equivalent cross-certificate + // that has the same subject DN and public key, but a different serial number. + $service = $this->getAiaOcspService( + self::$crossIntermediate->getCertificate(), + [self::$intermediate->getCertificate()] + ); + $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); + } + + public function testWhenResponderIsDelegatedBySiblingCaThenFails(): void + { + $service = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [self::$siblingCa->getCertificate()] + ); + + try { + $service->validateResponderCertificate(self::$siblingResponder->getCertificate(), new DateTime()); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + $this->assertSame( + "Certificate CN=Test Sibling OCSP Responder is not trusted", + $exception->getMessage() + ); + $this->assertInstanceOf(OCSPCertificateException::class, $exception->getPrevious()); + $this->assertSame(self::NOT_AUTHORIZED_MESSAGE, $exception->getPrevious()->getMessage()); + } + } + + public function testWhenIssuerIsImpostorWithSameNameButDifferentKeyThenFails(): void + { + // The responder chains to the genuine intermediate CA, but the subject certificate + // issuer is an impostor CA with the same subject DN and a different key, so the + // CA identity comparison must fail. + $service = $this->getAiaOcspService( + self::$impostorIntermediate->getCertificate(), + [self::$intermediate->getCertificate()] + ); + + try { + $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + $this->assertSame("Certificate CN=Test OCSP Responder is not trusted", $exception->getMessage()); + $this->assertInstanceOf(OCSPCertificateException::class, $exception->getPrevious()); + $this->assertSame(self::NOT_AUTHORIZED_MESSAGE, $exception->getPrevious()->getMessage()); + } + } + + public function testWhenResponderIsDelegatedByRootInsteadOfIssuerThenFails(): void + { + $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), []); + + try { + $service->validateResponderCertificate(self::$rootDelegatedResponder->getCertificate(), new DateTime()); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + $this->assertSame( + "Certificate CN=Root Delegated OCSP Responder is not trusted", + $exception->getMessage() + ); + $this->assertInstanceOf(OCSPCertificateException::class, $exception->getPrevious()); + $this->assertSame(self::NOT_AUTHORIZED_MESSAGE, $exception->getPrevious()->getMessage()); + } + } + + public function testWhenResponderIsTheIssuingCaItselfThenOcspSigningEkuIsNotRequired(): void + { + $this->expectNotToPerformAssertions(); + + // The issuing CA signs its own OCSP responses; the intermediate CA certificate + // does not have the OCSP-signing extended key usage. + $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), []); + $service->validateResponderCertificate(self::$intermediate->getCertificate(), new DateTime()); + } + + public function testWhenDelegatedResponderDoesNotHaveOcspSigningEkuThenThrows(): void + { + $this->expectException(OCSPCertificateException::class); + $this->expectExceptionMessage( + "Certificate CN=No EKU OCSP Responder does not contain the extended key usage " . + "extension value for OCSP response signing" + ); + + $service = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [self::$intermediate->getCertificate()] + ); + $service->validateResponderCertificate(self::$responderWithoutEku->getCertificate(), new DateTime()); + } + + /** + * @param X509[] $additionalIntermediateCertificates + */ + private function getAiaOcspService( + X509 $certificateIssuerCertificate, + array $additionalIntermediateCertificates, + ): AiaOcspService { + $configuration = new AiaOcspServiceConfiguration( + new UriCollection(), + new TrustedCertificates([self::$root->getCertificate()]) + ); + return new AiaOcspService( + $configuration, + self::$leaf->getCertificate(), + $certificateIssuerCertificate, + $additionalIntermediateCertificates + ); + } +} From b127ca61be051100c73977d076e3ed3d01a0c9d8 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:37 +0300 Subject: [PATCH 13/18] NFC-128 Implement OCSP and CRL revocation checking for intermediate CA certificates --- src/validator/ocsp/CrlClient.php | 42 +++ src/validator/ocsp/CrlClientImpl.php | 76 ++++ .../IntermediateRevocationCheckerImpl.php | 341 +++++++++++++++++ tests/testutil/TestPkiBuilder.php | 10 +- .../IntermediateRevocationCheckerImplTest.php | 342 ++++++++++++++++++ 5 files changed, 809 insertions(+), 2 deletions(-) create mode 100644 src/validator/ocsp/CrlClient.php create mode 100644 src/validator/ocsp/CrlClientImpl.php create mode 100644 src/validator/ocsp/IntermediateRevocationCheckerImpl.php create mode 100644 tests/validator/ocsp/IntermediateRevocationCheckerImplTest.php diff --git a/src/validator/ocsp/CrlClient.php b/src/validator/ocsp/CrlClient.php new file mode 100644 index 0000000..cd0f837 --- /dev/null +++ b/src/validator/ocsp/CrlClient.php @@ -0,0 +1,42 @@ +requestTimeout = $requestTimeout; + $this->logger = $logger; + } + + public static function build(int $requestTimeout, ?LoggerInterface $logger = null): CrlClient + { + return new CrlClientImpl($requestTimeout, $logger); + } + + public function fetch(Uri $uri): string + { + $this->logger?->debug("Fetching CRL from " . $uri->jsonSerialize()); + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_URL, $uri->jsonSerialize()); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_FAILONERROR, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $this->requestTimeout); + curl_setopt($curl, CURLOPT_TIMEOUT, $this->requestTimeout); + $result = curl_exec($curl); + + if (curl_errno($curl)) { + throw new CertificateRevocationCheckFailedException( + "CRL request failed: " . curl_error($curl) + ); + } + + $info = curl_getinfo($curl); + if ($info["http_code"] !== 200 || !is_string($result) || $result === "") { + throw new CertificateRevocationCheckFailedException( + "CRL request was not successful, HTTP status: " . $info["http_code"] + ); + } + + return $result; + } +} diff --git a/src/validator/ocsp/IntermediateRevocationCheckerImpl.php b/src/validator/ocsp/IntermediateRevocationCheckerImpl.php new file mode 100644 index 0000000..48ebd4d --- /dev/null +++ b/src/validator/ocsp/IntermediateRevocationCheckerImpl.php @@ -0,0 +1,341 @@ +ocspClient = $ocspClient; + $this->crlClient = $crlClient; + $this->aiaOcspServiceConfiguration = $aiaOcspServiceConfiguration; + $this->allowedTimeSkew = $allowedOcspResponseTimeSkew; + $this->maxThisUpdateAge = $maxOcspResponseThisUpdateAge; + $this->logger = $logger; + } + + public function validateNotRevoked( + X509 $certificate, + X509 $issuerCertificate, + array $additionalIntermediateCertificates, + ): void { + $ocspFailure = null; + + if ($this->hasOcspUrl($certificate)) { + try { + $this->checkWithOcsp($certificate, $issuerCertificate, $additionalIntermediateCertificates); + $this->logger?->debug("Intermediate CA certificate OCSP check result is GOOD"); + return; + } catch (CertificateRevokedException $e) { + // A definitive revoked status must not be overridden by a CRL fallback. + throw $e; + } catch (Exception $e) { + $this->logger?->debug( + "Intermediate CA certificate OCSP check was inconclusive, " . + "falling back to CRL: " . $e->getMessage() + ); + $ocspFailure = $e; + } + } + + $this->checkWithCrl($certificate, $issuerCertificate, $ocspFailure); + $this->logger?->debug("Intermediate CA certificate CRL check result is GOOD"); + } + + private function hasOcspUrl(X509 $certificate): bool + { + try { + return OcspUrl::getOcspUri($certificate) !== null; + } catch (Exception $e) { + return false; + } + } + + /** + * @param X509[] $additionalIntermediateCertificates + */ + private function checkWithOcsp( + X509 $certificate, + X509 $issuerCertificate, + array $additionalIntermediateCertificates, + ): void { + $ocspService = new AiaOcspService( + $this->aiaOcspServiceConfiguration, + $certificate, + $issuerCertificate, + $additionalIntermediateCertificates, + ); + + $certificateId = (new Ocsp())->generateCertificateId($certificate, $issuerCertificate); + $request = (new OcspRequestBuilder()) + ->withCertificateId($certificateId) + ->enableOcspNonce(false) + ->build(); + + $response = $this->ocspClient->request( + $ocspService->getAccessLocation(), + $request->getEncodeDer(), + ); + + if ($response->getStatus() != "successful") { + throw new CertificateRevocationCheckFailedException( + "OCSP response status: " . $response->getStatus() + ); + } + + $basicResponse = $response->getBasicResponse(); + + if (count($basicResponse->getResponses()) != 1) { + throw new CertificateRevocationCheckFailedException( + "OCSP response must contain one response, received " . + count($basicResponse->getResponses()) . " responses instead" + ); + } + + if (!OcspResponseValidator::certificateIdsMatch($certificateId, $basicResponse->getCertID())) { + throw new CertificateRevocationCheckFailedException( + "OCSP responded with certificate ID that differs from the requested ID" + ); + } + + if (count($basicResponse->getCertificates()) < 1) { + throw new CertificateRevocationCheckFailedException( + "OCSP response must contain the responder certificate, but none was provided" + ); + } + + $responderCert = $basicResponse->getCertificates()[0]; + OcspResponseValidator::validateResponseSignature($basicResponse, $responderCert); + + $now = DefaultClock::getInstance()->now(); + $ocspService->validateResponderCertificate($responderCert, $now); + + $this->validateOcspResponseTimes($basicResponse, $now); + + if ($response->isRevoked() === true) { + $reason = $response->getRevokeReason(); + throw new CertificateRevokedException( + "Intermediate CA certificate has been revoked" . + ($reason == "" ? "" : ": Revocation reason: " . $reason) + ); + } + if ($response->isRevoked() !== false) { + throw new CertificateRevocationCheckFailedException( + "Intermediate CA certificate OCSP status is unknown" + ); + } + } + + /** + * Validates that the current time is within the thisUpdate/nextUpdate validity window + * of the OCSP response. When nextUpdate is absent, the user certificate maximum + * response age policy is applied instead. + */ + private function validateOcspResponseTimes(OcspBasicResponse $basicResponse, DateTime $now): void + { + $skew = new DateInterval('PT' . $this->allowedTimeSkew . 'M'); + + $thisUpdate = $basicResponse->getThisUpdate(); + if ($thisUpdate > (clone $now)->add($skew)) { + throw new CertificateRevocationCheckFailedException( + "OCSP response thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . + "' is too far in the future" + ); + } + + $nextUpdate = $basicResponse->getNextUpdate(); + if ($nextUpdate === null) { + $minimumValidThisUpdateTime = (clone $now) + ->sub(new DateInterval('PT' . $this->maxThisUpdateAge . 'M')); + if ($thisUpdate < $minimumValidThisUpdateTime) { + throw new CertificateRevocationCheckFailedException( + "OCSP response thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too old" + ); + } + return; + } + + if ($nextUpdate < (clone $now)->sub($skew)) { + throw new CertificateRevocationCheckFailedException( + "OCSP response nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is in the past" + ); + } + } + + private function checkWithCrl( + X509 $certificate, + X509 $issuerCertificate, + ?Exception $ocspFailure, + ): void { + $urls = self::getCrlDistributionPointUrls($certificate); + + if ($urls === []) { + throw new CertificateRevocationCheckFailedException( + "Revocation status of the intermediate CA certificate could not be established: " . + "no usable OCSP or CRL revocation source", + $ocspFailure, + ); + } + + $lastFailure = $ocspFailure; + foreach ($urls as $url) { + try { + $this->validateWithCrlFrom($url, $certificate, $issuerCertificate); + return; + } catch (CertificateRevokedException $e) { + throw $e; + } catch (Exception $e) { + $this->logger?->debug("CRL check via " . $url->jsonSerialize() . " failed: " . $e->getMessage()); + $lastFailure = $e; + } + } + + throw new CertificateRevocationCheckFailedException( + "Revocation status of the intermediate CA certificate could not be established", + $lastFailure, + ); + } + + private function validateWithCrlFrom(Uri $url, X509 $certificate, X509 $issuerCertificate): void + { + $crlBytes = $this->crlClient->fetch($url); + + $crl = new X509(); + if (!$crl->loadCRL($crlBytes)) { + throw new CertificateRevocationCheckFailedException("CRL decoding failed"); + } + + $crl->loadCA($issuerCertificate->saveX509($issuerCertificate->getCurrentCert(), X509::FORMAT_PEM)); + if ($crl->validateSignature() !== true) { + throw new CertificateRevocationCheckFailedException( + "CRL signature verification against the certificate issuer failed" + ); + } + + $this->validateCrlTimes($crl, DefaultClock::getInstance()->now()); + + $serialNumber = $certificate->getCurrentCert()['tbsCertificate']['serialNumber']; + if ($crl->getRevoked($serialNumber->toString()) !== false) { + throw new CertificateRevokedException( + "Intermediate CA certificate has been revoked according to CRL" + ); + } + } + + private function validateCrlTimes(X509 $crl, DateTime $now): void + { + $tbsCertList = $crl->getCurrentCert()['tbsCertList']; + $skew = new DateInterval('PT' . $this->allowedTimeSkew . 'M'); + + $thisUpdateField = $tbsCertList['thisUpdate']; + $thisUpdate = new DateTime($thisUpdateField['utcTime'] ?? $thisUpdateField['generalTime']); + if ($thisUpdate > (clone $now)->add($skew)) { + throw new CertificateRevocationCheckFailedException( + "CRL thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too far in the future" + ); + } + + if (!isset($tbsCertList['nextUpdate'])) { + throw new CertificateRevocationCheckFailedException("CRL nextUpdate is missing"); + } + + $nextUpdateField = $tbsCertList['nextUpdate']; + $nextUpdate = new DateTime($nextUpdateField['utcTime'] ?? $nextUpdateField['generalTime']); + if ($nextUpdate < (clone $now)->sub($skew)) { + throw new CertificateRevocationCheckFailedException( + "CRL nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is in the past" + ); + } + } + + /** + * @return Uri[] + */ + private static function getCrlDistributionPointUrls(X509 $certificate): array + { + $urls = []; + $extension = $certificate->getExtension("id-ce-cRLDistributionPoints"); + if (!is_array($extension)) { + return $urls; + } + + foreach ($extension as $distributionPoint) { + $fullNames = $distributionPoint["distributionPoint"]["fullName"] ?? []; + foreach ($fullNames as $generalName) { + $url = $generalName["uniformResourceIdentifier"] ?? null; + if (is_string($url) && preg_match('/^https?:\/\//i', $url) === 1) { + $urls[] = new Uri($url); + } + } + } + + return $urls; + } +} diff --git a/tests/testutil/TestPkiBuilder.php b/tests/testutil/TestPkiBuilder.php index 792fea5..b3158c8 100644 --- a/tests/testutil/TestPkiBuilder.php +++ b/tests/testutil/TestPkiBuilder.php @@ -131,6 +131,7 @@ public function buildImpostorCa( * Builds a CRL signed by the given CA that revokes the given serial numbers. * * @param string[] $revokedSerialNumbers decimal serial number strings + * @param bool $includeNextUpdate whether the CRL contains a nextUpdate field * @return string the CRL in PEM format */ public function buildCrl( @@ -138,13 +139,16 @@ public function buildCrl( array $revokedSerialNumbers, ?DateTime $thisUpdate = null, ?DateTime $nextUpdate = null, + bool $includeNextUpdate = true, ): string { $authority = $this->toSigningAuthority($issuerCa); // Sign an empty CRL first; X509::revoke() requires an already loaded CRL. $signer = new X509(); $signer->setStartDate($thisUpdate ?? new DateTime("-1 hour")); - $signer->setEndDate($nextUpdate ?? new DateTime("+1 day")); + if ($includeNextUpdate) { + $signer->setEndDate($nextUpdate ?? new DateTime("+1 day")); + } $signed = $signer->signCRL($authority, new X509()); if ($signed === false) { throw new RuntimeException("Signing the test CRL failed"); @@ -164,7 +168,9 @@ public function buildCrl( $resigner = new X509(); $resigner->setStartDate($thisUpdate ?? new DateTime("-1 hour")); - $resigner->setEndDate($nextUpdate ?? new DateTime("+1 day")); + if ($includeNextUpdate) { + $resigner->setEndDate($nextUpdate ?? new DateTime("+1 day")); + } $resigned = $resigner->signCRL($authority, $crl); if ($resigned === false) { throw new RuntimeException("Re-signing the test CRL failed"); diff --git a/tests/validator/ocsp/IntermediateRevocationCheckerImplTest.php b/tests/validator/ocsp/IntermediateRevocationCheckerImplTest.php new file mode 100644 index 0000000..9ced878 --- /dev/null +++ b/tests/validator/ocsp/IntermediateRevocationCheckerImplTest.php @@ -0,0 +1,342 @@ +buildRootCa("Test Root CA"); + // The certificates whose revocation status is checked are intermediate CAs + // issued by the trusted root, with varying revocation source extensions. + self::$caWithoutRevocationSources = self::$pki->buildIntermediateCa("No Sources CA", self::$root); + self::$caWithCrl = self::$pki->buildIntermediateCa("CRL CA", self::$root, [ + "crlDistributionPointUrl" => self::CRL_URL, + ]); + self::$caWithOcsp = self::$pki->buildIntermediateCa("OCSP CA", self::$root, [ + "aiaOcspUrl" => self::OCSP_URL, + ]); + self::$caWithOcspAndCrl = self::$pki->buildIntermediateCa("OCSP and CRL CA", self::$root, [ + "aiaOcspUrl" => self::OCSP_URL, + "crlDistributionPointUrl" => self::CRL_URL, + ]); + self::$otherCa = self::$pki->buildIntermediateCa("Other CA", self::$root); + self::$rootDelegatedResponder = self::$pki->buildOcspResponder("Root OCSP Responder", self::$root); + } + + public function testWhenNoOcspUrlAndNoCrlDistributionPointThenThrows(): void + { + $this->expectException(CertificateRevocationCheckFailedException::class); + $this->expectExceptionMessage("no usable OCSP or CRL revocation source"); + + $checker = $this->getChecker(self::unusedOcspClient(), self::unusedCrlClient()); + $checker->validateNotRevoked( + self::$caWithoutRevocationSources->getCertificate(), + self::$root->getCertificate(), + [] + ); + } + + public function testWhenCrlDoesNotRevokeCertificateThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + $crl = self::$pki->buildCrl(self::$root, [self::$otherCa->getSerialNumber()]); + $checker = $this->getChecker(self::unusedOcspClient(), self::staticCrlClient($crl)); + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + } + + public function testWhenCrlRevokesCertificateThenThrows(): void + { + $this->expectException(CertificateRevokedException::class); + $this->expectExceptionMessage("Intermediate CA certificate has been revoked according to CRL"); + + $crl = self::$pki->buildCrl(self::$root, [self::$caWithCrl->getSerialNumber()]); + $checker = $this->getChecker(self::unusedOcspClient(), self::staticCrlClient($crl)); + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + } + + public function testWhenCrlIsSignedByWrongCaThenThrows(): void + { + $crl = self::$pki->buildCrl(self::$otherCa, []); + $checker = $this->getChecker(self::unusedOcspClient(), self::staticCrlClient($crl)); + + try { + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + $this->fail("Expected " . CertificateRevocationCheckFailedException::class . " was not thrown"); + } catch (CertificateRevocationCheckFailedException $exception) { + $this->assertSame( + "Revocation status of the intermediate CA certificate could not be established", + $exception->getMessage() + ); + $this->assertSame( + "CRL signature verification against the certificate issuer failed", + $exception->getPrevious()->getMessage() + ); + } + } + + public function testWhenCrlIsStaleThenThrows(): void + { + $staleCrl = self::$pki->buildCrl( + self::$root, + [], + new DateTime("-2 days"), + new DateTime("-1 day") + ); + $checker = $this->getChecker(self::unusedOcspClient(), self::staticCrlClient($staleCrl)); + + try { + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + $this->fail("Expected " . CertificateRevocationCheckFailedException::class . " was not thrown"); + } catch (CertificateRevocationCheckFailedException $exception) { + $this->assertSame( + "Revocation status of the intermediate CA certificate could not be established", + $exception->getMessage() + ); + $this->assertStringContainsString("is in the past", $exception->getPrevious()->getMessage()); + } + } + + public function testWhenCrlHasNoNextUpdateThenThrows(): void + { + $crl = self::$pki->buildCrl( + self::$root, + [], + includeNextUpdate: false + ); + $checker = $this->getChecker(self::unusedOcspClient(), self::staticCrlClient($crl)); + + try { + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + $this->fail("Expected " . CertificateRevocationCheckFailedException::class . " was not thrown"); + } catch (CertificateRevocationCheckFailedException $exception) { + $this->assertSame( + "Revocation status of the intermediate CA certificate could not be established", + $exception->getMessage() + ); + $this->assertSame("CRL nextUpdate is missing", $exception->getPrevious()->getMessage()); + } + } + + public function testWhenCrlClientThrowsThenThrows(): void + { + $crlClient = new class implements CrlClient { + public function fetch(Uri $uri): string + { + throw new CertificateRevocationCheckFailedException("CRL fetch failed"); + } + }; + $checker = $this->getChecker(self::unusedOcspClient(), $crlClient); + + try { + $checker->validateNotRevoked(self::$caWithCrl->getCertificate(), self::$root->getCertificate(), []); + $this->fail("Expected " . CertificateRevocationCheckFailedException::class . " was not thrown"); + } catch (CertificateRevocationCheckFailedException $exception) { + $this->assertSame( + "Revocation status of the intermediate CA certificate could not be established", + $exception->getMessage() + ); + $this->assertSame("CRL fetch failed", $exception->getPrevious()->getMessage()); + } + } + + public function testWhenOcspRespondsGoodThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + $responseDer = self::$pki->buildOcspResponseDer( + $this->generateCertificateId(self::$caWithOcsp), + self::$rootDelegatedResponder, + "good", + null, + null, + new DateTime("+1 hour") + ); + $checker = $this->getChecker(self::staticOcspClient($responseDer), self::unusedCrlClient()); + $checker->validateNotRevoked(self::$caWithOcsp->getCertificate(), self::$root->getCertificate(), []); + } + + public function testWhenOcspRespondsRevokedThenThrowsWithoutCrlFallback(): void + { + $responseDer = self::$pki->buildOcspResponseDer( + $this->generateCertificateId(self::$caWithOcspAndCrl), + self::$rootDelegatedResponder, + "revoked" + ); + // The CRL that does not revoke the certificate must not override the definitive + // OCSP revoked status. + $crlWithoutRevocation = self::$pki->buildCrl(self::$root, []); + $checker = $this->getChecker( + self::staticOcspClient($responseDer), + self::staticCrlClient($crlWithoutRevocation) + ); + + $this->expectException(CertificateRevokedException::class); + $this->expectExceptionMessage("Intermediate CA certificate has been revoked"); + + $checker->validateNotRevoked(self::$caWithOcspAndCrl->getCertificate(), self::$root->getCertificate(), []); + } + + public function testWhenOcspIsInconclusiveThenFallsBackToCrl(): void + { + $this->expectNotToPerformAssertions(); + + $ocspClient = new class implements OcspClient { + public function request(Uri $url, string $request): OcspResponse + { + throw new RuntimeException("Connection to the OCSP responder failed"); + } + }; + $crl = self::$pki->buildCrl(self::$root, [self::$otherCa->getSerialNumber()]); + $checker = $this->getChecker($ocspClient, self::staticCrlClient($crl)); + $checker->validateNotRevoked(self::$caWithOcspAndCrl->getCertificate(), self::$root->getCertificate(), []); + } + + public function testWhenOcspRespondsUnknownAndNoCrlThenThrows(): void + { + $responseDer = self::$pki->buildOcspResponseDer( + $this->generateCertificateId(self::$caWithOcsp), + self::$rootDelegatedResponder, + "unknown" + ); + $checker = $this->getChecker(self::staticOcspClient($responseDer), self::unusedCrlClient()); + + try { + $checker->validateNotRevoked(self::$caWithOcsp->getCertificate(), self::$root->getCertificate(), []); + $this->fail("Expected " . CertificateRevocationCheckFailedException::class . " was not thrown"); + } catch (CertificateRevocationCheckFailedException $exception) { + $this->assertStringContainsString("no usable OCSP or CRL revocation source", $exception->getMessage()); + $this->assertSame( + "Intermediate CA certificate OCSP status is unknown", + $exception->getPrevious()->getMessage() + ); + } + } + + private function getChecker(OcspClient $ocspClient, CrlClient $crlClient): IntermediateRevocationCheckerImpl + { + $configuration = new AiaOcspServiceConfiguration( + new UriCollection(), + new TrustedCertificates([self::$root->getCertificate()]) + ); + return new IntermediateRevocationCheckerImpl( + $ocspClient, + $crlClient, + $configuration, + self::ALLOWED_TIME_SKEW_MINUTES, + self::MAX_THIS_UPDATE_AGE_MINUTES + ); + } + + private function generateCertificateId(TestPkiCredential $certificate): array + { + return (new Ocsp())->generateCertificateId( + $certificate->getCertificate(), + self::$root->getCertificate() + ); + } + + private static function staticOcspClient(string $responseDer): OcspClient + { + return new class ($responseDer) implements OcspClient { + public function __construct(private string $responseDer) + { + } + + public function request(Uri $url, string $request): OcspResponse + { + return new OcspResponse($this->responseDer); + } + }; + } + + private static function staticCrlClient(string $crlBytes): CrlClient + { + return new class ($crlBytes) implements CrlClient { + public function __construct(private string $crlBytes) + { + } + + public function fetch(Uri $uri): string + { + return $this->crlBytes; + } + }; + } + + private static function unusedOcspClient(): OcspClient + { + return new class implements OcspClient { + public function request(Uri $url, string $request): OcspResponse + { + throw new RuntimeException("The OCSP client must not be used in this test"); + } + }; + } + + private static function unusedCrlClient(): CrlClient + { + return new class implements CrlClient { + public function fetch(Uri $uri): string + { + throw new RuntimeException("The CRL client must not be used in this test"); + } + }; + } +} From a088986a37b5c973826bf9079c762aa84a1d91cd Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:38 +0300 Subject: [PATCH 14/18] NFC-128 Validate authentication certificate chains with token-supplied intermediates --- .../SubjectCertificateTrustedValidator.php | 23 ++++- .../AuthTokenVersion1Validator.php | 97 ++++++++++++++++--- .../AuthTokenVersionValidatorFactory.php | 41 +++++--- .../AuthTokenVersion1ValidatorTest.php | 19 ++++ 4 files changed, 153 insertions(+), 27 deletions(-) diff --git a/src/validator/certvalidators/SubjectCertificateTrustedValidator.php b/src/validator/certvalidators/SubjectCertificateTrustedValidator.php index cc8c914..c47cea6 100644 --- a/src/validator/certvalidators/SubjectCertificateTrustedValidator.php +++ b/src/validator/certvalidators/SubjectCertificateTrustedValidator.php @@ -27,29 +27,46 @@ use web_eid\web_eid_authtoken_validation_php\util\TrustedCertificates; use phpseclib3\File\X509; use web_eid\web_eid_authtoken_validation_php\certificate\CertificateValidator; +use web_eid\web_eid_authtoken_validation_php\certificate\IntermediateRevocationChecker; use Psr\Log\LoggerInterface; -use web_eid\web_eid_authtoken_validation_php\util\DefaultClock; final class SubjectCertificateTrustedValidator implements SubjectCertificateValidator { private TrustedCertificates $trustedCACertificates; private X509 $subjectCertificateIssuerCertificate; + /** @var X509[] */ + private array $additionalIntermediateCertificates; + private ?IntermediateRevocationChecker $intermediateRevocationChecker; private $logger; + /** + * @param X509[] $additionalIntermediateCertificates token-supplied untrusted intermediate CA + * certificates, used only as candidates during certification path building + */ public function __construct( TrustedCertificates $trustedCACertificates, ?LoggerInterface $logger = null, + array $additionalIntermediateCertificates = [], + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ) { $this->logger = $logger; $this->trustedCACertificates = $trustedCACertificates; + $this->additionalIntermediateCertificates = $additionalIntermediateCertificates; + $this->intermediateRevocationChecker = $intermediateRevocationChecker; } public function validate(X509 $subjectCertificate): void { + // Intermediate CA certificates require revocation checks here because they are not + // checked elsewhere. Subject certificate revocation is handled separately by + // SubjectCertificateNotRevokedValidator. $this->subjectCertificateIssuerCertificate = CertificateValidator::validateIsValidAndSignedByTrustedCA( $subjectCertificate, $this->trustedCACertificates, + "User", + $this->additionalIntermediateCertificates, + $this->intermediateRevocationChecker, ); $this->logger?->debug( @@ -57,6 +74,10 @@ public function validate(X509 $subjectCertificate): void ); } + /** + * Returns the certificate that directly issued the subject certificate; + * the trust anchor when the anchor is the direct issuer. + */ public function getSubjectCertificateIssuerCertificate(): X509 { return $this->subjectCertificateIssuerCertificate; diff --git a/src/validator/versionvalidators/AuthTokenVersion1Validator.php b/src/validator/versionvalidators/AuthTokenVersion1Validator.php index f4689bd..bbab4d2 100644 --- a/src/validator/versionvalidators/AuthTokenVersion1Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion1Validator.php @@ -29,6 +29,8 @@ use phpseclib3\File\X509; use Exception; use web_eid\web_eid_authtoken_validation_php\authtoken\WebEidAuthToken; +use web_eid\web_eid_authtoken_validation_php\certificate\CertificateLoader; +use web_eid\web_eid_authtoken_validation_php\certificate\IntermediateRevocationChecker; use web_eid\web_eid_authtoken_validation_php\exceptions\AuthTokenParseException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateDecodingException; use web_eid\web_eid_authtoken_validation_php\util\TrustedCertificates; @@ -46,12 +48,13 @@ class AuthTokenVersion1Validator implements AuthTokenVersionValidator private const V1_SUPPORTED_TOKEN_FORMAT_PREFIX = "web-eid:1"; private SubjectCertificateValidatorBatch $simpleSubjectCertificateValidators; - private TrustedCertificates $trustedCACertificates; + protected TrustedCertificates $trustedCACertificates; private AuthTokenSignatureValidator $authTokenSignatureValidator; private AuthTokenValidationConfiguration $configuration; private ?OcspClient $ocspClient; private ?OcspServiceProvider $ocspServiceProvider; - private ?LoggerInterface $logger; + protected ?IntermediateRevocationChecker $intermediateRevocationChecker; + protected ?LoggerInterface $logger; public function __construct( SubjectCertificateValidatorBatch $simpleSubjectCertificateValidators, @@ -61,6 +64,7 @@ public function __construct( ?OcspClient $ocspClient, ?OcspServiceProvider $ocspServiceProvider, ?LoggerInterface $logger = null, + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ) { $this->simpleSubjectCertificateValidators = $simpleSubjectCertificateValidators; $this->trustedCACertificates = $trustedCACertificates; @@ -69,6 +73,7 @@ public function __construct( $this->ocspClient = $ocspClient; $this->ocspServiceProvider = $ocspServiceProvider; $this->logger = $logger; + $this->intermediateRevocationChecker = $intermediateRevocationChecker; } public function supports(?string $format): bool @@ -81,15 +86,21 @@ public function validate( WebEidAuthToken $authToken, string $currentChallengeNonce, ): X509 { - if ( - $this->isExactV10Format($authToken->getFormat()) && - !empty($authToken->getUnverifiedSigningCertificates()) - ) { - throw new AuthTokenParseException( - "'unverifiedSigningCertificates' field is not allowed for format '" . - $authToken->getFormat() . - "'", - ); + if ($this->isExactV10Format($authToken->getFormat())) { + if ($authToken->getUnverifiedSigningCertificates() !== null) { + throw new AuthTokenParseException( + "'unverifiedSigningCertificates' field is not allowed for format '" . + $authToken->getFormat() . + "'", + ); + } + if ($authToken->getUnverifiedIntermediateCertificates() !== null) { + throw new AuthTokenParseException( + "'unverifiedIntermediateCertificates' field is not allowed for format '" . + $authToken->getFormat() . + "'", + ); + } } if ( @@ -120,10 +131,14 @@ public function validate( ); } + $additionalIntermediateCertificates = + $this->decodeAdditionalIntermediateCertificates($authToken); + $this->simpleSubjectCertificateValidators->executeFor( $subjectCertificate, ); - $this->buildTrustValidatorBatch()->executeFor($subjectCertificate); + $this->buildTrustValidatorBatch($additionalIntermediateCertificates) + ->executeFor($subjectCertificate); $this->authTokenSignatureValidator->validate( $authToken->getAlgorithm(), @@ -135,11 +150,18 @@ public function validate( return $subjectCertificate; } - protected function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch - { + /** + * @param X509[] $additionalIntermediateCertificates token-supplied untrusted intermediate CA + * certificates, used only as candidates during certification path building + */ + protected function buildTrustValidatorBatch( + array $additionalIntermediateCertificates = [], + ): SubjectCertificateValidatorBatch { $trustedValidator = new SubjectCertificateTrustedValidator( $this->trustedCACertificates, $this->logger, + $additionalIntermediateCertificates, + $this->intermediateRevocationChecker, ); $batch = new SubjectCertificateValidatorBatch($trustedValidator); @@ -156,6 +178,7 @@ protected function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch $this->configuration->getAllowedOcspResponseTimeSkew(), $this->configuration->getMaxOcspResponseThisUpdateAge(), $this->logger, + $additionalIntermediateCertificates, ), ); } @@ -163,6 +186,52 @@ protected function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch return $batch; } + /** + * @return X509[] + * @throws AuthTokenParseException + * @throws CertificateDecodingException + */ + private function decodeAdditionalIntermediateCertificates( + WebEidAuthToken $authToken, + ): array { + self::validateIntermediateCertificatesField( + $authToken->getUnverifiedIntermediateCertificates(), + "unverifiedIntermediateCertificates", + $authToken->getFormat(), + ); + + return CertificateLoader::decodeCertificatesFromBase64( + $authToken->getUnverifiedIntermediateCertificates(), + "unverifiedIntermediateCertificates", + ); + } + + /** + * @param string[]|null $intermediateCertificates + * @throws AuthTokenParseException + */ + protected static function validateIntermediateCertificatesField( + ?array $intermediateCertificates, + string $fieldName, + ?string $format, + ): void { + if ($intermediateCertificates === null) { + return; + } + if ($intermediateCertificates === []) { + throw new AuthTokenParseException( + "'{$fieldName}' must not be empty for format '{$format}'", + ); + } + foreach ($intermediateCertificates as $certificate) { + if ($certificate === null || $certificate === "") { + throw new AuthTokenParseException( + "'{$fieldName}' must not contain null or empty entries for format '{$format}'", + ); + } + } + } + private function isExactV10Format(?string $format): bool { return $format === self::V1_SUPPORTED_TOKEN_FORMAT_PREFIX || diff --git a/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php b/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php index 93521f6..449c319 100644 --- a/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php +++ b/src/validator/versionvalidators/AuthTokenVersionValidatorFactory.php @@ -29,6 +29,8 @@ use web_eid\web_eid_authtoken_validation_php\validator\certvalidators\SubjectCertificateValidatorBatch; use web_eid\web_eid_authtoken_validation_php\validator\certvalidators\SubjectCertificatePurposeValidator; use web_eid\web_eid_authtoken_validation_php\validator\certvalidators\SubjectCertificatePolicyValidator; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\CrlClientImpl; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\IntermediateRevocationCheckerImpl; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClient; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClientImpl; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspServiceProvider; @@ -98,21 +100,34 @@ public static function create( ) ); - $ocspClient = null; + $aiaOcspServiceConfiguration = new AiaOcspServiceConfiguration( + $validationConfig->getNonceDisabledOcspUrls(), + $trustedCACertificates + ); + + // The OCSP client is needed even when the user certificate revocation check is + // disabled: token-supplied intermediate CA certificates are always checked for + // revocation when they are part of a built certification path. + $ocspClient = $providedOcspClient ?? OcspClientImpl::build( + $validationConfig->getOcspRequestTimeout(), + $logger + ); + + $intermediateRevocationChecker = new IntermediateRevocationCheckerImpl( + $ocspClient, + CrlClientImpl::build($validationConfig->getOcspRequestTimeout(), $logger), + $aiaOcspServiceConfiguration, + $validationConfig->getAllowedOcspResponseTimeSkew(), + $validationConfig->getMaxOcspResponseThisUpdateAge(), + $logger + ); + $ocspServiceProvider = null; if ($validationConfig->isUserCertificateRevocationCheckWithOcspEnabled()) { - $ocspClient = $providedOcspClient ?? OcspClientImpl::build( - $validationConfig->getOcspRequestTimeout(), - $logger - ); - $ocspServiceProvider = new OcspServiceProvider( $validationConfig->getDesignatedOcspServiceConfiguration(), - new AiaOcspServiceConfiguration( - $validationConfig->getNonceDisabledOcspUrls(), - $trustedCACertificates - ) + $aiaOcspServiceConfiguration ); } @@ -127,7 +142,8 @@ public static function create( $validationConfig, $ocspClient, $ocspServiceProvider, - $logger + $logger, + $intermediateRevocationChecker ); $validator1 = new AuthTokenVersion1Validator( @@ -137,7 +153,8 @@ public static function create( $validationConfig, $ocspClient, $ocspServiceProvider, - $logger + $logger, + $intermediateRevocationChecker ); return new self([ diff --git a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php index 0a5a798..dd46a99 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php @@ -120,6 +120,25 @@ public function testUnverifiedSigningCertificatesPresentForV1Fails(): void $this->validator->validate($token, 'nonce'); } + /** + * @throws AuthTokenException + */ + public function testUnverifiedIntermediateCertificatesPresentForV1Fails(): void + { + $token = $this->createMock(WebEidAuthToken::class); + + $token->method('getFormat')->willReturn('web-eid:1'); + $token->method('getUnverifiedSigningCertificates')->willReturn(null); + $token->method('getUnverifiedIntermediateCertificates')->willReturn(['MIICintermediate']); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'unverifiedIntermediateCertificates' field is not allowed for format 'web-eid:1'" + ); + + $this->validator->validate($token, 'nonce'); + } + /** * @throws AuthTokenException */ From e18e286ef50027375cd936f8d61555cd1355d9cc Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:39 +0300 Subject: [PATCH 15/18] NFC-128 Validate signing certificate chains with token-supplied intermediates --- .../AuthTokenVersion11Validator.php | 83 +++++--- .../AuthTokenV11CertificateTest.php | 191 ++++++++++++++++++ .../AuthTokenVersion11ValidatorTest.php | 157 ++++++++++++++ 3 files changed, 399 insertions(+), 32 deletions(-) diff --git a/src/validator/versionvalidators/AuthTokenVersion11Validator.php b/src/validator/versionvalidators/AuthTokenVersion11Validator.php index 61622f1..58931e0 100644 --- a/src/validator/versionvalidators/AuthTokenVersion11Validator.php +++ b/src/validator/versionvalidators/AuthTokenVersion11Validator.php @@ -26,10 +26,13 @@ namespace web_eid\web_eid_authtoken_validation_php\validator\versionvalidators; +use Exception; use phpseclib3\File\X509; use web_eid\web_eid_authtoken_validation_php\authtoken\WebEidAuthToken; use web_eid\web_eid_authtoken_validation_php\authtoken\SupportedSignatureAlgorithm; +use web_eid\web_eid_authtoken_validation_php\authtoken\UnverifiedSigningCertificate; use web_eid\web_eid_authtoken_validation_php\certificate\CertificateLoader; +use web_eid\web_eid_authtoken_validation_php\certificate\CertificateValidator; use web_eid\web_eid_authtoken_validation_php\exceptions\AuthTokenException; use web_eid\web_eid_authtoken_validation_php\exceptions\AuthTokenParseException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateDecodingException; @@ -74,39 +77,56 @@ public function validate( $authToken, $currentChallengeNonce, ); - $signingCertificates = $this->validateSigningCertificates($authToken); - foreach ($signingCertificates as $signingCertificate) { + foreach ($this->validateSigningCertificates($authToken) as $unverifiedSigningCertificate) { + $signingCertificate = CertificateLoader::decodeCertificateFromBase64( + $unverifiedSigningCertificate->getCertificate(), + "unverifiedSigningCertificates", + ); + $this->validateSameSubject( $subjectCertificate, $signingCertificate, ); $this->validateSameIssuer($subjectCertificate, $signingCertificate); - $this->validateSigningCertificateValidity($signingCertificate); $this->validateKeyUsage($signingCertificate); - $this->validateSigningCertificateChain($signingCertificate); + $this->validateSigningCertificateChain( + $signingCertificate, + CertificateLoader::decodeCertificatesFromBase64( + $unverifiedSigningCertificate->getIntermediateCertificates(), + "intermediateCertificates", + ), + ); } return $subjectCertificate; } /** - * @return X509[] + * @return UnverifiedSigningCertificate[] * @throws AuthTokenParseException - * @throws CertificateDecodingException */ private function validateSigningCertificates(WebEidAuthToken $token): array { $signingCertificates = $token->getUnverifiedSigningCertificates(); + $intermediateCertificates = $token->getUnverifiedIntermediateCertificates(); - if ($signingCertificates === null || empty($signingCertificates)) { + // When the authentication certificate's intermediate certificates are present, + // signing certificates are optional. + if ( + $signingCertificates === null && + $intermediateCertificates !== null && + $intermediateCertificates !== [] + ) { + return []; + } + + if ($signingCertificates === null || $signingCertificates === []) { throw new AuthTokenParseException( "'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'", ); } - $result = []; - foreach ($signingCertificates as $certificate) { if ( $certificate === null || @@ -119,13 +139,14 @@ private function validateSigningCertificates(WebEidAuthToken $token): array } $this->validateSupportedSignatureAlgorithms($certificate); - $result[] = CertificateLoader::decodeCertificateFromBase64( - $certificate->getCertificate(), - "unverifiedSigningCertificates", + self::validateIntermediateCertificatesField( + $certificate->getIntermediateCertificates(), + "intermediateCertificates", + $token->getFormat(), ); } - return $result; + return $signingCertificates; } /** @@ -207,21 +228,6 @@ private function validateSameIssuer( } } - /** - * @throws AuthTokenParseException - */ - private function validateSigningCertificateValidity( - X509 $signingCertificate, - ): void { - $valid = $signingCertificate->validateDate(); - - if ($valid !== true) { - throw new AuthTokenParseException( - "Signing certificate is not valid: {$valid}", - ); - } - } - private function extractAuthorityKeyIdentifier(X509 $cert): string { $ext = $cert->getExtension("id-ce-authorityKeyIdentifier"); @@ -241,13 +247,26 @@ private function validateKeyUsage(X509 $signingCertificate): void } /** + * @param X509[] $intermediateCertificates * @throws AuthTokenParseException */ - private function validateSigningCertificateChain(X509 $signingCertificate): void - { + private function validateSigningCertificateChain( + X509 $signingCertificate, + array $intermediateCertificates, + ): void { try { - $this->buildTrustValidatorBatch()->executeFor($signingCertificate); - } catch (AuthTokenException $e) { + // The signing certificate itself deliberately gets no revocation check during + // authentication: its revocation status matters at signing time and is the + // signature validation service's concern. Token-supplied intermediate + // certificates in its path are checked for revocation. + CertificateValidator::validateIsValidAndSignedByTrustedCA( + $signingCertificate, + $this->trustedCACertificates, + "Signing", + $intermediateCertificates, + $this->intermediateRevocationChecker, + ); + } catch (Exception $e) { throw new AuthTokenParseException( "Signing certificate chain validation failed", $e, diff --git a/tests/validator/versionvalidators/AuthTokenV11CertificateTest.php b/tests/validator/versionvalidators/AuthTokenV11CertificateTest.php index 457cc90..e89fe39 100644 --- a/tests/validator/versionvalidators/AuthTokenV11CertificateTest.php +++ b/tests/validator/versionvalidators/AuthTokenV11CertificateTest.php @@ -26,11 +26,15 @@ namespace web_eid\web_eid_authtoken_validation_php\validator\versionvalidators; +use DateTime; use web_eid\web_eid_authtoken_validation_php\authtoken\WebEidAuthToken; use web_eid\web_eid_authtoken_validation_php\certificate\CertificateValidator; use web_eid\web_eid_authtoken_validation_php\exceptions\AuthTokenParseException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateDecodingException; +use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateExpiredException; +use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotYetValidException; use web_eid\web_eid_authtoken_validation_php\testutil\AbstractTestWithValidator; +use web_eid\web_eid_authtoken_validation_php\testutil\Dates; use web_eid\web_eid_authtoken_validation_php\validator\certvalidators\SubjectCertificateValidatorBatch; use web_eid\web_eid_authtoken_validation_php\validator\AuthTokenSignatureValidator; use web_eid\web_eid_authtoken_validation_php\validator\AuthTokenValidationConfiguration; @@ -51,6 +55,11 @@ class AuthTokenV11CertificateTest extends AbstractTestWithValidator public const DIFFERENT_CERT = "MIIGvjCCBKagAwIBAgIQT7aXeR+zWlBb2Gbar+AFaTANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCTFYxOTA3BgNVBAoMMFZBUyBMYXR2aWphcyBWYWxzdHMgcmFkaW8gdW4gdGVsZXbEq3ppamFzIGNlbnRyczEaMBgGA1UEYQwRTlRSTFYtNDAwMDMwMTEyMDMxHTAbBgNVBAMMFERFTU8gTFYgZUlEIElDQSAyMDE3MB4XDTE4MTAzMDE0MTI0MloXDTIzMTAzMDE0MTI0MlowcDELMAkGA1UEBhMCTFYxHDAaBgNVBAMME0FORFJJUyBQQVJBVURaScWFxaAxFTATBgNVBAQMDFBBUkFVRFpJxYXFoDEPMA0GA1UEKgwGQU5EUklTMRswGQYDVQQFExJQTk9MVi0zMjE5MjItMzMwMzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDXkra3rDOOt5K6OnJcg/Xt6JOogPAUBX2kT9zWelze7WSuPx2Ofs//0JoBQ575IVdh3JpLhfh7g60YYi41M6vNACVSNaFOxiEvE9amSFizMiLk5+dp+79rymqOsVQG8CSu8/RjGGlDsALeb3N/4pUSTGXUwSB64QuFhOWjAcmKPhHeYtry0hK3MbwwHzFhYfGpo/w+PL14PEdJlpL1UX/aPyT0Zq76Z4T/Z3PqbTmQp09+2b0thC0JIacSkyJuTu8fVRQvse+8UtYC6Kt3TBLZbPtqfAFSXWbuE47Lc2o840NkVlMHVAesoRAfiQxsK35YWFT0rHPWbLjX6ySiaL25AgMBAAGjggI+MIICOjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQUHZWimPze2GXULNaP4EFVdF+MWKQwHwYDVR0jBBgwFoAUj2jOvOLHQCFTCUK75Z4djEvNvTgwgfsGA1UdIASB8zCB8DA7BgYEAI96AQIwMTAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwgbAGDCsGAQQBgfo9AgECATCBnzAvBggrBgEFBQcCARYjaHR0cHM6Ly93d3cuZXBhcmFrc3RzLmx2L3JlcG9zaXRvcnkwbAYIKwYBBQUHAgIwYAxexaBpcyBzZXJ0aWZpa8SBdHMgaXIgaWVrxLxhdXRzIExhdHZpamFzIFJlcHVibGlrYXMgaXpzbmllZ3TEgSBwZXJzb251IGFwbGllY2lub8WhxIEgZG9rdW1lbnTEgTB9BggrBgEFBQcBAQRxMG8wQgYIKwYBBQUHMAKGNmh0dHA6Ly9kZW1vLmVwYXJha3N0cy5sdi9jZXJ0L2RlbW9fTFZfZUlEX0lDQV8yMDE3LmNydDApBggrBgEFBQcwAYYdaHR0cDovL29jc3AucHJlcC5lcGFyYWtzdHMubHYwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2RlbW8uZXBhcmFrc3RzLmx2L2NybC9kZW1vX0xWX2VJRF9JQ0FfMjAxN18zLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAAOVoRbnMv2UXWYHgnmO9Zg9u8F1YvJiZPMeTYE2CVaiq0nXe4Mq0X5tWcsEiRpGQF9e0dWC6V5m6EmAsHxIRL4chZKRrIrPEiWtP3zyRI1/X2y5GwSUyZmgxkuSOHHw3UjzjrnOoI9izpC0OSNeumqpjT/tLAi35sktGkK0onEUPWGQnZLqd/hzykm+H/dmD27nOnfCJOSqbegLSbhV2w/WAII+IUD3vJ06F6rf9ZN8xbrGkPO8VMCIDIt0eBKFxBdSOgpsTfbERbjQJ+nFEDYhD0bFNYMsFSGnZiWpNaCcZSkk4mtNUa8sNXyaFQGIZk6NjQ/fsBANhUoxFz7rUKrRYqk356i8KFDZ+MJqUyodKKyW9oz+IO5eJxnL78zRbxD+EfAUmrLXOjmGIzU95RR1smS4cirrrPHqGAWojBk8hKbjNTJl9Tfbnsbc9/FUBJLVZAkCi631KfRLQ66bn8N0mbtKlNtdX0G47PXTy7SJtWwDtKQ8+qVpduc8xHLntbdAzie3mWyxA1SBhQuZ9BPf5SPBImWCNpmZNCTmI2e+4yyCnmG/kVNilUAaODH/fgQXFGdsKO/XATFohiies28twkEzqtlVZvZbpBhbJCHYVnQXMhMKcnblkDqXWcSWd3QAKig2yMH95uz/wZhiV+7tZ7cTgwcbCzIDCfpwBC3E="; + protected function tearDown(): void + { + Dates::resetMockedCertificateValidatorDate(); + } + public function testWhenValidV11TokenThenValidationSucceeds(): void { $authToken = new WebEidAuthToken(self::VALID_V11_AUTH_TOKEN); @@ -58,6 +67,157 @@ public function testWhenValidV11TokenThenValidationSucceeds(): void $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); } + /** + * The provided intermediate certificate is the configured trust anchor itself, + * so the certification path terminates immediately at the anchor and the + * intermediate candidate stays unused; no revocation check is performed. + * + * @throws AuthTokenParseException + */ + public function testWhenValidV11TokenWithIntermediateCertificatesThenValidationSucceeds(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + $tokenFields["unverifiedIntermediateCertificates"] = [self::getTestEsteid2018CAInBase64()]; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectNotToPerformAssertions(); + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * @throws AuthTokenParseException + */ + public function testWhenV11IntermediateCertificatesIsEmptyThenValidationFails(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + $tokenFields["unverifiedIntermediateCertificates"] = []; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'unverifiedIntermediateCertificates' must not be empty for format 'web-eid:1.1'" + ); + + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * @throws AuthTokenParseException + */ + public function testWhenV11IntermediateCertificatesContainEmptyEntryThenValidationFails(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + $tokenFields["unverifiedIntermediateCertificates"] = [""]; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'unverifiedIntermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'" + ); + + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * @throws AuthTokenParseException + */ + public function testWhenV11IntermediateCertificatesContainInvalidBase64ThenValidationFails(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + $tokenFields["unverifiedIntermediateCertificates"] = ["not-valid-base64!!!"]; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectException(CertificateDecodingException::class); + $this->expectExceptionMessage("'unverifiedIntermediateCertificates' decode failed"); + + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * When the authentication certificate's intermediate certificates are present, + * signing certificates are optional and their validation is skipped. + * + * @throws AuthTokenParseException + */ + public function testWhenV11SigningCertificatesAbsentAndIntermediateCertificatesPresentThenValidationSucceeds(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + unset($tokenFields["unverifiedSigningCertificates"]); + $tokenFields["unverifiedIntermediateCertificates"] = [self::getTestEsteid2018CAInBase64()]; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectNotToPerformAssertions(); + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * @throws AuthTokenParseException + */ + public function testWhenV11SigningCertificateHasIntermediateCertificatesThenValidationSucceeds(): void + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + $tokenFields["unverifiedSigningCertificates"][0]["intermediateCertificates"] = + [self::getTestEsteid2018CAInBase64()]; + + $authToken = new WebEidAuthToken(json_encode($tokenFields, JSON_UNESCAPED_SLASHES)); + + $this->expectNotToPerformAssertions(); + $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); + } + + /** + * With a mocked far-future clock the authentication certificate would expire first + * in validateV1, so the signing certificate chain is exercised directly with the + * authentication certificate validation mocked out. + * + * @throws AuthTokenParseException + */ + public function testWhenClockIsInFarFutureThenSigningCertificateChainValidationFailsWithExpiredCertificate(): void + { + Dates::setMockedCertificateValidatorDate(new DateTime("2099-01-01 00:00:00")); + + $authToken = new WebEidAuthToken(self::VALID_V11_AUTH_TOKEN); + $spy = $this->createValidatorWithMockedValidateV1ReturningAuthCertificate(); + + try { + $spy->validate($authToken, self::VALID_CHALLENGE_NONCE); + $this->fail("Expected AuthTokenParseException was not thrown"); + } catch (AuthTokenParseException $e) { + $this->assertSame("Signing certificate chain validation failed", $e->getMessage()); + $this->assertInstanceOf(CertificateExpiredException::class, $e->getPrevious()); + $this->assertSame("Signing certificate has expired", $e->getPrevious()->getMessage()); + } + } + + /** + * With a mocked past clock the authentication certificate would be not yet valid + * first in validateV1, so the signing certificate chain is exercised directly with + * the authentication certificate validation mocked out. + * + * @throws AuthTokenParseException + */ + public function testWhenClockIsInPastThenSigningCertificateChainValidationFailsWithNotYetValidCertificate(): void + { + Dates::setMockedCertificateValidatorDate(new DateTime("2000-01-01 00:00:00")); + + $authToken = new WebEidAuthToken(self::VALID_V11_AUTH_TOKEN); + $spy = $this->createValidatorWithMockedValidateV1ReturningAuthCertificate(); + + try { + $spy->validate($authToken, self::VALID_CHALLENGE_NONCE); + $this->fail("Expected AuthTokenParseException was not thrown"); + } catch (AuthTokenParseException $e) { + $this->assertSame("Signing certificate chain validation failed", $e->getMessage()); + $this->assertInstanceOf(CertificateNotYetValidException::class, $e->getPrevious()); + $this->assertSame("Signing certificate is not yet valid", $e->getPrevious()->getMessage()); + } + } + /** * @throws AuthTokenParseException */ @@ -163,4 +323,35 @@ public function testWhenV11SigningCertificateHasNoAuthorityKeyIdentifierThenVali $this->validator->validate($authToken, self::VALID_CHALLENGE_NONCE); } + + private function createValidatorWithMockedValidateV1ReturningAuthCertificate(): AuthTokenVersion11Validator + { + $tokenFields = json_decode(self::VALID_V11_AUTH_TOKEN, true); + + $authCertificate = new X509(); + $this->assertNotFalse($authCertificate->loadX509($tokenFields["unverifiedCertificate"])); + + $spy = $this->getMockBuilder(AuthTokenVersion11Validator::class) + ->setConstructorArgs([ + $this->createMock(SubjectCertificateValidatorBatch::class), + CertificateValidator::buildTrustFromCertificates([]), + $this->createMock(AuthTokenSignatureValidator::class), + new AuthTokenValidationConfiguration(), + null, + null + ]) + ->onlyMethods(['validateV1']) + ->getMock(); + + $spy->method('validateV1')->willReturn($authCertificate); + + return $spy; + } + + private static function getTestEsteid2018CAInBase64(): string + { + return base64_encode( + file_get_contents(__DIR__ . '/../../_resources/TEST_of_ESTEID2018.cer') + ); + } } diff --git a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php index 47d6324..54f4b39 100644 --- a/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php +++ b/tests/validator/versionvalidators/AuthTokenVersion11ValidatorTest.php @@ -243,6 +243,121 @@ public function testMissingSupportedAlgorithmsFails(): void $spy->validate($token, 'nonce'); } + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificatesMissingAndIntermediateCertificatesPresentThenSigningCertificateValidationIsSkipped(): void + { + $token = $this->createMock(WebEidAuthToken::class); + + $token->method('getFormat')->willReturn('web-eid:1.1'); + $token->method('getUnverifiedSigningCertificates')->willReturn(null); + $token->method('getUnverifiedIntermediateCertificates')->willReturn(['MIICintermediate']); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->assertInstanceOf(X509::class, $spy->validate($token, 'nonce')); + } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificatesAndIntermediateCertificatesMissingThenValidationFails(): void + { + $token = $this->createMock(WebEidAuthToken::class); + + $token->method('getFormat')->willReturn('web-eid:1.1'); + $token->method('getUnverifiedSigningCertificates')->willReturn(null); + $token->method('getUnverifiedIntermediateCertificates')->willReturn(null); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'" + ); + + $spy->validate($token, 'nonce'); + } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificatesEmptyAndIntermediateCertificatesPresentThenValidationFails(): void + { + $token = $this->createMock(WebEidAuthToken::class); + + $token->method('getFormat')->willReturn('web-eid:1.1'); + $token->method('getUnverifiedSigningCertificates')->willReturn([]); + $token->method('getUnverifiedIntermediateCertificates')->willReturn(['MIICintermediate']); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'unverifiedSigningCertificates' field is missing, null or empty for format 'web-eid:1.1'" + ); + + $spy->validate($token, 'nonce'); + } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificateIntermediateCertificatesEmptyThenValidationFails(): void + { + $token = $this->createTokenWithSigningCertificateIntermediateCertificates([]); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'intermediateCertificates' must not be empty for format 'web-eid:1.1'" + ); + + $spy->validate($token, 'nonce'); + } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificateIntermediateCertificatesContainNullEntryThenValidationFails(): void + { + $token = $this->createTokenWithSigningCertificateIntermediateCertificates([null]); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'intermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'" + ); + + $spy->validate($token, 'nonce'); + } + + /** + * @throws CertificateDecodingException + * @throws AuthTokenException + */ + public function testWhenSigningCertificateIntermediateCertificatesContainEmptyEntryThenValidationFails(): void + { + $token = $this->createTokenWithSigningCertificateIntermediateCertificates(['']); + + $spy = $this->createValidatorWithMockedValidateV1(); + + $this->expectException(AuthTokenParseException::class); + $this->expectExceptionMessage( + "'intermediateCertificates' must not contain null or empty entries for format 'web-eid:1.1'" + ); + + $spy->validate($token, 'nonce'); + } + /** * @throws CertificateDecodingException * @throws AuthTokenException @@ -277,4 +392,46 @@ public function assertChainFails(X509 $certificate): void $validator->assertChainFails($signingCertificate); } + + private function createValidatorWithMockedValidateV1(): AuthTokenVersion11Validator + { + $spy = $this->getMockBuilder(AuthTokenVersion11Validator::class) + ->setConstructorArgs([ + $this->createMock(SubjectCertificateValidatorBatch::class), + CertificateValidator::buildTrustFromCertificates([]), + $this->createMock(AuthTokenSignatureValidator::class), + new AuthTokenValidationConfiguration(), + null, + null + ]) + ->onlyMethods(['validateV1']) + ->getMock(); + + $spy->method('validateV1')->willReturn(new X509()); + + return $spy; + } + + private function createTokenWithSigningCertificateIntermediateCertificates( + array $intermediateCertificates + ): WebEidAuthToken { + $certPath = __DIR__ . '/../../_resources/ESTEID2018.cer'; + $der = file_get_contents($certPath); + + $this->assertIsString($der, "Certificate missing at: $certPath"); + + $certificate = UnverifiedSigningCertificate::fromArray([ + 'certificate' => base64_encode($der), + 'supportedSignatureAlgorithms' => [ + ['cryptoAlgorithm' => 'RSA', 'hashFunction' => 'SHA-256', 'paddingScheme' => 'PKCS1.5'], + ], + 'intermediateCertificates' => $intermediateCertificates, + ]); + + $token = $this->createMock(WebEidAuthToken::class); + $token->method('getFormat')->willReturn('web-eid:1.1'); + $token->method('getUnverifiedSigningCertificates')->willReturn([$certificate]); + + return $token; + } } From f4c82bce1a504d2c2901af2744354d663dacf1c3 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:40 +0300 Subject: [PATCH 16/18] NFC-128 Document intermediate certificate token fields --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 1bd8174..f4b6aca 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,17 @@ The authentication token validation process consists of two stages: - First, **user certificate validation**: the validator parses the token and extracts the user certificate from the *unverifiedCertificate* field. Then it checks the certificate expiration, purpose and policies. Next it checks that the certificate is signed by a trusted CA and checks the certificate status with OCSP. - Second, **token signature validation**: the validator validates that the token signature was created using the provided user certificate by reconstructing the signed data `hash(origin)+hash(challenge)` and using the public key from the certificate to verify the signature in the `signature` field. If the signature verification succeeds, then the origin and challenge nonce have been implicitly and correctly verified without the need to implement any additional security checks. +Starting from format version `web-eid:1.1`, the authentication token may additionally carry the intermediate CA certificates needed to build the trust chains of the certificates it contains, as well as the eID user's signing certificates: + +- `unverifiedIntermediateCertificates`: an array of base64-encoded DER intermediate CA certificates that make up the trust chain of the authentication certificate in `unverifiedCertificate`. Like the authentication certificate, these certificates are received from the client side and cannot be trusted; they are only used as candidate certificates when building the certification path, which must still terminate at a trusted certificate authority. The field is optional, but when present it must not be empty. When it is present, `unverifiedSigningCertificates` may be omitted. +- `unverifiedSigningCertificates[].intermediateCertificates`: an optional array of base64-encoded DER intermediate CA certificates that make up the trust chain of that signing certificate. When present it must not be empty and, as with `unverifiedIntermediateCertificates`, the certificates are only used as candidates when building the certification path to a trusted CA. + +When the token is in the `web-eid:1.1` format and contains `unverifiedSigningCertificates`, the validator additionally validates each signing certificate: that it has the same subject and issuer as the authentication certificate, is currently valid, is suitable for digital signatures (contains the non-repudiation key usage bit) and is signed by a trusted certificate authority, building the chain from the certificate's `intermediateCertificates` to a trusted CA. The signing certificate itself is deliberately not checked for revocation during authentication: its revocation status matters at signing time and is validated by the signature creation and validation services. + +When a token supplies intermediate CA certificates and a certification path is built through them, the validator checks the revocation status of the intermediate CA certificates in the path with the library's revocation checker, which prefers OCSP and falls back to CRLs, and rejects the token when the status of an intermediate is revoked or cannot be established. This check runs during path validation for both the authentication and the signing certificate chains, independently of the user certificate OCSP check configuration, because token-supplied intermediates are untrusted input. Deployments that need to accept tokens with intermediate certificates must therefore allow the network access that OCSP or CRL fetching requires. + +When the user certificate OCSP check uses an AIA OCSP responder, the responder is authorized according to [RFC 6960 section 4.2.2.2](https://datatracker.ietf.org/doc/html/rfc6960#section-4.2.2.2): the response must be signed either by the CA that issued the user certificate, in which case the OCSP-signing extended key usage is not required, or by a responder whose certificate contains the id-kp-OCSPSigning extended key usage and chains to a trusted CA through the certificate that issued the user certificate. Responder certificates are deliberately not checked for revocation, following the id-pkix-ocsp-nocheck convention of RFC 6960 section 4.2.2.2.1 and because querying an OCSP service about its own signer would be circular. + The website back end must lookup the challenge nonce from its local store using an identifier specific to the browser session, to guarantee that the authentication token was received from the same browser to which the corresponding challenge nonce was issued. The website back end must guarantee that the challenge nonce lifetime is limited and that its expiration is checked, and that it can be used only once by removing it from the store during validation. ## Basic usage From 8ae55a48f33d59c1722e08a8638d8e6519917ae8 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sat, 4 Jul 2026 07:38:41 +0300 Subject: [PATCH 17/18] NFC-128 Add OCSP tests for Belgian and Finnish test cards --- .../ocsp_response_belgian_test_id_card.der | Bin 0 -> 2026 bytes .../ocsp_response_finnish_test_id_card.der | Bin 0 -> 2287 bytes tests/testutil/AuthTokenValidators.php | 30 +++++++++++++++ .../AuthTokenCertificateBelgianIdCardTest.php | 36 ++++++++++++++++++ .../AuthTokenCertificateFinnishIdCardTest.php | 36 ++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 tests/_resources/ocsp_response_belgian_test_id_card.der create mode 100644 tests/_resources/ocsp_response_finnish_test_id_card.der diff --git a/tests/_resources/ocsp_response_belgian_test_id_card.der b/tests/_resources/ocsp_response_belgian_test_id_card.der new file mode 100644 index 0000000000000000000000000000000000000000..3cf707b54a5e2786ccd3786223dad31167d79a3b GIT binary patch literal 2026 zcmbW22~ZPP7{_-vn*@*}NDN04h(St~>m?vWP>>*sCy0qr0gJ$LROAQ&QKU!|v|L3L z2Qwa#Krl6;C^%x(f=CfWqM~?!Xa$i0t;GWigV2o(6-Q^B_RZ|P_rBTv-h2E1{l5n! zML0!$c+q9Kdk~E`S5Lj&s<~>p%jC2aEAEL>qkq zVn&2eDx;!!FtNFBiM~iKe{xnLe5C^k8@j5h6%n81gO%3|tQ=bYXH#Na|s) z_>;qpX-4#KkCm->RLYc8_@Lpdl~=lyuO9~UQlhsp)bbkc`?g588JG$?cC`Fp;gjwo z(xQALtp{mubjnOv`(q2$iHdVh+lKCu%m+Pr%AC}2`EKeZzrRjHW~P=m;=5Z;1jEIi zSB8r75YMBm!725D=~dcqd>-H-W&vKvrVSKA%J>TTW=E$jrb_qQcUfihPtuX(N z!n(gVI)|*eII14LV-s9kzquq=-s}9Q-2QTWG`mPh+H>jJ<`sh1(z-o2E+f&x;Av^6 zpb{(M(X&Dw=Dv$7?9|h@2|3F7Rr`Atji-!aeBbCO-3;_(65;@m5MKZZashvMG6Di4 zh&I%4C{p@0&KAE|F8rkFl(}s@CPLgAUv*vXe5;g-goEe)|QRfVDA+-)j7J?R`0ZuDpo2u+++m-lwZ`RmE!x{Z>a5*G;#U%u`}(4TjtWuZLcQTD%Y!|?GJi> z&Pi-xRsRyO{`H^-b;`=B$(sQ-pW;ggX5;u8O~%(hHdg!@ zyPR=$xf*sqz>Ej}vm25Cg+%_~B?QLnUOEb~2q6$ULn~L-Z4tU;#HCm;_i|_AwmlJN zfMU-nueNw&PS@(+J7+z0IOe&sm6dhX=TMN^ttW(L2XJo`PNLyUMfDRJrq_ssO>`7BW!%sC z(Y5eKABJEKp_oWKzB4P72B;I?5|DpKDBx)pvOwLy9N;3GEpyCde?6@z+ChXX z8M?vS%RW>T4bsym65`icB7Xh2Ps{<5$@zLde4CRKia#C`z?ok?rYje3V5;UEMjAV5 z0TSLJ@E6R&hPqh0=@~bRuMkwX%QKtn&K6imoqt`kkl#=VT{6F(^TPY?z{N!V@TSGZ zH3AfLB_klXVEK#FZFMz*?4huFlO~6;Gtl1QB=+fA(d^PS$#lgVgZ>ajxqs!${DW%x H!6Df{B0s*K literal 0 HcmV?d00001 diff --git a/tests/_resources/ocsp_response_finnish_test_id_card.der b/tests/_resources/ocsp_response_finnish_test_id_card.der new file mode 100644 index 0000000000000000000000000000000000000000..44dfa1b7117ea0f8d43dee0b26ae0079b2e6668b GIT binary patch literal 2287 zcmd5;c~p~E7SET>u%;m(JEUciotKb=#WH{(iijYx!%#m-z(61n(g0eOgdHp>s02o2 z8-*4nSP?9OQ&~j;0dWC5DnjWf!hjZ$iPU~U>p17k{5j{$AMd<(fA^ku?|tX~?tMT^ zm{WvMRbs*n5EGu@6)f=(4z~e75LQeWL7+h(hBT^0SwJMFDggyNCJ>7!qdmQ-=3tXt zP*kRPaAG*tlvtP|gxSdgo`Ay^@Pr&bjGQR}TlWM|wZKNXsgg1=05RYQVSYS2LBM7U zsA}Ldxdum805m#-Zf9>zqv`-HIfhXt`MU4%qxiFv6L|?NHlIRu(<0IUjS1`l4LI02 zQkh{O2C#uM5*E%<6@%8np|I)?WJ!bsJ@;kTq1rlK*+Thk@#|2my4so6r)GVxPQ(^; z`1JnT$t?BXLO^5jdJ6jKsDHcFfyxN0B7KDH)*Ugzs!4zdRQ~lm3Rsj}w8-9`y`lm^ zN;niojD}E!-Xw*u;Lo4-JE+S@+MoZ0c~YdUdS`d)bJjJjCExk&%Y9QK*~8rN>Bqln zNz$u#|ZXFz*m*k^AE52x?RU@p8y8K#Gs@AfQzErwc_?wG4^>BBS z_gWO6f8N~`T-q`k!Fti7)aq@I6_y;nMMk-mK_RP4=I-Z@kQ^O+xD!6vC0WtmYZ+*m zL0=z?aXy=o=af~M|HkTD!!IL^>MMV|s6V9VJ>N5#lVyMMs-?iinz`!o^D#+Wb8qaX zx-zpMG&ujB7FJ(_g9B40POKlZon^RbZ! ztpAa0Nc9%gR9#q0OfV7BS|XxJ#^v3QVl{3zti;_K9ZSuIG)jZV)cb|$W6c6D+3`Z+ z`KF6X-=g~X7L_mKZP71^X5L)07(Q{Ge%7_KC2fhlKhJn7_uR>TRB{j0kr{df3TX^) zMEgH|T=?YiJvfbvXx(SB+S+UC--~n>YH;i>XC#^8(hywUXt3R))~ z#huL;aH2U;uz;ORv8HTeYy}3IDl|JlqXDWN01QSLB03-w3oJb};@nY2S*v0)ZIstc2X>?Vk#F~$n z+;8R3*4zeqHvxPjtngYF@XAS6A7Hdx&aG z?8J|r(CVvZYhH))6u=gbw;b`B-xV1c zuGIvWhLl22R*qG^A*QmpcbwJQcQ38pXWDd1nt$0>D0|W)Q?t02q~{*8+~aut9l8Dj zx!6uwYEk;gYt%iT)j7Q|Sd_8Xduy(=!Q$Bsl)`{zc|);*$~^nbsU^#88{Zm!lg2Of zWxY9Rl+mZ2me&4)uh{b1tsrp0jNEf&CB;ogR! z^2tNGg}VaFJl@9VO!x`NgHvs{&)t>fk&90Gy4mlPCZ4&nCOJ27x4mZIhe(j}&|H#v zG^ggzCTI+Vf@;N38#0_)fFV-$`bJn}%B26tJ`eQe2CCSNSoKNauJH!7TRrs@3)g(Z zfnM`qV^yFkR}&%V9Ttm0j{~ia>k!M4~ zk^xCXjN|ZQVK|=W$`T5ZNC8oV1|N1+e9hGx9570y?1m zAp>-_=?Bk=d|qnWr*8n;B0dC(40e=F6kmXN`uMfLVbM6$r|l549|hQG;BC0$$G>r3 z=52P6;292$`W|2hzh;Vm;HTMQrOg;=n$?tEcwxh!fool{uk&=iE}Dw^#pY%)eX66m uD}B)IhI^3-)@9GQ_Rc>=XY6uQ%R1Mawj;6r@4ov|yJ+ESi!+CryMG7Ir&xOc literal 0 HcmV?d00001 diff --git a/tests/testutil/AuthTokenValidators.php b/tests/testutil/AuthTokenValidators.php index fc50192..7bd8835 100644 --- a/tests/testutil/AuthTokenValidators.php +++ b/tests/testutil/AuthTokenValidators.php @@ -29,6 +29,7 @@ use GuzzleHttp\Psr7\Uri; use web_eid\web_eid_authtoken_validation_php\validator\AuthTokenValidator; use web_eid\web_eid_authtoken_validation_php\validator\AuthTokenValidatorBuilder; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClient; final class AuthTokenValidators { @@ -109,6 +110,35 @@ public static function getAuthTokenValidatorForFinnishIdCard(): AuthTokenValidat ); } + public static function getAuthTokenValidatorForBelgianIdCardWithOcspCheck(OcspClient $ocspClient): AuthTokenValidator + { + return (self::getAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + CertificateLoader::loadCertificatesFromResources( + __DIR__ . "/../_resources/eID TEST EC Citizen CA.cer" + ) + )) + // The recorded OCSP response used in tests was created without a nonce. + ->withNonceDisabledOcspUrls(new Uri("http://eiddevcards.zetescards.be:8888")) + ->withOcspClient($ocspClient) + ->build(); + } + + public static function getAuthTokenValidatorForFinnishIdCardWithOcspCheck(OcspClient $ocspClient): AuthTokenValidator + { + return (self::getAuthTokenValidatorBuilder( + "https://47f0-46-131-86-189.ngrok-free.app", + CertificateLoader::loadCertificatesFromResources( + __DIR__ . "/../_resources/DVV TEST Certificates - G5E.crt", + __DIR__ . "/../_resources/VRK TEST CA for Test Purposes - G4.crt" + ) + )) + // The recorded OCSP response used in tests was created without a nonce. + ->withNonceDisabledOcspUrls(new Uri("http://ocsptest.fineid.fi/dvvtp5ec")) + ->withOcspClient($ocspClient) + ->build(); + } + public static function getAuthTokenValidatorWithWrongTrustedCertificate(): AuthTokenValidator { return self::getAuthTokenValidator( diff --git a/tests/validator/AuthTokenCertificateBelgianIdCardTest.php b/tests/validator/AuthTokenCertificateBelgianIdCardTest.php index b12036a..0a9fe8e 100644 --- a/tests/validator/AuthTokenCertificateBelgianIdCardTest.php +++ b/tests/validator/AuthTokenCertificateBelgianIdCardTest.php @@ -26,9 +26,12 @@ namespace web_eid\web_eid_authtoken_validation_php\validator; use DateTime; +use GuzzleHttp\Psr7\Uri; +use web_eid\web_eid_authtoken_validation_php\ocsp\OcspResponse; use web_eid\web_eid_authtoken_validation_php\testutil\AbstractTestWithValidator; use web_eid\web_eid_authtoken_validation_php\testutil\AuthTokenValidators; use web_eid\web_eid_authtoken_validation_php\testutil\Dates; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClient; class AuthTokenCertificateBelgianIdCardTest extends AbstractTestWithValidator { @@ -83,8 +86,41 @@ public function testWhenIdCardWithRSASignatureCertificateIsValidatedThenValidati $validator->validate($token, 'YPVgYc7Qds0qmK/RilPLffnsIg7IIovM4BAWqGZWwiY='); } + public function testWhenIdCardIsValidatedWithAiaOcspCheckThenDelegatedResponderIsAuthorizedAndValidationSucceeds(): void + { + $this->expectNotToPerformAssertions(); + // The OCSP response was recorded from the card's AIA OCSP responder at http://eiddevcards.zetescards.be:8888 + // on 2026-07-02. Its responder certificate is issued by eID TEST EC Citizen CA, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + $this->mockDate("2026-07-02T08:39:30Z"); + $recordedResponseClient = self::getRecordedResponseClient("ocsp_response_belgian_test_id_card.der"); + $validator = AuthTokenValidators::getAuthTokenValidatorForBelgianIdCardWithOcspCheck($recordedResponseClient); + $token = $validator->parse(self::BELGIAN_TEST_ID_CARD_AUTH_TOKEN_ECC); + + $validator->validate($token, 'iMeEwP2cgUINY2XoO/lqEpOUn7z/ysHRqGXkGKC4VXE='); + } + private function mockDate(string $date) { Dates::setMockedCertificateValidatorDate(new DateTime($date)); } + + private static function getRecordedResponseClient(string $resource): OcspClient + { + $response = file_get_contents(__DIR__ . "/../_resources/" . $resource); + return new class ($response) implements OcspClient { + private $response; + + public function __construct($response) + { + $this->response = $response; + } + + public function request(Uri $url, string $requestBody): OcspResponse + { + return new OcspResponse($this->response); + } + }; + } } diff --git a/tests/validator/AuthTokenCertificateFinnishIdCardTest.php b/tests/validator/AuthTokenCertificateFinnishIdCardTest.php index f4c6d40..4ebe60a 100644 --- a/tests/validator/AuthTokenCertificateFinnishIdCardTest.php +++ b/tests/validator/AuthTokenCertificateFinnishIdCardTest.php @@ -26,9 +26,12 @@ namespace web_eid\web_eid_authtoken_validation_php\validator; use DateTime; +use GuzzleHttp\Psr7\Uri; +use web_eid\web_eid_authtoken_validation_php\ocsp\OcspResponse; use web_eid\web_eid_authtoken_validation_php\testutil\AbstractTestWithValidator; use web_eid\web_eid_authtoken_validation_php\testutil\AuthTokenValidators; use web_eid\web_eid_authtoken_validation_php\testutil\Dates; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClient; class AuthTokenCertificateFinnishIdCardTest extends AbstractTestWithValidator { @@ -83,8 +86,41 @@ public function testWhenIdCardSignatureCertificateWithG4RootCertificateIsValidat $validator->validate($token, 'ZqlDATkQRqh7LkqEbspBc2qDjot29oiNLlITdLgiVIo='); } + public function testWhenIdCardIsValidatedWithAiaOcspCheckThenDelegatedResponderIsAuthorizedAndValidationSucceeds(): void + { + $this->expectNotToPerformAssertions(); + // The OCSP response was recorded from the card's AIA OCSP responder at http://ocsptest.fineid.fi/dvvtp5ec + // on 2026-07-02. Its responder certificate is issued by DVV TEST Certificates - G5E, the issuer of the + // authentication certificate, so the RFC 6960 delegated-responder authorization check in AiaOcspService + // must accept it. The clock is set to the recording time as the response thisUpdate age is limited. + $this->mockDate("2026-07-02T08:39:30Z"); + $recordedResponseClient = self::getRecordedResponseClient("ocsp_response_finnish_test_id_card.der"); + $validator = AuthTokenValidators::getAuthTokenValidatorForFinnishIdCardWithOcspCheck($recordedResponseClient); + $token = $validator->parse(self::FINNISH_TEST_ID_CARD_BACKMAN_JUHANI_AUTH_TOKEN); + + $validator->validate($token, 'x9qZDRO/ao2zprt3Z0bkW4CvvE/gALFtUIf3tcC0XxY='); + } + private function mockDate(string $date) { Dates::setMockedCertificateValidatorDate(new DateTime($date)); } + + private static function getRecordedResponseClient(string $resource): OcspClient + { + $response = file_get_contents(__DIR__ . "/../_resources/" . $resource); + return new class ($response) implements OcspClient { + private $response; + + public function __construct($response) + { + $this->response = $response; + } + + public function request(Uri $url, string $requestBody): OcspResponse + { + return new OcspResponse($this->response); + } + }; + } } From 17f4bfa2366c6bf3681257e8ae27e9316b4f1124 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Sun, 5 Jul 2026 19:33:41 +0300 Subject: [PATCH 18/18] NFC-128 Make AIA OCSP issuer matching configurable Ports Java commit b09b84f5: add ResponderIssuerMatchingPolicy (EXACT_CERTIFICATE default, SUBJECT_AND_PUBLIC_KEY opt-in) so that accepting equivalent cross-certificates for the AIA OCSP responder's issuer is an explicit choice instead of always-on behavior. The opt-in policy also requires revocation checking of non-anchor intermediates in the responder's own certification path, wired through the existing IntermediateRevocationChecker. --- README.md | 4 + .../AuthTokenValidationConfiguration.php | 13 ++ src/validator/AuthTokenValidatorBuilder.php | 21 +++ .../IntermediateRevocationCheckerImpl.php | 1 + src/validator/ocsp/OcspServiceProvider.php | 9 +- src/validator/ocsp/service/AiaOcspService.php | 67 ++++++- .../service/AiaOcspServiceConfiguration.php | 14 +- .../service/ResponderIssuerMatchingPolicy.php | 44 +++++ .../AuthTokenVersionValidatorFactory.php | 6 +- .../AuthTokenValidationConfigurationTest.php | 26 +++ .../AuthTokenValidatorBuilderTest.php | 15 ++ .../ocsp/service/AiaOcspServiceTest.php | 163 +++++++++++++++--- 12 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 src/validator/ocsp/service/ResponderIssuerMatchingPolicy.php diff --git a/README.md b/README.md index f4b6aca..52c7f00 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ The following additional configuration options are available in `AuthTokenValida - `withNonceDisabledOcspUrls(URI ...$urls)` – adds the given URLs to the list of OCSP responder access location URLs for which the nonce protocol extension will be disabled. Some OCSP responders don't support the nonce extension. +- `withAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy $matchingPolicy)` – controls how an AIA OCSP responder's issuer is matched against the user certificate's issuer. The default `EXACT_CERTIFICATE` policy requires the same X.509 certificate. Use `SUBJECT_AND_PUBLIC_KEY` to accept equivalent cross-certificates with the same subject and public key; this also enables revocation checking for non-anchor intermediate certificates in the responder's certification path. + - `withAllowedOcspResponseTimeSkew(int $allowedTimeSkew)` – sets the allowed time skew for OCSP response's `thisUpdate` and `nextUpdate` times to allow discrepancies between the system clock and the OCSP responder's clock or revocation updates that are not published in real time. The default allowed time skew is 15 minutes. The relatively long default is specifically chosen to account for one particular OCSP responder that used CRLs for authoritative revocation info, these CRLs were updated every 15 minutes. - `withMaxOcspResponseThisUpdateAge(int $maxThisUpdateAge)` – sets the maximum age for the OCSP response's `thisUpdate` time before it is considered too old to rely on. The default maximum age is 2 minutes. @@ -330,6 +332,8 @@ $validator = new AuthTokenValidatorBuilder() Unless a designated OCSP responder service is in use, it is required that the AIA extension that contains the certificate’s OCSP responder access location is present in the user certificate. The AIA OCSP URL will be used to check the certificate revocation status with OCSP. +By default, the certificate that directly signs an AIA OCSP response, or issues a delegated AIA OCSP responder, must exactly match the certificate that issued the user certificate. Deployments that require equivalent cross-certificates can explicitly select `ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY` with `withAiaOcspResponderIssuerMatchingPolicy()`. This policy also requires the revocation status of every non-anchor intermediate certificate in the responder's certification path to be established. + Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service. ## Possible validation errors diff --git a/src/validator/AuthTokenValidationConfiguration.php b/src/validator/AuthTokenValidationConfiguration.php index fa5b07e..f8bc3a1 100644 --- a/src/validator/AuthTokenValidationConfiguration.php +++ b/src/validator/AuthTokenValidationConfiguration.php @@ -32,6 +32,7 @@ use web_eid\web_eid_authtoken_validation_php\util\UriCollection; use InvalidArgumentException; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\DesignatedOcspServiceConfiguration; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\ResponderIssuerMatchingPolicy; final class AuthTokenValidationConfiguration { @@ -44,6 +45,8 @@ final class AuthTokenValidationConfiguration private array $disallowedSubjectCertificatePolicies; private UriCollection $nonceDisabledOcspUrls; private ?DesignatedOcspServiceConfiguration $designatedOcspServiceConfiguration = null; + private ResponderIssuerMatchingPolicy $aiaOcspResponderIssuerMatchingPolicy = + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE; /** * @copyright 2022 Petr Muzikant pmuzikant@email.cz @@ -136,6 +139,16 @@ public function getNonceDisabledOcspUrls(): UriCollection return $this->nonceDisabledOcspUrls; } + public function getAiaOcspResponderIssuerMatchingPolicy(): ResponderIssuerMatchingPolicy + { + return $this->aiaOcspResponderIssuerMatchingPolicy; + } + + public function setAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy $matchingPolicy): void + { + $this->aiaOcspResponderIssuerMatchingPolicy = $matchingPolicy; + } + /** * Checks that the configuration parameters are valid. * diff --git a/src/validator/AuthTokenValidatorBuilder.php b/src/validator/AuthTokenValidatorBuilder.php index a399e71..c8f5df0 100644 --- a/src/validator/AuthTokenValidatorBuilder.php +++ b/src/validator/AuthTokenValidatorBuilder.php @@ -31,6 +31,7 @@ use web_eid\web_eid_authtoken_validation_php\util\X509Collection; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\OcspClient; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\DesignatedOcspServiceConfiguration; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\ResponderIssuerMatchingPolicy; use Psr\Log\LoggerInterface; class AuthTokenValidatorBuilder @@ -232,6 +233,26 @@ public function withNonceDisabledOcspUrls( return $this; } + /** + * Sets how an AIA OCSP responder certificate issuer is matched against the issuer of the subject certificate. + * The default is {@see ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE}. Use + * {@see ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY} only when equivalent cross-certificates must be + * accepted. Under that policy, non-anchor intermediate certificates in the responder's certification path are + * checked for revocation. + * + * @param ResponderIssuerMatchingPolicy $matchingPolicy AIA OCSP responder issuer matching policy + * @return the builder instance for method chaining + */ + public function withAiaOcspResponderIssuerMatchingPolicy( + ResponderIssuerMatchingPolicy $matchingPolicy, + ): AuthTokenValidatorBuilder { + $this->configuration->setAiaOcspResponderIssuerMatchingPolicy($matchingPolicy); + $this->logger?->debug( + "AIA OCSP responder issuer matching policy set to " . $matchingPolicy->name, + ); + return $this; + } + public function withDesignatedOcspServiceConfiguration( DesignatedOcspServiceConfiguration $serviceConfiguration, ): AuthTokenValidatorBuilder { diff --git a/src/validator/ocsp/IntermediateRevocationCheckerImpl.php b/src/validator/ocsp/IntermediateRevocationCheckerImpl.php index 48ebd4d..7f6537f 100644 --- a/src/validator/ocsp/IntermediateRevocationCheckerImpl.php +++ b/src/validator/ocsp/IntermediateRevocationCheckerImpl.php @@ -131,6 +131,7 @@ private function checkWithOcsp( $certificate, $issuerCertificate, $additionalIntermediateCertificates, + $this, ); $certificateId = (new Ocsp())->generateCertificateId($certificate, $issuerCertificate); diff --git a/src/validator/ocsp/OcspServiceProvider.php b/src/validator/ocsp/OcspServiceProvider.php index 79e9eb2..0983a3c 100644 --- a/src/validator/ocsp/OcspServiceProvider.php +++ b/src/validator/ocsp/OcspServiceProvider.php @@ -28,6 +28,7 @@ use InvalidArgumentException; use phpseclib3\File\X509; +use web_eid\web_eid_authtoken_validation_php\certificate\IntermediateRevocationChecker; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\AiaOcspService; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\AiaOcspServiceConfiguration; use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\DesignatedOcspService; @@ -38,10 +39,12 @@ class OcspServiceProvider { private ?DesignatedOcspService $designatedOcspService; private AiaOcspServiceConfiguration $aiaOcspServiceConfiguration; + private ?IntermediateRevocationChecker $intermediateRevocationChecker; public function __construct( ?DesignatedOcspServiceConfiguration $designatedOcspServiceConfiguration, - AiaOcspServiceConfiguration $aiaOcspServiceConfiguration + AiaOcspServiceConfiguration $aiaOcspServiceConfiguration, + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ) { $this->designatedOcspService = !is_null($designatedOcspServiceConfiguration) ? new DesignatedOcspService($designatedOcspServiceConfiguration) @@ -49,6 +52,7 @@ public function __construct( $this->aiaOcspServiceConfiguration = $aiaOcspServiceConfiguration ?? throw new InvalidArgumentException("AIA Ocsp Service Configuration must not be null"); + $this->intermediateRevocationChecker = $intermediateRevocationChecker; } /** @@ -75,7 +79,8 @@ public function getService( $this->aiaOcspServiceConfiguration, $certificate, $certificateIssuerCertificate, - $additionalIntermediateCertificates + $additionalIntermediateCertificates, + $this->intermediateRevocationChecker, ); } } diff --git a/src/validator/ocsp/service/AiaOcspService.php b/src/validator/ocsp/service/AiaOcspService.php index e1a564f..0051623 100644 --- a/src/validator/ocsp/service/AiaOcspService.php +++ b/src/validator/ocsp/service/AiaOcspService.php @@ -28,6 +28,7 @@ use phpseclib3\File\X509; use web_eid\web_eid_authtoken_validation_php\certificate\CertificateValidator; +use web_eid\web_eid_authtoken_validation_php\certificate\IntermediateRevocationChecker; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotTrustedException; use web_eid\web_eid_authtoken_validation_php\exceptions\OCSPCertificateException; use web_eid\web_eid_authtoken_validation_php\exceptions\UserCertificateOCSPCheckFailedException; @@ -50,18 +51,24 @@ class AiaOcspService implements OcspService /** @var X509[] */ private array $additionalIntermediateCertificates; private bool $supportsNonce; + private ResponderIssuerMatchingPolicy $responderIssuerMatchingPolicy; + private ?IntermediateRevocationChecker $intermediateRevocationChecker; /** * @param X509 $certificate the subject certificate whose revocation status is checked * @param X509 $certificateIssuerCertificate the certificate that directly issued the subject certificate * @param X509[] $additionalIntermediateCertificates token-supplied untrusted intermediate CA * certificates, offered as candidates when building the responder certificate chain + * @param IntermediateRevocationChecker|null $intermediateRevocationChecker used to check the revocation + * status of non-anchor intermediate certificates in the responder's own certification path when + * the configured matching policy is {@see ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY} */ public function __construct( AiaOcspServiceConfiguration $configuration, X509 $certificate, X509 $certificateIssuerCertificate, array $additionalIntermediateCertificates = [], + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ) { if (is_null($configuration)) { throw new InvalidArgumentException("Configuration cannot be null"); @@ -71,6 +78,8 @@ public function __construct( $this->trustedCACertificates = $configuration->getTrustedCACertificates(); $this->certificateIssuerCertificate = $certificateIssuerCertificate; $this->additionalIntermediateCertificates = $additionalIntermediateCertificates; + $this->responderIssuerMatchingPolicy = $configuration->getResponderIssuerMatchingPolicy(); + $this->intermediateRevocationChecker = $intermediateRevocationChecker; $this->supportsNonce = !in_array( $this->url->jsonSerialize(), @@ -92,31 +101,56 @@ public function validateResponderCertificate(X509 $cert, DateTime $now): void { // Certification path validation includes the date-validity check of the responder // certificate. Token-supplied intermediates are offered as path-building candidates. - // Responder certificates are deliberately not revocation-checked (RFC 6960 4.2.2.2.1 - // id-pkix-ocsp-nocheck convention; querying an OCSP service about its own signer - // certificate would be circular). + // The responder certificate itself is never revocation-checked, whatever revocation + // policy the CA has chosen for it under RFC 6960 section 4.2.2.2.1: OCSP-checking a + // responder against its own service would be circular. In practice all production + // Estonian, Belgian and Finnish AIA responder certificates carry id-pkix-ocsp-nocheck, + // which tells clients to skip the check anyway. + // + // With exact issuer matching, the intermediate CA certificates are not checked again: + // this validation run has already vetted the exact issuer while validating the subject + // certificate, as either a configured trust anchor or a token-supplied intermediate + // that was revocation-checked then. With subject-and-public-key matching, however, the + // responder path may use a different equivalent cross-certificate, so every non-anchor + // intermediate in that path is revocation-checked. $responderIssuerCertificate = CertificateValidator::validateIsValidAndSignedByTrustedCA( $cert, $this->trustedCACertificates, "AIA OCSP responder", $this->additionalIntermediateCertificates, - null, + $this->getIntermediateRevocationCheckerForResponderPath(), $now, ); // RFC 6960 section 4.2.2.2: the response must be signed by the CA that issued the - // subject certificate or by a responder directly delegated by it. CA identity is - // compared by subject and public key so that equivalent cross-certificates for the - // same CA are accepted. - if (self::representsSameCA($cert, $this->certificateIssuerCertificate)) { + // subject certificate or by a responder directly delegated by it; a locally configured + // responder is handled by DesignatedOcspService. + if ($this->matchesCertificateIssuer($cert, $this->certificateIssuerCertificate)) { // The response is signed by the issuing CA itself; the OCSP-signing extended key // usage is required only for delegated responder certificates. return; } + if (self::representsSameCA($cert, $this->certificateIssuerCertificate)) { + // The response is signed directly by the issuing CA, but with a certificate that is + // only equivalent to (same subject and public key), not identical with, the subject + // certificate's issuer certificate. This can only happen under the EXACT_CERTIFICATE + // policy, otherwise the check above would already have matched and returned; report + // it explicitly here, because otherwise control falls through to the delegated + // responder branch below and fails with a misleading missing-OCSP-signing-extended + // -key-usage error. + throw new CertificateNotTrustedException( + $cert, + new OCSPCertificateException( + "OCSP response is signed by a certificate equivalent to but not the same as " . + "the subject certificate issuer; the exact-certificate responder issuer " . + "matching policy requires the issuer certificate itself" + ), + ); + } OcspResponseValidator::validateHasSigningExtension($cert); - if (!self::representsSameCA($responderIssuerCertificate, $this->certificateIssuerCertificate)) { + if (!$this->matchesCertificateIssuer($responderIssuerCertificate, $this->certificateIssuerCertificate)) { throw new CertificateNotTrustedException( $cert, new OCSPCertificateException("OCSP responder is not authorized by the subject certificate issuer"), @@ -130,6 +164,21 @@ private static function representsSameCA(X509 $first, X509 $second): bool && $first->getPublicKey()->toString('PKCS8') === $second->getPublicKey()->toString('PKCS8'); } + private function matchesCertificateIssuer(X509 $first, X509 $second): bool + { + return match ($this->responderIssuerMatchingPolicy) { + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE => $first->getCurrentCert() == $second->getCurrentCert(), + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY => self::representsSameCA($first, $second), + }; + } + + private function getIntermediateRevocationCheckerForResponderPath(): ?IntermediateRevocationChecker + { + return $this->responderIssuerMatchingPolicy === ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY + ? $this->intermediateRevocationChecker + : null; + } + private static function getOcspAiaUrlFromCertificate(X509 $certificate): Uri { try { diff --git a/src/validator/ocsp/service/AiaOcspServiceConfiguration.php b/src/validator/ocsp/service/AiaOcspServiceConfiguration.php index e20fce8..8d7e1f9 100644 --- a/src/validator/ocsp/service/AiaOcspServiceConfiguration.php +++ b/src/validator/ocsp/service/AiaOcspServiceConfiguration.php @@ -31,11 +31,16 @@ class AiaOcspServiceConfiguration { private UriCollection $nonceDisabledOcspUrls; private TrustedCertificates $trustedCACertificates; + private ResponderIssuerMatchingPolicy $responderIssuerMatchingPolicy; - public function __construct(UriCollection $nonceDisabledOcspUrls, TrustedCertificates $trustedCACertificates) - { + public function __construct( + UriCollection $nonceDisabledOcspUrls, + TrustedCertificates $trustedCACertificates, + ResponderIssuerMatchingPolicy $responderIssuerMatchingPolicy = ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + ) { $this->nonceDisabledOcspUrls = $nonceDisabledOcspUrls; $this->trustedCACertificates = $trustedCACertificates; + $this->responderIssuerMatchingPolicy = $responderIssuerMatchingPolicy; } public function getNonceDisabledOcspUrls() @@ -47,4 +52,9 @@ public function getTrustedCACertificates() { return $this->trustedCACertificates; } + + public function getResponderIssuerMatchingPolicy(): ResponderIssuerMatchingPolicy + { + return $this->responderIssuerMatchingPolicy; + } } diff --git a/src/validator/ocsp/service/ResponderIssuerMatchingPolicy.php b/src/validator/ocsp/service/ResponderIssuerMatchingPolicy.php new file mode 100644 index 0000000..ec51f0f --- /dev/null +++ b/src/validator/ocsp/service/ResponderIssuerMatchingPolicy.php @@ -0,0 +1,44 @@ +getNonceDisabledOcspUrls(), - $trustedCACertificates + $trustedCACertificates, + $validationConfig->getAiaOcspResponderIssuerMatchingPolicy() ); // The OCSP client is needed even when the user certificate revocation check is @@ -127,7 +128,8 @@ public static function create( if ($validationConfig->isUserCertificateRevocationCheckWithOcspEnabled()) { $ocspServiceProvider = new OcspServiceProvider( $validationConfig->getDesignatedOcspServiceConfiguration(), - $aiaOcspServiceConfiguration + $aiaOcspServiceConfiguration, + $intermediateRevocationChecker ); } diff --git a/tests/validator/AuthTokenValidationConfigurationTest.php b/tests/validator/AuthTokenValidationConfigurationTest.php index d13ccb3..06cf1db 100644 --- a/tests/validator/AuthTokenValidationConfigurationTest.php +++ b/tests/validator/AuthTokenValidationConfigurationTest.php @@ -27,9 +27,35 @@ use PHPUnit\Framework\TestCase; use GuzzleHttp\Psr7\Uri; use InvalidArgumentException; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\ResponderIssuerMatchingPolicy; class AuthTokenValidationConfigurationTest extends TestCase { + public function testAiaOcspResponderIssuerMatchingPolicyDefaultsToExactCertificate(): void + { + $configuration = new AuthTokenValidationConfiguration(); + + $this->assertSame( + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + $configuration->getAiaOcspResponderIssuerMatchingPolicy() + ); + $this->assertSame( + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + (clone $configuration)->getAiaOcspResponderIssuerMatchingPolicy() + ); + } + + public function testAiaOcspResponderIssuerMatchingPolicyIsPreservedByClone(): void + { + $configuration = new AuthTokenValidationConfiguration(); + $configuration->setAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY); + + $this->assertSame( + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY, + (clone $configuration)->getAiaOcspResponderIssuerMatchingPolicy() + ); + } + public function testWhenOriginUrlIsHttp(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/validator/AuthTokenValidatorBuilderTest.php b/tests/validator/AuthTokenValidatorBuilderTest.php index c5069e4..2a0af93 100644 --- a/tests/validator/AuthTokenValidatorBuilderTest.php +++ b/tests/validator/AuthTokenValidatorBuilderTest.php @@ -28,8 +28,10 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use GuzzleHttp\Psr7\Exception\MalformedUriException; +use ReflectionProperty; use web_eid\web_eid_authtoken_validation_php\testutil\Logger; use web_eid\web_eid_authtoken_validation_php\testutil\AuthTokenValidators; +use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\ResponderIssuerMatchingPolicy; class AuthTokenValidatorBuilderTest extends TestCase { @@ -113,4 +115,17 @@ public function testInvalidOcspRequestTimeout(): void $this->expectExceptionMessage("OCSP request timeout must be greater than zero"); $builderWithInvalidOcspRequestTimeout->build(); } + + public function testWithAiaOcspResponderIssuerMatchingPolicySetsConfiguration(): void + { + (self::$builder)->withAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY); + + $configuration = (new ReflectionProperty(AuthTokenValidatorBuilder::class, "configuration")) + ->getValue(self::$builder); + + $this->assertSame( + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY, + $configuration->getAiaOcspResponderIssuerMatchingPolicy() + ); + } } diff --git a/tests/validator/ocsp/service/AiaOcspServiceTest.php b/tests/validator/ocsp/service/AiaOcspServiceTest.php index 4aa4ac8..12803a8 100644 --- a/tests/validator/ocsp/service/AiaOcspServiceTest.php +++ b/tests/validator/ocsp/service/AiaOcspServiceTest.php @@ -26,8 +26,11 @@ use DateTime; use phpseclib3\File\X509; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use web_eid\web_eid_authtoken_validation_php\certificate\IntermediateRevocationChecker; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotTrustedException; +use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateRevocationCheckFailedException; use web_eid\web_eid_authtoken_validation_php\exceptions\OCSPCertificateException; use web_eid\web_eid_authtoken_validation_php\testutil\TestPkiBuilder; use web_eid\web_eid_authtoken_validation_php\testutil\TestPkiCredential; @@ -66,11 +69,10 @@ public static function setUpBeforeClass(): void self::$delegatedResponder = self::$pki->buildOcspResponder("Test OCSP Responder", self::$intermediate); self::$siblingCa = self::$pki->buildIntermediateCa("Test Sibling CA", self::$root); self::$siblingResponder = self::$pki->buildOcspResponder("Test Sibling OCSP Responder", self::$siblingCa); - // Same subject DN and public key as the intermediate CA, different serial and issuer. - self::$crossIntermediate = self::$pki->buildCrossCertificate( - self::$intermediate, - self::$pki->buildRootCa("Other Root CA") - ); + // An equivalent cross-certificate for the intermediate CA: same subject DN and public + // key, but a distinct certificate (different serial number), also issued by the trusted + // root so that it can chain to a trust anchor on its own. + self::$crossIntermediate = self::$pki->buildCrossCertificate(self::$intermediate, self::$root); // Same subject DN as the intermediate CA, different key. self::$impostorIntermediate = self::$pki->buildImpostorCa(self::$intermediate, self::$root); self::$rootDelegatedResponder = self::$pki->buildOcspResponder("Root Delegated OCSP Responder", self::$root); @@ -81,17 +83,33 @@ public static function setUpBeforeClass(): void ); } - public function testWhenResponderIsDelegatedByIssuerAndIntermediateIsSuppliedThenSucceeds(): void - { + #[DataProvider('matchingPolicies')] + public function testWhenResponderIsDelegatedByIssuerAndIntermediateIsSuppliedThenSucceeds( + ResponderIssuerMatchingPolicy $matchingPolicy, + ): void { $this->expectNotToPerformAssertions(); $service = $this->getAiaOcspService( self::$intermediate->getCertificate(), - [self::$intermediate->getCertificate()] + [self::$intermediate->getCertificate()], + $matchingPolicy, ); $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); } + public function testWhenMatchingPolicyIsNotSpecifiedThenExactCertificateMatchingIsUsed(): void + { + $configuration = new AiaOcspServiceConfiguration( + new UriCollection(), + new TrustedCertificates([self::$root->getCertificate()]) + ); + + $this->assertSame( + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + $configuration->getResponderIssuerMatchingPolicy() + ); + } + public function testWhenIntermediateIsNotSuppliedThenResponderPathBuildingFails(): void { $this->expectException(CertificateNotTrustedException::class); @@ -101,19 +119,106 @@ public function testWhenIntermediateIsNotSuppliedThenResponderPathBuildingFails( $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); } - public function testWhenIssuerIsEquivalentCrossCertificateThenSucceeds(): void + public function testWhenIntermediateRevocationStatusIsUnknownThenOnlySubjectAndPublicKeyPolicyFails(): void { - $this->expectNotToPerformAssertions(); + // Simulates an intermediate whose revocation status cannot be established, e.g. because + // it has no usable OCSP or CRL revocation source. + $alwaysFailingChecker = new class implements IntermediateRevocationChecker { + public function validateNotRevoked( + X509 $certificate, + X509 $issuerCertificate, + array $additionalIntermediateCertificates, + ): void { + throw new CertificateRevocationCheckFailedException( + "Revocation status of the intermediate CA certificate could not be established" + ); + } + }; + + $exactService = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [self::$intermediate->getCertificate()], + ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + $alwaysFailingChecker, + ); + $subjectAndPublicKeyService = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [self::$intermediate->getCertificate()], + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY, + $alwaysFailingChecker, + ); + + // The exact-certificate policy never checks the responder's own path intermediates, so + // the always-failing checker is never consulted. + $exactService->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); - // The subject certificate issuer is represented by an equivalent cross-certificate - // that has the same subject DN and public key, but a different serial number. + $this->expectException(CertificateNotTrustedException::class); + $subjectAndPublicKeyService->validateResponderCertificate( + self::$delegatedResponder->getCertificate(), + new DateTime() + ); + } + + public function testWhenIssuerIsEquivalentCrossCertificateWithDefaultPolicyThenFails(): void + { + // The responder still chains to the root via the real intermediate, so its issuer in the + // built path is the real intermediate. The subject certificate issuer is passed as the + // equivalent cross-certificate (same subject and public key, different certificate), + // which the default exact-certificate policy must reject. $service = $this->getAiaOcspService( self::$crossIntermediate->getCertificate(), [self::$intermediate->getCertificate()] ); + + try { + $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + $this->assertInstanceOf(OCSPCertificateException::class, $exception->getPrevious()); + $this->assertSame(self::NOT_AUTHORIZED_MESSAGE, $exception->getPrevious()->getMessage()); + } + } + + public function testWhenIssuerIsEquivalentCrossCertificateWithSubjectAndPublicKeyPolicyThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + $service = $this->getAiaOcspService( + self::$crossIntermediate->getCertificate(), + [self::$intermediate->getCertificate()], + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY, + ); $service->validateResponderCertificate(self::$delegatedResponder->getCertificate(), new DateTime()); } + public function testWhenResponseIsSignedByEquivalentCrossCertificateWithDefaultPolicyThenFails(): void + { + $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), []); + + try { + $service->validateResponderCertificate(self::$crossIntermediate->getCertificate(), new DateTime()); + $this->fail("Expected " . CertificateNotTrustedException::class . " was not thrown"); + } catch (CertificateNotTrustedException $exception) { + $this->assertInstanceOf(OCSPCertificateException::class, $exception->getPrevious()); + $this->assertStringContainsString( + "equivalent to but not the same as the subject certificate issuer", + $exception->getPrevious()->getMessage() + ); + } + } + + public function testWhenResponseIsSignedByEquivalentCrossCertificateWithSubjectAndPublicKeyPolicyThenSucceeds(): void + { + $this->expectNotToPerformAssertions(); + + $service = $this->getAiaOcspService( + self::$intermediate->getCertificate(), + [], + ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY, + ); + $service->validateResponderCertificate(self::$crossIntermediate->getCertificate(), new DateTime()); + } + public function testWhenResponderIsDelegatedBySiblingCaThenFails(): void { $service = $this->getAiaOcspService( @@ -134,14 +239,17 @@ public function testWhenResponderIsDelegatedBySiblingCaThenFails(): void } } - public function testWhenIssuerIsImpostorWithSameNameButDifferentKeyThenFails(): void - { + #[DataProvider('matchingPolicies')] + public function testWhenIssuerIsImpostorWithSameNameButDifferentKeyThenFails( + ResponderIssuerMatchingPolicy $matchingPolicy, + ): void { // The responder chains to the genuine intermediate CA, but the subject certificate // issuer is an impostor CA with the same subject DN and a different key, so the // CA identity comparison must fail. $service = $this->getAiaOcspService( self::$impostorIntermediate->getCertificate(), - [self::$intermediate->getCertificate()] + [self::$intermediate->getCertificate()], + $matchingPolicy, ); try { @@ -171,13 +279,15 @@ public function testWhenResponderIsDelegatedByRootInsteadOfIssuerThenFails(): vo } } - public function testWhenResponderIsTheIssuingCaItselfThenOcspSigningEkuIsNotRequired(): void - { + #[DataProvider('matchingPolicies')] + public function testWhenResponderIsTheIssuingCaItselfThenOcspSigningEkuIsNotRequired( + ResponderIssuerMatchingPolicy $matchingPolicy, + ): void { $this->expectNotToPerformAssertions(); // The issuing CA signs its own OCSP responses; the intermediate CA certificate // does not have the OCSP-signing extended key usage. - $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), []); + $service = $this->getAiaOcspService(self::$intermediate->getCertificate(), [], $matchingPolicy); $service->validateResponderCertificate(self::$intermediate->getCertificate(), new DateTime()); } @@ -196,22 +306,35 @@ public function testWhenDelegatedResponderDoesNotHaveOcspSigningEkuThenThrows(): $service->validateResponderCertificate(self::$responderWithoutEku->getCertificate(), new DateTime()); } + /** @return array */ + public static function matchingPolicies(): array + { + return [ + "EXACT_CERTIFICATE" => [ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE], + "SUBJECT_AND_PUBLIC_KEY" => [ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY], + ]; + } + /** * @param X509[] $additionalIntermediateCertificates */ private function getAiaOcspService( X509 $certificateIssuerCertificate, array $additionalIntermediateCertificates, + ?ResponderIssuerMatchingPolicy $matchingPolicy = null, + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, ): AiaOcspService { $configuration = new AiaOcspServiceConfiguration( new UriCollection(), - new TrustedCertificates([self::$root->getCertificate()]) + new TrustedCertificates([self::$root->getCertificate()]), + $matchingPolicy ?? ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, ); return new AiaOcspService( $configuration, self::$leaf->getCertificate(), $certificateIssuerCertificate, - $additionalIntermediateCertificates + $additionalIntermediateCertificates, + $intermediateRevocationChecker, ); } }