PHP Classes

File: src/Chronicle/Chronicle.php

Recommend this page to a friend!
  Classes of Scott Arciszewski   Chronicle   src/Chronicle/Chronicle.php   Download  
File: src/Chronicle/Chronicle.php
Role: Class source
Content type: text/plain
Description: Class source
Class: Chronicle
Append arbitrary data to a storage container
Author: By
Last change: Prepare for v1.3.0 release

Add PHP 8.1 to CI matrix
Add support for publicly listed replica instances

See #43
Document this new optional feature.
Fix Psalm nits
Optional feature: Cache responses for a very short period of time.

This will allow us to serve a lot more requests per second.
Document new behavior.
Add pagination to /since
Add JSON header to error responses
Suppress Psalm false positives
Fix double quote errors
Fix API endpoints
Bypass double-escaped identifier names
Add helper method for determining valid instance names.
Concurrent Chronicles

Add support for multiple instances via the ?instance=name parameter.

To implement, add something like this to your local/settings.json in the
instances key:

"public_prefix" => "table_name_prefix"

Then run bin/make-tables.php as normal.

Every instance is totally independent of each other. They have their own

* Clients
* Chain data
* Cross-Signing Targets and Policies
* Replications

If merged, I will document these features and roll it into v1.1.0
Boyscouting. The next release will be v1.1.0
Remove void type declarations (PHP 7.0 support)
Fix MixedTypeCoercion issue for extendBlakechain function return type
fix typo
Get DSN info at Chronicle Core from a short distance
Fix test issues
Resolve publish issues in MySQL
Future Genesis blocks will have a NULL prevhash, thereby allowing UNIQUE
FOREIGN KEY constraints to be created on prevhash pointing to a previous
row's currahsh.
Boyscouting. Update easydb to 2.7 to eliminate boolean workaround.
Docblock consistency, fix composer internal server
Type safety
Date: 1 year ago
Size: 14,088 bytes
 

Contents

Class file image Download
<?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. */ } }