<?php 
declare(strict_types=1); 
namespace ParagonIE\Quill; 
 
use GuzzleHttp\Client; 
use ParagonIE\Certainty\Exception\CertaintyException; 
use GuzzleHttp\Psr7\{ 
    Request, 
    Response 
}; 
use GuzzleHttp\Exception\GuzzleException; 
use ParagonIE\Certainty\RemoteFetch; 
use ParagonIE\Sapient\Adapter\Guzzle; 
use ParagonIE\Sapient\CryptographyKeys\{ 
    SealingPublicKey, 
    SharedEncryptionKey, 
    SigningPublicKey, 
    SigningSecretKey 
}; 
use ParagonIE\Sapient\Exception\{ 
    HeaderMissingException, 
    InvalidMessageException 
}; 
use ParagonIE\Sapient\Sapient; 
use ParagonIE\Sapient\Simple; 
use Psr\Http\Message\ResponseInterface; 
 
/** 
 * Class Quill 
 * @package ParagonIE\Quill 
 */ 
class Quill 
{ 
    const CLIENT_ID_HEADER = 'Chronicle-Client-Key-ID'; 
 
    /** 
     * @var string $chronicleUrl 
     */ 
    protected $chronicleUrl = ''; 
 
    /** 
     * @var string $clientID 
     */ 
    protected $clientID = ''; 
 
    /** 
     * @var SigningSecretKey $clientSSK 
     */ 
    protected $clientSSK = null; 
 
    /** 
     * @var Client 
     */ 
    protected $http = null; 
 
    /** 
     * @var SigningPublicKey $serverSPK 
     */ 
    protected $serverSPK = null; 
 
    /** 
     * Quill constructor. 
     * 
     * @param string $url 
     * @param string $clientId 
     * @param SigningPublicKey|null $serverPublicKey 
     * @param SigningSecretKey|null $clientSecretKey 
     * @param Client|null $http 
     * 
     * @throws CertaintyException 
     * @throws \SodiumException 
     * @throws \TypeError 
     */ 
    public function __construct( 
        string $url = '', 
        string $clientId = '', 
        SigningPublicKey $serverPublicKey = null, 
        SigningSecretKey $clientSecretKey = null, 
        Client $http = null 
    ) { 
        if ($url) { 
            $this->chronicleUrl = $url; 
        } 
        if ($clientId) { 
            $this->clientID = $clientId; 
        } 
        if ($serverPublicKey) { 
            $this->serverSPK = $serverPublicKey; 
        } 
        if ($clientSecretKey) { 
            $this->clientSSK = $clientSecretKey; 
        } 
        if ($http) { 
            $this->http = $http; 
        } else { 
            $this->http = new Client([ 
                'curl.options' => [ 
                    // https://github.com/curl/curl/blob/6aa86c493bd77b70d1f5018e102bc3094290d588/include/curl/curl.h#L1927 
                    CURLOPT_SSLVERSION => 
                        CURL_SSLVERSION_TLSv1_2 | (CURL_SSLVERSION_TLSv1 << 16) 
                ], 
                'verify' => (new RemoteFetch())->getLatestBundle()->getFilePath() 
            ]); 
        } 
    } 
 
    /** 
     * Write data to the Chronicle Instance. Return a boolean indicating 
     * success or failure, discarding the response body after verification. 
     * 
     * @param string $data 
     * @return bool 
     */ 
    public function blindWrite(string $data): bool 
    { 
        try { 
            $response = $this->write($data); 
            // If we're here, the data was written successfully. 
            return $response instanceof ResponseInterface; 
        } catch (InvalidMessageException $ex) { 
            return false; 
        } catch (HeaderMissingException $ex) { 
            return false; 
        } 
    } 
 
    /** 
     * Encrypt data with a shared (symmetric) encryption key, then write it 
     * to a Chronicle. Returns TRUE if published successfully. 
     * 
     * @param string $data 
     * @param SharedEncryptionKey $sharedEncryptionKey 
     * @return bool 
     */ 
    public function blindWriteEncrypted( 
        string $data, 
        SharedEncryptionKey $sharedEncryptionKey 
    ): bool 
    { 
        try { 
            $response = $this->writeEncrypted($data, $sharedEncryptionKey); 
            // If we're here, the data was written successfully. 
            return $response instanceof ResponseInterface; 
        } catch (InvalidMessageException $ex) { 
            return false; 
        } catch (HeaderMissingException $ex) { 
            return false; 
        } 
    } 
 
    /** 
     * Encrypt data with an public key (asymmetric encryption), then write it 
     * to a Chronicle. Returns TRUE if published successfully. 
     * 
     * @param string $data 
     * @param SealingPublicKey $publicKey 
     * @return bool 
     */ 
    public function blindWriteSealed( 
        string $data, 
        SealingPublicKey $publicKey 
    ): bool 
    { 
        try { 
            $response = $this->writeSealed($data, $publicKey); 
            // If we're here, the data was written successfully. 
            return $response instanceof ResponseInterface; 
        } catch (InvalidMessageException $ex) { 
            return false; 
        } catch (HeaderMissingException $ex) { 
            return false; 
        } 
    } 
 
    /** 
     * @return string 
     */ 
    public function getChronicleURL(): string 
    { 
        return $this->chronicleUrl; 
    } 
 
    /** 
     * @return string 
     */ 
    public function getClientID(): string 
    { 
        return $this->clientID; 
    } 
 
    /** 
     * @return SigningSecretKey 
     */ 
    public function getClientSecretKey(): SigningSecretKey 
    { 
        return $this->clientSSK; 
    } 
 
    /** 
     * @return SigningPublicKey 
     */ 
    public function getServerPublicKey(): SigningPublicKey 
    { 
        return $this->serverSPK; 
    } 
 
    /** 
     * @param string $url 
     * @return self 
     */ 
    public function setChronicleURL(string $url): self 
    { 
        $this->chronicleUrl = $url; 
        return $this; 
    } 
 
    /** 
     * @param string $clientID 
     * @return self 
     */ 
    public function setClientID(string $clientID): self 
    { 
        $this->clientID = $clientID; 
        return $this; 
    } 
 
    /** 
     * @param SigningSecretKey $secretKey 
     * @return self 
     */ 
    public function setClientSecretKey(SigningSecretKey $secretKey): self 
    { 
        $this->clientSSK = $secretKey; 
        return $this; 
    } 
 
    /** 
     * @param SigningPublicKey $publicKey 
     * @return self 
     */ 
    public function setServerPublicKey(SigningPublicKey $publicKey): self 
    { 
        $this->serverSPK = $publicKey; 
        return $this; 
    } 
 
    /** 
     * Encrypt a message and publish its contents onto a Chronicle instance, 
     * using a shared encryption key. (Symmetric cryptography.) 
     * 
     * @param string $data 
     * @param SharedEncryptionKey $sharedEncryptionKey 
     * @return ResponseInterface 
     * @throws HeaderMissingException 
     * @throws InvalidMessageException 
     */ 
    public function writeEncrypted( 
        string $data, 
        SharedEncryptionKey $sharedEncryptionKey 
    ): ResponseInterface { 
        return $this->write( 
            Simple::encrypt($data, $sharedEncryptionKey) 
        ); 
    } 
 
    /** 
     * Encrypt a message and publish its contents onto a Chronicle instance, 
     * using a public encryption key. (Asymmetric cryptography.) 
     * 
     * @param string $data 
     * @param SealingPublicKey $publicKey 
     * @return ResponseInterface 
     * @throws HeaderMissingException 
     * @throws InvalidMessageException 
     */ 
    public function writeSealed( 
        string $data, 
        SealingPublicKey $publicKey 
    ): ResponseInterface { 
        return $this->write( 
            Simple::seal($data, $publicKey) 
        ); 
    } 
 
    /** 
     * Write data to the Chronicle instance. Return the Response object. 
     * 
     * @param string $data 
     * @return ResponseInterface 
     * 
     * @throws HeaderMissingException 
     * @throws InvalidMessageException 
     */ 
    public function write(string $data): ResponseInterface 
    { 
        /** @psalm-suppress RedundantConditionGivenDocblockType */ 
        $this->assertValid(); 
        $sapient = new Sapient(new Guzzle($this->http)); 
 
        $url = $this->chronicleUrl; 
        $pieces = \explode('/', \trim($this->chronicleUrl, '/')); 
        $last = \array_pop($pieces); 
        if ($last !== 'publish') { 
            $precursor = \array_pop($pieces); 
            if ($precursor === 'chronicle') { 
                $url = $this->chronicleUrl . '/publish'; 
            } else { 
                $url = $this->chronicleUrl . '/chronicle/publish'; 
            } 
        } 
 
        $header = (string) static::CLIENT_ID_HEADER; 
        /** @var Request $request */ 
        $request = $sapient->createSignedRequest( 
            'POST', 
            $url, 
            $data, 
            $this->clientSSK, 
            [ 
                $header => $this->clientID 
            ] 
        ); 
        /** @var Response $response */ 
        $response = $this->http->send($request); 
 
        /** @var Response $verified */ 
        $verified = $sapient->verifySignedResponse( 
            $response, 
            $this->serverSPK 
        ); 
        return $this->validateResponse($verified); 
    } 
 
    /** 
     * @throws \Error 
     * @psalm-suppress DocblockTypeContradiction 
     */ 
    protected function assertValid(): void 
    { 
        if (!$this->clientID) { 
            throw new \Error('Client ID is not populated'); 
        } 
        if (!$this->chronicleUrl) { 
            throw new \Error('Chronicle URL is not populated'); 
        } 
        if (!$this->clientSSK) { 
            throw new \Error('Client signing secret key is not populated'); 
        } 
        if (!$this->serverSPK) { 
            throw new \Error('Server signing public key is not populated'); 
        } 
    } 
 
    /** 
     * Validate the Chronicle's JSON response. 
     * 
     * @param Response $response 
     * @return Response 
     * @throws InvalidMessageException 
     */ 
    protected function validateResponse(Response $response): Response 
    { 
        /** @var string $body */ 
        $body = (string) $response->getBody(); 
        /** @var array|false $decoded */ 
        $decoded = \json_decode($body, true); 
        if (!\is_array($decoded)) { 
            throw new InvalidMessageException('Could not parse JSON body'); 
        } 
        if ($decoded['status'] !== 'OK') { 
            throw new InvalidMessageException( 
                (string) ($decoded['message'] ?? 'An unknown error has occurred.') 
            ); 
        } 
        return $response; 
    } 
} 
 
 |