From 45311ab44b8f6e457b8b11538377236d8dc96d96 Mon Sep 17 00:00:00 2001 From: Paragon Initiative Enterprises Date: Tue, 31 Oct 2017 12:26:27 -0400 Subject: [PATCH] Begin Chronicle integration. --- .gitignore | 1 + data/ca-certs.json | 4 ++ src/Bundle.php | 18 +++++- src/Certainty.php | 30 +++++++++ src/Fetch.php | 35 +++++++---- src/LocalCACertBuilder.php | 121 ++++++++++++++++++++++++++++++++++++- src/RemoteFetch.php | 5 +- src/Validator.php | 118 ++++++++++++++++++++++++++++++++++++ 8 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 src/Certainty.php diff --git a/.gitignore b/.gitignore index b414035..9387617 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.idea +/local/dev-testing /local/keys.json /composer.lock /vendor \ No newline at end of file diff --git a/data/ca-certs.json b/data/ca-certs.json index 35f1d47..35feacb 100644 --- a/data/ca-certs.json +++ b/data/ca-certs.json @@ -1,23 +1,27 @@ [ { + "chronicle": "pTmauXUmQrr2BN8uJX3mCKk0GSokHl61qHUrXsUFziE=", "date": "2017-09-20", "file": "cacert-2017-09-20.pem", "sha256": "435ac8e816f5c10eaaf228d618445811c16a5e842e461cb087642b6265a36856", "signature": "9007f7f0411d6d1f1f5136b247375e614a24216e4fc6c9d6d12642f986f3d45cea3daa2a19705579845a37488ce679f78a1b890d24da6157a2e9894d351fa70a" }, { + "chronicle": "tUgevWspRLIznoIx0G6XRMucU4XJSBV3qYZEPWovZV8=", "date": "2017-06-07", "file": "cacert-2017-06-07.pem", "sha256": "e78c8ab7b4432bd466e64bb942d988f6c0ac91cd785017e465bdc96d42fe9dd0", "signature": "ed1fc6af6827cac04da6caf40deffeadc2a19feba5281d7cf92d1563ad9af49b8d25bf459e5d5acec0fe723394f88f240d4b716e52f3835f9ab3caa3cc85380e" }, { + "chronicle": "vkGXMsFKfLlQBh3uYUQbLFdXKgQe5huy-pZZ-9cIDJ4=", "date": "2017-01-18", "file": "cacert-2017-01-18.pem", "sha256": "e62a07e61e5870effa81b430e1900778943c228bd7da1259dd6a955ee2262b47", "signature": "0f217f29c9711cd74ed60f0f6da886c166969945546a6e75e6fa8cf5ea87387f5fce1e1ced71af46095d2dd411a3676ec1aa40927cc0d47a91adaeef965b240b" }, { + "chronicle": "5dmkHGPHwnIOawjmnrbXBIXap92GqF2aDraASC12AVM=", "date": "2016-11-02", "file": "cacert-2016-11-02.pem", "sha256": "cc7c9e2d259e20b72634371b146faec98df150d18dd9da9ad6ef0b2deac2a9d3", diff --git a/src/Bundle.php b/src/Bundle.php index 3f33d21..567e72f 100644 --- a/src/Bundle.php +++ b/src/Bundle.php @@ -12,6 +12,9 @@ */ class Bundle { + /** @var string $chronicleHash */ + protected $chronicleHash = ''; + /** @var Validator $customValidator */ protected $customValidator; @@ -31,17 +34,20 @@ class Bundle * @param string $sha256sum Hex-encoded string * @param string $signature Hex-encoded string * @param string $customValidator Fully-Qualified Class Name + * @param string $chronicleHash Chronicle Hash * @throws \TypeError */ public function __construct( $filePath = '', $sha256sum = '', $signature = '', - $customValidator = '' + $customValidator = '', + $chronicleHash = '' ) { $this->filePath = $filePath; $this->sha256sum = $sha256sum; $this->signature = $signature; + $this->chronicleHash = $chronicleHash; $newClass = new Validator(); if (!empty($customValidator)) { if (\class_exists($customValidator)) { @@ -112,6 +118,16 @@ public function getSignature($raw = false) return $this->signature; } + /** + * Get the Chronicle hash (always base64url-encoded) + * + * @return string + */ + public function getChronicleHash() + { + return $this->chronicleHash; + } + /** * Get the custom validator (assuming one is defined). * diff --git a/src/Certainty.php b/src/Certainty.php new file mode 100644 index 0000000..e32e124 --- /dev/null +++ b/src/Certainty.php @@ -0,0 +1,30 @@ + $fetch->getLatestBundle()->getFilePath() + ] + ); + } +} diff --git a/src/Fetch.php b/src/Fetch.php index 63195be..af4d557 100644 --- a/src/Fetch.php +++ b/src/Fetch.php @@ -8,6 +8,7 @@ class Fetch { const CHECK_SIGNATURE_BY_DEFAULT = false; + const CHECK_CHRONICLE_BY_DEFAULT = false; /** @var string $dataDirectory */ protected $dataDirectory = ''; @@ -33,29 +34,38 @@ public function __construct($dataDir = '') * is expected. Optionally checks the Ed25519 signature. * * @param bool|null $checkEd25519Signature + * @param bool|null $checkChronicle * @return Bundle * @throws \Exception */ - public function getLatestBundle($checkEd25519Signature = null) + public function getLatestBundle($checkEd25519Signature = null, $checkChronicle = null) { if (\is_null($checkEd25519Signature)) { $checkEd25519Signature = (bool) static::CHECK_SIGNATURE_BY_DEFAULT; } + if (\is_null($checkChronicle)) { + $checkChronicle = (bool) static::CHECK_CHRONICLE_BY_DEFAULT; + } + /** @var Bundle $bundle */ foreach ($this->listBundles() as $bundle) { if ($bundle->hasCustom()) { $validator = $bundle->getValidator(); - if ($validator::checkSha256Sum($bundle)) { - if (!$checkEd25519Signature) { - return $bundle; - } elseif ($validator::checkEd25519Signature($bundle)) { - return $bundle; - } + } else { + $validator = new Validator(); + } + + // If the SHA256 doesn't match, fail fast. + if ($validator::checkSha256Sum($bundle)) { + /** @var bool $valid */ + $valid = true; + if ($checkEd25519Signature) { + $valid = $valid && $validator::checkEd25519Signature($bundle); } - } elseif (Validator::checkSha256Sum($bundle)) { - if (!$checkEd25519Signature) { - return $bundle; - } elseif (Validator::checkEd25519Signature($bundle)) { + if ($checkChronicle) { + $valid = $valid && $validator::checkChronicleHash($bundle); + } + if ($valid) { return $bundle; } } @@ -113,7 +123,8 @@ protected function listBundles($customValidator = '') $this->dataDirectory . '/' . $row['file'], $row['sha256'], $row['signature'], - !empty($row['custom']) ? $row['custom'] : $customValidator + !empty($row['custom']) ? $row['custom'] : $customValidator, + isset($row['chronicle']) ? $row['chronicle'] : '' ); } \krsort($bundles); diff --git a/src/LocalCACertBuilder.php b/src/LocalCACertBuilder.php index 4848da3..3e74e79 100644 --- a/src/LocalCACertBuilder.php +++ b/src/LocalCACertBuilder.php @@ -1,6 +1,7 @@ chronicleUrl) || empty($this->chroniclePublicKey)) { + return ''; + } + + /** @var string $body */ + $body = \json_encode( + [ + 'repository' => $this->chronicleRepoName, + 'sha256' => $sha256sum, + 'signature' => $signature, + 'time' => (new \DateTime())->format(\DateTime::ATOM) + ], + JSON_PRETTY_PRINT + ); + if (!\is_string($body)) { + throw new \Exception('Could not build a valid JSON message.'); + } + $signature = \ParagonIE_Sodium_Compat::crypto_sign_detached($body, $this->secretKey); + + $http = Certainty::getGuzzleClient(); + $response = $http->post( + $this->chronicleUrl . '/publish', + [ + 'headers' => [ + Certainty::ED25519_HEADER => Base64UrlSafe::encode($signature) + ], + 'body' => $body, + ] + ); + + $responseBody = (string) $response->getBody(); + $validSig = false; + foreach ($response->getHeader(Certainty::ED25519_HEADER) as $sigLine) { + $sig = Base64UrlSafe::decode($sigLine); + $validSig = $validSig || \ParagonIE_Sodium_Compat::crypto_sign_verify_detached( + $sig, + $responseBody, + $this->chroniclePublicKey + ); + } + if (!$validSig) { + throw new \Exception('Invalid response from Chronicle'); + } + + /** @var array $json */ + $json = \json_decode($responseBody, true); + if (!\is_array($json)) { + return ''; + } + if (!isset($json['results'])) { + return ''; + } + if (!isset($json['results']['summaryhash'])) { + return ''; + } + return (string) $json['results']['summaryhash']; + } + /** * Get the public key. * @@ -145,13 +229,20 @@ public function save() $pieces = \explode('/', \trim($this->outputPem, '/')); // Put at the front of the array - \array_unshift($json, [ + $entry = [ 'custom' => \get_class($this->customValidator), 'date' => \date('Y-m-d'), 'file' => \array_pop($pieces), 'sha256' => $sha256sum, 'signature' => Hex::encode($signature) - ]); + ]; + + $chronicleHash = $this->commitToChronicle($sha256sum, $signature); + if (!empty($chronicleHash)) { + $entry['chronicle'] = $chronicleHash; + } + + \array_unshift($json, $entry); $jsonSave = \json_encode($json, JSON_PRETTY_PRINT); if (!\is_string($jsonSave)) { throw new \Exception(\json_last_error_msg()); @@ -163,6 +254,32 @@ public function save() return \is_int($return); } + /** + * Configure the local Chronicle. + * + * @param string $url + * @param string $publicKey + * @param string $repository + * @return $this + * @throws \Exception + */ + public function setChronicle($url = '', $publicKey = '', $repository = 'paragonie/certainty') + { + if (\ParagonIE_Sodium_Core_Util::strlen($publicKey) === 64) { + /** @var string $publicKey */ + $publicKey = Hex::decode($publicKey); + if (!\is_string($publicKey)) { + throw new \Exception('Signing secret keys must be SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES bytes long.'); + } + } elseif (\ParagonIE_Sodium_Core_Util::strlen($publicKey) !== 32) { + throw new \Exception('Signing secret keys must be SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES bytes long.'); + } + $this->chroniclePublicKey = $publicKey; + $this->chronicleUrl = $url; + $this->chronicleRepoName = $repository; + return $this; + } + /** * Specify the fully qualified class name for your custom * Validator class. diff --git a/src/RemoteFetch.php b/src/RemoteFetch.php index d8c8605..315fa09 100644 --- a/src/RemoteFetch.php +++ b/src/RemoteFetch.php @@ -13,6 +13,7 @@ class RemoteFetch extends Fetch { const CHECK_SIGNATURE_BY_DEFAULT = true; + const CHECK_CHRONICLE_BY_DEFAULT = true; const DEFAULT_URL = 'https://raw.githubusercontent.com/paragonie/certainty/master/data/'; /** @var \DateInterval */ @@ -44,9 +45,7 @@ public function __construct( if (\is_null($http)) { if (\file_exists($this->dataDirectory . '/ca-certs.json')) { - $http = new Client([ - 'verify' => (new Fetch($this->dataDirectory))->getLatestBundle()->getFilePath() - ]); + $http = Certainty::getGuzzleClient(new Fetch($this->dataDirectory)); } else { $http = new Client(); } diff --git a/src/Validator.php b/src/Validator.php index 8f4a876..6e13247 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -1,5 +1,6 @@ getChronicleHash())) { + return false; + } + $chronicleUrl = static::CHRONICLE_URL; + $publicKey = Base64UrlSafe::decode(static::CHRONICLE_PUBKEY); + $guzzle = Certainty::getGuzzleClient(); + + $response = $guzzle->get( + \rtrim($chronicleUrl, '/') . + '/lookup/' . + $bundle->getChronicleHash() + ); + + /** @var string $body */ + $body = (string) $response->getBody(); + + // Signature validation phase: + $sigValid = false; + foreach ($response->getHeader(Certainty::ED25519_HEADER) as $header) { + // Don't catch exceptions here: + /** @var string $signature */ + $signature = Base64UrlSafe::decode($header); + if (!\is_string($signature)) { + throw new \TypeError('Signature invalid'); + } + $sigValid = $sigValid || \ParagonIE_Sodium_Compat::crypto_sign_verify_detached( + $signature, + $body, + $publicKey + ); + } + if (!$sigValid) { + // No valid signatures + return false; + } + $json = \json_decode($body, true); + if (!\is_array($json)) { + throw new \TypeError('Invalid JSON response'); + } + + // If the status was successful, + if (!\hash_equals('OK', $json['status'])) { + if (isset($json['error'])) { + throw new \Exception($json['error']); + } + return false; + } + + // Make sure our sha256sum is present somewhere in the results + $hashValid = false; + foreach ($json['results'] as $results) { + $hashValid = $hashValid || static::validateChronicleContents($bundle, $results); + } + return $hashValid; + } + + /** + * Actually validates the contents of a Chronicle entry. + * + * @param Bundle $bundle + * @param array $result + * @return bool + */ + protected static function validateChronicleContents(Bundle $bundle, array $result = []) + { + if (!isset($result['signature'], $result['contents'], $result['publickey'])) { + // Incomplete data. + return false; + } + $publicKey = Hex::encode(Base64UrlSafe::decode($result['publickey'])); + if ( + !\hash_equals(static::PRIMARY_SIGNING_PUBKEY, $publicKey) + && + !\hash_equals(static::BACKUP_SIGNING_PUBKEY, $publicKey) + ) { + // This was not one of our keys. + return false; + } + + // Let's validate the signature. + $signature = Base64UrlSafe::decode($result['signature']); + if (!\ParagonIE_Sodium_Compat::crypto_sign_verify_detached( + $signature, + $result['contents'], + $publicKey + )) { + return false; + } + + // Lazy evaluation: SHA256 hash not present? + if (\strpos($result['contents'], $bundle->getSha256Sum()) === false) { + return false; + } + + // Lazy evaluation: Repository name not fouind? + if (\strpos($result['contents'], Certainty::REPOSITORY) === false) { + return false; + } + + // If we've gotten here, then this Chronicle has our update logged. + return true; + } }