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

View File

@@ -46,7 +46,7 @@
}, },
"config": { "config": {
"platform": { "platform": {
"php": "7.2.5" "php": "7.4.0"
} }
}, },
"extra": { "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 Kelunik\Acme\Crypto\PrivateKey;
use Monolog\Logger; use Monolog\Logger;
use Monolog\Processor\PsrLogMessageProcessor; use Monolog\Processor\PsrLogMessageProcessor;
use function Amp\ByteStream\getStderr;
class AcmeFactory class AcmeFactory
{ {
public function build(string $directory, PrivateKey $keyPair): AcmeService public function build(string $directory, PrivateKey $keyPair): AcmeService
{ {
$logger = null; $handler = new StreamHandler(getStderr());
if (\getenv('ACME_LOG')) { $handler->setFormatter(new ConsoleFormatter(null, null, true, true));
$logger = new Logger('acme');
$logger->pushProcessor(new PsrLogMessageProcessor);
$handler = new StreamHandler(new ResourceOutputStream(\STDERR)); $logger = new Logger('acme');
$handler->setFormatter(new ConsoleFormatter(null, null, true, true)); $logger->pushProcessor(new PsrLogMessageProcessor);
$logger->pushHandler($handler); $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\PrivateKey;
use Kelunik\Acme\Crypto\RsaKeyGenerator; use Kelunik\Acme\Crypto\RsaKeyGenerator;
use Kelunik\Acme\Csr\OpensslCsrGenerator; 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\Acme\Verifiers\Http01;
use Kelunik\AcmeClient; use Kelunik\AcmeClient;
use Kelunik\AcmeClient\AcmeFactory; use Kelunik\AcmeClient\AcmeFactory;
@@ -133,12 +136,33 @@ class Issue implements Command
$acme = $this->acmeFactory->build($server, $key); $acme = $this->acmeFactory->build($server, $key);
$concurrency = \min(20, \max($args->get('challenge-concurrency'), 1)); $concurrency = \min(20, \max($args->get('challenge-concurrency'), 1));
/** @var Order $order */
$order = yield $acme->newOrder($domains);
/** @var \Throwable[] $errors */ /** @var \Throwable[] $errors */
[$errors] = yield AcmeClient\concurrentMap( [$errors] = yield AcmeClient\concurrentMap(
$concurrency, $concurrency,
$domains, $order->getAuthorizationUrls(),
function ($domain, $i) use ($acme, $key, $docRoots, $user) { function ($authorizationUrl, $i) use ($acme, $key, $domains, $docRoots, $user) {
return $this->solveChallenge($acme, $key, $domain, $docRoots[$i], $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.'); throw new AcmeException('Issuance failed, not all challenges could be solved.');
} }
yield $acme->pollForOrderReady($order->getUrl());
$keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem'; $keyPath = 'certs/' . $keyFile . '/' . \reset($domains) . '/key.pem';
$bits = $args->get('bits'); $bits = $args->get('bits');
@@ -171,8 +197,13 @@ class Issue implements Command
$csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains); $csr = yield (new OpensslCsrGenerator)->generateCsr($key, $domains);
$location = yield $acme->requestCertificate($csr); yield $acme->finalizeOrder($order->getFinalizationUrl(), $csr);
$certificates = yield $acme->pollForCertificate($location); 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; $path = normalizePath($args->get('storage')) . '/certs/' . $keyFile;
$certificateStore = new CertificateStore($path); $certificateStore = new CertificateStore($path);
@@ -191,20 +222,17 @@ class Issue implements Command
private function solveChallenge( private function solveChallenge(
AcmeService $acme, AcmeService $acme,
PrivateKey $key, PrivateKey $key,
Authorization $authorization,
string $domain, string $domain,
string $path, string $path,
string $user = null string $user = null
): \Generator { ): \Generator {
[$location, $challenges] = yield $acme->requestChallenges($domain); $httpChallenge = $this->findHttpChallenge($authorization);
$goodChallenges = $this->findSuitableCombination($challenges); if ($httpChallenge === null) {
if (empty($goodChallenges)) {
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!"); throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
} }
$challenge = $challenges->challenges[\reset($goodChallenges)]; $token = $httpChallenge->getToken();
$token = $challenge->token;
if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) { if (!\preg_match('#^[a-zA-Z0-9-_]+$#', $token)) {
throw new AcmeException('Protocol violation: Invalid Token!'); throw new AcmeException('Protocol violation: Invalid Token!');
} }
@@ -219,8 +247,8 @@ class Issue implements Command
yield $challengeStore->put($token, $payload, $user); yield $challengeStore->put($token, $payload, $user);
yield (new Http01)->verifyChallenge($domain, $token, $payload); yield (new Http01)->verifyChallenge($domain, $token, $payload);
yield $acme->answerChallenge($challenge->uri, $payload); yield $acme->finalizeChallenge($httpChallenge->getUrl());
yield $acme->pollForChallenge($location); yield $acme->pollForAuthorization($authorization->getUrl());
$this->climate->comment(" {$domain} is now authorized."); $this->climate->comment(" {$domain} is now authorized.");
} finally { } finally {
@@ -246,24 +274,16 @@ class Issue implements Command
} }
} }
private function findSuitableCombination(\stdClass $response): array private function findHttpChallenge(Authorization $authorization): ?Challenge
{ {
$challenges = $response->challenges ?? []; $challenges = $authorization->getChallenges();
$combinations = $response->combinations ?? [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) { foreach ($challenges as $challenge) {
if ($challenge->type === 'http-01') { if ($challenge->getType() === 'http-01') {
$goodChallenges[] = $i; return $challenge;
} }
} }
foreach ($goodChallenges as $i => $challenge) { return null;
if (!\in_array([$challenge], $combinations, true)) {
unset($goodChallenges[$i]);
}
}
return $goodChallenges;
} }
} }

View File

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