├── phpstan.v7.1.neon ├── composer.json ├── LICENSE ├── CHANGELOG.md ├── src ├── ClientDecoratorInterface.php ├── BuilderChainable.php ├── BuilderTrait.php ├── Crypto │ ├── AesCbc.php │ └── Rsa.php ├── Builder.php ├── Helpers.php ├── Formatter.php └── ClientDecorator.php └── README.md /phpstan.v7.1.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan.neon.dist 3 | parameters: 4 | ignoreErrors: 5 | - 6 | message: "#^Cannot access offset 'AlipayOpenAppQrcode…' on EasyAlipay\\\\BuilderChainable\\.$#" 7 | count: 1 8 | path: tests/BuilderTest.php 9 | 10 | - 11 | message: "#^Cannot access offset 'alipay\\.open\\.app…' on EasyAlipay\\\\BuilderChainable\\.$#" 12 | count: 1 13 | path: tests/BuilderTest.php 14 | 15 | - 16 | message: "#^Parameter \\#2 \\$array of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertArrayHasKey\\(\\) expects array\\|ArrayAccess, array\\\\|false given\\.$#" 17 | count: 1 18 | path: tests/BuilderTest.php 19 | 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easyalipay/easyalipay", 3 | "description": "[A]Sync Chainable Alipay OpenAPI SDK for PHP", 4 | "type": "library", 5 | "keywords": [ 6 | "alipay", 7 | "openapi-chainable", 8 | "aes-cbc" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "James ZHNAG", 13 | "homepage": "https://github.com/TheNorthMemory" 14 | } 15 | ], 16 | "license": "MIT", 17 | "require": { 18 | "php": ">=7.1.2", 19 | "ext-curl": "*", 20 | "ext-libxml": "*", 21 | "ext-simplexml": "*", 22 | "ext-openssl": "*", 23 | "guzzlehttp/guzzle": "^6.5 || ^7.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^7.5 || ^8.5.16 || ^9.3.5", 27 | "phpstan/phpstan": "^0.12.89 || ^1.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "EasyAlipay\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "EasyAlipay\\Tests\\": "tests/" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-present James ZHANG(TheNorthMemory) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 变更历史 2 | 3 | ## [0.3.4](../../compare/0.3.3...0.3.4) - 2023-01-08 4 | 5 | - 使用`chr`代替`sprintf`,性能提高了一点点; 6 | - 支持以`json`作为关键字,来描述`biz_content`数据结构; 7 | 8 | ## [0.3.3](../../compare/0.3.2...0.3.3) - 2021-11-06 9 | 10 | - 优化`Rsa::parse`代码逻辑,去除`is_resource`/`is_object`检测; 11 | - 调整`Rsa::from[Pkcs8|Pkcs1|Spki]`加载语法糖实现,以`Rsa::from`为统一入口; 12 | 13 | ## [0.3.2](../../compare/v0.3.1...0.3.2) - 2021-11-03 14 | 15 | - 新增`phpstan/phpstan:^1.0`支持; 16 | - 优化代码,消除函数内部不安全的`Unsafe call to private|protected method|property ... through static::`调用隐患; 17 | - 优化`Makefile`生成大数逻辑,贴近真实序列号情况; 18 | 19 | ## [0.3.1](../../compare/v0.3.0...v0.3.1) - 2021-10-17 20 | 21 | - 调整`composer.json`,去除`version`字典; 22 | 23 | ## [0.3.0](../../compare/v0.2.0...v0.3.0) - 2021-10-17 24 | 25 | - 新增`Guzzle6`+`PHP7.1`及`Guzzle7`+`PHP8.1`支持; 26 | - 调整`\EasyAlipay\Crypto\Rsa::from`方法,增加第二入参`$type(private|public)`,显示声明第一入参类型; 27 | - 调整`\EasyAlipay\Crypto\Rsa::fromPkcs1`方法的第二入参为`$type(private|public)`,兼容布尔量声明方式; 28 | 29 | ## [0.2.0](../../compare/v0.1.0...v0.2.0) - 2021-08-20 30 | 31 | - 新增`\EasyAlipay\Helpers`类,以支持`公钥证书模式`使用; 32 | - 新增`\EasyAlipay\Crypto\Rsa::pkcs1ToSpki`转换函数,以支持加载`PKCS#1`格式的`RSA公钥`; 33 | - 新增`\EasyAlipay\ClientDecoratorInterface::getClient`接口函数,支持获取客户端实例; 34 | - 新增测试用例覆盖`PHP7.2/7.3/7.4/8.0+Linux/macOS/Windows`运行时; 35 | - 新增`Makefile`模拟工具,`RSA私钥`、`RSA公钥`、`X509证书`相关测试配套组件,由模拟工具生产; 36 | 37 | ## 0.1.0 - 2021-08-14 38 | 39 | 第一版,生产可用。 40 | -------------------------------------------------------------------------------- /src/ClientDecoratorInterface.php: -------------------------------------------------------------------------------- 1 | $options - Request options to apply. See \GuzzleHttp\RequestOptions. 35 | */ 36 | public function request(string $method, string $uri = '', array $options = []): ResponseInterface; 37 | 38 | /** 39 | * Create and send an asynchronous HTTP request. 40 | * 41 | * @param string $uri - The uri string. 42 | * @param string $method - The method string. 43 | * @param array $options - Request options to apply. See \GuzzleHttp\RequestOptions. 44 | */ 45 | public function requestAsync(string $method, string $uri = '', array $options = []): PromiseInterface; 46 | } 47 | -------------------------------------------------------------------------------- /src/BuilderChainable.php: -------------------------------------------------------------------------------- 1 | [] $options - Request options to apply. 29 | */ 30 | public function get(array ...$options): ResponseInterface; 31 | 32 | /** 33 | * Create and send an HTTP POST request. 34 | * 35 | * @param array[] $options - Request options to apply. 36 | */ 37 | public function post(array ...$options): ResponseInterface; 38 | 39 | /** 40 | * Create and send an asynchronous HTTP GET request. 41 | * 42 | * @param array[] $options - Request options to apply. 43 | */ 44 | public function getAsync(array ...$options): PromiseInterface; 45 | 46 | /** 47 | * Create and send an asynchronous HTTP POST request. 48 | * 49 | * @param array[] $options - Request options to apply. 50 | */ 51 | public function postAsync(array ...$options): PromiseInterface; 52 | } 53 | -------------------------------------------------------------------------------- /src/BuilderTrait.php: -------------------------------------------------------------------------------- 1 | $things - Request options to apply. 30 | * 31 | * @return array 32 | */ 33 | protected function prepare(string $verb, array ...$things): array 34 | { 35 | $method = ['method' => $this->entryMethod()]; 36 | switch(count($things)) { 37 | case 0: 38 | return ['query' => $method]; 39 | case 1: 40 | [$options] = $things; 41 | return array_replace_recursive($options, ['query' => $method]); 42 | case 2: 43 | [$thing, $options] = $things; 44 | return array_replace_recursive($options, $verb === 'GET' ? ['query' => $thing] : ['content' => $thing], ['query' => $method]); 45 | default: 46 | [$content, $query, $options] = $things; 47 | return array_replace_recursive($options, ['query' => $query, 'content' => $content], ['query' => $method]); 48 | } 49 | } 50 | 51 | /** 52 | * Alias of sending a synchronous HTTP POST request. 53 | * 54 | * @param string $name - The `entryMethod` pipe string. 55 | * @param array[] $options - Request options to apply. 56 | */ 57 | public function __call(string $name, array $options): ResponseInterface 58 | { 59 | return $this->{$name}->post(...$options); 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function get(array ...$options): ResponseInterface 66 | { 67 | return $this->getDriver()->request($verb = 'GET', '', $this->prepare($verb, ...$options)); 68 | } 69 | 70 | /** 71 | * @inheritDoc 72 | */ 73 | public function post(array ...$options): ResponseInterface 74 | { 75 | return $this->getDriver()->request($verb = 'POST', '', $this->prepare($verb, ...$options)); 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | public function getAsync(array ...$options): PromiseInterface 82 | { 83 | return $this->getDriver()->requestAsync($verb = 'GET', '', $this->prepare($verb, ...$options)); 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | */ 89 | public function postAsync(array ...$options): PromiseInterface 90 | { 91 | return $this->getDriver()->requestAsync($verb = 'POST', '', $this->prepare($verb, ...$options)); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Crypto/AesCbc.php: -------------------------------------------------------------------------------- 1 | - The app_id string. (optional) 25 | * - params - The ISV auth token string. (optional) 26 | * - params - The MD5 string of the merchant's X509 certificate issuer&serial attributes. (optional) 27 | * - params - The MD5 string of the platform's X509 certificate(s) issuer&serial attributes. (optional) 28 | * 29 | * ```php 30 | * // usage samples 31 | * $instance = Builder::factory([]); 32 | * $instance->chain('alipay.offline.market.shop.category.query')->get(['debug' => true]); 33 | * $instance->alipay->offline->marketShopCategoryQuery->getAsync(['debug' => true])->wait(); 34 | * ``` 35 | * @param array $config - configuration . 36 | */ 37 | public static function factory(array $config = []): BuilderChainable 38 | { 39 | return new class([], new ClientDecorator($config)) extends ArrayIterator implements BuilderChainable 40 | { 41 | use BuilderTrait; 42 | 43 | /** 44 | * Compose the chainable `ClientDecoratorInterface` instance, most starter with the tree root point 45 | * @param string[] $input 46 | * @param ClientDecoratorInterface $instance 47 | */ 48 | public function __construct(array $input, ClientDecoratorInterface $instance) { 49 | parent::__construct($input, self::STD_PROP_LIST | self::ARRAY_AS_PROPS); 50 | 51 | $this->driver = &$instance; 52 | } 53 | 54 | /** 55 | * @var ClientDecoratorInterface $driver 56 | */ 57 | protected $driver; 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function getDriver(): ClientDecoratorInterface 63 | { 64 | return $this->driver; 65 | } 66 | 67 | /** 68 | * Normalize the `$thing` by the rules: `PascalCase` -> `camelCase` & `dotNotation` -> `dot.notation` 69 | * 70 | * @param string $thing - The string waiting for normalization 71 | */ 72 | protected function normalize(string $thing = ''): string 73 | { 74 | return preg_replace_callback_array([ 75 | '#^[A-Z]#' => static function(array $v): string { return strtolower($v[0]); }, 76 | '#[A-Z]#' => static function(array $v): string { return strtolower('.' . $v[0]); }, 77 | ], $thing) ?? $thing; 78 | } 79 | 80 | /** 81 | * Compose the remote OpenAPI `method` 82 | * 83 | * @param string $seperator - The OpenAPI `method` seperator, default is dot(`.`) character 84 | */ 85 | protected function entryMethod(string $seperator = '.'): string 86 | { 87 | return implode($seperator, $this->simplized()); 88 | } 89 | 90 | /** 91 | * Only retrieve a copy array of the `method` segments 92 | * 93 | * @return string[] - The `method` segments array 94 | */ 95 | protected function simplized(): array 96 | { 97 | return array_filter($this->getArrayCopy(), static function($v) { return !($v instanceof BuilderChainable); }); 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function offsetGet($key): BuilderChainable 104 | { 105 | if (false === $this->offsetExists($key)) { 106 | $indices = $this->simplized(); 107 | $indices[] = $this->normalize($key); 108 | $this->offsetSet($key, new self($indices, $this->getDriver())); 109 | } 110 | 111 | return parent::offsetGet($key); 112 | } 113 | 114 | /** 115 | * @inheritDoc 116 | */ 117 | public function chain(string $method): BuilderChainable 118 | { 119 | return $this->offsetGet($method); 120 | } 121 | }; 122 | } 123 | 124 | private function __construct() 125 | { 126 | // cannot be instantiated 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | -{5}BEGIN CERTIFICATE-{5}(?:[^-]+)-{5}END CERTIFICATE-{5})#'; 29 | private const X509_ASN1_CERT_SIGNATURE_LONG_NAME = 'signatureTypeLN'; 30 | private const X509_ASN1_CERT_ISSUER = 'issuer'; 31 | private const X509_ASN1_CERT_SERIAL = 'serialNumber'; 32 | 33 | /** @var string `pem` identify */ 34 | public const CERT_PEM = 'pem'; 35 | 36 | /** @var string `attr` identify for the result of the `openssl_x509_parse` */ 37 | public const CERT_ATTR = 'attr'; 38 | 39 | /** 40 | * MD5 hash function 41 | * 42 | * @param string $things - To caculating things 43 | * 44 | * @return string - The digest string 45 | */ 46 | public static function md5(string ...$things): string 47 | { 48 | $ctx = hash_init(self::ALGO_MD5); 49 | 50 | array_walk($things, static function(string $thing) use ($ctx): void { hash_update($ctx, $thing); }); 51 | 52 | return hash_final($ctx); 53 | } 54 | 55 | /** 56 | * Load Rsa X509 Certificate(s). 57 | * 58 | * @param string $thing - The certificatie(s) file path string or `data://text/plain;utf-8,...` (RFC2397) string 59 | * @param string $pattern - The signatureAlgorithm matching pattern, default is `null` means for all 60 | * 61 | * @return array}> - The X509 Certificate instance list. 62 | */ 63 | public static function load(string $thing, ?string $pattern = null): array 64 | { 65 | preg_match_all(self::X509_CERT_FORMAT_PATTERN, file_get_contents($thing) ?: '', $matches); 66 | 67 | $certs = $matches['cert'] ?? []; 68 | 69 | array_walk($certs, static function(string &$cert) use ($pattern): void { 70 | $attr = openssl_x509_parse($cert, true); 71 | [self::X509_ASN1_CERT_SIGNATURE_LONG_NAME => $algo] = $attr ?: [self::X509_ASN1_CERT_SIGNATURE_LONG_NAME => null]; 72 | $cert = $pattern && $algo && false === stripos($algo, $pattern) ? null : [self::CERT_PEM => $cert, self::CERT_ATTR => $attr]; 73 | }); 74 | 75 | /** @var array}> $certs */ 76 | return array_filter($certs); 77 | } 78 | 79 | /** 80 | * Extract a certificate from given `thing` 81 | * 82 | * @param string $thing - The certificatie(s) file path string or `data://text/plain;utf-8,...` (RFC2397) string 83 | * @param string $pattern - The signatureAlgorithm matching pattern, default is `null` means for all 84 | * 85 | * @return string - The pem format certificate(s) 86 | */ 87 | public static function extract(string $thing, ?string $pattern = null): string 88 | { 89 | return implode(PHP_EOL, array_reduce(static::load($thing, $pattern), static function(array $carry, array $cert): array { 90 | $carry[] = $cert[static::CERT_PEM]; 91 | 92 | return $carry; 93 | }, [])); 94 | } 95 | 96 | /** 97 | * Calculate the given certificate(s) `SN` value string, rule as `md5(CN=$CN,OU=$OU,O=$O,C=$C$serialNumber)` 98 | * 99 | * @param string $thing - The certificatie(s) file path string or `data://text/plain;utf-8,...` (RFC2397) string 100 | * @param string $pattern - The signatureAlgorithm matching pattern, default is `null` means for all 101 | * 102 | * @return string - The SN value string 103 | */ 104 | public static function sn(string $thing, ?string $pattern = null): string 105 | { 106 | return implode('_', array_reduce(static::load($thing, $pattern), static function(array $carry, array $cert): array { 107 | /** 108 | * @var array $issuer 109 | * @var string $serial 110 | */ 111 | [self::X509_ASN1_CERT_ISSUER => $issuer, self::X509_ASN1_CERT_SERIAL => $serial] = $cert[self::CERT_ATTR]; 112 | 113 | $carry[] = static::md5(static::fold($issuer), $serial); 114 | 115 | return $carry; 116 | }, [])); 117 | } 118 | 119 | /** 120 | * Folder the `key/value` \$things in a reversed order then joined as `,` 121 | * 122 | * @param array $things - The `issuer` or `subject` arrtributes array 123 | */ 124 | public static function fold(array $things): string 125 | { 126 | return implode(',', array_reverse(array_reduce( 127 | array_keys($things), 128 | static function(array $carry, string $key) use($things): array { 129 | $carry[] = implode('=', [$key, $things[$key]]); 130 | 131 | return $carry; 132 | }, 133 | [] 134 | ))); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Formatter.php: -------------------------------------------------------------------------------- 1 | $thing - The input array. 55 | * 56 | * @return array - The sorted array. 57 | */ 58 | public static function ksort(array $thing = []): array 59 | { 60 | ksort($thing, SORT_STRING); 61 | 62 | return $thing; 63 | } 64 | 65 | /** 66 | * Like `queryString` does but without the `sign` and `empty value` entities. 67 | * 68 | * @param array $thing - The input array. 69 | * 70 | * @return string - The `key=value` pair string whose joined by `&` char. 71 | */ 72 | public static function queryStringLike(array $thing = []): string 73 | { 74 | $data = []; 75 | 76 | foreach ($thing as $key => $value) { 77 | if ($key === 'sign' || is_null($value) || $value === '') { 78 | continue; 79 | } 80 | $data[] = implode('=', [$key, $value]); 81 | } 82 | 83 | return implode('&', $data); 84 | } 85 | 86 | /** 87 | * Retrieve the current `yyyy-MM-dd HH:mm:ss` date time based on given `timeZone`. 88 | * 89 | * @param string $when - Any available inputs refer to the `DateTime() constructor`, default `Date.now()`. 90 | * @param string $timeZone - Any available inputs refer to the options in `DateTimeZone`, default `Asia/Shanghai`. 91 | * 92 | * @return string - `yyyy-MM-dd HH:mm:ss` date time string 93 | */ 94 | public static function localeDateTime(string $when = 'now', string $timeZone = 'Asia/Shanghai'): string 95 | { 96 | return (new DateTime($when, new DateTimeZone($timeZone)))->format('Y-m-d H:i:s'); 97 | } 98 | 99 | /** 100 | * Parse the `source` with given `placeholder`. 101 | * 102 | * @param string $source - The inputs string. 103 | * @param string $placeholder - The payload pattern. 104 | * 105 | * @return array{ident:?string,payload:?string,sign:?string} 106 | */ 107 | public static function fromJsonLike(string $source, string $placeholder = '(?[a-z](?:[a-z_])+)_response'): array 108 | { 109 | $maybe = '(?:[\r|\n|\s|\t]*)'; 110 | $pattern = "#^{$maybe}\{{$maybe}\"{$placeholder}\"{$maybe}:{$maybe}\"?(?.*?)\"?{$maybe}" 111 | . "(?:,)?{$maybe}(?:\"sign\"{$maybe}:{$maybe}\"(?[^\"]+)\"{$maybe})?\}{$maybe}$#m"; 112 | 113 | preg_match($pattern, $source, $matches); 114 | 115 | return ['ident' => $matches['ident'] ?? null, 'payload' => $matches['payload'] ?? null, 'sign' => $matches['sign'] ?? null]; 116 | } 117 | 118 | /** 119 | * flat the `key/value` \$inputs as html `` tag list 120 | * 121 | * @param string $template - The `sprintf` string template, acceptable `key` and `value` as parameters 122 | * @param array $inputs - The `key/value` pair 123 | * @return string[] 124 | */ 125 | protected static function inputsFlat(string $template, array $inputs): array 126 | { 127 | return array_reduce( 128 | array_keys($inputs), 129 | static function(array $carry, string $key) use ($template, $inputs): array { 130 | $carry[] = sprintf($template, htmlspecialchars($key, ENT_COMPAT), htmlspecialchars($inputs[$key], ENT_COMPAT)); 131 | return $carry; 132 | }, 133 | [] 134 | ); 135 | } 136 | 137 | /** 138 | * Translate the inputs for the page service, such as `alipay.trade.page.pay`, `alipay.trade.wap.pay` OpenAPI methods. 139 | * 140 | * @param string $baseUri - The gateway base_uri 141 | * @param string $method - The http verb, one of `GET` or `POST` 142 | * @param array $query - The http query 143 | * @param array $data - The additional data 144 | */ 145 | public static function page(string $baseUri = 'https://openapi.alipay.com/gateway.do', string $method = 'POST', array $query = ['charset' => 'UTF-8'], array $data = []): string 146 | { 147 | $name = 'EasyAlipay' . time(); 148 | 149 | ['charset' => $charset] = $query; 150 | unset($query['charset']); 151 | 152 | return implode('', ['', 153 | '', 154 | '', 155 | '...', 156 | sprintf('', $charset), 157 | '', 158 | '', 159 | sprintf('
', $name, $method, $baseUri, $charset), 160 | vsprintf(str_repeat('%s', count($in = static::inputsFlat('', $query))), $in), 161 | vsprintf(str_repeat('%s', count($in = static::inputsFlat('', $data))), $in), 162 | '
', 163 | sprintf('', $name), 164 | sprintf('', $name), 165 | '', 166 | '', 167 | ]); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/ClientDecorator.php: -------------------------------------------------------------------------------- 1 | > - The defaults configuration whose pased in `GuzzleHttp\Client`. 47 | */ 48 | protected static $defaults = [ 49 | 'base_uri' => 'https://openapi.alipay.com/gateway.do', 50 | 'headers' => [ 51 | 'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8', 52 | ], 53 | 'params' => [ 54 | 'charset' => 'UTF-8', 55 | 'format' => 'JSON', 56 | 'sign_type' => 'RSA2', 57 | 'version' => '1.0', 58 | ], 59 | ]; 60 | 61 | /** 62 | * Deep merge the input with the defaults 63 | * 64 | * @param array $config - The configuration. 65 | * 66 | * @return array - With the built-in configuration. 67 | */ 68 | protected static function withDefaults(array ...$config): array 69 | { 70 | return array_replace_recursive(static::$defaults, ['headers' => static::userAgent()], ...$config); 71 | } 72 | 73 | /** 74 | * Prepare the `User-Agent` key/value pair 75 | * 76 | * @return array 77 | */ 78 | protected static function userAgent(): array 79 | { 80 | return ['User-Agent' => implode(' ', [ 81 | sprintf('EasyAlipay/%d.%d', static::MAJOR_VERSION, static::MINOR_VERSION), 82 | sprintf('GuzzleHttp/%s', constant(Client::class . (defined(Client::class . '::VERSION') ? '::VERSION' : '::MAJOR_VERSION'))), 83 | sprintf('curl/%s', ((array)call_user_func('\curl_version'))['version'] ?? 'unknown'), 84 | sprintf('(%s/%s)', PHP_OS, php_uname('r')), 85 | sprintf('PHP/%s', PHP_VERSION), 86 | ])]; 87 | } 88 | 89 | /** 90 | * Taken body string 91 | * 92 | * @param MessageInterface $message 93 | */ 94 | protected static function body(MessageInterface $message): string 95 | { 96 | $stream = $message->getBody(); 97 | $content = (string)$stream; 98 | 99 | $stream->tell() && $stream->rewind(); 100 | 101 | return $content; 102 | } 103 | 104 | /** 105 | * Builtin pager local service 106 | * 107 | * @param RequestInterface $request 108 | * @param array $query - The http query 109 | * @param array $data - The additional data 110 | */ 111 | protected static function pager(RequestInterface $request, array $query = [], array $data = []): PromiseInterface 112 | { 113 | return Create::promiseFor(new Response(200, [], 114 | Formatter::page( 115 | (string)$request->getUri()->withQuery(''), 116 | $request->getMethod(), $query, $data 117 | ) 118 | )); 119 | } 120 | 121 | /** 122 | * @param \OpenSSLAsymmetricKey|resource|string|mixed $privateKey - The merchant privateKey. 123 | * 124 | * @return callable(callable(RequestInterface,array)) 125 | */ 126 | public static function signer($privateKey): callable 127 | { 128 | return static function(callable $handler) use ($privateKey): callable { 129 | return static function(RequestInterface $request, array $options) use ($handler, $privateKey): PromiseInterface { 130 | $data = ['biz_content' => json_encode((object)($options['json'] ?? $options['content'] ?? []), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]; 131 | ['params' => $params] = $options; 132 | $params['timestamp'] = $params['timestamp'] ?? Formatter::localeDateTime(); 133 | 134 | $query = Query::parse($request->getUri()->getQuery()) + $params; 135 | $signature = Crypto\Rsa::sign(Formatter::queryStringLike(Formatter::ksort($data + $query)), $privateKey); 136 | $data += ['sign' => $signature]; 137 | 138 | if (isset($options['pager']) && ($pager = $options['pager'])) { 139 | return is_callable($pager) ? $pager($request, $query, $data) : static::pager($request, $query, $data); 140 | } 141 | 142 | $modify = []; 143 | if ('GET' === $request->getMethod() || $request->getBody() instanceof MultipartStream) { 144 | $query += $data; 145 | } else { 146 | $modify += ['body' => Query::build($data, PHP_QUERY_RFC1738)]; 147 | } 148 | $modify += ['query' => Query::build($query, PHP_QUERY_RFC1738)]; 149 | 150 | unset($options['query'], $options['params'], $options['json'], $options['content']); 151 | 152 | return $handler(Utils::modifyRequest($request, $modify), $options); 153 | }; 154 | }; 155 | } 156 | 157 | /** 158 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $publicKey The platform publicKey 159 | * 160 | * @return callable(callable(RequestInterface,array)) 161 | */ 162 | public static function verifier($publicKey): callable 163 | { 164 | return static function(callable $handler) use ($publicKey): callable { 165 | return static function(RequestInterface $request, array $options) use ($publicKey, $handler): PromiseInterface { 166 | return $handler($request, $options)->then(static function(ResponseInterface $response) use ($publicKey): ResponseInterface { 167 | /** 168 | * @var ?string $payload 169 | * @var ?string $sign 170 | * @var ?string $ident 171 | */ 172 | ['payload' => $payload, 'sign' => $sign, 'ident' => $ident] = Formatter::fromJsonLike(static::body($response)); 173 | $verified = is_string($payload) && is_string($sign) && Crypto\Rsa::verify($payload, $sign, $publicKey); 174 | return $response 175 | ->withAddedHeader('X-Alipay-Signature', $sign ?? '') 176 | ->withAddedHeader('X-Alipay-Responder', str_replace('_', '.', $ident ?? '')) 177 | ->withAddedHeader('X-Alipay-Verified', $verified ? 'ok' : '') 178 | ->withBody(Utils::streamFor($payload ?? $response->getBody())); 179 | }); 180 | }; 181 | }; 182 | } 183 | 184 | /** 185 | * Decorate the `\GuzzleHttp\Client` factory 186 | * 187 | * Acceptable \$config parameters stucture 188 | * - privateKey: \OpenSSLAsymmetricKey|resource|string - The merchant private key. 189 | * - publicKey: \OpenSSLAsymmetricKey|resource|string - The platform public key. 190 | * - params?: array{app_id?:string, app_auth_token?:string, app_cert_sn?:string, alipay_root_cert_sn?:string} 191 | * - params - The app_id string. (optional) 192 | * - params - The ISV auth token string. (optional) 193 | * - params - The MD5 string of the merchant's X509 certificate issuer&serial attributes. (optional) 194 | * - params - The MD5 string of the platform's X509 certificate(s) issuer&serial attributes. (optional) 195 | * 196 | * @param array $config 197 | */ 198 | public function __construct(array $config = []) 199 | { 200 | /** @var HandlerStack $stack */ 201 | $stack = isset($config['handler']) && ($config['handler'] instanceof HandlerStack) ? $config['handler'] : HandlerStack::create(); 202 | $stack->before('prepare_body', static::signer($config['privateKey'] ?? ''), 'signer'); 203 | $stack->before('http_errors', static::verifier($config['publicKey'] ?? ''), 'verifier'); 204 | $this->stack = $config['handler'] = $stack; 205 | 206 | $this->client = new Client(static::withDefaults($config)); 207 | } 208 | 209 | /** 210 | * @inheritDoc 211 | */ 212 | public function getClient(): Client 213 | { 214 | return $this->client; 215 | } 216 | 217 | /** 218 | * @inheritDoc 219 | */ 220 | public function getHandlerStack(): HandlerStack 221 | { 222 | return $this->stack; 223 | } 224 | 225 | /** 226 | * @inheritDoc 227 | */ 228 | public function request(string $method, string $uri = '', array $options = []): ResponseInterface 229 | { 230 | return $this->client->request($method, $uri, $options); 231 | } 232 | 233 | /** 234 | * @inheritDoc 235 | */ 236 | public function requestAsync(string $method, string $uri = '', array $options = []): PromiseInterface 237 | { 238 | return $this->client->requestAsync($method, $uri, $options); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 支付宝 Alipay OpenAPI SDK 2 | 3 | [A]Sync Chainable Alipay OpenAPI SDK for PHP 4 | 5 | [![GitHub actions](https://github.com/TheNorthMemory/easyalipay/workflows/CI/badge.svg)](https://github.com/TheNorthMemory/easyalipay/actions) 6 | [![Version](https://img.shields.io/packagist/v/easyalipay/easyalipay)](https://packagist.org/packages/easyalipay/easyalipay) 7 | [![PHP Version](https://img.shields.io/packagist/php-v/easyalipay/easyalipay)](https://packagist.org/packages/easyalipay/easyalipay) 8 | [![License](https://img.shields.io/packagist/l/easyalipay/easyalipay)](https://packagist.org/packages/easyalipay/easyalipay) 9 | 10 | ## 概览 11 | 12 | 支付宝 OpenAPI 的[Guzzle HttpClient](http://docs.guzzlephp.org/)封装组合, 13 | 内置 `请求签名` 和 `应答验签` 两个middlewares中间件,创新性地实现了链式面向对象同步/异步调用远程接口。 14 | 15 | 如果你是使用 `Guzzle` 的商户开发者,可以使用 `EasyAlipay\Builder::factory` 工厂方法直接创建一个 `GuzzleHttp\Client` 的链式调用封装器, 16 | 实例在执行请求时将自动携带身份认证信息,并检查应答的支付宝的返回签名。 17 | 18 | ## 环境要求 19 | 20 | 我们开发和测试使用的环境如下: 21 | 22 | + PHP >=7.1.2 23 | + guzzlehttp/guzzle ^6.5 || ^7.0 24 | 25 | **注:** 26 | 27 | - 兼容支持`Guzzle6`的PHP最低版本为`7.1.2`,另PHP官方已于`1 Dec 2019`停止维护`PHP7.1`,详见附注链接; 28 | - 随`Guzzle7`支持的PHP最低版本为`7.2.5`,另PHP官方已于`30 Nov 2020`停止维护`PHP7.2`,详见附注链接; 29 | 30 | ## 安装 31 | 32 | 推荐使用PHP包管理工具`composer`引入SDK到项目中: 33 | 34 | ### 方式一 35 | 36 | 在项目目录中,通过composer命令行添加: 37 | 38 | ```shell 39 | composer require easyalipay/easyalipay 40 | ``` 41 | 42 | ### 方式二 43 | 44 | 在项目的`composer.json`中加入以下配置: 45 | 46 | ```json 47 | "require": { 48 | "easyalipay/easyalipay": "^0.3" 49 | } 50 | ``` 51 | 52 | 添加配置后,执行安装 53 | 54 | ```shell 55 | composer install 56 | ``` 57 | 58 | ## 约定 59 | 60 | 本类库是以 `OpenAPI` `公共请求参数`中的接入方法 `method` 以`.`做切分,映射成`attributes`,编码书写方式有如下约定: 61 | 62 | 1. 请求 接入方法 `method` 切分后的每个`attributes`,可直接以对象获取形式串接,例如 `alipay.trade.query` 即串成 `alipay->trade->query`; 63 | 2. 每个 接入方法 `method` 所支持的 `HTTP METHOD`,即作为被串接对象的末尾执行方法,例如: `alipay->trade->query->post(['content' => []])`; 64 | 3. 每个 接入方法 `method` 所支持的 `HTTP METHOD`,同时支持`Async`语法糖,例如: `alipay->trade->query->postAsync(['content' => []])`; 65 | 4. 每个 接入方法 `method` 可以使用`PascalCase`风格书写,例如: `alipay.trade.query`可写成 `AlipayTradeQuery`; 66 | 5. 在IDE集成环境下,也可以按照内置的`chain($method)`接口规范,直接以接入方法 `method`作为变量入参,来获取`OpenAPI`当前接入方法的实例,驱动末尾执行方法(填入对应参数),发起请求,例如 `chain('alipay.trade.query')->post(['content' => []])`; 67 | 6. 末尾`get`/`post`/`getAsync`/`postAsync`请求方法语法糖,型参`$options`语法糖规则如下: 68 | 1. `content`字典,对应的是`请求参数集合(biz_content)`字典,直接写原生`PHP array`即可; 69 | 2. `query`字典,对应的是除`请求参数集合(biz_content)`之外的,如部分特殊`公共请求参数(system_params)`有`通知地址(notify_url)`等,直接写原生`PHP array`即可; 70 | 3. 一个入参时`$options`按需带入`'content' => []` 及/或 `'query' => []`结构即可; 71 | 4. 简写语法糖支持`[get|post][Async](array $content, array $options)`、`[get|post][Async](array $content, array $query, array $options)`结构; 72 | 5. 本SDK所有`请求数据结构`遵循官方开发文档,该是蛇型即蛇形(如:`service_code`),该是驼峰就驼峰(如:`shopIds`),看到的数据结构,即`请求数据结构`,原生`PHP`语法即可; 73 | 7. 内置`返回值验签`中间件在解构原始`json`字符串后,直接返回`*_response`对应的内容,有可能是`json`,也可能是`AesCbc`加密串,按需对返回串做处理; 74 | 75 | 以下示例用法,以`异步(Async/PromiseA+)`或`同步(Sync)`结合此种编码模式展开。 76 | 77 | ## 开始 78 | 79 | 首先,通过 `EasyAlipay\Builder::factory` 工厂方法构建一个实例,然后如上述`约定`,链式`同步`或`异步`请求远端`OpenAPI`接口。 80 | 81 | ```php 82 | use EasyAlipay\Builder; 83 | use EasyAlipay\Crypto\Rsa; 84 | 85 | //应用app_id 86 | $appId = '2014072300007148'; 87 | 88 | //商户RSA私钥,入参是'从官方工具获取到的BASE64字符串' 89 | $privateKey = Rsa::fromPkcs1('MIIEpAIBAAKCAQEApdXuft3as2x...'); 90 | // 以上是下列代码的语法糖,格式为 'private.pkcs1://' + '从官方工具获取到的字符串' 91 | // $privateKey = Rsa::from('private.pkcs1://MIIEpAIBAAKCAQEApdXuft3as2x...'); 92 | // 也支持以下方式,须保证`private_key.pem`为完整X509格式 93 | // $privateKey = Rsa::from('file:///your/openapi/private_key.pem'); 94 | 95 | //支付宝RSA公钥,入参是'从官方工具获取到的BASE64字符串' 96 | $publicKey = Rsa::fromSpki('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg...'); 97 | // 以上是下列代码的语法糖,格式为 'public.spki://' + '从官方工具获取到的字符串' 98 | // $publicKey = Rsa::from('public.spki://MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg...', Rsa::KEY_TYPE_PUBLIC); 99 | // 也支持以下方式,须保证`public_key.pem`为完整X509格式 100 | // $publicKey = Rsa::from('file:///the/alipay/public_key.pem', Rsa::KEY_TYPE_PUBLIC); 101 | 102 | //如果是公钥证书模式,可以在工厂方法内传入 `$appCertSn` 及 `$alipayRootCertSn` 103 | // $appCertFilePath = '/my/cert/app_cert.crt'; 104 | // $appCertSn = \EasyAlipay\Helpers::sn($appCertFilePath); 105 | // $alipayRootCertFilePath = '/alipay/cert/alipayRootCert.crt'; 106 | // $alipayRootCertSn = \EasyAlipay\Helpers::sn($alipayRootCertFilePath); 107 | 108 | // 工厂方法构造一个实例 109 | $instance = Builder::factory([ 110 | 'privateKey' => $privateKey, 111 | 'publicKey' => $publicKey, 112 | 'params' => [ 113 | 'app_id' => $appId, 114 | // 'app_auth_token' => $appAuthToken, 115 | // 'app_cert_sn' => $appCertSn, 116 | // 'alipay_root_cert_sn' => $alipayRootCertSn, 117 | ], 118 | ]); 119 | ``` 120 | 121 | 初始化字典说明如下: 122 | 123 | - `privateKey` 为`商户API私钥`,一般是通过官方证书生成工具生成字符串,支持`PKCS#1`及`PKCS#8`格式的私钥加载; 124 | - `publicKey` 为`平台API公钥`,一般是通过官方证书生成工具生成字符串,支持`PKCS#8`及`SPKI`格式的公钥加载; 125 | - `params` 接口中的`公共请求参数`配置项,已内置`charset=UTF-8`, `format=JSON`, `sign_type=RSA2`及`version=1.0`; 126 | - `params['app_id' => $appId]` 为你的`应用app_id`; 127 | - `params['app_auth_token' => $appAuthToken]` 为你的`ISV`模式的授权`token`,按需配置; 128 | - `params['app_cert_sn' => $appCertSn]` 为`公钥证书模式`的商户证书相关信息`SN`,按需配置; 129 | - `params['alipay_root_cert_sn' => $alipayRootCertSn]` 为`公钥证书模式`的平台证书相关信息`SN`,按需配置; 130 | 131 | **注:** `OpenAPI` 以及 `GuzzleHttp\Client` 的 `array $config` 初始化参数,均融合在一个型参上。 132 | 133 | ### 统一收单线下交易查询 134 | 135 | ```php 136 | use GuzzleHttp\Utils; 137 | use GuzzleHttp\Exception\RequestException; 138 | 139 | try { 140 | $res = $instance 141 | ->alipay->trade->query 142 | ->get(['content' => [ 143 | 'out_trade_no' => '20150320010101001', 144 | ]]); 145 | 146 | echo $res->getBody(), PHP_EOL; 147 | } catch (RequestException $e) { 148 | // 进行错误处理 149 | if ($e->hasResponse()) { 150 | $r = $e->getResponse(); 151 | echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL; 152 | echo $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL; 153 | } 154 | } catch (\Throwable $e) { 155 | // 进行错误处理 156 | echo $e->getMessage(), PHP_EOL; 157 | echo $e->getTraceAsString(), PHP_EOL; 158 | } 159 | ``` 160 | 161 | ### 统一收单交易支付接口 162 | 163 | ```php 164 | use GuzzleHttp\Utils; 165 | use GuzzleHttp\Exception\RequestException; 166 | use Psr\Http\Message\ResponseInterface; 167 | 168 | $res = $instance 169 | ->alipay->trade->pay 170 | ->postAsync(['content' => [ 171 | 'out_trade_no' => '20150320010101001', 172 | 'scene' => 'bar_code', 173 | 'auth_code' => '28763443825664394', 174 | 'product_code' => 'FACE_TO_FACE_PAYMENT', 175 | 'subject' => 'Iphone6 16G', 176 | 'total_amount' => '88.88', 177 | ]]) 178 | ->then(static function(ResponseInterface $response) { 179 | // 正常逻辑回调处理 180 | return Utils::jsonDecode((string) $response->getBody(), true); 181 | }) 182 | ->otherwise(static function($e) { 183 | // 异常错误处理 184 | echo $e->getMessage(), PHP_EOL; 185 | if ($e instanceof RequestException && $e->hasResponse()) { 186 | $r = $e->getResponse(); 187 | echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL; 188 | echo $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL; 189 | } 190 | echo $e->getTraceAsString(), PHP_EOL; 191 | }) 192 | ->wait(); 193 | print_r($res); 194 | ``` 195 | 196 | ### 统一收单线下交易预创建 197 | 198 | ```php 199 | use GuzzleHttp\Utils; 200 | use GuzzleHttp\Exception\RequestException; 201 | use Psr\Http\Message\ResponseInterface; 202 | 203 | $res = $instance 204 | ->Alipay->Trade->Precreate 205 | ->postAsync([ 206 | 'out_trade_no' => '20150320010101001', 207 | 'subject' => 'Iphone6 16G', 208 | 'total_amount' => '88.88', 209 | ], ['query' => [ 210 | 'notify_url' => 'http://api.test.alipay.net/atinterface/receive_notify.htm' 211 | ]]) 212 | ->then(static function(ResponseInterface $response) { 213 | // 正常逻辑回调处理 214 | return Utils::jsonDecode((string) $response->getBody(), true); 215 | }) 216 | ->otherwise(static function($e) { 217 | // 异常错误处理 218 | }) 219 | ->wait(); 220 | print_r($res); 221 | ``` 222 | 223 | ### 手机网站支付接口2.0 224 | 225 | ```php 226 | use Psr\Http\Message\ResponseInterface; 227 | 228 | $res = $instance 229 | ->chain('alipay.trade.wap.pay') 230 | ->postAsync([ 231 | 'subject' => '商品名称', 232 | 'out_trade_no' => '22', 233 | 'total_amount' => '0.01', 234 | 'product_code' => 'FAST_INSTANT_TRADE_PAY', 235 | 'quit_url' => 'https://forum.alipay.com/mini-app/post/15501011', 236 | ], ['pager' => true]) 237 | ->then(static function(ResponseInterface $response) { 238 | // 正常逻辑回调处理 239 | return (string) $response->getBody(); 240 | }) 241 | ->otherwise(static function($e) { 242 | // 异常错误处理 243 | }) 244 | ->wait(); 245 | print_r($res); 246 | ``` 247 | 248 | ### 统一收单下单并支付页面接口 249 | 250 | ```php 251 | use GuzzleHttp\Utils; 252 | use GuzzleHttp\Exception\RequestException; 253 | 254 | try { 255 | $res = $instance['alipay.trade.page.pay'] 256 | ->post(['content' => [ 257 | 'subject' => '商品名称', 258 | 'out_trade_no' => '22', 259 | 'total_amount' => '0.01', 260 | 'product_code' => 'FAST_INSTANT_TRADE_PAY', 261 | ], 'pager' => true]); 262 | echo $resp->getBody(), PHP_EOL; 263 | } catch (RequestException $e) { 264 | // 进行错误处理 265 | } catch (\Throwable $e) { 266 | // 异常错误处理 267 | } 268 | ``` 269 | 270 | ### 上传门店照片和视频接口 271 | 272 | ```php 273 | use GuzzleHttp\Utils; 274 | use GuzzleHttp\Exception\RequestException; 275 | use GuzzleHttp\Psr\MultipartStream; 276 | use Psr\Http\Message\ResponseInterface; 277 | 278 | $media = new MultipartStream([ 279 | 'name' => 'image_content', 280 | 'contents' => 'file:///path/for/uploading.jpg', 281 | ]); 282 | 283 | $res = $instance 284 | ->chain('alipay.offline.material.image.upload') 285 | ->postAsync([ 286 | 'body' => $media, 287 | ]) 288 | ->then(static function(ResponseInterface $response) { 289 | // 正常逻辑回调处理 290 | return Utils::jsonDecode((string) $response->getBody(), true); 291 | }) 292 | ->otherwise(static function($e) { 293 | // 异常错误处理 294 | }) 295 | ->wait(); 296 | print_r($res); 297 | ``` 298 | 299 | ### 敏感信息加/解密 300 | 301 | ```php 302 | use EasyAlipay\Crypto\AesCbc; 303 | use GuzzleHttp\Utils; 304 | use Psr\Http\Message\ResponseInterface; 305 | 306 | $aesCipherKey = ''; 307 | 308 | $res = $instance 309 | ->chain('some.method.response.by.aes.encrypted') 310 | ->postAsync([]) 311 | ->then(static function(ResponseInterface $response) use ($aesCipherKey) { 312 | $json = Utils::jsonDecode((string) $response->getBody()); 313 | return AesCbc::decrypt((string) $json->response, $aesCipherKey); 314 | }) 315 | ->wait(); 316 | print_r($res); 317 | ``` 318 | 319 | ## 链接 320 | 321 | - [变更历史](CHANGELOG.md) 322 | - [更多示例代码](./docs/README.md) 323 | - [GuzzleHttp官方版本支持](https://docs.guzzlephp.org/en/stable/overview.html#requirements) 324 | - [PHP官方版本支持](https://www.php.net/supported-versions.php) 325 | 326 | ## 许可证 327 | 328 | [MIT](LICENSE) 329 | -------------------------------------------------------------------------------- /src/Crypto/Rsa.php: -------------------------------------------------------------------------------- 1 | 'sha1WithRSAEncryption', 'RSA2' => 'sha256WithRSAEncryption']; 51 | 52 | private const LOCAL_FILE_PROTOCOL = 'file://'; 53 | private const PKEY_PEM_NEEDLE = ' KEY-'; 54 | private const PKEY_PEM_FORMAT = "-----BEGIN %1\$s KEY-----\n%2\$s\n-----END %1\$s KEY-----"; 55 | private const PKEY_PEM_FORMAT_PATTERN = '#-{5}BEGIN ((?:RSA )?(?:PUBLIC|PRIVATE)) KEY-{5}\r?\n([^-]+)\r?\n-{5}END \1 KEY-{5}#'; 56 | private const CHR_CR = "\r"; 57 | private const CHR_LF = "\n"; 58 | 59 | /** @var array - Supported loading rules */ 60 | private const RULES = [ 61 | 'private.pkcs1' => [self::PKEY_PEM_FORMAT, 'RSA PRIVATE', 16], 62 | 'private.pkcs8' => [self::PKEY_PEM_FORMAT, 'PRIVATE', 16], 63 | 'public.pkcs1' => [self::PKEY_PEM_FORMAT, 'RSA PUBLIC', 15], 64 | 'public.spki' => [self::PKEY_PEM_FORMAT, 'PUBLIC', 14], 65 | ]; 66 | 67 | /** 68 | * @var string - Equal to `sequence(oid(1.2.840.113549.1.1.1), null))` 69 | * @link https://datatracker.ietf.org/doc/html/rfc3447#appendix-A.2 70 | */ 71 | private const ASN1_OID_RSAENCRYPTION = '300d06092a864886f70d0101010500'; 72 | private const ASN1_SEQUENCE = 48; 73 | private const CHR_NUL = "\0"; 74 | private const CHR_ETX = "\3"; 75 | 76 | /** 77 | * Translate the \$thing strlen from `X690` style to the `ASN.1` 128bit hexadecimal length string 78 | * 79 | * @param string $thing - The string 80 | * 81 | * @return string The `ASN.1` 128bit hexadecimal length string 82 | */ 83 | private static function encodeLength(string $thing): string 84 | { 85 | $num = strlen($thing); 86 | if ($num <= 0x7F) { 87 | return chr($num); 88 | } 89 | 90 | $tmp = ltrim(pack('N', $num), self::CHR_NUL); 91 | return pack('Ca*', strlen($tmp) | 0x80, $tmp); 92 | } 93 | 94 | /** 95 | * Convert the `PKCS#1` format RSA Public Key to `SPKI` format 96 | * 97 | * @param string $thing - The base64-encoded string, without evelope style 98 | * 99 | * @return string The `SPKI` style public key without evelope string 100 | */ 101 | public static function pkcs1ToSpki(string $thing): string 102 | { 103 | $raw = self::CHR_NUL . base64_decode($thing); 104 | $new = pack('H*', self::ASN1_OID_RSAENCRYPTION) . self::CHR_ETX . self::encodeLength($raw) . $raw; 105 | 106 | return base64_encode(pack('Ca*a*', self::ASN1_SEQUENCE, self::encodeLength($new), $new)); 107 | } 108 | 109 | /** 110 | * Sugar for loading input `privateKey` string, pure `base64-encoded-string` without LF and evelope. 111 | * 112 | * @param string $thing - The string in `PKCS#8` format. 113 | * @return \OpenSSLAsymmetricKey|resource|mixed 114 | * @throws UnexpectedValueException 115 | */ 116 | public static function fromPkcs8(string $thing) 117 | { 118 | return static::from(sprintf('private.pkcs8://%s', $thing), static::KEY_TYPE_PRIVATE); 119 | } 120 | 121 | /** 122 | * Sugar for loading input `privateKey/publicKey` string, pure `base64-encoded-string` without LF and evelope. 123 | * 124 | * Kind of the \$type Boolean is deprecated, use `self::KEY_TYPE_PRIVATE` or `self::KEY_TYPE_PUBLIC` instead. 125 | * 126 | * @param string $thing - The string in `PKCS#1` format. 127 | * @param boolean|string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`. 128 | * @return \OpenSSLAsymmetricKey|resource|mixed 129 | * @throws UnexpectedValueException 130 | */ 131 | public static function fromPkcs1(string $thing, $type = self::KEY_TYPE_PRIVATE) 132 | { 133 | return static::from(sprintf('%s://%s', 134 | ((is_bool($type) && $type) || $type === static::KEY_TYPE_PUBLIC) 135 | ? 'public.pkcs1' : 'private.pkcs1', $thing), $type); 136 | } 137 | 138 | /** 139 | * Sugar for loading input `publicKey` string, pure `base64-encoded-string` without LF and evelope. 140 | * 141 | * @param string $thing - The string in `SKPI` format. 142 | * @return \OpenSSLAsymmetricKey|resource|mixed 143 | * @throws UnexpectedValueException 144 | */ 145 | public static function fromSpki(string $thing) 146 | { 147 | return static::from(sprintf('public.spki://%s', $thing), static::KEY_TYPE_PUBLIC); 148 | } 149 | 150 | /** 151 | * Loading the privateKey/publicKey from a protocol like string. 152 | * 153 | * The `\$thing` can be one of the following: 154 | * - `file://` protocol `PKCS#1/PKCS#8 privateKey`/`SPKI publicKey`/`x509 certificate(for publicKey)` string. 155 | * - `public.spki://`, `public.pkcs1://`, `private.pkcs1://`, `private.pkcs8://` protocols string. 156 | * - full `PEM` in `PKCS#1/PKCS#8` format `privateKey`/`publicKey`/`x509 certificate(for publicKey)` string. 157 | * - `\OpenSSLAsymmetricKey` (PHP8) or `resource#pkey` (PHP7). 158 | * - `\OpenSSLCertificate` (PHP8) or `resource#X509` (PHP7) for publicKey. 159 | * - `Array` of `[privateKeyString,passphrase]` for encrypted privateKey. 160 | * 161 | * Kind of the \$type Boolean is deprecated, use `self::KEY_TYPE_PRIVATE` or `self::KEY_TYPE_PUBLIC` instead. 162 | * 163 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed $thing - The string. 164 | * @param boolean|string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`. 165 | * 166 | * @return \OpenSSLAsymmetricKey|resource|mixed 167 | */ 168 | public static function from($thing, $type = self::KEY_TYPE_PRIVATE) 169 | { 170 | $pkey = ($isPublic = is_bool($type) ? $type : $type === static::KEY_TYPE_PUBLIC) 171 | ? openssl_pkey_get_public(self::parse($thing, $type)) 172 | : openssl_pkey_get_private(self::parse($thing)); 173 | 174 | if (false === $pkey) { 175 | throw new UnexpectedValueException(sprintf( 176 | 'Cannot load %s from(%s), please take care about the \$thing input.', 177 | $isPublic ? 'publicKey' : 'privateKey', 178 | gettype($thing) 179 | )); 180 | } 181 | 182 | return $pkey; 183 | } 184 | 185 | /** 186 | * Parse the `\$thing` for the `openssl_pkey_get_public`/`openssl_pkey_get_private` function. 187 | * 188 | * The `\$thing` can be the `file://` protocol privateKey/publicKey string, eg: 189 | * - `file:///my/path/to/private.pkcs1.key` 190 | * - `file:///my/path/to/private.pkcs8.key` 191 | * - `file:///my/path/to/public.spki.pem` 192 | * - `file:///my/path/to/x509.crt` (for publicKey) 193 | * 194 | * The `\$thing` can be the `public.spki://`, `public.pkcs1://`, `private.pkcs1://`, `private.pkcs8://` protocols string, eg: 195 | * - `public.spki://MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg...` 196 | * - `public.pkcs1://MIIBCgKCAQEAgYxTW5Yj...` 197 | * - `private.pkcs1://MIIEpAIBAAKCAQEApdXuft3as2x...` 198 | * - `private.pkcs8://MIIEpAIBAAKCAQEApdXuft3as2x...` 199 | * 200 | * The `\$thing` can be the string with PEM `evelope`, eg: 201 | * - `-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----` 202 | * - `-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----` 203 | * - `-----BEGIN RSA PUBLIC KEY-----...-----END RSA PUBLIC KEY-----` 204 | * - `-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----` 205 | * - `-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----` (for publicKey) 206 | * 207 | * The `\$thing` can be the \OpenSSLAsymmetricKey/\OpenSSLCertificate/resouce, eg: 208 | * - `\OpenSSLAsymmetricKey` (PHP8) or `resource#pkey` (PHP7) for publicKey/privateKey. 209 | * - `\OpenSSLCertificate` (PHP8) or `resource#X509` (PHP7) for publicKey. 210 | * 211 | * The `\$thing` can be the Array{$privateKey,$passphrase} style for loading privateKey, eg: 212 | * - [`file:///my/path/to/encrypted.private.pkcs8.key`, 'your_pass_phrase'] 213 | * - [`-----BEGIN ENCRYPTED PRIVATE KEY-----...-----END ENCRYPTED PRIVATE KEY-----`, 'your_pass_phrase'] 214 | * 215 | * Kind of the \$type Boolean is deprecated, use `self::KEY_TYPE_PRIVATE` or `self::KEY_TYPE_PUBLIC` instead. 216 | * 217 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed $thing - The thing. 218 | * @param boolean|string $type - Either `self::KEY_TYPE_PUBLIC` or `self::KEY_TYPE_PRIVATE` string, default is `self::KEY_TYPE_PRIVATE`. 219 | * @return \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|array{string,string}|string|mixed 220 | */ 221 | private static function parse($thing, $type = self::KEY_TYPE_PRIVATE) 222 | { 223 | $src = $thing; 224 | 225 | if (is_string($src) && is_int(strpos($src, self::PKEY_PEM_NEEDLE)) 226 | && ((is_bool($type) && $type) || $type === self::KEY_TYPE_PUBLIC) && preg_match(self::PKEY_PEM_FORMAT_PATTERN, $src, $matches)) { 227 | [, $kind, $base64] = $matches; 228 | $mapRules = (array)array_combine(array_column(self::RULES, 1/*column*/), array_keys(self::RULES)); 229 | $protocol = $mapRules[$kind] ?? ''; 230 | if ('public.pkcs1' === $protocol) { 231 | $src = sprintf('%s://%s', $protocol, str_replace([self::CHR_CR, self::CHR_LF], '', $base64)); 232 | } 233 | } 234 | 235 | if (is_string($src) && is_bool(strpos($src, self::LOCAL_FILE_PROTOCOL)) && is_int(strpos($src, '://'))) { 236 | $protocol = parse_url($src, PHP_URL_SCHEME); 237 | [$format, $kind, $offset] = self::RULES[$protocol] ?? [null, null, null]; 238 | if ($format && $kind && $offset) { 239 | $src = substr($src, $offset); 240 | if ('public.pkcs1' === $protocol) { 241 | $src = static::pkcs1ToSpki($src); 242 | [$format, $kind] = self::RULES['public.spki']; 243 | } 244 | return sprintf($format, $kind, wordwrap($src, 64, self::CHR_LF, true)); 245 | } 246 | } 247 | 248 | return $src; 249 | } 250 | 251 | /** 252 | * Verifying the `message` with given `signature` string that uses `RSA/RSA2` algothrim. 253 | * 254 | * @param string $message - Content will be `openssl_verify`. 255 | * @param string $signature - The base64-encoded ciphertext. 256 | * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $publicKey - A PEM encoded public key. 257 | * @param string $type - one of the algo alias RSA/RSA2, default is `RSA2`. 258 | * 259 | * @return boolean - True is passed, false is failed. 260 | * @throws UnexpectedValueException 261 | */ 262 | public static function verify(string $message, string $signature, $publicKey, string $type = self::ALGO_TYPE_RSA2): bool 263 | { 264 | if (false === ($result = openssl_verify($message, base64_decode($signature), $publicKey, self::ALGOES[$type] ?? self::ALGOES[self::ALGO_TYPE_RSA2]))) { 265 | throw new UnexpectedValueException("Verified the {$message} by {$type} failed, please checking the \$publicKey whether or nor correct."); 266 | } 267 | 268 | return $result === 1; 269 | } 270 | 271 | /** 272 | * Creates and returns a `base64_encode` string that uses `RSA/RSA2` algothrim. 273 | * 274 | * @param string $message - Content will be `openssl_sign`. 275 | * @param \OpenSSLAsymmetricKey|resource|string|mixed $privateKey - A PEM encoded private key. 276 | * @param string $type - one of the algo alias RSA/RSA2, default is `RSA2`. 277 | * 278 | * @return string - The base64-encoded signature. 279 | * @throws UnexpectedValueException 280 | */ 281 | public static function sign(string $message, $privateKey, string $type = self::ALGO_TYPE_RSA2): string 282 | { 283 | if (false === openssl_sign($message, $signature, $privateKey, self::ALGOES[$type] ?? self::ALGOES[self::ALGO_TYPE_RSA2])) { 284 | throw new UnexpectedValueException("Signing the {$message} by {$type} failed, please checking the \$privateKey whether or nor correct."); 285 | } 286 | 287 | return base64_encode($signature); 288 | } 289 | } 290 | --------------------------------------------------------------------------------