请求签名
ADOPAY 开放网关接口(包括商户 API 和合作商 API 的账户、收款、代付等接口)使用 ES256(ECDSA P-256)请求签名校验请求的完整性与真实性。接入方在开通时生成 P-256 密钥对:公钥上传 Adopay 用于服务端验签,私钥自行保管并对每笔请求加签。平台返回的响应与 Webhook 通知也使用同一套规则加签,接入方可用平台公钥验签(见第 5 节)。
1. 签名请求头
调用网关开放接口时,以下请求头全部必填,缺一即被拒绝:
| 请求头 | 说明 |
|---|---|
X-Merchant-Id | 商户号(一级商户号)。服务端用它定位该商户的验签公钥配置。 |
X-Timestamp | Unix 秒级时间戳,纯数字(不要传毫秒)。服务端校验与当前时间的偏差,默认允许 ±300 秒。 |
X-Nonce | 防重放随机串,建议 UUID v4 或 32 位以上随机十六进制串。同一商户号下在有效期内(默认 300 秒)不可重复。 |
Digest | 请求体摘要,格式 SHA-256=<Base64(SHA-256(原始body字节))>。GET 等无 body 请求对空字节串计算,此时值恒为 SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=。 |
Authorization | ES256 签名信息,固定格式见第 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 连接,末尾没有换行符:
(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=。
第二步:拼接四行签名串。 规则:
(request-target)行:HTTP 方法转为小写(如post),路径以/开头,使用实际发送的原始路径;带 query 时必须使用实际发送的原始 query 字符串(不重新排序、不重新编码),与最终 URL 逐字节一致;无 query 时省略?及之后内容。x-timestamp、x-nonce两行的值与X-Timestamp、X-Nonce请求头完全一致。digest行使用完整的SHA-256=<Base64>值。
签名后不得再格式化或重新序列化请求体(例如不得对 JSON 重新排序键、重新压缩、改变转义),否则服务端按原始字节重算摘要必然不一致。
示例:
(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,对空字节串计算摘要):
(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。 按固定格式:
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 节的签名串规则一致,但取值来源不同:
(request-target)行复用本次请求的小写方法、路径和原始 query(不是响应状态行)。x-timestamp、x-nonce、digest三行使用响应头中的值,digest对响应体原始字节计算。
验证流程
- 用响应体原始字节重新计算 SHA-256,与响应
Digest头逐字比对;不要先反序列化 JSON 再重新序列化。 - 校验
X-Timestamp在允许时间窗内、X-Nonce未重复出现(防重放)。 - 解析响应
Authorization中的signature,按keyId定位平台公钥,按上一小节规则重建验签串后做 ES256 验签(DER 签名,标准 Base64 解码)。
任一步失败都不要信任该响应,应记录日志排查。常见原因:HTTP 客户端读取响应体时重新编码(改变了原始字节)、系统时间偏差超出时间窗。验签实现可直接参考第 7 节各语言示例中的签名串构造逻辑,将取值替换为响应头中的对应字段即可。
Webhook 通知验签
Webhook 由 Adopay 主动投递,验签串中的 (request-target) 行使用本次通知自身的小写方法(post)、回调路径与原始 query,其余规则与响应验签相同。完整步骤与 Python 验签示例见 Webhook 规范第 4 节。
另外,网关通信层 HTTP 状态码恒为 200,业务成败以响应体中的业务字段为准(见响应状态码)。
6. 服务端校验流程
服务端收到请求后按以下顺序校验,任一环节失败即拒绝请求(业务 status 返回非成功码,典型为 1003 验签失败或 1004 参数校验失败):
- 必填头检查:
X-Merchant-Id、X-Timestamp、X-Nonce、Digest、Authorization五个头全部存在。 - Nonce 防重放:同一商户号 +
X-Nonce在有效期内(默认 300 秒)首次出现才放行,重复出现直接拒绝。 - 时间戳校验:
X-Timestamp为合法 Unix 秒级时间戳,且与当前时间偏差不超过 ±300 秒。 - Digest 校验:服务端读取原始请求体字节重算 SHA-256,与
Digest头逐字比对。 - Authorization 解析与算法校验:解析
keyId/alg/headers/signature四个参数,alg必须为ES256,headers必须精确为(request-target) x-timestamp x-nonce digest。 - 公钥定位:按
X-Merchant-Id+keyId查找在 Adopay 登记的商户公钥。 - 签名验证:按第 3 节规则重建签名串,用商户公钥做 ES256 验签(DER 签名,标准 Base64 解码)。
理解该校验顺序有助于排查:例如 Digest 不匹配时问题一定出在请求体字节(重新序列化、编码、代理改写),与私钥无关。
7. 各语言接入示例
点击标签切换语言。每个示例的函数按调用顺序排列:入口 buildSignedHeaders 组装第 1 节的全部必填请求头(body 为最终发送的原始字节),内部调用 signRequest 生成 digest 与 authorization,再由摘要与签名串辅助函数完成底层计算。
// 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 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);
}
}// 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 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 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, authorization8. 安全要求与密钥轮换
- 私钥通过环境变量或密钥管理系统读取,不得写入源码、日志或公开仓库。
- 使用 HTTPS 传输;每次请求都基于实际请求体重新计算 Digest 和签名,不要复用旧签名。
- 密钥轮换流程(
keyId即密钥版本号):- 生成新密钥对,将新公钥上传 Adopay并登记为新版本(如
v2); - 服务端改用新私钥签名、
keyId改为v2,双版本并存期间旧v1仍可验签; - 稳定运行后下线旧版本密钥,并妥善销毁旧私钥。
- 生成新密钥对,将新公钥上传 Adopay并登记为新版本(如
- 疑似私钥泄露时立即联系 Adopay冻结对应
keyId并启用新版本。