Authentication签名认证
Request headers, HMAC-SHA256 signing, the test vector and IP whitelisting.请求头、HMAC-SHA256 签名、测试向量与 IP 白名单。
Headers请求头
| Header请求头 | Required必填 | Description说明 |
|---|---|---|
X-Merchant-Code | required必填 | Your merchantCode. On Wallet API calls it tells you which of our integrations is calling.商户编码 merchantCode。钱包接口里用来区分是平台哪一套接入在调用。 |
X-Request-Id | required必填 | Unique id of this HTTP request, 1–64 printable ASCII characters. A retry uses a new id. Quote it when you contact support.本次 HTTP 请求的唯一编号,1–64 个可见 ASCII 字符。重试也要换新的。找客服排查时提供它。 |
X-Timestamp | required必填 | Send time, Unix epoch milliseconds, e.g.发送时刻,Unix 毫秒时间戳,如 1792000000000 |
X-Signature | required必填 | HMAC-SHA256 signature,HMAC-SHA256 签名,见 签名 |
X-Api-Version | optional可选 | Protocol version, 1. Absent means 1.协议版本,当前 1;不带视为 1。 |
X-Test-Run | optional可选 | Only on Wallet API calls made by our conformance test (API conformance test). Not signed. You may tag such traffic, but you must process it exactly like real traffic.只出现在平台对接测试(接口对接测试)发出的钱包请求里,不参与签名。商户可以据此给测试流量打标,但处理逻辑必须和正式请求完全一样。 |
Signing签名
The same algorithm (HMAC-SHA256) is used in both directions. The signature covers the method, path, merchant code, timestamp, request id and the exact body bytes.
两个方向用同一个算法(HMAC-SHA256)。签名覆盖请求方法、路径、商户编码、时间戳、请求编号和正文的原始字节。
stringToSign = METHOD + "\n" + PATH + "\n" + X-Merchant-Code + "\n" + X-Timestamp + "\n" + X-Request-Id + "\n" + hex(SHA256(rawBody))
X-Signature = hex(HMAC_SHA256(secretKey, stringToSign)) // lowercase hex, 64 chars
| Part部分 | Value取值 |
|---|---|
METHOD | Uppercase: POST or GET.大写:POST 或 GET。 |
PATH | The request path without host and query string, exactly as sent (case-sensitive). Platform API: /merchant/v1/games/launch. Wallet API: the path part of callbackUrl plus the action, e.g. /live/debit.请求路径,不含域名和查询串,与实际发送的完全一致(区分大小写)。平台接口如 /merchant/v1/games/launch;钱包接口是 callbackUrl 的路径部分加动作,如 /live/debit。 |
X-Merchant-Code · X-Timestamp · X-Request-Id | Byte-for-byte the values sent in those headers.与请求头里的值逐字节相同。 |
rawBody | The exact bytes of the request body. For GET or an empty body, SHA-256 of the empty string:请求正文的原始字节。GET 或空正文取空串的 SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
|now − X-Timestamp| > 300 seconds with TIMESTAMP_EXPIRED. Keep server clocks synchronised (NTP).|当前时间 − X-Timestamp| > 300 秒 的请求拒绝,返回 TIMESTAMP_EXPIRED。服务器要用 NTP 对时。We additionally reject a repeated X-Request-Id from the same merchant within 11 minutes (DUPLICATE_REQUEST; the ±300 s window plus one minute). Doing the same on your Wallet API is recommended but not required — wallet idempotency relies on txnId.
平台还会拒绝同一商户 11 分钟内重复的 X-Request-Id(DUPLICATE_REQUEST;±300 秒窗口再加 1 分钟)。商户钱包接口建议也这样做,但不强制——钱包的幂等靠 txnId。
Test vector测试向量
secretKey = test-secret-key-0001
METHOD = POST
PATH = /wallet/debit
X-Merchant-Code = M0001
X-Timestamp = 1792000000000
X-Request-Id = req-0001
rawBody = {"txnId":"T7K2M9QXH4ZP8WNA","amount":"10.00"} (45 bytes, no trailing newline)
hex(SHA256(rawBody)) = b8d0525520debc2d5e2c62e83d2303a4cd29ed0c18c4139e98196c76c14fc2b8
X-Signature = e5273ee9fb4c05408af2f8b32b26b4b98bc891fb77e5ffe5b46f9c112ebe6589
# bash + openssl
sign() { # secretKey METHOD path merchantCode timestamp requestId body
local hash; hash=$(printf '%s' "$7" | openssl dgst -sha256 | awk '{print $NF}')
printf '%s\n%s\n%s\n%s\n%s\n%s' "$2" "$3" "$4" "$5" "$6" "$hash" \
| openssl dgst -sha256 -hmac "$1" | awk '{print $NF}'
}import hashlib, hmac
def sign(secret_key, method, path, merchant_code, timestamp, request_id, raw_body: bytes) -> str:
body_hash = hashlib.sha256(raw_body).hexdigest()
s = "\n".join([method, path, merchant_code, timestamp, request_id, body_hash])
return hmac.new(secret_key.encode(), s.encode(), hashlib.sha256).hexdigest()
# verify: hmac.compare_digest(sign(...), received_signature)const crypto = require('crypto');
function sign(secretKey, method, path, merchantCode, timestamp, requestId, rawBody) {
const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
const s = [method, path, merchantCode, timestamp, requestId, bodyHash].join('\n');
return crypto.createHmac('sha256', secretKey).update(s).digest('hex');
}
// verify: crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) (equal lengths first)func Sign(secretKey, method, path, merchantCode, timestamp, requestID string, rawBody []byte) string {
bodyHash := sha256.Sum256(rawBody)
s := strings.Join([]string{method, path, merchantCode, timestamp, requestID, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(s))
return hex.EncodeToString(mac.Sum(nil))
}
// verify: hmac.Equal([]byte(Sign(...)), []byte(received))static String sign(String secretKey, String method, String path, String merchantCode,
String timestamp, String requestId, byte[] rawBody) throws Exception {
String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(rawBody));
String s = String.join("\n", method, path, merchantCode, timestamp, requestId, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(s.getBytes(StandardCharsets.UTF_8))); // Java 17+
}
// verify: MessageDigest.isEqual(expected.getBytes(), received.getBytes())function sign(string $secretKey, string $method, string $path, string $merchantCode,
string $timestamp, string $requestId, string $rawBody): string {
$s = implode("\n", [$method, $path, $merchantCode, $timestamp, $requestId, hash('sha256', $rawBody)]);
return hash_hmac('sha256', $s, $secretKey);
}
// verify: hash_equals(sign(...), $receivedSignature)static string Sign(string secretKey, string method, string path, string merchantCode,
string timestamp, string requestId, byte[] rawBody)
{
var bodyHash = Convert.ToHexString(SHA256.HashData(rawBody)).ToLowerInvariant();
var s = string.Join("\n", method, path, merchantCode, timestamp, requestId, bodyHash);
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
return Convert.ToHexString(mac.ComputeHash(Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
}
// verify: CryptographicOperations.FixedTimeEquals(expectedBytes, receivedBytes)require 'openssl'
def sign(secret_key, method, path, merchant_code, timestamp, request_id, raw_body)
body_hash = OpenSSL::Digest::SHA256.hexdigest(raw_body)
s = [method, path, merchant_code, timestamp, request_id, body_hash].join("\n")
OpenSSL::HMAC.hexdigest('SHA256', secret_key, s)
endEvery sample reproduces the test vector above (checked by running them). Complete files with verification: Signing toolkits.每段示例都能算出上面的测试向量(实跑核过)。带验签的完整文件见 签名工具包。
IP whitelistIP 白名单
Both directions can be restricted by source IP. The whitelist is a second barrier; the signature (Signing) is the first.
两个方向都可以按来源 IP 限制。白名单是第二道门,第一道是签名(签名)。
| Direction方向 | Whitelist白名单 | How the source IP is determined来源 IP 怎么认 |
|---|---|---|
| You → our Platform API商户 → 平台接口 | Your server IPs / CIDRs, configured by us. Empty = not restricted.商户服务器的 IP / 网段,由平台配置。为空 = 不限制。 | From the TCP connection at our edge. We ignore any IP header you send (X-Forwarded-For, X-Real-IP, True-Client-IP, CF-Connecting-IP, Forwarded …); setting them has no effect. Call us directly — not through a proxy or CDN whose address is not whitelisted.只看连到平台入口的 TCP 连接地址。请求里自带的任何 IP 头一律忽略(X-Forwarded-For、X-Real-IP、True-Client-IP、CF-Connecting-IP、Forwarded 等),写了也没用。请直接调用,不要经过地址不在白名单里的代理或 CDN。 |
| We → your Wallet API平台 → 商户钱包接口 | Our egress IPs (we give you the full list; all of them are in use).平台出口 IP(平台给完整列表,每个都会用到)。 | Your decision — see the rule below.由商户实现,规则见下。 |
X-Forwarded-For only if the connection comes from your own load balancer, and then take the address your load balancer appended (the right-most entry), never the left-most one — the left-most part is written by the client and can be forged.
商户钱包接口的白名单必须按 TCP 连接的来源地址判断。只有连接来自商户自己的负载均衡时才读 X-Forwarded-For,而且取的是自己负载均衡追加的那一段(最右边),绝不取最左边——最左边是客户端自己写的,可以伪造。secretKey in a browser, mobile app, log, ticket or e-mail. If it leaks, ask us to rotate it.secretKey 不许出现在浏览器、App、日志、工单、邮件里。泄露了请立即联系平台更换。Test cases (every Platform API endpoint)测试用例(各平台接口通用)
The cases below apply to every signed endpoint and are sent to games/list. The platform checks each request in a fixed order (see Platform API overview), so each case yields exactly the result code shown. The platform runs the same cases, with the same IDs, before every release.
以下用例适用于所有需签名的接口,统一以 games/list 为载体发送。平台按固定顺序检查每个请求(见 平台接口概览),因此每条用例得到的结果码是确定的。平台每次发布前以相同的编号执行同一套用例。
A-01 Normal正常 Correct signature签名正确 Expected期望 OKA-02 Rejection拒绝 Wrong signature签名错误 Expected期望 INVALID_SIGNATURE ×2A-03 Rejection拒绝 Body changed after signing签名后改动正文 Expected期望 INVALID_SIGNATUREA-04 Rejection拒绝 Missing signing header缺少签名请求头 Expected期望 INVALID_SIGNATURE ×4A-05 Rejection拒绝 Unknown merchant code商户编码不存在 Expected期望 INVALID_SIGNATUREA-06 Boundary边界 Timestamp 6 minutes in the past时间戳早于当前 6 分钟 Expected期望 TIMESTAMP_EXPIREDA-07 Rejection拒绝 Repeated X-Request-IdX-Request-Id 重复 Expected期望 OK → DUPLICATE_REQUESTA-08 Configuration配置 Source IP not whitelisted来源 IP 不在白名单 Expected期望 IP_NOT_ALLOWEDA-09 Configuration配置 Forged forwarding header伪造转发头 Expected期望 IP_NOT_ALLOWEDA-10 Boundary边界 Body of 1 MiB + 1 byte正文 1 MiB + 1 字节 Expected期望 HTTP 413A-11 Boundary边界 Body of exactly 1 MiB正文恰好 1 MiB Expected期望 OKA-12 Boundary边界 Timestamp 295 seconds in the past时间戳早于当前 295 秒 Expected期望 OKA-13 Boundary边界 Timestamp 295 seconds in the future时间戳晚于当前 295 秒 Expected期望 OKA-14 Boundary边界 Timestamp 6 minutes in the future时间戳晚于当前 6 分钟 Expected期望 TIMESTAMP_EXPIREDA-15 Rejection拒绝 Malformed timestamp时间戳格式不合法 Expected期望 TIMESTAMP_EXPIRED ×2A-16 Boundary边界 X-Request-Id of 64 charactersX-Request-Id 长 64 个字符 Expected期望 OKA-17 Boundary边界 X-Request-Id of 65 charactersX-Request-Id 长 65 个字符 Expected期望 INVALID_SIGNATUREA-18 Rejection拒绝 X-Request-Id with a spaceX-Request-Id 含空格 Expected期望 INVALID_SIGNATUREA-19 Rejection拒绝 Malformed merchant code商户编码格式不合法 Expected期望 INVALID_SIGNATURE ×2A-20 Rejection拒绝 Merchant code in a different case商户编码大小写不一致 Expected期望 INVALID_SIGNATUREA-21 Normal正常 Signature in upper-case hex签名使用大写十六进制 Expected期望 OKA-22 Rejection拒绝 Signed for another path按其他路径签名 Expected期望 INVALID_SIGNATUREA-23 Rejection拒绝 Signed with another merchant's key使用其他商户的密钥签名 Expected期望 INVALID_SIGNATUREA-24 Configuration配置 Merchant disabled商户已停用 Expected期望 MERCHANT_DISABLEDA-25 Configuration配置 Maintenance on signed endpoints维护期间调用业务接口 Expected期望 MAINTENANCEA-26 Rejection拒绝 Empty body正文为空 Expected期望 INVALID_PARAMYour Wallet API is checked the other way round by the conformance test: group SIG in API conformance test.
反方向(商户钱包验平台的签名)由对接测试检查:见 接口对接测试 的 SIG 组。