<?php declare(strict_types=1); namespace ParagonIE\Chronicle\Process; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use ParagonIE\Chronicle\Chronicle; use ParagonIE\Chronicle\Error\ConfigurationError; use ParagonIE\Chronicle\Exception\{FilesystemException, InvalidInstanceException, TargetNotFound}; use ParagonIE\ConstantTime\Base64UrlSafe; use ParagonIE\EasyDB\EasyDB; use ParagonIE\Sapient\Adapter\Guzzle; use ParagonIE\Sapient\CryptographyKeys\SigningPublicKey; use ParagonIE\Sapient\Exception\InvalidMessageException; use ParagonIE\Sapient\Sapient; use Psr\Http\Message\ResponseInterface; /** * Class CrossSign * * Publish the latest hash onto another remote Chronicle instance. * * @package ParagonIE\Chronicle\Process */ class CrossSign { /** @var string */ protected $clientId; /** @var Client */ protected $guzzle; /** @var int */ protected $id; /** @var array<string, string> */ protected $lastRun; /** @var string */ protected $name; /** @var \DateTime */ protected $now; /** @var array */ protected $policy; /** @var SigningPublicKey */ protected $publicKey; /** @var Sapient */ protected $sapient; /** @var string */ protected $url; /** * CrossSign constructor. * * @param int $id * @param string $name * @param string $url * @param string $clientId * @param SigningPublicKey $publicKey * @param array $policy * @param array<string, string> $lastRun * @throws \Exception */ public function __construct( int $id, string $name, string $url, string $clientId, SigningPublicKey $publicKey, array $policy, array $lastRun = [] ) { $this->id = $id; $this->name = $name; $this->url = $url; $this->clientId = $clientId; $this->publicKey = $publicKey; $this->policy = $policy; $this->lastRun = $lastRun; $this->now = new \DateTime(); $this->guzzle = new Client(); $this->sapient = new Sapient(new Guzzle($this->guzzle)); } /** * Get a CrossSign instance, given its database ID * * @param int $id * @return self * * @throws InvalidInstanceException * @throws TargetNotFound */ public static function byId(int $id): self { $db = Chronicle::getDatabase(); /** @var array<string, string> $data */ $data = $db->row('SELECT * FROM ' . Chronicle::getTableName('xsign_targets') . ' WHERE id = ?', $id); if (empty($data)) { throw new TargetNotFound('Cross-sign target not found'); } /** @var array $policy */ $policy = \json_decode($data['policy'] ?? '[]', true); /** @var array<string, string> $lastRun */ $lastRun = \json_decode($data['lastrun'] ?? '[]', true); return new static( $id, $data['name'], $data['url'], $data['clientid'], new SigningPublicKey(Base64UrlSafe::decode($data['publickey'])), \is_array($policy) ? $policy : [], \is_array($lastRun) ? $lastRun : [] ); } /** * Are we supposed to cross-sign our latest hash to this target? * * @return bool * * @throws ConfigurationError * @throws InvalidInstanceException */ public function needsToCrossSign(): bool { if (empty($this->lastRun)) { return true; } if (!isset($this->lastRun['time'], $this->lastRun['id'])) { return true; } $db = Chronicle::getDatabase(); if (isset($this->policy['push-after'])) { /** @var int $head */ $head = $db->cell('SELECT MAX(id) FROM ' . Chronicle::getTableName('chain')); // Only run if we've had more than N entries if (($head - (int) ($this->lastRun['id'])) >= $this->policy['push-after']) { return true; } // Otherwise, fall back to the daily scheduler: } if (isset($this->policy['push-days'])) { $days = (string) \intval($this->policy['push-days']); if ($days < 10) { $days = '0' . $days; } try { $lastRun = (new \DateTime($this->lastRun['time'])) ->add(new \DateInterval('P' . $days . 'D')); } catch (\Exception $ex) { throw new ConfigurationError('Invalid push-days policy: ' . $days, 0, $ex); } // Return true only if we're more than N days since the last run: return $this->now > $lastRun; } throw new ConfigurationError('No valid policy configured'); } /** * Perform the actual cross-signing. * * First, sign and send a JSON request to the server. * Then, verify and decode the JSON response. * Finally, update the local metadata table. * * @return bool * * @throws InvalidMessageException * @throws GuzzleException * @throws FilesystemException * @throws InvalidInstanceException */ public function performCrossSign(): bool { $db = Chronicle::getDatabase(); $message = $this->getEndOfChain($db); if (!isset($message['currhash'], $message['summaryhash'])) { return false; } $response = $this->sapient->decodeSignedJsonResponse( $this->sendToPeer($message), $this->publicKey ); return $this->updateLastRun($db, $response, $message); } /** * Send a signed request to our peer, return their response. * * @param array $message * @return ResponseInterface * * @throws GuzzleException * @throws FilesystemException */ protected function sendToPeer(array $message): ResponseInterface { $signingKey = Chronicle::getSigningKey(); return $this->guzzle->send( $this->sapient->createSignedJsonRequest( 'POST', $this->url . '/publish', [ 'target' => $this->publicKey->getString(), 'cross-sign-at' => $this->now->format(\DateTime::ATOM), 'currhash' => $message['currhash'], 'summaryhash' => $message['summaryhash'] ], $signingKey, [ Chronicle::CLIENT_IDENTIFIER_HEADER => $this->clientId ] ) ); } /** * Get the last row in this Chronicle's chain. * * @param EasyDB $db * @return array<string, string> * @throws InvalidInstanceException */ protected function getEndOfChain(EasyDB $db): array { /** @var array<string, string> $last */ $last = $db->row('SELECT * FROM ' . Chronicle::getTableName('chain') . ' ORDER BY id DESC LIMIT 1'); if (empty($last)) { return []; } return $last; } /** * Update the lastrun element of the cross-signing table, which helps * enforce our local cross-signing policies: * * @param EasyDB $db * @param array $response * @param array $message * @return bool * @throws InvalidInstanceException */ protected function updateLastRun(EasyDB $db, array $response, array $message): bool { $db->beginTransaction(); $db->update( Chronicle::getTableNameUnquoted('xsign_targets'), [ 'lastrun' => \json_encode([ 'id' => $message['id'], 'time' => $this->now->format(\DateTime::ATOM), 'response' => $response ]) ], [ 'id' => $this->id ] ); return $db->commit(); } } |