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