Skip to content

请求签名 ​

ADOPAY 开放网关接口(包括商户 API 和合作商 API 的账户、收款、代付等接口)使用 ES256(ECDSA P-256)请求签名校验请求的完整性与真实性。接入方在开通时生成 P-256 密钥对:公钥上传 Adopay 用于服务端验签,私钥自行保管并对每笔请求加签。平台返回的响应与 Webhook 通知也使用同一套规则加签,接入方可用平台公钥验签(见第 5 节)。

1. 签名请求头 ​

调用网关开放接口时,以下请求头全部必填,缺一即被拒绝:

请求头说明
X-Merchant-Id商户号(一级商户号)。服务端用它定位该商户的验签公钥配置。
X-TimestampUnix 秒级时间戳,纯数字(不要传毫秒)。服务端校验与当前时间的偏差,默认允许 ±300 秒。
X-Nonce防重放随机串,建议 UUID v4 或 32 位以上随机十六进制串。同一商户号下在有效期内(默认 300 秒)不可重复。
Digest请求体摘要,格式 SHA-256=<Base64(SHA-256(原始body字节))>。GET 等无 body 请求对空字节串计算,此时值恒为 SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=。
AuthorizationES256 签名信息,固定格式见第 4 节。其中 keyId 为在 Adopay 登记的密钥版本号,未做过密钥轮换时固定为 v1。

2. 密钥要求 ​

项目要求
算法ECDSA P-256(ES256)
私钥接入方自行生成并保管,支持 SEC1(EC PRIVATE KEY)或 PKCS8(PRIVATE KEY)格式 PEM
公钥上传 Adopay用于验签,格式为 PUBLIC KEY PEM
密钥版本通过 Authorization 中的 keyId 标识,用于密钥轮换(见第 8 节)

私钥属于敏感凭证,只能在服务端使用,不得写入客户端代码、日志或公开仓库。

3. 构造签名串 ​

签名串(canonical string)是本次请求待签名内容的唯一确定文本,由四行按固定顺序组成,使用 \n 连接,末尾没有换行符:

text
(request-target): <小写HTTP方法> <路径>[?<原始query>]
x-timestamp: <时间戳>
x-nonce: <nonce>
digest: <Digest请求头的值>

构造分两步:

第一步:计算请求体摘要(Digest)。 对实际发送的原始请求体字节计算 SHA-256,再做标准 Base64 编码,得到 SHA-256=<Base64>。GET、DELETE 等无 body 请求对空字节串计算,此时值恒为 SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=。

第二步:拼接四行签名串。 规则:

  1. (request-target) 行:HTTP 方法转为小写(如 post),路径以 / 开头,使用实际发送的原始路径;带 query 时必须使用实际发送的原始 query 字符串(不重新排序、不重新编码),与最终 URL 逐字节一致;无 query 时省略 ? 及之后内容。
  2. x-timestamp、x-nonce 两行的值与 X-Timestamp、X-Nonce 请求头完全一致。
  3. digest 行使用完整的 SHA-256=<Base64> 值。

签名后不得再格式化或重新序列化请求体(例如不得对 JSON 重新排序键、重新压缩、改变转义),否则服务端按原始字节重算摘要必然不一致。

示例:

text
(request-target): post /cashin/pix/create-qrcode
x-timestamp: 1790200000
x-nonce: 550e8400-e29b-41d4-a716-446655440000
digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=

带 query 的 GET 示例(无 body,对空字节串计算摘要):

text
(request-target): get /cashin/order/query?merchantOrderNo=20260819000123&platOrderNo=3KITxA1dEXhZ2IRXvwck
x-timestamp: 1790200001
x-nonce: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
digest: SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=

4. 生成签名值 ​

用接入方 P-256 私钥对第 3 节构造的签名串完成签名,并将结果组装进 Authorization 请求头,分三步:

第一步:私钥签名。 使用 ES256 算法(对签名串的 SHA-256 哈希做 ECDSA 签名),输出 ASN.1/DER 编码的签名值。

第二步:Base64 编码。 对 DER 签名做标准 Base64 编码(含 + / =,不是 Base64URL)。

第三步:组装 Authorization。 按固定格式:

text
Signature keyId="<密钥版本>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<Base64签名>"
  • keyId:在 Adopay 登记的密钥版本号,默认 v1;服务端按 X-Merchant-Id + keyId 精确定位公钥。
  • alg 必须为 ES256,headers 必须精确为 (request-target) x-timestamp x-nonce digest;服务端会逐字比对这两个字段,防止签名降级或漏签字段。
  • 各参数之间用英文逗号分隔、不加空格,值用双引号包裹。

组装完成后,与第 1 节的其余请求头一并发送。完整代码见第 7 节。

5. 响应与 Webhook 验签 ​

Adopay 返回给调用方的所有响应(包括验签失败、业务失败的响应)以及 Webhook 通知,都使用与请求相同的规则加签。接入方应使用平台公钥验签,确认响应与通知确实来自 Adopay 且内容未被篡改。

获取平台公钥 ​

平台公钥随开户材料提供,为 PUBLIC KEY PEM 格式;Sandbox 与生产各自独立。平台签名通过 Authorization 中的 keyId(Webhook 另有 X-Secret-Key-Version 头)标识密钥版本,Adopay 轮换签名密钥时以新版本号标识,接入方应按 keyId 选择对应的平台公钥,不要假定平台只有一个版本。

响应头 ​

响应头说明
X-Merchant-Id平台签名主体标识(此处不是商户号)。
X-Timestamp / X-Nonce本次响应的时间戳与随机串(非请求的原值)。
Digest响应体摘要,基于响应体原始字节计算。
Authorization平台 ES256 签名。
Authorization.keyId平台签名密钥标识,用于平台密钥轮换。

构造验签串 ​

与第 3 节的签名串规则一致,但取值来源不同:

  1. (request-target) 行复用本次请求的小写方法、路径和原始 query(不是响应状态行)。
  2. x-timestamp、x-nonce、digest 三行使用响应头中的值,digest 对响应体原始字节计算。

验证流程 ​

  1. 用响应体原始字节重新计算 SHA-256,与响应 Digest 头逐字比对;不要先反序列化 JSON 再重新序列化。
  2. 校验 X-Timestamp 在允许时间窗内、X-Nonce 未重复出现(防重放)。
  3. 解析响应 Authorization 中的 signature,按 keyId 定位平台公钥,按上一小节规则重建验签串后做 ES256 验签(DER 签名,标准 Base64 解码)。

任一步失败都不要信任该响应,应记录日志排查。常见原因:HTTP 客户端读取响应体时重新编码(改变了原始字节)、系统时间偏差超出时间窗。验签实现可直接参考第 7 节各语言示例中的签名串构造逻辑,将取值替换为响应头中的对应字段即可。

Webhook 通知验签 ​

Webhook 由 Adopay 主动投递,验签串中的 (request-target) 行使用本次通知自身的小写方法(post)、回调路径与原始 query,其余规则与响应验签相同。完整步骤与 Python 验签示例见 Webhook 规范第 4 节。

另外,网关通信层 HTTP 状态码恒为 200,业务成败以响应体中的业务字段为准(见响应状态码)。

6. 服务端校验流程 ​

服务端收到请求后按以下顺序校验,任一环节失败即拒绝请求(业务 status 返回非成功码,典型为 1003 验签失败或 1004 参数校验失败):

  1. 必填头检查:X-Merchant-Id、X-Timestamp、X-Nonce、Digest、Authorization 五个头全部存在。
  2. Nonce 防重放:同一商户号 + X-Nonce 在有效期内(默认 300 秒)首次出现才放行,重复出现直接拒绝。
  3. 时间戳校验:X-Timestamp 为合法 Unix 秒级时间戳,且与当前时间偏差不超过 ±300 秒。
  4. Digest 校验:服务端读取原始请求体字节重算 SHA-256,与 Digest 头逐字比对。
  5. Authorization 解析与算法校验:解析 keyId / alg / headers / signature 四个参数,alg 必须为 ES256,headers 必须精确为 (request-target) x-timestamp x-nonce digest。
  6. 公钥定位:按 X-Merchant-Id + keyId 查找在 Adopay 登记的商户公钥。
  7. 签名验证:按第 3 节规则重建签名串,用商户公钥做 ES256 验签(DER 签名,标准 Base64 解码)。

理解该校验顺序有助于排查:例如 Digest 不匹配时问题一定出在请求体字节(重新序列化、编码、代理改写),与私钥无关。

7. 各语言接入示例 ​

点击标签切换语言。每个示例的函数按调用顺序排列:入口 buildSignedHeaders 组装第 1 节的全部必填请求头(body 为最终发送的原始字节),内部调用 signRequest 生成 digest 与 authorization,再由摘要与签名串辅助函数完成底层计算。

go
// Go 1.13+,仅标准库;SEC1(EC PRIVATE KEY)与 PKCS8(PRIVATE KEY)私钥均支持。
import (
	"crypto/ecdsa"
	"crypto/rand"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/hex"
	"encoding/pem"
	"fmt"
	"strings"
	"time"
)

// buildSignedHeaders 组装一笔请求所需的全部签名头(body 为最终发送的原始字节)。
func buildSignedHeaders(merchantID string, body, privateKeyPEM []byte) (map[string]string, error) {
	timestamp := fmt.Sprintf("%d", time.Now().Unix()) // Unix 秒级时间戳
	nonceBuf := make([]byte, 16)
	if _, err := rand.Read(nonceBuf); err != nil {
		return nil, err
	}
	nonce := hex.EncodeToString(nonceBuf) // 32 位随机十六进制串

	digest, authorization, err := signRequest(
		"POST", "/cashin/pix/create-qrcode", "", timestamp, nonce, body, "v1", privateKeyPEM)
	if err != nil {
		return nil, err
	}
	return map[string]string{
		"X-Merchant-Id": merchantID,
		"X-Timestamp":   timestamp,
		"X-Nonce":       nonce,
		"Digest":        digest,
		"Authorization": authorization,
	}, nil
}

// signRequest 使用商户 P-256 私钥生成 Digest 与 Authorization。
// 私钥支持 SEC1(EC PRIVATE KEY)和 PKCS8(PRIVATE KEY)两种 PEM 格式。
func signRequest(method, path, rawQuery, timestamp, nonce string, body []byte,
	keyID string, privateKeyPEM []byte) (string, string, error) {

	digest := buildDigest(body)
	canonical := buildCanonicalString(method, path, rawQuery, timestamp, nonce, digest)

	block, _ := pem.Decode(privateKeyPEM)
	if block == nil {
		return "", "", fmt.Errorf("invalid private key pem")
	}
	var key *ecdsa.PrivateKey
	if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
		key = k // SEC1
	} else {
		parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) // PKCS8
		if err != nil {
			return "", "", err
		}
		k, ok := parsed.(*ecdsa.PrivateKey)
		if !ok {
			return "", "", fmt.Errorf("private key is not EC P-256")
		}
		key = k
	}

	hash := sha256.Sum256([]byte(canonical))
	signature, err := ecdsa.SignASN1(rand.Reader, key, hash[:]) // 输出 DER 编码
	if err != nil {
		return "", "", err
	}
	authorization := fmt.Sprintf(
		`Signature keyId="%s",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="%s"`,
		keyID, base64.StdEncoding.EncodeToString(signature))
	return digest, authorization, nil
}

// buildCanonicalString 按固定顺序拼接四行签名串(末尾无换行符)。
func buildCanonicalString(method, path, rawQuery, timestamp, nonce, digest string) string {
	requestTarget := strings.ToLower(method) + " " + path
	if rawQuery != "" {
		requestTarget += "?" + strings.TrimPrefix(rawQuery, "?")
	}
	return strings.Join([]string{
		"(request-target): " + requestTarget,
		"x-timestamp: " + timestamp,
		"x-nonce: " + nonce,
		"digest: " + digest,
	}, "\n")
}

// buildDigest 按原始 body 字节计算 Digest 头。
func buildDigest(body []byte) string {
	sum := sha256.Sum256(body)
	return "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
}
java
// Java 8+,仅标准库;私钥必须为 PKCS8 格式。
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class GatewaySigner {

    // 组装一笔请求所需的全部签名头(body 为最终发送的原始字节)
    static Map<String, String> buildSignedHeaders(String merchantId, byte[] body,
                                                  byte[] privateKeyPem) throws Exception {
        String timestamp = String.valueOf(Instant.now().getEpochSecond()); // Unix 秒级时间戳
        String nonce = UUID.randomUUID().toString();

        String[] r = signRequest("POST", "/cashin/pix/create-qrcode", "",
                timestamp, nonce, body, "v1", privateKeyPem);
        Map<String, String> headers = new HashMap<>();
        headers.put("X-Merchant-Id", merchantId);
        headers.put("X-Timestamp", timestamp);
        headers.put("X-Nonce", nonce);
        headers.put("Digest", r[0]);
        headers.put("Authorization", r[1]);
        return headers;
    }

    // 使用商户 P-256 私钥生成 Digest 与 Authorization
    static String[] signRequest(String method, String path, String rawQuery,
                                String timestamp, String nonce, byte[] body,
                                String keyId, byte[] privateKeyPem) throws Exception {
        String digest = buildDigest(body);

        String requestTarget = method.toLowerCase() + " " + path;
        if (rawQuery != null && !rawQuery.isEmpty()) {
            requestTarget += "?" + (rawQuery.startsWith("?") ? rawQuery.substring(1) : rawQuery);
        }
        String canonical = String.join("\n",
                "(request-target): " + requestTarget,
                "x-timestamp: " + timestamp,
                "x-nonce: " + nonce,
                "digest: " + digest);

        String pem = new String(privateKeyPem, StandardCharsets.UTF_8)
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s", "");
        byte[] der = Base64.getDecoder().decode(pem);
        PrivateKey key = KeyFactory.getInstance("EC")
                .generatePrivate(new PKCS8EncodedKeySpec(der));

        Signature signer = Signature.getInstance("SHA256withECDSA"); // 输出 DER 编码
        signer.initSign(key);
        signer.update(canonical.getBytes(StandardCharsets.UTF_8));
        String signature = Base64.getEncoder().encodeToString(signer.sign());

        String authorization = "Signature keyId=\"" + keyId + "\",alg=\"ES256\","
                + "headers=\"(request-target) x-timestamp x-nonce digest\","
                + "signature=\"" + signature + "\"";
        return new String[]{digest, authorization};
    }

    // 按原始 body 字节计算 Digest 头
    static String buildDigest(byte[] body) throws Exception {
        byte[] hash = MessageDigest.getInstance("SHA-256").digest(body);
        return "SHA-256=" + Base64.getEncoder().encodeToString(hash);
    }
}
js
// Node.js 14+,仅内置 node:crypto;SEC1 与 PKCS8 私钥均支持。
import crypto from "node:crypto";

// 组装一笔请求所需的全部签名头(body 为最终发送的原始字节)
function buildSignedHeaders({ merchantId, body, privateKeyPem }) {
  const timestamp = String(Math.floor(Date.now() / 1000)); // Unix 秒级时间戳
  const nonce = crypto.randomUUID(); // Node 14.17+;低版本可用第三方 UUID 库

  const { digest, authorization } = signRequest({
    method: "POST",
    path: "/cashin/pix/create-qrcode",
    rawQuery: "",
    timestamp,
    nonce,
    body,
    keyId: "v1",
    privateKeyPem,
  });
  return {
    "X-Merchant-Id": merchantId,
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "Digest": digest,
    "Authorization": authorization,
  };
}

// 使用商户 P-256 私钥生成 Digest 与 Authorization
function signRequest({ method, path, rawQuery, timestamp, nonce, body, keyId, privateKeyPem }) {
  const digest = buildDigest(body);

  let requestTarget = `${method.toLowerCase()} ${path}`;
  if (rawQuery) {
    requestTarget += `?${rawQuery.replace(/^\?/, "")}`;
  }
  const canonical = [
    `(request-target): ${requestTarget}`,
    `x-timestamp: ${timestamp}`,
    `x-nonce: ${nonce}`,
    `digest: ${digest}`,
  ].join("\n");

  // ECDSA 输出 DER 编码,Base64 后即 signature
  const signature = crypto
    .createSign("SHA256")
    .update(canonical, "utf8")
    .sign(privateKeyPem, "base64");

  const authorization =
    `Signature keyId="${keyId}",alg="ES256",` +
    `headers="(request-target) x-timestamp x-nonce digest",` +
    `signature="${signature}"`;
  return { digest, authorization };
}

// 按原始 body 字节计算 Digest 头
function buildDigest(body) {
  return "SHA-256=" + crypto.createHash("sha256").update(body).digest("base64");
}
php
<?php
// PHP 7.1+ 且启用 openssl 扩展;SEC1 与 PKCS8 私钥均支持。

// 组装一笔请求所需的全部签名头(body 为最终发送的原始字节)
function buildSignedHeaders(string $merchantId, string $body, string $privateKeyPem): array
{
    $timestamp = (string) time(); // Unix 秒级时间戳
    $nonce = bin2hex(random_bytes(16)); // 32 位随机十六进制串

    [$digest, $authorization] = signRequest(
        'POST', '/cashin/pix/create-qrcode', '', $timestamp, $nonce, $body, 'v1', $privateKeyPem
    );

    return [
        'X-Merchant-Id' => $merchantId,
        'X-Timestamp'   => $timestamp,
        'X-Nonce'       => $nonce,
        'Digest'        => $digest,
        'Authorization' => $authorization,
    ];
}

// 使用商户 P-256 私钥生成 Digest 与 Authorization
function signRequest(
    string $method,
    string $path,
    string $rawQuery,
    string $timestamp,
    string $nonce,
    string $body,
    string $keyId,
    string $privateKeyPem
): array {
    $digest = buildDigest($body);

    $requestTarget = strtolower($method) . ' ' . $path;
    if ($rawQuery !== '') {
        $requestTarget .= '?' . ltrim($rawQuery, '?');
    }
    $canonical = implode("\n", [
        '(request-target): ' . $requestTarget,
        'x-timestamp: ' . $timestamp,
        'x-nonce: ' . $nonce,
        'digest: ' . $digest,
    ]);

    $privateKey = openssl_pkey_get_private($privateKeyPem);
    if ($privateKey === false) {
        throw new RuntimeException('invalid private key: ' . openssl_error_string());
    }
    // openssl_sign 对 EC 私钥输出 DER 编码签名,与网关要求一致
    if (!openssl_sign($canonical, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
        throw new RuntimeException('openssl_sign failed: ' . openssl_error_string());
    }

    $authorization = sprintf(
        'Signature keyId="%s",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="%s"',
        $keyId,
        base64_encode($signature)
    );

    return [$digest, $authorization];
}

// 按原始 body 字节计算 Digest 头
function buildDigest(string $body): string
{
    return 'SHA-256=' . base64_encode(hash('sha256', $body, true));
}
python
# Python 3.7+;需安装 cryptography(pip install cryptography)。
import base64
import hashlib
import time
import uuid

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec


# 组装一笔请求所需的全部签名头(body 为最终发送的原始字节)
def build_signed_headers(merchant_id, body, private_key_pem):
    timestamp = str(int(time.time()))  # Unix 秒级时间戳
    nonce = str(uuid.uuid4())

    digest, authorization = sign_request(
        "POST", "/cashin/pix/create-qrcode", "", timestamp, nonce,
        body, "v1", private_key_pem,
    )
    return {
        "X-Merchant-Id": merchant_id,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "Digest": digest,
        "Authorization": authorization,
    }


# 使用商户 P-256 私钥生成 Digest 与 Authorization
def sign_request(method, path, raw_query, timestamp, nonce, body, key_id, private_key_pem):
    digest = "SHA-256=" + base64.b64encode(hashlib.sha256(body).digest()).decode()

    request_target = method.lower() + " " + path
    if raw_query:
        request_target += "?" + raw_query.lstrip("?")
    canonical = "\n".join([
        f"(request-target): {request_target}",
        f"x-timestamp: {timestamp}",
        f"x-nonce: {nonce}",
        f"digest: {digest}",
    ])

    key = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
    der_sig = key.sign(canonical.encode("utf-8"), ec.ECDSA(hashes.SHA256()))
    signature = base64.b64encode(der_sig).decode()

    authorization = (
        f'Signature keyId="{key_id}",alg="ES256",'
        f'headers="(request-target) x-timestamp x-nonce digest",'
        f'signature="{signature}"'
    )
    return digest, authorization

8. 安全要求与密钥轮换 ​

  • 私钥通过环境变量或密钥管理系统读取,不得写入源码、日志或公开仓库。
  • 使用 HTTPS 传输;每次请求都基于实际请求体重新计算 Digest 和签名,不要复用旧签名。
  • 密钥轮换流程(keyId 即密钥版本号):
    1. 生成新密钥对,将新公钥上传 Adopay并登记为新版本(如 v2);
    2. 服务端改用新私钥签名、keyId 改为 v2,双版本并存期间旧 v1 仍可验签;
    3. 稳定运行后下线旧版本密钥,并妥善销毁旧私钥。
  • 疑似私钥泄露时立即联系 Adopay冻结对应 keyId 并启用新版本。