Click Pay 文档Click Pay 文档
  • en-US
  • 简体中文
  • en-US
  • 简体中文
  • Node.js 签名与验签示例

Node.js 签名与验签示例

运行环境

Node.js 18+,使用内置 crypto 和 fetch。回调接入片段另需 Express。

统一规则

这些示例用于默认商户 API 和默认平台回调,不适用于另行约定的定制签名格式。中英文页面引用同一份可执行代码。

有请求体的 API 请求,使用商户私钥签名:

path + lowercaseHex(SHA512(rawBody)) + Timestamp

无请求体的 API 请求,使用商户私钥签名:

path + Timestamp

默认异步回调,使用平台公钥验签:

lowercaseHex(SHA512(rawBody)) + Timestamp

API 同步响应封装,使用平台公钥验签:

lowercaseHex(SHA512(平台序列化的 data 字节)) + timestamp
  • 算法统一为 RSA PKCS#1 v1.5 + SHA-512,签名使用标准 Base64,不是 RSA-PSS,也不是对原文做 RSA 加密。
  • Timestamp 使用秒级 Unix 时间戳;验签使用收到的时间戳,不要重新生成。Header 名称不区分大小写,但 Base64 签名值区分大小写。
  • 请求 path 是接口路径,不包含协议、域名或查询字符串。回调原文不拼路径、URL 或分隔符。
  • JSON 只序列化一次:签名和发送必须使用同一份 UTF-8 字节。字段顺序不要求预先排序,但空格、转义、换行、数字表示和顺序必须与发送内容完全一致。
  • 无请求体使用 null / nil / None;它与“存在但长度为零的请求体”不同。回调始终对收到的完整 body 求摘要。
  • 私钥支持未加密 PKCS#1、PKCS#8;公钥支持 X.509 SPKI、PKCS#1;支持 PEM、纯 Base64 和转义换行。证书、加密私钥、SSH 格式及非 RSA 密钥不在本示例支持范围内。
  • 商户请求用商户私钥;验签必须使用后台配置的 平台公钥。不能使用商户自己的公钥,也不能信任请求中携带的公钥。不要发送私钥给技术支持;泄露后应更换。

失败处理

签名失败抛出异常或返回 error,调用方必须停止发送,不能用空签名继续请求。验签成功正常返回,失败记录 [payment-verify] FAILED 并抛出异常或返回 error;不得忽略失败后继续入账。

日志包含阶段、字节长度和错误原因,不记录原始请求体、完整签名或密钥。生产环境不要打开 HTTP 正文日志;不要把内部诊断错误原样返回给调用方,并对失败请求和日志做限流。

HTTPS 必须校验证书与主机名。网络超时不能证明订单创建失败,不能盲目自动重新下单;应按商户订单号查询或人工核查。

回调接入

先限制请求体大小,读取原始字节,读取 Timestamp、Signature,验签成功后再解析 JSON。检查订单归属、金额、币种和允许的状态变化,通过事务做持久化幂等处理。只有完成可靠处理后才按协议返回 SUCCESS。重复通知不得重复产生资金变动。

验签函数只负责密码学校验,不等于防重放。时间窗口应结合平台重试规则设置;重放检测、持久化幂等、回调请求限流仍由接入方实现。不要因固定短时间窗口而拒绝正常重试。

同步响应不能当作回调

API 同步响应从 JSON 的 timestamp、sign 字段取值,验签对象是 data 的平台序列化字节,不是整个响应体;回调则从 Header 取值,验签对象是完整 HTTP body。

不要把 data 解析后重新序列化再验签:大整数、1.00、转义或字段顺序可能改变。使用能保留 JSON 原始值文本的解析器提取 data(例如 Go json.RawMessage、C# JsonElement.GetRawText()),并把这些 UTF-8 字节传给示例的 verifyPlatform / VerifyPlatform / verify_platform。data 是字符串时保留 JSON 引号与转义,是 null 时保留字面量 null;不要自行添加换行或空格。缺少签名、字段异常或验签失败必须拒绝作为可信业务结果。

接口路径

同一请求函数可用于以下路径,参数以相应币种文档为准:

方法路径用途
POST/v1/merchant/pay代收下单
POST/v1/merchant/pay/order/info代收查单
POST/v1/merchant/payout代付下单
POST/v1/merchant/payout/order/info代付查单
GET/v1/merchant/account余额查询,无请求体

签名与验签完整代码

sign.cjs

const crypto = require('node:crypto');

function fail(message) { throw new Error(message); }
function timestamp(value) {
  const text = String(value);
  if (!/^[1-9][0-9]{0,18}$/.test(text) || BigInt(text) > 9223372036854775807n) fail('invalid Timestamp: use Unix seconds');
  return text;
}
function base64(text, label) {
  if (typeof text !== 'string' || !text || text.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) fail(`${label}: invalid Base64`);
  const bytes = Buffer.from(text, 'base64');
  if (bytes.toString('base64') !== text) fail(`${label}: non-canonical Base64`);
  return bytes;
}
function key(input, privateKey) {
  if (typeof input !== 'string' || input.length > 16384) fail('key: missing or too large');
  let text = input.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').trim();
  const labels = privateKey ? ['PRIVATE KEY', 'RSA PRIVATE KEY'] : ['PUBLIC KEY', 'RSA PUBLIC KEY'];
  if (text.startsWith('-----BEGIN')) {
    const match = /^-----BEGIN ([A-Z ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----$/.exec(text);
    if (!match || !labels.includes(match[1])) fail('key: expected one unencrypted RSA PEM block of the correct kind');
    text = match[2];
  }
  const der = base64(text.replace(/\s/g, ''), 'key');
  for (const type of privateKey ? ['pkcs8', 'pkcs1'] : ['spki', 'pkcs1']) {
    try {
      const result = privateKey ? crypto.createPrivateKey({ key: der, format: 'der', type })
        : crypto.createPublicKey({ key: der, format: 'der', type });
      if (result.asymmetricKeyType !== 'rsa') fail('key: not RSA');
      // createPublicKey can otherwise derive a public key from private-key input.
      if (!privateKey && !result.export({ format: 'der', type }).equals(der)) continue;
      return result;
    } catch { /* Try the other documented DER container. */ }
  }
  fail(`key: invalid/truncated or non-RSA ${privateKey ? 'private' : 'public'} key`);
}
function checked(operation, body, action) {
  try { return action(); }
  catch (error) {
    console.error(`[payment-${operation}] FAILED body_bytes=${Buffer.isBuffer(body) ? body.length : 0} error=${JSON.stringify(error.message)}`);
    throw error;
  }
}
function hash(body) {
  if (!Buffer.isBuffer(body)) fail('body: expected raw Buffer, not a parsed JSON object');
  return crypto.createHash('sha512').update(body).digest('hex');
}

// null means no request body. Buffer.alloc(0) means a present, empty body.
function signRequest(path, body, ts, merchantPrivateKey) {
  return checked('sign', body, () => {
    if (typeof path !== 'string' || !path.startsWith('/')) fail('path: expected API path');
    const origin = path + (body === null ? '' : hash(body)) + timestamp(ts);
    return crypto.sign('sha512', Buffer.from(origin, 'utf8'), {
      key: key(merchantPrivateKey, true), padding: crypto.constants.RSA_PKCS1_PADDING,
    }).toString('base64');
  });
}

// Callbacks: exact HTTP body + header Timestamp/Signature, with the PLATFORM public key.
// Signed API envelopes: pass the exact serialized data bytes, not the whole envelope.
function verifyPlatform(body, ts, signature, platformPublicKey) {
  return checked('verify', body, () => {
    const origin = hash(body) + timestamp(ts); // NO path or URL.
    const publicKey = key(platformPublicKey, false);
    const sig = base64(signature, 'Signature');
    const expected = Math.ceil(publicKey.asymmetricKeyDetails.modulusLength / 8);
    if (sig.length !== expected) fail(`Signature length: got ${sig.length}, expected ${expected} bytes`);
    if (!crypto.verify('sha512', Buffer.from(origin, 'utf8'), {
      key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING,
    }, sig)) fail('RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula');
  });
}
module.exports = { signRequest, verifyPlatform };

HTTP 请求工具

http.cjs

const { signRequest } = require('./sign.cjs');

// Node.js 18+. Serialize once before calling; rawBody is Buffer or null.
async function send(baseUrl, path, merchantCode, rawBody, merchantPrivateKey) {
  if (!baseUrl.startsWith('https://')) throw new Error('HTTPS is required');
  const timestamp = String(Math.floor(Date.now() / 1000));
  const signature = signRequest(path, rawBody, timestamp, merchantPrivateKey);
  const response = await fetch(baseUrl + path, {
    method: rawBody === null ? 'GET' : 'POST',
    body: rawBody,
    headers: { 'Content-Type': 'application/json', 'Merchant-Code': merchantCode,
      Timestamp: timestamp, Signature: signature },
    redirect: 'error', signal: AbortSignal.timeout(60000),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}; reconcile before retrying`);
  const chunks = [];
  let size = 0;
  for await (const chunk of response.body) {
    size += chunk.length;
    if (size > 8 * 1024 * 1024) throw new Error('Response exceeds 8 MiB');
    chunks.push(chunk);
  }
  return Buffer.concat(chunks); // Preserve raw response for envelope verification.
}
module.exports = { send };

接入示例

以下片段放在接入方应用中使用,payload、可信配置和事务业务处理函数由接入方提供;示例不会自动发起真实下单。

const { signRequest, verifyPlatform } = require('./sign.cjs');
const { send } = require('./http.cjs');
const body = Buffer.from(JSON.stringify(payload), 'utf8');
const responseBytes = await send(baseUrl, '/v1/merchant/pay', merchantCode, body, merchantPrivateKey);
// GET: await send(baseUrl, '/v1/merchant/account', merchantCode, null, merchantPrivateKey);
// Verify the signed response data before using it. Do not treat HTTP 200 as payment success.
// Express: register this raw-body route BEFORE any express.json() middleware.
app.post('/callback', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res, next) => {
  try {
    verifyPlatform(req.body, req.get('Timestamp'), req.get('Signature'), configuredPlatformPublicKey);
  } catch { return res.status(401).send('invalid signature'); }
  try {
    const event = JSON.parse(req.body.toString('utf8'));
    await validateAndApplyIdempotently(event); // Your durable business transaction.
    res.status(200).send('SUCCESS');
  } catch (error) { next(error); }
});
最近更新: 2026/9/22 14:41
Contributors: aa, aaa