跳到主要内容

鉴权机制

DEEPayment Merchant API 对每个请求使用 Ed25519 签名(RFC 9421 HTTP Message Signatures)鉴权。所有 POST 请求体还会用平台 X25519 公钥做 libsodium sealed box 加密。不使用令牌、共享密钥或 HMAC。

商户自行生成 Ed25519 密钥对,只把公钥交给平台。DEEPayment 不接收、不保存商户私钥。签名、摘要和 body 加密由平台提供的 SDK 完成,本页描述 SDK 实现的线路协议。

密钥配置​

开户后,在商户后台和自己的系统中配置以下四项。生成商户密钥对、转换成 SDK 私钥格式的命令见密钥配置。

Access Key

公开的商户标识,放入 Merchant-Access-Key。

商户 Ed25519 私钥

商户自己生成,用于签名请求。只把对应公钥上传到商户后台。见 密钥配置。

平台 X25519 body 公钥

商户后台展示公钥和 keyId,用于加密所有 POST body。

平台 webhook Ed25519 公钥

商户后台展示,用于校验 webhook 签名。见 代收 webhook。

密钥按环境隔离,生产密钥不要在测试环境使用。

鉴权摘要​

签名
对 RFC 9421 签名基串做 Ed25519 签名;label 固定 merchant,alg="ed25519"。
时间窗口
created、expires 使用 Unix 秒,expires - created 不超过 300 秒,过期签名被拒绝。
防重放
nonce 是 UUID v4,每次 HTTP 请求重新生成,重复使用会被拒绝。
body 加密
POST body 是 sealed box envelope,Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305;GET 不带 body。
body 完整性
Content-Digest: sha-256=:base64:(RFC 9530),对实际发送的 body 字节(加密后的 envelope)计算。

请求类型​

受保护接口只有两种请求形态。

类型HTTPbodyquery用途
写POSTsealed box envelope,Content-Type: application/json禁止创建、取消、确认、更新
读GET禁止只允许公开定位字段查询订单、余额、汇率、回单

不使用 PUT、PATCH、DELETE。orderNo、merchantOrderNo、币种、支付方式、分页和时间范围可以放 query;证件、账号、手机号、邮箱、卡字段和 extra 不能放 query。

请求 Header​

Header写(POST)读(GET)值
Merchant-Access-Key必传,参与签名必传,参与签名商户 Access Key
Content-Type必传,参与签名不传application/json
Content-Encryption必传,参与签名不传sealedbox-v1-x25519-xsalsa20poly1305
Content-Digest必传,参与签名不传sha-256=:<base64(SHA-256(wire body))>:
Idempotency-Key必传,参与签名不传UUID v4,每次请求可以不同;只用于链路追踪,不参与去重
Signature-Input必传必传签名覆盖字段和参数,label 固定 merchant
Signature必传必传merchant=:<base64(Ed25519 签名)>:

Content-Encoding 不传或为 identity,不要发送压缩 body。

Idempotency-Key 不参与去重。平台只按 merchantOrderNo 去重,这个头仅用于链路追踪,每次尝试可以取不同的值。nonce 是每次 HTTP 请求的防重放键,每次请求(包括重试)都必须重新生成。

Signature-Input​

Signature-Input 是一行 header,列出签名覆盖字段并携带四个固定参数。

写请求:

Signature-Input: merchant=("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"

读请求:

Signature-Input: merchant=("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
created

签名时刻,Unix 秒。

expires

过期时刻,Unix 秒,最大 created + 300。

nonce

UUID v4,每次 HTTP 请求重新生成。

alg

固定 ed25519。

服务端只接受这两组覆盖字段。增删或调换顺序都会鉴权失败。不使用 keyid 参数,商户公钥由 Merchant-Access-Key 定位。

签名基串​

按 Signature-Input 中的顺序,每个覆盖字段一行,最后一行是 "@signature-params",值为去掉 merchant= label 的 Signature-Input 值。各行用 \n 拼接,末尾不追加换行。

写请求:

"@method": POST
"@path": /api/v1/payments
"content-type": application/json
"content-encryption": sealedbox-v1-x25519-xsalsa20poly1305
"content-digest": sha-256=:UlEd3zTsYBmWqEb8EjQ/zFweyyH2SzENXUPpiLdKoew=:
"idempotency-key": 018fb9b4-95f3-4a47-8f08-27466f7d4c1d
"merchant-access-key": mak_live_test
"@signature-params": ("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"

读请求:

"@method": GET
"@path": /api/v1/payments
"@query": ?orderNo=P202608270001
"merchant-access-key": mak_live_test
"@signature-params": ("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
  • @method 是大写 HTTP 方法。
  • @path 是 URL path,例如 /api/v1/payments。
  • @query 是 ? 加实际发送的原始 query 串,不要重新排序、重新编码或丢弃空值;URL 没有 query 时值为 ?。
  • header 类字段取实际发送的 header 原值。

然后:

signature = Ed25519.Sign(merchantPrivateKey, signatureBase)
Signature: merchant=:base64(signature):

body 加密​

POST 请求先把业务 JSON 序列化,用平台 X25519 公钥做 sealed box 加密,再把下面的 envelope 作为 HTTP body 发送:

{
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": "body_20260827_01",
"ciphertext": "base64(sealed box 输出)"
}
version

固定 1。

alg

固定 sealedbox-v1-x25519-xsalsa20poly1305,必须和 Content-Encryption 相同。

keyId

商户后台展示的平台 body key id,用于选择解密私钥。

ciphertext

sealed box 输出(libsodium crypto_box_seal)的标准 base64。

  • sealed box 输出已包含临时公钥和认证 tag,不额外传 nonce、iv、tag。
  • envelope 只允许这四个字段,出现未知字段或尾随内容会被拒绝。
  • Content-Digest 对实际发送的 envelope 字节计算,不是对明文计算。
  • 明文业务 JSON 最大 1 MiB,wire body 最大 2 MiB。
  • 密文是非确定性的。重试同一笔创建时 envelope 和摘要都会变化;让它仍是同一笔创建的是不变的 merchantOrderNo,不是这个头。

merchantOrderNo、amount、paymentMethod 等业务字段按 API Reference 的定义放在明文中。

示例​

写请求:

POST /api/v1/payments HTTP/1.1
Content-Type: application/json
Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305
Content-Digest: sha-256=:UlEd3zTsYBmWqEb8EjQ/zFweyyH2SzENXUPpiLdKoew=:
Idempotency-Key: 018fb9b4-95f3-4a47-8f08-27466f7d4c1d
Merchant-Access-Key: mak_live_test
Signature-Input: merchant=("@method" "@path" "content-type" "content-encryption" "content-digest" "idempotency-key" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Signature: merchant=:FUfY/vgI3YaMjCD1IGYHDIe2yXHkEiyM8ALs5/nYlfR5LKwWL/vJRQybGM9OOV89g3vUjWyQo0tM5AKUmeA9CQ==:

{"version":1,"alg":"sealedbox-v1-x25519-xsalsa20poly1305","keyId":"body_20260827_01","ciphertext":"..."}

读请求:

GET /api/v1/payments?orderNo=P202608270001 HTTP/1.1
Merchant-Access-Key: mak_live_test
Signature-Input: merchant=("@method" "@path" "@query" "merchant-access-key");created=1787803200;expires=1787803500;nonce="b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a";alg="ed25519"
Signature: merchant=:H1IK1NGk7htlA/O3JZhndbDGkl/gB/a3Hw4wtlNGXoG+mHYvtSGUWBUs0fJOc0MpNrJ5KYGqnohDDXd9jAxYCg==:

以上签名由测试私钥 seed ERERERERERERERERERERERERERERERERERERERERERE=(base64)生成,对应公钥 0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc=,仅用于自测实现。

代码示例​

下面的示例不依赖 SDK,直接实现请求签名和 body 加密。每种语言都提供 signWrite(POST)和 signRead(GET)两个入口,只依赖语言标准库和一个 libsodium 绑定(Go 用 golang.org/x/crypto/nacl/box,Java 用 lazysodium-java,Ruby 用 rbnacl)。

示例中的密钥是协议测试向量密钥。用 created=1787803200、nonce b7754a6c-4a9c-4cf0-b77f-6f2d4b7e5f5a 调用 signRead("/api/v1/payments", "orderNo=P202608270001"),得到的 Signature 必须与上文读请求示例一致。先用这个方法验证移植结果,再换成生产密钥。

package main

import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"golang.org/x/crypto/nacl/box"
)

// Credentials from the merchant portal.
const (
accessKey = "mak_live_test"
merchantPrivateKeyBase64 = "ERERERERERERERERERERERERERERERERERERERERERE=" // Ed25519 seed (32 bytes) or full key (64 bytes)
platformBodyKeyID = "body_test_1"
platformBodyPublicKeyB64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=" // platform X25519 public key
)

var writeCovered = []string{"@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key"}
var readCovered = []string{"@method", "@path", "@query", "merchant-access-key"}

// Keys are decoded once at startup, not per request.
var (
merchantPrivateKey = loadPrivateKey(merchantPrivateKeyBase64)
platformBodyPublicKey = mustDecodeKey32(platformBodyPublicKeyB64)
)

func loadPrivateKey(b64 string) ed25519.PrivateKey {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
panic(err)
}
if len(raw) == ed25519.SeedSize {
return ed25519.NewKeyFromSeed(raw)
}
return ed25519.PrivateKey(raw)
}

func mustDecodeKey32(b64 string) *[32]byte {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(raw) != 32 {
panic("invalid 32-byte key")
}
var key [32]byte
copy(key[:], raw)
return &key
}

// uuidV4 returns a lowercase random UUID v4 (used for nonce and Idempotency-Key).
func uuidV4() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

// contentDigest returns the RFC 9530 Content-Digest header value for the wire body.
func contentDigest(body []byte) string {
sum := sha256.Sum256(body)
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}

// sealBody encrypts the business JSON to the platform X25519 public key (libsodium sealed box).
func sealBody(plaintext []byte) []byte {
ciphertext, err := box.SealAnonymous(nil, plaintext, platformBodyPublicKey, rand.Reader)
if err != nil {
panic(err)
}
envelope, _ := json.Marshal(map[string]any{
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": platformBodyKeyID,
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
})
return envelope
}

// signatureParams builds the value after "merchant=" in Signature-Input.
func signatureParams(covered []string, created int64, nonce string) string {
quoted := make([]string, len(covered))
for i, c := range covered {
quoted[i] = strconv.Quote(c)
}
return "(" + strings.Join(quoted, " ") + ")" +
";created=" + strconv.FormatInt(created, 10) +
";expires=" + strconv.FormatInt(created+300, 10) +
";nonce=" + strconv.Quote(nonce) +
";alg=\"ed25519\""
}

// signatureBase joins one line per covered component plus the @signature-params line.
// Derived components (@method, @path, @query) come from derived; header components come from headers.
func signatureBase(covered []string, derived, headers map[string]string, params string) []byte {
lines := make([]string, 0, len(covered)+1)
for _, c := range covered {
value, ok := derived[c]
if !ok {
value = headers[http.CanonicalHeaderKey(c)]
}
lines = append(lines, strconv.Quote(c)+": "+value)
}
lines = append(lines, `"@signature-params": `+params)
return []byte(strings.Join(lines, "\n"))
}

func sign(base []byte) string {
sig := ed25519.Sign(merchantPrivateKey, base)
return "merchant=:" + base64.StdEncoding.EncodeToString(sig) + ":"
}

// SignWrite returns the wire body and headers for POST {path} with the given business JSON.
func SignWrite(path string, businessJSON []byte, created int64, nonce, idempotencyKey string) ([]byte, map[string]string) {
body := sealBody(businessJSON)
headers := map[string]string{
"Content-Type": "application/json",
"Content-Encryption": "sealedbox-v1-x25519-xsalsa20poly1305",
"Content-Digest": contentDigest(body),
"Idempotency-Key": idempotencyKey,
"Merchant-Access-Key": accessKey,
}
params := signatureParams(writeCovered, created, nonce)
base := signatureBase(writeCovered, map[string]string{"@method": "POST", "@path": path}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return body, headers
}

// SignRead returns the headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed.
func SignRead(path, rawQuery string, created int64, nonce string) map[string]string {
params := signatureParams(readCovered, created, nonce)
headers := map[string]string{"Merchant-Access-Key": accessKey}
base := signatureBase(readCovered, map[string]string{"@method": "GET", "@path": path, "@query": "?" + rawQuery}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return headers
}

func main() {
now := time.Now().Unix()
body, headers := SignWrite("/api/v1/payments", []byte(`{"merchantOrderNo":"M202605060001","currency":"BRL","amount":"250.00","paymentMethod":{"code":"PIX"},"webhookUrl":"https://merchant.example/webhook/payment"}`), now, uuidV4(), uuidV4())
fmt.Println(string(body))
fmt.Println(headers)
fmt.Println(SignRead("/api/v1/payments", "merchantOrderNo=M202605060001", now, uuidV4()))
}

返回的 body 字节要原样发送。签名之后再对 envelope 重新序列化会导致 Content-Digest 和签名失效。

服务端校验顺序​

  1. 请求形状:方法、必传和禁止的 header、header 单值。
  2. 对 wire body 重算 Content-Digest。
  3. 解析 Signature-Input,校验 alg、nonce、时间窗口和覆盖字段。
  4. 用商户公钥对重建的签名基串校验 Ed25519 签名。
  5. 解析并解密 envelope,明文作为业务请求继续处理。

任一步失败返回 HTTP 401,msg 为 UNAUTHORIZED,不进入业务处理。

实现检查​

对 wire body 签名

Content-Digest 和签名都基于实际发送的加密 envelope 字节。

Query 原样

@query 是 ? 加原始 query,不要排序或重新编码。

时间同步

服务器时钟保持准确,超过 300 秒的签名会被拒绝。

私钥保护

Ed25519 私钥只保存在商户服务端,不写日志、不下发客户端、不发送给 DEEPayment。