From e8cd998852b3779e22711a8ceb65eb6a73d7f209 Mon Sep 17 00:00:00 2001 From: Anyon Date: Fri, 8 May 2026 11:32:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E5=BC=BA=E5=8C=96=20token=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E8=AF=81=E4=B9=A6=E6=A0=BC=E5=BC=8F=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CredentialValidator,集中校验微信 Token、EncodingAESKey、APIv3 Key、RSA 密钥和证书。 - 配置对象构造时提前验证微信、微信支付、支付宝关键凭证格式。 - 消息加解密和支付通知解密复用统一校验,减少运行期隐性错误。 - 补充无效 Token、密钥、证书和解密场景测试,提升凭证错误可诊断性。 --- src/Config/AlipayPaymentConfig.php | 10 +-- src/Config/AlipayPlatformConfig.php | 17 ++++- src/Config/WechatPaymentConfig.php | 17 ++++- src/Config/WechatPlatformConfig.php | 18 ++++- src/Config/WechatServiceConfig.php | 11 ++- src/Config/WechatWxappConfig.php | 8 ++- src/Support/CredentialValidator.php | 107 ++++++++++++++++++++++++++++ src/Support/MessageCrypto.php | 13 ++-- src/Support/PaymentCrypto.php | 30 +++++--- src/Support/Signature.php | 12 +++- tests/ConfigInterfaceTest.php | 50 +++++++++++-- tests/MessageCryptoTest.php | 7 +- tests/PaymentCryptoTest.php | 7 +- 13 files changed, 258 insertions(+), 49 deletions(-) create mode 100644 src/Support/CredentialValidator.php diff --git a/src/Config/AlipayPaymentConfig.php b/src/Config/AlipayPaymentConfig.php index c70d01a..030e6cf 100644 --- a/src/Config/AlipayPaymentConfig.php +++ b/src/Config/AlipayPaymentConfig.php @@ -1,9 +1,11 @@ */ namespace We\Config; @@ -11,6 +13,4 @@ namespace We\Config; /** * 支付宝支付配置,继承支付宝开放平台网关签名与验签参数。 */ -final class AlipayPaymentConfig extends AlipayPlatformConfig -{ -} +final class AlipayPaymentConfig extends AlipayPlatformConfig {} diff --git a/src/Config/AlipayPlatformConfig.php b/src/Config/AlipayPlatformConfig.php index 3eb5141..e594da6 100644 --- a/src/Config/AlipayPlatformConfig.php +++ b/src/Config/AlipayPlatformConfig.php @@ -1,15 +1,18 @@ */ namespace We\Config; use We\Contract\ConfigInterface; use We\Exception\WechatException; +use We\Support\CredentialValidator; /** * 支付宝开放平台基础配置。 @@ -44,6 +47,16 @@ class AlipayPlatformConfig implements ConfigInterface if (trim($this->appid) === '' || trim($this->privateKey) === '') { 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); + } } /** diff --git a/src/Config/WechatPaymentConfig.php b/src/Config/WechatPaymentConfig.php index e4411dc..98d1e61 100644 --- a/src/Config/WechatPaymentConfig.php +++ b/src/Config/WechatPaymentConfig.php @@ -1,15 +1,18 @@ */ namespace We\Config; use We\Contract\ConfigInterface; use We\Exception\WechatException; +use We\Support\CredentialValidator; /** * 微信支付 APIv3 商户配置。 @@ -53,6 +56,14 @@ final class WechatPaymentConfig implements ConfigInterface 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 { - return new static( + return new self( (string)($data['appid'] ?? ''), (string)($data['mch_id'] ?? $data['mchid'] ?? ''), (string)($data['api_v3_key'] ?? $data['mch_v3_key'] ?? ''), diff --git a/src/Config/WechatPlatformConfig.php b/src/Config/WechatPlatformConfig.php index 2a5363c..c7ef111 100644 --- a/src/Config/WechatPlatformConfig.php +++ b/src/Config/WechatPlatformConfig.php @@ -1,15 +1,18 @@ */ namespace We\Config; use We\Contract\ConfigInterface; use We\Exception\WechatException; +use We\Support\CredentialValidator; /** * 微信公众平台基础配置。 @@ -42,7 +45,7 @@ final class WechatPlatformConfig implements ConfigInterface */ public static function fromArray(array $data): static { - return new static( + return new self( (string)($data['appid'] ?? ''), (string)($data['appsecret'] ?? $data['app_secret'] ?? ''), (string)($data['token'] ?? ''), @@ -64,5 +67,14 @@ final class WechatPlatformConfig implements ConfigInterface 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'); + } } } diff --git a/src/Config/WechatServiceConfig.php b/src/Config/WechatServiceConfig.php index 8e50732..e26f495 100644 --- a/src/Config/WechatServiceConfig.php +++ b/src/Config/WechatServiceConfig.php @@ -1,15 +1,18 @@ */ namespace We\Config; use We\Contract\ConfigInterface; use We\Exception\WechatException; +use We\Support\CredentialValidator; /** * 微信服务平台(第三方平台)配置。 @@ -47,6 +50,8 @@ final class WechatServiceConfig implements ConfigInterface 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 { - return new static( + return new self( (string)($data['component_appid'] ?? ''), (string)($data['component_appsecret'] ?? $data['component_app_secret'] ?? ''), (string)($data['component_token'] ?? ''), diff --git a/src/Config/WechatWxappConfig.php b/src/Config/WechatWxappConfig.php index 72d29a5..2c3829c 100644 --- a/src/Config/WechatWxappConfig.php +++ b/src/Config/WechatWxappConfig.php @@ -1,9 +1,11 @@ */ namespace We\Config; @@ -37,7 +39,7 @@ final class WechatWxappConfig implements ConfigInterface */ public static function fromArray(array $data): static { - return new static( + return new self( (string)($data['appid'] ?? ''), (string)($data['appsecret'] ?? $data['app_secret'] ?? ''), (string)($data['storage_scope'] ?? $data['storageScope'] ?? ''), diff --git a/src/Support/CredentialValidator.php b/src/Support/CredentialValidator.php new file mode 100644 index 0000000..d100ead --- /dev/null +++ b/src/Support/CredentialValidator.php @@ -0,0 +1,107 @@ + + */ + +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-----'; + } +} diff --git a/src/Support/MessageCrypto.php b/src/Support/MessageCrypto.php index ebb90b5..69f422e 100644 --- a/src/Support/MessageCrypto.php +++ b/src/Support/MessageCrypto.php @@ -1,9 +1,11 @@ */ namespace We\Support; @@ -28,11 +30,10 @@ final class MessageCrypto string $encodingAesKey, private string $appid, ) { - if (strlen($encodingAesKey) !== 43) { - throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); - } + CredentialValidator::assertWechatToken($this->token); + CredentialValidator::assertEncodingAesKey($encodingAesKey); $key = base64_decode($encodingAesKey . '=', true); - if ($key === false || strlen($key) !== 32) { + if (!is_string($key)) { throw new WechatException('EncodingAESKey 必须是 43 位有效字符串'); } $this->aesKey = $key; diff --git a/src/Support/PaymentCrypto.php b/src/Support/PaymentCrypto.php index e867d31..cba11cb 100644 --- a/src/Support/PaymentCrypto.php +++ b/src/Support/PaymentCrypto.php @@ -1,9 +1,11 @@ */ namespace We\Support; @@ -20,17 +22,25 @@ final class PaymentCrypto /** * 解密微信支付 APIv3 通知中的 resource 字段。 * - * @param array{ciphertext:string,nonce:string,associated_data?:string} $resource + * @param array $resource * @return array */ public static function decryptResource(string $apiV3Key, array $resource): array { - foreach (['ciphertext', 'nonce'] as $field) { - if (!isset($resource[$field]) || !is_string($resource[$field]) || $resource[$field] === '') { - throw new WechatException('微信支付回调资源字段缺失: ' . $field); - } + CredentialValidator::assertApiV3Key($apiV3Key); + $ciphertextValue = $resource['ciphertext'] ?? null; + $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) { throw new WechatException('微信支付回调密文无效'); } @@ -42,9 +52,9 @@ final class PaymentCrypto 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, - $resource['nonce'], + $nonce, $tag, - (string)($resource['associated_data'] ?? '') + $associatedData ); if (!is_string($plain) || $plain === '') { throw new WechatException('微信支付回调解密失败'); diff --git a/src/Support/Signature.php b/src/Support/Signature.php index 4557884..fe5a67a 100644 --- a/src/Support/Signature.php +++ b/src/Support/Signature.php @@ -1,9 +1,11 @@ */ namespace We\Support; @@ -67,7 +69,11 @@ final class Signature if ($key === false) { 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; } } diff --git a/tests/ConfigInterfaceTest.php b/tests/ConfigInterfaceTest.php index d25d4a7..4697bed 100644 --- a/tests/ConfigInterfaceTest.php +++ b/tests/ConfigInterfaceTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -21,6 +23,7 @@ use We\Exception\WechatException; /** * 平台配置契约与配置对象测试用例。 + * @internal */ #[CoversClass(ConfigInterface::class)] final class ConfigInterfaceTest extends TestCase @@ -33,10 +36,10 @@ final class ConfigInterfaceTest extends TestCase foreach ([ new WechatPlatformConfig('wx_app', 'secret'), new WechatWxappConfig('wx_wxapp', 'secret'), - new WechatServiceConfig('wx_component', 'secret', 'token', 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'), - new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', 'private-key'), - new AlipayPlatformConfig('ali_app', 'private-key'), - new AlipayPaymentConfig('ali_pay', 'private-key'), + new WechatServiceConfig('wx_component', 'secret', 'token', TestKeys::encodingAesKey()), + new WechatPaymentConfig('wx_app', 'mch', str_repeat('k', 32), 'serial', TestKeys::privateKey()), + new AlipayPlatformConfig('ali_app', TestKeys::privateKey()), + new AlipayPaymentConfig('ali_pay', TestKeys::privateKey()), ] as $config) { $this->assertInstanceOf(ConfigInterface::class, $config); } @@ -64,6 +67,39 @@ final class ConfigInterfaceTest extends TestCase 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 返回子类实例。 */ @@ -71,7 +107,7 @@ final class ConfigInterfaceTest extends TestCase { $config = AlipayPaymentConfig::fromArray([ 'appid' => 'ali_pay', - 'private_key' => 'private-key', + 'private_key' => TestKeys::privateKey(), ]); $this->assertInstanceOf(AlipayPaymentConfig::class, $config); diff --git a/tests/MessageCryptoTest.php b/tests/MessageCryptoTest.php index aec0a9a..1b2ff23 100644 --- a/tests/MessageCryptoTest.php +++ b/tests/MessageCryptoTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -17,6 +19,7 @@ use We\Support\Signature; /** * 微信消息安全模式加解密测试用例。 + * @internal */ #[CoversClass(MessageCrypto::class)] final class MessageCryptoTest extends TestCase diff --git a/tests/PaymentCryptoTest.php b/tests/PaymentCryptoTest.php index 3f6e810..92acdac 100644 --- a/tests/PaymentCryptoTest.php +++ b/tests/PaymentCryptoTest.php @@ -1,9 +1,11 @@ */ namespace We\Tests; @@ -14,6 +16,7 @@ use We\Support\PaymentCrypto; /** * 微信支付 APIv3 通知 resource 解密测试用例。 + * @internal */ #[CoversClass(PaymentCrypto::class)] final class PaymentCryptoTest extends TestCase