<?php
declare(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);
}
|