Appearance
认证与签名
所有 /v1 接口(GET /v1/webhooks/event_types 除外)都要求每个请求单独签名。认证方式与 Fireblocks 相同:
http
X-API-Key: <keyId>
Authorization: Bearer <JWT>keyId:在租户后台创建 API Key 时获得(形如ak_0123456789abcdef01234567)。JWT:用你方的 Ed25519 私钥签名的短时效令牌(JWS Compact 格式,算法EdDSA)。平台只保存对应的公钥。
JWT 规则
Header
json
{ "alg": "EdDSA", "typ": "JWT" }alg 必须是 EdDSA;typ 不校验,建议填 JWT。
Claims(Payload),六个字段全部必填:
| Claim | 类型 | 规则 |
|---|---|---|
uri | string | 本次请求的 path + query,与实际发送的完全一致(含 /v1 前缀、已做 URL 编码、参数顺序一致)。例:/v1/transactions?kind=DEPOSIT&limit=50。没有查询参数时不要带 ?。不包含协议、域名和 #片段。 |
nonce | string | 每个请求唯一的随机串,1–64 字节(建议 UUID)。同一个 Key 下重复使用返回 401(nonce reused)。 |
iat | integer | 签发时间,Unix 秒(整数,不能是小数)。不得晚于服务器时间 5 秒以上。 |
exp | integer | 过期时间,Unix 秒。必须晚于服务器当前时间,且 exp - iat ≤ 30。建议 exp = iat + 25。 |
sub | string | 与 X-API-Key 相同的 keyId。 |
bodyHash | string | 原始请求体字节的 SHA-256,十六进制(大小写均可,建议小写)。无请求体(如 GET)时为空字符串的 SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。 |
签名:signature = Ed25519(privateKey, ASCII(base64url(header) + "." + base64url(claims))),JWT = base64url(header).base64url(claims).base64url(signature)。三段都使用 base64url 且不带 = 填充。
关键点
- 签名用的请求体字节必须与发送的字节完全相同:先把 JSON 序列化成字符串/字节,再用这份字节同时计算
bodyHash和作为请求体发送;不要让 HTTP 库再序列化一次。 - 每个请求(包括重试)都要重新生成 JWT:
nonce只能用一次,令牌最长 30 秒有效。 - 服务器时钟是基准:请保持 NTP 同步。
服务端校验顺序
服务端按以下顺序校验,遇到第一个失败即返回:
| # | 检查 | 失败时 |
|---|---|---|
| 1 | 存在 X-API-Key 头 | 401 UNAUTHORIZED authentication required |
| 2 | Authorization 以 Bearer (区分大小写)开头 | 401 UNAUTHORIZED |
| 3 | 请求体 ≤ 256 KiB | 422 BAD_REQUEST body too large |
| 4 | keyId 存在且状态为 ACTIVE(未吊销) | 401 UNAUTHORIZED |
| 5 | 租户状态为 ACTIVE | 403 FORBIDDEN tenant suspended |
| 6 | 若 Key 配置了 IP 白名单:客户端 IP 在白名单内(单个 IP 或 CIDR) | 403 FORBIDDEN ip <ip> not allowed for this api key(无法确定 IP 时为 client ip unknown) |
| 7 | JWT 为三段、base64url 可解码;header alg = EdDSA;签名用该 Key 的公钥验证通过;claims 可解析且六个字段齐全、类型正确 | 401 UNAUTHORIZED |
| 8 | sub = keyId;uri = 服务器收到的 path+query;exp > now;iat ≤ now + 5;exp - iat ≤ 30;bodyHash 匹配;nonce 长度 1–64 | 401 UNAUTHORIZED |
| 9 | 租户订阅未到期(校验签名通过后、进入业务前) | 403 FORBIDDEN tenant subscription expired(所有 /v1 接口一致,无 GET 例外) |
| 10 | nonce 未被该 Key 使用过 | 401 UNAUTHORIZED nonce reused |
| 11 | 该 Key 每秒请求数未超过上限(默认 50,1–1000 可配) | 429 RATE_LIMITED rate limit exceeded |
第 7、8 步的所有失败都只返回 authentication required,不说明具体原因(防止探测),请用下面的 排查表 自查。客户端 IP 取 TCP 对端地址;平台部署在可信反向代理之后时取 X-Forwarded-For 的第一个地址。
C 端自助注册已停用
托管 C 端的自助注册永久停用:POST /app/v1/auth/register/code 与 POST /app/v1/auth/register 不解析请求体,一律返回 403 FORBIDDEN(Self registration is disabled; contact your tenant),空请求体或非法 JSON 也一样,没有开关可重新打开。请由交易所侧建档后通过 POST /v1/users(可选 password、nickname)或租户后台内部开户为用户开通登录;历史 C_DIRECT 账号仍可正常登录。
示例代码
以下示例均可直接运行,并已与上述服务端校验逻辑逐条比对验证。环境变量:
| 变量 | 含义 |
|---|---|
WAAS_BASE_URL | 例如 https://api.fablewallet.top |
WAAS_API_KEY | keyId |
WAAS_PRIVATE_KEY_PEM | 私钥文件路径(PKCS#8 PEM:控制台下载的 .pem,或 openssl genpkey -algorithm ed25519 生成) |
Node.js
Node.js 18+,无第三方依赖。保存为 waas-client.mjs:
js
// WaaS B 端 API 客户端(Node.js 18+,无第三方依赖)
import { createHash, createPrivateKey, randomUUID, sign } from "node:crypto";
import { readFileSync } from "node:fs";
const BASE_URL = process.env.WAAS_BASE_URL ?? "https://api.example.com";
const API_KEY = process.env.WAAS_API_KEY; // keyId,例如 ak_3f9c...
const PRIVATE_KEY = createPrivateKey(readFileSync(process.env.WAAS_PRIVATE_KEY_PEM ?? "waas-api-key.pem"));
const b64u = (buf) => Buffer.from(buf).toString("base64url"); // base64url,无填充
/** 为一次请求生成 JWT。uri = 实际发送的 path + query(含 /v1 前缀);rawBody = 实际发送的请求体字节。 */
export function signJwt(uri, rawBody) {
const now = Math.floor(Date.now() / 1000);
const header = b64u(JSON.stringify({ alg: "EdDSA", typ: "JWT" }));
const claims = b64u(JSON.stringify({
uri,
nonce: randomUUID(), // 每个请求唯一,≤ 64 字符
iat: now,
exp: now + 25, // exp - iat ≤ 30
sub: API_KEY,
bodyHash: createHash("sha256").update(rawBody).digest("hex"), // 空请求体 = sha256("")
}));
const sig = b64u(sign(null, Buffer.from(`${header}.${claims}`), PRIVATE_KEY)); // Ed25519
return `${header}.${claims}.${sig}`;
}
/** 发送已签名请求。path 必须已经做好 URL 编码,例如 `/v1/users/${encodeURIComponent(id)}`。 */
export async function call(method, path, body, extraHeaders = {}) {
const raw = body === undefined ? "" : JSON.stringify(body); // 先序列化一次:签名和发送用同一份字节
const res = await fetch(BASE_URL + path, {
method,
body: raw === "" ? undefined : raw,
headers: {
"X-API-Key": API_KEY,
Authorization: `Bearer ${signJwt(path, raw)}`,
"Content-Type": "application/json",
...extraHeaders,
},
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const e = new Error(`${res.status} ${data?.error?.code}: ${data?.error?.message}`);
Object.assign(e, { status: res.status, error: data?.error }); // error.retryable / error.requestId
throw e;
}
return data;
}
// 用法示例
// const assets = await call("GET", "/v1/supported_assets");
// const addr = await call("POST", `/v1/users/${encodeURIComponent("u-10001")}/deposit_address`, { networkKey: "ethereum" });Python
Python 3.8+,pip install cryptography requests。保存为 waas_client.py:
python
# WaaS B 端 API 客户端(Python 3.8+;pip install cryptography requests)
import base64
import hashlib
import json
import os
import time
import uuid
import requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
BASE_URL = os.environ.get("WAAS_BASE_URL", "https://api.example.com")
API_KEY = os.environ["WAAS_API_KEY"] # keyId
with open(os.environ.get("WAAS_PRIVATE_KEY_PEM", "waas-api-key.pem"), "rb") as f:
PRIVATE_KEY = load_pem_private_key(f.read(), password=None) # Ed25519PrivateKey
def b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode() # base64url,无填充
def sign_jwt(uri: str, raw_body: bytes) -> str:
now = int(time.time())
header = b64u(json.dumps({"alg": "EdDSA", "typ": "JWT"}, separators=(",", ":")).encode())
claims = b64u(json.dumps({
"uri": uri, # path + query,含 /v1 前缀
"nonce": str(uuid.uuid4()), # 每个请求唯一
"iat": now,
"exp": now + 25, # exp - iat <= 30
"sub": API_KEY,
"bodyHash": hashlib.sha256(raw_body).hexdigest(), # 空请求体 = sha256(b"")
}, separators=(",", ":")).encode())
signature = PRIVATE_KEY.sign(f"{header}.{claims}".encode())
return f"{header}.{claims}.{b64u(signature)}"
def call(method: str, path: str, body=None, headers=None):
"""path 必须已做 URL 编码(urllib.parse.quote),且与实际发送的完全一致。"""
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
h = {
"X-API-Key": API_KEY,
"Authorization": "Bearer " + sign_jwt(path, raw),
"Content-Type": "application/json",
}
h.update(headers or {})
r = requests.request(method, BASE_URL + path, data=raw if raw else None, headers=h, timeout=35)
data = r.json() if r.content else None
if not r.ok:
err = (data or {}).get("error", {})
raise RuntimeError(f"{r.status_code} {err.get('code')}: {err.get('message')} (requestId={err.get('requestId')})")
return data
if __name__ == "__main__":
print(call("GET", "/v1/supported_assets"))路径参数请用 urllib.parse.quote(value, safe="") 编码后再拼接,并用拼接后的字符串签名。
Go
Go 1.20+,仅标准库。go run waas_client.go:
go
// WaaS B 端 API 客户端(Go 1.20+,仅标准库)
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"time"
)
type Client struct {
BaseURL string // 例如 https://api.example.com
KeyID string // X-API-Key
Key ed25519.PrivateKey // 与控制台登记的公钥配对的私钥
HTTP *http.Client
}
// LoadPrivateKey 读取 PKCS#8 PEM(控制台下载的 .pem 或 openssl genpkey 生成的文件)。
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
blk, _ := pem.Decode(b)
if blk == nil {
return nil, fmt.Errorf("no PEM block in %s", path)
}
k, err := x509.ParsePKCS8PrivateKey(blk.Bytes)
if err != nil {
return nil, err
}
pk, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an Ed25519 key")
}
return pk, nil
}
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } // base64url,无填充
// SignJWT 为一次请求生成 JWT;uri = 实际发送的 path + query(含 /v1);rawBody = 实际发送的请求体字节。
func (c *Client) SignJWT(uri string, rawBody []byte) (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
now := time.Now().Unix()
sum := sha256.Sum256(rawBody) // 空请求体 = sha256("")
header, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT"})
claims, _ := json.Marshal(map[string]any{
"uri": uri,
"nonce": hex.EncodeToString(nonce), // 32 字符,唯一
"iat": now,
"exp": now + 25, // exp - iat <= 30
"sub": c.KeyID,
"bodyHash": hex.EncodeToString(sum[:]),
})
signingInput := b64u(header) + "." + b64u(claims)
sig := ed25519.Sign(c.Key, []byte(signingInput))
return signingInput + "." + b64u(sig), nil
}
// Call 发送已签名请求;body 为 nil 时不发送请求体。path 必须已做 URL 编码。
func (c *Client) Call(method, path string, body any, extra map[string]string) ([]byte, int, error) {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return nil, 0, err
}
}
jwt, err := c.SignJWT(path, raw)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(method, c.BaseURL+path, bytes.NewReader(raw))
if err != nil {
return nil, 0, err
}
req.Header.Set("X-API-Key", c.KeyID)
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
hc := c.HTTP
if hc == nil {
hc = &http.Client{Timeout: 35 * time.Second}
}
resp, err := hc.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
return out, resp.StatusCode, err
}
func main() {
key, err := LoadPrivateKey(os.Getenv("WAAS_PRIVATE_KEY_PEM"))
if err != nil {
panic(err)
}
c := &Client{BaseURL: os.Getenv("WAAS_BASE_URL"), KeyID: os.Getenv("WAAS_API_KEY"), Key: key}
out, status, err := c.Call("GET", "/v1/supported_assets", nil, nil)
fmt.Println(status, string(out), err)
out, status, err = c.Call("POST", "/v1/users", map[string]string{"customerRefId": "u-10001"}, nil)
fmt.Println(status, string(out), err)
out, status, err = c.Call("GET", "/v1/transactions?kind=DEPOSIT&limit=10", nil, nil)
fmt.Println(status, string(out), err)
}Java
Java 17+,仅 JDK(java.net.http 与内置 Ed25519)。java WaasClient.java:
java
// WaaS B 端 API 客户端(Java 17+,仅 JDK 标准库)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
public class WaasClient {
private final String baseUrl; // 例如 https://api.example.com
private final String keyId; // X-API-Key
private final PrivateKey key; // Ed25519 私钥
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
public WaasClient(String baseUrl, String keyId, Path pkcs8Pem) throws Exception {
this.baseUrl = baseUrl;
this.keyId = keyId;
String pem = Files.readString(pkcs8Pem)
.replaceAll("-----(BEGIN|END) PRIVATE KEY-----", "")
.replaceAll("\\s", "");
this.key = KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem)));
}
private static String b64u(byte[] b) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(b); // base64url,无填充
}
private static String jsonString(String s) {
StringBuilder sb = new StringBuilder("\"");
for (char ch : s.toCharArray()) {
switch (ch) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
default -> {
if (ch < 0x20) sb.append(String.format("\\u%04x", (int) ch)); else sb.append(ch);
}
}
}
return sb.append('"').toString();
}
/** uri = 实际发送的 path + query(含 /v1);rawBody = 实际发送的请求体字节。 */
public String signJwt(String uri, byte[] rawBody) throws Exception {
long now = System.currentTimeMillis() / 1000;
String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(rawBody));
String header = b64u("{\"alg\":\"EdDSA\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
String claims = b64u(("{\"uri\":" + jsonString(uri)
+ ",\"nonce\":" + jsonString(UUID.randomUUID().toString())
+ ",\"iat\":" + now
+ ",\"exp\":" + (now + 25)
+ ",\"sub\":" + jsonString(keyId)
+ ",\"bodyHash\":\"" + bodyHash + "\"}").getBytes(StandardCharsets.UTF_8));
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update((header + "." + claims).getBytes(StandardCharsets.US_ASCII));
return header + "." + claims + "." + b64u(signer.sign());
}
/** jsonBody 为 null 表示无请求体;path 必须已做 URL 编码。 */
public HttpResponse<String> call(String method, String path, String jsonBody, String... extraHeaders) throws Exception {
byte[] raw = jsonBody == null ? new byte[0] : jsonBody.getBytes(StandardCharsets.UTF_8);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(35))
.header("X-API-Key", keyId)
.header("Authorization", "Bearer " + signJwt(path, raw))
.header("Content-Type", "application/json")
.method(method, raw.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(raw));
for (int i = 0; i + 1 < extraHeaders.length; i += 2) b.header(extraHeaders[i], extraHeaders[i + 1]);
return http.send(b.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
public static void main(String[] args) throws Exception {
WaasClient c = new WaasClient(System.getenv("WAAS_BASE_URL"), System.getenv("WAAS_API_KEY"),
Path.of(System.getenv("WAAS_PRIVATE_KEY_PEM")));
HttpResponse<String> r = c.call("GET", "/v1/supported_assets", null);
System.out.println(r.statusCode() + " " + r.body());
r = c.call("POST", "/v1/users", "{\"customerRefId\":\"u-10001\",\"email\":\"张三@example.com\"}");
System.out.println(r.statusCode() + " " + r.body());
r = c.call("GET", "/v1/transactions?kind=DEPOSIT&limit=10", null);
System.out.println(r.statusCode() + " " + r.body());
}
}请求体请用你项目中的 JSON 库(Jackson、Gson 等)序列化成字符串后传入 call,签名与发送使用的是同一份字节。
常见签名错误排查
| 现象 | 可能原因 | 处理 |
|---|---|---|
所有请求 401 | X-API-Key 缺失或拼错;Key 已吊销;Authorization 不是 Bearer 开头 | 核对 keyId 与 Key 状态 |
所有请求 401 | 控制台登记的公钥与签名私钥不配对 | 用 openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | xxd -p -c 64 重新导出公钥比对 |
所有请求 401 | header 的 alg 不是 EdDSA(如误用 ES256、Ed25519) | 固定 {"alg":"EdDSA","typ":"JWT"} |
所有请求 401 | base64 用了标准字母表(+ /)或带了 = 填充 | 改用 base64url 且去掉填充 |
所有请求 401 | iat/exp 用了毫秒或小数 | 用整数 Unix 秒 |
所有请求 401 | 本地时钟偏差:iat 比服务器快 5 秒以上,或慢到 exp 已过期 | 开启 NTP;保持 exp = iat + 25 |
所有请求 401 | exp - iat > 30 | 缩短有效期 |
只有带查询参数的请求 401 | uri 漏了查询串,或参数顺序/编码与实际发送不一致 | 先拼好完整 path+query 字符串,签名和请求都用它 |
只有路径含特殊字符的请求 401 | 签名用了未编码的路径,HTTP 库发送时做了编码(或反之) | 先对路径参数做 URL 编码,再签名并发送同一个字符串 |
所有请求 401 | uri 带了域名或漏了 /v1 前缀 | uri 只要 path+query,且以 /v1/ 开头 |
只有 POST/PATCH 401 | bodyHash 与实际发送的字节不一致(重复序列化、空格/键顺序不同、字符编码不同) | 序列化一次,复用同一份字节 |
只有 GET/DELETE 401 | 无请求体时没有用空串的哈希,或发送了 null/{} 作为请求体 | 无请求体时不发送 body,bodyHash 用 e3b0c442…b855 |
重试时 401 nonce reused | 重试复用了旧 JWT | 每次发送都重新签名 |
403 ip … not allowed | 出口 IP 不在白名单(NAT、云函数、IPv6 出口) | 在控制台补充出口 IP 或 CIDR |
403 tenant suspended | 租户被平台暂停 | 联系平台 |
429 RATE_LIMITED | 超过该 Key 的每秒上限 | 退避重试(重新签名);需要时在控制台调高上限 |