鉴权机制
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 加密
POSTbody 是 sealed box envelope,Content-Encryption: sealedbox-v1-x25519-xsalsa20poly1305;GET不带 body。- body 完整性
Content-Digest: sha-256=:base64:(RFC 9530),对实际发送的 body 字节(加密后的 envelope)计算。
请求类型
受保护接口只有两种请求形态。
| 类型 | HTTP | body | query | 用途 |
|---|---|---|---|---|
| 写 | POST | sealed 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。
nonceUUID 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,用于选择解密私钥。
ciphertextsealed 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 必须与上文读请求示例一致。先用这个方法验证移植结果,再换成生产密钥。
- Go
- JavaScript
- Python
- PHP
- Java
- Ruby
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()))
}
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const sodium = createRequire(import.meta.url)('libsodium-wrappers');
// Credentials from the merchant portal.
const accessKey = 'mak_live_test';
const merchantPrivateKeyBase64 = 'ERERERERERERERERERERERERERERERERERERERERERE='; // Ed25519 seed (32 bytes) or full key (64 bytes)
const platformBodyKeyId = 'body_test_1';
const platformBodyPublicKeyBase64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ='; // platform X25519 public key
const WRITE_COVERED = ['@method', '@path', 'content-type', 'content-encryption', 'content-digest', 'idempotency-key', 'merchant-access-key'];
const READ_COVERED = ['@method', '@path', '@query', 'merchant-access-key'];
await sodium.ready;
// Keys are decoded once at startup, not per request.
const merchantPrivateKey = (() => {
const raw = Buffer.from(merchantPrivateKeyBase64, 'base64');
return raw.length === 32 ? sodium.crypto_sign_seed_keypair(raw).privateKey : new Uint8Array(raw);
})();
const platformBodyPublicKey = Buffer.from(platformBodyPublicKeyBase64, 'base64');
// RFC 9530 Content-Digest of the wire body.
export function contentDigest(body) {
return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
}
// Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
export function sealBody(plaintext) {
const ciphertext = sodium.crypto_box_seal(Buffer.from(plaintext, 'utf8'), platformBodyPublicKey);
return Buffer.from(JSON.stringify({
version: 1,
alg: 'sealedbox-v1-x25519-xsalsa20poly1305',
keyId: platformBodyKeyId,
ciphertext: Buffer.from(ciphertext).toString('base64'),
}), 'utf8');
}
// Value after "merchant=" in Signature-Input.
function signatureParams(covered, created, nonce) {
return `(${covered.map((c) => JSON.stringify(c)).join(' ')})`
+ `;created=${created};expires=${created + 300};nonce=${JSON.stringify(nonce)};alg="ed25519"`;
}
// One line per covered component plus the @signature-params line, joined by \n.
// Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
function signatureBase(covered, derived, headers, params) {
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
const lines = covered.map((c) => `${JSON.stringify(c)}: ${derived[c] ?? lower[c]}`);
lines.push(`"@signature-params": ${params}`);
return Buffer.from(lines.join('\n'), 'utf8');
}
function sign(base) {
const sig = sodium.crypto_sign_detached(base, merchantPrivateKey);
return `merchant=:${Buffer.from(sig).toString('base64')}:`;
}
// Wire body and headers for POST {path} with the given business JSON string.
export function signWrite(path, businessJson, created = Math.floor(Date.now() / 1000), nonce = randomUUID(), idempotencyKey = randomUUID()) {
const body = sealBody(businessJson);
const headers = {
'Content-Type': 'application/json',
'Content-Encryption': 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest': contentDigest(body),
'Idempotency-Key': idempotencyKey,
'Merchant-Access-Key': accessKey,
};
const params = signatureParams(WRITE_COVERED, created, nonce);
const base = signatureBase(WRITE_COVERED, { '@method': 'POST', '@path': path }, headers, params);
headers['Signature-Input'] = `merchant=${params}`;
headers['Signature'] = sign(base);
return { body, headers };
}
// Headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed.
export function signRead(path, rawQuery, created = Math.floor(Date.now() / 1000), nonce = randomUUID()) {
const params = signatureParams(READ_COVERED, created, nonce);
const headers = { 'Merchant-Access-Key': accessKey };
const base = signatureBase(READ_COVERED, { '@method': 'GET', '@path': path, '@query': `?${rawQuery}` }, headers, params);
headers['Signature-Input'] = `merchant=${params}`;
headers.Signature = sign(base);
return headers;
}
// Usage
const { body, headers } = signWrite('/api/v1/payments', JSON.stringify({ merchantOrderNo: 'M202605060001', currency: 'BRL', amount: '250.00', paymentMethod: { code: 'PIX' }, webhookUrl: 'https://merchant.example/webhook/payment' }));
// await fetch('https://panama.deepayment.com/api/v1/payments', { method: 'POST', headers, body });
import base64
import hashlib
import json
import time
import uuid
from nacl.public import PublicKey, SealedBox
from nacl.signing import SigningKey
# Credentials from the merchant portal.
ACCESS_KEY = "mak_live_test"
MERCHANT_PRIVATE_KEY_B64 = "ERERERERERERERERERERERERERERERERERERERERERE=" # Ed25519 seed (32 bytes) or full key (64 bytes)
PLATFORM_BODY_KEY_ID = "body_test_1"
PLATFORM_BODY_PUBLIC_KEY_B64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=" # platform X25519 public key
WRITE_COVERED = ("@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key")
READ_COVERED = ("@method", "@path", "@query", "merchant-access-key")
# Keys are decoded once at import, not per request.
SIGNING_KEY = SigningKey(base64.b64decode(MERCHANT_PRIVATE_KEY_B64)[:32]) # PyNaCl takes the 32-byte seed
SEALED_BOX = SealedBox(PublicKey(base64.b64decode(PLATFORM_BODY_PUBLIC_KEY_B64)))
def content_digest(body: bytes) -> str:
"""RFC 9530 Content-Digest of the wire body."""
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
def seal_body(plaintext: bytes) -> bytes:
"""Encrypt the business JSON to the platform X25519 public key (libsodium sealed box)."""
envelope = {
"version": 1,
"alg": "sealedbox-v1-x25519-xsalsa20poly1305",
"keyId": PLATFORM_BODY_KEY_ID,
"ciphertext": base64.b64encode(SEALED_BOX.encrypt(plaintext)).decode(),
}
return json.dumps(envelope, separators=(",", ":")).encode()
def signature_params(covered, created: int, nonce: str) -> str:
"""Value after 'merchant=' in Signature-Input."""
components = " ".join(json.dumps(c) for c in covered)
return f"({components});created={created};expires={created + 300};nonce={json.dumps(nonce)};alg=\"ed25519\""
def signature_base(covered, derived: dict, headers: dict, params: str) -> bytes:
"""One line per covered component plus the @signature-params line, joined by \\n.
Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
"""
lower = {k.lower(): v for k, v in headers.items()}
lines = [f"{json.dumps(c)}: {derived.get(c, lower.get(c))}" for c in covered]
lines.append(f'"@signature-params": {params}')
return "\n".join(lines).encode()
def sign(base: bytes) -> str:
sig = SIGNING_KEY.sign(base).signature
return "merchant=:" + base64.b64encode(sig).decode() + ":"
def sign_write(path: str, business_json: bytes, created: int | None = None, nonce: str | None = None, idempotency_key: str | None = None):
"""Wire body and headers for POST {path} with the given business JSON bytes."""
created = created or int(time.time())
nonce = nonce or str(uuid.uuid4())
body = seal_body(business_json)
headers = {
"Content-Type": "application/json",
"Content-Encryption": "sealedbox-v1-x25519-xsalsa20poly1305",
"Content-Digest": content_digest(body),
"Idempotency-Key": idempotency_key or str(uuid.uuid4()),
"Merchant-Access-Key": ACCESS_KEY,
}
params = signature_params(WRITE_COVERED, created, nonce)
base = signature_base(WRITE_COVERED, {"@method": "POST", "@path": path}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return body, headers
def sign_read(path: str, raw_query: str, created: int | None = None, nonce: str | None = None) -> dict:
"""Headers for GET {path}?{raw_query}. raw_query must be sent exactly as signed."""
created = created or int(time.time())
nonce = nonce or str(uuid.uuid4())
params = signature_params(READ_COVERED, created, nonce)
headers = {"Merchant-Access-Key": ACCESS_KEY}
base = signature_base(READ_COVERED, {"@method": "GET", "@path": path, "@query": "?" + raw_query}, headers, params)
headers["Signature-Input"] = "merchant=" + params
headers["Signature"] = sign(base)
return headers
if __name__ == "__main__":
body, headers = sign_write("/api/v1/payments", json.dumps({"merchantOrderNo": "M202605060001", "currency": "BRL", "amount": "250.00", "paymentMethod": {"code": "PIX"}, "webhookUrl": "https://merchant.example/webhook/payment"}).encode())
# requests.post("https://panama.deepayment.com/api/v1/payments", data=body, headers=headers)
<?php
// Requires the sodium extension (bundled with PHP 7.2+).
// Credentials from the merchant portal.
const ACCESS_KEY = 'mak_live_test';
const MERCHANT_PRIVATE_KEY_B64 = 'ERERERERERERERERERERERERERERERERERERERERERE='; // Ed25519 seed (32 bytes) or full key (64 bytes)
const PLATFORM_BODY_KEY_ID = 'body_test_1';
const PLATFORM_BODY_PUBLIC_KEY_B64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ='; // platform X25519 public key
const WRITE_COVERED = ['@method', '@path', 'content-type', 'content-encryption', 'content-digest', 'idempotency-key', 'merchant-access-key'];
const READ_COVERED = ['@method', '@path', '@query', 'merchant-access-key'];
// Decoded once per process, not per request.
function merchantSecretKey(): string
{
static $secretKey = null;
if ($secretKey === null) {
$raw = base64_decode(MERCHANT_PRIVATE_KEY_B64, true);
$secretKey = strlen($raw) === SODIUM_CRYPTO_SIGN_SEEDBYTES
? sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($raw))
: $raw;
}
return $secretKey;
}
function uuidV4(): string
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
// RFC 9530 Content-Digest of the wire body.
function contentDigest(string $body): string
{
return 'sha-256=:' . base64_encode(hash('sha256', $body, true)) . ':';
}
// Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
function sealBody(string $plaintext): string
{
$ciphertext = sodium_crypto_box_seal($plaintext, base64_decode(PLATFORM_BODY_PUBLIC_KEY_B64, true));
return json_encode([
'version' => 1,
'alg' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'keyId' => PLATFORM_BODY_KEY_ID,
'ciphertext' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
}
// Value after "merchant=" in Signature-Input.
function signatureParams(array $covered, int $created, string $nonce): string
{
$components = implode(' ', array_map(fn($c) => json_encode($c), $covered));
return "({$components});created={$created};expires=" . ($created + 300) . ';nonce=' . json_encode($nonce) . ';alg="ed25519"';
}
// One line per covered component plus the @signature-params line, joined by "\n".
// Derived components (@method, @path, @query) come from $derived; header components are looked up in $headers.
function signatureBase(array $covered, array $derived, array $headers, string $params): string
{
$lower = array_change_key_case($headers, CASE_LOWER);
$lines = array_map(fn($c) => json_encode($c) . ': ' . ($derived[$c] ?? $lower[$c]), $covered);
$lines[] = '"@signature-params": ' . $params;
return implode("\n", $lines);
}
function sign(string $base): string
{
return 'merchant=:' . base64_encode(sodium_crypto_sign_detached($base, merchantSecretKey())) . ':';
}
// Wire body and headers for POST {$path} with the given business JSON string.
function signWrite(string $path, string $businessJson, ?int $created = null, ?string $nonce = null, ?string $idempotencyKey = null): array
{
$created ??= time();
$nonce ??= uuidV4();
$body = sealBody($businessJson);
$headers = [
'Content-Type' => 'application/json',
'Content-Encryption' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest' => contentDigest($body),
'Idempotency-Key' => $idempotencyKey ?? uuidV4(),
'Merchant-Access-Key' => ACCESS_KEY,
];
$params = signatureParams(WRITE_COVERED, $created, $nonce);
$base = signatureBase(WRITE_COVERED, ['@method' => 'POST', '@path' => $path], $headers, $params);
$headers['Signature-Input'] = 'merchant=' . $params;
$headers['Signature'] = sign($base);
return [$body, $headers];
}
// Headers for GET {$path}?{$rawQuery}. $rawQuery must be sent exactly as signed.
function signRead(string $path, string $rawQuery, ?int $created = null, ?string $nonce = null): array
{
$created ??= time();
$nonce ??= uuidV4();
$params = signatureParams(READ_COVERED, $created, $nonce);
$headers = ['Merchant-Access-Key' => ACCESS_KEY];
$base = signatureBase(READ_COVERED, ['@method' => 'GET', '@path' => $path, '@query' => '?' . $rawQuery], $headers, $params);
$headers['Signature-Input'] = 'merchant=' . $params;
$headers['Signature'] = sign($base);
return $headers;
}
// Usage
[$body, $headers] = signWrite('/api/v1/payments', json_encode([
'merchantOrderNo' => 'M202605060001', 'currency' => 'BRL', 'amount' => '250.00',
'paymentMethod' => ['code' => 'PIX'], 'webhookUrl' => 'https://merchant.example/webhook/payment',
], JSON_UNESCAPED_SLASHES));
// Send $body with $headers via curl to https://panama.deepayment.com/api/v1/payments
// Dependency: com.goterl:lazysodium-java (libsodium binding). Base64/UUID/SHA-256 come from the JDK.
import com.goterl.lazysodium.LazySodiumJava;
import com.goterl.lazysodium.SodiumJava;
import com.goterl.lazysodium.interfaces.Box;
import com.goterl.lazysodium.interfaces.Sign;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
public final class MerchantSigner {
// Credentials from the merchant portal.
static final String ACCESS_KEY = "mak_live_test";
static final String MERCHANT_PRIVATE_KEY_B64 = "ERERERERERERERERERERERERERERERERERERERERERE="; // Ed25519 seed (32 bytes) or full key (64 bytes)
static final String PLATFORM_BODY_KEY_ID = "body_test_1";
static final String PLATFORM_BODY_PUBLIC_KEY_B64 = "ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ="; // platform X25519 public key
static final List<String> WRITE_COVERED = List.of("@method", "@path", "content-type", "content-encryption", "content-digest", "idempotency-key", "merchant-access-key");
static final List<String> READ_COVERED = List.of("@method", "@path", "@query", "merchant-access-key");
static final LazySodiumJava SODIUM = new LazySodiumJava(new SodiumJava());
static final Base64.Encoder B64 = Base64.getEncoder();
// Keys are decoded once at class load, not per request.
static final byte[] MERCHANT_SECRET_KEY = merchantSecretKey();
static final byte[] PLATFORM_BODY_PUBLIC_KEY = Base64.getDecoder().decode(PLATFORM_BODY_PUBLIC_KEY_B64);
static byte[] merchantSecretKey() {
byte[] raw = Base64.getDecoder().decode(MERCHANT_PRIVATE_KEY_B64);
if (raw.length == Sign.SEEDBYTES) {
byte[] pk = new byte[Sign.PUBLICKEYBYTES];
byte[] sk = new byte[Sign.SECRETKEYBYTES];
SODIUM.cryptoSignSeedKeypair(pk, sk, raw);
return sk;
}
return raw;
}
/** RFC 9530 Content-Digest of the wire body. */
static String contentDigest(byte[] body) throws Exception {
return "sha-256=:" + B64.encodeToString(MessageDigest.getInstance("SHA-256").digest(body)) + ":";
}
/** Encrypt the business JSON to the platform X25519 public key (libsodium sealed box). */
static byte[] sealBody(byte[] plaintext) {
byte[] ciphertext = new byte[plaintext.length + Box.SEALBYTES];
if (!SODIUM.cryptoBoxSeal(ciphertext, plaintext, plaintext.length, PLATFORM_BODY_PUBLIC_KEY)) {
throw new IllegalStateException("seal failed");
}
String envelope = "{\"version\":1,\"alg\":\"sealedbox-v1-x25519-xsalsa20poly1305\",\"keyId\":\""
+ PLATFORM_BODY_KEY_ID + "\",\"ciphertext\":\"" + B64.encodeToString(ciphertext) + "\"}";
return envelope.getBytes(StandardCharsets.UTF_8);
}
static String quote(String value) { return "\"" + value + "\""; } // protocol values contain no characters that need escaping
/** Value after "merchant=" in Signature-Input. */
static String signatureParams(List<String> covered, long created, String nonce) {
StringJoiner components = new StringJoiner(" ", "(", ")");
covered.forEach(c -> components.add(quote(c)));
return components + ";created=" + created + ";expires=" + (created + 300) + ";nonce=" + quote(nonce) + ";alg=\"ed25519\"";
}
/**
* One line per covered component plus the @signature-params line, joined by \n.
* Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
*/
static byte[] signatureBase(List<String> covered, Map<String, String> derived, Map<String, String> headers, String params) {
Map<String, String> lower = new HashMap<>();
headers.forEach((k, v) -> lower.put(k.toLowerCase(Locale.ROOT), v));
StringJoiner lines = new StringJoiner("\n");
covered.forEach(c -> lines.add(quote(c) + ": " + derived.getOrDefault(c, lower.get(c))));
lines.add("\"@signature-params\": " + params);
return lines.toString().getBytes(StandardCharsets.UTF_8);
}
static String sign(byte[] base) {
byte[] sig = new byte[Sign.BYTES];
SODIUM.cryptoSignDetached(sig, base, base.length, MERCHANT_SECRET_KEY);
return "merchant=:" + B64.encodeToString(sig) + ":";
}
/** Wire body and headers for POST {path} with the given business JSON. */
static Map.Entry<byte[], Map<String, String>> signWrite(String path, byte[] businessJson) throws Exception {
long created = System.currentTimeMillis() / 1000;
byte[] body = sealBody(businessJson);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", "application/json");
headers.put("Content-Encryption", "sealedbox-v1-x25519-xsalsa20poly1305");
headers.put("Content-Digest", contentDigest(body));
headers.put("Idempotency-Key", UUID.randomUUID().toString());
headers.put("Merchant-Access-Key", ACCESS_KEY);
String params = signatureParams(WRITE_COVERED, created, UUID.randomUUID().toString());
byte[] base = signatureBase(WRITE_COVERED, Map.of("@method", "POST", "@path", path), headers, params);
headers.put("Signature-Input", "merchant=" + params);
headers.put("Signature", sign(base));
return Map.entry(body, headers);
}
/** Headers for GET {path}?{rawQuery}. rawQuery must be sent exactly as signed. */
static Map<String, String> signRead(String path, String rawQuery) {
long created = System.currentTimeMillis() / 1000;
String params = signatureParams(READ_COVERED, created, UUID.randomUUID().toString());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Merchant-Access-Key", ACCESS_KEY);
byte[] base = signatureBase(READ_COVERED, Map.of("@method", "GET", "@path", path, "@query", "?" + rawQuery), headers, params);
headers.put("Signature-Input", "merchant=" + params);
headers.put("Signature", sign(base));
return headers;
}
}
# Requires the rbnacl gem (libsodium binding): gem install rbnacl
require 'rbnacl'
require 'base64'
require 'digest'
require 'json'
require 'securerandom'
# Credentials from the merchant portal.
ACCESS_KEY = 'mak_live_test'
MERCHANT_PRIVATE_KEY_B64 = 'ERERERERERERERERERERERERERERERERERERERERERE=' # Ed25519 seed (32 bytes) or full key (64 bytes)
PLATFORM_BODY_KEY_ID = 'body_test_1'
PLATFORM_BODY_PUBLIC_KEY_B64 = 'ew1H2TQn+DERYHgcfHM/2J+IlwrvSQ2KoO4ZpMuKGxQ=' # platform X25519 public key
WRITE_COVERED = %w[@method @path content-type content-encryption content-digest idempotency-key merchant-access-key].freeze
READ_COVERED = %w[@method @path @query merchant-access-key].freeze
# Keys are decoded once at load, not per request.
SIGNING_KEY = RbNaCl::SigningKey.new(Base64.strict_decode64(MERCHANT_PRIVATE_KEY_B64)[0, 32]) # rbnacl takes the 32-byte seed
SEALED_BOX = RbNaCl::SealedBox.new(RbNaCl::PublicKey.new(Base64.strict_decode64(PLATFORM_BODY_PUBLIC_KEY_B64)))
# RFC 9530 Content-Digest of the wire body.
def content_digest(body)
"sha-256=:#{Base64.strict_encode64(Digest::SHA256.digest(body))}:"
end
# Encrypt the business JSON to the platform X25519 public key (libsodium sealed box).
def seal_body(plaintext)
JSON.generate(
version: 1,
alg: 'sealedbox-v1-x25519-xsalsa20poly1305',
keyId: PLATFORM_BODY_KEY_ID,
ciphertext: Base64.strict_encode64(SEALED_BOX.encrypt(plaintext))
)
end
# Value after "merchant=" in Signature-Input.
def signature_params(covered, created, nonce)
components = covered.map { |c| c.to_json }.join(' ')
"(#{components});created=#{created};expires=#{created + 300};nonce=#{nonce.to_json};alg=\"ed25519\""
end
# One line per covered component plus the @signature-params line, joined by "\n".
# Derived components (@method, @path, @query) come from derived; header components are looked up in headers.
def signature_base(covered, derived, headers, params)
lower = headers.to_h { |k, v| [k.downcase, v] }
lines = covered.map { |c| "#{c.to_json}: #{derived.fetch(c) { lower[c] }}" }
lines << "\"@signature-params\": #{params}"
lines.join("\n")
end
def sign(base)
"merchant=:#{Base64.strict_encode64(SIGNING_KEY.sign(base))}:"
end
# Wire body and headers for POST {path} with the given business JSON string.
def sign_write(path, business_json, created: Time.now.to_i, nonce: SecureRandom.uuid, idempotency_key: SecureRandom.uuid)
body = seal_body(business_json)
headers = {
'Content-Type' => 'application/json',
'Content-Encryption' => 'sealedbox-v1-x25519-xsalsa20poly1305',
'Content-Digest' => content_digest(body),
'Idempotency-Key' => idempotency_key,
'Merchant-Access-Key' => ACCESS_KEY
}
params = signature_params(WRITE_COVERED, created, nonce)
base = signature_base(WRITE_COVERED, { '@method' => 'POST', '@path' => path }, headers, params)
headers['Signature-Input'] = "merchant=#{params}"
headers['Signature'] = sign(base)
[body, headers]
end
# Headers for GET {path}?{raw_query}. raw_query must be sent exactly as signed.
def sign_read(path, raw_query, created: Time.now.to_i, nonce: SecureRandom.uuid)
params = signature_params(READ_COVERED, created, nonce)
headers = { 'Merchant-Access-Key' => ACCESS_KEY }
base = signature_base(READ_COVERED, { '@method' => 'GET', '@path' => path, '@query' => "?#{raw_query}" }, headers, params)
headers['Signature-Input'] = "merchant=#{params}"
headers['Signature'] = sign(base)
headers
end
# Usage
body, headers = sign_write('/api/v1/payments', JSON.generate(
merchantOrderNo: 'M202605060001', currency: 'BRL', amount: '250.00',
paymentMethod: { code: 'PIX' }, webhookUrl: 'https://merchant.example/webhook/payment'
))
# Net::HTTP.post(URI('https://panama.deepayment.com/api/v1/payments'), body, headers)
返回的 body 字节要原样发送。签名之后再对 envelope 重新序列化会导致 Content-Digest 和签名失效。
服务端校验顺序
- 请求形状:方法、必传和禁止的 header、header 单值。
- 对 wire body 重算
Content-Digest。 - 解析
Signature-Input,校验alg、nonce、时间窗口和覆盖字段。 - 用商户公钥对重建的签名基串校验 Ed25519 签名。
- 解析并解密 envelope,明文作为业务请求继续处理。
任一步失败返回 HTTP 401,msg 为 UNAUTHORIZED,不进入业务处理。
实现检查
对 wire body 签名
Content-Digest 和签名都基于实际发送的加密 envelope 字节。
Query 原样
@query 是 ? 加原始 query,不要排序或重新编码。
时间同步
服务器时钟保持准确,超过 300 秒的签名会被拒绝。
私钥保护
Ed25519 私钥只保存在商户服务端,不写日志、不下发客户端、不发送给 DEEPayment。