<?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, $proxy_config = null;
	private $delay_until = null;

    /**
     * @param $directory string ACME directory URL
     * @param $proxy int 代理模式，0为不使用代理，1为使用系统代理，2为使用反向代理
     * @param null $proxy_config array 反向代理配置，proxy参数为2时必填
     * @throws Exception
     */
	public function __construct($directory, $proxy = 0, $proxy_config = null)
    {
		$this->directory = $directory;
		$this->proxy = $proxy;
		if ($proxy == 2) {
			$this->proxy_config = $proxy_config;
		}
	}

	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'] = $this->unproxiedURL($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->unproxiedURL($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;
		}

		// 替换反向代理配置
		$url = $this->proxiedURL($url);

		$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 == 1) {
			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())
		);
	}

	// 替换反向代理配置
	protected function proxiedURL($url)
	{
		if ($this->proxy == 2) {
			return str_replace(
				$this->proxy_config['origin'],
				$this->proxy_config['proxy'],
				$url
			);
		}
		return $url;
	}

	// 反向替换反向代理配置
	protected function unproxiedURL($url)
	{
		if ($this->proxy == 2) {
			return str_replace(
				$this->proxy_config['proxy'],
				$this->proxy_config['origin'],
				$url
			);
		}
		return $url;
	}
}
