Major update, add renew command, rename register to setup

This commit is contained in:
Niklas Keller
2015-12-09 20:10:21 +01:00
parent b1e3839323
commit 7f0869150f
15 changed files with 517 additions and 231 deletions

View File

@@ -4,13 +4,17 @@ namespace Kelunik\AcmeClient\Commands;
use Amp\Dns as dns;
use Amp\Dns\Record;
use function Amp\File\put;
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;
@@ -18,6 +22,7 @@ use Throwable;
use function Amp\all;
use function Amp\any;
use function Amp\resolve;
use function Kelunik\AcmeClient\getServer;
class Issue implements Command {
private $logger;
@@ -31,35 +36,22 @@ class Issue implements Command {
}
private function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
throw new AcmeException("Please run this script as root!");
}
$domains = array_map("trim", explode(",", $args->get("domains")));
yield resolve($this->checkDnsRecords($domains));
$user = $args->get("user") ?? "www-data";
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
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), $keyPair);
$keyPair = yield $keyStore->get("account/key.pem");
$acme = new AcmeService(new AcmeClient(getServer(), $keyPair), $keyPair);
foreach ($domains as $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 server can solve!");
throw new AcmeException("Couldn't find any combination of challenges which this client can solve!");
}
$challenge = $challenges->challenges[reset($goodChallenges)];
@@ -72,30 +64,13 @@ class Issue implements Command {
$this->logger->debug("Generating payload...");
$payload = $acme->generateHttp01Payload($token);
$docRoot = rtrim($args->get("path") ?? __DIR__ . "/../../data/public", "/\\");
$path = $docRoot . "/.well-known/acme-challenge";
$this->logger->info("Providing payload at http://{$domain}/.well-known/acme-challenge/{$token}");
$docRoot = rtrim(str_replace("\\", "/", $args->get("path")), "/");
$challengeStore = new ChallengeStore($docRoot);
try {
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}", 0660);
$challengeStore->put($token, $payload, $user);
yield $acme->selfVerify($domain, $token, $payload);
$this->logger->info("Successfully self-verified challenge.");
@@ -106,53 +81,38 @@ class Issue implements Command {
yield $acme->pollForChallenge($location);
$this->logger->info("Challenge successful. {$domain} is now authorized.");
@unlink("{$path}/{$token}");
yield $challengeStore->delete($token);
} catch (Throwable $e) {
// no finally because generators...
@unlink("{$path}/{$token}");
yield $challengeStore->delete($token);
throw $e;
}
}
$path = __DIR__ . "/../../data/live/" . reset($domains);
$path = "certs/" . reset($domains) . "/key.pem";
$bits = $args->get("bits") ?? 2048;
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);
try {
$keyPair = yield $keyStore->get($path);
} catch (KeyStoreException $e) {
$keyPair = (new OpenSSLKeyGenerator)->generate($bits);
$keyPair = yield $keyStore->put($path, $keyPair);
}
$this->logger->info("Requesting certificate ...");
$location = yield $acme->requestCertificate($domainKeys, $domains);
$location = yield $acme->requestCertificate($keyPair, $domains);
$certificates = yield $acme->pollForCertificate($location);
$this->logger->info("Saving certificate ...");
$path = dirname(dirname(__DIR__)) . "/data/certs";
$certificateStore = new CertificateStore($path);
yield $certificateStore->put($certificates);
file_put_contents($path . "/cert.pem", reset($certificates));
file_put_contents($path . "/fullchain.pem", implode("\n", $certificates));
yield put($path . "/" . reset($domains) . "/config.json", json_encode([
"domains" => $domains, "path" => $args->get("path"), "user" => $user, "bits" => $bits
], JSON_PRETTY_PRINT) . "\n");
array_shift($certificates);
file_put_contents($path . "/chain.pem", implode("\n", $certificates));
$this->logger->info("Successfully issued certificate.");
$this->logger->info("Successfully issued certificate, see {$path}/" . reset($domains));
}
private function checkDnsRecords($domains): Generator {
@@ -174,34 +134,6 @@ class Issue implements Command {
$this->logger->info("Checked DNS records, all fine.");
}
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 ?? [];
@@ -227,26 +159,26 @@ class Issue implements Command {
"domains" => [
"prefix" => "d",
"longPrefix" => "domains",
"description" => "Domains to request a certificate for.",
"description" => "Comma separated list of 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" => "Path to the document root for ACME challenges.",
"required" => false,
"description" => "Absolute path to the document root of these domains.",
"required" => true,
],
"user" => [
"prefix" => "u",
"longPrefix" => "user",
"description" => "User running the web server.",
"defaultValue" => "www-data",
],
"bits" => [
"longPrefix" => "bits",
"description" => "Length of the private key in bit.",
"defaultValue" => 2048,
"castTo" => "int",
],
];
}

93
src/Commands/Renew.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
namespace Kelunik\AcmeClient\Commands;
use Amp\Process;
use Amp\Promise;
use Generator;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use function Amp\all;
use function Amp\File\get;
use function Amp\File\scandir;
use function Amp\pipe;
use function Amp\resolve;
class Renew implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function execute(Manager $args): Promise {
return resolve($this->doExecute($args));
}
private function doExecute(Manager $args): Generator {
$path = dirname(dirname(__DIR__)) . "/data/certs";
if (!realpath($path)) {
throw new \RuntimeException("Certificate path doesn't exist: '{$path}'");
}
$domains = yield scandir($path);
$promises = [];
foreach ($domains as $domain) {
$pem = yield get($path . "/" . $domain . "/cert.pem");
$cert = new Certificate($pem);
if ($cert->getValidTo() > time() + 30 * 24 * 60 * 60) {
$this->logger->info("Certificate for " . implode(",", $cert->getNames()) . " is still valid for more than 30 days.");
continue;
}
$json = yield get($path . "/" . $domain . "/config.json");
$config = json_decode($json);
$command = [
PHP_BINARY,
dirname(dirname(__DIR__)) . "/bin/acme",
"issue",
"-d",
implode(",", $config->domains),
"-p",
$config->path,
"-u",
$config->user,
];
$command = array_map("escapeshellarg", $command);
$command = implode(" ", $command);
$promises[] = pipe((new Process($command))->exec()->watch(function ($update) {
list($type, $data) = $update;
if ($type === "err") {
$this->logger->error($data);
} else {
$this->logger->info($data);
}
}), function ($result) use ($command) {
$result->command = $command;
return $result;
});
}
$results = yield all($promises);
foreach ($results as $result) {
if ($result->exit !== 0) {
throw new \RuntimeException("Invalid exit code: " . $result->exit . " (" . $result->command . ")");
}
}
}
public static function getDefinition(): array {
return [];
}
}

View File

@@ -2,12 +2,16 @@
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\Acme\KeyPair;
use function Kelunik\AcmeClient\getServer;
use Kelunik\AcmeClient\Stores\CertificateStore;
use Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\Certificate\Certificate;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
@@ -31,75 +35,36 @@ class Revoke implements Command {
throw new AcmeException("Please run this script as root!");
}
$server = $args->get("server");
$protocol = substr($server, 0, strpos("://", $server));
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
}
$keyPair = $this->checkRegistration($args);
$acme = new AcmeService(new AcmeClient($server, $keyPair), $keyPair);
$keyPair = yield $keyStore->get("account/key.pem");
$acme = new AcmeService(new AcmeClient(getServer(), $keyPair), $keyPair);
$this->logger->info("Revoking certificate ...");
$pem = yield get($args->get("cert"));
$cert = new Certificate($pem);
try {
$pem = yield get(dirname(dirname(__DIR__)) . "/data/certs/" . $args->get("name") . "/cert.pem");
$cert = new Certificate($pem);
} catch (FilesystemException $e) {
throw new \RuntimeException("There's no such certificate!");
}
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.");
}
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");
yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs"))->delete($args->get("name"));
}
public static function getDefinition(): array {
return [
"cert" => [
"prefix" => "c",
"longPrefix" => "cert",
"description" => "Certificate to be revoked.",
"required" => true,
],
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to use for authorization.",
"name" => [
"longPrefix" => "name",
"description" => "Common name of the certificate to be revoked.",
"required" => true,
],
];

View File

@@ -9,15 +9,19 @@ 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 Kelunik\AcmeClient\Stores\KeyStore;
use Kelunik\AcmeClient\Stores\KeyStoreException;
use League\CLImate\Argument\Manager;
use Psr\Log\LoggerInterface;
use RuntimeException;
use function Amp\File\exists;
use function Amp\File\put;
use function Amp\resolve;
use function Kelunik\AcmeClient\serverToIdentity;
class Register implements Command {
class Setup implements Command {
private $logger;
public function __construct(LoggerInterface $logger) {
@@ -29,10 +33,6 @@ class Register implements Command {
}
public function doExecute(Manager $args): Generator {
if (posix_geteuid() !== 0) {
throw new AcmeException("Please run this script as root!");
}
$email = $args->get("email");
yield resolve($this->checkEmail($email));
@@ -42,46 +42,47 @@ class Register implements Command {
if (!$protocol || $protocol === $server) {
$server = "https://" . $server;
} elseif ($protocol !== "https") {
throw new \InvalidArgumentException("Invalid server protocol, only HTTPS supported");
throw new \InvalidArgumentException("Invalid protocol, only https is allowed!");
}
$identity = str_replace(["/", "%"], "-", substr($server, 8));
$path = "account/key.pem";
$bits = 4096;
$path = __DIR__ . "/../../data/accounts";
$pathPrivate = "{$path}/{$identity}.private.key";
$pathPublic = "{$path}/{$identity}.public.key";
$keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data");
if ((yield exists($pathPrivate)) && (yield exists($pathPublic))) {
$this->logger->info("Loading existing keys ...");
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.");
$private = file_get_contents($pathPrivate);
$public = file_get_contents($pathPublic);
$this->logger->info("Saving new private key ...");
$keyPair = yield $keyStore->put($path, $keyPair);
$this->logger->info("New private key successfully saved.");
}
$keyPair = new KeyPair($private, $public);
} else {
$this->logger->info("Generating key keys ...");
$user = $args->get("user") ?? "www-data";
$userInfo = posix_getpwnam($user);
$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, 600);
chmod($pathPrivate, 600);
if (!$userInfo) {
throw new RuntimeException("User doesn't exist: '{$user}'");
}
$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()));
$this->logger->notice("Registration successful with contact " . json_encode($registration->getContact()));
yield put(dirname(dirname(__DIR__)) . "/data/account/config.json", json_encode([
"version" => 1,
"server" => $server,
"email" => $email,
], JSON_PRETTY_PRINT) . "\n");
}
private function checkEmail(string $email): Generator {
@@ -103,12 +104,12 @@ class Register implements Command {
"server" => [
"prefix" => "s",
"longPrefix" => "server",
"description" => "ACME server to register for.",
"description" => "ACME server to use for registration and issuance of certificates.",
"required" => true,
],
"email" => [
"longPrefix" => "email",
"description" => "Email to be notified about important account issues.",
"description" => "Email for important issues, will be sent to the ACME server.",
"required" => true,
],
];

27
src/Configuration.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace Kelunik\AcmeClient;
use RuntimeException;
class Configuration {
private $config;
public function __construct(string $file) {
$json = file_get_contents($file);
if (!$json) {
throw new RuntimeException("Couldn't read config file: '{$file}'");
}
$this->config = json_decode($json);
if (!$this->config) {
throw new RuntimeException("Couldn't read JSON: '{$json}'");
}
}
public function get(string $key) {
return $this->config->{$key} ?? null;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use InvalidArgumentException;
use Kelunik\Certificate\Certificate;
use function Amp\File\put;
use function Amp\File\chmod;
use function Amp\File\chown;
use function Amp\File\scandir;
use function Amp\File\unlink;
use function Amp\resolve;
use function Amp\File\rmdir;
class CertificateStore {
private $root;
public function __construct(string $root) {
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function put(array $certificates): Promise {
return resolve($this->doPut($certificates));
}
private function doPut(array $certificates): Generator {
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, 0770, true)) {
throw new FilesystemException("Couldn't create certificate directory: '{$path}'");
}
yield put($path . "/cert.pem", $certificates[0]);
yield chown($path . "/cert.pem", 0, 0);
yield chmod($path . "/cert.pem", 0640);
yield put($path . "/fullchain.pem", implode("\n", $certificates));
yield chown($path . "/fullchain.pem", 0, 0);
yield chmod($path . "/fullchain.pem", 0640);
yield put($path . "/chain.pem", implode("\n", $chain));
yield chown($path . "/chain.pem", 0, 0);
yield chmod($path . "/chain.pem", 0640);
} catch (FilesystemException $e) {
throw new CertificateStoreException("Couldn't save certificates for '{$commonName}'", 0, $e);
}
}
public function delete(string $name): Promise {
return resolve($this->doDelete($name));
}
private function doDelete(string $name): Generator {
foreach ((yield scandir($this->root . "/" . $name)) as $file) {
yield unlink($this->root . "/" . $name . "/" . $file);
}
yield rmdir($this->root . "/" . $name);
}
}

View File

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

View File

@@ -0,0 +1,60 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\Promise;
use Generator;
use function Amp\File\put;
use function Amp\File\unlink;
use function Amp\resolve;
class ChallengeStore {
private $docroot;
public function __construct(string $docroot) {
$this->docroot = rtrim(str_replace("\\", "/", $docroot), "/");
}
public function put(string $token, string $payload, string $user): Promise {
return resolve($this->doPut($token, $payload, $user));
}
private function doPut(string $token, string $payload, string $user): Generator {
$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, 0770, true)) {
throw new ChallengeStoreException("Couldn't create public directory to serve the challenges: '{$path}'");
}
if (!$userInfo = posix_getpwnam($user)) {
throw new ChallengeStoreException("Unknown user: '{$user}'");
}
// TODO: Make async, see https://github.com/amphp/file/issues/6
chown($this->docroot . "/.well-known", $userInfo["uid"]);
chown($this->docroot . "/.well-known/acme-challenge", $userInfo["uid"]);
yield put("{$path}/{$token}", $payload);
chown("{$path}/{$token}", $userInfo["uid"]);
chmod("{$path}/{$token}", 0660);
}
public function delete(string $token): Promise {
return resolve($this->doDelete($token));
}
private function doDelete(string $token): Generator {
$path = $this->docroot . "/.well-known/acme-challenge/{$token}";
$realpath = realpath($path);
if ($realpath) {
yield unlink($realpath);
}
}
}

View File

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

66
src/Stores/KeyStore.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace Kelunik\AcmeClient\Stores;
use Amp\File\FilesystemException;
use Amp\Promise;
use Generator;
use Kelunik\Acme\KeyPair;
use function Amp\File\chmod;
use function Amp\File\chown;
use function Amp\File\get;
use function Amp\File\put;
use function Amp\resolve;
class KeyStore {
private $root;
public function __construct(string $root = "") {
$this->root = rtrim(str_replace("\\", "/", $root), "/");
}
public function get(string $path): Promise {
return resolve($this->doGet($path));
}
private function doGet(string $path): Generator {
$file = $this->root . "/" . $path;
$realpath = realpath($file);
if (!$realpath) {
throw new KeyStoreException("File not found: '{$file}'");
}
$privateKey = yield 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"];
return new KeyPair($privateKey, $publicKey);
}
public function put(string $path, KeyPair $keyPair): Promise {
return resolve($this->doPut($path, $keyPair));
}
private function doPut(string $path, KeyPair $keyPair): Generator {
$file = $this->root . "/" . $path;
try {
// TODO: Replace with async version once available
mkdir(dirname($file), 0770, true);
yield put($file, $keyPair->getPrivate());
yield chmod($file, 0600);
yield chown($file, 0, 0);
} catch (FilesystemException $e) {
throw new KeyStoreException("Could not save key.", 0, $e);
}
return $keyPair;
}
}

View File

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

16
src/functions.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Kelunik\AcmeClient;
function commandToClass(string $command): string {
return __NAMESPACE__ . "\\Commands\\" . ucfirst($command);
}
function getServer(Configuration $config = null) {
if ($config === null) {
$path = dirname(__DIR__) . "/data";
$config = new Configuration($path . "/account/config.json");
}
return $config->get("server");
}