Upgrade to ACME RFC (v2)

This commit is contained in:
Niklas Keller
2021-07-04 12:40:11 +02:00
parent 6bfa43dad9
commit e9b382128d
6 changed files with 759 additions and 345 deletions

View File

@@ -1,10 +1,14 @@
#!/usr/bin/env php
<?php
use Amp\Http\Client\HttpClientBuilder;
use Amp\Loop;
use Auryn\Injector;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Commands\Command;
use League\CLImate\CLImate;
use function Kelunik\AcmeClient\getBinary;
use function Kelunik\AcmeClient\suggestCommand;
$logo = <<<LOGO
____ __________ ___ ___
@@ -51,7 +55,7 @@ $commands = [
'help' => 'Print this help information.',
];
$binary = \Kelunik\AcmeClient\getBinary();
$binary = getBinary();
$help = implode(PHP_EOL, array_map(function ($command) use ($commands) {
$help = " <green>{$command}</green>\n";
@@ -81,8 +85,8 @@ if (!in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
exit(1);
}
if (PHP_VERSION_ID < 70000) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at http://php.net/supported-versions.php and upgrade at least to PHP 7.0!");
if (PHP_VERSION_ID < 70400) {
$climate->yellow("You're using an older version of PHP which is no longer supported by this client. Have a look at https://php.net/supported-versions.php and upgrade at least to PHP 7.4!");
$climate->br(2);
}
@@ -94,7 +98,7 @@ if (count($argv) === 1 || in_array($argv[1], ['-h', 'help', '--help'], true)) {
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], array_keys($commands));
$suggestion = suggestCommand($argv[1], array_keys($commands));
if ($suggestion) {
$climate->br()->out(" Did you mean '$suggestion'?");
@@ -105,7 +109,7 @@ if (!array_key_exists($argv[1], $commands)) {
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
/** @var string|Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
@@ -136,7 +140,7 @@ try {
$injector = new Injector;
$injector->share($climate);
$injector->share(new AcmeFactory);
$injector->share(new Amp\Artax\DefaultClient);
$injector->share(HttpClientBuilder::buildDefault());
$command = $injector->make($class);
$exitCode = 1;

View File

@@ -46,7 +46,7 @@
},
"config": {
"platform": {
"php": "7.2.5"
"php": "7.4.0"
}
},
"extra": {

974
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,21 +10,19 @@ use Kelunik\Acme\AcmeService;
use Kelunik\Acme\Crypto\PrivateKey;
use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor;
use function Amp\ByteStream\getStderr;
class AcmeFactory
{
public function build(string $directory, PrivateKey $keyPair): AcmeService
{
$logger = null;
if (\getenv('ACME_LOG')) {
$handler = new StreamHandler(getStderr());
$handler->setFormatter(new ConsoleFormatter(null, null, true, true));
$logger = new Logger('acme');
$logger->pushProcessor(new PsrLogMessageProcessor);
$handler = new StreamHandler(new ResourceOutputStream(\STDERR));
$handler->setFormatter(new ConsoleFormatter(null, null, true, true));
$logger->pushHandler($handler);
}
return new AcmeService(new AcmeClient($directory, $keyPair, null, null, null, $logger));
return new AcmeService(new AcmeClient($directory, $keyPair, null, null, $logger), $logger);
}
}

View File

@@ -10,6 +10,9 @@ use Kelunik\Acme\Crypto\Backend\OpensslBackend;
use Kelunik\Acme\Crypto\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator;
use Kelunik\Acme\Protocol\Authorization;
use Kelunik\Acme\Protocol\Challenge;
use Kelunik\Acme\Protocol\Order;
use Kelunik\Acme\Verifiers\Http01;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
@@ -133,12 +136,33 @@ class Issue implements Command
$acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
/** @var Order $order */
$order = yield $acme->newOrder($domains);
/** @var \Throwable[] $errors */
[$errors] = yield AcmeClient\concurrentMap(
$concurrency,
$domains,
function ($domain, $i) use ($acme, $key, $docRoots, $user) {
return $this->solveChallenge($acme, $key, $domain, $docRoots[$i], $user);
$order->getAuthorizationUrls(),
function ($authorizationUrl, $i) use ($acme, $key, $domains, $docRoots, $user) {
/** @var Authorization $authorization */
$authorization = yield $acme->getAuthorization($authorizationUrl);
if ($authorization->getIdentifier()->getType() !== 'dns') {
throw new AcmeException('Invalid identifier: ' . $authorization->getIdentifier()->getType());
}
$name = $authorization->getIdentifier()->getValue();
if ($authorization->isWildcard()) {
$name .= '*.';
}
$index = \array_search($name, $domains, true);
if ($index === false) {
throw new AcmeException('Unknown identifier returned: ' . $name);
}
return yield from $this->solveChallenge($acme, $key, $authorization, $domains[$i], $docRoots[$i],
$user);
}
);
@@ -150,6 +174,8 @@ class Issue implements Command
throw new AcmeException('Issuance failed, not all challenges could be solved.');
}
yield $acme->pollForOrderReady($order->getUrl());
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits');
@@ -171,8 +197,13 @@ class Issue implements Command
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
$location = yield $acme->requestCertificate($csr);
$certificates = yield $acme->pollForCertificate($location);
yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
yield $acme->pollForOrderValid($order->getUrl());
/** @var Order $order */
$order = yield $acme->getOrder($order->getUrl());
$certificates = yield $acme->downloadCertificates($order->getCertificateUrl());
$path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path);
@@ -191,20 +222,17 @@ class Issue implements Command
private function solveChallenge(
AcmeService $acme,
PrivateKey $key,
Authorization $authorization,
string $domain,
string $path,
string $user = null
): \Generator {
[$location, $challenges] = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
if (empty($goodChallenges)) {
$httpChallenge = $this->findHttpChallenge($authorization);
if ($httpChallenge === null) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
}
$challenge = $challenges->challenges[\reset($goodChallenges)];
$token = $challenge->token;
$token = $httpChallenge->getToken();
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid Token!');
}
@@ -219,8 +247,8 @@ class Issue implements Command
yield $challengeStore->put($token, $payload, $user);
yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload);
yield $acme->pollForChallenge($location);
yield $acme->finalizeChallenge($httpChallenge->getUrl());
yield $acme->pollForAuthorization($authorization->getUrl());
$this->climate->comment(" {$domain} is now authorized.");
} finally {
@@ -246,24 +274,16 @@ class Issue implements Command
}
}
private function findSuitableCombination(\stdClass $response): array
private function findHttpChallenge(Authorization $authorization): ?Challenge
{
$challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? [];
$goodChallenges = [];
$challenges = $authorization->getChallenges();
foreach ($challenges as $i => $challenge) {
if ($challenge->type === 'http-01') {
$goodChallenges[] = $i;
foreach ($challenges as $challenge) {
if ($challenge->getType() === 'http-01') {
return $challenge;
}
}
foreach ($goodChallenges as $i => $challenge) {
if (!\in_array([$challenge], $combinations, true)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
return null;
}
}

View File

@@ -8,7 +8,7 @@ use Amp\Dns\Record;
use Amp\Promise;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Domain\Registration;
use Kelunik\Acme\Protocol\Account;
use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory;
use Kelunik\AcmeClient\Stores\KeyStore;
@@ -33,6 +33,12 @@ class Setup implements Command
'description' => 'E-mail for important issues, will be sent to the ACME server.',
'required' => true,
],
'agree-terms' => [
'longPrefix' => 'agree-terms',
'description' => 'Agree to terms of service of the configured ACME server.',
'defaultValue' => false,
'noValue' => true,
],
];
$configPath = AcmeClient\getConfigPath();
@@ -90,19 +96,17 @@ class Setup implements Command
$this->climate->whisper(' Registering with ' . \substr($server, 8) . ' ...');
/** @var Registration $registration */
$registration = yield $acme->register($email);
$this->climate->info(' Registration successful. Contacts: ' . \implode(
', ',
$registration->getContact()
));
/** @var Account $account */
$account = yield $acme->register($email, $args->get('agree-terms'));
$contacts = \implode(', ', \array_map("strval", $account->getContacts()));
$this->climate->info(' Registration successful. Contacts: ' . $contacts);
$this->climate->br();
return 0;
});
}
private function checkEmail(string $email)
private function checkEmail(string $email): \Generator
{
$host = \substr($email, \strrpos($email, '@') + 1);