mirror of
https://github.com/netcccyun/dnsmgr.git
synced 2026-02-21 15:31:12 +08:00
429 lines
12 KiB
PHP
429 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) {
|
|
$proxy_server = config_get('proxy_server');
|
|
$proxy_port = intval(config_get('proxy_port'));
|
|
$proxy_userpwd = config_get('proxy_user').':'.config_get('proxy_pwd');
|
|
$proxy_type = config_get('proxy_type');
|
|
if ($proxy_type == 'https') {
|
|
$proxy_type = CURLPROXY_HTTPS;
|
|
} elseif ($proxy_type == 'sock4') {
|
|
$proxy_type = CURLPROXY_SOCKS4;
|
|
} elseif ($proxy_type == 'sock5') {
|
|
$proxy_type = CURLPROXY_SOCKS5;
|
|
} else {
|
|
$proxy_type = CURLPROXY_HTTP;
|
|
}
|
|
curl_setopt($this->ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC);
|
|
curl_setopt($this->ch, CURLOPT_PROXY, $proxy_server);
|
|
curl_setopt($this->ch, CURLOPT_PROXYPORT, $proxy_port);
|
|
if ($proxy_userpwd != ':') {
|
|
curl_setopt($this->ch, CURLOPT_PROXYUSERPWD, $proxy_userpwd);
|
|
}
|
|
curl_setopt($this->ch, CURLOPT_PROXYTYPE, $proxy_type);
|
|
}
|
|
|
|
$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())
|
|
);
|
|
}
|
|
}
|