4 Commits

Author SHA1 Message Date
Niklas Keller
9b34d667d0 Merge pull request #14 from ekohl/fixes
Permission fixes and rewrite to kelunik/acme 0.3.0-dev
2016-02-24 15:45:51 +01:00
Ewoud Kohl van Wijngaarden
8ed17841b8 Rewrite to kelunik/acme version 0.3.0-dev 2016-02-24 15:33:38 +01:00
Ewoud Kohl van Wijngaarden
1fcd437aaf Remove the requirement to run as root 2016-02-24 15:33:34 +01:00
Ewoud Kohl van Wijngaarden
0db38e9d95 Correct permissions on files 2016-02-23 16:20:11 +01:00
18 changed files with 353 additions and 770 deletions

View File

@@ -6,9 +6,7 @@
`kelunik/acme-client` is a standalone ACME client written in PHP.
It's an alternative for the [official client](https://github.com/letsencrypt/letsencrypt) which is written in python.
> **Warning**: This software is under development. Use at your own risk.
The client has been updated on Mar 12th in a non-backwards compatible manner. Please review the changes or use a new clone.
> **Warning**: This software is under heavy development. Use at your own risk.
## Installation
@@ -30,33 +28,28 @@ By using this client you agree to any agreement and any further updates by conti
You're responsible to react to updates and stop the automation if you no longer agree with the terms of service.
```
bin/acme setup -s letsencrypt --email me@example.com
sudo bin/acme register \
--server acme-v01.api.letsencrypt.org/directory \
--email me@example.com
```
`-s` / `--server` can either be a URI or a shortcut. Available shortcuts:
* `letsencrypt` / `letsencrypt:production`
* `letsencrypt:staging`
After a successful registration you're able to issue certificates.
This client assumes you have a HTTP server setup and running.
You must have a document root setup in order to use this client.
```
bin/acme issue -s letsencrypt -d example.com:www.example.com -p /var/www/example.com
sudo bin/acme issue \
--server acme-v01.api.letsencrypt.org/directory \
--domains example.com,www.example.com \
--path /var/www/example.com
```
For renewal, just run this command again.
To revoke a certificate, you need a valid account key currently, just like for issuance.
```
bin/acme revoke --name example.com
```
For renewal, there's the `bin/acme check` subcommand.
It exists with a non-zero exit code, if the certificate is going to expire soon.
Default check time is 30 days, but you can use `--ttl` to customize it.
You may use this as daily cron:
```
bin/acme check --name example.com --ttl 30 -s letsencrypt || bin/acme issue ...
sudo bin/acme revoke \
--server acme-v01.api.letsencrypt.org/directory \
--cert data/live/example.com/cert.pem
```

View File

@@ -7,38 +7,9 @@ use Kelunik\AcmeClient\LoggerColorScheme;
use League\CLImate\CLImate;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
if (!file_exists(__DIR__ . "/../vendor/autoload.php")) {
echo <<<HELP
____ __________ ___ ___
/ __ `/ ___/ __ `__ \/ _ \
/ /_/ / /__/ / / / / / __/
\__,_/\___/_/ /_/ /_/\___/
You need to install the composer dependencies.
composer install --no-dev
HELP;
exit(-1);
}
require __DIR__ . "/../vendor/autoload.php";
$commands = [
"setup",
"issue",
"check",
"revoke",
];
$help = implode("\n ", array_map(function($command) {
return "bin/acme {$command}";
}, $commands));
$help = <<<EOT
____ __________ ___ ___
@@ -49,55 +20,51 @@ $help = <<<EOT
Usage: bin/acme command --args
Available Commands:
{$help}
bin/acme register
bin/acme issue
bin/acme revoke
Get more help by appending --help to specific commands.
EOT;
$commands = [
"issue" => "Kelunik\\AcmeClient\\Commands\\Issue",
"register" => "Kelunik\\AcmeClient\\Commands\\Register",
"revoke" => "Kelunik\\AcmeClient\\Commands\\Revoke",
];
$climate = new CLImate;
$injector = new Injector;
if (!in_array(PHP_SAPI, ["cli", "phpdbg"], true)) {
$climate->error("Please run this script via CLI!");
if (!isset($argv)) {
$climate->error("\$argv is not defined");
exit(1);
}
if (count($argv) === 1 || in_array($argv[1], ["h", "-h", "help", "--help"], true)) {
if (count($argv) === 1 || $argv[1] === "-h" || $argv[1] === "--help" || $argv[1] === "help") {
print $help;
exit(0);
}
if (!in_array($argv[1], $commands)) {
$climate->error("Unknown command '{$argv[1]}'. Use --help for a list of available commands.");
$suggestion = \Kelunik\AcmeClient\suggestCommand($argv[1], $commands);
if ($suggestion) {
$climate->br()->info(" Did you mean '$suggestion'?")->br();
}
if (!array_key_exists($argv[1], $commands)) {
$climate->error("Unknown command: '{$argv[1]}'");
exit(1);
}
/** @var \Kelunik\AcmeClient\Commands\Command $class */
$class = "Kelunik\\AcmeClient\\Commands\\" . ucfirst($argv[1]);
$definition = $class::getDefinition();
try {
$args = $argv;
unset($args[1]);
$climate->arguments->add($definition);
$climate->arguments->parse(array_values($args));
$climate->arguments->add($commands[$argv[1]]::getDefinition());
$climate->arguments->parse();
} catch (Exception $e) {
if (count($argv) === 3 && in_array($argv[2], ["h", "-h", "--help", "help"], true)) {
$climate->usage(["bin/acme {$argv[1]}"]);
$climate->br();
if ($climate->arguments->defined("help")) {
print $help;
exit(0);
} else {
$climate->error($e->getMessage());
exit(1);
}
}
@@ -108,13 +75,15 @@ $handler->setFormatter(new ColoredLineFormatter(new LoggerColorScheme, null, nul
$logger = new Logger("ACME");
$logger->pushHandler($handler);
$injector->alias(LoggerInterface::class, Logger::class);
$injector->alias("Psr\\Log\\LoggerInterface", Logger::class);
$injector->share($logger);
$command = $injector->make($class);
$command = $injector->make($commands[$argv[1]]);
Amp\run(function () use ($command, $climate, $logger) {
$handler = function($e) use ($logger) {
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
$error = (string) $e;
$lines = explode("\n", $error);
$lines = array_filter($lines, function ($line) {
@@ -126,14 +95,6 @@ Amp\run(function () use ($command, $climate, $logger) {
}
exit(1);
};
try {
yield $command->execute($climate->arguments);
} catch (Throwable $e) {
$handler($e);
} catch (Exception $e) {
$handler($e);
}
Amp\stop();

View File

@@ -1,28 +1,17 @@
{
"name": "kelunik/acme-client",
"description": "Standalone PHP ACME client.",
"keywords": [
"ACME",
"letsencrypt",
"certificate",
"https",
"encryption",
"ssl",
"tls"
],
"require": {
"amphp/process": "^0.1.1",
"bramus/monolog-colored-line-formatter": "^2",
"php": ">=7.0.0",
"ext-posix": "*",
"ext-openssl": "*",
"bramus/monolog-colored-line-formatter": "^2",
"kelunik/acme": "^0.3",
"kelunik/certificate": "^1",
"league/climate": "^3",
"monolog/monolog": "^1.17",
"php": "^5.5|^7",
"psr/log": "^1",
"rdlowrey/auryn": "^1",
"webmozart/assert": "^1"
"rdlowrey/auryn": "^1"
},
"license": "MIT",
"authors": [
@@ -36,12 +25,6 @@
"autoload": {
"psr-4": {
"Kelunik\\AcmeClient\\": "src"
},
"files": [
"src/functions.php"
]
},
"require-dev": {
"phpunit/phpunit": "^5"
}
}
}

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="./vendor/autoload.php">
<php>
<ini name="memory_limit" value="1G"/>
<ini name="error_reporting" value="-1"/>
</php>
<testsuites>
<testsuite name="Tests">
<directory>./test</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src/</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -1,67 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
class Check implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
/**
* @param Manager $args
* @return \Generator
*/
private function doExecute(Manager $args) {
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$server = \Kelunik\AcmeClient\serverToKeyname($server);
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $server;
$certificateStore = new CertificateStore($path);
$pem = (yield $certificateStore->get($args->get("name")));
$cert = new Certificate($pem);
$this->logger->info("Certificate is valid until " . date("d.m.Y", $cert->getValidTo()));
if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) {
exit(0);
}
$this->logger->warning("Certificate is going to expire within the specified " . $args->get("ttl") . " days.");
exit(1);
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to check.",
"required" => true,
],
"ttl" => [
"longPrefix" => "ttl",
"description" => "Minimum valid time in days.",
"defaultValue" => 30,
"castTo" => "int",
],
];
}
}

View File

@@ -2,10 +2,11 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Promise;
use League\CLImate\Argument\Manager;
interface Command {
public function execute(Manager $args);
public function execute(Manager $args): Promise;
public static function getDefinition();
public static function getDefinition(): array;
}

View File

@@ -2,20 +2,22 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns as dns;
use Amp\Dns\Record;
use Exception;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\ChallengeStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use stdClass;
use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
class Issue implements Command {
private $logger;
@@ -24,68 +26,81 @@ class Issue implements Command {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwnam(posix_geteuid());
$currentUsername = $processUser["name"];
$user = $args->get("user") ?: $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root!");
}
} else {
$user = $args->get("user") ?: "www-data";
private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
$processUser = posix_getpwuid(posix_geteuid());
$currentUsername = $processUser['name'];
$user = $args->get("user") ?? $currentUsername;
if ($currentUsername !== $user) {
throw new AcmeException("Running this script with --user only works as root");
}
} else {
$user = $args->get("user") ?? "www-data";
}
$domains = array_map("trim", explode(":", $args->get("domains")));
yield \Amp\resolve($this->checkDnsRecords($domains));
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
try {
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
} catch (KeyStoreException $e) {
$this->logger->error("Account key not found, did you run 'bin/acme setup'?");
exit(1);
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$domains = $args->get("domains");
$domains = array_map("trim", explode(",", $domains));
yield from $this->checkDnsRecords($domains);
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
foreach ($domains as $domain) {
list($location, $challenges) = (yield $acme->requestChallenges($domain));
list($location, $challenges) = yield $acme->requestChallenges($domain);
$goodChallenges = $this->findSuitableCombination($challenges);
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 server can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
$token = $challenge->token;
if (!preg_match("#^[a-zA-Z0-9-_]+$#", $token)) {
throw new AcmeException("Protocol violation: Invalid Token!");
throw new AcmeException("Protocol Violation: Invalid Token!");
}
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($keyPair, $token);
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/");
$challengeStore = new ChallengeStore($docRoot);
$docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\");
$path = $docRoot . "/.well-known/acme-challenge";
try {
$challengeStore->put($token, $payload, isset($user) ? $user : null);
if (!file_exists($docRoot)) {
throw new AcmeException("Document root doesn't exist: " . $docRoot);
}
if (!file_exists($path) && !@mkdir($path, 0770, true)) {
throw new AcmeException("Couldn't create public dir to serve the challenges: " . $path);
}
if (!$userInfo = posix_getpwnam($user)) {
throw new AcmeException("Unknown user: " . $user);
}
chown($docRoot . "/.well-known", $userInfo["uid"]);
chown($docRoot . "/.well-known/acme-challenge", $userInfo["uid"]);
$this->logger->info("Providing payload for {$domain} at {$path}/{$token}");
file_put_contents("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0664);
yield $acme->verifyHttp01Challenge($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
@@ -96,51 +111,66 @@ class Issue implements Command {
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
yield $challengeStore->delete($token);
} catch (Exception $e) {
// no finally because generators...
yield $challengeStore->delete($token);
throw $e;
@unlink("{$path}/{$token}");
} catch (Throwable $e) {
// no finally because generators...
yield $challengeStore->delete($token);
@unlink("{$path}/{$token}");
throw $e;
}
}
$path = "certs/" . $keyFile . "/" . reset($domains) . "/key.pem";
$bits = $args->get("bits");
$path = __DIR__ . "/../../data/live/" . reset($domains);
try {
$keyPair = (yield $keyStore->get($path));
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = (yield $keyStore->put($path, $keyPair));
if (!file_exists($path) && !mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create directory: {$path}");
}
if (file_exists($path . "/private.pem") && file_exists($path . "/public.pem")) {
$private = file_get_contents($path . "/private.pem");
$public = file_get_contents($path . "/public.pem");
$this->logger->info("Using existing domain key found at {$path}");
$domainKeys = new KeyPair($private, $public);
} else {
$domainKeys = (new OpenSSLKeyGenerator)->generate(2048);
file_put_contents($path . "/private.pem", $domainKeys->getPrivate());
file_put_contents($path . "/public.pem", $domainKeys->getPublic());
$this->logger->info("Saved new domain key at {$path}");
chmod($path . "/private.pem", 0600);
chmod($path . "/public.pem", 0600);
}
$this->logger->info("Requesting certificate ...");
$location = (yield $acme->requestCertificate($keyPair, $domains));
$certificates = (yield $acme->pollForCertificate($location));
$location = yield $acme->requestCertificate($domainKeys, $domains);
$certificates = yield $acme->pollForCertificate($location);
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile;
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
$this->logger->info("Saving certificate ...");
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
file_put_contents($path . "/cert.pem", reset($certificates));
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates));
array_shift($certificates);
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$this->logger->info("Successfully issued certificate.");
}
private function checkDnsRecords($domains) {
private function checkDnsRecords($domains): Generator {
$promises = [];
foreach ($domains as $domain) {
$promises[$domain] = \Amp\Dns\resolve($domain, [
$promises[$domain] = dns\resolve($domain, [
"types" => [Record::A],
"hosts" => false,
]);
}
list($errors) = (yield \Amp\any($promises));
list($errors) = yield any($promises);
if (!empty($errors)) {
throw new AcmeException("Couldn't resolve the following domains to an IPv4 record: " . implode(array_keys($errors)));
@@ -149,9 +179,37 @@ class Issue implements Command {
$this->logger->info("Checked DNS records, all fine.");
}
private function findSuitableCombination(stdClass $response) {
$challenges = isset($response->challenges) ? $response->challenges : [];
$combinations = isset($response->combinations) ? $response->combinations : [];
private function checkRegistration(Manager $args) {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if (file_exists($pathPrivate) && file_exists($pathPublic)) {
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Found account keys.");
return new KeyPair($private, $public);
}
throw new AcmeException("No registration found for server, please register first");
}
private function findSuitableCombination(stdClass $response): array {
$challenges = $response->challenges ?? [];
$combinations = $response->combinations ?? [];
$goodChallenges = [];
foreach ($challenges as $i => $challenge) {
@@ -169,36 +227,31 @@ class Issue implements Command {
return $goodChallenges;
}
public static function getDefinition() {
public static function getDefinition(): array {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "Server to use for issuance, see also 'bin/acme setup'.",
"required" => true,
],
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Colon separated list of domains to request a certificate for.",
"description" => "Domains to request a certificate for.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"required" => true,
],
"user" => [
"prefix" => "s",
"longPrefix" => "user",
"description" => "User for the public directory.",
"required" => false,
],
"path" => [
"prefix" => "p",
"longPrefix" => "path",
"description" => "Colon separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.",
"required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
"description" => "Path to the document root for ACME challenges.",
"required" => false,
],
];
}

111
src/Commands/Register.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\KeyPair;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\resolve;
class Register implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
public function doExecute(Manager $args): Generator {
$email = $args->get("email");
yield resolve($this->checkEmail($email));
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if ((yield exists($pathPrivate)) && (yield exists($pathPublic))) {
$this->logger->info("Loading existing keys ...");
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$keyPair = new KeyPair($private, $public);
} else {
$this->logger->info("Generating key keys ...");
$keyPair = (new OpenSSLKeyGenerator)->generate(4096);
if (!mkdir($path, 0700, true)) {
throw new AcmeException("Couldn't create account directory");
}
file_put_contents($pathPrivate, $keyPair->getPrivate());
file_put_contents($pathPublic, $keyPair->getPublic());
chmod($pathPrivate, 0600);
}
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$this->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = yield $acme->register($email);
$this->logger->notice("Registration successful with contact " . json_encode($registration->getContact()));
}
private function checkEmail(string $email): Generator {
$host = substr($email, strrpos($email, "@") + 1);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
}
}
public static function getDefinition(): array {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to register for.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email to be notified about important account issues.",
"required" => true,
],
];
}
}

View File

@@ -2,17 +2,18 @@
namespace Kelunik\AcmeClient\Commands;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Acme\KeyPair;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\File\exists;
use function Amp\File\get;
use function Amp\resolve;
class Revoke implements Command {
private $logger;
@@ -21,52 +22,80 @@ class Revoke implements Command {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args) {
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
private function doExecute(Manager $args): Generator {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem"));
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair));
$this->logger->info("Revoking certificate ...");
$path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem";
try {
$pem = (yield \Amp\File\get($path));
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate (" . $path . ")");
}
$pem = yield get($args->get("cert"));
$cert = new Certificate($pem);
if ($cert->getValidTo() < time()) {
$this->logger->warning("Certificate did already expire, no need to revoke it.");
return;
}
$this->logger->info("Certificate was valid for: " . implode(", ", $cert->getNames()));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile))->delete($args->get("name"));
yield $acme->revokeCertificate($pem);
$this->logger->info("Certificate has been revoked.");
}
public static function getDefinition() {
private function checkRegistration(Manager $args) {
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
if (file_exists($pathPrivate) && file_exists($pathPublic)) {
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Found account keys.");
return new KeyPair($private, $public);
}
throw new AcmeException("No registration found for server, please register first");
}
public static function getDefinition(): array {
return [
"cert" => [
"prefix" => "c",
"longPrefix" => "cert",
"description" => "Certificate to be revoked.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "",
"required" => true,
],
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"description" => "ACME server to use for authorization.",
"required" => true,
],
];

View File

@@ -1,100 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Dns\Record;
use Amp\Dns\ResolutionException;
use Amp\Promise;
use Generator;
use InvalidArgumentException;
use Kelunik\Acme\AcmeClient;
use Kelunik\Acme\AcmeException;
use Kelunik\Acme\AcmeService;
use Kelunik\Acme\OpenSSLKeyGenerator;
use Kelunik\Acme\Registration;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use RuntimeException;
class Setup implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args) {
return \Amp\resolve($this->doExecute($args));
}
public function doExecute(Manager $args) {
$email = $args->get("email");
yield \Amp\resolve($this->checkEmail($email));
$server = \Kelunik\AcmeClient\resolveServer($args->get("server"));
$keyFile = \Kelunik\AcmeClient\serverToKeyname($server);
$path = "accounts/{$keyFile}.pem";
$bits = 4096;
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
try {
$this->logger->info("Loading private key ...");
$keyPair = (yield $keyStore->get($path));
$this->logger->info("Existing private key successfully loaded.");
} catch (KeyStoreException $e) {
$this->logger->info("No existing private key found, generating new one ...");
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$this->logger->info("Generated new private key with {$bits} bits.");
$this->logger->info("Saving new private key ...");
$keyPair = (yield $keyStore->put($path, $keyPair));
$this->logger->info("New private key successfully saved.");
}
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$this->logger->info("Registering with ACME server " . substr($server, 8) . " ...");
/** @var Registration $registration */
$registration = (yield $acme->register($email));
$this->logger->notice("Registration successful with the following contact information: " . implode(", ", $registration->getContact()));
}
private function checkEmail($email) {
if (!is_string($email)) {
throw new InvalidArgumentException(sprintf("\$email must be of type string, %s given.", gettype($email)));
}
$host = substr($email, strrpos($email, "@") + 1);
if (!$host) {
throw new AcmeException("Invalid contact email: '{$email}'");
}
try {
yield \Amp\Dns\query($host, Record::MX);
} catch (ResolutionException $e) {
throw new AcmeException("No MX record defined for '{$host}'");
}
}
public static function getDefinition() {
return [
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email for important issues, will be sent to the ACME server.",
"required" => true,
],
];
}
}

View File

@@ -1,93 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use Webmozart\Assert\Assert;
class CertificateStore {
private $root;
public function __construct($root) {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get($name) {
return \Amp\resolve($this->doGet($name));
}
private function doGet($name) {
Assert::string($name, "Name must be a string. Got: %s");
try {
$contents = (yield \Amp\File\get($this->root . "/" . $name . "/cert.pem"));
yield new CoroutineResult($contents);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Failed to load certificate.", 0, $e);
}
}
public function put(array $certificates) {
return \Amp\resolve($this->doPut($certificates));
}
private function doPut(array $certificates) {
if (empty($certificates)) {
throw new InvalidArgumentException("Empty array not allowed");
}
$cert = new Certificate($certificates[0]);
$commonName = $cert->getSubject()->getCommonName();
if (!$commonName) {
throw new CertificateStoreException("Certificate doesn't have a common name.");
}
// See https://github.com/amphp/dns/blob/4c4d450d4af26fc55dc56dcf45ec7977373a38bf/lib/functions.php#L83
if (isset($commonName[253]) || !preg_match("~^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9]){0,1})(?:\\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*$~i", $commonName)) {
throw new CertificateStoreException("Invalid common name: '{$commonName}'");
}
try {
$chain = array_slice($certificates, 1);
$path = $this->root . "/" . $commonName;
$realpath = realpath($path);
if (!$realpath && !mkdir($path, 0775, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
yield \Amp\File\put($path . "/cert.pem", $certificates[0]);
yield \Amp\File\chmod($path . "/cert.pem", 0644);
yield \Amp\File\put($path . "/fullchain.pem", implode("\n", $certificates));
yield \Amp\File\chmod($path . "/fullchain.pem", 0644);
yield \Amp\File\put($path . "/chain.pem", implode("\n", $chain));
yield \Amp\File\chmod($path . "/chain.pem", 0644);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
}
public function delete($name) {
return \Amp\resolve($this->doDelete($name));
}
private function doDelete($name) {
Assert::string($name, "Name must be a string. Got: %s");
foreach ((yield \Amp\File\scandir($this->root . "/" . $name)) as $file) {
yield \Amp\File\unlink($this->root . "/" . $name . "/" . $file);
}
yield \Amp\File\rmdir($this->root . "/" . $name);
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class CertificateStoreException extends RuntimeException {
}

View File

@@ -1,73 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use InvalidArgumentException;
use Webmozart\Assert\Assert;
class ChallengeStore {
private $docroot;
public function __construct($docroot) {
if (!is_string($docroot)) {
throw new InvalidArgumentException(sprintf("\$docroot must be of type string, %s given.", gettype($docroot)));
}
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
}
public function put($token, $payload, $user = null) {
return \Amp\resolve($this->doPut($token, $payload, $user));
}
private function doPut($token, $payload, $user = null) {
Assert::string($token, "Token must be a string. Got: %s");
Assert::string($payload, "Payload must be a string. Got: %s");
Assert::nullOrString($user, "User must be a string or null. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge";
$realpath = realpath($path);
if (!realpath($this->docroot)) {
throw new ChallengeStoreException("Document root doesn't exist: '{$this->docroot}'");
}
if (!$realpath && !@mkdir($path, 0755, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if ($user) {
if (!$userInfo = posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
}
if (isset($userInfo)) {
yield \Amp\File\chown($this->docroot . "/.well-known", $userInfo["uid"], -1);
yield \Amp\File\chown($this->docroot . "/.well-known/acme-challenge", $userInfo["uid"], -1);
}
yield \Amp\File\put("{$path}/{$token}", $payload);
if (isset($userInfo)) {
yield \Amp\File\chown("{$path}/{$token}", $userInfo["uid"], -1);
}
yield \Amp\File\chmod("{$path}/{$token}", 0644);
}
public function delete($token) {
return \Amp\resolve($this->doDelete($token));
}
private function doDelete($token) {
Assert::string($token, "Token must be a string. Got: %s");
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield \Amp\File\unlink($realpath);
}
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class ChallengeStoreException extends RuntimeException {
}

View File

@@ -1,82 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\CoroutineResult;
use Amp\File\FilesystemException;
use InvalidArgumentException;
use Kelunik\Acme\KeyPair;
class KeyStore {
private $root;
public function __construct($root = "") {
if (!is_string($root)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($root)));
}
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doGet($path));
}
private function doGet($path) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = (yield \Amp\File\get($realpath));
$res = openssl_pkey_get_private($privateKey);
if ($res === false) {
throw new KeyStoreException("Invalid private key: '{$file}'");
}
$publicKey = openssl_pkey_get_details($res)["key"];
yield new CoroutineResult(new KeyPair($privateKey, $publicKey));
}
public function put($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
return \Amp\resolve($this->doPut($path, $keyPair));
}
private function doPut($path, KeyPair $keyPair) {
if (!is_string($path)) {
throw new InvalidArgumentException(sprintf("\$root must be of type string, %s given.", gettype($path)));
}
$file = $this->root . "/" . $path;
try {
// TODO: Replace with async version once available
if (!file_exists(dirname($file))) {
mkdir(dirname($file), 0755, true);
}
yield \Amp\File\put($file, $keyPair->getPrivate());
yield \Amp\File\chmod($file, 0600);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
}
yield new CoroutineResult($keyPair);
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use RuntimeException;
class KeyStoreException extends RuntimeException {
}

View File

@@ -1,59 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
use Webmozart\Assert\Assert;
function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) {
Assert::string($badCommand, "Bad command must be a string. Got: %s");
Assert::integer($suggestThreshold, "Suggest threshold must be an integer. Got: %s");
$badCommand = strtolower($badCommand);
$bestMatch = "";
$bestMatchPercentage = 0;
$byRefPercentage = 0;
foreach ($commands as $command) {
\similar_text($badCommand, strtolower($command), $byRefPercentage);
if ($byRefPercentage > $bestMatchPercentage) {
$bestMatchPercentage = $byRefPercentage;
$bestMatch = $command;
}
}
return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : "";
}
function resolveServer($uri) {
Assert::string($uri, "URI must be a string. Got: %s");
$shortcuts = [
"letsencrypt" => "https://acme-v01.api.letsencrypt.org/directory",
"letsencrypt:production" => "https://acme-v01.api.letsencrypt.org/directory",
"letsencrypt:staging" => "https://acme-staging.api.letsencrypt.org/directory",
];
if (isset($shortcuts[$uri])) {
return $shortcuts[$uri];
}
$protocol = substr($uri, 0, strpos($uri, "://"));
if (!$protocol || $protocol === $uri) {
return "https://{$uri}";
} else {
return $uri;
}
}
function serverToKeyname($server) {
$server = substr($server, strpos($server, "://") + 3);
$keyFile = str_replace("/", ".", $server);
$keyFile = preg_replace("@[^a-z0-9._-]@", "", $keyFile);
$keyFile = preg_replace("@\\.+@", ".", $keyFile);
return $keyFile;
}

View File

@@ -1,18 +0,0 @@
<?php
namespace Kelunik\AcmeClient;
class FunctionsTest extends \PHPUnit_Framework_TestCase {
public function testResolveServer() {
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("letsencrypt:production"));
$this->assertSame("https://acme-staging.api.letsencrypt.org/directory", resolveServer("letsencrypt:staging"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("acme-v01.api.letsencrypt.org/directory"));
$this->assertSame("https://acme-v01.api.letsencrypt.org/directory", resolveServer("https://acme-v01.api.letsencrypt.org/directory"));
}
public function testSuggestCommand() {
$this->assertSame("acme", suggestCommand("acme!", ["acme"]));
$this->assertSame("", suggestCommand("issue", ["acme"]));
}
}