From 9e5bdf63074dbc0c5512ab6379e71e644fb3a7f9 Mon Sep 17 00:00:00 2001 From: Paragon Initiative Enterprises Date: Sun, 22 Oct 2017 18:02:10 -0400 Subject: [PATCH] Initial commit --- .gitignore | 3 + LICENSE | 18 + README.md | 66 + composer.json | 27 + data/ca-certs.json | 26 + data/cacert-2016-11-02.pem | 4066 ++++++++++++++++++++++++++++++++ data/cacert-2017-01-18.pem | 4043 +++++++++++++++++++++++++++++++ data/cacert-2017-06-07.pem | 3955 +++++++++++++++++++++++++++++++ data/cacert-2017-09-20.pem | 3646 ++++++++++++++++++++++++++++ phpunit.xml.dist | 25 + psalm.xml | 28 + src/Bundle.php | 91 + src/Fetch.php | 84 + src/Validator.php | 43 + test/FetchTest.php | 73 + test/ValidatorTest.php | 41 + test/static/empty-dir/.gitkeep | 0 test/static/test-file.txt | Bin 0 -> 4096 bytes 18 files changed, 16235 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 data/ca-certs.json create mode 100644 data/cacert-2016-11-02.pem create mode 100644 data/cacert-2017-01-18.pem create mode 100644 data/cacert-2017-06-07.pem create mode 100644 data/cacert-2017-09-20.pem create mode 100644 phpunit.xml.dist create mode 100644 psalm.xml create mode 100644 src/Bundle.php create mode 100644 src/Fetch.php create mode 100644 src/Validator.php create mode 100644 test/FetchTest.php create mode 100644 test/ValidatorTest.php create mode 100644 test/static/empty-dir/.gitkeep create mode 100644 test/static/test-file.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5485d5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/local +/composer.lock +/vendor \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81a2fa9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +/* + * ISC License + * + * Copyright (c) 2017 + * Paragon Initiative Enterprises + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ diff --git a/README.md b/README.md new file mode 100644 index 0000000..75c6a1a --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Certainty - CA-Cert Automation for PHP Projects + +[![Build Status](https://travis-ci.org/paragonie/certainty.svg?branch=master)](https://travis-ci.org/paragonie/certainty) +[![Latest Stable Version](https://poser.pugx.org/paragonie/certainty/v/stable)](https://packagist.org/packages/paragonie/certainty) +[![Latest Unstable Version](https://poser.pugx.org/paragonie/certainty/v/unstable)](https://packagist.org/packages/paragonie/certainty) +[![License](https://poser.pugx.org/paragonie/certainty/license)](https://packagist.org/packages/paragonie/certainty) +[![Downloads](https://img.shields.io/packagist/dt/paragonie/certainty.svg)](https://packagist.org/packages/paragonie/certainty) + +Automate your PHP projects' cacert.pem management. + +**Requires PHP 5.6 or newer.** + +### Motivation + +Many HTTP libraries require you to specify a file path to a `cacert.pem` file in order to use TLS correctly. +Omitting this file means either disabling certificate validation entirely (which enables trivial man-in-the-middle +exploits), connection failures, or hoping that your library falls back safely to the operating system's bundle. + +In short, the possible outcomes are (from best to worst) are as follows: + +1. Specify a cacert file, and you get to enjoy TLS as it was intended. (Secure.) +2. Omit a cacert file, and the OS maybe bails you out. (Uncertain.) +3. Omit a cacert file, and it fails closed. (Connection failed. Angry customers.) +4. Omit a cacert file, and it fails open. (Data compromised. Hurt customers. Expensive legal proceedings.) + +Obviously, the first outcome is optimal. So we built *Certainty* to make it easier to ensure open +source projects do this. + +## Installing Certainty + +From Composer: + +```bash +composer require paragonie/certainty:dev-master +``` + +Due to the nature of CA Certificates, you want to use `dev-master`. If a major CA gets compromised and +their certificates are revoked, you don't want to continue trusting these certificates. + +## What Certainty Does + +Certainty maintains a repository of all the `cacert.pem` files, along with a + +## Using Certainty + +### Create Symlink to Latest CACert + +After running `composer update`, simply run a script that excecutes the following. + +```php +getLatestBundle() + ->createSymlink('/path/to/cacert.pem'); +``` + +Then, make sure your HTTP library is using the cacert path provided. For example, using cURL: + +```php + + + + + ./test + + + + + ./src + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..1643f4d --- /dev/null +++ b/psalm.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bundle.php b/src/Bundle.php new file mode 100644 index 0000000..7ae2ec9 --- /dev/null +++ b/src/Bundle.php @@ -0,0 +1,91 @@ +filePath = $filePath; + $this->sha256sum = $sha256sum; + $this->signature = $signature; + } + + /** + * Create a symbolic link that poinst to this bundle? + * + * @param string $destination + * @param bool $unlinkIfExists + * @return bool + * @throws \Exception + */ + public function createSymlink($destination = '', $unlinkIfExists = false) + { + if (\file_exists($destination)) { + if ($unlinkIfExists) { + \unlink($destination); + } else { + throw new \Exception('Destination already exists.'); + } + } + return \symlink($this->filePath, $destination); + } + + /** + * @return string + */ + public function getFilePath() + { + return $this->filePath; + } + + /** + * @param bool $raw + * @return string + */ + public function getSha256Sum($raw = false) + { + if ($raw) { + return Hex::decode($this->sha256sum); + } + return $this->sha256sum; + } + + /** + * @param bool $raw + * @return string + */ + public function getSignature($raw = false) + { + if ($raw) { + return Hex::decode($this->signature); + } + return $this->signature; + } +} diff --git a/src/Fetch.php b/src/Fetch.php new file mode 100644 index 0000000..825239e --- /dev/null +++ b/src/Fetch.php @@ -0,0 +1,84 @@ +dataDirectory = $dataDir; + } else { + $this->dataDirectory = \dirname(__DIR__) . '/data'; + } + } + + /** + * @return Bundle + * @throws \Exception + */ + public function getLatestBundle() + { + foreach ($this->listBundles() as $bundle) { + if (Validator::checkSha256Sum($bundle) && Validator::checkEd25519Signature($bundle)) { + return $bundle; + } + } + throw new \Exception('No valid bundles were found in the data directory.'); + } + + /** + * @return array + */ + public function getAllBundles() + { + return \array_values($this->listBundles()); + } + + /** + * @return array + * @throws \Exception + */ + protected function listBundles() + { + if (!\file_exists($this->dataDirectory . '/ca-certs.json')) { + throw new \Exception('ca-certs.json not found in data directory.'); + } + if (!\is_readable($this->dataDirectory . '/ca-certs.json')) { + throw new \Exception('ca-certs.json is not readable.'); + } + $contents = \file_get_contents($this->dataDirectory . '/ca-certs.json'); + if (!\is_string($contents)) { + throw new \Exception('ca-certs.json could not be read.'); + } + $data = \json_decode($contents, true); + if (!\is_array($data)) { + throw new \Exception('ca-certs.json is not a valid JSON file.'); + } + $bundles = []; + foreach ($data as $row) { + if (!isset($row['date'], $row['file'], $row['sha256'], $row['signature'])) { + // No + continue; + } + $key = (int) (\preg_replace('/[^0-9]/', '', $row['date'])); + $bundles[$key] = new Bundle( + $this->dataDirectory . '/' . $row['file'], + $row['sha256'], + $row['signature'] + ); + } + \krsort($bundles); + return $bundles; + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..b3949e4 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,43 @@ +getFilePath(), true); + return \hash_equals($bundle->getSha256Sum(true), $sha256sum); + } + + /** + * @param Bundle $bundle Which bundle to validate + * @param bool $backupKey Use the backup key? (Only if the primary is compromsied.) + * @return bool + */ + public static function checkEd25519Signature(Bundle $bundle, $backupKey = false) + { + if ($backupKey) { + $publicKey = Hex::decode(static::BACKUP_SIGNING_PUBKEY); + } else { + $publicKey = Hex::decode(static::PRIMARY_SIGNING_PUBKEY); + } + return \ParagonIE_Sodium_File::verify( + $bundle->getSignature(true), + $bundle->getFilePath(), + $publicKey + ); + } +} diff --git a/test/FetchTest.php b/test/FetchTest.php new file mode 100644 index 0000000..9796c95 --- /dev/null +++ b/test/FetchTest.php @@ -0,0 +1,73 @@ +root = __DIR__ . '/static/'; + } + + /** + * @covers Fetch + */ + public function testEmptyDir() + { + try { + (new Fetch($this->root . 'empty-dir'))->getAllBundles(); + $this->fail('Expected an exception.'); + } catch (\Exception $ex) { + $this->assertSame( + 'ca-certs.json not found in data directory.', + $ex->getMessage() + ); + } + } + + /** + * @covers Fetch + */ + public function testEmptyJson() + { + $this->assertSame( + [], + (new Fetch($this->root . 'data-empty'))->getAllBundles() + ); + } + + /** + * @covers Fetch + */ + public function testInvalid() + { + try { + (new Fetch($this->root . 'data-invalid'))->getLatestBundle(); + $this->fail('Expected an exception.'); + } catch (\Exception $ex) { + $this->assertSame( + 'No valid bundles were found in the data directory.', + $ex->getMessage() + ); + } + } + + /** + * + */ + public function testLiveDataDir() + { + $this->assertInstanceOf( + Bundle::class, + (new Fetch())->getLatestBundle(), + 'The live data directory has no valid signatures.' + ); + } +} diff --git a/test/ValidatorTest.php b/test/ValidatorTest.php new file mode 100644 index 0000000..c08222a --- /dev/null +++ b/test/ValidatorTest.php @@ -0,0 +1,41 @@ +bundle = new Bundle( + __DIR__ . '/static/test-file.txt', + '7b8eb84bbaa30c648f3fc9b28d720ab247314032cc4c1f8ad7bd13f7eb2a40a8', + '456729f1ea34ea0712476e82a904664ead413157291ec47d7c1595795032f004cf6e5532cd8f80d54a8cb86e92dac71367677f110daba1cc2a1bbbcef4ef1a04' + ); + } + + /** + * @covers Validator::checkSha256Sum() + */ + public function testSha256sum() + { + $this->assertTrue(Validator::checkSha256Sum($this->bundle), 'Sha256sum of test case is wrong.'); + } + + /** + * @covers Validator::checkEd25519Signature() + */ + public function testEd25519() + { + $this->assertTrue(Validator::checkEd25519Signature($this->bundle)); + $this->assertFalse(Validator::checkEd25519Signature($this->bundle, true)); + } +} diff --git a/test/static/empty-dir/.gitkeep b/test/static/empty-dir/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/static/test-file.txt b/test/static/test-file.txt new file mode 100644 index 0000000000000000000000000000000000000000..7700f6470f717f90d7bc92131cb8462078771fd9 GIT binary patch literal 4096 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 literal 0 HcmV?d00001