diff --git a/README.md b/README.md index 1bd8174..52c7f00 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 @@ -298,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. @@ -319,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/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" } 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/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 defc25c..a66279f 100644 --- a/src/certificate/CertificateLoader.php +++ b/src/certificate/CertificateLoader.php @@ -81,11 +81,38 @@ public static function decodeCertificateFromBase64( } catch (Exception $e) { throw new CertificateDecodingException( "'{$fieldName}' decode failed", - 0, $e, ); } 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/src/certificate/CertificateValidator.php b/src/certificate/CertificateValidator.php index c680f85..9add111 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,35 +71,218 @@ 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. + * + * 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 + * @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 = [], + ?IntermediateRevocationChecker $intermediateRevocationChecker = null, + ?DateTime $now = null, ): X509 { - $now = DefaultClock::getInstance()->now(); - self::certificateIsValidOnDate($certificate, $now, "User"); + $now = $now ?? DefaultClock::getInstance()->now(); + self::certificateIsValidOnDate($certificate, $now, $certificateSubject); - foreach ($trustedCertificates->getCertificates() as $trustedCertificate) { - $certificate->loadCA( - $trustedCertificate->saveX509($trustedCertificate->getCurrentCert(), X509::FORMAT_PEM) + // Prevent SSRF via CA Issuers URI from user-provided certificate AIA. + // All CA certificates must come from configuration or from the token itself. + X509::disableURLFetch(); + + [$path, $trustAnchor] = self::buildCertificationPath( + $certificate, + $trustedCertificates, + $additionalIntermediateCertificates, + $now, + ); + + if ($intermediateRevocationChecker !== null) { + self::validateIntermediateCertificatesNotRevoked( + $path, + $trustAnchor, + $additionalIntermediateCertificates, + $intermediateRevocationChecker, ); } - if ($certificate->validateSignature()) { - $chain = $certificate->getChain(); - $trustedCACert = next($chain); + // 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); + } - // Verify that the trusted CA cert is presently valid before returning the result. - self::certificateIsValidOnDate($trustedCACert, $now, "Trusted CA"); - return $trustedCACert; + /** + * 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); } - public static function buildTrustFromCertificates(array $certificates): TrustedCertificates + /** + * @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) { + if (self::isSignedBy($current, $trustedCertificate)) { + return [$path, $trustedCertificate]; + } + } + + 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; + } + + /** + * 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 { - return new TrustedCertificates($certificates); + $basicConstraints = $certificate->getExtension("id-ce-basicConstraints"); + if (!is_array($basicConstraints) || ($basicConstraints["cA"] ?? false) !== true) { + return false; + } + + $keyUsage = $certificate->getExtension("id-ce-keyUsage"); + return $keyUsage === false || + (is_array($keyUsage) && in_array("keyCertSign", $keyUsage, true)); + } + + /** + * 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 + { + 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; + } + } + + /** + * 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 @@ +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/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/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php index 9d761c7..f4e71de 100644 --- a/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php +++ b/src/validator/certvalidators/SubjectCertificateNotRevokedValidator.php @@ -49,21 +49,29 @@ 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; $this->ocspClient = $ocspClient; $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/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/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..7f6537f --- /dev/null +++ b/src/validator/ocsp/IntermediateRevocationCheckerImpl.php @@ -0,0 +1,342 @@ +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, + $this, + ); + + $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/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/ocsp/OcspResponseValidator.php b/src/validator/ocsp/OcspResponseValidator.php index 57d03d0..a6e45fb 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; @@ -60,11 +61,38 @@ 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" ); } } + /** + * 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..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,21 +52,35 @@ public function __construct( $this->aiaOcspServiceConfiguration = $aiaOcspServiceConfiguration ?? throw new InvalidArgumentException("AIA Ocsp Service Configuration must not be null"); + $this->intermediateRevocationChecker = $intermediateRevocationChecker; } /** * 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, + $this->intermediateRevocationChecker, + ); } } diff --git a/src/validator/ocsp/service/AiaOcspService.php b/src/validator/ocsp/service/AiaOcspService.php index 87434e6..0051623 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->responderIssuerMatchingPolicy = $configuration->getResponderIssuerMatchingPolicy(); + $this->intermediateRevocationChecker = $intermediateRevocationChecker; $this->supportsNonce = !in_array( $this->url->jsonSerialize(), @@ -73,10 +99,84 @@ 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. + // 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, + $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; 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); - CertificateValidator::validateIsValidAndSignedByTrustedCA($cert, $this->trustedCACertificates); + + if (!$this->matchesCertificateIssuer($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 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 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 @@ +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, + 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(); + + // When the authentication certificate's intermediate certificates are present, + // signing certificates are optional. + if ( + $signingCertificates === null && + $intermediateCertificates !== null && + $intermediateCertificates !== [] + ) { + return []; + } - if ($signingCertificates === null || empty($signingCertificates)) { + 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 || @@ -118,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; } /** @@ -206,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"); @@ -239,6 +246,34 @@ private function validateKeyUsage(X509 $signingCertificate): void } } + /** + * @param X509[] $intermediateCertificates + * @throws AuthTokenParseException + */ + private function validateSigningCertificateChain( + X509 $signingCertificate, + array $intermediateCertificates, + ): void { + try { + // 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, + ); + } + } + /** * @throws AuthTokenException */ diff --git a/src/validator/versionvalidators/AuthTokenVersion1Validator.php b/src/validator/versionvalidators/AuthTokenVersion1Validator.php index 8e44a1e..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,27 +73,34 @@ public function __construct( $this->ocspClient = $ocspClient; $this->ocspServiceProvider = $ocspServiceProvider; $this->logger = $logger; + $this->intermediateRevocationChecker = $intermediateRevocationChecker; } 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( 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; } - private 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 @@ private function buildTrustValidatorBatch(): SubjectCertificateValidatorBatch $this->configuration->getAllowedOcspResponseTimeSkew(), $this->configuration->getMaxOcspResponseThisUpdateAge(), $this->logger, + $additionalIntermediateCertificates, ), ); } @@ -163,6 +186,52 @@ private 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/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..dcd2daa 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; @@ -52,7 +54,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 +67,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 +75,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" ); } @@ -97,21 +100,36 @@ public static function create( ) ); - $ocspClient = null; + $aiaOcspServiceConfiguration = new AiaOcspServiceConfiguration( + $validationConfig->getNonceDisabledOcspUrls(), + $trustedCACertificates, + $validationConfig->getAiaOcspResponderIssuerMatchingPolicy() + ); + + // 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, + $intermediateRevocationChecker ); } @@ -126,7 +144,8 @@ public static function create( $validationConfig, $ocspClient, $ocspServiceProvider, - $logger + $logger, + $intermediateRevocationChecker ); $validator1 = new AuthTokenVersion1Validator( @@ -136,7 +155,8 @@ public static function create( $validationConfig, $ocspClient, $ocspServiceProvider, - $logger + $logger, + $intermediateRevocationChecker ); return new self([ 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 0000000..3cf707b Binary files /dev/null and b/tests/_resources/ocsp_response_belgian_test_id_card.der differ 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 0000000..44dfa1b Binary files /dev/null and b/tests/_resources/ocsp_response_finnish_test_id_card.der differ 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); + } +} diff --git a/tests/certificate/CertificateValidatorTest.php b/tests/certificate/CertificateValidatorTest.php index 53f0cae..cb20173 100644 --- a/tests/certificate/CertificateValidatorTest.php +++ b/tests/certificate/CertificateValidatorTest.php @@ -28,14 +28,36 @@ 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\AuthTokenException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateExpiredException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotTrustedException; use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateNotYetValidException; +use web_eid\web_eid_authtoken_validation_php\exceptions\CertificateRevokedException; 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 { @@ -118,6 +140,277 @@ 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 testWhenCheckerThrowsForIntermediateThenThrowsNamingOffendingIntermediate(): void + { + $checkerException = new CertificateRevokedException("Intermediate CA certificate has been revoked"); + $checker = new class ($checkerException) implements IntermediateRevocationChecker { + public function __construct(private AuthTokenException $exception) + { + } + + public function validateNotRevoked( + X509 $certificate, + X509 $issuerCertificate, + array $additionalIntermediateCertificates, + ): void { + if ($certificate->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, [ + "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/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/testutil/TestPkiBuilder.php b/tests/testutil/TestPkiBuilder.php new file mode 100644 index 0000000..b3158c8 --- /dev/null +++ b/tests/testutil/TestPkiBuilder.php @@ -0,0 +1,365 @@ +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 + * @param bool $includeNextUpdate whether the CRL contains a nextUpdate field + * @return string the CRL in PEM format + */ + public function buildCrl( + TestPkiCredential $issuerCa, + 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")); + 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"); + } + + 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")); + 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"); + } + 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); + } +} 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); + } + }; + } } diff --git a/tests/validator/AuthTokenStructureTest.php b/tests/validator/AuthTokenStructureTest.php index 83b088c..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 @@ -58,4 +74,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/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/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/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"); + } + }; + } +} 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")); } diff --git a/tests/validator/ocsp/service/AiaOcspServiceTest.php b/tests/validator/ocsp/service/AiaOcspServiceTest.php new file mode 100644 index 0000000..12803a8 --- /dev/null +++ b/tests/validator/ocsp/service/AiaOcspServiceTest.php @@ -0,0 +1,340 @@ +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); + // 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); + self::$responderWithoutEku = self::$pki->buildOcspResponder( + "No EKU OCSP Responder", + self::$intermediate, + false + ); + } + + #[DataProvider('matchingPolicies')] + public function testWhenResponderIsDelegatedByIssuerAndIntermediateIsSuppliedThenSucceeds( + ResponderIssuerMatchingPolicy $matchingPolicy, + ): void { + $this->expectNotToPerformAssertions(); + + $service = $this->getAiaOcspService( + 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); + $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 testWhenIntermediateRevocationStatusIsUnknownThenOnlySubjectAndPublicKeyPolicyFails(): void + { + // 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()); + + $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( + 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()); + } + } + + #[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()], + $matchingPolicy, + ); + + 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()); + } + } + + #[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(), [], $matchingPolicy); + $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()); + } + + /** @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()]), + $matchingPolicy ?? ResponderIssuerMatchingPolicy::EXACT_CERTIFICATE, + ); + return new AiaOcspService( + $configuration, + self::$leaf->getCertificate(), + $certificateIssuerCertificate, + $additionalIntermediateCertificates, + $intermediateRevocationChecker, + ); + } +} 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 4367738..54f4b39 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'], ]; } @@ -94,6 +92,9 @@ public static function invalidFormats(): array [''], ['web-eid:1'], ['web-eid:1.0'], + ['web-eid:1.1.0'], + ['web-eid:1.10'], + ['web-eid:1.2'], ['web-eid:2'], ['webauthn:1.1'], ]; @@ -241,4 +242,196 @@ 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 + */ + 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); + } + + 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; + } } diff --git a/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php b/tests/validator/versionvalidators/AuthTokenVersion1ValidatorTest.php index c5065cb..dd46a99 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'], ]; } @@ -89,6 +87,13 @@ public static function invalidFormats(): array [null], [''], ['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'], @@ -115,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 */ 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 */