| <?php
declare(strict_types=1);
namespace ParagonIE\Chronicle;
use ParagonIE\Blakechain\Blakechain;
use ParagonIE\Chronicle\Exception\{
    BaseException,
    ChainAppendException,
    ClientNotFound,
    FilesystemException,
    HTTPException,
    InstanceNotFoundException,
    InvalidInstanceException,
    SecurityViolation,
    TimestampNotProvided
};
use ParagonIE\ConstantTime\Base64UrlSafe;
use ParagonIE\EasyDB\EasyDB;
use ParagonIE\Sapient\Adapter\Slim;
use ParagonIE\Sapient\CryptographyKeys\{
    SigningPublicKey,
    SigningSecretKey
};
use ParagonIE\Sapient\Sapient;
use Psr\Http\Message\{
    RequestInterface,
    ResponseInterface
};
use Slim\Http\Response;
/**
 * Class Chronicle
 * @package ParagonIE\Chronicle
 */
class Chronicle
{
    /** @var ResponseCache|null $cache */
    protected static $cache;
    /** @var EasyDB $easyDb */
    protected static $easyDb;
    /** @var array<string, string> $settings */
    protected static $settings;
    /** @var SigningSecretKey $signingKey */
    protected static $signingKey;
    /** @var string $tablePrefix */
    protected static $tablePrefix = '';
    /* This constant is the name of the header used to find the
       corresponding public key: */
    const CLIENT_IDENTIFIER_HEADER = 'Chronicle-Client-Key-ID';
    /* This constant denotes the Chronicle version running, server-side */
    const VERSION = '1.3.x';
    /**
     * @return ResponseCache|null
     * @throws Exception\CacheMisuseException
     * @throws \Psr\Cache\InvalidArgumentException
     */
    public static function getResponseCache()
    {
        if (empty(self::$cache)) {
            if (empty(self::$settings['cache'])) {
                return null;
            }
            if (!ResponseCache::isAvailable()) {
                return null;
            }
            self::$cache = new ResponseCache((int) self::$settings['cache']);
        }
        return self::$cache;
    }
    /**
     * @param RequestInterface $request
     * @param ResponseInterface $response
     * @return ResponseInterface
     *
     * @throws \Exception
     * @throws \Psr\Cache\InvalidArgumentException
     */
    public static function cache(
        RequestInterface $request,
        ResponseInterface $response
    ): ResponseInterface {
        $cache = self::getResponseCache();
        if (!empty($cache)) {
            $cache->saveResponse((string) $request->getUri(), $response);
        }
        return $response;
    }
    /**
     * @param RequestInterface $request
     * @return ResponseInterface|null
     *
     * @throws \Exception
     * @throws \Psr\Cache\InvalidArgumentException
     */
    public static function getFromCache(RequestInterface $request)
    {
        $cache = self::getResponseCache();
        if (empty($cache)) {
            return null;
        }
        /** @var Response $response */
        $response = $cache->loadResponse((string) $request->getUri());
        return $response;
    }
    /**
     * @param string $name
     * @param bool $dontEscape
     * @return string
     * @throws InvalidInstanceException
     */
    public static function getTableName(string $name, bool $dontEscape = false)
    {
        if (empty(self::$tablePrefix)) {
            if ($dontEscape) {
                return 'chronicle_' . $name;
            }
            return self::$easyDb->escapeIdentifier(
                'chronicle_' . $name
            );
        }
        if (self::$tablePrefix === 'replication') {
            throw new InvalidInstanceException(
                'The name "replication" is a reserved name.'
            );
        }
        if ($dontEscape) {
            return 'chronicle_' . self::$tablePrefix . '_' . $name;
        }
        return self::$easyDb->escapeIdentifier(
            'chronicle_' . self::$tablePrefix . '_' . $name
        );
    }
    /**
     * @param string $name
     * @param bool $dontEscape
     * @return string
     * @throws InvalidInstanceException
     */
    public static function getTableNameUnquoted(string $name, bool $dontEscape = false)
    {
        return trim(self::getTableName($name, $dontEscape), '"');
    }
    /**
     * This extends the Blakechain with an arbitrary message, signature, and
     * public key.
     *
     * @param string $body
     * @param string $signature
     * @param SigningPublickey $publicKey
     * @return array<string, string>
     *
     * @throws BaseException
     * @throws \SodiumException
     * @psalm-suppress MixedTypeCoercion
     */
    public static function extendBlakechain(
        string $body,
        string $signature,
        SigningPublicKey $publicKey
    ): array {
        $db = self::$easyDb;
        if ($db->inTransaction()) {
            $db->commit();
        }
        $db->beginTransaction();
        /** @var array<string, string> $lasthash */
        $lasthash = $db->row(
            'SELECT currhash, hashstate 
             FROM ' . self::getTableName('chain') . ' 
             ORDER BY id DESC 
             LIMIT 1'
        );
        // Instantiate the Blakechain.
        $blakechain = new Blakechain();
        if (empty($lasthash)) {
            $prevhash = null;
        } else {
            $prevhash = $lasthash['currhash'];
            $blakechain->setFirstPrevHash(
                Base64UrlSafe::decode($lasthash['currhash'])
            );
            $hashstate = Base64UrlSafe::decode($lasthash['hashstate']);
            $blakechain->setSummaryHashState($hashstate);
        }
        $currentTime = (new \DateTime())->format(\DateTime::ATOM);
        // Append data to the Blakechain:
        $blakechain->appendData(
            $currentTime .
            $publicKey->getString(true) .
            Base64UrlSafe::decode($signature) .
            $body
        );
        // Fields for insert:
        $fields = [
            'data' => $body,
            'prevhash' => $prevhash,
            'currhash' => $blakechain->getLastHash(),
            'hashstate' => $blakechain->getSummaryHashState(),
            'summaryhash' => $blakechain->getSummaryHash(),
            'publickey' => $publicKey->getString(),
            'signature' => $signature,
            'created' => $currentTime
        ];
        // Normalize data fields based on database type
        self::normalize($db->getDriver(), $fields);
        // Insert new row into the database:
        $db->insert(self::getTableNameUnquoted('chain', true), $fields);
        if (!$db->commit()) {
            $db->rollBack();
            throw new ChainAppendException('Could not commit new hash to database');
        }
        // This data is returned to the publisher:
        return [
            'currhash' => (string) $fields['currhash'],
            'summaryhash' => (string) $fields['summaryhash'],
            'created' => (string) $currentTime
        ];
    }
    /**
     * Normalize the data before it goes to database, because every database
     * has its own system.
     *
     * @param string $databaseType
     * @param array &$data
     * @return void
     *
     */
    public static function normalize(string $databaseType, array &$data)
    {
        // Detect database type
        if (\strtolower($databaseType) === 'mysql') {
            // Ignore this; it will be set by the database system automatically.
            if (isset($data['created'])) {
                unset($data['created']);
            }
        }
        // We don't return anything here.
    }
    /**
     * Return a generic error response, timestamped and then signed by the
     * Chronicle server's public key.
     *
     * @param ResponseInterface $response
     * @param string $errorMessage
     * @param int $errorCode
     * @return ResponseInterface
     *
     * @throws FilesystemException
     */
    public static function errorResponse(
        ResponseInterface $response,
        string $errorMessage,
        int $errorCode = 400
    ): ResponseInterface {
        $response = $response->withAddedHeader('Content-Type', 'application/json');
        return static::getSapient()->createSignedJsonResponse(
            $errorCode,
            [
                'version' => static::VERSION,
                'datetime' => (new \DateTime())->format(\DateTime::ATOM),
                'status' => 'ERROR',
                'message' => $errorMessage
            ],
            self::getSigningKey(),
            $response->getHeaders(),
            $response->getProtocolVersion()
        );
    }
    /**
     * Given a clients Public ID, retrieve their Ed25519 public key.
     *
     * @param string $clientId
     * @param bool $adminOnly
     * @return SigningPublicKey
     *
     * @throws BaseException
     */
    public static function getClientsPublicKey(
        string $clientId,
        bool $adminOnly = false
    ): SigningPublicKey {
        if ($adminOnly) {
            /** @var array<string, string> $sqlResult */
            $sqlResult = static::$easyDb->row(
                "SELECT * FROM " . self::getTableName('clients') . " WHERE publicid = ? AND isAdmin",
                $clientId
            );
        } else {
            /** @var array<string, string> $sqlResult */
            $sqlResult = static::$easyDb->row(
                "SELECT * FROM " . self::getTableName('clients') . " WHERE publicid = ?",
                $clientId
            );
        }
        if (empty($sqlResult)) {
            throw new ClientNotFound('Client not found');
        }
        return new SigningPublicKey(
            Base64UrlSafe::decode($sqlResult['publickey'])
        );
    }
    /**
     * Get the EasyDB object (used for database queries)
     *
     * @return EasyDB
     */
    public static function getDatabase(): EasyDB
    {
        return self::$easyDb;
    }
    /**
     * Return a Sapient object, with the Slim Framework adapter included.
     *
     * @return Sapient
     */
    public static function getSapient(): Sapient
    {
        return new Sapient(new Slim());
    }
    /**
     * @return array<string, string>
     */
    public static function getSettings(): array
    {
        return self::$settings;
    }
    /**
     * This gets the server's signing key.
     *
     * We should audit all calls to this method.
     *
     * @return SigningSecretKey
     *
     * @throws FilesystemException
     */
    public static function getSigningKey(): SigningSecretKey
    {
        if (self::$signingKey) {
            return self::$signingKey;
        }
        // Load the signing key:
        $keyFile = \file_get_contents(CHRONICLE_APP_ROOT . '/local/signing-secret.key');
        if (!\is_string($keyFile)) {
            throw new FilesystemException('Could not load key file');
        }
        return new SigningSecretKey(
            Base64UrlSafe::decode($keyFile)
        );
    }
    /**
     * Is this a valid name for an instance?
     *
     * @param string $name
     * @return bool
     */
    public static function isValidInstanceName(string $name): bool
    {
        return (bool) \preg_match('#^[A-Za-z0-9_\-]+$#', $name);
    }
    /**
     * @return int
     */
    public static function getPageSize(): int
    {
        return (int) self::$settings['paginate-export'];
    }
    /**
     * Store the database object in the Chronicle class.
     *
     * @param EasyDB $db
     * @return EasyDB
     */
    public static function setDatabase(EasyDB $db): EasyDB
    {
        self::$easyDb = $db;
        return self::$easyDb;
    }
    /**
     * Should we enable pagination?
     *
     * Only returns true if "paginate-export" is greater than 0.
     *
     * @return bool
     */
    public static function shouldPaginate(): bool
    {
        return !empty(self::$settings['paginate-export']);
    }
    /**
     * @param array<string, string> $settings
     * @return void
     */
    public static function storeSettings(array $settings)
    {
        self::$settings = $settings;
    }
    /**
     * @param string $prefix
     * @return void
     *
     * @throws InstanceNotFoundException
     */
    public static function setTablePrefix(string $prefix)
    {
        /** @var array<string, string> $instances */
        $instances = self::$settings['instances'];
        if (!\in_array($prefix, $instances, true)) {
            throw new InstanceNotFoundException(
                'Instance ' . $prefix . ' not found in settings'
            );
        }
        self::$tablePrefix = $prefix;
    }
    /**
     * Optional feature: Reject old signed messages.
     *
     * @param RequestInterface $request
     * @param string $index
     * @return void
     *
     * @throws HTTPException
     * @throws SecurityViolation
     * @throws TimestampNotProvided
     */
    public static function validateTimestamps(
        RequestInterface $request,
        string $index = 'request-time'
    ): void {
        if (empty(self::$settings['request-timeout'])) {
            return;
        }
        $body = (string) $request->getBody();
        if (empty($body)) {
            throw new HTTPException('No post body was provided', 406);
        }
        /** @var array $json */
        $json = \json_decode($body, true);
        if (!\is_array($json)) {
            throw new HTTPException('Invalid JSON message', 406);
        }
        if (empty($json[$index])) {
            throw new TimestampNotProvided('Parameter "' . $index . '" not provided.', 401);
        }
        try {
            $sent = new \DateTimeImmutable((string)($json[$index]));
        } catch (\Exception $ex) {
            throw new SecurityViolation('Request timestamp is invalid. Please resend.', 408);
        }
        $expires = $sent->add(
            \DateInterval::createFromDateString(
                (string) self::$settings['request-timeout']
            )
        );
        if (new \DateTime('NOW') > $expires) {
            throw new SecurityViolation('Request timestamp is too old. Please resend.', 408);
        }
        /* Timestamp checks out. We don't throw anything. */
    }
}
 |