├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── certainty-cert-symlink ├── composer.json ├── data ├── ca-certs.json ├── cacert-2021-01-19.pem ├── cacert-2021-04-13.pem ├── cacert-2021-05-25.pem ├── cacert-2021-07-05.pem ├── cacert-2021-09-30.pem ├── cacert-2021-10-26.pem ├── cacert-2022-02-01.pem ├── cacert-2022-03-18.pem ├── cacert-2022-04-26.pem ├── cacert-2022-07-19.pem ├── cacert-2022-10-11.pem ├── cacert-2023-01-10.pem ├── cacert-2023-05-30.pem ├── cacert-2023-08-22.pem ├── cacert-2023-12-12.pem └── cacert-2024-03-11.pem ├── docs ├── README.md └── features │ ├── LocalCACertBuilder.md │ └── RemoteFetch.md ├── local ├── README.md ├── keygen.php └── signer.php ├── phpunit.xml.dist ├── psalm.xml ├── src ├── Bundle.php ├── Certainty.php ├── Composer.php ├── Exception │ ├── BundleException.php │ ├── CertaintyException.php │ ├── CryptoException.php │ ├── EncodingException.php │ ├── FilesystemException.php │ ├── InvalidResponseException.php │ ├── NetworkException.php │ └── RemoteException.php ├── Fetch.php ├── LocalCACertBuilder.php ├── RemoteFetch.php └── Validator.php └── test ├── BundleTest.php ├── CustomCASupportTest.php ├── CustomValidator.php ├── FetchTest.php ├── RemoteFetchTest.php ├── ValidatorTest.php └── static ├── data-empty └── ca-certs.json ├── data-invalid ├── ca-certs.json └── cacert-2017-09-20.pem ├── data-remote └── .gitkeep ├── data-valid └── .gitkeep ├── data-valid2 └── .gitkeep ├── empty-dir └── .gitkeep ├── repeat-globalsign.pem └── test-file.txt /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | old: 7 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-20.04'] 12 | php-versions: ['5.5', '5.6', '7.0'] 13 | phpunit-versions: ['7.5.20'] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php-versions }} 22 | extensions: mbstring, intl 23 | ini-values: post_max_size=256M, max_execution_time=180 24 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 25 | 26 | - name: Fix permissions 27 | run: sudo chmod -R 0777 . 28 | 29 | - name: Install dependencies 30 | run: composer self-update --1; composer install 31 | 32 | - name: PHPUnit tests 33 | run: vendor/bin/phpunit 34 | 35 | moderate: 36 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 37 | runs-on: ${{ matrix.operating-system }} 38 | strategy: 39 | matrix: 40 | operating-system: ['ubuntu-latest'] 41 | php-versions: ['7.1', '7.2', '7.3'] 42 | phpunit-versions: ['latest'] 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | 47 | - name: Setup PHP 48 | uses: shivammathur/setup-php@v2 49 | with: 50 | php-version: ${{ matrix.php-versions }} 51 | extensions: mbstring, intl, sodium 52 | ini-values: post_max_size=256M, max_execution_time=180 53 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 54 | 55 | - name: Fix permissions 56 | run: sudo chmod -R 0777 . 57 | 58 | - name: Install dependencies 59 | run: composer update 60 | 61 | - name: PHPUnit tests 62 | run: vendor/bin/phpunit 63 | 64 | modern: 65 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 66 | runs-on: ${{ matrix.operating-system }} 67 | strategy: 68 | matrix: 69 | operating-system: ['ubuntu-latest'] 70 | php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3'] 71 | phpunit-versions: ['latest'] 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v4 75 | 76 | - name: Setup PHP 77 | uses: shivammathur/setup-php@v2 78 | with: 79 | php-version: ${{ matrix.php-versions }} 80 | extensions: mbstring, intl, sodium 81 | ini-values: post_max_size=256M, max_execution_time=180 82 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 83 | 84 | - name: Fix permissions 85 | run: sudo chmod -R 0777 . 86 | 87 | - name: Install dependencies 88 | run: composer update 89 | 90 | - name: PHPUnit tests 91 | run: vendor/bin/phpunit 92 | 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /local/dev-testing 3 | /local/keys.json 4 | /composer.lock 5 | /test/static/*/*.cache 6 | /test/static/*/*.json 7 | /test/static/*/*.pem 8 | !/test/static/data-invalid/ca-certs.json 9 | !/test/static/data-invalid/cacert-2017-09-20.pem 10 | /test/static/ca-certs.json 11 | /test/static/combined.pem 12 | /vendor 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * ISC License 3 | * 4 | * Copyright (c) 2017 - 2022 5 | * Paragon Initiative Enterprises 6 | * 7 | * Permission to use, copy, modify, and/or distribute this software for any 8 | * purpose with or without fee is hereby granted, provided that the above 9 | * copyright notice and this permission notice appear in all copies. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | */ 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Certainty - CA-Cert Automation for PHP Projects 2 | 3 | [![Build Status](https://github.com/paragonie/certainty/actions/workflows/ci.yml/badge.svg)](https://github.com/paragonie/certainty/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/paragonie/certainty/v/stable)](https://packagist.org/packages/paragonie/certainty) 5 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/certainty/v/unstable)](https://packagist.org/packages/paragonie/certainty) 6 | [![License](https://poser.pugx.org/paragonie/certainty/license)](https://packagist.org/packages/paragonie/certainty) 7 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/certainty.svg)](https://packagist.org/packages/paragonie/certainty) 8 | 9 | Automate your PHP projects' cacert.pem management. 10 | [Read the blog post introducing Certainty](https://paragonie.com/blog/2017/10/certainty-automated-cacert-pem-management-for-php-software). 11 | 12 | **Requires PHP 5.5 or newer.** 13 | Certainty should work on any operating system (including Windows), although the symlink 14 | feature may not function in Virtualbox Shared Folders. 15 | 16 | ## Who is Certainty meant for? 17 | 18 | * Open source developers with no control over where their code is deployed 19 | (e.g. Magento module developers). 20 | * People whose code might be deployed in weird environments with CACert 21 | bundles that are outdated or in unpredictable locations. 22 | * People who are generally forced between: 23 | 1. Disabling certificate validation entirely, or 24 | 2. Increasing their support burden to deal with corner-cases where suddenly 25 | HTTP requests are failing on weird systems 26 | 27 | Certainty allows your software to "just work" (which is usually the motivation 28 | for disabling certificate validation) without being vulnerable to man-in-the-middle 29 | attacks. 30 | 31 | ### Motivation 32 | 33 | Many HTTP libraries require you to specify a file path to a `cacert.pem` file in order to use TLS correctly. 34 | Omitting this file means either disabling certificate validation entirely (which enables trivial man-in-the-middle 35 | exploits), connection failures, or hoping that your library falls back safely to the operating system's bundle. 36 | 37 | In short, the possible outcomes (from best to worst) are as follows: 38 | 39 | 1. Specify a cacert file, and you get to enjoy TLS as it was intended. (Secure.) 40 | 2. Omit a cacert file, and the OS maybe bails you out. (Uncertain.) 41 | 3. Omit a cacert file, and it fails closed. (Connection failed. Angry customers.) 42 | 4. Omit a cacert file, and it fails open. (Data compromised. Hurt customers. Expensive legal proceedings.) 43 | 44 | Obviously, the first outcome is optimal. So we built *Certainty* to make it easier to ensure open 45 | source projects do this. 46 | 47 | ## Installing Certainty 48 | 49 | From Composer: 50 | 51 | ```bash 52 | composer require paragonie/certainty:^2 53 | ``` 54 | 55 | Certainty will keep certificates up to date via `RemoteFetch`, so you don't need to update 56 | Certainty library just to get fresh CA-Cert bundles. Update only for bugfixes (especially 57 | security fixes) and new features. 58 | 59 | ### Non-Supported Use Case: 60 | 61 | If you are not using [`RemoteFetch`](docs/features/RemoteFetch.md) (which is strongly recommended 62 | that you do, and we only provide support for systems that *do* use `RemoteFetch`), then you want 63 | to use `dev-master` rather than a version constraint, due to the nature of CA Certificates. 64 | 65 | If a major CA gets compromised and their certificates are revoked, you don't want to continue 66 | trusting these certificates. 67 | 68 | Furthermore, in the event of avoiding `RemoteFetch`, you should be running `composer update` at least 69 | once per week to prevent stale CA-Cert files from causing issues. 70 | 71 | ## Using Certainty 72 | 73 | See [the documentation](docs/README.md). 74 | 75 | ## What Certainty Does 76 | 77 | Certainty maintains a repository of all the `cacert.pem` files since 2017, along with a sha256sum and 78 | Ed25519 signature of each file. When you request the latest bundle, Certainty will check both these 79 | values (the latter can only be signed by a key held by Paragon Initiative Enterprises, LLC) for each 80 | entry in the JSON value, and return the latest bundle that passes validation. 81 | 82 | The cacert.pem files contained within are [reproducible from Mozilla's bundle](https://curl.haxx.se/docs/mk-ca-bundle.html). 83 | 84 | ### How is Certainty different from composer/ca-bundle? 85 | 86 | The key differences are: 87 | 88 | * Certainty will keep the CA-Cert bundles on your system up-to-date even if you do not 89 | run `composer update`. 90 | * We sign our CA-Cert bundles using Ed25519, and check every update into the 91 | [PHP community Chronicle](https://php-chronicle.pie-hosted.com). 92 | 93 | ## Support Contracts 94 | 95 | If your company uses this library in their products or services, you may be 96 | interested in [purchasing a support contract from Paragon Initiative Enterprises](https://paragonie.com/enterprise). 97 | -------------------------------------------------------------------------------- /bin/certainty-cert-symlink: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | getLatestBundle() 12 | ->createSymlink($argv[1], true); 13 | echo 'OK', PHP_EOL; 14 | exit(0); 15 | } catch (\Throwable $ex) { 16 | echo $ex->getMessage(), PHP_EOL; 17 | echo $ex->getTraceAsString(), PHP_EOL; 18 | exit(1); 19 | } 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/certainty", 3 | "description": "Up-to-date, verifiable repository for Certificate Authorities", 4 | "keywords": ["CA", "Certificate Authority", "CA-Cert", "CACert", "cacert.pem", "ca-cert.pem", "PKI", "TLS", "SSL", "Public-Key Infractructure", "Ed25519"], 5 | "license": "ISC", 6 | "authors": [ 7 | { 8 | "name": "Paragon Initiative Enterprises", 9 | "email": "security@paragonie.com", 10 | "homepage": "https://paragonie.com" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { 15 | "ParagonIE\\Certainty\\": "src/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "ParagonIE\\Certainty\\Tests\\": "test/" 21 | } 22 | }, 23 | "bin": [ 24 | "bin/certainty-cert-symlink" 25 | ], 26 | "require": { 27 | "php": "^5.5|^7|^8", 28 | "ext-curl": "*", 29 | "ext-json": "*", 30 | "guzzlehttp/guzzle": "^6|^7", 31 | "paragonie/constant_time_encoding": "^1|^2|^3", 32 | "paragonie/sodium_compat": "^1|^2" 33 | }, 34 | "require-dev": { 35 | "composer/composer": "^1|^2", 36 | "phpunit/phpunit": "^4|^5|^6|^7|^8|^9" 37 | }, 38 | "scripts": { 39 | "post-autoload-dump": [ 40 | "ParagonIE\\Certainty\\Composer::postAutoloadDump" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /data/ca-certs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "chronicle": "JB31eHJBKk8_rdK3KZsrV9qQR8tu6ZEgvbvJRYfOYi0=", 4 | "date": "2024-03-11", 5 | "file": "cacert-2024-03-11.pem", 6 | "sha256": "1794c1d4f7055b7d02c2170337b61b48a2ef6c90d77e95444fd2596f4cac609f", 7 | "signature": "4cec2f4dbafb3aa3cf7749e906881edfad14f38829a7aa6fcc9f4ded560fa7326e775b76429ddef4d2f853a6db9231f3704e759198c2b5ee5fd4197eceae4802", 8 | "trust-channel": "Mozilla" 9 | }, 10 | { 11 | "chronicle": "SNm3W9LqMDJQYLlsIQDrQ_lFNGm0RwXG2hypl0jo8Ek=", 12 | "date": "2023-12-12", 13 | "file": "cacert-2023-12-12.pem", 14 | "sha256": "ccbdfc2fe1a0d7bbbb9cc15710271acf1bb1afe4c8f1725fe95c4c7733fcbe5a", 15 | "signature": "1cdf6e355df4399a6bac9a897e6285c1475ca79bc7da303c762471381a1d57e27bbff331c43c25a76fa08a476a390099e9761483688c15bbf0ebc0f3f19f3d0c", 16 | "trust-channel": "Mozilla" 17 | }, 18 | { 19 | "chronicle": "o297WfvcA7ApK2pmFb9nKtMksdzyadhy6Gzdt2GyLdg=", 20 | "date": "2023-08-22", 21 | "file": "cacert-2023-08-22.pem", 22 | "sha256": "23c2469e2a568362a62eecf1b49ed90a15621e6fa30e29947ded3436422de9b9", 23 | "signature": "c544b6b11249ccaef5b05f6a33bdae6123c746bf2733d397a949e327af61a071fcd06fe586eb7de08e9261c9489d177618336d84cc021ec411ae3e1c4a763306", 24 | "trust-channel": "Mozilla" 25 | }, 26 | { 27 | "chronicle": "87WBgxu1Yg0ElU9Dl3vUi1Wrin94iPkOm3jUffX4YeE=", 28 | "date": "2023-05-30", 29 | "file": "cacert-2023-05-30.pem", 30 | "sha256": "5fadcae90aa4ae041150f8e2d26c37d980522cdb49f923fc1e1b5eb8d74e71ad", 31 | "signature": "4577475f0b21c5da82f2bc323414f4c6ac1b0ef60e65af511bcdb82acf767aab3f1e38d64bc166612e047b6063d384230b016a3c1e8b5d0bc401587511e2d30a", 32 | "trust-channel": "Mozilla" 33 | }, 34 | { 35 | "chronicle": "xociaMQZosyByFz_vHcjNLk15ajzMtJFFwsw5tVBGyo=", 36 | "date": "2023-01-10", 37 | "file": "cacert-2023-01-10.pem", 38 | "sha256": "fb1ecd641d0a02c01bc9036d513cb658bbda62a75e246bedbc01764560a639f0", 39 | "signature": "2b19d26379c46e9ee4f0b08b56610f72cd471ff1539024dd64233030df89e8aec7b65d64a3e1ce9369340bec8a86fee8a8053889af76e1d8393d2c29e729060c", 40 | "trust-channel": "Mozilla" 41 | }, 42 | { 43 | "chronicle": "j_EHquKHXlOmGssLmaejhhsfGuQFb9g3YRz3Ih7QTJk=", 44 | "date": "2022-10-11", 45 | "file": "cacert-2022-10-11.pem", 46 | "sha256": "2cff03f9efdaf52626bd1b451d700605dc1ea000c5da56bd0fc59f8f43071040", 47 | "signature": "152a2d0b3d599589e6a762af3624a8d7ddf3ae55c004c3f6c6eecb78a89f6f1591ad81960185ea6218781a79ee3f422fd77540333a9321bfbf1bbe592ab4cf0e", 48 | "trust-channel": "Mozilla" 49 | }, 50 | { 51 | "chronicle": "A3D3tp2AolZAxKuzm4oIdk_S6UlziFao9aLIHIHD0JA=", 52 | "date": "2022-07-19", 53 | "file": "cacert-2022-07-19.pem", 54 | "sha256": "6ed95025fba2aef0ce7b647607225745624497f876d74ef6ec22b26e73e9de77", 55 | "signature": "d89addce89dcb52fa549ee2dabd48dc8a931534e3060b151c29fc74ec779ce44e87863d44db9bdc2896dd2e5043bc3eadcdc1e978367742f1c1e7436b17efd01", 56 | "trust-channel": "Mozilla" 57 | }, 58 | { 59 | "chronicle": "Oa7LYZPP78IgzhyIDbuWoggku-OUDDA8a7gCg7vDvPc=", 60 | "date": "2022-04-26", 61 | "file": "cacert-2022-04-26.pem", 62 | "sha256": "08df40e8f528ed283b0e480ba4bcdbfdd2fdcf695a7ada1668243072d80f8b6f", 63 | "signature": "6ae4816fb270451cd6ee30aff5988fa27e184e3c541e39cec3de3c630be2c83c9052028b50879fc89ff4a77a031ad97c66035cac12653399e6d7e2b678ac2502", 64 | "trust-channel": "Mozilla" 65 | }, 66 | { 67 | "chronicle": "EEfqWci8H7OgiElBNrkPCwf9usR-zOKs7HzK-2Nfsh8=", 68 | "date": "2022-03-18", 69 | "file": "cacert-2022-03-18.pem", 70 | "sha256": "2d0575e481482551a6a4f9152e7d2ab4bafaeaee5f2606edb829c2fdb3713336", 71 | "signature": "d6c9c96d8aa474b6104d477563496ccdb80c07bb55414e81bc222fab260255a861c0af137758aad6fbd0715778d82de1800dc4e5c90ae3586aaeb7a3de8b5602", 72 | "trust-channel": "Mozilla" 73 | }, 74 | { 75 | "chronicle": "BhRSfuEAN8SUEJdUZ3ASoh047gXolgymD5oPRxL3314=", 76 | "date": "2022-02-01", 77 | "file": "cacert-2022-02-01.pem", 78 | "sha256": "1d9195b76d2ea25c2b5ae9bee52d05075244d78fcd9c58ee0b6fac47d395a5eb", 79 | "signature": "1661125866b0ffaa15ecf9db75f653fbbc945da2b7b35e0ddee5cc8bd5a5fdb2d231219f4e06d683c5b700e3bf3c2ba6dea261bc865815824b666f2433afb501", 80 | "trust-channel": "Mozilla" 81 | }, 82 | { 83 | "chronicle": "SRMxLDDpfaRCXWie4ihbBr5T0QMrVBHUrAfCqYHOFtw=", 84 | "date": "2021-10-26", 85 | "file": "cacert-2021-10-26.pem", 86 | "sha256": "ae31ecb3c6e9ff3154cb7a55f017090448f88482f0e94ac927c0c67a1f33b9cf", 87 | "signature": "249d82a2a70bdd149347fc06015ed9eb9903b1b8284dd2f147b18f7f9a95fed6d1c578ee481cd8db34a3ae2af9a4978b07852e421e9c064d3f7c1b6258e5db0f", 88 | "trust-channel": "Mozilla" 89 | }, 90 | { 91 | "chronicle": "cZNwbjSoyq4PZ3mJfvYv0ZYU3Bouapr89UGA9pPRM_Q=", 92 | "date": "2021-09-30", 93 | "file": "cacert-2021-09-30.pem", 94 | "sha256": "f524fc21859b776e18df01a87880efa198112214e13494275dbcbd9bcb71d976", 95 | "signature": "8fa37f70c6fd391383edbc78a6ec89cf8942d0cf0f42dda51f5744eaefd876a7112dda0d1050c5db17f7a5ce828a8bc9e2e0564b0b79ab0a70c0faaf237d520d", 96 | "trust-channel": "Mozilla" 97 | }, 98 | { 99 | "chronicle": "QqIcQ_u6EJ8i7LJnn2zSlgtkLC2nQ5lx1A8UpYXpw9c=", 100 | "date": "2021-07-05", 101 | "file": "cacert-2021-07-05.pem", 102 | "sha256": "a3b534269c6974631db35f952e8d7c7dbf3d81ab329a232df575c2661de1214a", 103 | "signature": "88219de6590f51ec45213249465a478b05b929b58443706637cc1be93bbe7b156ba82d3751d6f0c99d36bc228bbc09ca74f0cf6e9f0118c900f9f372cb75b80d", 104 | "trust-channel": "Mozilla" 105 | }, 106 | { 107 | "chronicle": "97ZXamCrbirSheuYSn7XmjVCKHEE7lPTyHfr5zIwGr0=", 108 | "date": "2021-05-25", 109 | "file": "cacert-2021-05-25.pem", 110 | "sha256": "3a32ad57e7f5556e36ede625b854057ac51f996d59e0952c207040077cbe48a9", 111 | "signature": "f3ee82c034d0b3369302abd4d1c5b4ac1095fa60b0acd254167f2720dfcb0fc9b4d82d7c0f359b9002ab0dbf5b95055cb79e31d9bfc4830f351f4c4f59c0a207", 112 | "trust-channel": "Mozilla" 113 | }, 114 | { 115 | "chronicle": "4r9MVTgwEPgfGeY2r7h_JR71M2PGcxmgBHDQvRKlucA=", 116 | "date": "2021-04-13", 117 | "file": "cacert-2021-04-13.pem", 118 | "sha256": "533610ad2b004c1622a40622f86ced5e89762e1c0e4b3ae08b31b240d863e91f", 119 | "signature": "0700bade0b87a673b5f5156793d102de4da1b982d34a975d0dbc8780c50b5e0a737c0169e9ee2cb7f39d66cfdc49d66cfc74743b1cb8bbbb3ff5e1e717ed810f", 120 | "trust-channel": "Mozilla" 121 | }, 122 | { 123 | "chronicle": "06K9BhusfSdyMaL1bZexm81lQ5D0-dr73vnk3ZwClG0=", 124 | "date": "2021-01-19", 125 | "file": "cacert-2021-01-19.pem", 126 | "sha256": "e010c0c071a2c79a76aa3c289dc7e4ac4ed38492bfda06d766a80b707ebd2f29", 127 | "signature": "8ae1ab7de1bb955937a69094c70cdc3dd2f9470e8079c45749c385f9619ca5e6f01984c29e55648b1445c5fd3321b1bbf3422c16d91f970a25bf29e8d7949307", 128 | "trust-channel": "Mozilla" 129 | } 130 | ] -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Certainty Documentation 2 | 3 | Before you begin, which problem are you trying to solve? 4 | 5 | * [I want my users to have always-up-to-date CA-Cert files](features/RemoteFetch.md) 6 | * [I just want an updated CA-Cert file in a predictable location](features/RemoteFetch.md#symlinks) 7 | * [I need to run a custom/internal certificate authority while also trusting the most recent CA certs](features/LocalCACertBuilder.md) (**Advanced**) 8 | 9 | ## Troubleshooting 10 | 11 | ### Cannot connect to https://php-chronicle.pie-hosted.com/chronicle 12 | 13 | If you're having difficulties connecting to the PHP Chronicle, you can use 14 | a replica instance of the PHP Chronicle. 15 | 16 | #### PHP Chronicle Replicas for Certainty 17 | 18 | | URL | Public Key | Operator | 19 | | --- | ---------- | -------- | 20 | | https://php-chronicle-replica.pie-hosted.com/chronicle/replica/_vi6Mgw6KXBSuOFUwYA2H2GEPLawUmjqFJbCCuqtHzGZ/ | `MoavD16iqe9-QVhIy-ewD4DMp0QRH-drKfwhfeDAUG0=` | [Paragon Initiative Enterprises](https://paragonie.com) | 21 | 22 | ### I'm Getting a File Permission Error When Trying to Use Certainty 23 | 24 | Make sure the `vendor/paragonie/certainty/data` directory is writable. For example: 25 | 26 | ```bash 27 | chown -R webuser:webuser vendor/paragonie/certainty/data 28 | chmod 0775 vendor/paragonie/certainty/data 29 | chmod 0664 vendor/paragonie/certainty/data/* 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/features/LocalCACertBuilder.md: -------------------------------------------------------------------------------- 1 | # Using Certainty with Custom CA Certificates 2 | 3 | So you want to run your own in-house certificate authority. Certainty was designed to make 4 | your life easier, by allowing you to: 5 | 6 | * Keep your users up-to-date with the latest CA-Cert bundles (like all Certainty users) 7 | * Bundle your in-house CA Certificate(s) alongside the Mozilla list 8 | 9 | This allows your internal CA to be trusted by the software deployed on your network without 10 | causing CA validation pains for external Internet resources. 11 | 12 | However, this is an advanced use-case, so it requires a little bit of work to get started. 13 | 14 | ## Getting Started with Custom CAs 15 | 16 | ### Generate a Keypair 17 | 18 | We include a script in the `local` directory for generating a keypair. 19 | 20 | ```bash 21 | php local/keygen.php 22 | ``` 23 | 24 | This will save an Ed25519 keypair to a file called keys.json. 25 | 26 | ### Create a Custom Validator 27 | 28 | For example: 29 | 30 | ```php 31 | getLatestBundle(); 66 | 67 | LocalCACertBuilder::fromBundle($latest) 68 | ->setSigningKey(Hex::decode('your hex-encoded secret key goes here')) 69 | ->appendCACertFile('/path/to/your-in-house-ca-certs.pem') 70 | ->setOutputJsonFile('/path/to/output/ca-certs.json') 71 | ->setOutputPemFile('/path/to/output/cacert-' . date('Y-m-d') . '.pem') 72 | ->save(); 73 | ``` 74 | 75 | Once you are satisfied, you can publish your data directory and your users, whom will be using 76 | your custom Validator alongside [`RemoteFetch`](RemoteFetch.md), should always be up-to-date. 77 | 78 | ### Chronicle Verification for Custom CAs 79 | 80 | The default Validator is configured to verify that all updates are published to the PHP 81 | Community's Chronicle instance. You can either chose to opt out of verification, or 82 | [run your own Chronicle](https://github.com/paragonie/chronicle/tree/master/docs). 83 | 84 | Once you have one setup, all you need to do is update your `LocalCACertBuilder` code with 85 | the Client ID, Public Key, and URL. 86 | 87 | ```php 88 | getLatestBundle(); 94 | 95 | /* This snippet is mostly identical from the previous one. */ 96 | LocalCACertBuilder::fromBundle($latest) 97 | ->setSigningKey(Hex::decode('your hex-encoded secret key goes here')) 98 | ->appendCACertFile('/path/to/your-in-house-ca-certs.pem') 99 | ->setOutputJsonFile('/path/to/output/ca-certs.json') 100 | ->setOutputPemFile('/path/to/output/cacert-' . date('Y-m-d') . '.pem') 101 | /* This is the new part: */ 102 | ->setChronicle( 103 | 'https://foo-chronicle.example.com/', 104 | '', 105 | '', 106 | 'acme-company/local-certainty-ca' 107 | ) 108 | /* You always save() at the end */ 109 | ->save(); 110 | ``` -------------------------------------------------------------------------------- /docs/features/RemoteFetch.md: -------------------------------------------------------------------------------- 1 | # RemoteFetch 2 | 3 | This downloads the latest CA certificates from our Github repository and caches them locally. 4 | 5 | ## Basic Usage 6 | 7 | Using the `RemoteFetch` class is rather straightforward. 8 | 9 | #### Basic Usage with cURL 10 | 11 | ```php 12 | getLatestBundle(); 17 | 18 | $ch = curl_init(); 19 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); 20 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 21 | curl_setopt($ch, CURLOPT_CAINFO, $latestCACertBundle->getFilePath()); 22 | ``` 23 | 24 | #### Basic Usage with Guzzle 25 | 26 | ```php 27 | getLatestBundle(); 33 | $client = new Client(); 34 | 35 | $response = $client->request('POST', '/url', [ 36 | 'verify' => $latestCACertBundle->getFilePath() 37 | ]); 38 | ``` 39 | 40 | #### Basic Usage with Streams 41 | 42 | ```php 43 | getLatestBundle(); 48 | 49 | $context = stream_context_create([ 50 | 'ssl' => [ 51 | 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, 52 | 'verify_peer' => true, 53 | 'cafile' => $latestCACertBundle->getFilePath(), 54 | 'verify_depth' => 5 55 | ] 56 | ]); 57 | 58 | $data = file_get_contents( 59 | 'https://php-chronicle.pie-hosted.com/chronicle/lookup/HuICLQCF_DWnQGbosC6fK8PuifQgIrRi2WYshB2erZY=', 60 | false, 61 | $context 62 | ); 63 | ``` 64 | 65 | #### Composer Integration 66 | 67 | **Since version 2.2.0.** 68 | 69 | You can have Certainty request an up-to-date bundle at runtime by ensuring 70 | you add this entry to your composer.json file: 71 | 72 | ```json 73 | { 74 | "scripts": { 75 | "post-autoload-dump": [ 76 | "ParagonIE\\Certainty\\Composer::postAutoloadDump" 77 | ] 78 | } 79 | } 80 | ``` 81 | 82 | Then, you can simply use the local `Fetch` class instead of `RemoteFetch` in 83 | your application code. Every time you run `composer update`, it will fetch 84 | the latest bundles from Certainty. 85 | 86 | This is a great way to reduce your runtime performance overhead while 87 | guaranteeing that you have the latest CACert bundle. 88 | 89 | ### Changing the Path or URL 90 | 91 | By default, Certainty's `RemoteFetch` feature pulls from Github and uses the most recent CA-Cert 92 | bundled with the source code to ensure Github is actually Github. 93 | 94 | You can change the URL or local save directory either by passing string arguments to the constructor, 95 | like so: 96 | 97 | ```php 98 | setCacheTimeout(new \DateInterval('PT06H')); 122 | 123 | // Alternatively, the constructor approach: 124 | $fetcher = new RemoteFetch( 125 | '/path/to/certainty/data', 126 | RemoteFetch::DEFAULT_URL, 127 | null, // automatically selects/configures Guzzle 128 | new \DateInterval('PT06H') // 6 hours 129 | ); 130 | ``` 131 | 132 | ## Symlinks 133 | 134 | Being able to fetch the most recent CA-Cert bundle's file path at runtime is the preferred usage 135 | for Certainty, but some will prefer to create a symlink at a predictable location so they can use 136 | that path in their code. 137 | 138 | Certainty supports this usage. 139 | 140 | ```php 141 | getLatestBundle(); 145 | 146 | $latest->createSymlink('/path/to/cacert.pem', true); 147 | ``` 148 | 149 | The second argument, `true`, tells Certainty to remove the existing symlink if it already exists. 150 | 151 | ## Using a Different Chronicle 152 | 153 | To use a different Chronicle instance (i.e. a replica of the PHP Chronicle 154 | instead of the main instance), you can configure your `RemoteFetch` object 155 | by calling the `setChronicle()` method with a URL and a public key. 156 | 157 | ```php 158 | setChronicle( 162 | 'https://php-chronicle-replica.pie-hosted.com/chronicle/replica/_vi6Mgw6KXBSuOFUwYA2H2GEPLawUmjqFJbCCuqtHzGZ', 163 | 'MoavD16iqe9-QVhIy-ewD4DMp0QRH-drKfwhfeDAUG0=' 164 | ); 165 | ``` 166 | -------------------------------------------------------------------------------- /local/README.md: -------------------------------------------------------------------------------- 1 | # Important 2 | 3 | The files in this directory are only useful if you're running a local CA. 4 | 5 | Otherwise, you don't need it for this utility. 6 | -------------------------------------------------------------------------------- /local/keygen.php: -------------------------------------------------------------------------------- 1 | bin2hex($secret), 18 | 'public-key' => bin2hex($public) 19 | ], 20 | JSON_PRETTY_PRINT 21 | ) 22 | ); 23 | -------------------------------------------------------------------------------- /local/signer.php: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./test 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Bundle.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 67 | $this->sha256sum = $sha256sum; 68 | $this->signature = $signature; 69 | $this->chronicleHash = $chronicleHash; 70 | if (!empty($customValidator)) { 71 | /** @psalm-suppress MixedMethodCall */ 72 | if (\class_exists($customValidator)) { 73 | $newClass = new $customValidator(); 74 | if (!($newClass instanceof Validator)) { 75 | throw new \TypeError('Invalid validator class'); 76 | } 77 | } 78 | } 79 | if (!isset($newClass)) { 80 | $newClass = new Validator(); 81 | } 82 | /** @var Validator $newClass */ 83 | $this->customValidator = $newClass; 84 | $this->trustChannel = $trustChannel; 85 | } 86 | 87 | /** 88 | * Creates a symbolic link that points to this bundle. 89 | * 90 | * @param string $destination 91 | * @param bool $unlinkIfExists 92 | * @return bool 93 | * @throws CertaintyException 94 | */ 95 | public function createSymlink($destination = '', $unlinkIfExists = false) 96 | { 97 | if (\file_exists($destination)) { 98 | if ($unlinkIfExists) { 99 | \unlink($destination); 100 | } else { 101 | throw new FilesystemException('Destination already exists.'); 102 | } 103 | } 104 | return \symlink($this->filePath, $destination); 105 | } 106 | 107 | /** 108 | * @return string 109 | * @throws CertaintyException 110 | */ 111 | public function getFileContents() 112 | { 113 | $contents = \file_get_contents($this->filePath); 114 | if (!\is_string($contents)) { 115 | throw new FilesystemException('Could not read file ' . $this->filePath); 116 | } 117 | return (string) $contents; 118 | } 119 | 120 | /** 121 | * @return string 122 | */ 123 | public function getFilePath() 124 | { 125 | return $this->filePath; 126 | } 127 | 128 | /** 129 | * Get the SHA256 hash of this bundle's contents. Defaults 130 | * to returning a hex-encoded string. 131 | * 132 | * @param bool $raw Return a raw binary string rather than hex-encoded? 133 | * @return string 134 | */ 135 | public function getSha256Sum($raw = false) 136 | { 137 | if ($raw) { 138 | return Hex::decode($this->sha256sum); 139 | } 140 | return $this->sha256sum; 141 | } 142 | 143 | /** 144 | * Get the Ed25519 signature for this bundle. Defaults 145 | * to returning a hex-encoded string. 146 | * 147 | * @param bool $raw Return a raw binary string rather than hex-encoded? 148 | * @return string 149 | */ 150 | public function getSignature($raw = false) 151 | { 152 | if ($raw) { 153 | return Hex::decode($this->signature); 154 | } 155 | return $this->signature; 156 | } 157 | 158 | /** 159 | * @return string 160 | */ 161 | public function getTrustChannel() 162 | { 163 | return $this->trustChannel; 164 | } 165 | 166 | /** 167 | * Get the Chronicle hash (always base64url-encoded) 168 | * 169 | * @return string 170 | */ 171 | public function getChronicleHash() 172 | { 173 | return $this->chronicleHash; 174 | } 175 | 176 | /** 177 | * Get the custom validator (assuming one is defined). 178 | * 179 | * @return Validator 180 | * @throws CertaintyException 181 | * 182 | * @psalm-suppress DocblockTypeContradiction 183 | */ 184 | public function getValidator() 185 | { 186 | if (!isset($this->customValidator)) { 187 | throw new CertaintyException('Custom class not defined'); 188 | } 189 | return $this->customValidator; 190 | } 191 | 192 | /** 193 | * Does this Bundle need a custom validator? This is typically only true 194 | * if a custom CA cert is being employed in addition to the Mozilla bundles. 195 | * 196 | * @return bool 197 | */ 198 | public function hasCustom() 199 | { 200 | return !empty($this->customValidator); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Certainty.php: -------------------------------------------------------------------------------- 1 | true]; 28 | if (!\is_null($fetch)) { 29 | try { 30 | $options['verify'] = $fetch->getLatestBundle()->getFilePath(); 31 | } catch (CertaintyException $ex) { 32 | // Fail closed just for usability. We're verifying anyway. 33 | } 34 | } 35 | 36 | if (\defined('CURLOPT_SSLVERSION') && \defined('CURL_SSLVERSION_TLSv1_2') && \defined('CURL_SSLVERSION_TLSv1')) { 37 | // https://github.com/curl/curl/blob/6aa86c493bd77b70d1f5018e102bc3094290d588/include/curl/curl.h#L1927 38 | $options['curl.options'][CURLOPT_SSLVERSION] = CURL_SSLVERSION_TLSv1_2 | (CURL_SSLVERSION_TLSv1 << 16); 39 | } 40 | $options['connect_timeout'] = (int) $timeout; 41 | 42 | return new Client($options); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Composer.php: -------------------------------------------------------------------------------- 1 | getComposer()->getConfig()->get('vendor-dir'); 28 | require_once $vendorDir . '/autoload.php'; 29 | 30 | $dataDir = \dirname($vendorDir) . '/data'; 31 | (new RemoteFetch($dataDir))->getLatestBundle(false, false); 32 | self::dos2unixAll($dataDir); 33 | (new RemoteFetch($dataDir))->getLatestBundle(); 34 | 35 | echo '[OK] Remote Fetch of latest CACert Bundle', PHP_EOL; 36 | } 37 | 38 | /** 39 | * Prevent newline weirdness with Git from causing invalid files (SHA-256, signatures) 40 | * 41 | * @param string $dataDir 42 | * @return void 43 | */ 44 | public static function dos2unixAll($dataDir) 45 | { 46 | foreach (glob($dataDir . '/*.pem') as $pemFile) { 47 | $contents = file_get_contents($pemFile); 48 | $fixed = str_replace("\r\n", "\n", $contents); 49 | file_put_contents($pemFile, $fixed); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Exception/BundleException.php: -------------------------------------------------------------------------------- 1 | $unverified 41 | */ 42 | protected $unverified = []; 43 | 44 | /** 45 | * Fetch constructor. 46 | * 47 | * You almost certainly want to use RemoteFetch instead. 48 | * 49 | * @param string $dataDir Where the certificates and configuration lives 50 | * 51 | * @throws CertaintyException 52 | */ 53 | public function __construct($dataDir) 54 | { 55 | if (!\is_readable($dataDir)) { 56 | throw new FilesystemException('Directory is not readable: ' . $dataDir); 57 | } 58 | $this->dataDirectory = $dataDir; 59 | } 60 | 61 | /** 62 | * Get the latest bundle. Checks the SHA256 hash of the file versus what 63 | * is expected. Optionally checks the Ed25519 signature. 64 | * 65 | * @param bool|null $checkEd25519Signature Enforce Ed25519 signatures? 66 | * @param bool|null $checkChronicle Require cert bundles be stored 67 | * inside a Chronicle instance? 68 | * @return Bundle 69 | * 70 | * @throws CertaintyException 71 | * @throws \SodiumException 72 | */ 73 | public function getLatestBundle($checkEd25519Signature = null, $checkChronicle = null) 74 | { 75 | $sodiumCompatIsntSlow = $this->sodiumCompatIsntSlow(); 76 | if (\is_null($checkEd25519Signature)) { 77 | $checkEd25519Signature = (bool) (static::CHECK_SIGNATURE_BY_DEFAULT && $sodiumCompatIsntSlow); 78 | } 79 | $conditionalChronicle = \is_null($checkChronicle); 80 | if ($conditionalChronicle) { 81 | $checkChronicle = (bool) (static::CHECK_CHRONICLE_BY_DEFAULT && $sodiumCompatIsntSlow); 82 | } 83 | 84 | $bundleIndex = 0; 85 | $bundlesAvailable = $this->listBundles('', $this->trustChannel); 86 | if (empty($bundlesAvailable)) { 87 | throw new BundleException('No bundles were found in the data directory.'); 88 | } 89 | foreach ($bundlesAvailable as $bundle) { 90 | if ($bundle->hasCustom()) { 91 | $validator = $bundle->getValidator(); 92 | } else { 93 | $validator = new Validator($this->chronicleUrl, $this->chroniclePublicKey); 94 | } 95 | 96 | // If the SHA256 doesn't match, fail fast. 97 | if ($validator::checkSha256Sum($bundle)) { 98 | $valid = true; 99 | if ($checkEd25519Signature) { 100 | $valid = $validator->checkEd25519Signature($bundle); 101 | if (!$valid) { 102 | $this->markBundleAsBad($bundleIndex, 'Ed25519 signature mismatch'); 103 | } 104 | } 105 | if ($conditionalChronicle && $checkChronicle) { 106 | // Conditional Chronicle check (only on first brush): 107 | $index = array_search($bundle->getFilePath(), $this->unverified, true); 108 | if ($index !== false) { 109 | $validChronicle = $validator->checkChronicleHash($bundle); 110 | if ($validChronicle) { 111 | unset($this->unverified[$index]); 112 | } else { 113 | $this->markBundleAsBad($bundleIndex, 'Chronicle'); 114 | } 115 | } 116 | } elseif ($checkChronicle) { 117 | // Always check Chronicle: 118 | $valid = $valid && $validator->checkChronicleHash($bundle); 119 | } 120 | if ($valid) { 121 | return $bundle; 122 | } 123 | } else { 124 | $this->markBundleAsBad($bundleIndex, 'SHA256 mismatch'); 125 | } 126 | ++$bundleIndex; 127 | } 128 | throw new BundleException('No valid bundles were found in the data directory.'); 129 | } 130 | 131 | /** 132 | * Get an array of all of the Bundles, ordered most-recent to oldest. 133 | * 134 | * No validation is performed automatically. 135 | * 136 | * @param string $customValidator Fully-qualified class name for Validator 137 | * @return array 138 | * 139 | * @throws CertaintyException 140 | */ 141 | public function getAllBundles($customValidator = '') 142 | { 143 | return \array_values( 144 | $this->listBundles( 145 | $customValidator, 146 | $this->trustChannel 147 | ) 148 | ); 149 | } 150 | 151 | /** 152 | * @param string $url 153 | * @param string $publicKey 154 | * @return self 155 | */ 156 | public function setChronicle($url, $publicKey) 157 | { 158 | $this->chronicleUrl = $url; 159 | $this->chroniclePublicKey = $publicKey; 160 | return $this; 161 | } 162 | 163 | /** 164 | * @param int $index 165 | * @param string $reason 166 | * @return void 167 | * @throws EncodingException 168 | * @throws FilesystemException 169 | */ 170 | protected function markBundleAsBad($index = 0, $reason = '') 171 | { 172 | /** @var array> $data */ 173 | $data = $this->loadCaCertsFile(); 174 | $now = (new \DateTime())->format(\DateTime::ATOM); 175 | $data[$index]['bad-bundle'] = 'Marked bad on ' . $now . ' for reason: ' . $reason; 176 | \file_put_contents( 177 | $this->dataDirectory . '/ca-certs.json', 178 | json_encode($data, JSON_PRETTY_PRINT) 179 | ); 180 | } 181 | 182 | /** 183 | * @return array 184 | * @throws EncodingException 185 | * @throws FilesystemException 186 | */ 187 | protected function loadCaCertsFile() 188 | { 189 | if (!\file_exists($this->dataDirectory . '/ca-certs.json')) { 190 | throw new FilesystemException('ca-certs.json not found in data directory.'); 191 | } 192 | if (!\is_readable($this->dataDirectory . '/ca-certs.json')) { 193 | throw new FilesystemException('ca-certs.json is not readable.'); 194 | } 195 | $contents = \file_get_contents($this->dataDirectory . '/ca-certs.json'); 196 | if (!\is_string($contents)) { 197 | throw new FilesystemException('ca-certs.json could not be read.'); 198 | } 199 | /** @var array|bool $data */ 200 | $data = \json_decode($contents, true); 201 | if (!\is_array($data)) { 202 | throw new EncodingException('ca-certs.json is not a valid JSON file.'); 203 | } 204 | return (array) $data; 205 | } 206 | 207 | /** 208 | * List bundles 209 | * 210 | * @param string $customValidator Fully-qualified class name for Validator 211 | * @param string $trustChannel 212 | * @return array 213 | * 214 | * @throws CertaintyException 215 | */ 216 | protected function listBundles( 217 | $customValidator = '', 218 | $trustChannel = Certainty::TRUST_DEFAULT 219 | ) { 220 | $data = $this->loadCaCertsFile(); 221 | $bundles = []; 222 | /** @var array $row */ 223 | foreach ($data as $row) { 224 | if (!isset($row['date'], $row['file'], $row['sha256'], $row['signature'], $row['trust-channel'])) { 225 | // The necessary keys are not defined. 226 | continue; 227 | } 228 | if (!file_exists($this->dataDirectory . '/' . $row['file'])) { 229 | // Skip nonexistent files 230 | continue; 231 | } 232 | if (!empty($row['bad-bundle'])) { 233 | // Bundle marked as "bad" 234 | continue; 235 | } 236 | if ($row['trust-channel'] !== $trustChannel) { 237 | // Only include these. 238 | continue; 239 | } 240 | $key = (int) (\preg_replace('/[^0-9]/', '', $row['date']) . '0000'); 241 | while (isset($bundles[$key])) { 242 | ++$key; 243 | } 244 | $bundles[$key] = new Bundle( 245 | $this->dataDirectory . '/' . $row['file'], 246 | $row['sha256'], 247 | $row['signature'], 248 | !empty($row['custom']) ? $row['custom'] : $customValidator, 249 | isset($row['chronicle']) ? $row['chronicle'] : '', 250 | $trustChannel 251 | ); 252 | } 253 | \krsort($bundles); 254 | return $bundles; 255 | } 256 | 257 | /** 258 | * @return bool 259 | * 260 | * @psalm-suppress RedundantCondition PHP_INT_SIZE is env-specific 261 | */ 262 | protected function sodiumCompatIsntSlow() 263 | { 264 | if (\extension_loaded('sodium')) { 265 | return true; 266 | } 267 | if (\extension_loaded('libsodium')) { 268 | return true; 269 | } 270 | return PHP_INT_SIZE !== 4; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/LocalCACertBuilder.php: -------------------------------------------------------------------------------- 1 | getFilePath(), 83 | $old->getSha256Sum(), 84 | $old->getSignature() 85 | ); 86 | $new->customValidator = $old->getValidator(); 87 | $new->trustChannel = $old->getTrustChannel(); 88 | return $new; 89 | } 90 | 91 | /** 92 | * Load the original bundle's contents. 93 | * 94 | * @return self 95 | * @throws CertaintyException 96 | */ 97 | public function loadOriginal() 98 | { 99 | $this->original = \file_get_contents($this->filePath); 100 | if (!\is_string($this->original)) { 101 | throw new FilesystemException('Could not read contents of CACert file provided.'); 102 | } 103 | return $this; 104 | } 105 | 106 | /** 107 | * Append a CACert file, containing your in-house certificates, to the bundle 108 | * being compiled. 109 | * 110 | * @param string $path 111 | * @return self 112 | * @throws CertaintyException 113 | */ 114 | public function appendCACertFile($path = '') 115 | { 116 | if (!$this->original) { 117 | $this->loadOriginal(); 118 | } 119 | if (!$this->contents) { 120 | $this->contents = $this->original . "\n"; 121 | } 122 | $contents = \file_get_contents($path); 123 | if (!\is_string($contents)) { 124 | throw new FilesystemException('Could not read contents of CACert file provided.'); 125 | } 126 | $this->contents .= $contents . "\n"; 127 | return $this; 128 | } 129 | 130 | /** 131 | * Publish the most recent CACert information to the local Chronicle. 132 | * 133 | * @param string $sha256sum 134 | * @param string $signature 135 | * @return string 136 | * 137 | * @throws CertaintyException 138 | * @throws EncodingException 139 | * @throws InvalidResponseException 140 | * @throws GuzzleException 141 | * @throws \SodiumException 142 | */ 143 | protected function commitToChronicle($sha256sum, $signature) 144 | { 145 | if (empty($this->chronicleUrl) || empty($this->chroniclePublicKey) || empty($this->chronicleClientId)) { 146 | return ''; 147 | } 148 | 149 | $body = \json_encode( 150 | [ 151 | 'repository' => $this->chronicleRepoName, 152 | 'sha256' => $sha256sum, 153 | 'signature' => $signature, 154 | 'time' => (new \DateTime())->format(\DateTime::ATOM) 155 | ], 156 | JSON_PRETTY_PRINT 157 | ); 158 | if (!\is_string($body)) { 159 | throw new EncodingException('Could not build a valid JSON message.'); 160 | } 161 | $signature = \ParagonIE_Sodium_Compat::crypto_sign_detached($body, $this->secretKey); 162 | 163 | $http = Certainty::getGuzzleClient(new Fetch(dirname($this->getFilePath()))); 164 | /** @var Response $response */ 165 | $response = $http->post( 166 | $this->chronicleUrl . '/publish', 167 | [ 168 | 'headers' => [ 169 | Certainty::CHRONICLE_CLIENT_ID => $this->chronicleClientId, 170 | Certainty::ED25519_HEADER => Base64UrlSafe::encode($signature) 171 | ], 172 | 'body' => $body, 173 | ] 174 | ); 175 | 176 | /** @var string $responseBody */ 177 | $responseBody = (string) $response->getBody(); 178 | 179 | /** @var bool $validSig */ 180 | $validSig = false; 181 | 182 | /** @var array $sigHeaders */ 183 | $sigHeaders = $response->getHeader(Certainty::ED25519_HEADER); 184 | 185 | /** @var string $sigLine */ 186 | foreach ($sigHeaders as $sigLine) { 187 | /** @var string $sig */ 188 | $sig = Base64UrlSafe::decode($sigLine); 189 | $validSig = $validSig || \ParagonIE_Sodium_Compat::crypto_sign_verify_detached( 190 | $sig, 191 | $responseBody, 192 | $this->chroniclePublicKey 193 | ); 194 | } 195 | if (!$validSig) { 196 | throw new InvalidResponseException('No valid signature for Chronicle response.'); 197 | } 198 | 199 | /** @var array>|bool $json */ 200 | $json = \json_decode($responseBody, true); 201 | if (!\is_array($json)) { 202 | return ''; 203 | } 204 | if (!isset($json['results'])) { 205 | return ''; 206 | } 207 | if (!isset($json['results']['summaryhash'])) { 208 | return ''; 209 | } 210 | return (string) $json['results']['summaryhash']; 211 | } 212 | 213 | /** 214 | * Get the public key. 215 | * 216 | * @param bool $raw 217 | * @return string 218 | * @throws \SodiumException 219 | */ 220 | public function getPublicKey($raw = false) 221 | { 222 | if ($raw) { 223 | return \ParagonIE_Sodium_Compat::crypto_sign_publickey_from_secretkey($this->secretKey); 224 | } 225 | return Hex::encode( 226 | \ParagonIE_Sodium_Compat::crypto_sign_publickey_from_secretkey($this->secretKey) 227 | ); 228 | } 229 | 230 | /** 231 | * Sign and save the combined CA-Cert file. 232 | * 233 | * @return bool 234 | * @throws CertaintyException 235 | * @throws \SodiumException 236 | * 237 | * @psalm-suppress RedundantConditionGivenDocblockType 238 | */ 239 | public function save() 240 | { 241 | if (!$this->secretKey) { 242 | throw new CertaintyException( 243 | 'No signing key provided.' 244 | ); 245 | } 246 | if (!$this->outputJson) { 247 | throw new CertaintyException( 248 | 'No output file path for JSON data specified.' 249 | ); 250 | } 251 | if (!$this->outputPem) { 252 | throw new CertaintyException( 253 | 'No output file path for combined certificates specified.' 254 | ); 255 | } 256 | /** @var string $return */ 257 | $return = \file_put_contents($this->outputPem, $this->contents); 258 | if (!\is_int($return)) { 259 | throw new FilesystemException('Could not save PEM file.'); 260 | } 261 | $sha256sum = \hash('sha256', $this->contents); 262 | $signature = \ParagonIE_Sodium_Compat::crypto_sign_detached( 263 | $this->contents, 264 | $this->secretKey 265 | ); 266 | 267 | if (\file_exists($this->outputJson)) { 268 | /** @var string $fileData */ 269 | $fileData = \file_get_contents($this->outputJson); 270 | /** @var array|bool $json */ 271 | $json = \json_decode($fileData, true); 272 | if (!\is_array($json)) { 273 | throw new EncodingException('Invalid JSON data stored in file.'); 274 | } 275 | } else { 276 | $json = []; 277 | } 278 | $pieces = \explode('/', \trim($this->outputPem, '/')); 279 | 280 | // Put at the front of the array 281 | $entry = [ 282 | 'custom' => \get_class($this->customValidator), 283 | 'date' => \date('Y-m-d'), 284 | 'file' => \array_pop($pieces), 285 | 'sha256' => $sha256sum, 286 | 'signature' => Hex::encode($signature), 287 | 'trust-channel' => $this->trustChannel 288 | ]; 289 | 290 | $chronicleHash = $this->commitToChronicle($sha256sum, $signature); 291 | if (!empty($chronicleHash)) { 292 | $entry['chronicle'] = $chronicleHash; 293 | } 294 | 295 | \array_unshift($json, $entry); 296 | $jsonSave = \json_encode($json, JSON_PRETTY_PRINT); 297 | if (!\is_string($jsonSave)) { 298 | throw new EncodingException(\json_last_error_msg()); 299 | } 300 | $this->sha256sum = $sha256sum; 301 | $this->signature = $signature; 302 | 303 | $return = \file_put_contents($this->outputJson, $jsonSave); 304 | return \is_int($return); 305 | } 306 | 307 | /** 308 | * Configure the local Chronicle. 309 | * 310 | * @param string $url 311 | * @param string $publicKey 312 | * @param string $clientId 313 | * @param string $repository 314 | * @return self 315 | * @throws CryptoException 316 | */ 317 | public function setChronicle( 318 | $url = '', 319 | $publicKey = '', 320 | $clientId = '', 321 | $repository = 'paragonie/certainty' 322 | ) { 323 | if (Binary::safeStrlen($publicKey) === 64) { 324 | $publicKey = Hex::decode($publicKey); 325 | } elseif (Binary::safeStrlen($publicKey) !== 32) { 326 | throw new CryptoException( 327 | 'Signing secret keys must be SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES bytes long.' 328 | ); 329 | } 330 | $this->chronicleClientId = $clientId; 331 | $this->chroniclePublicKey = $publicKey; 332 | $this->chronicleUrl = $url; 333 | $this->chronicleRepoName = $repository; 334 | return $this; 335 | } 336 | 337 | /** 338 | * Specify the fully qualified class name for your custom 339 | * Validator class. 340 | * 341 | * @param string $string 342 | * @return self 343 | * @throws \TypeError 344 | * 345 | * @psalm-suppress MixedMethodCall 346 | */ 347 | public function setCustomValidator($string = '') 348 | { 349 | if (\class_exists($string)) { 350 | $newClass = new $string(); 351 | if (!($newClass instanceof Validator)) { 352 | throw new \TypeError('Invalid validator class'); 353 | } 354 | $this->customValidator = $newClass; 355 | } 356 | return $this; 357 | } 358 | 359 | /** 360 | * Specify the full path of the file that the combined CA-cert will be 361 | * written to when save() is invoked. 362 | * 363 | * @param string $string 364 | * @return self 365 | */ 366 | public function setOutputPemFile($string = '') 367 | { 368 | $this->outputPem = $string; 369 | return $this; 370 | } 371 | 372 | /** 373 | * Specify the full path of the file that will contain the updated 374 | * sha256/Ed25519 metadata. 375 | * 376 | * @param string $string 377 | * @return self 378 | */ 379 | public function setOutputJsonFile($string = '') 380 | { 381 | $this->outputJson = $string; 382 | return $this; 383 | } 384 | 385 | /** 386 | * Specify the signing key to be used. 387 | * 388 | * @param string $secretKey 389 | * @return self 390 | * @throws CryptoException 391 | */ 392 | public function setSigningKey($secretKey = '') 393 | { 394 | // Handle hex-encoded strings. 395 | if (Binary::safeStrlen($secretKey) === 128) { 396 | $secretKey = Hex::decode($secretKey); 397 | } elseif (Binary::safeStrlen($secretKey) !== 64) { 398 | throw new CryptoException( 399 | 'Signing secret keys must be SODIUM_CRYPTO_SIGN_SECRETKEYBYTES bytes long.' 400 | ); 401 | } 402 | $this->secretKey = $secretKey; 403 | return $this; 404 | } 405 | 406 | /** 407 | * Don't leak secret keys. 408 | * 409 | * @return array 410 | */ 411 | public function __debugInfo() 412 | { 413 | return []; 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/RemoteFetch.php: -------------------------------------------------------------------------------- 1 | url = $url; 65 | 66 | if (\is_null($http)) { 67 | if (\file_exists($this->dataDirectory . '/ca-certs.json')) { 68 | $http = Certainty::getGuzzleClient(new Fetch($this->dataDirectory), $connectTimeout); 69 | } else { 70 | $http = Certainty::getGuzzleClient(new Fetch(__DIR__."/../data/"), $connectTimeout); 71 | } 72 | } 73 | /** @var Client $http */ 74 | $this->http = $http; 75 | 76 | if (\is_null($timeout)) { 77 | /* Default: 24 hours */ 78 | try { 79 | $timeoutObj = new \DateInterval('P01D'); 80 | } catch (\Exception $ex) { 81 | throw new CertaintyException('Invalid DateInterval', 0, $ex); 82 | } 83 | } elseif (\is_string($timeout)) { 84 | try { 85 | $timeoutObj = new \DateInterval($timeout); 86 | } catch (\Exception $ex) { 87 | throw new CertaintyException('Invalid DateInterval', 0, $ex); 88 | } 89 | } elseif ($timeout instanceof \DateInterval) { 90 | $timeoutObj = $timeout; 91 | } else { 92 | throw new \TypeError('Invalid timeout. Expected a DateInterval or string.'); 93 | } 94 | /** @var \DateInterval $timeoutObj */ 95 | $this->cacheTimeout = $timeoutObj; 96 | if (isset($chronicleUrl, $chroniclePublicKey)) { 97 | $this->setChronicle($chronicleUrl, $chroniclePublicKey); 98 | } 99 | } 100 | 101 | /** 102 | * Do we need to fetch updates? 103 | * 104 | * @return bool 105 | */ 106 | public function cacheExpired() 107 | { 108 | if (!\file_exists($this->dataDirectory . '/ca-certs.cache')) { 109 | return true; 110 | } 111 | $cacheTime = \file_get_contents($this->dataDirectory . '/ca-certs.cache'); 112 | if (!\is_string($cacheTime)) { 113 | return true; 114 | } 115 | try { 116 | $expires = (new \DateTime($cacheTime))->add($this->cacheTimeout); 117 | return $expires <= new \DateTime('NOW'); 118 | } catch (\Exception $ex) { 119 | } 120 | return true; 121 | } 122 | 123 | /** 124 | * List bundles 125 | * 126 | * @param string $customValidator 127 | * @param string $trustChannel 128 | * 129 | * @return array 130 | * @throws CertaintyException 131 | */ 132 | protected function listBundles( 133 | $customValidator = '', 134 | $trustChannel = Certainty::TRUST_DEFAULT 135 | ) { 136 | if ($this->cacheExpired()) { 137 | if (!$this->remoteFetchBundles()) { 138 | throw new NetworkException('Could not download bundles'); 139 | } 140 | } 141 | return parent::listBundles($customValidator, $trustChannel); 142 | } 143 | 144 | /** 145 | * This handles the actual HTTP request. 146 | * 147 | * @return bool 148 | * @throws EncodingException 149 | */ 150 | protected function remoteFetchBundles() 151 | { 152 | /** @var Request $request */ 153 | $request = $this->http->get($this->url . '/ca-certs.json'); 154 | /** @var string $body */ 155 | $body = (string) $request->getBody(); 156 | /** @var array|bool $jsonDecoded */ 157 | $jsonDecoded = \json_decode($body, true); 158 | if (!\is_array($jsonDecoded)) { 159 | throw new EncodingException(\json_last_error_msg()); 160 | } 161 | 162 | if (\file_exists($this->dataDirectory . '/ca-certs.json')) { 163 | \rename( 164 | $this->dataDirectory . '/ca-certs.json', 165 | $this->dataDirectory . '/ca-certs-backup-' . \date('YmdHis') . '.json' 166 | ); 167 | } 168 | \file_put_contents($this->dataDirectory . '/ca-certs.json', $body); 169 | 170 | /** 171 | * @var array $item 172 | */ 173 | foreach ($jsonDecoded as $item) { 174 | if (!isset($item['file'])) { 175 | continue; 176 | } 177 | $filename = $item['file']; 178 | if (!\preg_match('#^cacert(\-[0-9]{4}\-[0-9]{2}\-[0-9]{2})?\.pem$#', $filename)) { 179 | // Invalid filename 180 | continue; 181 | } 182 | if (!\file_exists($this->dataDirectory . '/' . $filename)) { 183 | /** @var Request $request */ 184 | $request = $this->http->get($this->url . '/' . $filename); 185 | /** @var string $body */ 186 | $body = (string) $request->getBody(); 187 | \file_put_contents($this->dataDirectory . '/' . $filename, $body); 188 | $this->unverified []= $this->dataDirectory . '/' . $item['file']; 189 | } 190 | } 191 | 192 | return !\is_bool( 193 | \file_put_contents( 194 | $this->dataDirectory . '/ca-certs.cache', 195 | (new \DateTime())->format(\DateTime::ATOM) 196 | ) 197 | ); 198 | } 199 | 200 | /** 201 | * @param \DateInterval $interval 202 | * @return self 203 | */ 204 | public function setCacheTimeout(\DateInterval $interval) 205 | { 206 | $this->cacheTimeout = $interval; 207 | return $this; 208 | } 209 | 210 | /** 211 | * Replace the HTTP client with a new one. 212 | * 213 | * @param Client $client 214 | * @return $this 215 | */ 216 | public function setHttpClient(Client $client) 217 | { 218 | $this->http = $client; 219 | return $this; 220 | } 221 | 222 | /** 223 | * 224 | * @param string $url 225 | * @return self 226 | */ 227 | public function setRemoteSource($url = '') 228 | { 229 | $this->url = $url; 230 | return $this; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Validator.php: -------------------------------------------------------------------------------- 1 | chronicleUrl = $chronicleUrl; 60 | $this->chroniclePublicKey = $chroniclePublicKey; 61 | } 62 | 63 | /** 64 | * Validate SHA256 checksums. 65 | * 66 | * @param Bundle $bundle 67 | * @return bool 68 | */ 69 | public static function checkSha256Sum(Bundle $bundle) 70 | { 71 | $sha256sum = \hash_file('sha256', $bundle->getFilePath(), true); 72 | try { 73 | return SodiumUtil::hashEquals($bundle->getSha256Sum(true), $sha256sum); 74 | } catch (\SodiumException $ex) { 75 | return false; 76 | } 77 | } 78 | 79 | /** 80 | * Check Ed25519 signature for this bundle's contents. 81 | * 82 | * @param Bundle $bundle Which bundle to validate 83 | * @param bool $backupKey Use the backup key? (Only if the primary is compromised.) 84 | * @return bool 85 | * @throws \SodiumException 86 | */ 87 | public function checkEd25519Signature(Bundle $bundle, $backupKey = false) 88 | { 89 | /** @var string $publicKey */ 90 | if ($backupKey) { 91 | $publicKey = Hex::decode((string) static::BACKUP_SIGNING_PUBKEY); 92 | } else { 93 | $publicKey = Hex::decode((string) static::PRIMARY_SIGNING_PUBKEY); 94 | } 95 | 96 | try { 97 | return \ParagonIE_Sodium_Compat::crypto_sign_verify_detached( 98 | $bundle->getSignature(true), 99 | $bundle->getFileContents(), 100 | $publicKey 101 | ); 102 | } catch (CertaintyException $ex) { 103 | return false; 104 | } 105 | /* 106 | return \ParagonIE_Sodium_File::verify( 107 | $bundle->getSignature(true), 108 | $bundle->getFilePath(), 109 | $publicKey 110 | ); 111 | */ 112 | } 113 | 114 | /** 115 | * Is this update checked into a Chronicle? 116 | * 117 | * @param Bundle $bundle 118 | * @return bool 119 | * @throws \Exception 120 | * @throws ConnectException 121 | * @throws EncodingException 122 | * @throws RemoteException 123 | */ 124 | public function checkChronicleHash(Bundle $bundle) 125 | { 126 | if (empty($this->chronicleUrl) && empty($this->chroniclePublicKey)) { 127 | // Custom validator has opted to fail open here. Who are we to dissent? 128 | return true; 129 | } 130 | if (empty($bundle->getChronicleHash())) { 131 | // No chronicle hash? This check fails closed. 132 | return false; 133 | } 134 | // Inherited classes can override this. 135 | /** @var string $chronicleUrl */ 136 | $chronicleUrl = $this->chronicleUrl; 137 | 138 | /** @var string $publicKey */ 139 | $publicKey = Base64UrlSafe::decode($this->chroniclePublicKey); 140 | 141 | /** @var Client $guzzle */ 142 | $guzzle = Certainty::getGuzzleClient(new Fetch(dirname($bundle->getFilePath()))); 143 | 144 | // We could catch the ConnectException, but let's not. 145 | /** @var Response $response */ 146 | $response = $guzzle->get( 147 | \rtrim($chronicleUrl, '/') . 148 | '/lookup/' . 149 | $bundle->getChronicleHash() 150 | ); 151 | 152 | /** @var string $body */ 153 | $body = (string) $response->getBody(); 154 | 155 | // Signature validation phase: 156 | $sigValid = false; 157 | 158 | /** @var array $sigHeaders */ 159 | $sigHeaders = $response->getHeader(Certainty::ED25519_HEADER); 160 | 161 | /** @var string $header */ 162 | foreach ($sigHeaders as $header) { 163 | // Don't catch exceptions here: 164 | $signature = Base64UrlSafe::decode($header); 165 | $sigValid = $sigValid || \ParagonIE_Sodium_Compat::crypto_sign_verify_detached( 166 | (string) $signature, 167 | (string) $body, 168 | (string) $publicKey 169 | ); 170 | } 171 | if (!$sigValid) { 172 | if (static::THROW_MORE_EXCEPTIONS) { 173 | throw new CryptoException('Invalid signature.'); 174 | } 175 | // No valid signatures 176 | return false; 177 | } 178 | /** @var array|bool $json */ 179 | $json = \json_decode($body, true); 180 | if (!\is_array($json)) { 181 | throw new EncodingException('Invalid JSON response'); 182 | } 183 | 184 | /** @var string $status */ 185 | $jsonStatus = (string) $json['status']; 186 | // If the status was successful, 187 | try { 188 | $ok = SodiumUtil::hashEquals('OK', $jsonStatus); 189 | } catch (\SodiumException $ex) { 190 | $ok = false; 191 | } 192 | if (!$ok) { 193 | if (static::THROW_MORE_EXCEPTIONS) { 194 | if (isset($json['error'])) { 195 | /** @var string $jsonError */ 196 | $jsonError = $json['error']; 197 | throw new RemoteException($jsonError); 198 | } 199 | throw new RemoteException('Invalid status returned by the API'); 200 | } 201 | return false; 202 | } 203 | 204 | // Make sure our sha256sum is present somewhere in the results 205 | $hashValid = false; 206 | /** @var array $jsonResults */ 207 | $jsonResults = $json['results']; 208 | foreach ($jsonResults as $results) { 209 | /** @var array $results */ 210 | $hashValid = $hashValid || static::validateChronicleContents($bundle, $results); 211 | } 212 | return $hashValid; 213 | } 214 | 215 | /** 216 | * Actually validates the contents of a Chronicle entry. 217 | * 218 | * @param Bundle $bundle 219 | * @param array $result Chronicle API response (post signature validation) 220 | * @return bool 221 | * @throws CryptoException 222 | * @throws InvalidResponseException 223 | * @throws \SodiumException 224 | */ 225 | protected static function validateChronicleContents(Bundle $bundle, array $result = []) 226 | { 227 | if (!isset($result['signature'], $result['contents'], $result['publickey'])) { 228 | if (static::THROW_MORE_EXCEPTIONS) { 229 | throw new InvalidResponseException('Incomplete data'); 230 | } 231 | // Incomplete data. 232 | return false; 233 | } 234 | /** @var string $publicKey */ 235 | $publicKey = (string) Hex::encode( 236 | (string) Base64UrlSafe::decode($result['publickey']) 237 | ); 238 | if ( 239 | !SodiumUtil::hashEquals( 240 | (string) static::PRIMARY_SIGNING_PUBKEY, 241 | (string) $publicKey 242 | ) 243 | && 244 | !SodiumUtil::hashEquals( 245 | (string) static::BACKUP_SIGNING_PUBKEY, 246 | (string) $publicKey 247 | ) 248 | ) { 249 | // This was not one of our keys. 250 | return false; 251 | } 252 | 253 | // Let's validate the signature. 254 | /** @var string $signature */ 255 | $signature = (string) Base64UrlSafe::decode($result['signature']); 256 | if (!\ParagonIE_Sodium_Compat::crypto_sign_verify_detached( 257 | $signature, 258 | $result['contents'], 259 | Hex::decode($publicKey) 260 | )) { 261 | if (static::THROW_MORE_EXCEPTIONS) { 262 | throw new CryptoException('Invalid signature.'); 263 | } 264 | return false; 265 | } 266 | 267 | // Lazy evaluation: SHA256 hash not present? 268 | if (\strpos($result['contents'], $bundle->getSha256Sum()) === false) { 269 | if (static::THROW_MORE_EXCEPTIONS) { 270 | throw new InvalidResponseException('SHA256 hash not present in response body'); 271 | } 272 | return false; 273 | } 274 | 275 | // Lazy evaluation: Repository name not fouind? 276 | if (\strpos($result['contents'], Certainty::REPOSITORY) === false) { 277 | /** @var string $altRepoName */ 278 | $altRepoName = \json_encode(Certainty::REPOSITORY); 279 | if (\strpos($result['contents'], $altRepoName) === false) { 280 | if (static::THROW_MORE_EXCEPTIONS) { 281 | throw new InvalidResponseException('Repository name not present in response body'); 282 | } 283 | return false; 284 | } 285 | } 286 | 287 | // If we've gotten here, then this Chronicle has our update logged. 288 | return true; 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /test/BundleTest.php: -------------------------------------------------------------------------------- 1 | defaultDir = dirname(__DIR__) . '/data'; 30 | if (!\is_dir($this->defaultDir)) { 31 | \mkdir($this->defaultDir); 32 | } 33 | $this->link = __DIR__ . '/static/symlink-test'; 34 | } 35 | 36 | /** 37 | * @after 38 | */ 39 | public function after() 40 | { 41 | if (\file_exists($this->link)) { 42 | \unlink($this->link); 43 | } 44 | } 45 | 46 | /** 47 | * @covers Bundle::createSymlink() 48 | * @throws CertaintyException 49 | * @throws \SodiumException 50 | */ 51 | public function testCreateSymlink() 52 | { 53 | if (\file_exists($this->link)) { 54 | \unlink($this->link); 55 | } 56 | $test = __DIR__ . '/static/test-file.txt'; 57 | if (!@\symlink($test, $this->link)) { 58 | $this->markTestSkipped('Possibly a read-only file-system (e.g. VirtualBox shared folder). Skipping.'); 59 | return; 60 | } 61 | 62 | $latest = (new Fetch($this->defaultDir))->getLatestBundle(); 63 | 64 | $latest->createSymlink($this->link, true); 65 | 66 | $this->assertSame( 67 | \hash_file('sha384', $this->link), 68 | \hash_file('sha384', $latest->getFilePath()), 69 | 'Symlink creation failed.' 70 | ); 71 | } 72 | 73 | /** 74 | * @covers Bundle::getFilePath() 75 | * @covers Bundle::getSha256Sum() 76 | * @covers Bundle::getSignature() 77 | * @throws CertaintyException 78 | * @throws \SodiumException 79 | */ 80 | public function testGetters() 81 | { 82 | $latest = (new Fetch($this->defaultDir))->getLatestBundle(); 83 | $this->assertTrue(\is_string($latest->getFilePath())); 84 | $this->assertTrue(\is_string($latest->getSha256Sum())); 85 | $this->assertTrue(\is_string($latest->getSignature())); 86 | $this->assertTrue(\is_string($latest->getSha256Sum(true))); 87 | $this->assertTrue(\is_string($latest->getSignature(true))); 88 | 89 | $this->assertSame(64, Binary::safeStrlen($latest->getSha256Sum())); 90 | $this->assertSame(128, Binary::safeStrlen($latest->getSignature())); 91 | $this->assertSame(32, Binary::safeStrlen($latest->getSha256Sum(true))); 92 | $this->assertSame(64, Binary::safeStrlen($latest->getSignature(true))); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/CustomCASupportTest.php: -------------------------------------------------------------------------------- 1 | defaultDir = dirname(__DIR__) . '/data'; 30 | if (!\is_dir($this->defaultDir)) { 31 | \mkdir($this->defaultDir); 32 | } 33 | } 34 | 35 | /** 36 | * @afterClass 37 | */ 38 | public function after() 39 | { 40 | \unlink(__DIR__ . '/static/combined.pem'); 41 | \unlink(__DIR__ . '/static/ca-certs.json'); 42 | } 43 | 44 | /** 45 | * @covers \ParagonIE\Certainty\Tests\CustomValidator 46 | * 47 | * @throws CertaintyException 48 | * @throws CryptoException 49 | * @throws \SodiumException 50 | */ 51 | public function testCustom() 52 | { 53 | $keypair = \ParagonIE_Sodium_Compat::crypto_sign_keypair(); 54 | $secretKey = \ParagonIE_Sodium_Compat::crypto_sign_secretkey($keypair); 55 | $publicKey = \ParagonIE_Sodium_Compat::crypto_sign_publickey($keypair); 56 | 57 | $validator = new CustomValidator(); 58 | $validator::setPublicKey(Hex::encode($publicKey)); 59 | 60 | $latest = (new Fetch($this->defaultDir))->getLatestBundle(); 61 | LocalCACertBuilder::fromBundle($latest) 62 | ->setCustomValidator(CustomValidator::class) 63 | ->setOutputPemFile(__DIR__ . '/static/combined.pem') 64 | ->setOutputJsonFile(__DIR__ . '/static/ca-certs.json') 65 | ->setSigningKey($secretKey) 66 | ->appendCACertFile(__DIR__ . '/static/repeat-globalsign.pem') 67 | ->save(); 68 | 69 | $customLatest = (new Fetch(__DIR__ . '/static'))->getLatestBundle(); 70 | $this->assertSame( 71 | \hash_file('sha256', __DIR__ . '/static/combined.pem'), 72 | $customLatest->getSha256Sum() 73 | ); 74 | $this->assertTrue(file_exists($customLatest->getFilePath()), 'File does not exist'); 75 | 76 | $this->assertTrue($validator->checkEd25519Signature($customLatest)); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /test/CustomValidator.php: -------------------------------------------------------------------------------- 1 | getSignature(true), 41 | $bundle->getFileContents(), 42 | Hex::decode(self::$publicKey) 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/FetchTest.php: -------------------------------------------------------------------------------- 1 | defaultDir = dirname(__DIR__) . '/data'; 29 | if (!\is_dir($this->defaultDir)) { 30 | \mkdir($this->defaultDir); 31 | } 32 | $this->root = __DIR__ . '/static/'; 33 | } 34 | 35 | /** 36 | * @covers \ParagonIE\Certainty\Fetch 37 | */ 38 | public function testEmptyDir() 39 | { 40 | try { 41 | (new Fetch($this->root . 'empty-dir'))->getAllBundles(); 42 | $this->fail('Expected an exception.'); 43 | } catch (\Exception $ex) { 44 | $this->assertSame( 45 | 'ca-certs.json not found in data directory.', 46 | $ex->getMessage() 47 | ); 48 | } 49 | } 50 | 51 | /** 52 | * @covers \ParagonIE\Certainty\Fetch 53 | * @throws 54 | */ 55 | public function testEmptyJson() 56 | { 57 | $this->assertSame( 58 | [], 59 | (new Fetch($this->root . 'data-empty'))->getAllBundles() 60 | ); 61 | } 62 | 63 | /** 64 | * @covers \ParagonIE\Certainty\Fetch 65 | */ 66 | public function testInvalid() 67 | { 68 | try { 69 | (new Fetch($this->root . 'data-invalid'))->getLatestBundle(); 70 | $this->fail('Expected an exception.'); 71 | } catch (\Exception $ex) { 72 | $this->assertSame( 73 | 'No valid bundles were found in the data directory.', 74 | $ex->getMessage() 75 | ); 76 | } 77 | } 78 | 79 | /** 80 | * @throws CertaintyException 81 | * @throws \SodiumException 82 | */ 83 | public function testLiveDataDir() 84 | { 85 | $this->assertInstanceOf( 86 | Bundle::class, 87 | (new Fetch($this->defaultDir))->getLatestBundle(), 88 | 'The live data directory has no valid signatures.' 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/RemoteFetchTest.php: -------------------------------------------------------------------------------- 1 | ranOnce = true; 26 | $this->dir = __DIR__ . '/static/data-remote'; 27 | if (!\is_dir($this->dir)) { 28 | \mkdir($this->dir); 29 | } 30 | } 31 | 32 | /** 33 | * @after 34 | */ 35 | public function after() 36 | { 37 | if (file_exists($this->dir . '/ca-certs.json')) { 38 | \unlink($this->dir . '/ca-certs.json'); 39 | } 40 | if (file_exists($this->dir . '/ca-certs.cache')) { 41 | \unlink($this->dir . '/ca-certs.cache'); 42 | } 43 | foreach(\glob($this->dir . '/*.pem') as $f) { 44 | $real = \realpath($f); 45 | if (\strpos($real, $this->dir) === 0) { 46 | \unlink($f); 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * @covers \ParagonIE\Certainty\RemoteFetch 53 | * @throws CertaintyException 54 | * @throws \SodiumException 55 | */ 56 | public function testRemoteFetch() 57 | { 58 | if (!$this->ranOnce) { 59 | $this->before(); 60 | } 61 | $this->assertFalse(\file_exists($this->dir . '/ca-certs.json')); 62 | $fetch = new RemoteFetch($this->dir); 63 | Composer::dos2unixAll($this->dir); 64 | $fetch->getLatestBundle(); 65 | $this->assertTrue(\file_exists($this->dir . '/ca-certs.json')); 66 | 67 | // Force a cache expiration 68 | \file_put_contents( 69 | $this->dir . '/ca-certs.cache', 70 | (new \DateTime()) 71 | ->sub(new \DateInterval('PT06H')) 72 | ->format(\DateTime::ATOM) 73 | ); 74 | $fetch->setCacheTimeout(new \DateInterval('PT01M')); 75 | $this->assertTrue($fetch->cacheExpired()); 76 | 77 | 78 | $latest = $fetch->getLatestBundle(); 79 | file_put_contents($latest->getFilePath(), ' corrupt', FILE_APPEND); 80 | (new RemoteFetch($this->dir))->getLatestBundle(); 81 | 82 | $cacerts = json_decode(file_get_contents($this->dir . '/ca-certs.json'), true); 83 | $this->assertTrue(!empty($cacerts[0]['bad-bundle'])); 84 | } 85 | 86 | public function testLatest() 87 | { 88 | if (!$this->ranOnce) { 89 | $this->before(); 90 | } 91 | $files = array(); 92 | $dir = dirname(__DIR__) . '/data'; 93 | foreach (glob($dir . '/cacert-*.pem') as $file) { 94 | $pieces = explode('/', $file); 95 | $filename = array_pop($pieces); 96 | if (preg_match('/^cacert\-(\d+)\-(\d+)\-(\d+).pem$/', $filename, $m)) { 97 | $i = (int) ($m[1] . $m[2] . $m[3]); 98 | $files[$i] = $file; 99 | } 100 | } 101 | krsort($files); 102 | $path = array_shift($files); 103 | 104 | $fetch = new RemoteFetch($dir); 105 | $this->assertSame( 106 | $path, 107 | $fetch->getLatestBundle()->getFilePath() 108 | ); 109 | $this->assertSame( 110 | hash_file('sha256', $path), 111 | $fetch->getLatestBundle()->getSha256Sum() 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/ValidatorTest.php: -------------------------------------------------------------------------------- 1 | validator = new Validator(); 36 | $this->bundle = new Bundle( 37 | __DIR__ . '/static/test-file.txt', 38 | '7b8eb84bbaa30c648f3fc9b28d720ab247314032cc4c1f8ad7bd13f7eb2a40a8', 39 | '32867d753a1887bb46358bbe9bf6cf50b4b0d1927e9cfa5fdb71a2f2f88a6540017277f2a395a272584385ec5a00fcc0a3713ec80aeb4574edb6340e76379a0f' 40 | ); 41 | $this->dir = __DIR__ . '/static/data-valid'; 42 | if (!\is_dir($this->dir)) { 43 | \mkdir($this->dir); 44 | } 45 | $this->dir2 = __DIR__ . '/static/data-valid2'; 46 | if (!\is_dir($this->dir2)) { 47 | \mkdir($this->dir2); 48 | } 49 | } 50 | 51 | /** 52 | * @afterClass 53 | */ 54 | public function after() 55 | { 56 | foreach ([$this->dir, $this->dir2] as $d) { 57 | if (\file_exists($d . '/ca-certs.json')) { 58 | \unlink($d . '/ca-certs.json'); 59 | } 60 | if (\file_exists($d . '/ca-certs.cache')) { 61 | \unlink($d . '/ca-certs.cache'); 62 | } 63 | foreach (\glob($d . '/*.pem') as $f) { 64 | $real = \realpath($f); 65 | if (\strpos($real, $d) === 0) { 66 | \unlink($f); 67 | } 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * @covers Validator::checkSha256Sum() 74 | */ 75 | public function testSha256sum() 76 | { 77 | $this->assertTrue( 78 | $this->validator->checkSha256Sum($this->bundle), 79 | 'Sha256sum of test case is wrong.' 80 | ); 81 | } 82 | 83 | /** 84 | * @throws CertaintyException 85 | * @throws \SodiumException 86 | */ 87 | public function testChronicle() 88 | { 89 | $this->markTestSkipped('this is flaky due to the replica being out'); 90 | $remoteFetch = new RemoteFetch($this->dir); 91 | $remoteFetch2 = new RemoteFetch($this->dir2); 92 | $remoteFetch2->setChronicle( 93 | 'https://php-chronicle-replica.pie-hosted.com/chronicle/replica/_vi6Mgw6KXBSuOFUwYA2H2GEPLawUmjqFJbCCuqtHzGZ', 94 | 'MoavD16iqe9-QVhIy-ewD4DMp0QRH-drKfwhfeDAUG0=' 95 | ); 96 | 97 | $this->assertSame( 98 | $remoteFetch->getLatestBundle()->getSha256Sum(), 99 | $remoteFetch2->getLatestBundle()->getSha256Sum() 100 | ); 101 | 102 | $this->assertSame( 103 | $remoteFetch->getLatestBundle()->getChronicleHash(), 104 | $remoteFetch2->getLatestBundle()->getChronicleHash() 105 | ); 106 | } 107 | 108 | /** 109 | * @covers Validator::checkEd25519Signature() 110 | * @throws \SodiumException 111 | */ 112 | public function testEd25519() 113 | { 114 | $this->assertTrue($this->validator->checkEd25519Signature($this->bundle)); 115 | $this->assertFalse($this->validator->checkEd25519Signature($this->bundle, true)); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /test/static/data-empty/ca-certs.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/static/data-invalid/ca-certs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "date": "2017-09-20", 4 | "file": "cacert-2017-09-20.pem", 5 | "sha256": "cc7c9e2d259e20b72634371b146faec98df150d18dd9da9ad6ef0b2deac2a9d3", 6 | "signature": "59687e4a471591fd09f2e9d84a595fd37618eadf0c4a3eef56feaca10100a175da520dbd068473189af3775ca91e1f48eb55155accb9d5c6137d25b6a9e93103", 7 | "trust-channel": "Mozilla" 8 | } 9 | ] -------------------------------------------------------------------------------- /test/static/data-remote/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/certainty/852c1a731f2e60897c9f797c9dd04b9b7474b3ad/test/static/data-remote/.gitkeep -------------------------------------------------------------------------------- /test/static/data-valid/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/certainty/852c1a731f2e60897c9f797c9dd04b9b7474b3ad/test/static/data-valid/.gitkeep -------------------------------------------------------------------------------- /test/static/data-valid2/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/certainty/852c1a731f2e60897c9f797c9dd04b9b7474b3ad/test/static/data-valid2/.gitkeep -------------------------------------------------------------------------------- /test/static/empty-dir/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/certainty/852c1a731f2e60897c9f797c9dd04b9b7474b3ad/test/static/empty-dir/.gitkeep -------------------------------------------------------------------------------- /test/static/repeat-globalsign.pem: -------------------------------------------------------------------------------- 1 | GlobalSign Root CA 2 | ================== 3 | -----BEGIN CERTIFICATE----- 4 | MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx 5 | GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds 6 | b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV 7 | BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD 8 | VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa 9 | DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc 10 | THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb 11 | Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP 12 | c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX 13 | gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV 14 | HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF 15 | AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj 16 | Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG 17 | j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH 18 | hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC 19 | X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /test/static/test-file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/certainty/852c1a731f2e60897c9f797c9dd04b9b7474b3ad/test/static/test-file.txt --------------------------------------------------------------------------------