| 
<?phpdeclare(strict_types=1);
 
 /**
 * This script sets up cross-signing to another Chronicle
 */
 
 use GetOpt\{
 GetOpt,
 Option
 };
 use ParagonIE\EasyDB\{
 EasyDB,
 Factory
 };
 use GuzzleHttp\Client;
 use ParagonIE\Chronicle\Chronicle;
 use ParagonIE\Chronicle\Exception\InstanceNotFoundException;
 use ParagonIE\ConstantTime\Base64UrlSafe;
 use ParagonIE\Sapient\CryptographyKeys\SigningPublicKey;
 
 $root = \dirname(__DIR__);
 /** @psalm-suppress UnresolvableInclude */
 require_once $root . '/cli-autoload.php';
 
 if (!\is_readable($root . '/local/settings.json')) {
 echo 'Settings are not loaded.', PHP_EOL;
 exit(1);
 }
 
 /** @var array<string, string> $settings */
 $settings = \json_decode(
 (string) \file_get_contents($root . '/local/settings.json'),
 true
 );
 /** @var EasyDB $db */
 $db = Factory::create(
 $settings['database']['dsn'],
 $settings['database']['username'] ?? '',
 $settings['database']['password'] ?? '',
 $settings['database']['options'] ?? []
 );
 
 /**
 * This defines the Command Line options.
 */
 $getopt = new GetOpt([
 new Option(null, 'url', Getopt::REQUIRED_ARGUMENT),
 new Option(null, 'publickey', Getopt::REQUIRED_ARGUMENT),
 new Option(null, 'clientid', Getopt::REQUIRED_ARGUMENT),
 new Option(null, 'push-after', Getopt::OPTIONAL_ARGUMENT),
 new Option(null, 'push-days', Getopt::OPTIONAL_ARGUMENT),
 new Option(null, 'name', Getopt::OPTIONAL_ARGUMENT),
 new Option('i', 'instance', Getopt::OPTIONAL_ARGUMENT),
 ]);
 $getopt->process();
 
 /** @var string $url */
 $url = $getopt->getOption('url');
 /** @var string $publicKey */
 $publicKey = $getopt->getOption('publickey');
 /** @var string $clientId */
 $clientId = $getopt->getOption('clientid');
 /** @var string|null $pushAfter $pushAfter */
 $pushAfter = $getopt->getOption('push-after') ?? null;
 /** @var string|null $pushDays */
 $pushDays = $getopt->getOption('push-days') ?? null;
 /** @var string $name */
 $name = $getopt->getOption('name') ?? (new DateTime())->format(DateTime::ATOM);
 /** @var string $instance */
 $instance = $getopt->getOption('instance') ?? '';
 
 try {
 if (!empty($instance)) {
 /** @var array<string, string> $instances */
 $instances = $settings['instances'];
 if (!\array_key_exists($instance, $instances)) {
 throw new InstanceNotFoundException(
 'Instance ' . $instance . ' not found'
 );
 }
 Chronicle::setTablePrefix($instances[$instance]);
 }
 } catch (InstanceNotFoundException $ex) {
 echo $ex->getMessage(), PHP_EOL;
 exit(1);
 }
 
 /** @var array<string, string> $fields */
 $fields = [];
 /** @var array<string, int> $policy */
 $policy = [];
 if ($pushAfter) {
 $policy['push-after'] = (int) $pushAfter;
 }
 if ($pushDays) {
 $policy['push-days'] = (int) $pushDays;
 }
 if (empty($policy)) {
 echo "Not enough data. Please specify one of:\n",
 "\t--push-days\n",
 "\t--push-after\n";
 exit(1);
 }
 $fields['policy'] = \json_encode($policy);
 if ($url) {
 $fields['url'] = $url;
 } else {
 echo "URL must be specified.\n";
 exit(2);
 }
 if (is_string($publicKey)) {
 try {
 $publicKeyObj = new SigningPublicKey(
 Base64UrlSafe::decode($publicKey)
 );
 } catch (\Throwable $ex) {
 echo $ex->getMessage(), PHP_EOL;
 exit(1);
 }
 $fields['publickey'] = $publicKey;
 }
 $fields['clientid'] = $clientId;
 
 // Retrieve public key from remote server.
 /** @var array<string, string> $response */
 $response = json_decode(
 (string) (new Client())
 ->get($url)
 ->getBody()
 ->getContents(),
 true
 );
 
 // If we were passed a public key, make sure it matches. Otherwise, TOFU.
 if (isset($fields['publickey'])) {
 if (!hash_equals($response['public-key'], $fields['publickey'])) {
 echo 'ERROR: Server\'s public key does not match the one you provided!', PHP_EOL;
 echo '- ' . $fields['publickey'] . PHP_EOL;
 echo '+ ' . $response['public-key'] . PHP_EOL;
 exit(4);
 }
 } else {
 try {
 /** @var SigningPublicKey $publicKeyObj */
 $publicKeyObj = new SigningPublicKey(
 Base64UrlSafe::decode($response['public-key'])
 );
 } catch (\Throwable $ex) {
 echo $ex->getMessage(), PHP_EOL;
 exit(1);
 }
 /** @var string $accept */
 $accept = prompt(
 "The public key we retrieved from the server is {$response['public-key']}.\n" .
 "Are you sure you trust this public key? (y/N)"
 );
 switch (trim(strtolower($accept))) {
 case 'y':
 case 'yes':
 // Okay
 break;
 default:
 // NOT Okay. Abort.
 echo 'Aborted.', PHP_EOL;
 exit(1);
 }
 $fields['publickey'] = $response['public-key'];
 }
 
 // Write to database...
 $db->beginTransaction();
 $table = Chronicle::getTableName('xsign_targets');
 if ($db->exists('SELECT * FROM ' . $table . ' WHERE name = ?', $name)) {
 // Update an existing cross-sign target
 $db->update($table, $fields, ['name' => $name]);
 } else {
 // Create a new cross-sign target
 if (empty($url) || empty($publicKey)) {
 $db->rollBack();
 echo '--url and --publickey are mandatory for new cross-sign targets', PHP_EOL;
 exit(1);
 }
 $fields['name'] = $name;
 $db->insert($table, $fields);
 }
 
 if (!$db->commit()) {
 $db->rollBack();
 /** @var array<int, string> $errorInfo */
 $errorInfo = $db->errorInfo();
 echo $errorInfo[0], PHP_EOL;
 exit(1);
 }
 
 |