This commit is contained in:
2024-08-05 22:57:28 +08:00
commit 0efda6c02a
1779 changed files with 171774 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
/**
* Encode and decode safely in base64.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class Base64SafeEncoder
{
public function encode(string $input): string
{
return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
}
public function decode(string $input): string
{
$remainder = \strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= str_repeat('=', $padlen);
}
return base64_decode(strtr($input, '-_', '+/'));
}
}
+421
View File
@@ -0,0 +1,421 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Core\Exception\AcmeCoreClientException;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Protocol\ExpectedJsonException;
use AcmePhp\Core\Exception\Server\BadNonceServerException;
use AcmePhp\Core\Protocol\ExternalAccount;
use AcmePhp\Core\Util\JsonDecoder;
use AcmePhp\Ssl\KeyPair;
use AcmePhp\Ssl\Parser\KeyParser;
use AcmePhp\Ssl\Signer\DataSigner;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Psr\Http\Message\ResponseInterface;
/**
* Guzzle HTTP client wrapper to send requests signed with the account KeyPair.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class SecureHttpClient
{
/**
* @var KeyPair
*/
private $accountKeyPair;
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var Base64SafeEncoder
*/
private $base64Encoder;
/**
* @var KeyParser
*/
private $keyParser;
/**
* @var DataSigner
*/
private $dataSigner;
/**
* @var ServerErrorHandler
*/
private $errorHandler;
/**
* @var ResponseInterface
*/
private $lastResponse;
/**
* @var string
*/
private $nonceEndpoint;
public function __construct(
KeyPair $accountKeyPair,
ClientInterface $httpClient,
Base64SafeEncoder $base64Encoder,
KeyParser $keyParser,
DataSigner $dataSigner,
ServerErrorHandler $errorHandler
) {
$this->accountKeyPair = $accountKeyPair;
$this->httpClient = $httpClient;
$this->base64Encoder = $base64Encoder;
$this->keyParser = $keyParser;
$this->dataSigner = $dataSigner;
$this->errorHandler = $errorHandler;
}
public function getJWK(): array
{
$privateKey = $this->accountKeyPair->getPrivateKey();
$parsedKey = $this->keyParser->parse($privateKey);
switch ($parsedKey->getType()) {
case OPENSSL_KEYTYPE_RSA:
return [
// this order matters
'e' => $this->base64Encoder->encode($parsedKey->getDetail('e')),
'kty' => 'RSA',
'n' => $this->base64Encoder->encode($parsedKey->getDetail('n')),
];
case OPENSSL_KEYTYPE_EC:
return [
// this order matters
'crv' => 'P-'.$parsedKey->getBits(),
'kty' => 'EC',
'x' => $this->base64Encoder->encode($parsedKey->getDetail('x')),
'y' => $this->base64Encoder->encode($parsedKey->getDetail('y')),
];
default:
throw new AcmeCoreClientException('Private key type not supported');
}
}
public function getJWKThumbprint(): string
{
return hash('sha256', json_encode($this->getJWK()), true);
}
/**
* Generates a payload signed with account's KID.
*
* @param string|array|null $payload
*/
public function signKidPayload(string $endpoint, string $account, $payload = null, bool $withNonce = true): array
{
$protected = ['alg' => $this->getAlg(), 'kid' => $account, 'url' => $endpoint];
if ($withNonce) {
$protected['nonce'] = $this->getNonce();
}
return $this->signPayload($protected, $payload);
}
/**
* Generates a payload signed with JWK.
*
* @param string|array|null $payload
*/
public function signJwkPayload(string $endpoint, $payload = null, bool $withNonce = true): array
{
$protected = ['alg' => $this->getAlg(), 'jwk' => $this->getJWK(), 'url' => $endpoint];
if ($withNonce) {
$protected['nonce'] = $this->getNonce();
}
return $this->signPayload($protected, $payload);
}
/**
* Generates an External Account Binding payload signed with JWS.
*
* @param string|array|null $payload
*/
public function createExternalAccountPayload(ExternalAccount $externalAccount, string $url): array
{
$signer = new Sha256();
$protected = [
'alg' => method_exists($signer, 'algorithmId') ? $signer->algorithmId() : $signer->getAlgorithmId(),
'kid' => $externalAccount->getId(),
'url' => $url,
];
$encodedProtected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES));
$encodedPayload = $this->base64Encoder->encode(json_encode($this->getJWK(), JSON_UNESCAPED_SLASHES));
$hmacKey = $this->base64Encoder->decode($externalAccount->getHmacKey());
$hmacKey = class_exists(InMemory::class) ? InMemory::plainText($hmacKey) : $hmacKey;
$signature = $this->base64Encoder->encode($signer->sign($encodedProtected.'.'.$encodedPayload, $hmacKey));
return [
'protected' => $encodedProtected,
'payload' => $encodedPayload,
'signature' => $signature,
];
}
/**
* Send a request encoded in the format defined by the ACME protocol
* and its content (optionally parsed as JSON).
*
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ExpectedJsonException when $returnJson = true and the response is not valid JSON
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
*
* @return array|string Array of parsed JSON if $returnJson = true, string otherwise
*/
public function request(string $method, string $endpoint, array $data = [], bool $returnJson = true)
{
$response = $this->rawRequest($method, $endpoint, $data);
$body = Utils::copyToString($response->getBody());
if (!$returnJson) {
return $body;
}
try {
if ('' === $body) {
throw new \InvalidArgumentException('Empty body received.');
}
$data = JsonDecoder::decode($body, true);
} catch (\InvalidArgumentException $exception) {
throw new ExpectedJsonException(sprintf('ACME client expected valid JSON as a response to request "%s %s" (given: "%s")', $method, $endpoint, ServerErrorHandler::getResponseBodySummary($response)), $exception);
}
return $data;
}
/**
* Send a request encoded in the format defined by the ACME protocol and return the response object.
*
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ExpectedJsonException when $returnJson = true and the response is not valid JSON
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
*/
public function rawRequest(string $method, string $endpoint, array $data = []): ResponseInterface
{
$call = function () use ($method, $endpoint, $data) {
$request = $this->createRequest($method, $endpoint, $data);
try {
$this->lastResponse = $this->httpClient->send($request);
} catch (\Exception $exception) {
$this->handleClientException($request, $exception);
}
return $request;
};
try {
$call();
} catch (BadNonceServerException $e) {
$call();
}
return $this->lastResponse;
}
public function setAccountKeyPair(KeyPair $keyPair)
{
$this->accountKeyPair = $keyPair;
}
public function getLastCode(): int
{
return $this->lastResponse->getStatusCode();
}
public function getLastLocation(): string
{
return $this->lastResponse->getHeaderLine('Location');
}
public function getLastLinks(): array
{
return Header::parse($this->lastResponse->getHeader('Link'));
}
public function getAccountKeyPair(): KeyPair
{
return $this->accountKeyPair;
}
public function getKeyParser(): KeyParser
{
return $this->keyParser;
}
public function getDataSigner(): DataSigner
{
return $this->dataSigner;
}
public function setNonceEndpoint(string $endpoint)
{
$this->nonceEndpoint = $endpoint;
}
public function getBase64Encoder(): Base64SafeEncoder
{
return $this->base64Encoder;
}
/**
* Sign the given Payload.
*/
private function signPayload(array $protected, array $payload = null): array
{
if (!isset($protected['alg'])) {
throw new \InvalidArgumentException('The property "alg" is required in the protected array');
}
$alg = $protected['alg'];
$privateKey = $this->accountKeyPair->getPrivateKey();
list($algorithm, $format) = $this->extractSignOptionFromJWSAlg($alg);
$encodedProtected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES));
if (null === $payload) {
$encodedPayload = '';
} elseif ([] === $payload) {
$encodedPayload = $this->base64Encoder->encode('{}');
} else {
$encodedPayload = $this->base64Encoder->encode(json_encode($payload, JSON_UNESCAPED_SLASHES));
}
$signature = $this->base64Encoder->encode(
$this->dataSigner->signData($encodedProtected.'.'.$encodedPayload, $privateKey, $algorithm, $format)
);
return [
'protected' => $encodedProtected,
'payload' => $encodedPayload,
'signature' => $signature,
];
}
private function createRequest($method, $endpoint, $data)
{
$request = new Request($method, $endpoint);
$request = $request->withHeader('Accept', 'application/json,application/jose+json,');
if ('POST' === $method && \is_array($data)) {
$request = $request->withHeader('Content-Type', 'application/jose+json');
$request = $request->withBody(Utils::streamFor(json_encode($data)));
}
return $request;
}
private function handleClientException(Request $request, \Exception $exception)
{
if ($exception instanceof RequestException && $exception->getResponse() instanceof ResponseInterface) {
$this->lastResponse = $exception->getResponse();
throw $this->errorHandler->createAcmeExceptionForResponse($request, $this->lastResponse, $exception);
}
throw new AcmeCoreClientException(sprintf('An error occured during request "%s %s"', $request->getMethod(), $request->getUri()), $exception);
}
private function getNonce(): ?string
{
if ($this->lastResponse && $this->lastResponse->hasHeader('Replay-Nonce')) {
return $this->lastResponse->getHeaderLine('Replay-Nonce');
}
if (null !== $this->nonceEndpoint) {
$this->request('HEAD', $this->nonceEndpoint, [], false);
if ($this->lastResponse->hasHeader('Replay-Nonce')) {
return $this->lastResponse->getHeaderLine('Replay-Nonce');
}
}
return null;
}
private function getAlg(): string
{
$privateKey = $this->accountKeyPair->getPrivateKey();
$parsedKey = $this->keyParser->parse($privateKey);
switch ($parsedKey->getType()) {
case OPENSSL_KEYTYPE_RSA:
return 'RS256';
case OPENSSL_KEYTYPE_EC:
switch ($parsedKey->getBits()) {
case 256:
case 384:
return 'ES'.$parsedKey->getBits();
case 521:
return 'ES512';
}
// no break to let the default case
default:
throw new AcmeCoreClientException('Private key type is not supported');
}
}
private function extractSignOptionFromJWSAlg($alg): array
{
if (!preg_match('/^([A-Z]+)(\d+)$/', $alg, $match)) {
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
if (!\defined('OPENSSL_ALGO_SHA'.$match[2])) {
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
$algorithm = \constant('OPENSSL_ALGO_SHA'.$match[2]);
switch ($match[1]) {
case 'RS':
$format = DataSigner::FORMAT_DER;
break;
case 'ES':
$format = DataSigner::FORMAT_ECDSA;
break;
default:
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
return [$algorithm, $format];
}
}
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Ssl\KeyPair;
use AcmePhp\Ssl\Parser\KeyParser;
use AcmePhp\Ssl\Signer\DataSigner;
use GuzzleHttp\ClientInterface;
/**
* Guzzle HTTP client wrapper to send requests signed with the account KeyPair.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class SecureHttpClientFactory
{
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var Base64SafeEncoder
*/
private $base64Encoder;
/**
* @var KeyParser
*/
private $keyParser;
/**
* @var DataSigner
*/
private $dataSigner;
/**
* @var ServerErrorHandler
*/
private $errorHandler;
public function __construct(
ClientInterface $httpClient,
Base64SafeEncoder $base64Encoder,
KeyParser $keyParser,
DataSigner $dataSigner,
ServerErrorHandler $errorHandler
) {
$this->httpClient = $httpClient;
$this->base64Encoder = $base64Encoder;
$this->keyParser = $keyParser;
$this->dataSigner = $dataSigner;
$this->errorHandler = $errorHandler;
}
/**
* Create a SecureHttpClient using a given account KeyPair.
*/
public function createSecureHttpClient(KeyPair $accountKeyPair): SecureHttpClient
{
return new SecureHttpClient(
$accountKeyPair,
$this->httpClient,
$this->base64Encoder,
$this->keyParser,
$this->dataSigner,
$this->errorHandler
);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Server\BadCsrServerException;
use AcmePhp\Core\Exception\Server\BadNonceServerException;
use AcmePhp\Core\Exception\Server\CaaServerException;
use AcmePhp\Core\Exception\Server\ConnectionServerException;
use AcmePhp\Core\Exception\Server\DnsServerException;
use AcmePhp\Core\Exception\Server\IncorrectResponseServerException;
use AcmePhp\Core\Exception\Server\InternalServerException;
use AcmePhp\Core\Exception\Server\InvalidContactServerException;
use AcmePhp\Core\Exception\Server\InvalidEmailServerException;
use AcmePhp\Core\Exception\Server\MalformedServerException;
use AcmePhp\Core\Exception\Server\OrderNotReadyServerException;
use AcmePhp\Core\Exception\Server\RateLimitedServerException;
use AcmePhp\Core\Exception\Server\RejectedIdentifierServerException;
use AcmePhp\Core\Exception\Server\TlsServerException;
use AcmePhp\Core\Exception\Server\UnauthorizedServerException;
use AcmePhp\Core\Exception\Server\UnknownHostServerException;
use AcmePhp\Core\Exception\Server\UnsupportedContactServerException;
use AcmePhp\Core\Exception\Server\UnsupportedIdentifierServerException;
use AcmePhp\Core\Exception\Server\UserActionRequiredServerException;
use AcmePhp\Core\Util\JsonDecoder;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Create appropriate exception for given server response.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ServerErrorHandler
{
private static $exceptions = [
'badCSR' => BadCsrServerException::class,
'badNonce' => BadNonceServerException::class,
'caa' => CaaServerException::class,
'connection' => ConnectionServerException::class,
'dns' => DnsServerException::class,
'incorrectResponse' => IncorrectResponseServerException::class,
'invalidContact' => InvalidContactServerException::class,
'invalidEmail' => InvalidEmailServerException::class,
'malformed' => MalformedServerException::class,
'orderNotReady' => OrderNotReadyServerException::class,
'rateLimited' => RateLimitedServerException::class,
'rejectedIdentifier' => RejectedIdentifierServerException::class,
'serverInternal' => InternalServerException::class,
'tls' => TlsServerException::class,
'unauthorized' => UnauthorizedServerException::class,
'unknownHost' => UnknownHostServerException::class,
'unsupportedContact' => UnsupportedContactServerException::class,
'unsupportedIdentifier' => UnsupportedIdentifierServerException::class,
'userActionRequired' => UserActionRequiredServerException::class,
];
/**
* Get a response summary (useful for exceptions).
* Use Guzzle method if available (Guzzle 6.1.1+).
*/
public static function getResponseBodySummary(ResponseInterface $response): string
{
// Rewind the stream if possible to allow re-reading for the summary.
if ($response->getBody()->isSeekable()) {
$response->getBody()->rewind();
}
if (method_exists(RequestException::class, 'getResponseBodySummary')) {
return RequestException::getResponseBodySummary($response);
}
$body = Utils::copyToString($response->getBody());
if (\strlen($body) > 120) {
return substr($body, 0, 120).' (truncated...)';
}
return $body;
}
public function createAcmeExceptionForResponse(
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
): AcmeCoreServerException {
$body = Utils::copyToString($response->getBody());
try {
$data = JsonDecoder::decode($body, true);
} catch (\InvalidArgumentException $e) {
$data = null;
}
if (!$data || !isset($data['type'], $data['detail'])) {
// Not JSON: not an ACME error response
return $this->createDefaultExceptionForResponse($request, $response, $previous);
}
$type = preg_replace('/^urn:(ietf:params:)?acme:error:/i', '', $data['type']);
if (!isset(self::$exceptions[$type])) {
// Unknown type: not an ACME error response
return $this->createDefaultExceptionForResponse($request, $response, $previous);
}
$exceptionClass = self::$exceptions[$type];
return new $exceptionClass(
$request,
sprintf('%s (on request "%s %s")', $data['detail'], $request->getMethod(), $request->getUri()),
$previous
);
}
private function createDefaultExceptionForResponse(
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
): AcmeCoreServerException {
return new AcmeCoreServerException(
$request,
sprintf(
'A non-ACME %s HTTP error occured on request "%s %s" (response body: "%s")',
$response->getStatusCode(),
$request->getMethod(),
$request->getUri(),
self::getResponseBodySummary($response)
),
$previous
);
}
}