feat: 接入 CoinGlass 清算热力图隔离链路
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled

This commit is contained in:
dela
2026-05-25 16:57:22 +08:00
parent e5fb54cb72
commit 1b61541ff5
32 changed files with 2550 additions and 70 deletions

View File

@@ -0,0 +1,155 @@
package coinglass
import (
"bytes"
"compress/gzip"
"crypto/aes"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
// ErrFakeSuccess — HTTP 200 + success=true + data 空。spec §4.6 / §11。
ErrFakeSuccess = errors.New("coinglass fake-success (TLS fingerprint suspected)")
// ErrUnknownV — dispatcher 见到未知 v 分支bundle 可能升级。spec §4.3。
ErrUnknownV = errors.New("coinglass unknown v branch")
// ErrDecryptFail — 双阶段解密任一步失败base64 / aes / pkcs7 / gzip / utf8
ErrDecryptFail = errors.New("coinglass decrypt pipeline failed")
// ErrAPIBusiness — success=false 且有明确 code/msgparams 错等编码 bug
ErrAPIBusiness = errors.New("coinglass API business error")
)
// aesECBDecrypt 解密 AES-ECBstdlib 没有 ECB 模式,按 block 循环 Decrypt
// 调用方保证 src 长度是 block size 的整数倍。
func aesECBDecrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("%w: aes new cipher: %v", ErrDecryptFail, err)
}
bs := block.BlockSize()
if len(src) == 0 || len(src)%bs != 0 {
return nil, fmt.Errorf("%w: ciphertext len %d not multiple of block %d", ErrDecryptFail, len(src), bs)
}
dst := make([]byte, len(src))
for i := 0; i < len(src); i += bs {
block.Decrypt(dst[i:i+bs], src[i:i+bs])
}
return dst, nil
}
// aesECBEncrypt 加密 AES-ECB仅 data= 签名用spec §4.5)。
func aesECBEncrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("%w: aes new cipher: %v", ErrDecryptFail, err)
}
bs := block.BlockSize()
if len(src)%bs != 0 {
return nil, fmt.Errorf("%w: plaintext len %d not multiple of block %d", ErrDecryptFail, len(src), bs)
}
dst := make([]byte, len(src))
for i := 0; i < len(src); i += bs {
block.Encrypt(dst[i:i+bs], src[i:i+bs])
}
return dst, nil
}
// pkcs7Pad / pkcs7Unpad — RFC 5652 §6.3。
func pkcs7Pad(b []byte, blockSize int) []byte {
pad := blockSize - len(b)%blockSize
out := make([]byte, len(b)+pad)
copy(out, b)
for i := len(b); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) {
if len(b) == 0 || len(b)%blockSize != 0 {
return nil, fmt.Errorf("%w: pkcs7 unpad bad len %d", ErrDecryptFail, len(b))
}
pad := int(b[len(b)-1])
if pad == 0 || pad > blockSize {
return nil, fmt.Errorf("%w: pkcs7 unpad bad value %d", ErrDecryptFail, pad)
}
for i := len(b) - pad; i < len(b); i++ {
if int(b[i]) != pad {
return nil, fmt.Errorf("%w: pkcs7 unpad inconsistent", ErrDecryptFail)
}
}
return b[:len(b)-pad], nil
}
// decryptBlob — V2 通用管线base64dec → AES-ECB → unpad → gunzip → strip outer quotes。
func decryptBlob(ciphertextB64 string, key []byte) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", fmt.Errorf("%w: b64dec: %v", ErrDecryptFail, err)
}
decrypted, err := aesECBDecrypt(key, raw)
if err != nil {
return "", err
}
unpadded, err := pkcs7Unpad(decrypted, aes.BlockSize)
if err != nil {
return "", err
}
gz, err := gzip.NewReader(bytes.NewReader(unpadded))
if err != nil {
return "", fmt.Errorf("%w: gzip header: %v", ErrDecryptFail, err)
}
defer gz.Close()
plain, err := io.ReadAll(gz)
if err != nil {
return "", fmt.Errorf("%w: gunzip: %v", ErrDecryptFail, err)
}
text := string(plain)
if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' {
text = text[1 : len(text)-1]
}
return text, nil
}
// userKeyFromSeed — Stage 1 key 派生base64(seed)[:16]cg.md L38
func userKeyFromSeed(seed string) []byte {
enc := base64.StdEncoding.EncodeToString([]byte(seed))
if len(enc) < 16 {
// 不应发生(任何非空 seed base64 后都 ≥ 4 chars
// 实际 seed 至少 16 字节 → enc 至少 24 chars保险兜底。
return []byte(enc)
}
return []byte(enc[:16])
}
// bodyKeyFromToken — Stage 2 key 派生token[:16] 原 ASCIIcg.md §43-50no second base64
func bodyKeyFromToken(token string) []byte {
if len(token) < 16 {
return []byte(token)
}
return []byte(token[:16])
}
// DecryptUniversal 跑完整双阶段管线spec §4.4)。
//
// seed 由 dispatcher.resolveSeed(v, ...) 给出
// userHeaderB64 = response.headers["user"]
// bodyDataB64 = body.data envelope 外层,未解密的 ciphertext
//
// 返回 plaintext JSON 字节流,可直接喂给 mapper。
func DecryptUniversal(seed, userHeaderB64, bodyDataB64 string) ([]byte, error) {
token, err := decryptBlob(userHeaderB64, userKeyFromSeed(seed))
if err != nil {
return nil, err
}
plain, err := decryptBlob(bodyDataB64, bodyKeyFromToken(token))
if err != nil {
return nil, err
}
return []byte(plain), nil
}