这个 Crypto\Rsa 类,是对之前的一个实现 Util\SensitiveInfoCrypto 重构。上一版实现是这么用的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
// Encrypt usage:
$encryptor = new SensitiveInfoCrypto(
    PemUtil::loadCertificate('/downloaded/pubcert.pem')
);
$json = json_encode(['name' => $encryptor('Alice')]);
// That's simple!

// Decrypt usage:
$decryptor = new SensitiveInfoCrypto(
    null,
    PemUtil::loadPrivateKey('/merchant/key.pem')
);
$decrypted = $decryptor->setStage('decrypt')(
    'base64 encoding message was given by the payment plat'
);
// That's simple too!

// Working both Encrypt and Decrypt usages:
$crypto = new SensitiveInfoCrypto(
    PemUtil::loadCertificate('/merchant/cert.pem'),
    PemUtil::loadPrivateKey('/merchant/key.pem')
);
$encrypted = $crypto('Carol');
$decrypted = $crypto->setStage('decrypt')($encrypted);
// Having fun with this!

有开发者反馈,上述用法看似简单,其实用起来”坑”蛮多的。稍微分析一下,确实是的。”坑”点在于:初始化所需的私钥公钥(证书),在业务场景下是非配对的!公钥(证书)加密时,所用的公钥(证书)平台证书(公钥),而解密时所需的私钥,是商户私钥。并且,加解密稍不注意就会干扰到业务处理(初始化参数以及切换stage稍微繁琐)。

是的,这个SensitiveInfoCrypto类过度设计了。

所以,在新包内,这个是必须要被重写一遍实现的。

Crypto\Rsa::preCondition 前置条件检测

检测当前ext-openssl扩展,是否支持SHA256哈希散列,为了更清晰地区别传统Hash散列算法,这里用到了算法别名即sha256WithRSAEncryption。代码块如下:

1
2
3
4
5
6
7
8
<?php
const sha256WithRSAEncryption = 'sha256WithRSAEncryption';
private static function preCondition(): void
{
    if (!in_array(sha256WithRSAEncryption, openssl_get_md_methods(true))) {
        throw new RuntimeException('It looks like the ext-openssl extension missing the `sha256WithRSAEncryption` digest method.');
    }
}

小技巧: 这里用到了命名空间下常量功能(PHP7开始支持),定义了一个同名的 sha256WithRSAEncryption 哈希别名常量,RSA下的SHA256哈希散列别名,这个检测其实是多余的,在未来的某个版本,可以安全地移除掉。

Crypto\Rsa::encrypt 公钥加密

既然是要重写,首先要考虑易用,那静态方法其实比实例化后使用方便得多,代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
/**
 * Encrypts text with `OPENSSL_PKCS1_OAEP_PADDING`.
 *
 * @param string $plaintext - Cleartext to encode.
 * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string|mixed $publicKey - A PEM encoded public key.
 *
 * @return string - The base64-encoded ciphertext.
 * @throws UnexpectedValueException
 */
public static function encrypt(string $plaintext, $publicKey): string
{
    if (!openssl_public_encrypt($plaintext, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) {
        throw new UnexpectedValueException('Encrypting the input $plaintext failed, please checking your $publicKey whether or nor correct.');
    }

    return base64_encode($encrypted);
}

函数接受两个参数,同时对返回值做了类型签名,所接受的第二参数 $publicKey 是透传给 openssl_public_encrypt 函数的,所以可以接受的类型范围比较广。 这里捎带提一下,PHP8 有许多改进,尤其是把OpenSSL相关的原资源类型,现在定义成对象了,即代码注释上的: \OpenSSLAsymmetricKey|\OpenSSLCertificate,这俩是PHP8上才有的。

在加入PHPStan代码静态分析工具后,这里就稍显尴尬了,因为本SDK最低版本要兼容至PHP7.2,迭代过程中,前后兼容PHP8是个挑战,遂加入了 phpstan-baseline.neon 基线,特意区分开了 phpstan-php7.neonphpstan.neon.dist 各两个配置文件,静态分析从4级(level3)提升至6级(level5)再至7级(level6),以至最高级别(level8/max)做了大量的代码注释修正以及代码优化。 目前看到的即是最高等级静态分析的代码。

小技巧: 这里同样用到了PHP7命名空间下声明使用常量功能,即 use const OPENSSL_PKCS1_OAEP_PADDING;。所以在中间代码块上,可以不用再特别注意 FQN,可以安全使用。

我们用测试用例来覆盖一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
const BASE64_EXPRESSION = '#^[a-zA-Z0-9][a-zA-Z0-9\+/]*={0,2}$#';
/**
 * @return array<string,array{string,string|resource|mixed,resource|mixed}>
 */
public function keysProvider(): array
{
    $privateKey = openssl_pkey_new([
        'digest_alg' => 'sha256',
        'default_bits' => 2048,
        'private_key_bits' => 2048,
        'private_key_type' => OPENSSL_KEYTYPE_RSA,
        'config' => dirname(__DIR__) . DS . 'fixtures' . DS . 'openssl.conf',
    ]);

    while ($msg = openssl_error_string()) {
        'cli' === PHP_SAPI && fwrite(STDERR, 'OpenSSL ' . $msg . PHP_EOL);
    }

    ['key' => $publicKey] = $privateKey ? openssl_pkey_get_details($privateKey) : [];

    return [
        'plaintext, publicKey and privateKey' => ['hello wechatpay 你好 微信支付', $publicKey, $privateKey]
    ];
}
/**
 * @dataProvider keysProvider
 * @param string $plaintext
 * @param object|resource|mixed $publicKey
 */
public function testEncrypt(string $plaintext, $publicKey): void
{
    $ciphertext = Rsa::encrypt($plaintext, $publicKey);
    self::assertIsString($ciphertext);
    self::assertNotEquals($plaintext, $ciphertext);

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        $this->assertMatchesRegularExpression(BASE64_EXPRESSION, $ciphertext);
    } else {
        self::assertRegExp(BASE64_EXPRESSION, $ciphertext);
    }
}

BASE64_EXPRESSION 是个命名空间常量,是 base64 字符串的一个正则匹配规则,相较于Formatter类内置的 bas64 检测规则,这里做了调整,加入来必须是字母或数字开头规则。

有人可能会问,这里为什么不用\w\d代替呢?答案是:按照base64规范,只能出现字母或数字或加号或斜线\w\word 的简写, \word 存在语言适配表现不一致情况,即在法语系内,部分字符也是匹配到了 \w 内,这是其一;其二就是 \d 按照PHP官方文档介绍,是decial digit的简写,decial可能会带入点号(.)及逗号(,),不严谨,遂还是按照base64规范来。

另外,这里的数据供给器keysProvider函数,调试调整了一段时间,思考如下:

  1. 相较于传统使用文件fixtures来提供RSA私钥/公钥,使是函数生成,是为了更安全的被使用在测试场景中;
  2. 这里尝试更范的场景覆盖,每轮生成的私钥公钥理论上不一样,覆盖会更广;

数据供给器生成环节,检测出一个问题就是,在windows上,PHP7.2/7.37.4+表现不一致,内置的 openssl_pkey_new 函数在7.2/7.3上不工作。这真是“意外”中的意外。

在翻了PHP源码以及百谷歌度之后,最后从PHP手册上找到了线索如下:

Note: Note to Win32 Users

Additionally, if you are planning to use the key generation and certificate signing functions, you will need to install a valid openssl.cnf file on your system.

随后又翻了下PHP的变更历史,PHP7.4.0对windows环境做了优化,C++代码做了自动搜索openssl.cnf文件并取默认值。前向兼容方案遂如上述代码,在私钥生成时,指定配置文件即可。

小技巧:

  1. ext-openssl在工作时,会在各个阶段把异常信息打入堆栈中,可以通过 openssl_error_string 获取到堆栈信息;
  2. 在测试环境下,本测试供给器函数,把这些“错误”信息,使用了 fwrite 直接写入至 STDERR 管道,仅在CLI模式下有效;
  3. 数组Array解构,除了用list顺序解构(PHP7+)之外,还可以通过键值key来解构,即 ['key' => $publicKey] = [] 形式来解构;

Crypto\Rsa::decrypt 私钥解密

对应地,私钥解密也变得用起来简单得多了,型参类型签名,返回值类型签名,代码块如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
/**
 * Decrypts base64 encoded string with `privateKey` with `OPENSSL_PKCS1_OAEP_PADDING`.
 *
 * @param string $ciphertext - Was previously encrypted string using the corresponding public key.
 * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|resource|string|mixed $privateKey - A PEM encoded private key.
 *
 * @return string - The utf-8 plaintext.
 * @throws UnexpectedValueException
 */
public static function decrypt(string $ciphertext, $privateKey): string
{
    if (!openssl_private_decrypt(base64_decode($ciphertext), $decrypted, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) {
        throw new UnexpectedValueException('Decrypting the input $ciphertext failed, please checking your $privateKey whether or nor correct.');
    }

    return $decrypted;
}

如前所属,每轮测试的数据供给是不一样的,所以得从加密开始,测试用例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
/**
 * @dataProvider keysProvider
 * @param string $plaintext
 * @param object|resource|mixed $publicKey
 * @param object|resource|mixed $privateKey
 */
public function testDecrypt(string $plaintext, $publicKey, $privateKey): void
{
    $ciphertext = Rsa::encrypt($plaintext, $publicKey);
    self::assertIsString($ciphertext);
    self::assertNotEquals($plaintext, $ciphertext);

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        $this->assertMatchesRegularExpression(BASE64_EXPRESSION, $ciphertext);
    } else {
        self::assertRegExp(BASE64_EXPRESSION, $ciphertext);
    }

    $mytext = Rsa::decrypt($ciphertext, $privateKey);
    self::assertIsString($mytext);
    self::assertEquals($plaintext, $mytext);
}

这里有个知识点需要补充一下,即,publicKey 公钥和 privateKey 私钥是配对的,公钥可以从私钥提取、也可以从私钥签发的证书提取。当前测试用例是从私钥提取的,后边再讲从证书提取。

Crypto\Rsa::sign 私钥签名

顾名思义,私钥理应是私密的,用来做签名,具有不可篡改特性。签名封装代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php
/**
 * Creates and returns a `base64_encode` string that uses `sha256WithRSAEncryption`.
 *
 * @param string $message - Content will be `openssl_sign`.
 * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string|mixed $privateKey - A PEM encoded private key.
 *
 * @return string - The base64-encoded signature.
 * @throws UnexpectedValueException
 */
public static function sign(string $message, $privateKey): string
{
    static::preCondition();

    if (!openssl_sign($message, $signature, $privateKey, sha256WithRSAEncryption)) {
        throw new UnexpectedValueException('Signing the input $message failed, please checking your $privateKey whether or nor correct.');
    }

    return base64_encode($signature);
}

测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php
/**
 * @dataProvider keysProvider
 * @param string $plaintext
 * @param object|resource|mixed $publicKey
 * @param object|resource|mixed $privateKey
 */
public function testSign(string $plaintext, $publicKey, $privateKey): void
{
    $signature = Rsa::sign($plaintext, $privateKey);

    self::assertIsString($signature);
    self::assertNotEquals($plaintext, $signature);

    if (method_exists($this, 'assertMatchesRegularExpression')) {
        $this->assertMatchesRegularExpression(BASE64_EXPRESSION, $signature);
    } else {
        self::assertRegExp(BASE64_EXPRESSION, $signature);
    }
}

因为使用了同一套数据供给器代码,所以这个测试用例上,第二参数$publicKey还得加上(虽然没用)。

Crypto\Rsa::verify 公钥验签

这个验签逻辑,可以用来理解非对称加密技术。如上一小结,私钥数据签名的数据,一般私钥是需要严密保存的,基本不会对外分发。那问题来了,收到加密数据的接收方,应该如何验证数据签名来自预期的数据签名方呢?公钥验签就是来解决这个数据数据签名真伪的一种方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
/**
 * Verifying the `message` with given `signature` string that uses `sha256WithRSAEncryption`.
 *
 * @param string $message - Content will be `openssl_verify`.
 * @param string $signature - The base64-encoded ciphertext.
 * @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string|mixed $publicKey - A PEM encoded public key.
 *
 * @return boolean - True is passed, false is failed.
 * @throws UnexpectedValueException
 */
public static function verify(string $message, string $signature, $publicKey): bool
{
    static::preCondition();

    if (($result = openssl_verify($message, base64_decode($signature), $publicKey, sha256WithRSAEncryption)) === false) {
        throw new UnexpectedValueException('Verified the input $message failed, please checking your $publicKey whether or nor correct.');
    }

    return $result === 1;
}

小知识:上一小结提到,私钥公钥是配对出现的,公钥含在私钥证书里,所以验签逻辑的公钥输入,可以是源私钥,也可以是源私钥签发的证书,即代码注释里的\OpenSSLAsymmetricKey\OpenSSLCertificate

至此,01章节格式化请求参数格式化响应参数提到的两个关键函数 Rsa::signRsa::verify 也讲解完了,微信支付APIv3的核心部件,通过这两个静态类,共计10余个函数就抽象完成了。