dnsmgr/app/lib/acme/ACMEv2.php
2024-12-26 14:34:55 +08:00

410 lines
12 KiB
PHP

<?php
namespace app\lib\acme;
use Exception;
class ACMEv2
{ // Communication with Let's Encrypt via ACME v2 protocol
protected
$ch = null, $logger = true, $bits, $sha_bits, $directory, $resources, $jwk_header, $kid_header, $account_key, $thumbprint, $nonce = null, $proxy;
private $delay_until = null;
public function __construct($directory, $proxy = false)
{
$this->directory = $directory;
$this->proxy = $proxy;
}
public function __destruct()
{
if (PHP_MAJOR_VERSION < 8 && $this->account_key) openssl_pkey_free($this->account_key);
if ($this->ch) curl_close($this->ch);
}
public function loadAccountKey($account_key_pem)
{
if (PHP_MAJOR_VERSION < 8 && $this->account_key) openssl_pkey_free($this->account_key);
if (false === ($this->account_key = openssl_pkey_get_private($account_key_pem))) {
throw new Exception('Could not load account key: ' . $account_key_pem . ' (' . $this->get_openssl_error() . ')');
}
if (false === ($details = openssl_pkey_get_details($this->account_key))) {
throw new Exception('Could not get account key details: ' . $account_key_pem . ' (' . $this->get_openssl_error() . ')');
}
$this->bits = $details['bits'];
switch ($details['type']) {
case OPENSSL_KEYTYPE_EC:
if (version_compare(PHP_VERSION, '7.1.0') < 0) throw new Exception('PHP >= 7.1.0 required for EC keys !');
$this->sha_bits = ($this->bits == 521 ? 512 : $this->bits);
$this->jwk_header = array( // JOSE Header - RFC7515
'alg' => 'ES' . $this->sha_bits,
'jwk' => array( // JSON Web Key
'crv' => 'P-' . $details['bits'],
'kty' => 'EC',
'x' => $this->base64url(str_pad($details['ec']['x'], ceil($this->bits / 8), "\x00", STR_PAD_LEFT)),
'y' => $this->base64url(str_pad($details['ec']['y'], ceil($this->bits / 8), "\x00", STR_PAD_LEFT))
)
);
break;
case OPENSSL_KEYTYPE_RSA:
$this->sha_bits = 256;
$this->jwk_header = array( // JOSE Header - RFC7515
'alg' => 'RS256',
'jwk' => array( // JSON Web Key
'e' => $this->base64url($details['rsa']['e']), // public exponent
'kty' => 'RSA',
'n' => $this->base64url($details['rsa']['n']) // public modulus
)
);
break;
default:
throw new Exception('Unsupported key type! Must be RSA or EC key.');
break;
}
$this->kid_header = array(
'alg' => $this->jwk_header['alg'],
'kid' => null
);
$this->thumbprint = $this->base64url( // JSON Web Key (JWK) Thumbprint - RFC7638
hash(
'sha256',
json_encode($this->jwk_header['jwk']),
true
)
);
}
public function getAccountID()
{
if (!$this->kid_header['kid']) self::getAccount();
return $this->kid_header['kid'];
}
public function setLogger($value = true)
{
switch (true) {
case is_bool($value):
break;
case is_callable($value):
break;
default:
throw new Exception('setLogger: invalid value provided');
break;
}
$this->logger = $value;
}
public function log($txt)
{
switch (true) {
case $this->logger === true:
error_log($txt);
break;
case $this->logger === false:
break;
default:
$fn = $this->logger;
$fn($txt);
break;
}
}
protected function create_ACME_Exception($type, $detail, $subproblems = array())
{
$this->log('ACME_Exception: ' . $detail . ' (' . $type . ')');
return new ACME_Exception($type, $detail, $subproblems);
}
protected function get_openssl_error()
{
$out = array();
$arr = error_get_last();
if (is_array($arr)) {
$out[] = $arr['message'];
}
$out[] = openssl_error_string();
return implode(' | ', $out);
}
protected function getAccount()
{
$this->log('Getting account info');
$ret = $this->request('newAccount', array('onlyReturnExisting' => true));
$this->log('Account info retrieved');
return $ret;
}
protected function keyAuthorization($token)
{
return $token . '.' . $this->thumbprint;
}
protected function readDirectory()
{
$this->log('Initializing ACME v2 environment: ' . $this->directory);
$ret = $this->http_request($this->directory); // Read ACME Directory
if (
!is_array($ret['body']) ||
!empty(array_diff_key(
array_flip(array('newNonce', 'newAccount', 'newOrder')),
$ret['body']
))
) {
throw new Exception('Failed to read directory: ' . $this->directory);
}
$this->resources = $ret['body']; // store resources for later use
$this->log('Initialized');
}
protected function request($type, $payload = '', $retry = false)
{
if (!$this->jwk_header) {
throw new Exception('use loadAccountKey to load an account key');
}
if (!$this->resources) $this->readDirectory();
if (0 === stripos($type, 'http')) {
$this->resources['_tmp'] = $type;
$type = '_tmp';
}
try {
$ret = $this->http_request($this->resources[$type], json_encode(
$this->jws_encapsulate($type, $payload)
));
} catch (ACME_Exception $e) { // retry previous request once, if replay-nonce expired/failed
if (!$retry && $e->getType() === 'urn:ietf:params:acme:error:badNonce') {
$this->log('Replay-Nonce expired, retrying previous request');
return $this->request($type, $payload, true);
}
if (!$retry && $e->getType() === 'urn:ietf:params:acme:error:rateLimited' && $this->delay_until !== null) {
return $this->request($type, $payload, true);
}
throw $e; // rethrow all other exceptions
}
if (!$this->kid_header['kid'] && $type === 'newAccount') {
$this->kid_header['kid'] = $ret['headers']['location'];
$this->log('AccountID: ' . $this->kid_header['kid']);
}
return $ret;
}
protected function jws_encapsulate($type, $payload, $is_inner_jws = false)
{ // RFC7515
if ($type === 'newAccount' || $is_inner_jws) {
$protected = $this->jwk_header;
} else {
$this->getAccountID();
$protected = $this->kid_header;
}
if (!$is_inner_jws) {
if (!$this->nonce) {
$ret = $this->http_request($this->resources['newNonce'], false);
}
$protected['nonce'] = $this->nonce;
$this->nonce = null;
}
if (!isset($this->resources[$type])) {
throw new Exception('Resource "' . $type . '" not available.');
}
$protected['url'] = $this->resources[$type];
$protected64 = $this->base64url(json_encode($protected, JSON_UNESCAPED_SLASHES));
$payload64 = $this->base64url(is_string($payload) ? $payload : json_encode($payload, JSON_UNESCAPED_SLASHES));
if (false === openssl_sign(
$protected64 . '.' . $payload64,
$signature,
$this->account_key,
'SHA' . $this->sha_bits
)) {
throw new Exception('Failed to sign payload !' . ' (' . $this->get_openssl_error() . ')');
}
return array(
'protected' => $protected64,
'payload' => $payload64,
'signature' => $this->base64url($this->jwk_header['alg'][0] == 'R' ? $signature : $this->asn2signature($signature, ceil($this->bits / 8)))
);
}
private function asn2signature($asn, $pad_len)
{
if ($asn[0] !== "\x30") throw new Exception('ASN.1 SEQUENCE not found !');
$asn = substr($asn, $asn[1] === "\x81" ? 3 : 2);
if ($asn[0] !== "\x02") throw new Exception('ASN.1 INTEGER 1 not found !');
$R = ltrim(substr($asn, 2, ord($asn[1])), "\x00");
$asn = substr($asn, ord($asn[1]) + 2);
if ($asn[0] !== "\x02") throw new Exception('ASN.1 INTEGER 2 not found !');
$S = ltrim(substr($asn, 2, ord($asn[1])), "\x00");
return str_pad($R, $pad_len, "\x00", STR_PAD_LEFT) . str_pad($S, $pad_len, "\x00", STR_PAD_LEFT);
}
protected function base64url($data)
{ // RFC7515 - Appendix C
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
protected function base64url_decode($data)
{
return base64_decode(strtr($data, '-_', '+/'));
}
private function json_decode($str)
{
$ret = json_decode($str, true);
if ($ret === null) {
throw new Exception('Could not parse JSON: ' . $str);
}
return $ret;
}
protected function http_request($url, $data = null)
{
if ($this->ch === null) {
$this->ch = curl_init();
}
if ($this->delay_until !== null) {
$delta = $this->delay_until - time();
if ($delta > 0) {
$this->log('Delaying ' . $delta . 's (rate limit)');
sleep($delta);
}
$this->delay_until = null;
}
$method = $data === false ? 'HEAD' : ($data === null ? 'GET' : 'POST');
$user_agent = 'ACMECert v3.4.0 (+https://github.com/skoerfgen/ACMECert)';
$header = ($data === null || $data === false) ? array() : array('Content-Type: application/jose+json');
$headers = array();
curl_setopt_array($this->ch, array(
CURLOPT_URL => $url,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_NOBODY => $data === false,
CURLOPT_USERAGENT => $user_agent,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $header,
CURLOPT_POSTFIELDS => $data,
CURLOPT_HEADERFUNCTION => static function ($ch, $header) use (&$headers) {
$headers[] = $header;
return strlen($header);
}
));
if ($this->proxy) {
curl_set_proxy($this->ch);
}
$took = microtime(true);
$body = curl_exec($this->ch);
$took = round(microtime(true) - $took, 2) . 's';
if ($body === false) throw new Exception('HTTP Request Error: ' . curl_error($this->ch));
$headers = array_reduce( // parse http response headers into array
array_filter($headers, function ($item) {
return trim($item) != '';
}),
function ($carry, $item) use (&$code) {
$parts = explode(':', $item, 2);
if (count($parts) === 1) {
list(, $code) = explode(' ', trim($item), 3);
$carry = array();
} else {
list($k, $v) = $parts;
$k = strtolower(trim($k));
switch ($k) {
case 'link':
if (preg_match('/<(.*)>\s*;\s*rel=\"(.*)\"/', $v, $matches)) {
$carry[$k][$matches[2]][] = trim($matches[1]);
}
break;
case 'content-type':
list($v) = explode(';', $v, 2);
default:
$carry[$k] = trim($v);
break;
}
}
return $carry;
},
array()
);
$this->log(' ' . $url . ' [' . $code . '] (' . $took . ')');
if (!empty($headers['replay-nonce'])) $this->nonce = $headers['replay-nonce'];
if (isset($headers['retry-after'])) {
if (is_numeric($headers['retry-after'])) {
$this->delay_until = time() + ceil($headers['retry-after']);
} else {
$this->delay_until = strtotime($headers['retry-after']);
}
$tmp = $this->delay_until - time();
// ignore delay if not in range 1s..5min
if ($tmp > 300 || $tmp < 1) $this->delay_until = null;
}
if (!empty($headers['content-type'])) {
switch ($headers['content-type']) {
case 'application/json':
if ($code[0] == '2') { // on non 2xx response: fall through to problem+json case
$body = $this->json_decode($body);
if (isset($body['error']) && !(isset($body['status']) && $body['status'] === 'valid')) {
$this->handleError($body['error']);
}
break;
}
case 'application/problem+json':
$body = $this->json_decode($body);
$this->handleError($body);
break;
}
}
if ($code[0] != '2') {
throw new Exception('Invalid HTTP-Status-Code received: ' . $code . ': ' . print_r($body, true));
}
$ret = array(
'code' => $code,
'headers' => $headers,
'body' => $body
);
return $ret;
}
private function handleError($error)
{
throw $this->create_ACME_Exception(
$error['type'],
$error['detail'],
array_map(function ($subproblem) {
return $this->create_ACME_Exception(
$subproblem['type'],
(isset($subproblem['identifier']['value']) ?
'"' . $subproblem['identifier']['value'] . '": ' :
''
) . $subproblem['detail']
);
}, isset($error['subproblems']) ? $error['subproblems'] : array())
);
}
}