密码学实战:明明都是调医保接口,为什么授权码用数字信封、查单只用 SM4?——被 BJCA 证书体系折磨明白之后,我把这套东西彻底讲透 写在前面之前做一个互联网医院项目对接北京医保移动支付。首信医保支付通道给的接入文档里有两类接口当时对接的时候我直接懵了最近复习的时候重新整理了一下方便自己以后查看授权码接口获取支付确认页授权码要求走 BJCA 数字信封 PKCS7 签名CommonFunc 接口支付状态查询/撤销/退费查询要求走 SM4 加密 SM3WithSM2 签名同一个对接方同一个支付流程加密体系完全是两套。当时我脑子里冒出来一串问题数字信封到底是个啥 为什么一个接口要搞得这么复杂另一个又这么简单 BJCA 是个什么机构sn 是什么appName 又是什么 公钥还能按序列号动态获取对方怎么知道我用的是哪个公钥 P7 签名为什么把证书直接塞在报文里传过去安全吗不怕被伪造吗这篇文章就是我把自己从一脸懵到彻底搞懂的过程整理出来。全程用生活场景打比方不堆术语看完你应该能把这套体系讲给自己的同事听。一、数字信封是什么先别看定义看一个寄快递的场景之前简单了解了下可以参考BJCA 数字信封和 BouncyCastle 签名格式是个啥假设我要给你寄一份机密文件。最直觉的做法找个箱子锁上寄给你。但问题来了——开箱的钥匙怎么给你钥匙跟箱子一起寄快递员半路拆开抄一把箱子形同虚设。提前当面给你那我们得先见一面麻烦死了。数字信封的玩法是这样的第 1 步临时造一把一次性钥匙把文件锁进箱子对称加密快 第 2 步用【你的公钥】把这把钥匙装进一个小信封只有你的私钥能拆 第 3 步箱子 信封一起寄出 ​ 你收到后用私钥拆信封 → 拿到钥匙 → 开箱为什么这么绕因为两种加密各有缺陷对称加密SM4/AES非对称加密SM2/RSA速度快慢慢一到两个数量级密钥怎么给对方难——这就是密钥分发难题容易——公钥随便发私钥自己留着数字信封就是各取所长数据用对称的快只把那一把小钥匙用非对称的安全送达。加密大件快递小件。就这么多。数字信封的原理没有更多了剩下的复杂度都在公钥从哪来——这才是重头戏。二、为什么授权码接口用数字信封查单接口只用 SM4一句话钥匙从哪来不一样。查单接口老搭档模式CommonFunc 接口bizType1001 支付查询、1002 撤销、1003 退费查询、1004 处方支付查询走的是预共享密钥我们入网的时候首信线下给了我们一个appSecret每次加密直接取它的前 xx 字节当 SM4 密钥// 对业务数据做 SM4 加密密钥 appSecret 前 xx 字节 inData Hex.toHexString(SM4Utils.encryptEcbPadding( sxProperties.getAppSecret().substring(0, xx).getBytes(StandardCharsets.UTF_8), object.toString().getBytes(StandardCharsets.UTF_8))).toUpperCase();类比我们和首信是多年的老搭档入网那天就当面换好了暗号。之后每次通信直接用暗号根本不存在钥匙怎么传的问题。而且查询是高频调用每笔订单要查还有重试机制对称加密快正合适。授权码接口生人模式授权码接口移动支付确认页、退费页、电子医保凭证页、银行卡管理页走的是 BJCA 那套证书体系加密用的公钥不是预共享的// 从 BJCA 的证书服务按序列号取医保端加密公钥证书 String cert CaUtils.getCert(sed, sxProperties.getSn()); // 用这张证书里的公钥做数字信封 String encDataStr CaUtils.dataEncryption(sed, cert, inData);类比这回对面不是老搭档了中间隔着一个公证处BJCA。对方的公钥是一张证书还可能到期换新。既然钥匙没法提前塞给你那就每次现场造一把一次性钥匙用对方当前的公钥封进去寄过去——这就必须用数字信封了。对比一下查单接口CommonFunc授权码接口密钥来源预共享 appSecret写死在配置里每次现场生成一次性密钥怎么送达不需要送达双方都有数字信封对方公钥加密送达密钥会换吗不会除非线下重新交换每次都是新的证书到期还会换签名方式SM3WithSM2PKCS7 Attach对于这种获取授权码拉起支付的场景数字信封还是很安全的三、BJCA 是个什么角色这场交易里有三方不是两方我一开始以为数字信封就是我们和医保端两家的事经过查阅才发现不对——getCert、dataEncryption这些调用压根不是本地运算而是远程调用了一台 BJCA 的服务器。先把三方角色摆出来角色代码干什么的我们互联网医院appName xxx114业务系统BJCA SVS 服务器配置类里的address1: xxx.xxx.xx.xx:8000北京数字认证公司部署的签名验签服务器医保端首信sn 1a10xxxxxxxx换来的那张证书对方BJCA 是第三方 CA数字认证机构类似公证处。它在这套体系里干两件事给双方发证书——证书 公钥 身份信息 CA 签章本质是这是真公钥的担保运营一台 SVS 服务器上面存着两样东西证书目录公共区谁都可以按序列号来查私钥保险柜托管区双方开户时把自己私钥灌进去永远不外发这一点很重要我们应用的私钥从头到尾没有出现在我们的代码和配置里。对比一下 CommonFunc 那条路——SM2privateKeyHex、appSecret就明晃晃写在配置文件里。这是托管和自持两种安全等级的差别医保政务级的要求明显高一档。BJCA SVS 服务器证书目录 私钥保险柜 ┌──────────────────────────────────────┐ │ 证书目录公共区凭 sn 可查 │ │ ├─ 医保端加密证书 sn:1a10...c8a3 │ │ └─ 我们的应用证书 sn:xxxx │ │ 私钥柜托管区凭 appName 定位 │ │ ├─ 我们应用的私钥 appName:xxx │ │ └─ 医保端的私钥 appName:yyy │ └──────────────────────────────────────┘ ↑ 查sn拿公钥证书 ↑ 查sn拿公钥证书 我们 ──┘ └── 医保端所以回到标题里那个问题——公钥还能这么拿——能。证书不是私下传的文件而是存在 SVS 服务器的公共目录里sn序列号就是目录里的编号好比快递柜的取件码。getCert(sn) 拿着取件码去柜子里取对方公钥。四、sn 和 appName 到底是什么我一开始全理解错了这里我卡了最久。我先把自己当时的错误理解摆出来看你是不是也这么想的1我们双方都存各自的 appName 和对方的 sn 2我们加密用 sn 实现通过对方的公钥加密对方再通过 appName 获取自己的私钥解密 3签名我们通过 appName 实现私钥签名对方通过 sn 获取我们的公钥实现公钥验签前两条是对的第三条错了一半。而且错的这一半恰恰是最精华的部分。先说对的appName 自己的身份。拿着它去 SVS能定位到自己名下托管的私钥sn 对方加密证书的序列号。拿着它去 SVS 证书目录能查到对方的加密公钥再说错的对方验我们的签名根本不需要通过 sn 去查我们的公钥。那是怎么验的这就要说到下一个更颠覆认知的知识点了。五、最绕的点每个人手里其实有两张证书我懵的根源在这正规 PKI 部署里每个实体往往有两张证书——签名证书和加密证书两对完全独立的密钥。每方两张证书两对独立密钥 ​ ┌─────────────────────────────────────────────────┐ │ 我们 │ │ ├─ 签名证书公钥A 私钥A → 嵌在P7里寄给对方验签 │ │ └─ 加密证书公钥B 私钥B → 对方做信封时用公钥B │ │ 我们拆信封用私钥B │ └─────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────┐ │ 其他例如首信医保 │ │ ├─ 签名证书 → 嵌在他们回给我们的P7里我们验签用 │ │ └─ 加密证书 → 我们配置的sn指向的就是这张 │ │ 医保端加密公钥证书 │ └─────────────────────────────────────────────────┘代码里其实有直接证据——getCert(sn)取回来的证书日志里明确打印的是医保端加密公钥证书。言下之意还存在另一张签名证书走的是另一条路。为什么分两张安全工程的惯例签名私钥永远不出密码机、只负责签身份泄露了可以吊销换新加密私钥要配合解密历史数据生命周期管理方式不一样。两个职责两对钥匙。appName → 定位自己的私钥签名用 拆对方的信封用 对方sn → 只干一件事做给对方的信封时查对方的加密公钥 验签 → 什么都不用配证书随P7报文自带验证书链即可于是四个加密动作对应的钥匙一张表说清动作用哪对密钥钥匙怎么找我们做信封加密对方的加密公钥getCert(对方的sn)查证书目录对方拆信封解密对方的加密私钥信封结构里自带收件人证书标识SVS 对号入座我们 P7 签名我们的签名私钥appName定位 SVS 托管的私钥对方验签我们的签名公钥什么都不用查——证书就打包在 P7 报文里这里有个衍生问题我顺便也被绕进去过appName 一个账户名下有两对密钥SVS 怎么知道我要用签名私钥还是解密私钥答案是调哪个方法就是哪种业务。看这四个方法方法操作语义SVS 用哪把钥匙encodeEnvelopedData(cert, data)做信封传入的 cert 里的公钥对方的压根不用我们的钥匙signDataByP7Attach(data)签名我们 appName 下的签名私钥decodeEnvelopedData(data)拆信封我们 appName 下的解密私钥verifySignedDataByP7Attach验签不用任何私钥用报文里内嵌证书的公钥类比银行卡号appName标识账户但取款和开保险箱是不同业务、不同授权——柜员看你办什么业务拿钥匙不是看卡号猜。而且拆信封还有第二重精确匹配信封的收件人信息里写着这把密钥是用序列号 xxx 的证书封的SVS 据此在名下多张证书里精确对号入座。总结sn 解决“我把东西加密寄给对方”的问题查对方加密公钥appName 解决“我签名、我收快递”的问题用自己私钥而“对方怎么验我”根本不需要事先安排——我的签名证书每次都 photocopy 一份钉在信上公证处BJCA的钢印就是防伪。六、P7 签名证书为什么敢直接塞进报文里传这是整个体系里最反直觉的一环我当时的问题是签名证书和原文一起打包传过去对方从 P7 里现取我们的证书验签——那怎么保证安全性攻击者伪造一张证书不就完了先破一个直觉证书本来就是公开物公钥密码学的整个设定就是公钥随便传。你去看 HTTPS网站的证书也是每次握手时大大方方发给浏览器的。证书不保密它声明身份。内嵌证书没泄露任何秘密。你真正该问的是攻击者能不能连证书带签名一起伪造攻击者的三种死法攻击方式死在哪原因伪造证书自制密钥对冒充我们证书链验证假证书上没有 CA 签章链一验就断盗用真证书公开物随便拿 自己造签名签名验证造的签名对不上证书里的公钥——他没有配对的私钥篡改报文业务数据签名验证签名是用真私钥针对原文做的改一个字节验签即挂只有真证书 真私钥持有者的签名能同时过两道锁。验 BJCA 证书链到底在验什么就是一条自下而上的验证路径我们的签名证书随 P7 报文到达 ↑ 谁签发的BJCA 中间CA —— 用中间CA证书的公钥验我们证书上的签名 ✓ BJCA 中间CA证书 ↑ 谁签发的BJCA 根CA —— 用根CA证书的公钥验中间CA证书的签名 ✓ BJCA 根CA证书 ↑ 到达信任锚点预装在验证方信任库里验完收工外加两项例行检查有效期 是否被吊销。最后一环是关键中的关键——信任锚点不在传输路径上。报文里的一切证书、签名、密文攻击者都能替换但验证所依赖的根 CA 证书是开户时通过线下正规渠道预装在信任库里的不在攻击面上。类比公证处的钢印经过公证的文件随便复印复印件依然有效——因为钢印在纸面上伪造者能打印一模一样的文件但假钢印一验就穿帮验证方手里有真钢印的印鉴备案。证书随报文传输 复印件随处可传证书链验证 对着备案验钢印。七、把整个流程串起来走一遍最后把收发双方的全部动作串成一条线。先记住一个总原则谁发送谁就做信封 P7 签名谁接收谁就验签 拆信封。操作完全镜像。请求方向我们是发送方// 第 1 步做数字信封 // getCert(对方sn) 取对方加密证书 → SVS 现场生成一次性 SM4 密钥 // 加密业务数据再用对方公钥把密钥封进去 → encData String cert CaUtils.getCert(sed, sxProperties.getSn()); String encDataStr CaUtils.dataEncryption(sed, cert, inData); ​ // 第 2 步P7 签名 // SVS 用 appName 定位我们托管的签名私钥对 encData密文做 P7 签名 // 我们的签名证书和原文一起打包进签名结构 → signData String signDataStr CaUtils.getP7Attach(sed, encDataStr); ​ // 第 3 步发送报文里两个字段 // param.put(signData, signDataStr); // param.put(encData, encDataStr);注意第 2 步的一个细节签的是信封密文不是明文。防的是传输途中整个信封被篡改替换。对方收到后// 第 4 步对方视角 // a. 从 P7 里直接取出我们的签名证书零配置公钥随报文自带 // b. 验证书链BJCA 签发有效期被吊销 // c. 用证书里的公钥验签名值 // d. 拆信封信封收件人信息标明用他们哪张证书 // SVS 用他们 appName 下托管的解密私钥解出 SM4 密钥 → 明文响应方向完全镜像对方拿他们配置里的我们的 sn查我们的加密证书做信封用他们的签名私钥做 P7返回给我们。我们的处理就三行// 验对方的 P7他们的证书内嵌其中 CaUtils.verifyP7(sed, jsonObject.getStr(signData)); // 从 P7 里抠出被签名的原文 给我们的信封密文 String p7SignatureInfo CaUtils.getP7SignatureInfo(sed, jsonObject.getStr(signData)); // 我们 appName 下的解密私钥拆信封 String result CaUtils.dataDecode(sed, p7SignatureInfo); // 拿到 pageAuthCode第二行值得多看一眼——这是 Attach 模式带原文签名的红利信封密文就藏在签名结构里对方连单独的 encData 字段都不用传。钥匙使用清单收藏这张表步骤谁操作用哪把钥匙钥匙在哪做信封发送方接收方的加密公钥SVS 证书目录sn 查P7 签名发送方发送方的签名私钥SVS 托管appName 定位验签接收方发送方的签名公钥P7 报文里内嵌现取拆信封接收方接收方的解密私钥SVS 托管appName 定位整个模型里任何一方的私钥都不出 SVS 服务器双方配置里只有 appName 和对方的 sn 两个编号验签连配置都不需要——信任全部交给证书体系自证。八、写在最后回头看最开始那一串问题现在都能一句话答上来了数字信封是什么对称密钥加密数据 对方公钥加密这把密钥。解决对称加密密钥怎么安全送达的问题。为什么授权码用它、查单不用查单的密钥是入网时预共享的 appSecret不存在分发问题授权码走 BJCA 证书体系密钥不预共享、每次一次性必须靠信封送达。sn 是什么对方加密证书的序列号去 SVS 证书目录查对方公钥的取件码。appName 是什么自己的账户身份定位 SVS 上托管的自己的私钥。对方怎么验我们的签名不用查任何东西我们的签名证书打包在 P7 里随报文寄过去了对方验 BJCA 证书链后直接用证书公钥验签。最后说点感受。对接之前我以为加密就是调个加密函数对接之后才理解工业级的加密方案里真正难的不是算法是密钥的管理——钥匙存在哪、怎么发、怎么换、怎么防止伪造。数字信封解决怎么发证书目录解决存在哪证书链解决怎么防伪SVS 托管解决私钥不出柜。四个机制各管一段拼在一起才是完整的安全体系。搞明白这套东西之后再回头看 HTTPS 的 TLS 握手会发现套路惊人地相似——只不过医保这套用的是国密算法SM2/SM3/SM4CA 换成了 BJCA 罢至。所谓一通百通大概就是这个意思。九、补充一下微信支付宝医保三套常见方案加密对比三套方案全景对比首信医保微信京通支付宝算法体系国密 SM 系列RSA AES-256-GCMRSA2业务数据加密SM4 / 数字信封回调报文 AES-GCM请求不加密不加密靠 HTTPS签名SM3WithSM2 / PKCS7商户私钥 RSA 签名SDK 自动商户私钥 RSA2 签名SDK 自动密钥来源SVS 托管 证书目录PlatformConfiguration表OSS 文件下载SDKBJCA SVSClient微信官方wechatpay-java支付宝官方alipay-sdk-java核心洞察为什么微信/支付宝不需要数字信封回到之前问的“数字信封解决什么”——密钥分发难题。再看三家医保政务网络环境 国密合规强制报文要应用层加密加密密钥无法预共享给所有接入方 → 必须数字信封动态分发微信APIv3 密钥是商户开户时一对一配置的商户平台后台自己设的根本不存在“分发”问题 → 固定对称密钥即可支付宝TLS 已经保证机密性应用层只要防篡改 → 连对称加密都省了所以数字信封不是“更高级”而是特定约束下的特定解接入方数量不定、密钥无法预配置、且必须应用层加密时才需要。微信支付宝的商户体系是一对一开户、密钥自己设问题本身就不存在。一句话“三条链路三种范式——医保是国密合规场景SM4 加密 数字信封分发一次性密钥 PKCS7 签名微信是商户私钥签名 APIv3 密钥解密回调对称密钥预共享无需信封支付宝最简RSA2 签名防篡改机密性全交给 HTTPS。选型差异的本质是数字信封解决的是‘密钥没法预共享’的问题微信支付宝的商户开户体系里这个问题不存在。”