| 
<?phpdeclare(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();
 }
 }
 
 |