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