Ruby 微信支付 V3 加密/解密详解-Ruby 示例补充

lanzhiheng · 2021年05月05日 · 最后由 LPFpengfei 回复于 2023年04月23日 · 2864 次阅读
本帖已被管理员设置为精华贴

这篇文章是笔者最近对接微信支付最新接口的经验总结,主要用 Ruby 实现了它加密/解密过程,希望能帮助到有这方面需求的同学。被微信的文档折腾了好几天,甚至连做梦都在加密/解密,克服完重重困难之后最终写下这篇文章。原文链接:https://www.lanzhiheng.com/posts/wechat-v3-api-in-ruby

前言

微信支付的相关 API 升级到 V3 版本,接口的规则跟老版本有很大的区别,其中最折腾人的还是接口的加密/解密方式。因为微信没有提供 Ruby 相关的代码示例,所以这篇文章也就当作是微信文档的补充,以及分享笔者遇到的一些坑。

微信 V3 接口数据传输的模式用回了我们熟悉的 JSON 格式,这点还是挺让人欣慰的。还有就是签名方式的转变,之前姜神开发的wx_pay没法用于新的接口了,只能自己慢慢去对各种接口进行封装。

当然老的接口还是能用的,如果你不想折腾依旧还是可以用老接口,套上姜神的wx_pay也是蛮舒服的。然而由于笔者要对接的是微信收付通的平台服务,所以怎样都要从头撸起了。

开发准备

简单汇总一下概念,要完成微信支付相关的业务逻辑,其实我们总共需要两个证书,一个私钥。具体文档可以看这里

证书主要包括

  1. 商户证书:文件名为apiclient_cert.pem,在商户平台上可下载,压缩包里面还会包含所需要的商户私钥apiclient_key.pem(注意别分享给别人)。
  2. 平台证书:这个需要接口调通之后通过接口/v3/certificates进行下载,需要折腾一番就是,因为接口考虑到安全性不会给你直接返回证书,而是返回经过AEAD_AES_256_GCM处理的结果,需要进行解密,我稍后回附上解密的代码。

当然你也可以用官方提供的下载工具来下载平台证书,这样就不用自己解密了。不过笔者强烈建议不要这样做,因为在支付通知 API中,微信在请求体中传过来的东西依旧是经过AEAD_AES_256_GCM处理的,所以你无论如何都得自己去实现解密的代码,绕不过去就是了。我们就按

  1. 签名生成
  2. 签名验证
  3. 证书回调报文解密
  4. 敏感信息加密

的顺序来一一实现 Ruby 版本的加密/解密代码。为了方便验证我先随机生成相关的私钥/证书,教程的链接如下我就不详细说了,只贴结果

1. 商户私钥

> cat random_apiclient_key.pem

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAvvF6T4fL4D760qInE3eX9Xxm2i+J0o9M/beXSZkTFsAqFbqf
NhEyVJeAVflfzvuVHIo24uJYFq5j8Y5NvI3RimhwTvjxl803D8mykzTPwPI0M5WQ
QM278f5vXOycOzXD+/X7kJ4NEujodoUuXWEUKu9ZCksLCHHtB3fmPiaspe1EU0kW
PvHOS6fQPTQ55OswFmFsvfsRODBAdCIlQAwqTgZaFQ6hFf3AqPFLbNb1O1v3W2b+
QjzpA+o99Uujl87H5i3+7rOYpNeoM8FqBp4tGAAGY2JwEVD2YlZrv4pNwuKZLOK/
GsIPyKZCeZ3P8FDX/C9ANQ3rgqcjKH/gj7jmdQIDAQABAoIBAQCQzgbI6540yO5k
8O4beFX4qMhDbUvjMCPeQe3stbbhSQhhhC8bzLzTpDWCfeUnzmmNxE/NjoPpZ4WJ
+jZ/6Tlg8sVBTs/BJLM+OONBegqYM9ZczG8ihiOjaSbBXPs6eBLSMQD/8qzNi25H
+8ZmsKmfyfZHtRN/6w4r3MTym1fRWKgkXyHjhgOwS+Hy0PCaRO6Z448mMOlRPxDW
h5T0ttkCEbbHIDmMTGtQNZaqd1/5LmVrMsTouyj6COYQaW6A69xkiZhuE7zKsH4F
BwMRBJmtRF4QK+P3lY2dlITRGhfaxXd5DzElnnvd6UkOf0r8VuIAuDLrWPkn017h
4H0XJG7hAoGBAOWfQw790LZAKxrXPjRMpKmjZqlnVEgE+wSeMtI/156ecQcgI3DX
3RGsbjcGt08H+BWSUIk35kUKfnbZKNK6dn1FAO7guqDLnse+OYXg2kuAeuDgxUdm
Fbg+pQBUu8QqdeLLy3VkzXrdc5fKymhZswtqpx5eAautHBzwSVibTlVJAoGBANTg
vx2sJzOhhu0wK/luxdFP0xIdusyhMOgYlYXL5l5C6cG8X4lUCMFCGD9fLMwFLN5K
ShqNvXzzaSE3aC1QhnYxafs966TKJkNnQKz8yC22CySXMQxHxZuRsrHoa3uOFdkZ
HYo5vEP3egVdKHUUh0cs0S11GcsaQdfccHO2yMPNAoGANc2HbO/UA6AteXCNxrte
qdD7sR3hBa8FEiPvTIxg/W2qljzVkQ9DYWzBtmsAcKgxXPyXmk9ayTqYP0jK4/WE
5f1RJqfJkvujDLJp0BDLlX1ZTW/dScmFtVIYX2d7R4+bZ7TQy4T/EJbrCtoday35
YedvmRH12kAJok47IWPiiuECgYAfDl6zXH8nmCQQDFwN+qwfWi7n0LCE0+tHoPaH
W3TTQZ3KpsmlRj40u4jADgmCBitCjsH617zSMsyejO/E1J+ZNKJKhgEPvHISmUil
NAecK5e6kdgU+4+Hn5zbOZYco2DqmDBoDv45SCxkBfA2DHWj25T0tcW6jK0Yac96
AiuN7QKBgQC30pAAXBOZwfGIaI3COqhwS7xWVsVWAMXXNBQLqjrmLXP77rg74RXu
hcXDPzKlrTVD41MNjar0jpTn1H43SVB8Uz77vgctS6wqt8dLs5XcywwI+mH9H7LL
lLpyL95dbDwYMWZ7HVOXuClF8LgHHbBH4uQMRJzyDW24IgPnZsZTWw==
-----END RSA PRIVATE KEY-----

2. 商户证书

> cat random_apiclient_cert.pem

-----BEGIN CERTIFICATE-----
MIIDZjCCAk6gAwIBAgIFAlSoAcAwDQYJKoZIhvcNAQELBQAwQjETMBEGCgmSJomT
8ixkARkWA29yZzEZMBcGCgmSJomT8ixkARkWCXJ1YnktbGFuZzEQMA4GA1UEAwwH
UnVieSBDQTAeFw0yMTA0MjcxMTUxNTZaFw0yMzA0MjcxMTUxNTZaMEIxEzARBgoJ
kiaJk/IsZAEZFgNvcmcxGTAXBgoJkiaJk/IsZAEZFglydWJ5LWxhbmcxEDAOBgNV
BAMMB1J1YnkgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+8XpP
h8vgPvrSoicTd5f1fGbaL4nSj0z9t5dJmRMWwCoVup82ETJUl4BV+V/O+5Ucijbi
4lgWrmPxjk28jdGKaHBO+PGXzTcPybKTNM/A8jQzlZBAzbvx/m9c7Jw7NcP79fuQ
ng0S6Oh2hS5dYRQq71kKSwsIce0Hd+Y+Jqyl7URTSRY+8c5Lp9A9NDnk6zAWYWy9
+xE4MEB0IiVADCpOBloVDqEV/cCo8Uts1vU7W/dbZv5CPOkD6j31S6OXzsfmLf7u
s5ik16gzwWoGni0YAAZjYnARUPZiVmu/ik3C4pks4r8awg/IpkJ5nc/wUNf8L0A1
DeuCpyMof+CPuOZ1AgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/
BAQDAgEGMB0GA1UdDgQWBBS3rhd3GsQVpudgfupC8od1YBQTIjAfBgNVHSMEGDAW
gBS3rhd3GsQVpudgfupC8od1YBQTIjANBgkqhkiG9w0BAQsFAAOCAQEAh6alNLX6
FoHf5M6+6DUwfC3DremY7ROuJbaiFjiRaqQaKbe9VP/piZAQ1PO4WuGyYAJfXHYd
lkw7431z0isEJjgoIf/qVR7ffrBJmZ8k5S+CEregO3j3/2QoiMFkAm8NG2rlyywq
ElI8lRkiJQprIWYo3CJm+egdx9fjyfs/2y6Aj7bHGJG9ri0NhNwzzJ2eRiuTFTvD
Yc4YRsmY3D0aZ9r5VnOdlsNnVoRL/3G9f5P7tA2Yli8uw4flbkiE4GLhjf77R+yR
EKRuhu217NRZS8iJrA/drzX10/lLLjitxEG7Clhz5E2L5tpY1YxoBJ9VMf5Xp3hQ
JQ3ErslGEsD5Qw==
-----END CERTIFICATE-----

3. 平台证书

> cat random_platform_cert.pem

-----BEGIN CERTIFICATE-----
MIIDZTCCAk2gAwIBAgIEBf+LbjANBgkqhkiG9w0BAQsFADBCMRMwEQYKCZImiZPy
LGQBGRYDb3JnMRkwFwYKCZImiZPyLGQBGRYJcnVieS1sYW5nMRAwDgYDVQQDDAdS
dWJ5IENBMB4XDTIxMDQyNzExNTUyNloXDTIzMDQyNzExNTUyNlowQjETMBEGCgmS
JomT8ixkARkWA29yZzEZMBcGCgmSJomT8ixkARkWCXJ1YnktbGFuZzEQMA4GA1UE
AwwHUnVieSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMe2SjFV
Z67F9L+ML0Shu48OqGqEbCfALjW2wNjLLKK+oVu3U/NZ+XKujgIMyis4CsF/lOEd
e2evpemyxxJ345OOK6es4XWL87Cg6vSqwtrd0Cf+qRB8lCz1ptPFLzGa3QW0OupL
T/KXO/r0Zji/eurvWTpwhIqsZYZomExH1UVh9pq9Y+YJrEP4cE1HITmKTHTGc+VZ
5tjqqhJqREklrWlq21kAgaM4pwIZpIJ7RCWRw4WqHhQNhJRJRonzAgo5v+9dF2Bc
UokbPsYuFOj1d4XxnGnEX65DfT29gbPLFSam3Xyrei+444A6YYXaNX2JkCuDFCES
R3xERDzuOP24SgcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E
BAMCAQYwHQYDVR0OBBYEFHH9P2qC8LFE7Rx5vxn9Of89ESRxMB8GA1UdIwQYMBaA
FHH9P2qC8LFE7Rx5vxn9Of89ESRxMA0GCSqGSIb3DQEBCwUAA4IBAQAzSAef025k
Oo1pBIFz94gA//tiKkFdOHPLiph3TAO+Dop9x2GnKDLo75PxTck2HT2VVRl42w87
+5xiyGV+hNQJaPHmWyWa+P9Y3BWMHNXLY6ScaxcsX5N8fwVsYyRgwcMSeOnjtd4q
pRhaz5rEBnDYMr1uVbxkIwLXCcgN/vRr8AWD+qYhMu1866vEA36urzvegU7KVPvv
pDxuJcnNg+44qf+hHxDPXFkmI+tiT8pWD0SHEY9mT/tHSt4ujdZku2xsK5OU2vFF
giAyUOjrW+mvkPxzrPzQGaCb8AIAFiVMR4pEK2O/CfB3vkSrWsQmKRYuYjbdxoDv
6wncYGzLrl3A
-----END CERTIFICATE-----

4. 平台密钥(可能有用)

> cat random_platform_key.pem

-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAx7ZKMVVnrsX0v4wvRKG7jw6oaoRsJ8AuNbbA2Mssor6hW7dT
81n5cq6OAgzKKzgKwX+U4R17Z6+l6bLHEnfjk44rp6zhdYvzsKDq9KrC2t3QJ/6p
EHyULPWm08UvMZrdBbQ66ktP8pc7+vRmOL966u9ZOnCEiqxlhmiYTEfVRWH2mr1j
5gmsQ/hwTUchOYpMdMZz5Vnm2OqqEmpESSWtaWrbWQCBozinAhmkgntEJZHDhaoe
FA2ElElGifMCCjm/710XYFxSiRs+xi4U6PV3hfGcacRfrkN9Pb2Bs8sVJqbdfKt6
L7jjgDphhdo1fYmQK4MUIRJHfEREPO44/bhKBwIDAQABAoIBADcjXwyL1dptEQup
eotqU8xFcb4m3W2EI730vP2d6q7sDsSxst3nI3XEN7TdLxwLlvyhastURnP0DMye
7VNuAkkE4YyjsIOxphBH/VabpryirQu9xZOlsYtQL0UcldEOPqOKhRGWxXXmx0qc
G3TjeN5QQsRduFpJCqa3TgUReBJ1YcgsF24iv+6S21QwacaiELO9LHEnRboITn8E
H4S78FdjWUG7r0/39HIWL/dodfsLHM5RvfEdnrWJ/A+hKgIIDIbAUlYGK6ijceYy
FeB0LSV/5goljMj0rB+QitGH8GJmhJwl15/nQ4SLqCZLIodxd3ySJtn1AzARdQPP
nrFuxAECgYEA/0VcgZJFzgUvcghqnxeJNucKYc9Wm5SdVVrC8ts/LM1diDaKgnV/
ziaDwtg8UIHetjvgMnISb4e83jUXdTFVeEf5l9AXV5G6ST5378DoXHsbO3kW3ED8
UrbgFkeyMkD6eikdDPqpND6vKEczR7UqW/ajHt4NHsHyqkyaqDrK3wcCgYEAyEhO
o2ulG9QRNS2TrFYvgAlr0ZfezqE6Vh0PYrwXfzuJVwY1IEMnK4MCB9fMKSfNFkQt
olxK0yEx+h8TnGv6HfYnlC/bA4ddahbWF0/OqRiho+OyBxWJpeWe/eKCEPWXdmOQ
3QLSdXLZK8303+F/2Frh4/FUX6LohTxoPkMBfQECgYEAu64LbVhV6jr1vylg+scb
IzqK7465ZnnFk1O/sT5xHEeBVPyEqZYp+S9oAIFrFuXlEKbFF1G3LDjoK5dtP8Sd
ymlgoLVl9AQ4qlE7bRKvxA7e3sMQg69j1IyQBNGBumD7x4UizsAcV0UfEsYGddpE
4ohbNf6cNtjxyTO5IabYMVECgYAurXt2Zt4iMDiadjbWkXeclZWFUanh6n2YGEm/
ryqiwpNtrsqu7Dey0mOkxEyWwunvaJBiKLRfpHrrWlbNu/SdCwOKa+TVW7UPxqa6
5CS8EDuL4MNbF0/vVCbL8QBzR2m3c9kNSV0Xdl7a8LNDgmCzYesHnvUVHPioJL3+
1MsCAQKBgCkyKcsQhrii51otXF4N6c4ej3mqpq3pIm8ZSkskhKLxyBon+WawO58O
EMeq7Hrv10qNOjELU4wTcuqlWeT259LwfsySw9mM4lTV+H0064tLK20JVGQU90P4
30545RaoJaEci/MZu39+FJNXuLgIgKNijowAAwwp7avoOubuXc/2
-----END RSA PRIVATE KEY-----

平台的私钥只有微信有,不会下发给我们,我为了方便验证结果,所以也先放出来,说不定后面会用到。上面都列出了文件名,我分别用 Ruby 相应的对象来承接它们,后面就不重复说了,记住变量名就好

> @platform_cert = OpenSSL::X509::Certificate.new(File.read('random_platform_cert.pem')) # 平台证书对象
=> #<OpenSSL::X509::Certificate: subject=#<OpenSSL::X509::Name CN=Ruby CA,DC=ru...
> @platform_cert.serial.to_s(16) # 平台证书序列号
=> "05FF8B6E"
> @apiclient_cert = OpenSSL::X509::Certificate.new(File.read('random_apiclient_cert.pem')) # 商户证书对象
=> #<OpenSSL::X509::Certificate: subject=#<OpenSSL::X509::Name CN=Ruby CA,DC=ru....
> @apiclient_cert.serial.to_s(16) # 商户证书序列号
=> "0254A801C0"

> @apiclient_key = OpenSSL::PKey::RSA.new(File.read('random_apiclient_key.pem')) # 商户私钥
=> #<OpenSSL::PKey::RSA:0x00007facbb8ebf88>
> @platform_key = OpenSSL::PKey::RSA.new(File.read('random_platform_key.pem')) # 平台私钥(微信不会给我们的)
=> #<OpenSSL::PKey::RSA:0x00007facb6071d58>

公钥可以通过证书来获得,我们来模拟一下公钥加密然后私钥解密的过程

> result = Base64.strict_encode64(@apiclient_cert.public_key.public_encrypt('hello')) # 公钥加密,并Base64加密
=> ETGGd92t9.......

> @apiclient_key.private_decrypt(Base64.strict_decode64(result)) # 先Base64解密,然后私钥解密
=> "hello"

微信支付里面大部分的加密/解密过程都类似,反正都会经过 Base64 先处理,记住这个模式先咯。下面我们一一来分解。

开发指南

一。签名生成

在对微信的接口发起请求的时候都需要对接口相关的信息进行签名操作,把签名后的签名串附在 HTTP 头部一起发送给微信,微信那边会验证签名。这里用到的签名算法是SHA256-RSA2048。需要用到的是商户特有的商户私钥apiclient_key.pem(我们这里的文件名是random_apiclient_key.pem)。

首先需要做的是把HTTP请求的方法获取请求的绝对URL时间戳, 随机字符串, 请求报文主体JSON格式等 5 个关键部分用\n拼接起来。这个都比较容易,只是有下面的细节要注意一下,稍有不慎,你的签名可能就会过不了。

  1. GET 请求没有请求主体,所以他请求报文主体的 JSON 格式为空字符串。所以拼接好之后大概是这样GET\n/v3/certificates\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n。别忘了最后面的\n
  2. GET 请求带参数的情况,它的参数会以 query 的形式放在路径的后面,这也需要签名,而且参数一定要排序,这个微信没有说清楚,不排序的话签名验证会失败。所以请求的 URL 大概像是/v3/certificates?a=1&b=2,如果拿/v3/certificates?b=1&a=2来签名,微信那边会报签名错误。记得 query 一定要先排序
  3. POST 请求的报文主体 body,需要先序列化为 JSON 字符串,然后再进行拼接。

所以代码大概像下面这样

# 字符串拼接
def build_string(method, url, timestamp, noncestr, body)
  "#{method}\n#{url}\n#{timestamp}\n#{noncestr}\n#{body}\n"
end

def signature_string(string)
  result = @apiclient_key.sign('SHA256', string) # 商户私钥的SHA256-RSA2048签名
  Base64.strict_encode64(result) # Base64处理
end

示例

> string = build_string('POST', '/api/v3', 1600000, 'noncestr',{a: 1, b: 2}.to_json)
=> "POST\n/api/v3\n1600000\nnoncestr\n{\"a\":1,\"b\":2}\n"

> signature_string(string)
=> "UlvGju/kSSI86ZkBOZ4tKcCg0a37Ccpjb9mrF6HSvr+QoANRXCmBPyoUbN8NIrzR9VEQlgVEG4EeN0c+ZLvGc2yAjPO10GTrhxp0WochnZrvvCAEBkwseM0BA3U+cfqQU6N4RFPtTmUMCDWn2nO74BjY/qkEbIBJHG1cWhUfnVH6I+X3SJeWpSGb86DeJ342EdGhfiZfN0rdjM8Ae3kQkn4btEk4dLHPkoVDFsGf1bGtdXUx3T+krJDhBoPZmw6SQEGqITvlNTI6BqYn6CLH56UQBFyr3nf0VzYnm9oa+L0fxVQRynf7Ari7m0bnTLguxtCFC+JSzj/ismfsUhCmkw=="

这样就得到签名了,为了发送请求还需要构建 HTTP 头部信息Authorization。这个比较简单,借助上面两个方法我的封装如下

def build_authorization_header(method, url, body)
  timestamp = Time.now.to_i
  nonce_str = SecureRandom.hex
  string = build_string(method, url, timestamp, nonce_str, body)
  signature = signature_string(string)

  params = {
    mchid: ENV['MchID'], # 商户号
    nonce_str: nonce_str,
    serial_no: @apiclient_cert.serial.to_s(16), # 平台证书的序列号,可以这样获得
    signature: signature,
    timestamp: timestamp
  }

  params_string = params.stringify_keys.map { |key, value| "#{key}=\"#{value}\"" }.join(',')

  "WECHATPAY2-SHA256-RSA2048 #{params_string}"
end

调用大概像这样,把得到的串放到请求头的Authorization中即可

> build_authorization_header('POST', '/api/v1', {a: 1}.to_json)

=> "WECHATPAY2-SHA256-RSA2048 mchid=\"1600000000\",nonce_str=\"382d40886e54f22e05398ca40e629d12\",serial_no=\"0254A801C0\",signature=\"hNn2iAb3jhKIyq4u2adHuY0NkOuncVwnVDb+x2PaPWdL8ZqQXMkTP/XlmgNa2TDkibFhWzI0+Hoxo/kJ7ZeWSZoRa2I1jO0V2ee8T/lHQKBNtC0Kf48YDo+xq2Wd6WgjVg2BW0rdD6DSwMOVso5cGGBNup9PXm6+RNcj9kGLEG3BH3UcVVziQ9yEo0jZZHXg+sRXKmEe2j/Y1EAf30s46nkETZn+wp+SJuCtBdMT/rZZe3qFteOgDktWmxRpLFNHBba8CSNlPWwj9t+nI6xbcEm8S3D3pkJwYAnSfVuxEP0tgvKnVZVNP70ZvXJgJ/puho+h2Ot4OBMM10lfGG2Nyg==\",timestamp=\"1619576150\""

PS: 结果暴露了当时的 timestamp,nonce_str 还有一个假商户号,仅供读者验证。

二。签名验证

微信的文档如下

如果验证商户的请求签名正确,微信支付会在应答的 HTTP 头部中包括应答签名。我们建议商户验证应答签名。

同样的,微信支付会在回调的 HTTP 头部中包括回调报文的签名。商户必须验证回调的签名,以确保回调是由微信支付发送。

也就是说调用微信接口之后,微信会响应,这个时候响应的头部会携带一些信息,我们通过这些信息可以校验响应是不是来自微信的。感觉是否验证都 OK,所以微信只是建议我们去验证。

不过第二点,主要是微信支付成功之后微信会往我们的回调链接发请求,这个时候请求头里面会包含类似的信息,此时就必须验证了。一般我们会通过这个请求来让已经微信支付过的订单状态变成“已支付”,如果不验证请求是否来自微信,万一有人模拟微信向我们的接口发请求,那么就很容易导致数据紊乱,所以微信要求我们这种情况下必须验证。

验证算法也很简单,元素有四个

  1. HTTP 头 Wechatpay-Timestamp 中的应答时间戳
  2. HTTP 头 Wechatpay-Nonce 中的应答随机串
  3. 应答主体(response Body)
  4. 签名结果

这些都能够从请求头或者应答头获得,类似这样

timestamp = request.headers['Wechatpay-Timestamp']
noncestr = request.headers['Wechatpay-Nonce']
body = JSON.parse(request.body.read)
signature = request.headers['Wechatpay-Signature']

校验方法大概就是这样

# 通过证书获取平台的公钥
> @platform_public_key = @platform_cert.public_key


# 检测消息是否来自于微信
def notification_from_wechat?(timestamp, noncestr, body, signature)
  string = "#{timestamp}\n#{noncestr}\n#{body}\n"
  decoded_signature = Base64.strict_decode64(signature)
  @platform_public_key.verify('SHA256', decoded_signature, string) # 平台公钥
end

这里要用到平台的公钥,通过证书就能获取到。这里可以简单验证一下上面方法的正确性。其实头部的Wechatpay-Signature里面的东西,是通过微信平台的私钥来签名生成。根据文档签名的对象就是timestamp, noncestr, body等元素用\n拼接所得到的字符串。因此我们可以用最开始模拟的平台私钥来“仿制”微信的签名,所用的算法依旧是 SHA256 with RSA。签名过程大概是

> timestamp = Time.now.to_i
=> 1619658449

> noncestr = SecureRandom.hex
=> "8e934f4b9b470371d0668cc3a8797dce"

> body = { number: 'N1600000' }
=> {:number=>"N1600000"}

> str = "#{timestamp}\n#{noncestr}\n#{body.to_json}\n"
=> "1619658449\n8e934f4b9b470371d0668cc3a8797dce\n{\"number\":\"N1600000\"}\n"

> signature = Base64.strict_encode64(@platform_key.sign('SHA256', str)) # 模拟微信平台私钥签名
=> "Y5amODpAsF3vbJicAruUOhj7+siBziM98hNe58gYUCZAVTz+xDhkLnTdgSnjaGywHHIr0GsETRnhEKgd4eYtZ2TcD4zvAo0muqnLBF4ff5uNtmboc3NyMyhblgb8/61WkaqPWl3vvJYDXKB/RV2iotQFT3kQ/mv4TlMrCV9cfOmqqwKMRKqC6qAiIkTQ66FXAD5PDEu2B3dgxt2/rzoKKaQ7jfT56MkXQynf1dGRAllLTJKC8m0vqZSgkTZyCWT54tX3AHBOIuNyKtTEgZd58HG7xJosqPsqvjGx5hMn9LGxUqxvtrJBhBE1bqTVMtZScXQsVbdhXqKS0MhUNz79Hg=="

最后用我们所封装的方法notification_from_wechat?来验证一下,消息是否来自于微信

> notification_from_wechat?(timestamp, noncestr, body.to_json, signature)
=> true

结果是好的。总的说就是微信用自身的平台私钥把要给你的东西做好签名,你的服务收到微信请求的时候要用微信分发的公钥(通过平台证书获取)以及一些元数据来验证请求是否来自于微信,如果是才进行下一步操作。

三。证书和回调报文解密

这也是一个挺头疼的问题,在支付成功的回调或者用户主动下载平台证书的时候,微信的请求头或者响应体中会包含一些重要信息,而这些信息并不是明文的,微信对信息进行了 AES-256-GCM 加密,并把加密所使用的nonceassociated_data以及加密后并经过 Base64 处理的ciphertext返回给我们,我们所要做的就是对这个结果进行解密,以获得我们真正需要的信息。Ruby 所对应的文档也在OpenSSL 相关章节

Ruby 的文档写的很清楚,我们只需要把他的 128 改成 256 就是微信的加密方式。先尝试做个加密,在这里加密所用的 key 是商户的 Key,这个是由微信商户分发,后台可以查看。我们就随机生成一个

mch_api_key = 'da67e7c75f842937501aa4d203060bca'
nonce = '46e4d8f11f62' # 必须是12个字节
associated_data = 'transaction'
> data = "Very, very confidential data"
> cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
> cipher.key = mch_api_key
> cipher.iv =  nonce
> cipher.auth_data = associated_data

> encrypted = cipher.update(data) + cipher.final
=> "l*\a\xE2\x8C\x923\n\xEF\x81M+\xC7\xD9$\x9B\xF3\x7FR\xC2\x9Ay\x9Bns)\x91 "

tag = cipher.auth_tag # produces 16 bytes tag by default
> "\x15Jd\x8F`\x84\x90F\xD7\xE3\x060<\x92.Q"

encryptedtag拼接再经过 Base64 处理其实就对应了微信给你的ciphertext(文档没有详细说,尝试出来的)。也就是

> ciphertext = Base64.strict_encode64(encrypted + tag)
=> "bCoH4oySMwrvgU0rx9kkm/N/UsKaeZtucymRIBVKZI9ghJBG1+MGMDySLlE="

最后我封装了一个类似这样的方法,用于解密上面的数据

def decrypt_the_encrypt_params(associated_data:, nonce:, ciphertext:)
  tag_length = 16 # tag的长度一般都是16个字节
  decipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
  decipher.key = mch_api_key # 换成微信分发的商户专属api_key
  decipher.iv = nonce
  signature = Base64.strict_decode64(ciphertext)
  length = signature.length
  real_signature = signature.slice(0, length - tag_length)
  tag = signature.slice(length - tag_length, length)
  decipher.auth_tag = tag
  decipher.auth_data = associated_data
  decipher.update(real_signature)
end

解密结果

> decrypt_the_encrypt_params(associated_data: associated_data, nonce: nonce, ciphertext: ciphertext)

=> "Very, very confidential data"

结果跟原来字符串一样,证明解密方法是有效的。

四。敏感信息加解密

最后来看看敏感信息加密,只能说不得不服微信这次接口的版本升级,安全性真的有够重视的,所有接口只要涉及到姓名,身份证号,手机号码,邮箱这些敏感信息都都需要先对信息进行加密处理,再通过接口进行传输。敏感信息加密的文档在此。而这次的加密过程总的来说就是本篇文章最开始提到的,公钥加密,私钥解密。公钥通过平台证书获得,所以用的是平台公钥。

> @platform_public_key = @platform_cert.public_key
=> #<OpenSSL::PKey::RSA:0x00007f8f4402e498>

然后用它来加密就行,不过微信文档有提到

我们使用了相对更安全的 RSAES-OAEP(Optimal Asymmetric Encryption Padding)。

在 OpenSSL 里面 pading 值设置成RSA_PKCS1_OAEP_PADDING,在 Ruby 的文档里面也能找到相关的属性,所以私密信息的加密方法如下

def signature_important_info(string)
  Base64.strict_encode64(@platform_public_key.public_encrypt(string, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING))
end
> encrypt = signature_important_info('lanzhiheng')
=> "NStnKjrimDMNgmIsdInFv+dEfwQjBBgFoe9fVRgZjESnXLNl4YiYzdAUHkYi+AK4G28mSo75cLY2xSmM7CiYcUozYzJDV6VWubz5SEYWCqWGYvl5+qPNVJt0JfE4g76ZHCQYxbaP5bKFIcfcFjH2x3E5Ssn5etfNGfXrOPekloyq/rv/ZccZwW8W6Yofiql65UFdv4TG2/tubhqiYh8rtQxSV7RWQdAgT0vaQPFXyhadxPQawDAUaGUEtO4GrNSvo6Fgpm3Pa2YCv8QgrF5l4jA7AOZqtsTvAQrXE3hwD9IHqsKaCp5JNERdHy6u0u4Nte1BQZ216O9gg4RxZpb0qQ=="

注意上面的结果可能每次运行都会不一样,我这里只是提供其中的一个结果。用我生成的“假”平台私钥匙模拟一下解密过程,虽说我们永远都不需要去解密它们(那是微信的事情)。

> signature = Base64.decode64(encrypt) # 先Base64解密
=> "5+g*:\xE2\x983\r\x82b,t\x89\xC5\xBF\xE7D\x7F\x04#\x04\x18\x05\xA1\xEF_U\x18\x19\x8CD\xA7\\\xB3e\xE1\x88\x98\xCD\xD0\x14\x1EF\"\xF8\x02\xB8\eo&J\x8E\xF9p\xB66\xC5)\x8C\xEC(\x98qJ3c2CW\xA5V\xB9\xBC\xF9HF\x16\n\xA5\x86b\xF9y\xFA\xA3\xCDT\x9Bt%\xF18\x83\xBE\x99\x1C$\x18\xC5\xB6\x8F\xE5\xB2\x85!\xC7\xDC\x161\xF6\xC7q9J\xC9\xF9z\xD7\xCD\x19\xF5\xEB8\xF7\xA4\x96\x8C\xAA\xFE\xBB\xFFe\xC7\x19\xC1o\x16\xE9\x8A\x1F\x8A\xA9z\xE5A]\xBF\x84\xC6\xDB\xFBnn\x1A\xA2b\x1F+\xB5\fRW\xB4VA\xD0 OK\xDA@\xF1W\xCA\x16\x9D\xC4\xF4\x1A\xC00\x14he\x04\xB4\xEE\x06\xAC\xD4\xAF\xA3\xA1`\xA6m\xCFkf\x02\xBF\xC4 \xAC^e\xE20;\x00\xE6j\xB6\xC4\xEF\x01\n\xD7\x13xp\x0F\xD2\a\xAA\xC2\x9A\n\x9EI4D]\x1F.\xAE\xD2\xEE\r\xB5\xEDAA\x9D\xB5\xE8\xEF`\x83\x84qf\x96\xF4\xA9"

> @platform_key.private_decrypt(signature, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING) # 私钥解密
=> "lanzhiheng"

可见解密结果跟加密前的数据能对得上,相对来说这个过程还是比较好理解的。

尾声

这篇文章是个人最近被微信加密/解密折腾的一个总结,主要就是微信的代码示例没有提供 Ruby 版本的,所以笔者得自己实现,并分享相关的 Ruby 代码,就是用 Ruby 实现了微信商户平台中“开发指南”的所有加密/解密过程,主要分为

  1. 签名生成
  2. 签名验证
  3. 证书和回调报文解密
  4. 敏感信息加解密

四个部分,他们可能会用到不同的加密/解密算法,以及不同的证书/密钥。这些都是需要开发者在研发过程中多加注意的。希望这篇文章对您有帮助。

哈哈,前段时间也刚搞过这个。可气的就是文档根本没说清楚。

而encrypted跟tag拼接再经过 Base64 处理其实就对应了微信给你的ciphertext(文档没有详细说,尝试出来的)

https://burogu.bubuyu.top/blogs/48

之前我也有写过一篇……小菜鸡写的太烂没好意思贴出来😂

原来 AEAD_AES_256_GCM 是这样弄的……当时倒腾了好久,最后还找了个 gem😂 学习了学习了

yuchiXiong 回复

很 nice 啊。早点见到你这篇我就不用搞那么辛苦了。 😂

lanzhiheng 回复

😂 😂 😂 也是忘了 hhhh

😀 牛啊牛啊,收获两篇好文

之前参照微信提供的 php 代码来搞的,避开很多坑。

hooopo 将本帖设为了精华贴。 05月06日 10:36

微信支付,我用 Java 的轮子了。implementation "com.github.binarywang:wx-java-pay-spring-boot-starter:4.0.0"

@yuchiXiong @mingyuan0715 我封装了 Gem 要试一下吗?😂

lanzhiheng 回复

nice~可惜现在已经没有在维护之前那个做支付的 ruby 项目了😂

为什么我是用 gem 中的下载证书接口下载的证书格式不对呢

@lanzhiheng 这里应该用

@apiclient_key.private_decrypt(Base64.decode64(data), OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING).force_encoding("utf-8")
需要 登录 后方可回复, 如果你还没有账号请 注册新账号