Skip to content

认证与签名 ​

所有 /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类型规则
uristring本次请求的 path + query,与实际发送的完全一致(含 /v1 前缀、已做 URL 编码、参数顺序一致)。例:/v1/transactions?kind=DEPOSIT&limit=50。没有查询参数时不要带 ?。不包含协议、域名和 #片段。
noncestring每个请求唯一的随机串,1–64 字节(建议 UUID)。同一个 Key 下重复使用返回 401(nonce reused)。
iatinteger签发时间,Unix 秒(整数,不能是小数)。不得晚于服务器时间 5 秒以上。
expinteger过期时间,Unix 秒。必须晚于服务器当前时间,且 exp - iat ≤ 30。建议 exp = iat + 25。
substring与 X-API-Key 相同的 keyId。
bodyHashstring原始请求体字节的 SHA-256,十六进制(大小写均可,建议小写)。无请求体(如 GET)时为空字符串的 SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。

签名:signature = Ed25519(privateKey, ASCII(base64url(header) + "." + base64url(claims))),JWT = base64url(header).base64url(claims).base64url(signature)。三段都使用 base64url 且不带 = 填充。

关键点

  1. 签名用的请求体字节必须与发送的字节完全相同:先把 JSON 序列化成字符串/字节,再用这份字节同时计算 bodyHash 和作为请求体发送;不要让 HTTP 库再序列化一次。
  2. 每个请求(包括重试)都要重新生成 JWT:nonce 只能用一次,令牌最长 30 秒有效。
  3. 服务器时钟是基准:请保持 NTP 同步。

服务端校验顺序 ​

服务端按以下顺序校验,遇到第一个失败即返回:

#检查失败时
1存在 X-API-Key 头401 UNAUTHORIZED authentication required
2Authorization 以 Bearer (区分大小写)开头401 UNAUTHORIZED
3请求体 ≤ 256 KiB422 BAD_REQUEST body too large
4keyId 存在且状态为 ACTIVE(未吊销)401 UNAUTHORIZED
5租户状态为 ACTIVE403 FORBIDDEN tenant suspended
6若 Key 配置了 IP 白名单:客户端 IP 在白名单内(单个 IP 或 CIDR)403 FORBIDDEN ip <ip> not allowed for this api key(无法确定 IP 时为 client ip unknown)
7JWT 为三段、base64url 可解码;header alg = EdDSA;签名用该 Key 的公钥验证通过;claims 可解析且六个字段齐全、类型正确401 UNAUTHORIZED
8sub = keyId;uri = 服务器收到的 path+query;exp > now;iat ≤ now + 5;exp - iat ≤ 30;bodyHash 匹配;nonce 长度 1–64401 UNAUTHORIZED
9租户订阅未到期(校验签名通过后、进入业务前)403 FORBIDDEN tenant subscription expired(所有 /v1 接口一致,无 GET 例外)
10nonce 未被该 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_KEYkeyId
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,签名与发送使用的是同一份字节。

常见签名错误排查 ​

现象可能原因处理
所有请求 401X-API-Key 缺失或拼错;Key 已吊销;Authorization 不是 Bearer 开头核对 keyId 与 Key 状态
所有请求 401控制台登记的公钥与签名私钥不配对用 openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | xxd -p -c 64 重新导出公钥比对
所有请求 401header 的 alg 不是 EdDSA(如误用 ES256、Ed25519)固定 {"alg":"EdDSA","typ":"JWT"}
所有请求 401base64 用了标准字母表(+ /)或带了 = 填充改用 base64url 且去掉填充
所有请求 401iat/exp 用了毫秒或小数用整数 Unix 秒
所有请求 401本地时钟偏差:iat 比服务器快 5 秒以上,或慢到 exp 已过期开启 NTP;保持 exp = iat + 25
所有请求 401exp - iat > 30缩短有效期
只有带查询参数的请求 401uri 漏了查询串,或参数顺序/编码与实际发送不一致先拼好完整 path+query 字符串,签名和请求都用它
只有路径含特殊字符的请求 401签名用了未编码的路径,HTTP 库发送时做了编码(或反之)先对路径参数做 URL 编码,再签名并发送同一个字符串
所有请求 401uri 带了域名或漏了 /v1 前缀uri 只要 path+query,且以 /v1/ 开头
只有 POST/PATCH 401bodyHash 与实际发送的字节不一致(重复序列化、空格/键顺序不同、字符编码不同)序列化一次,复用同一份字节
只有 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 的每秒上限退避重试(重新签名);需要时在控制台调高上限