PAYATHON 2026

Payment gateway 是否需要 Information hash?

支付小周

结论

只对 random salt + key + account codes + amount 计算普通哈希,无法充分保护支付请求。

建议采用以下方案:

  1. 所有请求必须使用 HTTPS,并正确验证 TLS 证书。
  2. 使用 HMAC-SHA-256 对规范化后的完整请求签名,不要使用普通哈希,也不要自行设计“加盐哈希”。
  3. 签名内容应包含调用方标识、时间戳、一次性随机数和请求 ID。
  4. 网关需要检查时间窗口,并记录已经使用过的随机数,以防止重放。
  5. 密钥只能保存在可信的应用后端,不能放入浏览器、移动端或其他由用户控制的客户端。
  6. 网关仍需独立校验金额、账户权限和交易状态。签名不能代替业务授权。

如果攻击者只是位于网络路径上的第三方,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、防重放机制、幂等处理和服务端业务授权。缺少其中任何一项,都可能留下被绕过或重复交易的风险。

备注:内容仅供参考。