├── .editorconfig ├── .gitlab-ci.sh ├── .gitlab-ci.yml ├── .styleci.yml ├── .travis.sh ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── humbug.json.dist ├── phpunit.xml └── src ├── Downloader.php ├── Exceptions ├── CouldNotDownloadCertificate.php ├── Handler.php ├── InvalidUrl.php └── TrackDomainTrait.php ├── IssuerMeta.php ├── SslCertificate.php ├── SslChain.php ├── SslRevocationList.php ├── StreamConfig.php ├── Url.php └── helpers.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitlab-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xe 4 | 5 | # Update packages and install composer and PHP dependencies. 6 | apt-get update -yqq 7 | apt-get install git libcurl4-gnutls-dev libicu-dev libmcrypt-dev libvpx-dev libjpeg-dev libpng-dev libxpm-dev zlib1g-dev libfreetype6-dev libxml2-dev libexpat1-dev libbz2-dev libgmp3-dev libldap2-dev unixodbc-dev libpq-dev libsqlite3-dev libaspell-dev libsnmp-dev libpcre3-dev libtidy-dev -yqq 8 | pecl install xdebug 9 | echo "zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20151012/xdebug.so" > /usr/local/etc/php/conf.d/xdebug.ini 10 | 11 | # Set memory limit 12 | echo "memory_limit=512M" > /usr/local/etc/php/conf.d/composer-docker-memory-limit.ini 13 | 14 | # Compile PHP, include these extensions. 15 | docker-php-ext-install mbstring mcrypt pdo_mysql curl json intl gd xml zip bz2 opcache 16 | 17 | # Install Composer and project dependencies. 18 | EXPECTED_SIGNATURE=$(curl https://composer.github.io/installer.sig) 19 | php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" 20 | php -r "if (hash_file('SHA384', 'composer-setup.php') === '$EXPECTED_SIGNATURE') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" 21 | php ./composer-setup.php --install-dir=/usr/local/bin --filename=composer 22 | PATH=$PATH:/usr/local/bin/composer 23 | 24 | # Git debug 25 | echo $PATH 26 | which git 27 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - bash .gitlab-ci.sh 3 | - composer update --no-interaction --prefer-source 4 | 5 | phpunit:php7.0: 6 | image: php:7.0 7 | script: 8 | - php vendor/bin/phpunit --colors --coverage-text 9 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | linting: true 4 | -------------------------------------------------------------------------------- /.travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xe 4 | 5 | wget https://github.com/nikic/php-ast/archive/master.zip 6 | unzip master.zip 7 | cd php-ast-master/ 8 | phpize 9 | ./configure 10 | make 11 | make install && echo "extension=ast.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `ssl-certificate` will be documented in this file 4 | 5 | ## 2.5.4 - 2020-01-26 6 | - Update carbon to `2.x` to modernize. 7 | 8 | 9 | ## 2.5.3 - 2020-01-13 10 | - Adjust tests for changing external variables (DNS and SSL changes) 11 | - Fix CLR checking bug for updated CLR/OCSP practices. 12 | 13 | ## 2.5.2 - 2017-07-14 14 | - Update composer dependency packages. 15 | - Update Url.php class for new League UriParser. 16 | 17 | ## 2.5.1 - 2017-07-14 18 | - Fix issue when only provided a TLD; package will now throw an exception. 19 | 20 | ## 2.5.0 - 2017-05-20 21 | - Delay CRL check from being early loaded when constructing class. 22 | - Added a new withSslCrlCheck method to pull crl status. 23 | - Update to PHPUnit 6.1.0 and fix test to reflect changes. 24 | 25 | ## 2.4.2 - 2016-03-06 26 | - Fix bug in isSelfSigned method 27 | 28 | ## 2.4.1 - 2016-03-06 29 | - Add new isValidDate method to compare a date to the validity window 30 | - Use new method to fix a bug reported in the isValidUntil method 31 | 32 | ## 2.4.0 - 2016-03-02 33 | - Expand Unit tests. 34 | - Remove etsy/phan for the time being; it's causing issues with automated tests. 35 | - Small refactor of certain helper functions. 36 | - Start fix for bug in SSL verifier application 37 | - Begin improving domain related methods 38 | - Add isSelfSigned method - returns a bool, or null when checks are unsure 39 | 40 | ## 2.3.6 - 2016-01-30 41 | - Fix bug discovered with verification logic. 42 | 43 | ## 2.3.1 - 2.3.5 - 2016-01-30 44 | - Track URL/Hostname in exceptions. 45 | - Technically this changes the API, but in a BC way; this is really being done 46 | to fix a bug in an application so I'm only doing a minor bump. 47 | - Use a class trait to add these domain tracking methods 48 | - This method should have been public 49 | - Fail whale all day! 50 | 51 | ## 2.3.0 - 2016-01-06 52 | 53 | - Fixes issues caused by CloudFlare domains/SSLs 54 | - Misc updates for tests and travis-ci 55 | - Update response from Downloader to include the input domain 56 | - Update SslCertificate getDomain method to compare input domian with main SSL domain 57 | - Add new getCertificateDomain method to SslCertificate; this is literally the original getDomain method 58 | - Add new method to URL to getInputUrl 59 | - Update CHANGELOG.md file 60 | - Update README.md file 61 | 62 | ## 2.2.2 - 2016-12-24 63 | 64 | - Remove unneeded return in downloadCertificateFromUrl 65 | - Update code for styleci 66 | 67 | ## 2.2.1 - 2016-12-24 68 | 69 | - Refactor prepareCertificateResponse 70 | - Move Exception logic to handler class 71 | 72 | ## 2.1.1 - 2016-12-22 73 | 74 | - Fix for wildcard SANs 75 | 76 | ## 2.1.0 - 2016-12-22 77 | 78 | - Add support for travis-ci 79 | - Add support for scrutinizer-ci 80 | - Add support for styleci 81 | - Move helper functions from Url class to helper file 82 | - Remove comments from 1.1.1 since they make Scrutinizer sad 83 | - Fix slightly flawed logic in SslCertificate isValid function (this brings the minor bump) 84 | - Clean up multiple functions that had unnecessary arguments 85 | - Run styleci and merge in changes 86 | - Add badges to README file! 87 | - Update CHANGELOG.md file 88 | - Update README.md file 89 | 90 | ## 2.0.0 & 2.0.1 - 2016-12-12 91 | 92 | - Update CHANGELOG.md file 93 | - Add etsy/phan support 94 | - Add PHPStan as require-dev 95 | - Better PHPDocs and Coding standards all over 96 | - Updated tests to match code changes 97 | - Refactor SslCertificate revoked status related code 98 | - Made isClrRevoked private, was previously public 99 | - Added new public function isRevoked 100 | 101 | ## 1.1.3 - 2016-12-12 102 | 103 | - Damage control version; set to same point as 1.1.1, this prevents issues caused by derp. 104 | 105 | ## 1.1.2 - 2016-12-12 106 | 107 | - Accidental release; meant to be a Major version bump as some changes are not backwards-compatible. 108 | 109 | ## 1.1.1 - Never Released 110 | 111 | - Update README.md file 112 | - Improve logic, reduce assumptions 113 | - Add more return types; also add commented ones for PHP 7.1 114 | 115 | ## 1.1.0 - Never Released 116 | 117 | - Refactor Url class; use league parser 118 | - Does contain non-breaking API changes 119 | - verifyDNS => verifyAndGetDNS 120 | - Added: getValidatedURL 121 | - Basic code style fixes 122 | 123 | ## 1.0.3 - Never Released 124 | 125 | - Update composer test command shortcut 126 | - Adjust some logic for SSL chains 127 | - Update helper functions 128 | - Update tests for Url and Downloader 129 | 130 | ## 1.0.2 - Never Released 131 | 132 | - Update PHPunit test configs 133 | - Add humbug support 134 | - Update Downloader tests 135 | - Fix automated tests 136 | 137 | ## 1.0.1 - 2016-09-19 138 | 139 | - Pushed to composer, so update the README.md 140 | 141 | ## 1.0.0 - 2016-09-19 142 | 143 | - package forked 144 | - reset semver 145 | - removed previous package tags 146 | - added `SslChain.php` 147 | - added `SslRevocationList.php` 148 | - added `StreamConfig.php` 149 | - added `IssuerMeta.php` 150 | - added the following functions: 151 | - InvalidUrl `couldNotResolveDns` 152 | - CouldNotDownloadCertificate `failedHandshake` 153 | - CouldNotDownloadCertificate `connectionTimeout` 154 | - Downloader `prepareCertificateResponse` - private 155 | - Downloader `downloadRevocationListFromUrl` 156 | - Url `getValidatedURL` 157 | - Url `getPort` 158 | - Url `getTestURL` 159 | - Url `getIp` 160 | - SslCertificate `extractCrlLinks` - private 161 | - SslCertificate `setcrlLinks` - private 162 | - SslCertificate `getRevokedDate` - private 163 | - SslCertificate `parseCertChains` - private 164 | - SslCertificate `hasSslChain` 165 | - SslCertificate `getTestedDomain` 166 | - SslCertificate `getCertificateChains` 167 | - SslCertificate `getSerialNumber` 168 | - SslCertificate `hasCrlLink` 169 | - SslCertificate `getCrlLinks` 170 | - SslCertificate `getCrl` 171 | - SslCertificate `isClrRevoked` 172 | - SslCertificate `getResolvedIp` 173 | - SslCertificate `getConnectionMeta` 174 | - SslCertificate `isTrusted` 175 | - added `SslChainTest.php` 176 | - added various stubs for Testing 177 | - updated all tests to maintain high coverage levels 178 | 179 | # Original package history 180 | 181 | ## 1.2.0 - 2016-08-20 182 | 183 | - added `getSignatureAlgorithm` 184 | 185 | ## 1.1.0 - 2016-07-29 186 | 187 | - added `isValidUntil` 188 | 189 | ## 1.0.0 - 2016-07-28 190 | 191 | - initial release 192 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Original work Copyright (c) 2016 Spatie bvba 4 | Modified work Copyright (c) 2016 Liquid Web 5 | 6 | > Permission is hereby granted, free of charge, to any person obtaining a copy 7 | > of this software and associated documentation files (the "Software"), to deal 8 | > in the Software without restriction, including without limitation the rights 9 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | > copies of the Software, and to permit persons to whom the Software is 11 | > furnished to do so, subject to the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be included in 14 | > all copies or substantial portions of the Software. 15 | > 16 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | > THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A php package to validate SSL certificates 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/liquidweb/ssl-certificate.svg?style=flat-square)](https://packagist.org/packages/liquidweb/ssl-certificate) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 5 | [![Build Status](https://travis-ci.org/liquidweb/ssl-certificate.svg?branch=master)](https://travis-ci.org/liquidweb/ssl-certificate) 6 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/liquidweb/ssl-certificate/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/liquidweb/ssl-certificate/?branch=master) 7 | [![Scrutinizer Code Coverage](https://scrutinizer-ci.com/g/liquidweb/ssl-certificate/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/liquidweb/ssl-certificate/?branch=master) 8 | [![StyleCI](https://styleci.io/repos/68636263/shield?branch=master)](https://styleci.io/repos/68636263) 9 | [![Total Downloads](https://img.shields.io/packagist/dt/liquidweb/ssl-certificate.svg?style=flat-square)](https://packagist.org/packages/liquidweb/ssl-certificate) 10 | 11 | This package was inspired by, and forked from, the original [spatie/ssl-certificate](https://github.com/spatie/ssl-certificate) SSL certificate data validation and query class. Where this package differs is the scope of validation and intended goals. This package takes the SSL certificate validation a few steps further than the original class that inspired it. 12 | 13 | This variant is able to detect if an ssl is: 14 | * trusted in a browser, 15 | * a match for the domain tested, 16 | * valid in a general sense, 17 | * providing additional chains, 18 | * and even if the SSL is CRL revoked. 19 | 20 | Additionally, this package tracks and provides methods to view SSL Chain information. 21 | 22 | Here are a few examples: 23 | 24 | ```php 25 | $certificate = SslCertificate::createForHostName('liquidweb.com'); // Basic SSL test 26 | $certificateWithCrl = SslCertificate::createForHostName('liquidweb.com')->withSslCrlCheck(); // SSL test with CRL checks 27 | 28 | $certificate->getIssuer(); // returns "GlobalSign Extended Validation CA - SHA256 - G2" 29 | $certificate->isValid(); // returns true if the certificate is currently valid 30 | $certificate->isTrusted(); // returns true if the certificate is trusted by default 31 | $certificate->hasSslChain(); // returns bool of ssl chain status 32 | $certificate->expirationDate(); // returns an instance of Carbon 33 | $certificate->expirationDate()->diffInDays(); // returns an int 34 | $certificate->getSignatureAlgorithm(); // returns a string 35 | $certificate->getSerialNumber(); // returns a string 36 | 37 | // methods that require CRL test 38 | $certificateWithCrl->isRevoked(); // returns bool of revoked status, or null if no list provided 39 | $certificateWithCrl->getCrlRevokedTime(); // returns a Carbon instance of the CRL revocation time 40 | $certificateWithCrl->isValid(); // results may vary from `certificate` if the SSL is CRL revoked 41 | $certificateWithCrl->isTrusted(); // results may vary from `certificate` if the SSL is CRL revoked 42 | ``` 43 | 44 | ## Installation 45 | 46 | You can install the package via composer: 47 | 48 | ```bash 49 | composer require liquidweb/ssl-certificate 50 | ``` 51 | 52 | While not required it is highly suggested to install the PHP gmp extension as this will help speed up the CRL verification methods. This is due to the usage of phpseclib to work with decoding and comparing of ASN1 serials in the CRL lists. 53 | 54 | ## Usage 55 | 56 | You can create an instance of `LiquidWeb\SslCertificate\SslCertificate` with this named constructor: 57 | 58 | ```php 59 | $certificate = SslCertificate::createForHostName('liquidweb.com'); 60 | ``` 61 | 62 | If the given `hostName` is invalid `LiquidWeb\SslCertificate\InvalidUrl` will be thrown. 63 | 64 | If the given `hostName` is valid but there was a problem downloading the certifcate `LiquidWeb\SslCertificate\CouldNotDownloadCertificate` will be thrown. 65 | 66 | ### Getting the issuer name 67 | 68 | ```php 69 | $certificate->getIssuer(); // returns "GlobalSign Extended Validation CA - SHA256 - G2" 70 | ``` 71 | 72 | ### Getting the domain name 73 | 74 | Getting a domain can be done one of two ways; you can either use `getDomain` or `getCertificateDomain`. 75 | They are very similar but work slightly different in subtle ways. 76 | 77 | ```php 78 | $certificate->getDomain(); // returns "www.liquidweb.com" 79 | ``` 80 | 81 | Returns the user input domain, or the primary domain name for the certificate. This dynamic style of results helps to resolve issues with CloudFlare SSLs. 82 | If the certificate's primary domain is not at all similar to the input domain then this method returns the input domain. 83 | 84 | ```php 85 | $certificate->getCertificateDomain(); // returns "www.liquidweb.com" 86 | ``` 87 | 88 | Returns the primary domain name for the certificate; this will consistently and ONLY return the SSLs subject CN. 89 | 90 | ### Getting the certificate's signing algorithm 91 | 92 | Returns the algorithm used for signing the certificate 93 | 94 | ```php 95 | $certificate->getSignatureAlgorithm(); // returns "RSA-SHA256" 96 | ``` 97 | 98 | ### Getting the additional domain names 99 | 100 | A certificate can cover multiple (sub)domains. Here's how to get them. 101 | 102 | ```php 103 | $certificate->getAdditionalDomains(); // returns [ 104 | "www.liquidweb.com", 105 | "www.stormondemand.com", 106 | "www.sonet7.com", 107 | "manage.stormondemand.com", 108 | "manage.liquidweb.com", 109 | "liquidweb.com" 110 | ] 111 | ``` 112 | 113 | A domain name return with this method can start with `*` meaning it is valid for all subdomains of that domain. 114 | 115 | ### Getting the date when the certificate becomes valid 116 | 117 | ```php 118 | $certificate->validFromDate(); // returns an instance of Carbon 119 | ``` 120 | 121 | ### Getting the expiration date 122 | 123 | ```php 124 | $certificate->expirationDate(); // returns an instance of Carbon 125 | ``` 126 | 127 | ### Determining if the certificate is still valid 128 | 129 | Returns true if the SSL is valid for the domain, trusted by default, and is not currently expired. 130 | 131 | An SSL is valid for the domain provided if the domain is the main subject, or a SAN. 132 | Trust status is determined based on how the SSL was downloaded; if it requires no cURL ssl verificaiton then it's untrused. 133 | Expiration status is found valid if current Date and time is between `validFromDate` and `expirationDate`. 134 | 135 | ```php 136 | $certificate->isValid(); // returns a boolean 137 | ``` 138 | 139 | You also use this method to determine if a given domain is covered by the certificate. Of course it'll keep checking if the current Date and time is between `validFromDate` and `expirationDate`. 140 | 141 | ```php 142 | $certificate->isValid('liquidweb.com'); // returns true; 143 | $certificate->isValid('spatie.be'); // returns false; 144 | ``` 145 | 146 | ### Determining if the certificate is still valid until a given date 147 | 148 | Returns true if a given date is within the certificate's expiration window. The SSL may still be invlaid for other reasons, this simply checks the date agains the `validFromDate` and `expirationDate` dates of the SSL. 149 | 150 | ```php 151 | $certificate->isValidDate(Carbon::create('2017', '03', '30', '12', '00', '00', 'utc')); // returns a boolean 152 | ``` 153 | 154 | ### Determining if the certificate is still valid until a given date and ensure it's a valid SSL 155 | 156 | Returns true if the certificate is valid and if the `expirationDate` is before the given date. 157 | 158 | ```php 159 | $certificate->isValidUntil(Carbon::now()->addDays(7)); // returns a boolean 160 | ``` 161 | 162 | ### Determining if the certificate is expired 163 | 164 | ```php 165 | $certificate->isExpired(); // returns a boolean if expired 166 | ``` 167 | 168 | ### Determining if a certificate has been revoked 169 | 170 | ```php 171 | $certificate = SslCertificate::createForHostName('liquidweb.com')->withSslCrlCheck(); 172 | $certificate->isRevoked(); 173 | ``` 174 | 175 | ## Changelog 176 | 177 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 178 | 179 | ## Testing 180 | 181 | ``` bash 182 | $ composer test 183 | ``` 184 | 185 | Note: When working to test your implementation of this library you can use [BadSSL](https://badssl.com/) to simulate various SSL scenarios. 186 | 187 | ## Credits 188 | 189 | - [Dan Pock](https://github.com/mallardduck) - Fork Creator & Maintainer 190 | - [Freek Van der Herten](https://github.com/freekmurze) - Original package creator 191 | - [All Contributors](../../contributors) 192 | 193 | The helper functions and tests were copied from the [Laravel Framework](https://github.com/laravel/framework). 194 | 195 | ## License 196 | 197 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 198 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liquidweb/ssl-certificate", 3 | "description": "A class to easily query the properties of and validate the status of an ssl certificate ", 4 | "keywords": [ 5 | "ssl", 6 | "ssl-certificate", 7 | "security" 8 | ], 9 | "homepage": "https://github.com/liquidweb/ssl-certificate", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Dan Pock", 14 | "email": "dpock@liquidweb.com", 15 | "homepage": "https://liquidweb.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "suggest" : { 20 | "ext-gmp": "This helps to speed up the phpseclib functions, highly suggested while not required." 21 | }, 22 | "require": { 23 | "php": "^7.3|^8.0", 24 | "ext-mbstring": "*", 25 | "ext-filter": "*", 26 | "ext-openssl": "*", 27 | "league/uri": "^5.3.0", 28 | "nesbot/carbon": "^1.39.1|^2.0", 29 | "phpseclib/phpseclib": "^2.0.6" 30 | }, 31 | "require-dev": { 32 | "roave/security-advisories": "dev-master", 33 | "composer/package-versions-deprecated": "1.11.99.1", 34 | "brianium/paratest": "^6.2", 35 | "phpstan/phpstan": "^0.12.77", 36 | "phpunit/phpunit": "^9.5.2" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "LiquidWeb\\SslCertificate\\": "src" 41 | }, 42 | "files": [ 43 | "src/helpers.php" 44 | ] 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "LiquidWeb\\SslCertificate\\Test\\": "tests" 49 | } 50 | }, 51 | "scripts": { 52 | "test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --colors --coverage-text" 53 | }, 54 | "config": { 55 | "sort-packages": true 56 | }, 57 | "prefer-stable": true 58 | } 59 | -------------------------------------------------------------------------------- /humbug.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "timeout": 15, 8 | "logs": { 9 | "text": "build/humbuglog.txt" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests/ 17 | 18 | 19 | 20 | 21 | 22 | src/ 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Downloader.php: -------------------------------------------------------------------------------- 1 | getTestURL()}", 22 | $errorNumber, 23 | $errorDescription, 24 | $timeout, 25 | STREAM_CLIENT_CONNECT, 26 | $sslConfig->getContext() 27 | ); 28 | unset($sslConfig); 29 | } catch (Throwable $thrown) { 30 | // Try again in insecure mode 31 | $sslConfig = StreamConfig::configInsecure(); 32 | $trusted = false; 33 | 34 | try { 35 | // As the URL failed verification we set to false 36 | $client = stream_socket_client( 37 | 'ssl://'.$parsedUrl->getTestURL(), 38 | $errorNumber, 39 | $errorDescription, 40 | $timeout, 41 | STREAM_CLIENT_CONNECT, 42 | $sslConfig->getContext() 43 | ); 44 | unset($sslConfig); 45 | } catch (Throwable $thrown) { 46 | (new Handler($thrown))->downloadHandler($parsedUrl); 47 | } 48 | } 49 | 50 | return self::prepareCertificateResponse($client, $trusted, $parsedUrl); 51 | } 52 | 53 | private static function prepareCertificateResponse($resultClient, bool $trusted, Url $parsedUrl): array 54 | { 55 | $response = stream_context_get_options($resultClient); 56 | $connectionInfo = stream_get_meta_data($resultClient)['crypto']; 57 | unset($resultClient); 58 | $mainCert = openssl_x509_parse($response['ssl']['peer_certificate'], true); 59 | 60 | $full_chain = []; 61 | if (count($response['ssl']['peer_certificate_chain']) > 1) { 62 | foreach ($response['ssl']['peer_certificate_chain'] as $cert) { 63 | $parsedCert = openssl_x509_parse($cert, true); 64 | $isChain = ! ($parsedCert['hash'] === $mainCert['hash']); 65 | if ($isChain === true) { 66 | array_push($full_chain, $parsedCert); 67 | } 68 | } 69 | } 70 | 71 | return [ 72 | 'inputDomain' => $parsedUrl->getInputUrl(), 73 | 'tested' => $parsedUrl->getTestURL(), 74 | 'trusted' => $trusted, 75 | 'dns-resolves-to' => $parsedUrl->getIp(), 76 | 'cert' => $mainCert, 77 | 'full_chain' => $full_chain, 78 | 'connection' => $connectionInfo, 79 | ]; 80 | } 81 | 82 | public static function downloadRevocationListFromUrl(string $url): array 83 | { 84 | $parsedUrl = new Url($url); 85 | $csrConfig = StreamConfig::configCrl(); 86 | $file = file_get_contents($parsedUrl->getValidatedURL(), false, $csrConfig->getContext()); 87 | unset($csrConfig, $parsedUrl); 88 | $x509 = new X509(); 89 | $crl = $x509->loadCRL($file); 90 | unset($x509, $file); 91 | 92 | return $crl; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Exceptions/CouldNotDownloadCertificate.php: -------------------------------------------------------------------------------- 1 | setErrorDomain($hostName); 16 | 17 | return $exception; 18 | } 19 | 20 | public static function noCertificateInstalled(string $hostName): self 21 | { 22 | $exception = new static("Could not find a certificate on host named `{$hostName}`."); 23 | $exception->setErrorDomain($hostName); 24 | 25 | return $exception; 26 | } 27 | 28 | public static function failedHandshake(Url $url): self 29 | { 30 | if ($url->getPort() === '80') { 31 | return new static('Server does not support SSL over port 80.'); 32 | } 33 | $exception = new static("Server SSL handshake error – the certificate for `{$url->getTestURL()}` will not work."); 34 | $exception->setErrorDomain($url->getHostName()); 35 | 36 | return $exception; 37 | } 38 | 39 | public static function connectionTimeout(string $hostName): self 40 | { 41 | $exception = new static("Connection timed out while testing `{$hostName}`."); 42 | $exception->setErrorDomain($hostName); 43 | 44 | return $exception; 45 | } 46 | 47 | public static function unknownError(string $hostName, string $errorMessage): self 48 | { 49 | $exception = new static("Could not download certificate for host `{$hostName}` because {$errorMessage}"); 50 | $exception->setErrorDomain($hostName); 51 | 52 | return $exception; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Exceptions/Handler.php: -------------------------------------------------------------------------------- 1 | thrown = $thrown; 16 | } 17 | 18 | public function downloadHandler(Url $parsedUrl) 19 | { 20 | $errorMsg = $this->thrown->getMessage(); 21 | if (str_contains($errorMsg, 'getaddrinfo failed') === true) { 22 | throw CouldNotDownloadCertificate::hostDoesNotExist($parsedUrl->getHostName()); 23 | } 24 | 25 | if (str_contains($errorMsg, 'error:14090086') === true) { 26 | throw CouldNotDownloadCertificate::noCertificateInstalled($parsedUrl->getHostName()); 27 | } 28 | 29 | if (str_contains($errorMsg, 'error:14077410') === true || str_contains($errorMsg, 'error:140770FC') === true || str_contains($errorMsg, 'error:14094410:SSL')) { 30 | throw CouldNotDownloadCertificate::failedHandshake($parsedUrl); 31 | } 32 | 33 | if (str_contains($errorMsg, '(Connection timed out)') === true) { 34 | throw CouldNotDownloadCertificate::connectionTimeout($parsedUrl->getTestURL()); 35 | } 36 | 37 | throw CouldNotDownloadCertificate::unknownError($parsedUrl->getTestURL(), $errorMsg); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidUrl.php: -------------------------------------------------------------------------------- 1 | setErrorDomain($hostName); 29 | 30 | return $exception; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/TrackDomainTrait.php: -------------------------------------------------------------------------------- 1 | errorDomain = $domain; 12 | } 13 | 14 | public function getErrorDomain() 15 | { 16 | return $this->errorDomain; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/IssuerMeta.php: -------------------------------------------------------------------------------- 1 | commonName = isset($input['commonName']) ? $input['commonName'] : ''; 29 | $this->countryName = ($input['countryName']) ? $input['countryName'] : ''; 30 | $this->organizationName = isset($input['organizationName']) ? $input['organizationName'] : ''; 31 | $this->organizationUnitName = isset($input['organizationalUnitName']) ? $input['organizationalUnitName'] : ''; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/SslCertificate.php: -------------------------------------------------------------------------------- 1 | getCrlLinks(); 89 | if (is_null($links) === true || empty($links) === true) { 90 | return $this; 91 | } 92 | $this->crl = SslRevocationList::createFromUrl($links[0]); 93 | 94 | foreach ($this->crl->getRevokedList() as $revoked) { 95 | if ($this->serial->equals($revoked['userCertificate'])) { 96 | $this->trusted = false; 97 | $this->revoked = true; 98 | $this->revokedTime = new Carbon($revoked['revocationDate']['utcTime']); 99 | 100 | return $this; 101 | } 102 | } 103 | $this->revoked = false; 104 | 105 | return $this; 106 | } 107 | 108 | public function __construct(array $downloadResults) 109 | { 110 | $this->inputDomain = $downloadResults['inputDomain']; 111 | $this->testedDomain = $downloadResults['tested']; 112 | $this->trusted = $downloadResults['trusted']; 113 | $this->ip = $downloadResults['dns-resolves-to']; 114 | $this->certificateFields = $downloadResults['cert']; 115 | $this->certificateChains = self::parseCertChains($downloadResults['full_chain']); 116 | $this->connectionMeta = $downloadResults['connection']; 117 | $this->serial = new BigInteger($downloadResults['cert']['serialNumber']); 118 | 119 | if (isset($downloadResults['cert']['extensions']['crlDistributionPoints'])) { 120 | $this->crlLinks = self::parseCrlLinks($downloadResults['cert']['extensions']['crlDistributionPoints']); 121 | } 122 | } 123 | 124 | public function hasSslChain(): bool 125 | { 126 | if (isset($this->certificateChains) && count($this->certificateChains) >= 1) { 127 | return true; 128 | } 129 | 130 | return false; 131 | } 132 | 133 | public function getCertificateFields(): array 134 | { 135 | return $this->certificateFields; 136 | } 137 | 138 | public function getCertificateChains(): array 139 | { 140 | return $this->certificateChains; 141 | } 142 | 143 | public function getSerialNumber(): string 144 | { 145 | return strtoupper($this->serial->toHex()); 146 | } 147 | 148 | public function hasCrlLink(): bool 149 | { 150 | return isset($this->certificateFields['extensions']['crlDistributionPoints']); 151 | } 152 | 153 | public function getCrlLinks() 154 | { 155 | if (! $this->hasCrlLink()) { 156 | return; 157 | } 158 | 159 | return $this->crlLinks; 160 | } 161 | 162 | public function getCrl() 163 | { 164 | if (! $this->hasCrlLink()) { 165 | return; 166 | } 167 | 168 | return $this->crl; 169 | } 170 | 171 | public function isRevoked() 172 | { 173 | return $this->revoked; 174 | } 175 | 176 | public function getCrlRevokedTime() 177 | { 178 | if ($this->isRevoked()) { 179 | return $this->revokedTime; 180 | } 181 | } 182 | 183 | public function getResolvedIp(): string 184 | { 185 | return $this->ip; 186 | } 187 | 188 | public function getIssuer(): string 189 | { 190 | return $this->certificateFields['issuer']['CN']; 191 | } 192 | 193 | public function getDomain(): string 194 | { 195 | $certDomain = $this->getCertificateDomain(); 196 | if (str_contains($certDomain, $this->inputDomain) === false) { 197 | return $this->inputDomain; 198 | } 199 | 200 | return $certDomain ?? ''; 201 | } 202 | 203 | public function getTestedDomain(): string 204 | { 205 | return $this->testedDomain; 206 | } 207 | 208 | public function getInputDomain(): string 209 | { 210 | return $this->inputDomain; 211 | } 212 | 213 | public function getCertificateDomain(): string 214 | { 215 | return $this->certificateFields['subject']['CN']; 216 | } 217 | 218 | public function getAdditionalDomains(): array 219 | { 220 | $additionalDomains = explode(', ', $this->certificateFields['extensions']['subjectAltName'] ?? ''); 221 | 222 | return array_map(function (string $domain) { 223 | return str_replace('DNS:', '', $domain); 224 | }, $additionalDomains); 225 | } 226 | 227 | public function getSignatureAlgorithm(): string 228 | { 229 | return $this->certificateFields['signatureTypeSN'] ?? ''; 230 | } 231 | 232 | public function getConnectionMeta(): array 233 | { 234 | return $this->connectionMeta; 235 | } 236 | 237 | public function validFromDate(): Carbon 238 | { 239 | return Carbon::createFromTimestampUTC($this->certificateFields['validFrom_time_t']); 240 | } 241 | 242 | public function expirationDate(): Carbon 243 | { 244 | return Carbon::createFromTimestampUTC($this->certificateFields['validTo_time_t']); 245 | } 246 | 247 | public function isExpired(): bool 248 | { 249 | return $this->expirationDate()->isPast(); 250 | } 251 | 252 | public function isTrusted(): bool 253 | { 254 | return $this->trusted; 255 | } 256 | 257 | public function isValid(string $url = null): bool 258 | { 259 | // Verify SSL not expired 260 | if (! Carbon::now()->between($this->validFromDate(), $this->expirationDate())) { 261 | return false; 262 | } 263 | // Verify the SSL applies to the domain; use $url if provided, other wise use input 264 | if ($this->appliesToUrl($url ?? $this->inputDomain) === false) { 265 | return false; 266 | } 267 | // Check SerialNumber for CRL list 268 | if ($this->isRevoked()) { 269 | return false; 270 | } 271 | 272 | return true; 273 | } 274 | 275 | public function isValidUntil(Carbon $carbon, string $url = null): bool 276 | { 277 | if ($this->isValidDate($carbon) === false) { 278 | return false; 279 | } 280 | 281 | return $this->isValid($url); 282 | } 283 | 284 | public function isValidDate(Carbon $carbon): bool 285 | { 286 | if ($carbon->between($this->validFromDate(), $this->expirationDate()) === false) { 287 | return false; 288 | } 289 | 290 | return true; 291 | } 292 | 293 | public function isSelfSigned(): bool 294 | { 295 | // Get the issuer data 296 | $url = $this->getIssuer(); 297 | // make sure we don't include wildcard if it's there... 298 | if (starts_with($url, '*.') === true) { 299 | $url = substr($url, 2); 300 | } 301 | //Try to parse the string 302 | try { 303 | $issuerUrl = new Url($url); 304 | } catch (\Exception $e) { 305 | // if we hit this exception then the string is not likely a URL 306 | // If it's not a URL and is valid we can assume it's not self signed 307 | return false; 308 | } 309 | // If it is a domain, run appliesToUrl 310 | if ($this->appliesToUrl((string) $issuerUrl) === true) { 311 | return true; 312 | } 313 | 314 | return false; 315 | } 316 | 317 | public function appliesToUrl(string $url): bool 318 | { 319 | if (starts_with($url, '*.') === true) { 320 | $url = substr($url, 2); 321 | } 322 | $host = (new Url($url))->getHostName() ?: $url; 323 | 324 | $certificateHosts = array_merge([$this->getCertificateDomain()], $this->getAdditionalDomains()); 325 | 326 | foreach ($certificateHosts as $certificateHost) { 327 | if (strtolower($host) === strtolower($certificateHost)) { 328 | return true; 329 | } 330 | 331 | if ($this->wildcardHostCoversHost($certificateHost, $host)) { 332 | return true; 333 | } 334 | } 335 | 336 | return false; 337 | } 338 | 339 | protected function wildcardHostCoversHost(string $wildcardHost, string $host): bool 340 | { 341 | if ($host === $wildcardHost) { 342 | return true; 343 | } 344 | 345 | if (! starts_with($wildcardHost, '*')) { 346 | return false; 347 | } 348 | 349 | $wildcardHostWithoutWildcard = substr($wildcardHost, 2); 350 | 351 | return substr_count($wildcardHost, '.') >= substr_count($host, '.') && ends_with($host, $wildcardHostWithoutWildcard); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/SslChain.php: -------------------------------------------------------------------------------- 1 | name = $chainInput['name']; 50 | $this->subject = $chainInput['subject']; 51 | $this->hash = $chainInput['hash']; 52 | $this->issuer = $chainInput['issuer']; 53 | $this->version = $chainInput['version']; 54 | $this->serial = new BigInteger($chainInput['serialNumber']); 55 | $this->validFrom = self::setValidFromDate($chainInput['validFrom_time_t']); 56 | $this->validTo = self::setValidToDate($chainInput['validTo_time_t']); 57 | $this->signatureType = $chainInput['signatureTypeSN']; 58 | } 59 | 60 | public function getLocationName(): string 61 | { 62 | return $this->subject['C'] ?? ''; 63 | } 64 | 65 | public function getOrganizationName(): string 66 | { 67 | return $this->subject['O'] ?? ''; 68 | } 69 | 70 | public function getOrganizationUnitName(): string 71 | { 72 | return $this->subject['OU'] ?? ''; 73 | } 74 | 75 | public function getCommonName(): string 76 | { 77 | return $this->subject['CN'] ?? ''; 78 | } 79 | 80 | public function getHash(): string 81 | { 82 | return $this->hash; 83 | } 84 | 85 | public function getIssuerLocationName(): string 86 | { 87 | return $this->issuer['C'] ?? ''; 88 | } 89 | 90 | public function getIssuerOrganizationName(): string 91 | { 92 | return $this->issuer['O'] ?? ''; 93 | } 94 | 95 | public function getIssuerOrganizationUnitName(): string 96 | { 97 | if (isset($this->issuer['OU']) === true) { 98 | if (is_array($this->issuer['OU']) === true) { 99 | return $this->issuer['OU'][0] ?? ''; 100 | } 101 | } 102 | 103 | return $this->issuer['OU'] ?? ''; 104 | } 105 | 106 | public function getIssuerCommonName(): string 107 | { 108 | return $this->issuer['CN'] ?? ''; 109 | } 110 | 111 | public function getSerialNumber(): string 112 | { 113 | return strtoupper($this->serial->toHex()); 114 | } 115 | 116 | public function validFromDate(): Carbon 117 | { 118 | return $this->validFrom; 119 | } 120 | 121 | public function expirationDate(): Carbon 122 | { 123 | return $this->validTo; 124 | } 125 | 126 | public function getSignatureAlgorithm(): string 127 | { 128 | return $this->signatureType; 129 | } 130 | 131 | public function isExpired(): bool 132 | { 133 | return $this->expirationDate()->isPast(); 134 | } 135 | 136 | public function isValid() 137 | { 138 | if (Carbon::now()->between($this->validFromDate(), $this->expirationDate()) === false) { 139 | return false; 140 | } 141 | 142 | return true; 143 | } 144 | 145 | public function isValidUntil(Carbon $carbon): bool 146 | { 147 | if ($this->expirationDate()->gt($carbon) === true) { 148 | return false; 149 | } 150 | 151 | return $this->isValid(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/SslRevocationList.php: -------------------------------------------------------------------------------- 1 | timestamp = Carbon::now(); 50 | $this->issuer = $issuer; 51 | $this->createdAt = Carbon::parse($createdAt)->setTimezone('UTC'); 52 | $this->expiration = Carbon::parse($expiration)->setTimezone('UTC'); 53 | $this->ttl = Carbon::parse($expiration)->diffInMinutes(Carbon::now()); 54 | $this->signature = $signature; 55 | $this->signatureAlgorithm = $signatureAlgorithm; 56 | $this->revokedCertsList = $certsList; 57 | } 58 | 59 | public function getRevokedList(): array 60 | { 61 | return $this->revokedCertsList; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/StreamConfig.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'capture_peer_cert' => true, 20 | 'capture_peer_cert_chain' => true, 21 | 'disable_compression' => true, 22 | ], 23 | ] 24 | ); 25 | 26 | return new static($streamContext); 27 | } 28 | 29 | public static function configInsecure(): self 30 | { 31 | $streamContext = stream_context_create( 32 | [ 33 | 'ssl' => [ 34 | 'allow_self_signed' => true, 35 | 'verify_peer' => false, 36 | 'verify_peer_name' => false, 37 | 'capture_peer_cert' => true, 38 | 'capture_peer_cert_chain' => true, 39 | 'disable_compression' => true, 40 | ], 41 | ] 42 | ); 43 | 44 | return new static($streamContext); 45 | } 46 | 47 | public static function configCrl(): self 48 | { 49 | $streamContext = stream_context_create( 50 | [ 51 | 'http' => [ 52 | 'method' => 'GET', 53 | 'max_redirects' => '0', 54 | 'ignore_errors' => '1', 55 | ], 56 | ] 57 | ); 58 | 59 | return new static($streamContext); 60 | } 61 | 62 | public function __construct($streamContext) 63 | { 64 | $this->streamContext = $streamContext; 65 | } 66 | 67 | public function getContext() 68 | { 69 | return $this->streamContext; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Url.php: -------------------------------------------------------------------------------- 1 | getInputUrl(); 25 | } 26 | 27 | private static function verifyAndGetDNS($domain): ?string 28 | { 29 | $domainIp = gethostbyname($domain); 30 | if (! filter_var($domainIp, FILTER_VALIDATE_IP)) { 31 | return null; 32 | } 33 | 34 | return $domainIp; 35 | } 36 | 37 | public function __construct(string $url) 38 | { 39 | $this->inputUrl = $url; 40 | $parser = new UriParser(); 41 | $this->parsedUrl = $parser($this->inputUrl); 42 | 43 | // Verify parsing has a host 44 | if (is_null($this->parsedUrl['host'])) { 45 | try { 46 | $this->parsedUrl = $parser('https://'.$this->inputUrl); 47 | } catch (\Exception $e) { 48 | throw InvalidUrl::couldNotValidate($url); 49 | } 50 | if (is_null($this->parsedUrl['host'])) { 51 | throw InvalidUrl::couldNotDetermineHost($url); 52 | } 53 | } 54 | 55 | if (! filter_var($this->getValidUrl(), FILTER_VALIDATE_URL)) { 56 | throw InvalidUrl::couldNotValidate($url); 57 | } 58 | 59 | $this->ipAddress = self::verifyAndGetDNS($this->parsedUrl['host']); 60 | $this->validatedURL = $url; 61 | } 62 | 63 | public function getIp(): ?string 64 | { 65 | if (null === $this->ipAddress) { 66 | throw InvalidUrl::couldNotResolveDns($this->inputUrl); 67 | } 68 | 69 | return $this->ipAddress; 70 | } 71 | 72 | public function getInputUrl(): string 73 | { 74 | return $this->inputUrl; 75 | } 76 | 77 | public function getHostName(): string 78 | { 79 | return $this->parsedUrl['host']; 80 | } 81 | 82 | public function getValidatedURL(): string 83 | { 84 | return $this->validatedURL; 85 | } 86 | 87 | public function getPort(): string 88 | { 89 | return (isset($this->parsedUrl['port'])) ? $this->parsedUrl['port'] : '443'; 90 | } 91 | 92 | public function getTestURL(): string 93 | { 94 | return "{$this->getHostName()}:{$this->getPort()}"; 95 | } 96 | 97 | public function getValidUrl(): string 98 | { 99 | if ($this->getPort() === '80') { 100 | return 'http://'.$this->getHostName().'/'; 101 | } 102 | 103 | return 'https://'.$this->getHostName().'/'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 |