Payment gateway 是否需要 Information hash?
结论
只对 random salt + key + account codes + amount 计算普通哈希,无法充分保护支付请求。
建议采用以下方案:
- 所有请求必须使用 HTTPS,并正确验证 TLS 证书。
- 使用
HMAC-SHA-256对规范化后的完整请求签名,不要使用普通哈希,也不要自行设计“加盐哈希”。 - 签名内容应包含调用方标识、时间戳、一次性随机数和请求 ID。
- 网关需要检查时间窗口,并记录已经使用过的随机数,以防止重放。
- 密钥只能保存在可信的应用后端,不能放入浏览器、移动端或其他由用户控制的客户端。
- 网关仍需独立校验金额、账户权限和交易状态。签名不能代替业务授权。
如果攻击者只是位于网络路径上的第三方,HTTPS 已经可以防止请求被窃听或篡改。HMAC 可以提供应用级认证,作为额外的安全措施,但如果调用方服务器、本地客户端或密钥已经失陷,HMAC 也无法解决问题。
为什么原方案不够安全
普通哈希不是消息认证
如果只是计算:
hash(salt + key + account1 + account2 + amount)
其安全性取决于字段的拼接方式、使用的哈希算法,以及密钥的存放位置。直接拼接密钥和消息再计算哈希,并不是标准的消息认证方法。某些构造还可能受到长度扩展等攻击。
应该使用专门为带密钥消息认证设计的 HMAC:
HMAC-SHA-256(secret_key, canonical_request)
不要自行设计密码学构造,也不要使用 MD5 或 SHA-1。
随机 salt 不能阻止重放
salt 随请求以明文发送并不是问题。问题在于,如果网关不记录它,攻击者就可以原样重放整个请求,包括:
- 交易数据
- salt
- 签名
此时签名仍然有效,网关可能再次初始化或执行同一笔交易。这里真正需要的是一次性随机数 nonce,而不是密码哈希中使用的 salt。服务端还必须拒绝已经使用过的 nonce。
请求中也应包含时间戳,并设置较短的有效期。不过,仅靠时间戳和 nonce 仍然不够,支付接口还应使用唯一请求 ID 保证幂等性。
字符串直接拼接可能产生歧义
下面两组字段直接拼接后,可能得到相同的字符串:
account1 = "12", account2 = "3"
account1 = "1", account2 = "23"
JSON 也可能因为字段顺序、数字格式、空白或 Unicode 编码不同而产生不同的字节序列。因此,签名协议必须规定唯一且稳定的规范化方式。
可以选择:
- 对收到的原始 HTTP body 字节签名;
- 使用确定性的 JSON canonicalization;
- 明确规定字段顺序、长度、编码和分隔方式。
金额建议使用最小货币单位的整数。例如,用 1099 表示 $10.99,不要对浮点数 10.99 签名。
如果密钥在浏览器中,签名没有保护作用
如果这里的“应用”是浏览器页面,终端用户可以查看 JavaScript、读取密钥,并自行构造任意合法签名。代码混淆无法解决这个问题。
正确的数据流应该是:
浏览器 → 应用后端 → Payment Gateway
共享密钥只保存在应用后端和网关中。浏览器提交的商品、账户和金额只能视为用户的请求意图,最终金额应由后端根据订单或商品记录重新计算。
推荐的签名设计
可以为每个调用方分配:
client_id:公开的调用方标识;secret:高强度随机密钥,只由调用方后端和网关保存。
请求头示例:
POST /v1/transactions HTTP/1.1
Content-Type: application/json
X-Client-Id: app-42
X-Timestamp: 1788235200
X-Nonce: 80e3be61-18b8-4f60-9681-e424e16f97dd
X-Request-Id: 8cc64a06-d767-4475-97e1-a4f109cb0837
X-Signature: v1=<hex-encoded-hmac>
请求体:
{
"sourceAccount": "ACCT-1001",
"destinationAccount": "ACCT-2002",
"currency": "USD",
"amountMinor": 1099
}
待签名内容可以定义为:
HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
CLIENT_ID + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
REQUEST_ID + "\n" +
SHA256(RAW_REQUEST_BODY)
签名计算方式:
signature = HMAC-SHA-256(secret, stringToSign)
路径应包含所有会影响请求含义的查询参数,并明确规定查询参数的规范化方式。除非相关格式已经经过严格定义,否则不要把可能被代理改写的 Host、空白或其他不稳定信息纳入签名。
Node.js 示例
下面的示例假设签名覆盖原始 UTF-8 请求体。在生产环境中,网关必须在解析 JSON 之前保留原始 body,因为重新序列化对象可能改变字段顺序或格式。
调用方生成签名:
import crypto from "node:crypto";
function sha256Hex(data) {
return crypto.createHash("sha256").update(data).digest("hex");
}
function createSignature({
secret,
method,
path,
clientId,
timestamp,
nonce,
requestId,
rawBody
}) {
const stringToSign = [
method.toUpperCase(),
path,
clientId,
String(timestamp),
nonce,
requestId,
sha256Hex(rawBody)
].join("\n");
return crypto
.createHmac("sha256", secret)
.update(stringToSign, "utf8")
.digest("hex");
}
const body = {
sourceAccount: "ACCT-1001",
destinationAccount: "ACCT-2002",
currency: "USD",
amountMinor: 1099
};
const rawBody = JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();
const requestId = crypto.randomUUID();
const signature = createSignature({
secret: process.env.PAYMENT_GATEWAY_SECRET,
method: "POST",
path: "/v1/transactions",
clientId: "app-42",
timestamp,
nonce,
requestId,
rawBody
});
网关验证请求时,应先根据 client_id 查找对应密钥,再检查时间戳和一次性随机数:
import crypto from "node:crypto";
function safeEqualHex(expectedHex, receivedHex) {
if (
!/^[0-9a-f]+$/i.test(expectedHex) ||
!/^[0-9a-f]+$/i.test(receivedHex)
) {
return false;
}
const expected = Buffer.from(expectedHex, "hex");
const received = Buffer.from(receivedHex, "hex");
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}
async function verifyRequest(request, nonceStore, lookupSecret) {
const clientId = request.headers["x-client-id"];
const timestampText = request.headers["x-timestamp"];
const nonce = request.headers["x-nonce"];
const requestId = request.headers["x-request-id"];
const receivedSignature =
request.headers["x-signature"]?.replace(/^v1=/, "");
if (
!clientId ||
!timestampText ||
!nonce ||
!requestId ||
!receivedSignature
) {
return false;
}
const timestamp = Number(timestampText);
if (!Number.isSafeInteger(timestamp)) {
return false;
}
const now = Math.floor(Date.now() / 1000);
const allowedSkewSeconds = 300;
if (Math.abs(now - timestamp) > allowedSkewSeconds) {
return false;
}
const secret = await lookupSecret(clientId);
if (!secret) {
return false;
}
const expectedSignature = createSignature({
secret,
method: request.method,
path: request.originalUrl,
clientId,
timestamp,
nonce,
requestId,
rawBody: request.rawBody
});
if (!safeEqualHex(expectedSignature, receivedSignature)) {
return false;
}
// 该操作必须是原子的:仅当 nonce 不存在时写入并返回成功。
// 保存期限至少覆盖允许的时间窗口。
const nonceAccepted = await nonceStore.insertIfAbsent(
`${clientId}:${nonce}`,
allowedSkewSeconds
);
return nonceAccepted;
}
insertIfAbsent 必须通过数据库唯一约束、Redis SET ... NX EX 或其他原子机制实现。如果先查询再写入,就会产生并发竞争,两个相同的请求可能同时通过验证。
网关还需要做的业务校验
签名验证通过,只能说明:“持有该密钥的一方发送了这些字段,而且字段在签名后没有被修改。”这并不代表该交易在业务上合法。
网关还必须检查:
client_id是否有权使用这两个账户;- 付款账户与收款账户之间是否允许发生该资金流转;
- 币种是否受支持,金额是否为正数并且没有超过限额;
- 金额是否与服务端保存的订单一致;
- 请求 ID 是否已经处理;
- 订单是否已经付款、取消、过期或退款;
- 调用方是否已被禁用,密钥是否已被撤销;
- 请求体是否包含未签名但会影响交易的字段。
尤其不能把浏览器提供的金额直接当作最终价格。更稳妥的接口设计是让调用方提交不可变的 order_id,再由可信后端读取订单金额和收款账户。即使请求中同时传入金额,也应与服务端记录核对。
实施时的注意事项
- 强制使用 HTTPS。HMAC 不提供加密,如果没有 TLS,请求内容仍可能泄露。
- 使用密码学安全的随机源生成密钥,不要用主机名、密码或可预测字符串作为密钥。
- 不要根据 HTTP
Host自动选择密钥。应使用明确的client_id,并验证其授权范围。 - 将密钥保存在密钥管理系统或受保护的服务端配置中,不要写入代码、日志或前端资源。
- 为签名格式加入版本号,例如
v1=,以便以后迁移算法或规范。 - 支持密钥轮换,在短暂的过渡期内可以同时验证当前密钥和上一把密钥。
- 使用常量时间比较函数校验签名,避免普通字符串比较泄露时序信息。
- 限制请求体大小,并严格验证字段类型、长度、字符集和允许值。
- 为认证失败、重复 nonce、重复请求 ID 和异常金额记录审计日志并配置告警,但不要记录密钥或完整的支付敏感数据。
- 如果基础设施允许,服务之间还可以使用 mTLS 或短期凭证,降低长期共享密钥带来的风险。
原设想中,使用调用方独有的秘密验证请求完整性,这个方向是合理的。但实现时应采用标准 HMAC 协议,并同时使用 TLS、防重放机制、幂等处理和服务端业务授权。缺少其中任何一项,都可能留下被绕过或重复交易的风险。
备注:内容仅供参考。
