官方APIv3做了安全加强,对于敏感信息及回调通知信息,才有了AES-GCM加密,依赖商户平台配置APIv3密钥。PHP自7.1开始支持GCM模式。上一讲提到了协变(covariant)设计规则,这个类的实现就是对AesInterface做了方法入参扩展,分别如下。

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

这个方法如同Rsa::preCondition类似,检测当前ext-openssl扩展,是否支持aes-256-gcm加解密算法,均可在未来的版本中安全删除。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php
/**
 * Detect the ext-openssl whether or nor including the `aes-256-gcm` algorithm
 *
 * @throws RuntimeException
 */
private static function preCondition(): void
{
    if (!in_array(static::ALGO_AES_256_GCM, openssl_get_cipher_methods())) {
        throw new RuntimeException('It looks like the ext-openssl extension missing the `aes-256-gcm` cipher method.');
    }
}

扩展知识:static::ALGO_AES_256_GCMPHP7中的延迟静态绑定(late static bindings),其作用域是要看运行时的上下文类,更多知识参阅PHP官方文档。

Crypto\AesGcm::encrypt 加密

这个方法,在官方的wechatpay-guzzle-middleware没有实现,这是新包新增的。从这个我们能窥出一丢丢官方接口设计上的一些分歧点

官方文档上的敏感信息加解密,上下行均才有RSA证书加密模式,RSA是非对称加解密,公钥加密/私钥解密,可以提供极佳的安全体验。而在证书及回调通知时,却采用的是对称加解密方案,加密均由平台方来完成,商户侧仅需在收到报文进行解密即可。

为什么在敏感信息加解密使用非对称加解密,而在证书及回调通知使用对称加解密,唯一合理的解释就是为了安全,“良苦用心”没有明说,然这个没明说却给对接APIv3带出了许多“难以理解”;而不提供对称加密函数,这又让人不得不把问题上升到哲学层面(我认为你不需要,所以我不提供了)唉。。。

实现这个函数,也就几行代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
/**
 * Encrypts given data with given key, iv and aad, returns a base64 encoded string.
 *
 * @param string $plaintext - Text to encode.
 * @param string $key - The secret key, 32 bytes string.
 * @param string $iv - The initialization vector, 16 bytes string.
 * @param string $aad - The additional authenticated data, maybe empty string.
 *
 * @return string - The base64-encoded ciphertext.
 */
public static function encrypt(string $plaintext, string $key, string $iv = '', string $aad = ''): string
{
    static::preCondition();

    $ciphertext = openssl_encrypt($plaintext, static::ALGO_AES_256_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, static::BLOCK_SIZE);

    if (false === $ciphertext) {
        throw new UnexpectedValueException('Encrypting the input $plaintext failed, please checking your $key and $iv whether or nor correct.');
    }

    return base64_encode($ciphertext . $tag);
}

测试代码如下:

 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
43
<?php
const BASE64_EXPRESSION = '#^[a-zA-Z0-9\+/]+={0,2}$#';

/**
 * @return array<string,array{string,string,string,string}>
 */
public function dataProvider(): array
{
    return [
        'random key and iv' => [
            'hello wechatpay 你好 微信支付',
            Formatter::nonce(AesGcm::KEY_LENGTH_BYTE),
            Formatter::nonce(AesGcm::BLOCK_SIZE),
            ''
        ],
        'random key, iv and aad' => [
            'hello wechatpay 你好 微信支付',
            Formatter::nonce(AesGcm::KEY_LENGTH_BYTE),
            Formatter::nonce(AesGcm::BLOCK_SIZE),
            Formatter::nonce(AesGcm::BLOCK_SIZE)
        ],
    ];
}

/**
 * @dataProvider dataProvider
 * @param string $plaintext
 * @param string $key
 * @param string $iv
 * @param string $aad
 */
public function testEncrypt(string $plaintext, $key, $iv, $aad): void
{
    $ciphertext = AesGcm::encrypt($plaintext, $key, $iv, $aad);
    self::assertIsString($ciphertext);
    self::assertNotEquals($plaintext, $ciphertext);

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

目前看的这个测试用例,是修正后的,期间翻了一次车。。。因由是由BASE64_EXPRESSION类常量的定义引起的。

测试用例覆盖,采用了与RsaTest类似的动态生成数据供给方案,为验证加密后的字符串是base64而不是其他,所以需要个规则来判断字符串是不是base64,这里采用了正则表达式。正是这个表达式,翻了车了。

上一版的正则表达式为#^[a-zA-Z0-9][a-zA-Z0-9\+/]*={0,2}$#,我们来回溯一些测试样本数据:

1
2
3
('hello wechatpay 你好 微信支付', 'RSXrQ0bANKaUGdbvWwPENFNjhftB6EYs', 'XxG5mkSo7DBiGxSN', '') -> '/bXfSUzxl3dcrGBbduG6Jh9vd269iRzO91qSRnzzLl+RPxH6fVPS6hKPlC3hADltDKuU'
('hello wechatpay 你好 微信支付', '0hVcffnbHcx9zpyi9bgmbGtDZHOXuq6V', 'In4deshcFFOhdyTs', 'lUY1Dm04bXRhj4Z1') -> '+RwaCGnFNMJPezHifxSBjEuJR3LBYndNLZHO1gV9cj5/hlL55hwlcNzpAOr/1Vm42hp8'
('hello wechatpay 你好 微信支付', '0ZehDc6SnHPcEzqXv18Qiikz0syFvUoO', 'urJE0OMNEuVYwY9Y', '') -> '/LtXN1bbvlxubbgypv23QKsdIw14RAhsL1GNUHAfwfEBBNp2elvcy7mw8D8KUOJ4VIUC'

base64字符串+/也可以出现在起始位,翻新了我对base64的认知。。。

Crypto\AesGcm::decrypt 解密

解密函数对官方源版做了部分调整,调整点是对auth_tag长度判断上进行判断。按照php官方手册上说openssl_decrypt在出来GCM模式密文时,调用方要自行判断auth_tag的长度。实现代码如下:

 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
<?php
/**
 * Takes a base64 encoded string and decrypts it using a given key, iv and aad.
 *
 * @param string $ciphertext - The base64-encoded ciphertext.
 * @param string $key - The secret key, 32 bytes string.
 * @param string $iv - The initialization vector, 16 bytes string.
 * @param string $aad - The additional authenticated data, maybe empty string.
 *
 * @return string - The utf-8 plaintext.
 */
public static function decrypt(string $ciphertext, string $key, string $iv = '', string $aad = ''): string
{
    static::preCondition();

    $ciphertext = base64_decode($ciphertext);
    $authTag = substr($ciphertext, intval(-static::BLOCK_SIZE));
    $tagLength = strlen($authTag);

    /* Manually checking the length of the tag, because the `openssl_decrypt` was mentioned there, it's the caller's responsibility. */
    if ($tagLength > static::BLOCK_SIZE || ($tagLength < 12 && $tagLength !== 8 && $tagLength !== 4)) {
        throw new RuntimeException('The inputs `$ciphertext` incomplete, the bytes length must be one of 16, 15, 14, 13, 12, 8 or 4.');
    }

    $plaintext = openssl_decrypt(substr($ciphertext, 0, intval(-static::BLOCK_SIZE)), static::ALGO_AES_256_GCM, $key, OPENSSL_RAW_DATA, $iv, $authTag, $aad);

    if (false === $plaintext) {
        throw new UnexpectedValueException('Decrypting the input $ciphertext failed, please checking your $key and $iv whether or nor correct.');
    }

    return $plaintext;
}

测试代码如下:

 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
43
44
45
<?php
/**
 * @return array<string,array{string,string,string,string}>
 */
public function dataProvider(): array
{
    return [
        'random key and iv' => [
            'hello wechatpay 你好 微信支付',
            Formatter::nonce(AesGcm::KEY_LENGTH_BYTE),
            Formatter::nonce(AesGcm::BLOCK_SIZE),
            ''
        ],
        'random key, iv and aad' => [
            'hello wechatpay 你好 微信支付',
            Formatter::nonce(AesGcm::KEY_LENGTH_BYTE),
            Formatter::nonce(AesGcm::BLOCK_SIZE),
            Formatter::nonce(AesGcm::BLOCK_SIZE)
        ],
    ];
}

/**
 * @dataProvider dataProvider
 * @param string $plaintext
 * @param string $key
 * @param string $iv
 * @param string $aad
 */
public function testDecrypt(string $plaintext, $key, $iv, $aad): void
{
    $ciphertext = AesGcm::encrypt($plaintext, $key, $iv, $aad);
    self::assertIsString($ciphertext);
    self::assertNotEquals($plaintext, $ciphertext);

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

    $mytext = AesGcm::decrypt($ciphertext, $key, $iv, $aad);
    self::assertIsString($mytext);
    self::assertEquals($plaintext, $mytext);
}

至此,APIv3上的包括证书及回调所需的函数,均封装完毕,下一讲就对接HttpClient,来驱动请求响应。