<?php
namespace ParagonIE\Certainty;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use ParagonIE\Certainty\Exception\CryptoException;
use ParagonIE\Certainty\Exception\EncodingException;
use ParagonIE\Certainty\Exception\InvalidResponseException;
use ParagonIE\Certainty\Exception\RemoteException;
use ParagonIE\ConstantTime\Base64UrlSafe;
use ParagonIE\ConstantTime\Hex;
/**
* Class Validator
* @package ParagonIE\Certainty
*/
class Validator
{
// Set this to true to not throw exceptions
const THROW_MORE_EXCEPTIONS = false;
// Ed25519 public keys
const PRIMARY_SIGNING_PUBKEY = '98f2dfad4115fea9f096c35485b3bf20b06e94acac3b7acf6185aa5806020342';
const BACKUP_SIGNING_PUBKEY = '1cb438a66110689f1192b511a88030f02049c40d196dc1844f9e752531fdd195';
// Chronicle settings.
const CHRONICLE_URL = 'https://php-chronicle.pie-hosted.com/chronicle';
const CHRONICLE_PUBKEY = 'MoavD16iqe9-QVhIy-ewD4DMp0QRH-drKfwhfeDAUG0=';
/**
* Validate SHA256 checksums.
*
* @param Bundle $bundle
* @return bool
*/
public static function checkSha256Sum(Bundle $bundle)
{
$sha256sum = \hash_file('sha256', $bundle->getFilePath(), true);
return \hash_equals($bundle->getSha256Sum(true), $sha256sum);
}
/**
* Check Ed25519 signature for this bundle's contents.
*
* @param Bundle $bundle Which bundle to validate
* @param bool $backupKey Use the backup key? (Only if the primary is compromised.)
* @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
);
}
/**
* Is this update checked into a Chronicle?
*
* @param Bundle $bundle
* @return bool
* @throws \Exception
* @throws ConnectException
* @throws EncodingException
* @throws RemoteException
*/
public static function checkChronicleHash(Bundle $bundle)
{
if (empty(static::CHRONICLE_PUBKEY) && empty(static::CHRONICLE_URL)) {
// Custom validator has opted to fail open here. Who are we to dissent?
return true;
}
if (empty($bundle->getChronicleHash())) {
// No chronicle hash? This check fails closed.
return false;
}
// Inherited classes can override this.
$chronicleUrl = static::CHRONICLE_URL;
/** @var string $publicKey */
$publicKey = Base64UrlSafe::decode(static::CHRONICLE_PUBKEY);
/** @var Client $guzzle */
$guzzle = Certainty::getGuzzleClient();
// We could catch the ConnectException, but let's not.
$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 EncodingException('Signature invalid');
}
$sigValid = $sigValid || \ParagonIE_Sodium_Compat::crypto_sign_verify_detached(
(string) $signature,
(string) $body,
(string) $publicKey
);
}
if (!$sigValid) {
if (static::THROW_MORE_EXCEPTIONS) {
throw new CryptoException('Invalid signature.');
}
// No valid signatures
return false;
}
$json = \json_decode($body, true);
if (!\is_array($json)) {
throw new EncodingException('Invalid JSON response');
}
// If the status was successful,
if (!\hash_equals('OK', $json['status'])) {
if (self::THROW_MORE_EXCEPTIONS) {
if (isset($json['error'])) {
throw new RemoteException($json['error']);
}
throw new RemoteException('Invalid status returned by the API');
}
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 Chronicle API response (post signature validation)
* @return bool
* @throws CryptoException
* @throws InvalidResponseException
*/
protected static function validateChronicleContents(Bundle $bundle, array $result = [])
{
if (!isset($result['signature'], $result['contents'], $result['publickey'])) {
if (static::THROW_MORE_EXCEPTIONS) {
throw new InvalidResponseException('Incomplete data');
}
// Incomplete data.
return false;
}
$publicKey = (string) Hex::encode(
(string) 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.
/** @var string $signature */
$signature = (string) Base64UrlSafe::decode($result['signature']);
if (!\ParagonIE_Sodium_Compat::crypto_sign_verify_detached(
$signature,
$result['contents'],
Hex::decode($publicKey)
)) {
if (static::THROW_MORE_EXCEPTIONS) {
throw new CryptoException('Invalid signature.');
}
return false;
}
// Lazy evaluation: SHA256 hash not present?
if (\strpos($result['contents'], $bundle->getSha256Sum()) === false) {
if (static::THROW_MORE_EXCEPTIONS) {
throw new InvalidResponseException('SHA256 hash not present in response body');
}
return false;
}
// Lazy evaluation: Repository name not fouind?
if (\strpos($result['contents'], Certainty::REPOSITORY) === false) {
/** @var string $altRepoName */
$altRepoName = \json_encode(Certainty::REPOSITORY);
if (\strpos($result['contents'], $altRepoName) === false) {
if (static::THROW_MORE_EXCEPTIONS) {
throw new InvalidResponseException('Repository name not present in response body');
}
return false;
}
}
// If we've gotten here, then this Chronicle has our update logged.
return true;
}
}
|