【开源】全网首个支持国密算法的微信支付 C#/.NET SDK(附 SM2/SM3/SM4 跨语言联调的踩坑要点)
前言
自 2021 年 8 月公开发布后,本项目已开源一年有余。在此期间,受到了很多开发者的关注和使用,也接收了很多热心开源的开发者给予的帮助,无论是上报了缺陷或建议,还是发起了 PR,亦或是在社群内回答他人的提问,都是对本项目做出的贡献。在这里,要对这些朋友表示衷心的感谢,是你们的支持使得该项目至今保持着良好的发展。
就在上个月,微信支付为了进一步响应《中华人民共和国密码法 》的号召,提供了使用 SM2 算法的国密证书和使用 SM2/SM3/SM4 算法的 API v3 接口。官方原文链接:https://pay.weixin.qq.com/docs/merchant/development/shangmi/introduction.html
为了满足开发者的使用需要,本项目率先做出升级适配以支持国密算法。下面会简单介绍一下接入方式。
在本文的结尾部分,会详细给出在接入国密算法中可能遇到的各种问题。即便你并没有使用本库,甚至并非 C#/.NET 的开发者,如果在对接 SM2/SM3/SM4 算法过程中遇到了瓶颈,相信在阅读本文后也会有所收获。
国密算法接入指引
这里假设你已了解本库的基本用法,并已成功接入了微信支付基于 RSA 算法的 API v3 接口。如果你对此部分仍不了解,可以前往本文最下方给出的仓库链接中自行阅读相关文档,或进入社群中提出问题(群号请见仓库简介)。
在微信商户平台开通国密接入权限后,只需要在原有的项目代码基础上做出如下几点调整。
- 初始化 SDK 时指定签名算法
首先,构造得到 WechatTenpayClient
对象的方式与原有方式基本一致,只需将 MerchantCertificateSerialNumber
、MerchantCertificatePrivateKey
替换为相应的国密证书内容即可,并指定签名认证方式:
var options = new WechatTenpayClientOptions()
{
// 其他配置项略
SignScheme = Constants.SignSchemes.WECHATPAY2_SM2_WITH_SM3
};
var client = new WechatTenpayClient(options);
- 下载平台证书时指定证书类型
接着,在获取平台证书时,需指定证书的算法类型:
var request = new Models.QueryCertificatesRequest()
{
AlgorithmType = "SM2"
};
var response = await client.ExecuteQueryCertificatesAsync(request);
- 管理平台证书时指定证书类型
与此同时,在存入平台证书管理器 PlatformCertificateManager
时,同样需指定证书的算法类型:
// 原本的 RSA 证书
manager.AddEntry(new CertificateEntry("RSA", "RSA 证书序列号", "RSA 证书内容", "RSA 证书生效时间", "RSA 证书过期时间"));
// 现在的 SM2 证书
manager.AddEntry(new CertificateEntry("SM2", "SM2 证书序列号", "SM2 证书内容", "SM2 证书生效时间", "SM2 证书过期时间"));
这样,就已经完成了接入国密算法的全部流程。请求自动签名、响应验证签名、加解密敏感数据字段、解析回调通知事件模型等相关的扩展方法调用方式与原有方式完全一致。具体用法请阅读相关文档。
国密算法踩坑要点
由于很多开发者本身对于加密算法、尤其是非对称加密算法并不熟悉,很多时候都是在网上寻找别人的参考代码。可在项目中使用时往往会遇到这样的问题:
- 调试时发现跟某些网页版的在线加解密工具给出的结果并不一致,甚至不同的在线工具给出的结果也都不一致,这是怎么回事?
- 项目中存在多种编程语言联调的场景,C# 实现的加解密结果自己调用时好好的,怎么到了跟 Java/PHP/Go 其他语言语言的程序里反向加解密,却无法得到正确的结果了呢?
在 RSA 算法中,出现这种情况往往是因为彼此采用了不同的填充模式与分组/工作模式。很多开发者因为不熟悉这些模式之间的区别,在不同的项目或在线工具中使用了不同的模式(甚至很多在线工具中并没有提供多种模式的可选项,很难知晓它到底采用了何种模式),所以在对接时出现了各种各样问题。
好在 RSA 算法毕竟是一种国际标准算法,存在的历史相对较长,且各个编程语言都对此有良好的支持,同时网络上相关的公开资料也很丰富,所以即便有问题大家还是能相对容易地找出解决方案的。
但 SM2/SM3/SM4 等国密算法(其实严格来说它们也是国际标准,但除了国内以外很少有开发者使用它们)在网络上的公开资料太过稀少,导致很多开发者在遇到问题时就并不知道从何入手解决。
这里给出开发者们可能遇到的一些常见问题和排错思路。
1. 二进制数据与 Base64 / Hex 编码
首先应理解,无论何种加密算法或哈希(又译摘要、杂凑)算法,其本质都是数学运算。而在计算机中的各种文本、图片、音乐、视频等等各种格式的文件,其本质都是二进制数据。对一段文本做加密,实际上也就是对其相应的二进制数据做出一系列复杂的数学运算。
在 C# 中,表示二进制数据的数据结构即 byte[]
。但这种结构既不利于人类阅读,也不方便信息传输(比如当你需要打印时)。所以人们会想出种种方式,将一个 byte[]
转换为更方便的 ASCII 字符串。
第一种方式就是比较常见的 Base64 编码了,其得名因为结果中包含 52 个大小写英文字母 + 10 个阿拉伯数字 + +
和 /
两个字符,共 64 个字符。Base64 的具体编码规则这里不再展开,主流编程语言对此都有良好的支持,内置标准库中都会包含相关 API。
小扩展:有些人会问了,Base64 编码用到的字符不是还有一个
=
吗,怎么不叫 Base65 呢?这个问题其实很简单,就留待读者自行去发掘答案吧。
第二种方式相对于 Base64 比较少见、但其实应用也很广泛的,也就是 Hex 编码,也有译为“十六进制编码”的。C# 中 byte
的范围是 0~255,恰好可被十六进制的 0x00~0xff 所表示。
小扩展:Base64 编码更流行是因为用到的字符更多,所以其果的长度会更短,传输起来更快捷,简而言之就是熵值高。但对于信息量不大的数据,Hex 编码会更常见,比如常见的 MD5/SHA/SM3 等哈希算法,当用字符串表示结果时往往都会采用 Hex 编码。
在 RSA 算法中,Base64 是一种更为流行的编码方式,所以开发者们很少会产生困惑。而 SM2 中就比较混乱了,有些库中接收的参数、或返回的结果要求的是 Base64 编码,另一些却是 Hex 编码。
在微信支付中,官方提供的国密工具里给出的所有数据均是 Hex 编码的;而在实际的接口调用中,要求传入的数据均是 Base64 编码的。开发者在调试时,请注意数据(含公钥、私钥、明文、密文、签名等)的编码方式,从肉眼上看它们还是很容易区分的。如果你需要两种编码方式彼此互相转换,也有很多的在线工具可以使用,这里就不再赘述。
2. 字符集与字符编码
字符集(即 Charset)与字符编码(即 Character Encoding)其实是两个概念,前者指一个集合中包含哪些字符,后者指这些字符怎么用二进制表示。
很多开发者会将二者混淆,也许是因为 ASCII、Big5、GBK 等既是字符集的名字,又是字符编码的名字。但在 Unicode 中二者是不同的,其中 Unicode 是字符集的名字,而 UTF-8、UTF-16、UTF-32 等是字符编码的名字。
在微信支付中,所有文本数据均使用 UTF-8 字符编码,这种字符编码也是目前国际上最流行的字符编码。
但如果你在对接某些国内的政企系统,可能会要求你使用 GBK、GB2312 等其他形式的字符编码。
3. SM2 之证书、公钥、私钥的格式与内容
众所周知,SM2 与 RSA 一样,都是一种非对称加密算法,而非对称加密算法是有一个成对出现的公、私钥的,其中公钥用于加密或验签,私钥用于解密或签名。
小扩展:对于 RSA 算法来说,公、私钥的加解密关系也可以反过来,但这只是在数学上成立的情况,实际项目中请千万不要这么做。至于具体原因你可以自行搜索相关资料。
那么可能有些开发者会好奇,证书又是干什么呢?
其实证书在整个非对称加解密的运算过程中,就是充当公钥的作用。因为公钥信息是被嵌入到证书内容里的,在加密或验签过程中实际是从证书中提取公钥,然后再用公钥去进行相应的运算的。
当然了,证书本身除了包含公钥信息,还会携带其他数据,从而还会起到身份标识、信任链等等其他作用。但这都跟非对称加解密的运算本身无关,这里就不再展开。
此外,很多非对称加密算法在数学原理上,是可以从私钥推导出公钥的。这也是为何微信支付官方提供的国密工具里,可以填入一个私钥就能自动生成出对应公钥的原因。
证书、公钥、私钥,都有很多种存储方式,常见的有以下几种:
类型 | 格式 | 说明 |
---|---|---|
.DER / .CER | 二进制 | 只能用于存储证书 |
.PFX / .P12 | 二进制 | 可同时存储证书(包含公钥)和私钥 |
.JKS | 二进制 | 可同时存储证书(包含公钥)和私钥 |
.CER / .CRT | 文本 | 只能用于存储证书 |
.PEM | 文本 | 可存储证书(包含公钥)、公钥和私钥,但需分开保存 |
其中 .PEM 这种格式,还分为不同编码方式,最为常见的有 PKCS#1 和 PKCS#8 两种。
对于 RSA 算法而言,PKCS#1 编码的公、私钥形如:
-----BEGIN RSA PUBLIC KEY-----
BASE64 ENCODED DATA
-----END RSA PUBLIC KEY-----
-----BEGIN RSA PRIVATE KEY-----
BASE64 ENCODED DATA
-----END RSA PRIVATE KEY-----
对于 SM2 算法而言,PKCS#1 编码的公、私钥形如:
-----BEGIN EC PUBLIC KEY-----
BASE64 ENCODED DATA
-----END EC PUBLIC KEY-----
-----BEGIN EC PRIVATE KEY-----
BASE64 ENCODED DATA
-----END EC PRIVATE KEY-----
无论 RSA 算法还是 SM2 算法,PKCS#8 编码的公、私钥都形如:
-----BEGIN PUBLIC KEY-----
BASE64 ENCODED DATA
-----END PUBLIC KEY-----
-----BEGIN PRIVATE KEY-----
BASE64 ENCODED DATA
-----END PRIVATE KEY-----
在微信支付中,官方提供的证书工具所生成的证书、公钥、私钥文件均为 PKCS#8 编码的 PEM 格式。
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的证书、公钥、私钥文件格式及编码,如果并非 PKCS#8 PEM,需要先自行做转换。
4. SM2 之椭圆曲线算法与公、私钥
上一小节提到,微信支付给出的公、私钥文件均为 PKCS#8 编码的 PEM 格式,也即形如:
-----BEGIN PUBLIC KEY-----
BASE64 ENCODED DATA
-----END PUBLIC KEY-----
-----BEGIN PRIVATE KEY-----
BASE64 ENCODED DATA
-----END PRIVATE KEY-----
但如果你使用过官方提供的国密工具、或者很多在线的 SM2 加解密工具,你会发现它们要求填入的公、私钥,都是一串十六进制的字符串,形如:
# 公钥
0453B97D723AA4CEAC97A13B8C50AA53D40DE36960CFC3A3D7929FD54F39F824ED5A4A27AF871AD62C25C75C9D75C75A0907C565A78B805E9502E616C4E77F3B42
# 私钥
B17EACC0BB629AB92C591287F2FA4589D10CD1E13BD4BDFDC9589A940F937C7C
看起来并非是上述 PEM 格式 Base64 解码后再 Hex 编码的结果,因为字节长度要短很多,这又是怎么一回事儿?
这是因为 SM2 算法与 RSA 算法不同的是,它是基于 ECC 算法(即椭圆曲线加密算法)实现的。本文不会去探讨椭圆曲线的数学原理——感兴趣的朋友可以自行搜索相关资料——仅仅指出上面这种十六进制格式的、相对较短的公、私钥,实际是 ECC 的公私钥,而它们是可以通过上面给出的 SM2 公、私钥推导出的。
用一个不准确、但容易理解的方式来说的话就是:
- 提取 SM2 公钥中的参数
Q
,作为 ECC 公钥,长度为 64 字节,并在开头固定追加一个字节0x04
,共 65 字节。 - 提取 SM2 私钥中的参数
d
,作为 ECC 私钥,长度为 32 字节。
小扩展:ECC 公钥还存在一种更短的、只有 33 字节的压缩形式,由于在 SM2 算法中很少用到,这里不再展开。
微信支付官方文档中给出了一种通过 OpenSSL 从 SM2 公、私钥导出 ECC 公、私钥的方式。本库的 SM2Utility
工具类中也包含了相关的导出方法。
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的公、私钥究竟需要传入的是哪种。
5. SM2 之 C1|C2|C3
、C1|C3|C2
与 ASN.1 编码
如果你在项目中使用 BouncyCastle 库过程中,在解密时得到了 Invalid ciphertext
这样的异常,那么很可能是因为密文结果的拼接方式存在问题。
因为 SM2 算法的加密结果,是用 C1
、C2
、C3
三个值拼接而成的,本文不会去探讨它们的实际含义——感兴趣的朋友可以自行搜索相关资料——仅仅指出按照现行的国家标准《GM/T 0009-2012》、同时也是国际标准《ISO/IEC 14888-3:2018》来看,拼接方式应为且只能为 C1|C3|C2
。
但在 SM2 的相关标准尚未正式定稿之前,已有某些库会按照 C1|C2|C3
的拼接方式做了实现并已投入到生产环境中使用,这在某些国内银行的接口对接中比较常见。
同样地,如果在使用 BouncyCastle 库过程中,在解密时得到了 Invalid point encoding 0x30
这样的异常,那么很可能是因为密文或签名结果是 ASN.1 编码结果。
本文不会去探讨 ASN.1 的具体编码要求,仅仅指出 ASN.1 编码后的数据会比未编码后的数据更长:
- 对于密文
C1|C3|C2
来说,未编码前的密文长度比明文多 97 字节(下面会讲到有时是多 96 字节是怎么回事)、且开头固定为0x04
;编码后的密文长度会比前者更长、且开头固定为0x30
。 - 对于签名
R|S
来说,未编码前的签名长度固定为 128 字节;编码后的签名长度固定为 142 字节。
所以,你可以根据结果的字节长度来判断,该结果是否采用了 ASN.1 编码。
在微信支付中,官方提供的国密工具同时给出了两种拼接方式和是否使用 ASN.1 编码的可选配置项;而在实际的接口调用中,要求传入的密文拼接方式为 C1|C3|C2
,且密文与签名值均需转换为 ASN.1 编码。本库的 SM2Utility
工具类中也包含了相关的转换方法。
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的加密结果拼接方式、签名结果格式是哪种。如果不一致,还需要做一次转换。
6. SM2 之前缀标志位 0x04
如果你在项目中使用 BouncyCastle 库过程中,在解密时得到了 Invalid point encoding 0x5c
这样的异常,那么很可能是因为密文开头前未追加前缀标识标志 0x04
。
这也是在前文第 5 小节提到 ASN.1 编码时所讲的,为何密文结果有时比明文多 96 字节、有时多 97 字节,这多出来的 1 个字节实际就是这个标志位。该标志位为 SM2 国标中所规定的固定值。
与之类似的是,在前文第 4 小节提到的 ECC 公钥中,其开头也是固定的 0x04
,该标志位表示未压缩公钥。与之对应的还有压缩公钥,开头固定为 0x02
或 0x03
(取决于构成 ECC 公钥两个坐标点的奇偶性)。
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的公钥或密文,是否开头需要额外追加或删除 0x04
。
7. SM2 之前缀标志位 0x00
此问题一般出现在 Java 项目中,因为 Java 中的 byte
这种数据结构是有符号的,其表示范围并非像其他大多数语言中那样的 0~255、而是 -128~127。所以在构造 ECC 私钥所需的 BigInteger
时,需在开头额外指定符号标志位 0x00
。
换而言之,Java 中使用的 ECC 私钥长度为 33 字节,而非其他大多数语言中的 32 字节。
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的私钥,是否开头需要额外追加或删除 0x00
。
8. SM4 之填充模式与分组模式
在文章开头部分曾提到,RSA 算法中存在很多填充模式与分组/工作模式。
这里先引入块加密(又译分组加密,即 Block Cipher)算法的概念。RSA 就是一种非对称的块加密算法。而在对称加密中,比较有名的块加密算法有 AES、DES 等,也包括我们的国密算法 SM4。
所谓块加密算法,即把明文数据分成若干的等长的块,然后对每个块分别进行算法迭代。但在给定块长度的情况下,明文数据不可能总是恰好被均分成等长的块,势必有某个较短的块需要填充数据变成一个完整的块。那么填充数据的方式,就被称之为“填充模式”(即 Padding Mode);而分组迭代的方式,则被称之为“分组/工作模式”(即 Chunking Mode / Mode)。
常见的填充模式有:
- PKCS1_PADDING
- PKCS5_PADDING
- PKCS7_PADDING
- OAEP_PADDING
- ZERO_PADDING
- NO_PADDING
常见的分组/工作模式有:
- ECB
- CBC
- CFB
- OFB
- GCM
本文不会去探讨不同的填充模式与分组/工作模式的区别及优缺点——感兴趣的朋友可以自行搜索相关资料——仅仅指出,在特定的填充模式 + 分组/工作模式下加密的密文,反过来只能以同样的模式来解密。如果加解密时模式混用,要么只能得到一团无意义的乱码数据,要么直接抛出运行时异常。
在微信支付中,实际的接口调用中所使用的模式信息见下表:
场景 | RSA 算法及模式 | SM 算法及模式 |
---|---|---|
平台证书解密、回调通知加解密 | AES/GCM/NO_PADDING | SM4/GCM/NO_PADDING |
敏感信息加解密(境内支付相关接口) | RSA/ECB/PKCS1_OAEP_PADDING | SM2 |
敏感信息加解密(境外支付相关接口) | RSA/ECB/PKCS1_PADDING | - |
因此,当你在使用某些编程语言的加密库,或是某些在线加解密工具,请先注意其所支持的填充模式与分组/工作模式跟微信支付所要求的是否一致。
项目仓库地址
- GitHub:https://github.com/fudiwei/DotNetCore.SKIT.FlurlHttpClient.Wechat.git
- Gitee:https://gitee.com/fudiwei/DotNetCore.SKIT.FlurlHttpClient.Wechat.git
以上仓库地址同步更新,均可接受 Issue 或 Pull Request。