chenhg5/cc-connect · error

wecom-ws: ciphertext not multiple of block size

Error message

wecom-ws: ciphertext not multiple of block size

What it means

AES-256-CBC decryption requires the ciphertext length to be an exact multiple of the 16-byte block size. wecomDecryptFile checks this after constructing the cipher and rejects any other length, since CBC cannot process a partial final block. This typically means the downloaded data is not actually the encrypted payload.

Source

Thrown at platform/wecom/websocket_media.go:286

// wecomDecryptFile decrypts payload from WeCom WS media URLs (AES-256-CBC, IV = first 16 key bytes).
// Same algorithm as @wecom/aibot-node-sdk decryptFile.
func wecomDecryptFile(ciphertext []byte, aesKeyB64 string) ([]byte, error) {
	if len(ciphertext) == 0 {
		return nil, fmt.Errorf("wecom-ws: empty ciphertext")
	}
	key, err := decodeWeComAESKey(aesKeyB64)
	if err != nil {
		return nil, err
	}
	key32 := key[:32]
	iv := key32[:16]

	block, err := aes.NewCipher(key32)
	if err != nil {
		return nil, err
	}
	if len(ciphertext)%aes.BlockSize != 0 {
		return nil, fmt.Errorf("wecom-ws: ciphertext not multiple of block size")
	}
	plain := make([]byte, len(ciphertext))
	cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ciphertext)
	return pkcs7UnpadWeCom(plain)
}

func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
	if len(data) == 0 {
		return nil, fmt.Errorf("wecom-ws: empty padded data")
	}
	padLen := int(data[len(data)-1])
	if padLen < 1 || padLen > 32 || padLen > len(data) {
		return nil, fmt.Errorf("wecom-ws: invalid pkcs7 pad length %d", padLen)
	}
	for i := len(data) - padLen; i < len(data); i++ {
		if int(data[i]) != padLen {
			return nil, fmt.Errorf("wecom-ws: invalid pkcs7 padding")
		}

View on GitHub (pinned to 4000b2338a)

Solutions

  1. Log the first bytes and Content-Type of the downloaded body; if it's JSON/HTML, the URL is wrong or expired — fetch a fresh media URL.
  2. Only call wecomDecryptFile when the URL/flag indicates WS-encrypted media; otherwise return raw bytes.
  3. Check Content-Length against bytes read to detect truncation before decrypting.

Example fix

// before
raw, _ := io.ReadAll(lim)
return wecomDecryptFile(raw, aesKey)
// after
raw, _ := io.ReadAll(lim)
if len(raw)%16 != 0 {
    return nil, fmt.Errorf("got %d bytes, ct=%q — media URL likely expired or not encrypted", len(raw), raw[:min(32, len(raw))])
}
return wecomDecryptFile(raw, aesKey)
Defensive patterns

Strategy: validation

Validate before calling

if len(raw)%aes.BlockSize != 0 {
    return fmt.Errorf("not ciphertext: %d bytes, head=%q", len(raw), raw[:min(16, len(raw))])
}

Try / catch

plain, err := wecomDecryptFile(raw, key)
if err != nil && strings.Contains(err.Error(), "multiple of block size") {
    log.Printf("body not encrypted (head=%q) — refresh media URL", raw[:min(32, len(raw))])
}

Prevention

When it happens

Trigger: downloadWeComWSMedia downloads a body whose length % 16 != 0 (e.g. a JSON error body, an HTML login page, a truncated transfer) and then calls wecomDecryptFile on it.

Common situations: WeCom media URL expired and returned a small error JSON instead of the binary; HTTP transfer truncated by a proxy/timeout; applying decryption to a URL that was never encrypted (plain media endpoint).

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of chenhg5/cc-connect@4000b2338a (2026-09-06). Data as JSON: /api/errors/c9c610080135f56c. Report an issue: GitHub.