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 0000000..7700f64 Binary files /dev/null and b/test/static/test-file.txt differ