5

微信支付PHP开发对接18讲——02: RSA-OAEP非对称加解密重构

 2 years ago
source link: https://thenorthmemory.github.io/post/18-points-of-the-wechatpay-php-openapi-sdk-section02/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

微信支付PHP开发对接18讲——02: RSA-OAEP非对称加解密重构

2021-06-29

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

<?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。代码块如下:

<?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 公钥加密

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

<?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,可以安全使用。

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

<?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 私钥解密

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

<?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;
}

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

<?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 私钥签名

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

<?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);
}

测试代码如下:

<?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 公钥验签

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

<?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余个函数就抽象完成了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK