Files
Memoh/internal/channel/adapters/weixin/crypto.go
T
晨苒 e2e3b69acf feat(channel): add WeChat (weixin) adapter with QR code (#278)
* feat(channel): add WeChat (weixin) adapter with QR code

* fix(channel): fix weixin block streaming

* chore(channel): update weixin logo
2026-03-22 23:28:57 +08:00

189 lines
5.7 KiB
Go

// Derived from @tencent-weixin/openclaw-weixin (MIT License, Copyright (c) 2026 Tencent Inc.)
// See LICENSE in this directory for the full license text.
package weixin
import (
"crypto/aes"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// encryptAESECB encrypts plaintext with AES-128-ECB and PKCS7 padding.
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
bs := block.BlockSize()
padded := pkcs7Pad(plaintext, bs)
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += bs {
block.Encrypt(out[i:i+bs], padded[i:i+bs])
}
return out, nil
}
// decryptAESECB decrypts ciphertext with AES-128-ECB and PKCS7 padding.
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
bs := block.BlockSize()
if len(ciphertext)%bs != 0 {
return nil, fmt.Errorf("ciphertext length %d is not a multiple of block size %d", len(ciphertext), bs)
}
out := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += bs {
block.Decrypt(out[i:i+bs], ciphertext[i:i+bs])
}
return pkcs7Unpad(out, bs)
}
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padded := make([]byte, len(data)+padding)
copy(padded, data)
for i := len(data); i < len(padded); i++ {
padded[i] = byte(padding) //nolint:gosec // padding is always 1..blockSize(16)
}
return padded
}
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 {
return data, nil
}
padding := int(data[len(data)-1])
if padding > blockSize || padding == 0 {
return nil, fmt.Errorf("invalid pkcs7 padding %d", padding)
}
for i := len(data) - padding; i < len(data); i++ {
if data[i] != byte(padding) { //nolint:gosec // padding is always 1..blockSize(16)
return nil, fmt.Errorf("invalid pkcs7 padding at byte %d", i)
}
}
return data[:len(data)-padding], nil
}
// aesECBPaddedSize returns the ciphertext size after AES-128-ECB with PKCS7 padding.
// PKCS7 always adds at least 1 byte of padding, rounding up to a 16-byte boundary.
func aesECBPaddedSize(plaintextSize int) int {
// ceil((n+1) / 16) * 16
return ((plaintextSize + 1 + 15) / 16) * 16 //nolint:mnd
}
// parseAESKey parses a base64-encoded AES key. Handles two formats:
// - base64(raw 16 bytes)
// - base64(hex string of 16 bytes) -> 32 hex chars.
func parseAESKey(aesKeyBase64 string) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, fmt.Errorf("aes key base64 decode: %w", err)
}
if len(decoded) == 16 {
return decoded, nil
}
if len(decoded) == 32 {
s := string(decoded)
if isHexString(s) {
key, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("aes key hex decode: %w", err)
}
return key, nil
}
}
return nil, fmt.Errorf("aes key must be 16 raw bytes or 32-char hex, got %d bytes", len(decoded))
}
func isHexString(s string) bool {
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return false
}
}
return true
}
// CDN URL helpers.
func buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL string) string {
return cdnBaseURL + "/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
}
func buildCDNUploadURL(cdnBaseURL, uploadParam, filekey string) string {
return cdnBaseURL + "/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
"&filekey=" + url.QueryEscape(filekey)
}
// downloadAndDecrypt fetches encrypted bytes from the CDN and decrypts with AES-128-ECB.
func downloadAndDecrypt(cdnBaseURL, encryptedQueryParam, aesKeyBase64 string) ([]byte, error) {
key, err := parseAESKey(aesKeyBase64)
if err != nil {
return nil, err
}
u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
encrypted, err := fetchURL(u)
if err != nil {
return nil, fmt.Errorf("cdn download: %w", err)
}
return decryptAESECB(encrypted, key)
}
// downloadPlain fetches unencrypted bytes from the CDN.
func downloadPlain(cdnBaseURL, encryptedQueryParam string) ([]byte, error) {
u := buildCDNDownloadURL(encryptedQueryParam, cdnBaseURL)
return fetchURL(u)
}
func fetchURL(u string) ([]byte, error) {
resp, err := http.Get(u) //nolint:gosec,noctx
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("cdn %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
// uploadToCDN encrypts and uploads bytes to the WeChat CDN, returning the download param.
func uploadToCDN(cdnBaseURL, uploadParam, filekey string, plaintext, aesKey []byte) (string, error) {
ciphertext, err := encryptAESECB(plaintext, aesKey)
if err != nil {
return "", fmt.Errorf("cdn encrypt: %w", err)
}
u := buildCDNUploadURL(cdnBaseURL, uploadParam, filekey)
req, err := http.NewRequest(http.MethodPost, u, io.NopCloser(strings.NewReader(string(ciphertext)))) //nolint:noctx
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) //nolint:mnd,gosec // CDN URL from admin config
if err != nil {
return "", fmt.Errorf("cdn upload: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("cdn upload %d: %s", resp.StatusCode, string(body))
}
downloadParam := resp.Header.Get("x-encrypted-param")
if downloadParam == "" {
return "", errors.New("cdn upload: missing x-encrypted-param header")
}
return downloadParam, nil
}