支付系统中的通信安全

支付系统中的通信安全

如果对加解密与加签和验签有疑惑,可以先去上一篇文章中看看哦。
这篇文章讨论一个在支付系统对接中很常见的问题:HTTPS 已经提供了链路加密,为什么还要做“报文级二次加密”?

在高敏业务里,TLS 可能终止在网关/WAF/反向代理;排障抓包、链路追踪、日志脱敏不彻底,都可能让敏感字段在中间环节以明文出现。

因此我们常会在 HTTPS 之上再做一层“报文级”的加密与签名:即使报文在中间环节被看到也仍是密文,同时还能校验来源与防篡改。

方案一:专线

网络专线,简单来说,就是运营商(如电信、联通、移动)为企业或机构提供的一条专用、独享、端到端的通信通道。

你可以把它想象成一条信息高速公路上的专属快车道,而普通家庭宽带则是和大家共用的普通车道。

这样的好处是这条路搭建了别人与你的支付系统的专属车道,安全性和速度会大大提升。

当然设置专线是比较昂贵的,那么有没有什么方案是不通过专线也能提高安全性的呢?

方案二:报文加解密(消息级安全)

我们可以模仿 HTTPS 的思想,对报文使用自定义方式进行加解密与加签验签。

在进入流程前,先补充一个基础概念:Hash 函数。

Hash 函数

Hash(散列)函数是一类把任意长度的输入数据映射成固定长度输出的函数,输出通常称为 哈希值 / 摘要(digest)。

1)核心特性

  • 确定性:同样的输入必然得到同样的输出。
  • 固定输出长度:不管输入是 10 字节还是 10MB,输出长度固定(例如 SHA-256 恒为 256 bit)。
  • 雪崩效应:输入只改 1 bit,输出会发生显著变化。
  • 单向性(预映像困难):已知哈希值,很难反推出原文。
  • 抗碰撞:很难找到两段不同的输入,使它们哈希结果相同。

注意:哈希函数不是加密算法,它不支持“解密”,更像是“指纹生成器”。

2)常见算法与选择建议

  • MD5 / SHA-1:已存在实用碰撞攻击,不建议用于安全场景。
  • SHA-256 / SHA-512(SHA-2):工程上最常用,兼容性与安全性都很好。
  • SHA-3:新一代标准,在一些高安全要求场景也会使用。

3)Hash 在通信安全中的典型用法

  • 完整性校验:发送方计算 hash(body),接收方重算并比对,判断报文是否被篡改。
  • 签名的输入:通常对摘要签名(例如 Sign(hash(body))),性能更好也更统一。
  • 消息认证码(MAC)/ HMAC:只传 hash(body) 不安全(攻击者能改 body 后重算 hash)。常用 HMAC(key, body) 来保证“完整性 + 身份性”。

报文级安全流程(参考实现)

本文的“二次加密”主要解决两件事:

  • 让敏感字段端到端保持密文(即使经过网关/WAF/代理、抓包、日志/链路追踪等环节,也尽量不暴露明文)。
  • 让服务端能验证请求来源与完整性(防篡改、可追责,并配合 ts + nonce 防重放)。

0)AEAD / AAD 是什么?

  • AEAD(Authenticated Encryption with Associated Data)可以理解为“加密 + 认证”一次完成。
    • 加密:保证内容看不懂。
    • 认证:保证内容没被篡改(篡改会直接解密失败)。
  • AAD(Associated Data)是“关联数据”:不加密,但会参与认证计算。
    • 适合放:merchantIdtsnoncemethodpath 等“无需保密但必须防篡改”的字段。
    • 好处:攻击者哪怕只改了这些元信息,AES-GCM 校验也会失败,服务端会直接拒绝。

1)证书/公钥交换与信任建立

在所有步骤之前,双方先完成证书/公钥交换与信任建立:服务端持有自己的私钥与证书;客户端保存并校验服务端证书指纹/证书链;客户端也有自己的私钥与证书(或公钥)供服务端验签(同时约定 merchantId / keyId / 证书序列号等标识)。

1
2
3
4
5
6
7
8
9
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); // 创建 RSA 密钥对生成器
kpg.initialize(2048); // 初始化密钥长度为 2048
KeyPair clientKp = kpg.generateKeyPair(); // 生成客户端密钥对
PrivateKey clientPriv = clientKp.getPrivate(); // 获取客户端私钥(用于签名)
PublicKey clientPub = clientKp.getPublic(); // 获取客户端公钥(服务端用于验签)
KeyPair serverKp = kpg.generateKeyPair(); // 生成服务端密钥对
PrivateKey serverPriv = serverKp.getPrivate(); // 获取服务端私钥(用于解密 encKey)
PublicKey serverPub = serverKp.getPublic(); // 获取服务端公钥(客户端用于加密 aesKey)
String merchantId = "mch_001"; // 示例:客户端身份标识

2)生成 ts + nonce(防重放)

1
2
3
4
5
6
7
8
String method = "POST"; // 示例:HTTP 方法
String path = "/api/pay"; // 示例:HTTP 路径
String bodyJson = "{\"orderId\":\"A123\",\"amount\":100,\"currency\":\"CNY\"}"; // 示例:业务 JSON
byte[] body = bodyJson.getBytes(StandardCharsets.UTF_8);
long ts = System.currentTimeMillis();
byte[] nonce = new byte[16];
new SecureRandom().nextBytes(nonce);
String nonceB64 = Base64.getEncoder().encodeToString(nonce);

3)计算 bodyHash 并加上 canonicalString

1
2
3
4
5
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] bodyHash = md.digest(body);
String bodyHashHex = javax.xml.bind.DatatypeConverter.printHexBinary(bodyHash).toLowerCase();
String canonicalString = method + "\n" + path + "\n" + bodyHashHex + "\n" + ts + "\n" + nonceB64 + "\n" + merchantId;
byte[] canonicalBytes = canonicalString.getBytes(StandardCharsets.UTF_8);

4)私钥签名

1
2
3
4
5
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(clientPriv);
signer.update(canonicalBytes);
byte[] signature = signer.sign();
String signatureB64 = Base64.getEncoder().encodeToString(signature);

5)生成随机对称密钥 + iv

1
2
3
4
5
6
7
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(256); // 不支持可改 128
SecretKey aesKey = kg.generateKey();
byte[] aesKeyBytes = aesKey.getEncoded();
byte[] iv = new byte[12]; // GCM 推荐 96bit
new SecureRandom().nextBytes(iv);
String ivB64 = Base64.getEncoder().encodeToString(iv);

6)AEAD(AES-GCM)加密 body

如果要把元信息与密文绑定,建议把 merchantId/ts/nonce/method/path 作为 AAD 传入(代码略)。

1
2
3
4
5
Cipher aesEnc = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcm = new GCMParameterSpec(128, iv);
aesEnc.init(Cipher.ENCRYPT_MODE, aesKey, gcm);
byte[] ciphertext = aesEnc.doFinal(body);
String ciphertextB64 = Base64.getEncoder().encodeToString(ciphertext);

7)服务端公钥加密 aesKey(封装密钥)

1
2
3
4
Cipher rsaEnc = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsaEnc.init(Cipher.ENCRYPT_MODE, serverPub);
byte[] encKey = rsaEnc.doFinal(aesKeyBytes);
String encKeyB64 = Base64.getEncoder().encodeToString(encKey);

8)请求包字段一览

字段 类型 用途
merchantId / keyId / certSerial / alg 明文元信息 定位商户与密钥,选择验签/解密方式
ts / nonce 明文元信息 防重放(时间窗校验 + 去重)
encKey 密钥封装 用服务端公钥加密后的对称密钥(会话 key)
iv 密钥封装 对称加密使用的 IV/nonce
ciphertext 密文 业务 body 的密文(含认证 tag)
signature 完整性与身份 对 canonicalString 的签名,用于验签与防篡改

9)服务端处理:防重放、解密、验签

服务端收到后先做基础校验与防重放:检查 ts 是否在允许窗口内;检查 nonce 是否已使用过(Redis/DB 去重,过期删除);然后用服务端私钥解出 aesKey,并用 AEAD 解密 ciphertext 得到 body(解密失败直接拒绝)。最后重建 canonicalString 并验签。

(下方 Java demo 与原文一致,这里略。)

安全性提升(上线常用加强项)

1)密钥与证书治理

  • 证书/公钥轮换:在请求里携带 keyId / certSerial,服务端支持同一商户多把证书并行有效(灰度切换),并定义旧证书下线时间。
  • 私钥保护:私钥禁止明文落盘,优先放在 KMS/HSM 或操作系统密钥库中,限制导出与访问权限。
  • 权限隔离:把“签名/解密”能力做成独立组件或独立权限账户(最小权限原则),避免业务进程直接接触私钥。

2)防重放(ts + nonce)的工程落地

  • 时间窗:明确服务端允许窗口(例如 ±5 分钟),超窗直接拒绝。
  • nonce 去重:服务端把 merchantId + nonce 存入 Redis/DB 去重,TTL ≥ 时间窗(并留冗余)。
  • 业务幂等:支付类接口建议额外提供 requestId(或 orderId + attemptNo)并在服务端做幂等,避免“合法重放”造成重复扣款。

3)canonicalString 规范(避免验签失败与歧义)

  • 覆盖范围:建议至少覆盖 method + path + query + bodyHash + ts + nonce + merchantId
  • 规范化规则:明确编码(UTF-8)、换行符(n)、大小写、空值处理、query 排序方式。
  • 避免直接签 JSON 原文:更推荐签 bodyHash 或“规范化 JSON”结果,避免字段顺序/空格导致的验签不一致。

4)风控、审计与告警

  • 记录安全事件日志:验签失败、重放命中、时间窗超限、解密失败、keyId 不存在、证书过期等。
  • 对同一 merchantId 的连续失败做限流/熔断/告警,防止被探测与撞库。

5)国密与硬件

  • 国密组合(示例):SM2(签名/密钥交换) + SM4-GCM(对称加密) + SM3(Hash)。
  • HSM/KMS 的价值:密钥不可导出、硬件内签名/解密、可审计、可轮换,适合高合规与高安全场景。

支付系统中的通信安全
https://www.likeben.games/2026/04/12/支付系统中的通信安全/
作者
Ben
发布于
2026年4月12日
许可协议