├── .prettierrc ├── example ├── src │ ├── app.conf.php │ ├── Template.php │ ├── LogWriter.php │ ├── Config.php │ ├── Pages.php │ ├── Router.php │ ├── Logger.php │ └── Auth.php ├── tpl │ ├── welcome.phtml │ ├── site.phtml │ └── login.phtml ├── public │ ├── .htaccess │ ├── index.php │ └── js │ │ └── errors.js ├── Dockerfile.PhpFpm ├── composer.json └── Dockerfile.Apache ├── src ├── util │ ├── HashAlgorithm.php │ ├── TrustedCertificates.php │ ├── SecureRandom.php │ ├── DefaultClock.php │ ├── DateAndTime.php │ ├── CollectionsUtil.php │ └── AsnUtil.php ├── validator │ ├── ocsp │ │ ├── OcspClient.php │ │ ├── service │ │ │ ├── OcspService.php │ │ │ ├── AiaOcspServiceConfiguration.php │ │ │ ├── DesignatedOcspService.php │ │ │ ├── DesignatedOcspServiceConfiguration.php │ │ │ └── AiaOcspService.php │ │ ├── OcspUrl.php │ │ ├── OcspRequestBuilder.php │ │ ├── OcspServiceProvider.php │ │ ├── OcspClientImpl.php │ │ └── OcspResponseValidator.php │ ├── certvalidators │ │ ├── SubjectCertificateValidator.php │ │ ├── SubjectCertificateValidatorBatch.php │ │ ├── SubjectCertificateTrustedValidator.php │ │ ├── SubjectCertificatePolicyValidator.php │ │ └── SubjectCertificatePurposeValidator.php │ ├── AuthTokenValidator.php │ ├── AuthTokenSignatureValidator.php │ └── AuthTokenValidationConfiguration.php ├── exceptions │ ├── ChallengeNullOrEmptyException.php │ ├── CertificateDecodingException.php │ ├── AuthTokenSignatureValidationException.php │ ├── UserCertificateParseException.php │ ├── AuthTokenParseException.php │ ├── ChallengeNonceExpiredException.php │ ├── ChallengeNonceGenerationException.php │ ├── SessionDoesNotExistException.php │ ├── CertificateExpiredException.php │ ├── CertificateNotYetValidException.php │ ├── UserCertificateMissingPurposeException.php │ ├── ChallengeNonceNotFoundException.php │ ├── UserCertificateWrongPurposeException.php │ ├── UserCertificateDisallowedPolicyException.php │ ├── OCSPCertificateException.php │ ├── UserCertificateOCSPCheckFailedException.php │ ├── CertificateNotTrustedException.php │ ├── UserCertificateRevokedException.php │ └── AuthTokenException.php ├── ocsp │ ├── exceptions │ │ ├── OcspCertificateException.php │ │ ├── OcspVerifyFailedException.php │ │ ├── OcspResponseDecodeException.php │ │ └── OcspException.php │ ├── maps │ │ ├── OcspResponseMap.php │ │ └── OcspRequestMap.php │ ├── OcspRequest.php │ ├── certificate │ │ └── CertificateLoader.php │ ├── Ocsp.php │ ├── OcspBasicResponse.php │ └── OcspResponse.php ├── challenge │ ├── ChallengeNonceGenerator.php │ ├── ChallengeNonce.php │ ├── ChallengeNonceGeneratorImpl.php │ ├── ChallengeNonceStore.php │ └── ChallengeNonceGeneratorBuilder.php ├── certificate │ ├── SubjectCertificatePolicies.php │ ├── CertificateLoader.php │ ├── CertificateData.php │ └── CertificateValidator.php └── authtoken │ └── WebEidAuthToken.php ├── .github └── workflows │ └── php.yml ├── LICENSE └── composer.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@prettier/plugin-php"] 3 | } -------------------------------------------------------------------------------- /example/src/app.conf.php: -------------------------------------------------------------------------------- 1 | 'https://localhost', 5 | ]; -------------------------------------------------------------------------------- /example/tpl/welcome.phtml: -------------------------------------------------------------------------------- 1 |

Hello

2 |

Logout

-------------------------------------------------------------------------------- /example/public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine on 2 | RewriteCond %{REQUEST_FILENAME} !-f 3 | RewriteCond %{REQUEST_FILENAME} !.(ico,css,js,jpg,gif,png)$ 4 | RewriteCond %{REQUEST_FILENAME} !-d 5 | RewriteRule . index.php [L] -------------------------------------------------------------------------------- /example/Dockerfile.PhpFpm: -------------------------------------------------------------------------------- 1 | FROM php:fpm 2 | 3 | WORKDIR /var/www/html/web-eid-php-proxy 4 | 5 | COPY . . 6 | 7 | RUN apt-get update 8 | RUN apt-get install unzip 9 | 10 | COPY --from=composer /usr/bin/composer /usr/bin/composer 11 | RUN composer install 12 | -------------------------------------------------------------------------------- /example/tpl/site.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/util/HashAlgorithm.php: -------------------------------------------------------------------------------- 1 | =8.1", 13 | "web-eid/web-eid-authtoken-validation-php": "1.3.*", 14 | "altorouter/altorouter": "^2.0.3", 15 | "psr/log": "^3.0" 16 | }, 17 | "autoload": { 18 | "classmap": ["src"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/Dockerfile.Apache: -------------------------------------------------------------------------------- 1 | FROM php:apache 2 | 3 | COPY . /var/www/html 4 | 5 | RUN apt-get update 6 | RUN apt-get install ssl-cert unzip 7 | 8 | COPY --from=composer /usr/bin/composer /usr/bin/composer 9 | RUN composer install 10 | 11 | ENV APACHE_DOCUMENT_ROOT /var/www/html/public 12 | 13 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf 14 | RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 15 | 16 | # In case you need to control error reporting 17 | #RUN echo "error_reporting=E_ALL & ~E_DEPRECATED" >> /usr/local/etc/php/conf.d/error_reporting.ini 18 | 19 | RUN a2enmod rewrite 20 | RUN a2enmod ssl 21 | RUN a2ensite default-ssl 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: '8.2' 23 | 24 | - name: Validate composer.json and composer.lock 25 | run: composer validate --strict 26 | 27 | - name: Cache Composer packages 28 | id: composer-cache 29 | uses: actions/cache@v4 30 | with: 31 | path: vendor 32 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-php- 35 | 36 | - name: Install dependencies 37 | run: composer install --prefer-dist --no-progress 38 | 39 | - name: Run test suite 40 | run: composer test 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 Estonian Information System Authority 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-eid/web-eid-authtoken-validation-php", 3 | "description": "Web eID authentication token validation library for PHP", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Guido Gröön", 9 | "role" : "developer" 10 | } 11 | ], 12 | "require-dev": { 13 | "phpunit/phpunit": "^10.5" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "web_eid\\web_eid_authtoken_validation_php\\": ["src"] 18 | }, 19 | "classmap": [ 20 | "src/util/CollectionsUtil.php" 21 | ] 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "web_eid\\web_eid_authtoken_validation_php\\": ["tests"] 26 | } 27 | }, 28 | "require": { 29 | "php": "^8.1.0", 30 | "phpseclib/phpseclib": "^3.0.0", 31 | "guzzlehttp/psr7": "^2.6.0", 32 | "psr/log": "^3.0.0" 33 | }, 34 | "scripts": { 35 | "fix-php": ["prettier src/**/* --write", "prettier examples/src/* --write"], 36 | "test": "phpunit --no-coverage --display-warnings", 37 | "test-coverage": [ 38 | "@putenv XDEBUG_MODE=coverage", 39 | "phpunit --coverage-html coverage" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspClient.php: -------------------------------------------------------------------------------- 1 | 32 | * They are used by AuthTokenValidatorImpl and are not part of the public API. 33 | */ 34 | interface SubjectCertificateValidator 35 | { 36 | public function validate(X509 $subjectCertificate): void; 37 | } 38 | -------------------------------------------------------------------------------- /src/exceptions/OCSPCertificateException.php: -------------------------------------------------------------------------------- 1 | overrideFromEnv(); 36 | $router = new Router($config); 37 | $router->init(); 38 | -------------------------------------------------------------------------------- /src/exceptions/CertificateNotTrustedException.php: -------------------------------------------------------------------------------- 1 | getSubjectDN(X509::DN_STRING) . " is not trusted", $cause); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /example/src/LogWriter.php: -------------------------------------------------------------------------------- 1 | error(sprintf("Code: %s", $code)); 39 | echo "success"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/exceptions/UserCertificateRevokedException.php: -------------------------------------------------------------------------------- 1 | certificates = new X509Collection(...$certificates); 38 | } 39 | 40 | public function count(): int 41 | { 42 | return count($this->certificates); 43 | } 44 | 45 | public function getCertificates(): X509Collection 46 | { 47 | return $this->certificates; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/challenge/ChallengeNonceGenerator.php: -------------------------------------------------------------------------------- 1 | getCode(), $cause); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/util/SecureRandom.php: -------------------------------------------------------------------------------- 1 | configArr = $configArr; 33 | return $instance; 34 | } 35 | 36 | public function overrideFromEnv() 37 | { 38 | foreach ($this->configArr as $key => $value) { 39 | $envKey = 'WEB_EID_SAMPLE_'.strtoupper($key); 40 | $envValue = getenv($envKey); 41 | if ($envValue !== false) { 42 | $this->configArr[$key] = $envValue; 43 | } 44 | } 45 | 46 | return $this; 47 | } 48 | 49 | public function get($name) 50 | { 51 | return isset ($this->configArr[$name]) ? $this->configArr[$name] : null; 52 | } 53 | } -------------------------------------------------------------------------------- /src/certificate/SubjectCertificatePolicies.php: -------------------------------------------------------------------------------- 1 | nonceDisabledOcspUrls = $nonceDisabledOcspUrls; 38 | $this->trustedCACertificates = $trustedCACertificates; 39 | } 40 | 41 | public function getNonceDisabledOcspUrls() 42 | { 43 | return $this->nonceDisabledOcspUrls; 44 | } 45 | 46 | public function getTrustedCACertificates() 47 | { 48 | return $this->trustedCACertificates; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/util/DefaultClock.php: -------------------------------------------------------------------------------- 1 | mockedClock)) { 48 | return $this->mockedClock; 49 | } 50 | return new DateTime(); 51 | } 52 | 53 | public function setClock(DateTime $mockedClock): void 54 | { 55 | $this->mockedClock = $mockedClock; 56 | } 57 | 58 | public function resetClock(): void 59 | { 60 | unset($this->mockedClock); 61 | } 62 | } -------------------------------------------------------------------------------- /example/src/Pages.php: -------------------------------------------------------------------------------- 1 | template = new Template(); 33 | } 34 | 35 | private function _generateCsrfToken() 36 | { 37 | // Store token to session 38 | $_SESSION["csrf-token"] = bin2hex(random_bytes(32)); 39 | return $_SESSION["csrf-token"]; 40 | } 41 | 42 | public function login() 43 | { 44 | $this->data = [ 45 | "content" => $this->template->getHtml(__DIR__ . '/../tpl/login.phtml') 46 | ]; 47 | } 48 | 49 | public function welcome() 50 | { 51 | $data = ["auth_user" => $_SESSION["auth-user"]]; 52 | $this->data = [ 53 | "content" => $this->template->getHtml(__DIR__ . '/../tpl/welcome.phtml', $data) 54 | ]; 55 | } 56 | 57 | public function __destruct() 58 | { 59 | $this->data["token"] = $this->_generateCsrfToken();; 60 | echo $this->template->getHtml(__DIR__ . '/../tpl/site.phtml', $this->data); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/challenge/ChallengeNonce.php: -------------------------------------------------------------------------------- 1 | base64EncodedNonce = $base64EncodedNonce; 45 | $this->expirationTime = $expirationTime; 46 | } 47 | 48 | /** 49 | * Get base64 encoded nounce 50 | * 51 | * @return string 52 | */ 53 | public function getBase64EncodedNonce(): string 54 | { 55 | return $this->base64EncodedNonce; 56 | } 57 | 58 | /** 59 | * Get nounce expiration time 60 | * 61 | * @return DateTime 62 | */ 63 | public function getExpirationTime(): DateTime 64 | { 65 | return $this->expirationTime; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/challenge/ChallengeNonceGeneratorImpl.php: -------------------------------------------------------------------------------- 1 | challengeNonceStore = $challengeNonceStore; 38 | $this->secureRandom = $secureRandom; 39 | $this->ttl = $ttl; 40 | } 41 | 42 | public function generateAndStoreNonce(): ChallengeNonce 43 | { 44 | $nonceString = call_user_func($this->secureRandom, self::NONCE_LENGTH); 45 | $expirationTime = DateAndTime::utcNow()->modify("+{$this->ttl} seconds"); 46 | $base64Nonce = base64_encode($nonceString); 47 | $challengeNonce = new ChallengeNonce($base64Nonce, $expirationTime); 48 | $this->challengeNonceStore->put($challengeNonce); 49 | return $challengeNonce; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/public/js/errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020-2024 Estonian Information System Authority 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | 23 | "use strict"; 24 | 25 | const alertUi = { 26 | alert: document.querySelector("#error-message"), 27 | alertMessage: document.querySelector("#error-message .message"), 28 | alertDetails: document.querySelector("#error-message .details") 29 | }; 30 | 31 | export function hideErrorMessage() { 32 | alertUi.alert.style.display = "none"; 33 | } 34 | 35 | export function showErrorMessage(error) { 36 | const message = "Authentication failed"; 37 | const details = 38 | `[Code]\n${error.code}` + 39 | `\n\n[Message]\n${error.message}` + 40 | (error.response ? `\n\n[response]\n${JSON.stringify(error.response, null, " ")}` : ""); 41 | 42 | alertUi.alertMessage.innerText = message; 43 | alertUi.alertDetails.innerText = details; 44 | alertUi.alert.style.display = "block"; 45 | } 46 | 47 | export async function checkHttpError(response) { 48 | if (!response.ok) { 49 | let body; 50 | try { 51 | body = await response.text(); 52 | } catch (error) { 53 | body = "<>"; 54 | } 55 | const error = new Error("Server error: " + body); 56 | error.code = response.status; 57 | throw error; 58 | } 59 | } -------------------------------------------------------------------------------- /src/validator/certvalidators/SubjectCertificateValidatorBatch.php: -------------------------------------------------------------------------------- 1 | validatorList = new SubjectCertificateValidatorCollection(...$validatorList); 42 | } 43 | 44 | public function executeFor(X509 $subjectCertificate): void 45 | { 46 | foreach ($this->validatorList as $validator) { 47 | $validator->validate($subjectCertificate); 48 | } 49 | } 50 | 51 | public function addOptional(bool $condition, SubjectCertificateValidator $optionalValidator): SubjectCertificateValidatorBatch 52 | { 53 | if ($condition) { 54 | $this->validatorList->pushItem($optionalValidator); 55 | } 56 | 57 | return $this; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/certificate/CertificateLoader.php: -------------------------------------------------------------------------------- 1 | loadX509(file_get_contents($resourceName)); 54 | if ($loaded) { 55 | array_push($caCertificates, $cert); 56 | } else { 57 | throw new CertificateDecodingException($resourceName); 58 | } 59 | } 60 | return $caCertificates; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /example/tpl/login.phtml: -------------------------------------------------------------------------------- 1 |

Authenticate with Web eID

2 |

3 | 4 |
5 |
6 |
7 |
8 | 9 | -------------------------------------------------------------------------------- /src/ocsp/maps/OcspResponseMap.php: -------------------------------------------------------------------------------- 1 | ASN1::TYPE_SEQUENCE, 39 | "children" => [ 40 | "responseStatus" => [ 41 | "type" => ASN1::TYPE_ENUMERATED, 42 | "mapping" => [ 43 | 0 => "successful", 44 | 1 => "malformedRequest", 45 | 2 => "internalError", 46 | 3 => "tryLater", 47 | 5 => "sigRequired", 48 | 6 => "unauthorized", 49 | ], 50 | ], 51 | "responseBytes" => [ 52 | "constant" => 0, 53 | "explicit" => true, 54 | "optional" => true, 55 | "type" => ASN1::TYPE_SEQUENCE, 56 | "children" => [ 57 | "responseType" => ["type" => ASN1::TYPE_OBJECT_IDENTIFIER], 58 | "response" => ["type" => ASN1::TYPE_OCTET_STRING], 59 | ], 60 | ], 61 | ], 62 | ]; 63 | } 64 | -------------------------------------------------------------------------------- /src/validator/certvalidators/SubjectCertificateTrustedValidator.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 42 | $this->trustedCACertificates = $trustedCACertificates; 43 | } 44 | 45 | public function validate(X509 $subjectCertificate): void 46 | { 47 | $this->subjectCertificateIssuerCertificate = CertificateValidator::validateIsValidAndSignedByTrustedCA( 48 | $subjectCertificate, 49 | $this->trustedCACertificates 50 | ); 51 | 52 | $this->logger?->debug("Subject certificate is valid and signed by a trusted CA"); 53 | } 54 | 55 | public function getSubjectCertificateIssuerCertificate(): X509 56 | { 57 | return $this->subjectCertificateIssuerCertificate; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspUrl.php: -------------------------------------------------------------------------------- 1 | getExtension("id-pe-authorityInfoAccess"); 51 | if ($authorityInformationAccess) { 52 | foreach ($authorityInformationAccess as $accessDescription) { 53 | if (in_array($accessDescription["accessMethod"], ["id-pkix-ocsp", "id-ad-ocsp"]) && array_key_exists("uniformResourceIdentifier", $accessDescription["accessLocation"])) { 54 | $accessLocationUrl = $accessDescription["accessLocation"]["uniformResourceIdentifier"]; 55 | return new Uri($accessLocationUrl); 56 | } 57 | } 58 | } 59 | 60 | return null; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/validator/AuthTokenValidator.php: -------------------------------------------------------------------------------- 1 | 51 | * See {@link CertificateData} and {@link TitleCase} for convenience methods for retrieving user 52 | * information from the certificate. 53 | * 54 | * @param WebEidAuthToken authToken the Web eID authentication token 55 | * @param String currentChallengeNonce the challenge nonce that is associated with the authentication token 56 | * @return validated subject certificate 57 | */ 58 | public function validate(WebEidAuthToken $authToken, string $currentChallengeNonce): X509; 59 | } 60 | -------------------------------------------------------------------------------- /example/src/Router.php: -------------------------------------------------------------------------------- 1 | config = $config; 33 | } 34 | 35 | public function init() 36 | { 37 | $router = new AltoRouter(); 38 | $router->setBasePath(""); 39 | 40 | // Page routes 41 | $router->map("GET", "/", ["controller" => "Pages", "method" => "login"]); 42 | $router->map("GET", "/logout", ["controller" => "Auth", "method" => "logout"]); 43 | // Endpoint for extension errors logging 44 | $router->map("POST", "/logger", ["controller" => "LogWriter", "method" => "add"]); 45 | 46 | // Web eID routes 47 | $router->map("GET", "/nonce", ["controller" => "Auth", "method" => "getNonce"]); 48 | $router->map("POST", "/validate", ["controller" => "Auth", "method" => "validate"]); 49 | 50 | // Allow route only for authenticated users 51 | if (isset($_SESSION["auth-user"])) { 52 | $router->map("GET", "/welcome", ["controller" => "Pages", "method" => "welcome"]); 53 | } 54 | 55 | $match = $router->match(); 56 | 57 | if (!$match) { 58 | // Redirect to login 59 | header("Location: /"); 60 | return; 61 | } 62 | 63 | 64 | $controller = new $match["target"]["controller"]($this->config); 65 | $method = $match["target"]["method"]; 66 | 67 | call_user_func([$controller, $method], $match["params"], []); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/util/DateAndTime.php: -------------------------------------------------------------------------------- 1 | setTimezone(new DateTimeZone("UTC")))->format("Y-m-d H:i:s e"); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspRequestBuilder.php: -------------------------------------------------------------------------------- 1 | secureRandom = function ($nonce_length): string { 43 | return SecureRandom::generate($nonce_length); 44 | }; 45 | } 46 | 47 | public function withCertificateId(array $certificateId): OcspRequestBuilder 48 | { 49 | $this->certificateId = $certificateId; 50 | return $this; 51 | } 52 | 53 | public function enableOcspNonce(bool $ocspNonceEnabled): OcspRequestBuilder 54 | { 55 | $this->ocspNonceEnabled = $ocspNonceEnabled; 56 | return $this; 57 | } 58 | 59 | public function build(): OcspRequest 60 | { 61 | $ocspRequest = new OcspRequest(); 62 | if (is_null($this->certificateId)) { 63 | throw new InvalidArgumentException("Certificate Id must not be null"); 64 | } 65 | $ocspRequest->addCertificateId($this->certificateId); 66 | 67 | if ($this->ocspNonceEnabled) { 68 | $nonceBytes = call_user_func($this->secureRandom, 32); 69 | $ocspRequest->addNonceExtension($nonceBytes); 70 | } 71 | 72 | return $ocspRequest; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ocsp/OcspRequest.php: -------------------------------------------------------------------------------- 1 | ocspRequest = [ 42 | "tbsRequest" => [ 43 | "version" => "v1", 44 | ], 45 | ]; 46 | } 47 | 48 | public function addCertificateId(array $certificateId): void 49 | { 50 | $request = [ 51 | "reqCert" => $certificateId, 52 | ]; 53 | $this->ocspRequest["tbsRequest"]["requestList"][] = $request; 54 | } 55 | 56 | public function addNonceExtension(string $nonce): void 57 | { 58 | $nonceExtension = [ 59 | "extnId" => AsnUtil::ID_PKIX_OCSP_NONCE, 60 | "critical" => false, 61 | "extnValue" => ASN1::encodeDER($nonce, ['type' => ASN1::TYPE_OCTET_STRING]), 62 | ]; 63 | $this->ocspRequest["tbsRequest"]["requestExtensions"][] = $nonceExtension; 64 | } 65 | 66 | /** 67 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 68 | */ 69 | public function getNonceExtension(): ?string 70 | { 71 | return AsnUtil::decodeNonceExtension($this->ocspRequest["tbsRequest"]["requestExtensions"] ?? []); 72 | } 73 | 74 | public function getEncodeDer(): string 75 | { 76 | return ASN1::encodeDER($this->ocspRequest, OcspRequestMap::MAP); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/validator/certvalidators/SubjectCertificatePolicyValidator.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 39 | $this->disallowedSubjectCertificatePolicyIds = $disallowedSubjectCertificatePolicyIds; 40 | } 41 | 42 | public function validate(X509 $subjectCertificate): void 43 | { 44 | $this->logger?->debug("Validating"); 45 | 46 | // No need to validate 47 | if (count($this->disallowedSubjectCertificatePolicyIds) == 0) { 48 | return; 49 | } 50 | 51 | $policies = $subjectCertificate->getExtension('id-ce-certificatePolicies'); 52 | // When there is no certificatePolicies or certificate parse failed 53 | if (!$policies) { 54 | return; 55 | } 56 | 57 | // Loop through disallowed policies array 58 | foreach ($policies as $policy) { 59 | if (in_array($policy['policyIdentifier'], $this->disallowedSubjectCertificatePolicyIds)) { 60 | throw new UserCertificateDisallowedPolicyException(); 61 | } 62 | } 63 | 64 | $this->logger?->debug("User certificate does not contain disallowed policies."); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/validator/ocsp/service/DesignatedOcspService.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 45 | } 46 | 47 | public function doesSupportNonce(): bool 48 | { 49 | return $this->configuration->doesSupportNonce(); 50 | } 51 | 52 | public function getAccessLocation(): Uri 53 | { 54 | return $this->configuration->getOcspServiceAccessLocation(); 55 | } 56 | 57 | public function supportsIssuerOf(X509 $certificate): bool 58 | { 59 | return $this->configuration->supportsIssuerOf($certificate); 60 | } 61 | 62 | public function validateResponderCertificate(X509 $cert, DateTime $now): void 63 | { 64 | // Certificate pinning is implemented simply by comparing the certificates or their public keys, 65 | // see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning. 66 | if ($this->configuration->getResponderCertificate()->getCurrentCert() != $cert->getCurrentCert()) { 67 | throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to the configured designated OCSP responder certificate"); 68 | } 69 | CertificateValidator::certificateIsValidOnDate($cert, $now, "Designated OCSP responder"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /example/src/Logger.php: -------------------------------------------------------------------------------- 1 | log(LogLevel::EMERGENCY, $message, $context); 33 | } 34 | 35 | public function alert(string|\Stringable $message, array $context = []): void 36 | { 37 | $this->log(LogLevel::ALERT, $message, $context); 38 | } 39 | 40 | public function critical(string|\Stringable $message, array $context = []): void 41 | { 42 | $this->log(LogLevel::CRITICAL, $message, $context); 43 | } 44 | 45 | public function error(string|\Stringable $message, array $context = []): void 46 | { 47 | $this->log(LogLevel::ERROR, $message, $context); 48 | } 49 | 50 | public function warning(string|\Stringable $message, array $context = []): void 51 | { 52 | $this->log(LogLevel::WARNING, $message, $context); 53 | } 54 | 55 | public function notice(string|\Stringable $message, array $context = []): void 56 | { 57 | $this->log(LogLevel::NOTICE, $message, $context); 58 | } 59 | 60 | public function info(string|\Stringable $message, array $context = []): void 61 | { 62 | $this->log(LogLevel::INFO, $message, $context); 63 | } 64 | 65 | public function debug(string|\Stringable $message, array $context = []): void 66 | { 67 | $this->log(LogLevel::DEBUG, $message, $context); 68 | } 69 | 70 | public function log($level, string|\Stringable $message, array $context = []): void 71 | { 72 | // Build the message with the current date, log level, 73 | // and the string from the arguments 74 | $message = sprintf( 75 | '%s: %s%s', 76 | $level, 77 | $message, 78 | PHP_EOL // Line break 79 | ); 80 | 81 | error_log($message, 0); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/certificate/CertificateData.php: -------------------------------------------------------------------------------- 1 | getSubjectDNProp($fieldId); 88 | if ($result) { 89 | return join(", ", $result); 90 | } else { 91 | return null; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspServiceProvider.php: -------------------------------------------------------------------------------- 1 | designatedOcspService = !is_null($designatedOcspServiceConfiguration) ? new DesignatedOcspService($designatedOcspServiceConfiguration) : null; 45 | $this->aiaOcspServiceConfiguration = $aiaOcspServiceConfiguration ?? throw new InvalidArgumentException("AIA Ocsp Service Configuration must not be null"); 46 | 47 | } 48 | 49 | /** 50 | * A static factory method that returns either the designated or AIA OCSP service instance depending on whether 51 | * the designated OCSP service is configured and supports the issuer of the certificate. 52 | * 53 | * @param certificate subject certificate that is to be checked with OCSP 54 | * @return OcspService either the designated or AIA OCSP service instance 55 | */ 56 | public function getService(X509 $certificate): OcspService 57 | { 58 | if (!is_null($this->designatedOcspService) && $this->designatedOcspService->supportsIssuerOf($certificate)) { 59 | return $this->designatedOcspService; 60 | } 61 | 62 | return new AiaOcspService($this->aiaOcspServiceConfiguration, $certificate); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspClientImpl.php: -------------------------------------------------------------------------------- 1 | requestTimeout = $ocspRequestTimeout; 43 | $this->logger = $logger; 44 | } 45 | 46 | public static function build(int $ocspRequestTimeout, ?LoggerInterface $logger = null): OcspClient 47 | { 48 | return new OcspClientImpl($ocspRequestTimeout, $logger); 49 | } 50 | 51 | public function request(Uri $uri, string $encodedOcspRequest): OcspResponse 52 | { 53 | $curl = curl_init(); 54 | curl_setopt($curl, CURLOPT_URL, $uri->jsonSerialize()); 55 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 56 | curl_setopt($curl, CURLOPT_FAILONERROR, true); 57 | curl_setopt($curl, CURLOPT_POST, true); 58 | curl_setopt($curl, CURLOPT_HTTPHEADER, ["Content-Type: " . self::OCSP_REQUEST_TYPE]); 59 | curl_setopt($curl, CURLOPT_POSTFIELDS, $encodedOcspRequest); 60 | curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $this->requestTimeout); 61 | curl_setopt($curl, CURLOPT_TIMEOUT, $this->requestTimeout); 62 | $result = curl_exec($curl); 63 | 64 | if (curl_errno($curl)) { 65 | throw new UserCertificateOCSPCheckFailedException(curl_error($curl)); 66 | } 67 | 68 | $info = curl_getinfo($curl); 69 | if ($info["http_code"] !== 200) { 70 | throw new UserCertificateOCSPCheckFailedException("OCSP request was not successful, response: " + $result); 71 | } 72 | 73 | $response = new OcspResponse($result); 74 | 75 | $responseJson = json_encode($response->getResponse(), JSON_INVALID_UTF8_IGNORE); 76 | $this->logger?->debug("OCSP response: " . $responseJson); 77 | 78 | if ($info["content_type"] !== self::OCSP_RESPONSE_TYPE) { 79 | throw new UserCertificateOCSPCheckFailedException("OCSP response content type is not " . self::OCSP_RESPONSE_TYPE); 80 | } 81 | 82 | return $response; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/validator/certvalidators/SubjectCertificatePurposeValidator.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 45 | } 46 | 47 | /** 48 | * Validates that the purpose of the user certificate from the authentication token contains client authentication. 49 | * 50 | * @param X509 $subjectCertificate user certificate to be validated 51 | * @throws UserCertificateMissingPurposeException 52 | * @throws UserCertificateWrongPurposeException 53 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 54 | * 55 | */ 56 | public function validate(X509 $subjectCertificate): void 57 | { 58 | $keyUsage = $subjectCertificate->getExtension(self::KEY_USAGE); 59 | if (!$keyUsage || empty($keyUsage)) { 60 | throw new UserCertificateMissingPurposeException(); 61 | } 62 | if (!$keyUsage[self::KEY_USAGE_DIGITAL_SIGNATURE]) { 63 | throw new UserCertificateWrongPurposeException(); 64 | } 65 | $usages = $subjectCertificate->getExtension(self::EXTENDED_KEY_USAGE); 66 | if (!$usages || empty($usages)) { 67 | // Digital Signature extension present, but Extended Key Usage extension not present, 68 | // assume it is an authentication certificate (e.g. Luxembourg eID). 69 | $this->logger?->debug("User certificate has Digital Signature key usage and no Extended Key Usage extension, this means that it can be used for client authentication."); 70 | return; 71 | } 72 | // Extended usages must contain TLS Web Client Authentication 73 | if (!in_array(self::EXTENDED_KEY_USAGE_CLIENT_AUTHENTICATION, $usages)) { 74 | throw new UserCertificateWrongPurposeException(); 75 | } 76 | 77 | $this->logger?->debug("User certificate can be used for client authentication."); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/validator/ocsp/service/DesignatedOcspServiceConfiguration.php: -------------------------------------------------------------------------------- 1 | ocspServiceAccessLocation = $ocspServiceAccessLocation; 54 | $this->responderCertificate = $responderCertificate; 55 | $this->supportedIssuers = $this->getIssuerX500Names($supportedCertificateIssuers); 56 | $this->doesSupportNonce = $doesSupportNonce; 57 | 58 | OcspResponseValidator::validateHasSigningExtension($responderCertificate); 59 | } 60 | 61 | public function getOcspServiceAccessLocation(): Uri 62 | { 63 | return $this->ocspServiceAccessLocation; 64 | } 65 | 66 | public function getResponderCertificate(): X509 67 | { 68 | return $this->responderCertificate; 69 | } 70 | 71 | public function doesSupportNonce(): bool 72 | { 73 | return $this->doesSupportNonce; 74 | } 75 | 76 | public function supportsIssuerOf(X509 $certificate): bool 77 | { 78 | return in_array($certificate->getIssuerDN(X509::DN_STRING), $this->supportedIssuers); 79 | } 80 | 81 | private function getIssuerX500Names(X509Collection $supportedCertificateIssuers): array 82 | { 83 | $supportedIssuers = []; 84 | foreach ($supportedCertificateIssuers as $issuer) { 85 | $supportedIssuers[] = $issuer->getSubjectDN(X509::DN_STRING); 86 | } 87 | return $supportedIssuers; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/authtoken/WebEidAuthToken.php: -------------------------------------------------------------------------------- 1 | unverifiedCertificate = $this->filterString('unverifiedCertificate', $jsonDecoded['unverifiedCertificate']); 62 | } 63 | // algorithm 64 | if (isset($jsonDecoded['algorithm'])) { 65 | $this->algorithm = $this->filterString('algorithm', $jsonDecoded['algorithm']); 66 | } 67 | // signature 68 | if (isset($jsonDecoded['signature'])) { 69 | $this->signature = $this->filterString('signature', $jsonDecoded['signature']); 70 | } 71 | // format 72 | if (isset($jsonDecoded['format'])) { 73 | $this->format = $this->filterString('format', $jsonDecoded['format']); 74 | } 75 | } 76 | 77 | public function getUnverifiedCertificate(): ?string 78 | { 79 | return $this->unverifiedCertificate; 80 | } 81 | 82 | public function getAlgorithm(): ?string 83 | { 84 | return $this->algorithm; 85 | } 86 | 87 | public function getSignature(): ?string 88 | { 89 | return $this->signature; 90 | } 91 | 92 | public function getFormat(): ?string 93 | { 94 | return $this->format; 95 | } 96 | 97 | private function filterString(string $key, $data): string 98 | { 99 | $type = gettype($data); 100 | if ($type != "string") { 101 | throw new UnexpectedValueException("Error parsing Web eID authentication token: '{$key}' is {$type}, string expected"); 102 | } 103 | return $data; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/validator/ocsp/service/AiaOcspService.php: -------------------------------------------------------------------------------- 1 | url = self::getOcspAiaUrlFromCertificate($certificate); 56 | $this->trustedCACertificates = $configuration->getTrustedCACertificates(); 57 | $this->supportsNonce = !in_array($this->url->jsonSerialize(), $configuration->getNonceDisabledOcspUrls()->getUrlsArray()); 58 | } 59 | 60 | public function doesSupportNonce(): bool 61 | { 62 | return $this->supportsNonce; 63 | } 64 | 65 | public function getAccessLocation(): Uri 66 | { 67 | return $this->url; 68 | } 69 | 70 | public function validateResponderCertificate(X509 $cert, DateTime $now): void 71 | { 72 | CertificateValidator::certificateIsValidOnDate($cert, $now, "AIA OCSP responder"); 73 | // Trusted certificates' validity has been already verified in validateCertificateExpiry(). 74 | OcspResponseValidator::validateHasSigningExtension($cert); 75 | CertificateValidator::validateIsValidAndSignedByTrustedCA($cert, $this->trustedCACertificates); 76 | } 77 | 78 | private static function getOcspAiaUrlFromCertificate(X509 $certificate): Uri 79 | { 80 | try { 81 | $uri = OcspUrl::getOcspUri($certificate); 82 | } catch (Exception $e) { 83 | throw new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed"); 84 | } 85 | 86 | if (is_null($uri)) { 87 | throw new UserCertificateOCSPCheckFailedException("Getting the AIA OCSP responder field from the certificate failed"); 88 | } 89 | return $uri; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/certificate/CertificateValidator.php: -------------------------------------------------------------------------------- 1 | validateDate($date)) { 52 | 53 | if ($date < new DateTime($subjectCertificate->getCurrentCert()['tbsCertificate']['validity']['notBefore']['utcTime'])) { 54 | throw new CertificateNotYetValidException($subject); 55 | } 56 | 57 | if ($date > new DateTime($subjectCertificate->getCurrentCert()['tbsCertificate']['validity']['notAfter']['utcTime'])) { 58 | throw new CertificateExpiredException($subject); 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 65 | */ 66 | public static function validateIsValidAndSignedByTrustedCA( 67 | X509 $certificate, 68 | TrustedCertificates $trustedCertificates, 69 | ): X509 70 | { 71 | $now = DefaultClock::getInstance()->now(); 72 | self::certificateIsValidOnDate($certificate, $now, "User"); 73 | 74 | foreach ($trustedCertificates->getCertificates() as $trustedCertificate) { 75 | $certificate->loadCA( 76 | $trustedCertificate->saveX509($trustedCertificate->getCurrentCert(), X509::FORMAT_PEM) 77 | ); 78 | } 79 | 80 | if ($certificate->validateSignature()) { 81 | $chain = $certificate->getChain(); 82 | $trustedCACert = end($chain); 83 | 84 | // Verify that the trusted CA cert is presently valid before returning the result. 85 | self::certificateIsValidOnDate($trustedCACert, $now, "Trusted CA"); 86 | return $trustedCACert; 87 | } 88 | 89 | throw new CertificateNotTrustedException($certificate); 90 | } 91 | 92 | public static function buildTrustFromCertificates(array $certificates): TrustedCertificates 93 | { 94 | return new TrustedCertificates($certificates); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/challenge/ChallengeNonceStore.php: -------------------------------------------------------------------------------- 1 | doesSessionExist()) { 48 | throw new SessionDoesNotExistException(); 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Store challenge nonce object into session 55 | * 56 | * @param ChallengeNonce $challengeNonce - challenge nonce object 57 | */ 58 | public function put(ChallengeNonce $challengeNonce) 59 | { 60 | $_SESSION[self::CHALLENGE_NONCE_SESSION_KEY] = serialize($challengeNonce); 61 | } 62 | 63 | /** 64 | * Get challenge nonce from store and remove it from store 65 | * 66 | * @return null|ChallengeNonce 67 | */ 68 | public function getAndRemove(): ?ChallengeNonce 69 | { 70 | 71 | if (!isset($_SESSION[self::CHALLENGE_NONCE_SESSION_KEY])) { 72 | throw new ChallengeNonceNotFoundException(); 73 | } 74 | 75 | // Unserialize challenge nonce from session 76 | $challengeNonce = unserialize($_SESSION[self::CHALLENGE_NONCE_SESSION_KEY], [ 77 | "allowed_classes" => [ 78 | ChallengeNonce::class, 79 | DateTime::class 80 | ] 81 | ]); 82 | 83 | if (!$challengeNonce) { 84 | throw new ChallengeNonceNotFoundException(); 85 | } 86 | 87 | if (DateAndTime::utcNow() > $challengeNonce->getExpirationTime()) { 88 | throw new ChallengeNonceExpiredException(); 89 | } 90 | 91 | // Remove challenge nonce from session 92 | unset($_SESSION[self::CHALLENGE_NONCE_SESSION_KEY]); 93 | 94 | return $challengeNonce; 95 | } 96 | 97 | /** 98 | * Returns boolean, is session available 99 | * 100 | * @return bool 101 | */ 102 | private function doesSessionExist(): bool 103 | { 104 | return (session_status() !== PHP_SESSION_NONE); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/challenge/ChallengeNonceGeneratorBuilder.php: -------------------------------------------------------------------------------- 1 | ttl = 300; 54 | $this->secureRandom = function ($nonce_length) { 55 | return SecureRandom::generate($nonce_length); 56 | }; 57 | } 58 | 59 | /** 60 | * Override default nonce time-to-live duration. 61 | *

62 | * When the time-to-live passes, the nonce is considered to be expired. 63 | * 64 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 65 | * 66 | * @param int $seconds - challenge nonce time-to-live duration in seconds 67 | * @return ChallengeNonceGeneratorBuilder builder instance 68 | */ 69 | public function withNonceTtl(int $seconds): ChallengeNonceGeneratorBuilder 70 | { 71 | $this->ttl = $seconds; 72 | return $this; 73 | } 74 | 75 | /** 76 | * Sets the challenge nonce store where the generated challenge nonces will be stored. 77 | * 78 | * @param challengeNonceStore challenge nonce store 79 | * @return ChallengeNonceGeneratorBuilder builder instance 80 | */ 81 | public function withChallengeNonceStore(ChallengeNonceStore $challengeNonceStore): ChallengeNonceGeneratorBuilder 82 | { 83 | $this->challengeNonceStore = $challengeNonceStore; 84 | return $this; 85 | } 86 | 87 | /** 88 | * Sets the source of random bytes for the nonce. 89 | * 90 | * @param callable $secureRandom function which returns random bytes with number of bytes as input 91 | * 92 | * @return ChallengeNonceGeneratorBuilder builder instance 93 | */ 94 | public function withSecureRandom(callable $secureRandom): ChallengeNonceGeneratorBuilder 95 | { 96 | $this->secureRandom = $secureRandom; 97 | return $this; 98 | } 99 | 100 | /** 101 | * Validates the configuration and builds the {@link ChallengeNonceGenerator} instance. 102 | * 103 | * @return new challenge nonce generator instance 104 | */ 105 | public function build(): ChallengeNonceGenerator 106 | { 107 | // Force to use PHP session based nounce store when store is not set 108 | if (!isset($this->challengeNonceStore)) { 109 | $this->challengeNonceStore = new ChallengeNonceStore(); 110 | } 111 | $this->validateParameters(); 112 | return new ChallengeNonceGeneratorImpl($this->challengeNonceStore, $this->secureRandom, $this->ttl); 113 | } 114 | 115 | private function validateParameters(): void 116 | { 117 | if (is_null($this->challengeNonceStore)) { 118 | throw new InvalidArgumentException("Challenge nonce store must not be null"); 119 | } 120 | if (is_null($this->secureRandom)) { 121 | throw new InvalidArgumentException("Secure random generator must not be null"); 122 | } 123 | DateAndTime::requirePositiveDuration($this->ttl, "Nonce TTL"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/validator/AuthTokenSignatureValidator.php: -------------------------------------------------------------------------------- 1 | siteOrigin = $siteOrigin; 58 | } 59 | 60 | public function validate(string $algorithm, string $signature, $publicKey, string $currentChallengeNonce): void 61 | { 62 | if (empty($currentChallengeNonce)) { 63 | throw new ChallengeNullOrEmptyException(); 64 | } 65 | 66 | if (is_null($publicKey)) { 67 | throw new InvalidArgumentException("Public key is null"); 68 | } 69 | 70 | $this->requireNotEmpty($algorithm, "algorithm"); 71 | $this->requireNotEmpty($signature, "signature"); 72 | 73 | if (!in_array($algorithm, self::ALLOWED_SIGNATURE_ALGORITHMS)) { 74 | throw new AuthTokenParseException("Unsupported signature algorithm"); 75 | } 76 | 77 | $decodedSignature = base64_decode($signature); 78 | 79 | // Note that in case of ECDSA, some eID cards output raw R||S, so we need to trascode it to DER 80 | if (in_array($algorithm, self::ECDSA_ALGORITHMS) && !AsnUtil::isSignatureInAsn1Format($decodedSignature)) { 81 | $decodedSignature = AsnUtil::transcodeSignatureToDER($decodedSignature); 82 | } 83 | 84 | if (in_array($algorithm, self::RSASSA_PSS_ALGORITHMS)) { 85 | $publicKey = openssl_get_publickey($publicKey->withPadding(RSA::SIGNATURE_PSS)->toString('PSS')); 86 | if (!$publicKey) { 87 | throw new AuthTokenParseException('Could not use PSS padding for RSASSA-PSS algorithm'); 88 | } 89 | } 90 | 91 | $hashAlgorithm = $this->hashAlgorithmForName($algorithm); 92 | 93 | $originHash = openssl_digest($this->siteOrigin->jsonSerialize(), $hashAlgorithm, true); 94 | $nonceHash = openssl_digest($currentChallengeNonce, $hashAlgorithm, true); 95 | $concatSignedFields = $originHash . $nonceHash; 96 | 97 | $result = openssl_verify($concatSignedFields, $decodedSignature, $publicKey, $hashAlgorithm); 98 | if ($result !== 1) { 99 | throw new AuthTokenSignatureValidationException($result === -1 ? openssl_error_string() : "Signature is invalid"); 100 | } 101 | } 102 | 103 | private function hashAlgorithmForName(string $algorithm): string 104 | { 105 | return "sha" . substr($algorithm, -3); 106 | } 107 | 108 | private function requireNotEmpty(string $argument, string $fieldName): void 109 | { 110 | if (empty($argument)) { 111 | throw new AuthTokenParseException("'" . $fieldName . "' is null or empty"); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/util/CollectionsUtil.php: -------------------------------------------------------------------------------- 1 | array[$offset]); 51 | } 52 | 53 | #[\ReturnTypeWillChange] 54 | public function offsetGet(mixed $offset): mixed 55 | { 56 | return $this->array[$offset]; 57 | } 58 | 59 | public function offsetSet(mixed $offset, mixed $value): void 60 | { 61 | $this->validateType($value); 62 | $this->array[$offset] = $value; 63 | } 64 | 65 | public function offsetUnset(mixed $offset): void 66 | { 67 | unset($this->array[$offset]); 68 | } 69 | 70 | public function count(): int 71 | { 72 | return count($this->array); 73 | } 74 | 75 | public function getIterator(): ArrayIterator 76 | { 77 | return new ArrayIterator($this->array); 78 | } 79 | 80 | public function pushItem($value): void 81 | { 82 | $this->validateType($value); 83 | array_push($this->array, $value); 84 | } 85 | } 86 | 87 | class X509Collection extends Collection 88 | { 89 | public function __construct(X509 ...$certificates) 90 | { 91 | $this->array = $certificates; 92 | } 93 | 94 | public function validateType($value): void 95 | { 96 | if (!$value instanceof X509) { 97 | throw new TypeError("Wrong type, expected " . X509::class); 98 | } 99 | } 100 | 101 | // For logging purpose 102 | public static function getSubjectDNs(?X509Collection $x509Collection, X509 ...$certificates): array 103 | { 104 | $array = is_null($x509Collection) ? $certificates : $x509Collection; 105 | $subjectDNs = []; 106 | foreach ($array as $certificate) { 107 | $subjectDNs[] = $certificate->getSubjectDN(X509::DN_STRING); 108 | } 109 | return $subjectDNs; 110 | } 111 | } 112 | 113 | class SubjectCertificateValidatorCollection extends Collection 114 | { 115 | public function __construct(SubjectCertificateValidator ...$validators) 116 | { 117 | $this->array = $validators; 118 | } 119 | 120 | public function validateType($value): void 121 | { 122 | if (!$value instanceof SubjectCertificateValidator) { 123 | throw new TypeError("Wrong type, expected " . SubjectCertificateValidator::class); 124 | } 125 | } 126 | } 127 | 128 | class UriCollection extends Collection 129 | { 130 | public function __construct(Uri ...$urls) 131 | { 132 | $this->array = $urls; 133 | } 134 | 135 | public function validateType($value): void 136 | { 137 | if (!$value instanceof Uri) { 138 | throw new TypeError("Wrong type, expected " . Uri::class); 139 | } 140 | } 141 | 142 | public function getUrls(): array 143 | { 144 | $result = []; 145 | foreach ($this->array as $uri) { 146 | $result[] = $uri; 147 | } 148 | return $result; 149 | } 150 | 151 | public function getUrlsArray(): array 152 | { 153 | $result = []; 154 | foreach ($this->array as $uri) { 155 | $result[] = $uri->jsonSerialize(); 156 | } 157 | return $result; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/ocsp/certificate/CertificateLoader.php: -------------------------------------------------------------------------------- 1 | loadX509($fileContent); 55 | if (!$loaded) { 56 | throw new OcspCertificateException( 57 | "Certificate decoding from Base64 or parsing failed for " . 58 | $pathToFile 59 | ); 60 | } 61 | $this->certificate = $certificate; 62 | return $this; 63 | } 64 | 65 | /** 66 | * Loads the certificate from string and returns the certificate 67 | * 68 | * @param string certString - certificate as string 69 | * @throws OcspCertificateException when the certificate decoding or parse fails 70 | */ 71 | public function fromString(string $certString) 72 | { 73 | $certificate = new X509(); 74 | $loaded = false; 75 | try { 76 | $loaded = $certificate->loadX509($certString); 77 | } catch (Exception $e) { 78 | } 79 | if (!$loaded) { 80 | throw new OcspCertificateException( 81 | "Certificate decoding from Base64 or parsing failed" 82 | ); 83 | } 84 | $this->certificate = $certificate; 85 | return $this; 86 | } 87 | 88 | public function getIssuerCertificateUrl(): string 89 | { 90 | if (!$this->certificate) { 91 | throw new OcspCertificateException("Certificate not loaded"); 92 | } 93 | 94 | $url = ""; 95 | $opts = $this->certificate->getExtension("id-pe-authorityInfoAccess"); 96 | foreach ($opts as $opt) { 97 | if ($opt["accessMethod"] == "id-ad-caIssuers") { 98 | $url = $opt["accessLocation"]["uniformResourceIdentifier"]; 99 | break; 100 | } 101 | } 102 | return $url; 103 | } 104 | 105 | public function getOcspResponderUrl(): string 106 | { 107 | if (!$this->certificate) { 108 | throw new OcspCertificateException("Certificate not loaded"); 109 | } 110 | 111 | $url = ""; 112 | $opts = $this->certificate->getExtension("id-pe-authorityInfoAccess"); 113 | foreach ($opts as $opt) { 114 | if ($opt["accessMethod"] == "id-ad-ocsp" || $opt["accessMethod"] == "id-pkix-ocsp") { 115 | $url = $opt["accessLocation"]["uniformResourceIdentifier"]; 116 | break; 117 | } 118 | } 119 | return $url; 120 | } 121 | 122 | public function getCert(): X509 123 | { 124 | if (!$this->certificate) { 125 | throw new OcspCertificateException("Certificate not loaded"); 126 | } 127 | return $this->certificate; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/ocsp/Ocsp.php: -------------------------------------------------------------------------------- 1 | [], 76 | "issuerNameHash" => "", 77 | "issuerKeyHash" => "", 78 | "serialNumber" => [], 79 | ]; 80 | 81 | if ( 82 | !isset( 83 | $certificate->getCurrentCert()["tbsCertificate"]["serialNumber"] 84 | ) 85 | ) { 86 | // Serial number of subject certificate does not exist 87 | throw new OcspCertificateException( 88 | "Serial number of subject certificate does not exist" 89 | ); 90 | } 91 | 92 | $certificateId["serialNumber"] = clone $certificate->getCurrentCert()["tbsCertificate"]["serialNumber"]; 93 | 94 | // issuer name 95 | if ( 96 | !isset( 97 | $issuerCertificate->getCurrentCert()["tbsCertificate"][ 98 | "subject" 99 | ] 100 | ) 101 | ) { 102 | // Serial number of issuer certificate does not exist 103 | throw new OcspCertificateException( 104 | "Serial number of issuer certificate does not exist" 105 | ); 106 | } 107 | 108 | $issuer = $issuerCertificate->getCurrentCert()["tbsCertificate"][ 109 | "subject" 110 | ]; 111 | $issuerEncoded = ASN1::encodeDER($issuer, Name::MAP); 112 | $certificateId["issuerNameHash"] = hash($hashAlgorithm->value, $issuerEncoded, true); 113 | 114 | // issuer public key 115 | if ( 116 | !isset( 117 | $issuerCertificate->getCurrentCert()["tbsCertificate"][ 118 | "subjectPublicKeyInfo" 119 | ]["subjectPublicKey"] 120 | ) 121 | ) { 122 | // SubjectPublicKey of issuer certificate does not exist 123 | throw new OcspCertificateException( 124 | "SubjectPublicKey of issuer certificate does not exist" 125 | ); 126 | } 127 | 128 | $publicKey = $issuerCertificate->getCurrentCert()["tbsCertificate"][ 129 | "subjectPublicKeyInfo" 130 | ]["subjectPublicKey"]; 131 | $certificateId["issuerKeyHash"] = hash( 132 | $hashAlgorithm->value, 133 | AsnUtil::extractKeyData($publicKey), 134 | true 135 | ); 136 | 137 | $certificateId["hashAlgorithm"]["algorithm"] = Asn1::getOID("id-" . $hashAlgorithm->value); 138 | 139 | return $certificateId; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/ocsp/maps/OcspRequestMap.php: -------------------------------------------------------------------------------- 1 | ASN1::TYPE_SEQUENCE, 44 | "children" => [ 45 | "tbsRequest" => [ 46 | "type" => ASN1::TYPE_SEQUENCE, 47 | "children" => [ 48 | "version" => [ 49 | "constant" => 0, 50 | "explicit" => true, 51 | "optional" => true, 52 | "mapping" => [0 => "v1"], 53 | "default" => "v1", 54 | "type" => ASN1::TYPE_INTEGER, 55 | ], 56 | "requestList" => [ 57 | "type" => ASN1::TYPE_SEQUENCE, 58 | "min" => 0, 59 | "max" => -1, 60 | "children" => [ 61 | "type" => ASN1::TYPE_SEQUENCE, 62 | "children" => [ 63 | "reqCert" => [ 64 | "type" => ASN1::TYPE_SEQUENCE, 65 | "children" => [ 66 | "hashAlgorithm" => 67 | AlgorithmIdentifier::MAP, 68 | "issuerNameHash" => [ 69 | "type" => ASN1::TYPE_OCTET_STRING, 70 | ], 71 | "issuerKeyHash" => [ 72 | "type" => ASN1::TYPE_OCTET_STRING, 73 | ], 74 | "serialNumber" => 75 | CertificateSerialNumber::MAP, 76 | ], 77 | ], 78 | "singleRequestExtensions" => 79 | [ 80 | "constant" => 0, 81 | "explicit" => true, 82 | "optional" => true, 83 | ] + Extensions::MAP, 84 | ], 85 | ], 86 | ], 87 | "requestExtensions" => 88 | [ 89 | "constant" => 2, 90 | "explicit" => true, 91 | "optional" => true, 92 | ] + Extensions::MAP, 93 | "requestorName" => 94 | [ 95 | "constant" => 1, 96 | "optional" => true, 97 | "explicit" => true, 98 | ] + GeneralName::MAP, 99 | ], 100 | ], 101 | "optionalSignature" => [ 102 | "constant" => 0, 103 | "explicit" => true, 104 | "optional" => true, 105 | "type" => ASN1::TYPE_SEQUENCE, 106 | "children" => [ 107 | "signatureAlgorithm" => AlgorithmIdentifier::MAP, 108 | "signature" => ["type" => ASN1::TYPE_BIT_STRING], 109 | "certs" => [ 110 | "constant" => 0, 111 | "explicit" => true, 112 | "optional" => true, 113 | "type" => ASN1::TYPE_SEQUENCE, 114 | "min" => 0, 115 | "max" => -1, 116 | "children" => Certificate::MAP, 117 | ], 118 | ], 119 | ], 120 | ], 121 | ]; 122 | } 123 | -------------------------------------------------------------------------------- /src/ocsp/OcspBasicResponse.php: -------------------------------------------------------------------------------- 1 | ocspBasicResponse = $ocspBasicResponse; 44 | } 45 | 46 | public function getResponses(): array 47 | { 48 | return $this->ocspBasicResponse["tbsResponseData"]["responses"]; 49 | } 50 | 51 | /** 52 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 53 | */ 54 | public function getCertificates(): array 55 | { 56 | $certificatesArr = []; 57 | if (isset($this->ocspBasicResponse["certs"])) { 58 | foreach ($this->ocspBasicResponse["certs"] as $cert) { 59 | $x509 = new X509(); 60 | /* 61 | We need to DER encode each responder certificate array as there exists some 62 | more loading in X509->loadX509 method, which is not executed when loading just basic array. 63 | For example without this the publicKey would not be in PEM format and X509->getPublicKey() 64 | will throw error. It also maps out the extensions from BIT STRING 65 | */ 66 | $x509->loadX509(ASN1::encodeDER($cert, Certificate::MAP)); 67 | $certificatesArr[] = $x509; 68 | } 69 | unset($x509); 70 | } 71 | 72 | return $certificatesArr; 73 | } 74 | 75 | public function getSignature(): string 76 | { 77 | $signature = $this->ocspBasicResponse["signature"]; 78 | return pack("c*", ...array_slice(unpack("c*", $signature), 1)); 79 | } 80 | 81 | public function getProducedAt(): DateTime 82 | { 83 | return new DateTime( 84 | $this->ocspBasicResponse["tbsResponseData"]["producedAt"] 85 | ); 86 | } 87 | 88 | public function getThisUpdate(): DateTime 89 | { 90 | return new DateTime($this->getResponses()[0]["thisUpdate"]); 91 | } 92 | 93 | public function getNextUpdate(): ?DateTime 94 | { 95 | if (isset($this->getResponses()[0]["nextUpdate"])) { 96 | return new DateTime($this->getResponses()[0]["nextUpdate"]); 97 | } 98 | return null; 99 | } 100 | 101 | /** 102 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 103 | */ 104 | public function getSignatureAlgorithm(): string 105 | { 106 | $algorithm = strtolower( 107 | $this->ocspBasicResponse["signatureAlgorithm"]["algorithm"] 108 | ); 109 | 110 | if (false !== ($pos = strpos($algorithm, "sha3-"))) { 111 | return substr($algorithm, $pos, 8); 112 | } 113 | if (false !== ($pos = strpos($algorithm, "sha"))) { 114 | return substr($algorithm, $pos, 6); 115 | } 116 | 117 | throw new OcspCertificateException( 118 | "Signature algorithm " . $algorithm . " not implemented" 119 | ); 120 | } 121 | 122 | public function getNonceExtension(): ?string 123 | { 124 | return AsnUtil::decodeNonceExtension($this->ocspBasicResponse["tbsResponseData"]["responseExtensions"]); 125 | } 126 | 127 | public function getCertID(): array 128 | { 129 | $certStatusResponse = $this->getResponses()[0]; 130 | // Translate algorithm name to OID for correct equality check 131 | $certStatusResponse["certID"]["hashAlgorithm"][ 132 | "algorithm" 133 | ] = ASN1::getOID( 134 | $certStatusResponse["certID"]["hashAlgorithm"]["algorithm"] 135 | ); 136 | return $certStatusResponse["certID"]; 137 | } 138 | 139 | public function getEncodedResponseData(): string 140 | { 141 | return ASN1::encodeDER( 142 | $this->ocspBasicResponse["tbsResponseData"], 143 | OcspBasicResponseMap::MAP["children"]["tbsResponseData"] 144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /example/src/Auth.php: -------------------------------------------------------------------------------- 1 | config = $config; 44 | } 45 | 46 | public function trustedIntermediateCACertificates(): array 47 | { 48 | $directory = __DIR__ . "/../certificates/"; 49 | $certificates = glob($directory . "*.der.crt"); 50 | return CertificateLoader::loadCertificatesFromResources(...$certificates); 51 | } 52 | 53 | public function generator(): ChallengeNonceGenerator 54 | { 55 | return (new ChallengeNonceGeneratorBuilder()) 56 | ->withNonceTtl(300) 57 | ->build(); 58 | } 59 | 60 | public function tokenValidator(): AuthTokenValidator 61 | { 62 | $logger = new Logger(); 63 | 64 | return (new AuthTokenValidatorBuilder($logger)) 65 | // Change the URL when you run the example in your own machine. 66 | ->withSiteOrigin(new Uri($this->config->get('origin_url'))) 67 | ->withTrustedCertificateAuthorities(...self::trustedIntermediateCACertificates()) 68 | ->build(); 69 | } 70 | 71 | /** 72 | * Get challenge nonce 73 | * 74 | * @return string 75 | */ 76 | public function getNonce() 77 | { 78 | try { 79 | header("Content-Type: application/json; charset=utf-8"); 80 | $challengeNonce = $this->generator()->generateAndStoreNonce(); 81 | $responseArr = ["nonce" => $challengeNonce->getBase64EncodedNonce()]; 82 | echo json_encode($responseArr); 83 | } catch (Exception $e) { 84 | header("HTTP/1.0 500 Internal Server Error"); 85 | echo "Nonce generation failed"; 86 | } 87 | } 88 | 89 | private function getPrincipalNameFromCertificate(X509 $userCertificate): string 90 | { 91 | $givenName = CertificateData::getSubjectGivenName($userCertificate); 92 | $surname = CertificateData::getSubjectSurname($userCertificate); 93 | if ($givenName && $surname) { 94 | return $givenName . " " . $surname; 95 | } else { 96 | return CertificateData::getSubjectCN($userCertificate); 97 | } 98 | } 99 | 100 | /** 101 | * Authenticate 102 | * 103 | * @return string 104 | */ 105 | public function validate() 106 | { 107 | // Header names must be treated as case-insensitive (according to RFC2616) so we convert them to lowercase 108 | $headers = array_change_key_case(getallheaders(), CASE_LOWER); 109 | 110 | if (!isset($headers["x-csrf-token"]) || ($headers["x-csrf-token"] != $_SESSION["csrf-token"])) { 111 | header("HTTP/1.0 405 Method Not Allowed"); 112 | echo "CSRF token missing, unable to process your request"; 113 | return; 114 | } 115 | 116 | $authToken = file_get_contents("php://input"); 117 | 118 | try { 119 | 120 | /* Get and remove nonce from store */ 121 | $challengeNonce = (new ChallengeNonceStore())->getAndRemove(); 122 | 123 | try { 124 | 125 | // Validate token 126 | $cert = $this->tokenValidator()->validate(new WebEidAuthToken($authToken), $challengeNonce->getBase64EncodedNonce()); 127 | 128 | session_regenerate_id(); 129 | 130 | $subjectName = $this->getPrincipalNameFromCertificate($cert); 131 | $result = [ 132 | "sub" => $subjectName 133 | ]; 134 | 135 | $_SESSION["auth-user"] = $subjectName; 136 | 137 | echo json_encode($result); 138 | } catch (Exception $e) { 139 | header("HTTP/1.0 400 Bad Request"); 140 | echo "Validation failed"; 141 | } 142 | } catch (ChallengeNonceExpiredException $e) { 143 | header("HTTP/1.0 400 Bad Request"); 144 | echo "Challenge nonce not found or expired"; 145 | } 146 | } 147 | 148 | /** 149 | * Logout 150 | * 151 | */ 152 | public function logout() 153 | { 154 | unset($_SESSION["auth-user"]); 155 | session_regenerate_id(); 156 | // Redirect to login 157 | header("Location: /"); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/ocsp/OcspResponse.php: -------------------------------------------------------------------------------- 1 | ocspResponse = ASN1::asn1map($decoded[0], OcspResponseMap::MAP, [ 46 | "response" => function ($encoded) { 47 | return ASN1::asn1map( 48 | self::getDecoded($encoded)[0], 49 | OcspBasicResponseMap::MAP 50 | ); 51 | }, 52 | ]); 53 | } 54 | 55 | public function getResponse(): array 56 | { 57 | return $this->ocspResponse; 58 | } 59 | 60 | public function getBasicResponse(): OcspBasicResponse 61 | { 62 | if ( 63 | Ocsp::ID_PKIX_OCSP_BASIC_STRING != 64 | $this->ocspResponse["responseBytes"]["responseType"] 65 | ) { 66 | throw new UnexpectedValueException( 67 | 'responseType is not "id-pkix-ocsp-basic" but is ' . 68 | $this->ocspResponse["responseBytes"]["responseType"] 69 | ); 70 | } 71 | 72 | if (!$this->ocspResponse["responseBytes"]["response"]) { 73 | throw new UnexpectedValueException( 74 | "Could not decode OcspResponse->responseBytes->response" 75 | ); 76 | } 77 | 78 | return new OcspBasicResponse( 79 | $this->ocspResponse["responseBytes"]["response"] 80 | ); 81 | } 82 | 83 | public function getStatus(): string 84 | { 85 | return $this->ocspResponse["responseStatus"]; 86 | } 87 | 88 | public function getRevokeReason(): string 89 | { 90 | return $this->revokeReason; 91 | } 92 | 93 | public function isRevoked() 94 | { 95 | $basicResponse = $this->getBasicResponse(); 96 | $this->validateResponse($basicResponse); 97 | 98 | if (isset($basicResponse->getResponses()[0]["certStatus"]["good"])) { 99 | return false; 100 | } 101 | if (isset($basicResponse->getResponses()[0]["certStatus"]["revoked"])) { 102 | $revokedStatus = $basicResponse->getResponses()[0]["certStatus"][ 103 | "revoked" 104 | ]; 105 | // Check revoke reason 106 | if (isset($revokedStatus["revokedReason"])) { 107 | $this->revokeReason = $revokedStatus["revokedReason"]; 108 | } 109 | return true; 110 | } 111 | return null; 112 | } 113 | 114 | public function validateSignature(): void 115 | { 116 | $basicResponse = $this->getBasicResponse(); 117 | $this->validateResponse($basicResponse); 118 | 119 | $responderCert = $basicResponse->getCertificates()[0]; 120 | // get public key from responder certificate in order to verify signature on response 121 | $publicKey = $responderCert 122 | ->getPublicKey() 123 | ->withHash($basicResponse->getSignatureAlgorithm()); 124 | // verify response data 125 | $encodedTbsResponseData = $basicResponse->getEncodedResponseData(); 126 | $signature = $basicResponse->getSignature(); 127 | 128 | if (!$publicKey->verify($encodedTbsResponseData, $signature)) { 129 | throw new OcspVerifyFailedException( 130 | "OCSP response signature is not valid" 131 | ); 132 | } 133 | } 134 | 135 | public function validateCertificateId(array $requestCertificateId): void 136 | { 137 | $basicResponse = $this->getBasicResponse(); 138 | if ($requestCertificateId != $basicResponse->getCertID()) { 139 | throw new OcspVerifyFailedException( 140 | "OCSP responded with certificate ID that differs from the requested ID" 141 | ); 142 | } 143 | } 144 | 145 | private function validateResponse(OcspBasicResponse $basicResponse): void 146 | { 147 | // Must be one response 148 | if (count($basicResponse->getResponses()) != 1) { 149 | throw new OcspVerifyFailedException( 150 | "OCSP response must contain one response, received " . 151 | count($basicResponse->getResponses()) . 152 | " responses instead" 153 | ); 154 | } 155 | 156 | // At least on cert must exist in responder 157 | if (count($basicResponse->getCertificates()) < 1) { 158 | throw new OcspVerifyFailedException( 159 | "OCSP response must contain the responder certificate, but none was provided" 160 | ); 161 | } 162 | } 163 | 164 | private static function getDecoded(string $encodedBER) { 165 | $decoded = ASN1::decodeBER($encodedBER); 166 | if (!is_array($decoded)) { 167 | throw new OcspResponseDecodeException(); 168 | } 169 | return $decoded; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/validator/ocsp/OcspResponseValidator.php: -------------------------------------------------------------------------------- 1 | 44 | * https://oidref.com/1.3.6.1.5.5.7.3.9. 45 | */ 46 | private const OCSP_SIGNING = "id-kp-OCSPSigning"; 47 | private const ERROR_PREFIX = "Certificate status update time check failed: "; 48 | public function __construct() 49 | { 50 | throw new BadFunctionCallException("Utility class"); 51 | } 52 | 53 | public static function validateHasSigningExtension(X509 $certificate): void 54 | { 55 | if (!$certificate->getExtension("id-ce-extKeyUsage") || !in_array(self::OCSP_SIGNING, $certificate->getExtension("id-ce-extKeyUsage"))) { 56 | throw new OCSPCertificateException("Certificate " . $certificate->getSubjectDN(X509::DN_STRING) . " does not contain the key usage extension for OCSP response signing"); 57 | } 58 | } 59 | 60 | public static function validateResponseSignature(OcspBasicResponse $basicResponse, X509 $responderCert): void 61 | { 62 | // get public key from responder certificate in order to verify signature on response 63 | $publicKey = $responderCert->getPublicKey()->withHash($basicResponse->getSignatureAlgorithm()); 64 | // verify response data 65 | $encodedTbsResponseData = $basicResponse->getEncodedResponseData(); 66 | $signature = $basicResponse->getSignature(); 67 | 68 | if (!$publicKey->verify($encodedTbsResponseData, $signature)) { 69 | throw new UserCertificateOCSPCheckFailedException("OCSP response signature is invalid"); 70 | } 71 | } 72 | 73 | public static function validateCertificateStatusUpdateTime(OcspBasicResponse $basicResponse, int $allowedOcspResponseTimeSkew, int $maxOcspResponseThisUpdateAge): void 74 | { 75 | // From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt: 76 | // 4.2.2. Notes on OCSP Responses 77 | // 4.2.2.1. Time 78 | // Responses whose nextUpdate value is earlier than 79 | // the local system time value SHOULD be considered unreliable. 80 | // Responses whose thisUpdate time is later than the local system time 81 | // SHOULD be considered unreliable. 82 | // If nextUpdate is not set, the responder is indicating that newer 83 | // revocation information is available all the time. 84 | $now = DefaultClock::getInstance()->now(); 85 | $earliestAcceptableTimeSkew = (clone $now)->sub(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M')); 86 | $latestAcceptableTimeSkew = (clone $now)->add(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M')); 87 | $minimumValidThisUpdateTime = (clone $now)->sub(new DateInterval('PT' . $maxOcspResponseThisUpdateAge . 'M')); 88 | 89 | $thisUpdate = $basicResponse->getThisUpdate(); 90 | if ($thisUpdate > $latestAcceptableTimeSkew) { 91 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX . 92 | "thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too far in the future, " . 93 | "latest allowed: '" . DateAndTime::toUtcString($latestAcceptableTimeSkew) . "'"); 94 | } 95 | 96 | if ($thisUpdate < $minimumValidThisUpdateTime) { 97 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX . 98 | "thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too old, " . 99 | "minimum time allowed: '" . DateAndTime::toUtcString($minimumValidThisUpdateTime) . "'"); 100 | } 101 | 102 | $nextUpdate = $basicResponse->getNextUpdate(); 103 | if (is_null($nextUpdate)) { 104 | return; 105 | } 106 | 107 | if ($nextUpdate < $earliestAcceptableTimeSkew) { 108 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX . 109 | "nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is in the past"); 110 | } 111 | 112 | if ($nextUpdate < $thisUpdate) { 113 | throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX . 114 | "nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is before thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "'"); 115 | } 116 | } 117 | 118 | public static function validateSubjectCertificateStatus(OcspResponse $response): void 119 | { 120 | if (is_null($response->isRevoked())) { 121 | throw new UserCertificateRevokedException("Unknown status"); 122 | } 123 | if ($response->isRevoked() === false) { 124 | return; 125 | } 126 | if ($response->isRevoked() === true) { 127 | throw ($response->getRevokeReason() == "") ? new UserCertificateRevokedException() : new UserCertificateRevokedException("Revocation reason: " . $response->getRevokeReason()); 128 | } 129 | throw new UserCertificateRevokedException("Status is neither good, revoked nor unknown"); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/util/AsnUtil.php: -------------------------------------------------------------------------------- 1 | 0x7f. 78 | // 79 | // b1 = length of contents. 80 | // b2 = length of r after being prefixed if necessary. 81 | // b3 = length of s after being prefixed if necessary. 82 | 83 | $asn1 = ''; // ASN.1 contents. 84 | $len = 0; // Length of ASN.1 contents. 85 | $c_len = intdiv(strlen($p1363), 2); // Length of each P1363 component. 86 | 87 | // Separate P1363 signature into its two equally sized components. 88 | foreach (str_split($p1363, $c_len) as $c) { 89 | // 0x02 prefix before each component. 90 | $asn1 .= "\x02"; 91 | 92 | if (unpack('C', $c)[1] > 0x7f) { 93 | // Add 0x00 because first byte of component > 0x7f. 94 | // Length of component = ($c_len + 1). 95 | $asn1 .= pack('C', $c_len + 1) . "\x00"; 96 | $len += 2 + ($c_len + 1); 97 | } else { 98 | $asn1 .= pack('C', $c_len); 99 | $len += 2 + $c_len; 100 | } 101 | 102 | // Append formatted component to ASN.1 contents. 103 | $asn1 .= $c; 104 | } 105 | 106 | // 0x30 b1, then contents. 107 | return "\x30" . pack('C', $len) . $asn1; 108 | } 109 | 110 | public static function loadOIDs(): void 111 | { 112 | ASN1::loadOIDs([ 113 | "id-pkix-ocsp-nonce" => self::ID_PKIX_OCSP_NONCE, 114 | "id-sha1" => "1.3.14.3.2.26", 115 | 'id-sha256' => '2.16.840.1.101.3.4.2.1', 116 | 'id-sha384' => '2.16.840.1.101.3.4.2.2', 117 | 'id-sha512' => '2.16.840.1.101.3.4.2.3', 118 | 'id-sha224' => '2.16.840.1.101.3.4.2.4', 119 | 'id-sha512/224' => '2.16.840.1.101.3.4.2.5', 120 | 'id-sha512/256' => '2.16.840.1.101.3.4.2.6', 121 | 'id-mgf1' => '1.2.840.113549.1.1.8', 122 | "sha256WithRSAEncryption" => "1.2.840.113549.1.1.11", 123 | "qcStatements(3)" => "1.3.6.1.5.5.7.1.3", 124 | "street" => "2.5.4.9", 125 | "id-pkix-ocsp-basic" => "1.3.6.1.5.5.7.48.1.1", 126 | "id-pkix-ocsp" => "1.3.6.1.5.5.7.48.1", 127 | "secp384r1" => "1.3.132.0.34", 128 | "id-pkix-ocsp-archive-cutoff" => "1.3.6.1.5.5.7.48.1.6", 129 | "id-pkix-ocsp-nocheck" => "1.3.6.1.5.5.7.48.1.5", 130 | ]); 131 | } 132 | 133 | public static function extractKeyData(string $publicKey): string 134 | { 135 | $extractedBER = ASN1::extractBER($publicKey); 136 | $decodedBER = ASN1::decodeBER($extractedBER); 137 | $subjectPublicKey = ASN1::asn1map( 138 | $decodedBER[0], 139 | SubjectPublicKeyInfo::MAP 140 | )["subjectPublicKey"]; 141 | // Remove first byte 142 | return pack("c*", ...array_slice(unpack("c*", $subjectPublicKey), 1)); 143 | } 144 | 145 | public static function decodeNonceExtension(array $ocspExtensions): ?string 146 | { 147 | $nonceExtension = current( 148 | array_filter( 149 | $ocspExtensions, 150 | function ($extension) { 151 | return self::ID_PKIX_OCSP_NONCE == ASN1::getOID($extension["extnId"]); 152 | } 153 | ) 154 | ); 155 | if (!$nonceExtension || !isset($nonceExtension["extnValue"])) { 156 | return null; 157 | } 158 | 159 | $nonceValue = $nonceExtension["extnValue"]; 160 | 161 | $decoded = ASN1::decodeBER($nonceValue); 162 | if (is_array($decoded)) { 163 | // The value was DER-encoded, it is required to be an octet string. 164 | $nonceString = ASN1::asn1map($decoded[0], ['type' => ASN1::TYPE_OCTET_STRING]); 165 | return is_string($nonceString) ? $nonceString : null; 166 | } 167 | 168 | // The value was not DER-encoded, return it as-is. 169 | return $nonceValue; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/validator/AuthTokenValidationConfiguration.php: -------------------------------------------------------------------------------- 1 | disallowedSubjectCertificatePolicies = [ 56 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V1, 57 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V2, 58 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY_V3, 59 | SubjectCertificatePolicies::$ESTEID_SK_2015_MOBILE_ID_POLICY 60 | ]; 61 | $this->nonceDisabledOcspUrls = new UriCollection(); 62 | } 63 | 64 | public function setSiteOrigin(Uri $siteOrigin): void 65 | { 66 | $this->siteOrigin = $siteOrigin; 67 | } 68 | 69 | public function getSiteOrigin(): ?Uri 70 | { 71 | return $this->siteOrigin; 72 | } 73 | 74 | public function &getTrustedCACertificates(): array 75 | { 76 | return $this->trustedCACertificates; 77 | } 78 | 79 | public function isUserCertificateRevocationCheckWithOcspEnabled(): bool 80 | { 81 | return $this->isUserCertificateRevocationCheckWithOcspEnabled; 82 | } 83 | 84 | public function setUserCertificateRevocationCheckWithOcspDisabled(): void 85 | { 86 | $this->isUserCertificateRevocationCheckWithOcspEnabled = false; 87 | } 88 | 89 | public function getOcspRequestTimeout(): int 90 | { 91 | return $this->ocspRequestTimeout; 92 | } 93 | 94 | public function setOcspRequestTimeout(int $ocspRequestTimeout): void 95 | { 96 | $this->ocspRequestTimeout = $ocspRequestTimeout; 97 | } 98 | 99 | public function getAllowedOcspResponseTimeSkew(): int 100 | { 101 | return $this->allowedOcspResponseTimeSkew; 102 | } 103 | 104 | public function setAllowedOcspResponseTimeSkew(int $allowedOcspResponseTimeSkew): void 105 | { 106 | $this->allowedOcspResponseTimeSkew = $allowedOcspResponseTimeSkew; 107 | } 108 | 109 | public function getMaxOcspResponseThisUpdateAge(): int 110 | { 111 | return $this->maxOcspResponseThisUpdateAge; 112 | } 113 | 114 | public function setMaxOcspResponseThisUpdateAge(int $maxOcspResponseThisUpdateAge): void 115 | { 116 | $this->maxOcspResponseThisUpdateAge = $maxOcspResponseThisUpdateAge; 117 | } 118 | 119 | public function getDesignatedOcspServiceConfiguration(): ?DesignatedOcspServiceConfiguration 120 | { 121 | return $this->designatedOcspServiceConfiguration; 122 | } 123 | 124 | public function setDesignatedOcspServiceConfiguration(DesignatedOcspServiceConfiguration $designatedOcspServiceConfiguration): void 125 | { 126 | $this->designatedOcspServiceConfiguration = $designatedOcspServiceConfiguration; 127 | } 128 | 129 | public function &getDisallowedSubjectCertificatePolicies(): array 130 | { 131 | return $this->disallowedSubjectCertificatePolicies; 132 | } 133 | 134 | public function getNonceDisabledOcspUrls(): UriCollection 135 | { 136 | return $this->nonceDisabledOcspUrls; 137 | } 138 | 139 | /** 140 | * Checks that the configuration parameters are valid. 141 | * 142 | * @throws IllegalArgumentException when any parameter is invalid 143 | */ 144 | public function validate(): void 145 | { 146 | if (is_null($this->siteOrigin)) { 147 | throw new InvalidArgumentException("Origin URI must not be null"); 148 | } 149 | 150 | self::validateIsOriginURL($this->siteOrigin); 151 | 152 | if (count($this->trustedCACertificates) == 0) { 153 | throw new InvalidArgumentException("At least one trusted certificate authority must be provided"); 154 | } 155 | 156 | DateAndTime::requirePositiveDuration($this->ocspRequestTimeout, "OCSP request timeout"); 157 | DateAndTime::requirePositiveDuration($this->allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew"); 158 | DateAndTime::requirePositiveDuration($this->maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age"); 159 | } 160 | 161 | /** 162 | * Validates that the given URI is an origin URL as defined in MDN, 163 | * in the form of {@code "://" [ ":" ]}. 164 | * 165 | * @param uri URI with origin URL 166 | * @throws IllegalArgumentException when the URI is not in the form of origin URL 167 | * @copyright 2022 Petr Muzikant pmuzikant@email.cz 168 | */ 169 | public function validateIsOriginURL(Uri $uri): void 170 | { 171 | // 1. Verify that the URI can be converted to absolute URL. 172 | if (!Uri::isAbsolute($uri)) { 173 | throw new InvalidArgumentException("Provided URI is not a valid URL"); 174 | } 175 | 176 | // 2. Verify that the URI contains only HTTPS scheme, host and optional port components. 177 | if (!Uri::isSameDocumentReference( 178 | $uri, 179 | Uri::fromParts( 180 | [ 181 | "scheme" => "https", 182 | "host" => $uri->getHost(), 183 | "port" => $uri->getPort(), 184 | ] 185 | ) 186 | )) { 187 | throw new InvalidArgumentException("Origin URI must only contain the HTTPS scheme, host and optional port component"); 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------