diff --git a/internal/xkapi/client.go b/internal/xkapi/client.go index 927de99..5551d5a 100644 --- a/internal/xkapi/client.go +++ b/internal/xkapi/client.go @@ -104,9 +104,10 @@ func (c *Client) doJSON(req *http.Request, out any) error { return fmt.Errorf("http %d: %s", resp.StatusCode, string(body)) } var envelope struct { - Code int `json:"code"` - Result json.RawMessage `json:"result"` - Message string `json:"message"` + Code int `json:"code"` + Result json.RawMessage `json:"result"` + ResultEncrypted bool `json:"result_encrypted"` + Message string `json:"message"` } if err := json.Unmarshal(body, &envelope); err != nil { return err @@ -114,10 +115,22 @@ func (c *Client) doJSON(req *http.Request, out any) error { if envelope.Code != 0 { return fmt.Errorf("api code %d: %s", envelope.Code, envelope.Message) } - if out != nil && len(envelope.Result) > 0 { - return json.Unmarshal(envelope.Result, out) + if out == nil || len(envelope.Result) == 0 { + return nil } - return nil + resultJSON := envelope.Result + if envelope.ResultEncrypted { + var cipherStr string + if err := json.Unmarshal(envelope.Result, &cipherStr); err != nil { + return fmt.Errorf("parse encrypted result: %w", err) + } + plain, err := DecryptBody(cipherStr, c.token) + if err != nil { + return fmt.Errorf("decrypt result: %w", err) + } + resultJSON = []byte(plain) + } + return json.Unmarshal(resultJSON, out) } // BatchCreate 创建同步批次,返回 batch_id。 diff --git a/internal/xkapi/crypto.go b/internal/xkapi/crypto.go new file mode 100644 index 0000000..614339c --- /dev/null +++ b/internal/xkapi/crypto.go @@ -0,0 +1,122 @@ +package xkapi + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// DeriveTransitAESKey 从 HY_TRANSIT_API_TOKEN 派生 AES-128 密钥(与 PHP HyTransitCryptoService 一致)。 +func DeriveTransitAESKey(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:16] +} + +// EncryptBody AES-128-ECB + PKCS7 加密为 Base64(与 PHP HyTransitCryptoService 一致)。 +func EncryptBody(plain, token string) (string, error) { + return encryptTransitBody(plain, token) +} + +func encryptTransitBody(plain, token string) (string, error) { + key := DeriveTransitAESKey(token) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + data := pkcs7Pad([]byte(plain), block.BlockSize()) + out := make([]byte, len(data)) + ecb := newECBEncrypter(block) + ecb.CryptBlocks(out, data) + return base64.StdEncoding.EncodeToString(out), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - (len(data) % blockSize) + padding := make([]byte, pad) + for i := range padding { + padding[i] = byte(pad) + } + return append(data, padding...) +} + +type ecbEncrypter struct { + b cipher.Block + blockSize int +} + +func newECBEncrypter(b cipher.Block) *ecbEncrypter { + return &ecbEncrypter{b: b, blockSize: b.BlockSize()} +} + +func (x *ecbEncrypter) CryptBlocks(dst, src []byte) { + if len(src)%x.blockSize != 0 { + panic("invalid padding") + } + for len(src) > 0 { + x.b.Encrypt(dst, src[:x.blockSize]) + src = src[x.blockSize:] + dst = dst[x.blockSize:] + } +} + +// DecryptBody AES-128-ECB + PKCS7 解密 Base64 密文(与 PHP AesUtilService / hy.EncryptBody 对称)。 +func DecryptBody(ciphertext, token string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + key := DeriveTransitAESKey(token) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + if len(raw)%block.BlockSize() != 0 { + return "", fmt.Errorf("ciphertext length not multiple of block size") + } + out := make([]byte, len(raw)) + ecb := newECBDecrypter(block) + ecb.CryptBlocks(out, raw) + plain, err := pkcs7Unpad(out, block.BlockSize()) + if err != nil { + return "", err + } + return string(plain), nil +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, fmt.Errorf("invalid padding size") + } + pad := int(data[len(data)-1]) + if pad == 0 || pad > blockSize || pad > len(data) { + return nil, fmt.Errorf("invalid padding") + } + for i := 0; i < pad; i++ { + if data[len(data)-1-i] != byte(pad) { + return nil, fmt.Errorf("invalid padding bytes") + } + } + return data[:len(data)-pad], nil +} + +type ecbDecrypter struct { + b cipher.Block + blockSize int +} + +func newECBDecrypter(b cipher.Block) *ecbDecrypter { + return &ecbDecrypter{b: b, blockSize: b.BlockSize()} +} + +func (x *ecbDecrypter) CryptBlocks(dst, src []byte) { + if len(src)%x.blockSize != 0 { + panic("invalid padding") + } + for len(src) > 0 { + x.b.Decrypt(dst, src[:x.blockSize]) + src = src[x.blockSize:] + dst = dst[x.blockSize:] + } +} diff --git a/internal/xkapi/crypto_test.go b/internal/xkapi/crypto_test.go new file mode 100644 index 0000000..5998a90 --- /dev/null +++ b/internal/xkapi/crypto_test.go @@ -0,0 +1,49 @@ +package xkapi + +import ( + "encoding/hex" + "encoding/json" + "testing" +) + +// 固定向量须与 xk-api scripts/hy_transit_crypto_vector.php 输出一致。 +const ( + vectorTransitToken = "test-transit-api-token-for-golden-vector" + vectorPlainJSON = `{"batch_id":12,"organID":"o1","organName":"测试机构"}` + vectorDerivedKeyHex = "0887a3ab67abbbeda0901b562799c248" + vectorEncrypted = "V5D6xp9Txjj6lUB42NMgySnLw2ZC+nEVdYkbiufEtKEyYbMpNhuxeiAhnpg0xmw+ktGYuwnf9oeS8HbwBjJBBw==" +) + +func TestDeriveTransitAESKeyGolden(t *testing.T) { + got := hex.EncodeToString(DeriveTransitAESKey(vectorTransitToken)) + if got != vectorDerivedKeyHex { + t.Fatalf("derived key %s != %s", got, vectorDerivedKeyHex) + } +} + +func TestEncryptGoldenMatchesVector(t *testing.T) { + enc, err := EncryptBody(vectorPlainJSON, vectorTransitToken) + if err != nil { + t.Fatal(err) + } + if enc != vectorEncrypted { + t.Fatalf("encrypted %s != %s", enc, vectorEncrypted) + } +} + +func TestDecryptGoldenVector(t *testing.T) { + plain, err := DecryptBody(vectorEncrypted, vectorTransitToken) + if err != nil { + t.Fatal(err) + } + if plain != vectorPlainJSON { + t.Fatalf("plain %q != %q", plain, vectorPlainJSON) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(plain), &decoded); err != nil { + t.Fatal(err) + } + if decoded["batch_id"] != float64(12) { + t.Fatalf("batch_id %v", decoded["batch_id"]) + } +}