feat(security): 强化 token 密钥证书格式校验

- 新增 CredentialValidator,集中校验微信 Token、EncodingAESKey、APIv3 Key、RSA 密钥和证书。

- 配置对象构造时提前验证微信、微信支付、支付宝关键凭证格式。

- 消息加解密和支付通知解密复用统一校验,减少运行期隐性错误。

- 补充无效 Token、密钥、证书和解密场景测试,提升凭证错误可诊断性。
This commit is contained in:
Anyon 2026-05-08 11:32:58 +08:00
parent 80af84bcb9
commit e8cd998852
13 changed files with 258 additions and 49 deletions

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 支付宝支付配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
@ -11,6 +13,4 @@ namespace We\Config;
/** /**
* 支付宝支付配置,继承支付宝开放平台网关签名与验签参数。 * 支付宝支付配置,继承支付宝开放平台网关签名与验签参数。
*/ */
final class AlipayPaymentConfig extends AlipayPlatformConfig final class AlipayPaymentConfig extends AlipayPlatformConfig {}
{
}

View File

@ -1,15 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 支付宝开放平台配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
use We\Contract\ConfigInterface; use We\Contract\ConfigInterface;
use We\Exception\WechatException; use We\Exception\WechatException;
use We\Support\CredentialValidator;
/** /**
* 支付宝开放平台基础配置。 * 支付宝开放平台基础配置。
@ -44,6 +47,16 @@ class AlipayPlatformConfig implements ConfigInterface
if (trim($this->appid) === '' || trim($this->privateKey) === '') { if (trim($this->appid) === '' || trim($this->privateKey) === '') {
throw new WechatException('支付宝 appid 与 private_key 不能为空'); throw new WechatException('支付宝 appid 与 private_key 不能为空');
} }
if (!in_array(strtoupper($this->signType), ['RSA', 'RSA2'], true)) {
throw new WechatException('支付宝 sign_type 仅支持 RSA 或 RSA2');
}
if (!filter_var($this->gateway, FILTER_VALIDATE_URL)) {
throw new WechatException('支付宝 gateway 必须是有效 URL');
}
CredentialValidator::assertPrivateKey($this->privateKey, '支付宝 private_key', true);
if ($this->alipayPublicKey !== '') {
CredentialValidator::assertPublicKey($this->alipayPublicKey, '支付宝 alipay_public_key', true);
}
} }
/** /**

View File

@ -1,15 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信支付 APIv3 商户配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
use We\Contract\ConfigInterface; use We\Contract\ConfigInterface;
use We\Exception\WechatException; use We\Exception\WechatException;
use We\Support\CredentialValidator;
/** /**
* 微信支付 APIv3 商户配置。 * 微信支付 APIv3 商户配置。
@ -53,6 +56,14 @@ final class WechatPaymentConfig implements ConfigInterface
throw new WechatException($name . ' 不能为空'); throw new WechatException($name . ' 不能为空');
} }
} }
CredentialValidator::assertApiV3Key($this->apiV3Key);
CredentialValidator::assertPrivateKey($this->merchantPrivateKey, 'merchantPrivateKey');
if ($this->platformCertificate !== '') {
CredentialValidator::assertPublicKey($this->platformCertificate, 'platformCertificate');
}
if ($this->platformPublicKey !== '') {
CredentialValidator::assertPublicKey($this->platformPublicKey, 'platformPublicKey');
}
} }
/** /**
@ -62,7 +73,7 @@ final class WechatPaymentConfig implements ConfigInterface
*/ */
public static function fromArray(array $data): static public static function fromArray(array $data): static
{ {
return new static( return new self(
(string)($data['appid'] ?? ''), (string)($data['appid'] ?? ''),
(string)($data['mch_id'] ?? $data['mchid'] ?? ''), (string)($data['mch_id'] ?? $data['mchid'] ?? ''),
(string)($data['api_v3_key'] ?? $data['mch_v3_key'] ?? ''), (string)($data['api_v3_key'] ?? $data['mch_v3_key'] ?? ''),

View File

@ -1,15 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信公众平台配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
use We\Contract\ConfigInterface; use We\Contract\ConfigInterface;
use We\Exception\WechatException; use We\Exception\WechatException;
use We\Support\CredentialValidator;
/** /**
* 微信公众平台基础配置。 * 微信公众平台基础配置。
@ -42,7 +45,7 @@ final class WechatPlatformConfig implements ConfigInterface
*/ */
public static function fromArray(array $data): static public static function fromArray(array $data): static
{ {
return new static( return new self(
(string)($data['appid'] ?? ''), (string)($data['appid'] ?? ''),
(string)($data['appsecret'] ?? $data['app_secret'] ?? ''), (string)($data['appsecret'] ?? $data['app_secret'] ?? ''),
(string)($data['token'] ?? ''), (string)($data['token'] ?? ''),
@ -64,5 +67,14 @@ final class WechatPlatformConfig implements ConfigInterface
throw new WechatException($name . ' 不能为空'); throw new WechatException($name . ' 不能为空');
} }
} }
if ($this->token !== '') {
CredentialValidator::assertWechatToken($this->token);
}
if ($this->encodingAesKey !== '') {
if (trim($this->token) === '') {
throw new WechatException('token 不能为空');
}
CredentialValidator::assertEncodingAesKey($this->encodingAesKey, 'encodingAesKey');
}
} }
} }

View File

@ -1,15 +1,18 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信服务平台(第三方平台)配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
use We\Contract\ConfigInterface; use We\Contract\ConfigInterface;
use We\Exception\WechatException; use We\Exception\WechatException;
use We\Support\CredentialValidator;
/** /**
* 微信服务平台(第三方平台)配置。 * 微信服务平台(第三方平台)配置。
@ -47,6 +50,8 @@ final class WechatServiceConfig implements ConfigInterface
throw new WechatException($name . ' 不能为空'); throw new WechatException($name . ' 不能为空');
} }
} }
CredentialValidator::assertWechatToken($this->componentToken, 'componentToken');
CredentialValidator::assertEncodingAesKey($this->componentEncodingAesKey, 'componentEncodingAesKey');
} }
/** /**
@ -56,7 +61,7 @@ final class WechatServiceConfig implements ConfigInterface
*/ */
public static function fromArray(array $data): static public static function fromArray(array $data): static
{ {
return new static( return new self(
(string)($data['component_appid'] ?? ''), (string)($data['component_appid'] ?? ''),
(string)($data['component_appsecret'] ?? $data['component_app_secret'] ?? ''), (string)($data['component_appsecret'] ?? $data['component_app_secret'] ?? ''),
(string)($data['component_token'] ?? ''), (string)($data['component_token'] ?? ''),

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信小程序配置对象。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Config; namespace We\Config;
@ -37,7 +39,7 @@ final class WechatWxappConfig implements ConfigInterface
*/ */
public static function fromArray(array $data): static public static function fromArray(array $data): static
{ {
return new static( return new self(
(string)($data['appid'] ?? ''), (string)($data['appid'] ?? ''),
(string)($data['appsecret'] ?? $data['app_secret'] ?? ''), (string)($data['appsecret'] ?? $data['app_secret'] ?? ''),
(string)($data['storage_scope'] ?? $data['storageScope'] ?? ''), (string)($data['storage_scope'] ?? $data['storageScope'] ?? ''),

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/
namespace We\Support;
use We\Exception\WechatException;
/**
* 密钥、证书与平台安全参数校验工具。
*/
final class CredentialValidator
{
/**
* 校验微信服务器配置 Token。
*/
public static function assertWechatToken(string $token, string $field = 'token'): void
{
if (preg_match('/^[A-Za-z0-9]{3,32}$/', $token) !== 1) {
throw new WechatException($field . ' 必须是 3-32 位英文或数字');
}
}
/**
* 校验微信消息 EncodingAESKey。
*/
public static function assertEncodingAesKey(string $encodingAesKey, string $field = 'EncodingAESKey'): void
{
if (strlen($encodingAesKey) !== 43) {
throw new WechatException($field . ' 必须是 43 位有效字符串');
}
$key = base64_decode($encodingAesKey . '=', true);
if ($key === false || strlen($key) !== 32) {
throw new WechatException($field . ' 必须是 43 位有效字符串');
}
}
/**
* 校验微信支付 APIv3 密钥。
*/
public static function assertApiV3Key(string $apiV3Key): void
{
if (strlen($apiV3Key) !== 32) {
throw new WechatException('apiV3Key 必须是 32 字节字符串');
}
}
/**
* 校验 RSA 私钥。
*/
public static function assertPrivateKey(string $privateKey, string $field, bool $wrapRawKey = false): void
{
$resource = openssl_pkey_get_private(self::normalizePrivateKey($privateKey, $wrapRawKey));
if ($resource === false) {
throw new WechatException($field . ' 格式无效');
}
}
/**
* 校验 RSA 公钥或证书。
*/
public static function assertPublicKey(string $publicKey, string $field, bool $wrapRawKey = false): void
{
$resource = openssl_pkey_get_public(self::normalizePublicKey($publicKey, $wrapRawKey));
if ($resource === false) {
throw new WechatException($field . ' 格式无效');
}
}
/**
* 将私钥规范化为 PEM 字符串;支付宝支持传入无头尾的密钥正文。
*/
public static function normalizePrivateKey(string $privateKey, bool $wrapRawKey = false): string
{
$key = trim($privateKey);
if ($key === '' || str_contains($key, 'BEGIN')) {
return $key;
}
if (!$wrapRawKey) {
return $key;
}
return "-----BEGIN PRIVATE KEY-----\n" . chunk_split($key, 64, "\n") . '-----END PRIVATE KEY-----';
}
/**
* 将公钥规范化为 PEM 字符串;支付宝支持传入无头尾的公钥正文。
*/
public static function normalizePublicKey(string $publicKey, bool $wrapRawKey = false): string
{
$key = trim($publicKey);
if ($key === '' || str_contains($key, 'BEGIN')) {
return $key;
}
if (!$wrapRawKey) {
return $key;
}
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split($key, 64, "\n") . '-----END PUBLIC KEY-----';
}
}

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信消息安全模式加解密工具。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Support; namespace We\Support;
@ -28,11 +30,10 @@ final class MessageCrypto
string $encodingAesKey, string $encodingAesKey,
private string $appid, private string $appid,
) { ) {
if (strlen($encodingAesKey) !== 43) { CredentialValidator::assertWechatToken($this->token);
throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); CredentialValidator::assertEncodingAesKey($encodingAesKey);
}
$key = base64_decode($encodingAesKey . '=', true); $key = base64_decode($encodingAesKey . '=', true);
if ($key === false || strlen($key) !== 32) { if (!is_string($key)) {
throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); throw new WechatException('EncodingAESKey 必须是 43 位有效字符串');
} }
$this->aesKey = $key; $this->aesKey = $key;

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信支付 APIv3 通知资源解密工具。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Support; namespace We\Support;
@ -20,17 +22,25 @@ final class PaymentCrypto
/** /**
* 解密微信支付 APIv3 通知中的 resource 字段。 * 解密微信支付 APIv3 通知中的 resource 字段。
* *
* @param array{ciphertext:string,nonce:string,associated_data?:string} $resource * @param array<string,mixed> $resource
* @return array<string,mixed> * @return array<string,mixed>
*/ */
public static function decryptResource(string $apiV3Key, array $resource): array public static function decryptResource(string $apiV3Key, array $resource): array
{ {
foreach (['ciphertext', 'nonce'] as $field) { CredentialValidator::assertApiV3Key($apiV3Key);
if (!isset($resource[$field]) || !is_string($resource[$field]) || $resource[$field] === '') { $ciphertextValue = $resource['ciphertext'] ?? null;
throw new WechatException('微信支付回调资源字段缺失: ' . $field); $nonce = $resource['nonce'] ?? null;
} $associatedData = $resource['associated_data'] ?? '';
if (!is_string($ciphertextValue) || $ciphertextValue === '') {
throw new WechatException('微信支付回调资源字段缺失: ciphertext');
} }
$ciphertext = base64_decode($resource['ciphertext'], true); if (!is_string($nonce) || $nonce === '') {
throw new WechatException('微信支付回调资源字段缺失: nonce');
}
if (!is_string($associatedData)) {
throw new WechatException('微信支付回调资源字段无效: associated_data');
}
$ciphertext = base64_decode($ciphertextValue, true);
if ($ciphertext === false || strlen($ciphertext) <= 16) { if ($ciphertext === false || strlen($ciphertext) <= 16) {
throw new WechatException('微信支付回调密文无效'); throw new WechatException('微信支付回调密文无效');
} }
@ -42,9 +52,9 @@ final class PaymentCrypto
'aes-256-gcm', 'aes-256-gcm',
$apiV3Key, $apiV3Key,
OPENSSL_RAW_DATA, OPENSSL_RAW_DATA,
$resource['nonce'], $nonce,
$tag, $tag,
(string)($resource['associated_data'] ?? '') $associatedData
); );
if (!is_string($plain) || $plain === '') { if (!is_string($plain) || $plain === '') {
throw new WechatException('微信支付回调解密失败'); throw new WechatException('微信支付回调解密失败');

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 签名与验签工具。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Support; namespace We\Support;
@ -67,7 +69,11 @@ final class Signature
if ($key === false) { if ($key === false) {
throw new SignatureException('微信支付平台公钥或证书无效'); throw new SignatureException('微信支付平台公钥或证书无效');
} }
$decoded = base64_decode($signature, true);
if ($decoded === false) {
return false;
}
return openssl_verify($message, base64_decode($signature, true) ?: '', $key, OPENSSL_ALGO_SHA256) === 1; return openssl_verify($message, $decoded, $key, OPENSSL_ALGO_SHA256) === 1;
} }
} }

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 平台配置契约与配置对象测试。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Tests; namespace We\Tests;
@ -21,6 +23,7 @@ use We\Exception\WechatException;
/** /**
* 平台配置契约与配置对象测试用例。 * 平台配置契约与配置对象测试用例。
* @internal
*/ */
#[CoversClass(ConfigInterface::class)] #[CoversClass(ConfigInterface::class)]
final class ConfigInterfaceTest extends TestCase final class ConfigInterfaceTest extends TestCase
@ -33,10 +36,10 @@ final class ConfigInterfaceTest extends TestCase
foreach ([ foreach ([
new WechatPlatformConfig('wx_app', 'secret'), new WechatPlatformConfig('wx_app', 'secret'),
new WechatWxappConfig('wx_wxapp', 'secret'), new WechatWxappConfig('wx_wxapp', 'secret'),
new WechatServiceConfig('wx_component', 'secret', 'token', 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'), new WechatServiceConfig('wx_component', 'secret', 'token', TestKeys::encodingAesKey()),
new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'private-key'), new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', TestKeys::privateKey()),
new AlipayPlatformConfig('ali_app', 'private-key'), new AlipayPlatformConfig('ali_app', TestKeys::privateKey()),
new AlipayPaymentConfig('ali_pay', 'private-key'), new AlipayPaymentConfig('ali_pay', TestKeys::privateKey()),
] as $config) { ] as $config) {
$this->assertInstanceOf(ConfigInterface::class, $config); $this->assertInstanceOf(ConfigInterface::class, $config);
} }
@ -64,6 +67,39 @@ final class ConfigInterfaceTest extends TestCase
WechatPaymentConfig::fromArray(['appid' => 'wx_app']); WechatPaymentConfig::fromArray(['appid' => 'wx_app']);
} }
/**
* 测试微信服务平台配置会校验 EncodingAESKey 格式。
*/
public function testWechatServiceConfigRejectsInvalidEncodingAesKey(): void
{
$this->expectException(WechatException::class);
$this->expectExceptionMessage('componentEncodingAesKey');
new WechatServiceConfig('wx_component', 'secret', 'token', 'invalid');
}
/**
* 测试微信消息 Token 只能使用官方允许的英文或数字格式。
*/
public function testWechatConfigRejectsInvalidMessageToken(): void
{
$this->expectException(WechatException::class);
$this->expectExceptionMessage('token');
new WechatPlatformConfig('wx_app', 'secret', 'message_token', TestKeys::encodingAesKey());
}
/**
* 测试微信支付配置会校验 APIv3 Key 与商户私钥格式。
*/
public function testWechatPaymentConfigRejectsInvalidCryptoMaterial(): void
{
$this->expectException(WechatException::class);
$this->expectExceptionMessage('merchantPrivateKey');
new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'invalid-private-key');
}
/** /**
* 测试支付宝支付配置 fromArray 返回子类实例。 * 测试支付宝支付配置 fromArray 返回子类实例。
*/ */
@ -71,7 +107,7 @@ final class ConfigInterfaceTest extends TestCase
{ {
$config = AlipayPaymentConfig::fromArray([ $config = AlipayPaymentConfig::fromArray([
'appid' => 'ali_pay', 'appid' => 'ali_pay',
'private_key' => 'private-key', 'private_key' => TestKeys::privateKey(),
]); ]);
$this->assertInstanceOf(AlipayPaymentConfig::class, $config); $this->assertInstanceOf(AlipayPaymentConfig::class, $config);

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信消息安全模式加解密测试。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Tests; namespace We\Tests;
@ -17,6 +19,7 @@ use We\Support\Signature;
/** /**
* 微信消息安全模式加解密测试用例。 * 微信消息安全模式加解密测试用例。
* @internal
*/ */
#[CoversClass(MessageCrypto::class)] #[CoversClass(MessageCrypto::class)]
final class MessageCryptoTest extends TestCase final class MessageCryptoTest extends TestCase

View File

@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** /**
* 微信支付 APIv3 通知 resource 解密测试。 * This file is part of HyperfAdmin.
*
* @Link https://thinkadmin.top
* @Author Anyon<zoujingli@qq.com>
*/ */
namespace We\Tests; namespace We\Tests;
@ -14,6 +16,7 @@ use We\Support\PaymentCrypto;
/** /**
* 微信支付 APIv3 通知 resource 解密测试用例。 * 微信支付 APIv3 通知 resource 解密测试用例。
* @internal
*/ */
#[CoversClass(PaymentCrypto::class)] #[CoversClass(PaymentCrypto::class)]
final class PaymentCryptoTest extends TestCase final class PaymentCryptoTest extends TestCase