feat: 接入 CoinGlass 清算热力图隔离链路
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled
This commit is contained in:
155
internal/repo/webapi/coinglass/crypto.go
Normal file
155
internal/repo/webapi/coinglass/crypto.go
Normal 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/msg(params 错等编码 bug)。
|
||||
ErrAPIBusiness = errors.New("coinglass API business error")
|
||||
)
|
||||
|
||||
// aesECBDecrypt 解密 AES-ECB(stdlib 没有 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] 原 ASCII(cg.md §43-50:no 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
|
||||
}
|
||||
Reference in New Issue
Block a user