CertificateDownloader.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. #!/usr/bin/env php
  2. <?php declare(strict_types=1);
  3. // load autoload.php
  4. $possibleFiles = [__DIR__.'/../vendor/autoload.php', __DIR__.'/../../../autoload.php', __DIR__.'/../../autoload.php'];
  5. $file = null;
  6. foreach ($possibleFiles as $possibleFile) {
  7. if (\file_exists($possibleFile)) {
  8. $file = $possibleFile;
  9. break;
  10. }
  11. }
  12. if (null === $file) {
  13. throw new \RuntimeException('Unable to locate autoload.php file.');
  14. }
  15. require_once $file;
  16. unset($possibleFiles, $possibleFile, $file);
  17. use GuzzleHttp\Middleware;
  18. use GuzzleHttp\Exception\RequestException;
  19. use Psr\Http\Message\ResponseInterface;
  20. use WeChatPay\Builder;
  21. use WeChatPay\ClientDecoratorInterface;
  22. use WeChatPay\Crypto\AesGcm;
  23. /**
  24. * CertificateDownloader class
  25. */
  26. class CertificateDownloader
  27. {
  28. private const DEFAULT_BASE_URI = 'https://api.mch.weixin.qq.com/';
  29. public function run(): void
  30. {
  31. $opts = $this->parseOpts();
  32. if (!$opts || isset($opts['help'])) {
  33. $this->printHelp();
  34. return;
  35. }
  36. if (isset($opts['version'])) {
  37. self::prompt(ClientDecoratorInterface::VERSION);
  38. return;
  39. }
  40. $this->job($opts);
  41. }
  42. /**
  43. * Before `verifier` executing, decrypt and put the platform certificate(s) into the `$certs` reference.
  44. *
  45. * @param string $apiv3Key
  46. * @param array<string,?string> $certs
  47. *
  48. * @return callable(ResponseInterface)
  49. */
  50. private static function certsInjector(string $apiv3Key, array &$certs): callable {
  51. return static function(ResponseInterface $response) use ($apiv3Key, &$certs): ResponseInterface {
  52. $body = (string) $response->getBody();
  53. $json = \json_decode($body);
  54. $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
  55. \array_map(static function($row) use ($apiv3Key, &$certs) {
  56. $cert = $row->encrypt_certificate;
  57. $certs[$row->serial_no] = AesGcm::decrypt($cert->ciphertext, $apiv3Key, $cert->nonce, $cert->associated_data);
  58. }, $data);
  59. return $response;
  60. };
  61. }
  62. /**
  63. * @param array<string,string|true> $opts
  64. *
  65. * @return void
  66. */
  67. private function job(array $opts): void
  68. {
  69. static $certs = ['any' => null];
  70. $outputDir = $opts['output'] ?? \sys_get_temp_dir();
  71. $apiv3Key = (string) $opts['key'];
  72. $instance = Builder::factory([
  73. 'mchid' => $opts['mchid'],
  74. 'serial' => $opts['serialno'],
  75. 'privateKey' => \file_get_contents((string)$opts['privatekey']),
  76. 'certs' => &$certs,
  77. 'base_uri' => (string)($opts['baseuri'] ?? self::DEFAULT_BASE_URI),
  78. ]);
  79. /** @var \GuzzleHttp\HandlerStack $stack */
  80. $stack = $instance->getDriver()->select(ClientDecoratorInterface::JSON_BASED)->getConfig('handler');
  81. // The response middle stacks were executed one by one on `FILO` order.
  82. $stack->after('verifier', Middleware::mapResponse(self::certsInjector($apiv3Key, $certs)), 'injector');
  83. $stack->before('verifier', Middleware::mapResponse(self::certsRecorder((string) $outputDir, $certs)), 'recorder');
  84. $instance->chain('v3/certificates')->getAsync(
  85. ['debug' => true]
  86. )->otherwise(static function($exception) {
  87. self::prompt($exception->getMessage());
  88. if ($exception instanceof RequestException && $exception->hasResponse()) {
  89. /** @var ResponseInterface $response */
  90. $response = $exception->getResponse();
  91. self::prompt((string) $response->getBody(), '', '');
  92. }
  93. self::prompt($exception->getTraceAsString());
  94. })->wait();
  95. }
  96. /**
  97. * After `verifier` executed, wrote the platform certificate(s) onto disk.
  98. *
  99. * @param string $outputDir
  100. * @param array<string,?string> $certs
  101. *
  102. * @return callable(ResponseInterface)
  103. */
  104. private static function certsRecorder(string $outputDir, array &$certs): callable {
  105. return static function(ResponseInterface $response) use ($outputDir, &$certs): ResponseInterface {
  106. $body = (string) $response->getBody();
  107. $json = \json_decode($body);
  108. $data = \is_object($json) && isset($json->data) && \is_array($json->data) ? $json->data : [];
  109. \array_walk($data, static function($row, $index, $certs) use ($outputDir) {
  110. $serialNo = $row->serial_no;
  111. $outpath = $outputDir . \DIRECTORY_SEPARATOR . 'wechatpay_' . $serialNo . '.pem';
  112. self::prompt(
  113. 'Certificate #' . $index . ' {',
  114. ' Serial Number: ' . self::highlight($serialNo),
  115. ' Not Before: ' . (new \DateTime($row->effective_time))->format(\DateTime::W3C),
  116. ' Not After: ' . (new \DateTime($row->expire_time))->format(\DateTime::W3C),
  117. ' Saved to: ' . self::highlight($outpath),
  118. ' You may confirm the above infos again even if this library already did(by Crypto\Rsa::verify):',
  119. ' ' . self::highlight(\sprintf('openssl x509 -in %s -noout -serial -dates', $outpath)),
  120. ' Content: ', '', $certs[$serialNo] ?? '', '',
  121. '}'
  122. );
  123. \file_put_contents($outpath, $certs[$serialNo]);
  124. }, $certs);
  125. return $response;
  126. };
  127. }
  128. /**
  129. * @param string $thing
  130. */
  131. private static function highlight(string $thing): string
  132. {
  133. return \sprintf("\x1B[1;32m%s\x1B[0m", $thing);
  134. }
  135. /**
  136. * @param string $messages
  137. */
  138. private static function prompt(...$messages): void
  139. {
  140. \array_walk($messages, static function (string $message): void { \printf('%s%s', $message, \PHP_EOL); });
  141. }
  142. /**
  143. * @return ?array<string,string|true>
  144. */
  145. private function parseOpts(): ?array
  146. {
  147. $opts = [
  148. [ 'key', 'k', true ],
  149. [ 'mchid', 'm', true ],
  150. [ 'privatekey', 'f', true ],
  151. [ 'serialno', 's', true ],
  152. [ 'output', 'o', false ],
  153. // baseuri can be one of 'https://api2.mch.weixin.qq.com/', 'https://apihk.mch.weixin.qq.com/'
  154. [ 'baseuri', 'u', false ],
  155. ];
  156. $shortopts = 'hV';
  157. $longopts = [ 'help', 'version' ];
  158. foreach ($opts as $opt) {
  159. [$key, $alias] = $opt;
  160. $shortopts .= $alias . ':';
  161. $longopts[] = $key . ':';
  162. }
  163. $parsed = \getopt($shortopts, $longopts);
  164. if (!$parsed) {
  165. return null;
  166. }
  167. $args = [];
  168. foreach ($opts as $opt) {
  169. [$key, $alias, $mandatory] = $opt;
  170. if (isset($parsed[$key]) || isset($parsed[$alias])) {
  171. /** @var string|string[] $possible */
  172. $possible = $parsed[$key] ?? $parsed[$alias] ?? '';
  173. $args[$key] = \is_array($possible) ? $possible[0] : $possible;
  174. } elseif ($mandatory) {
  175. return null;
  176. }
  177. }
  178. if (isset($parsed['h']) || isset($parsed['help'])) {
  179. $args['help'] = true;
  180. }
  181. if (isset($parsed['V']) || isset($parsed['version'])) {
  182. $args['version'] = true;
  183. }
  184. return $args;
  185. }
  186. private function printHelp(): void
  187. {
  188. self::prompt(
  189. 'Usage: 微信支付平台证书下载工具 [-hV]',
  190. ' -f=<privateKeyFilePath> -k=<apiv3Key> -m=<merchantId>',
  191. ' -s=<serialNo> -o=[outputFilePath] -u=[baseUri]',
  192. 'Options:',
  193. ' -m, --mchid=<merchantId> 商户号',
  194. ' -s, --serialno=<serialNo> 商户证书的序列号',
  195. ' -f, --privatekey=<privateKeyFilePath>',
  196. ' 商户的私钥文件',
  197. ' -k, --key=<apiv3Key> APIv3密钥',
  198. ' -o, --output=[outputFilePath]',
  199. ' 下载成功后保存证书的路径,可选,默认为临时文件目录夹',
  200. ' -u, --baseuri=[baseUri] 接入点,可选,默认为 ' . self::DEFAULT_BASE_URI,
  201. ' -V, --version Print version information and exit.',
  202. ' -h, --help Show this help message and exit.', ''
  203. );
  204. }
  205. }
  206. // main
  207. (new CertificateDownloader())->run();