Files
Cyrene_v2/plugins/channels/weixin_ilink/media_key.go
T
AskaEth e852e60af9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(channels): media_key 注释指向修正(design/08 §3.3 已不存在 → §4 weixin-ilink 条目)
2026-10-05 11:26:02 +08:00

158 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
// 本文件承载微信 iLink 的**入站解密密钥来源**:它从 media.go 拆出,因为「密钥从哪来」
// 与「字节怎么取回来」是两件事,而前者在真机上踩过坑(2026-10-04 真机:入站 `.txt`
// 的取回以
//
// derive inbound key: decode bot_token for key: illegal base64 data at input byte 12
//
// 失败)。
//
// 权威口径取自官方包 @tencent-weixin/openclaw-weixin 2.4.9 的 src/(2026-10-04 拉取
// 核对,AGENTS §9.5):
// - src/media/media-download.ts:**每条附件自带平台下发的 AES-128 密钥** —— 图片取
// `image_item.aeskey`(十六进制,优先),语音/文件/视频取 `*_item.media.aes_key`;
// - src/cdn/pic-decrypt.ts 的 parseAesKey:`aes_key` 有两种线上编码,base64(16 字节
// 原始密钥) 与 base64(32 位十六进制串);
// - 整个包**没有任何**「从 bot_token 推导媒体密钥」的代码。
//
// 设计文档 design/08 §4 的 weixin-ilink 条目(2026-10-05 已更正;**旧口径原记在已不存在的
// §3.3**)记的「bot_token base64 解码取后 16 字节」来源单一且自标 ⚠️,
// 与真机不符:真实 bot_token 形如 `<12 位>@im.bot:<38 位十六进制>`(官方 docs 02 的
// confirmed 响应示例 `44b9b8ec@im.bot:060000b7...` 同形),整体**不是** base64,
// 于是旧实现在第 12 个字符(`@`)处必然失败。因此本包的口径是:
// **按条密钥是主路径,deriveInboundKey 只作兜底**。
import (
"crypto/aes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)
// base64Variants 是解码平台字段时依次尝试的 base64 变体。真实 token/凭据既可能是
// 标准带 padding 的 base64,也可能是 URL-safe(`-`/`_`)或无 padding 的形态 —— 它们
// 互为子集但不互相兼容,所以逐一尝试,而不是先猜一个再报「illegal base64」。
// 顺序有意义:先标准、再「无 padding 标准」,最后 URL-safe 两式。
var base64Variants = []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
// decodeBase64Flexible 解码一个平台下发的 base64 字段,容忍 URL-safe 与无 padding 变体。
func decodeBase64Flexible(s string) ([]byte, error) {
token := strings.TrimSpace(s)
if token == "" {
return nil, fmt.Errorf("weixin_ilink: empty base64 payload")
}
var firstErr error
for _, enc := range base64Variants {
decoded, err := enc.DecodeString(token)
if err == nil {
return decoded, nil
}
if firstErr == nil {
firstErr = err
}
}
return nil, fmt.Errorf("weixin_ilink: decode base64 (standard/url-safe, padded/raw): %w", firstErr)
}
// parseMediaAesKey 把一个平台下发的 `aes_key` 解析成 16 字节 AES-128 密钥。两种线上
// 编码都要认(官方 src/cdn/pic-decrypt.ts 的 parseAesKey):先 base64 解码,得到 16
// 字节就是原始密钥;得到 32 个 ASCII 十六进制字符则是「密钥的十六进制串」,再解一层
// 十六进制。少了第二分支,文件/语音/视频会永远以「密钥长度不对」解不开。
func parseMediaAesKey(encoded string) ([]byte, error) {
raw, err := decodeBase64Flexible(encoded)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decode media aes_key: %w", err)
}
if len(raw) == aes.BlockSize {
return raw, nil
}
if len(raw) == hex.EncodedLen(aes.BlockSize) && isHexASCII(raw) {
key := make([]byte, aes.BlockSize)
if _, err := hex.Decode(key, raw); err != nil {
return nil, fmt.Errorf("weixin_ilink: decode 32-char hex media aes_key: %w", err)
}
return key, nil
}
return nil, fmt.Errorf("%w: got %d decoded bytes", ErrKeyNotAES128, len(raw))
}
// isHexASCII 报告 b 是否全为 ASCII 十六进制字符。
func isHexASCII(b []byte) bool {
for _, c := range b {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// mediaAesKeyOf 返回一个媒体 item 自带的按条 AES 密钥。图片优先用 `image_item.aeskey`
// (十六进制原始密钥),其余类型用 `<kind>_item.media.aes_key`(base64)—— 与官方
// src/media/media-download.ts 的分支一一对应。两者都没有时返回 ErrNoMediaKey。
func mediaAesKeyOf(item map[string]any) ([]byte, error) {
if intOf(item["type"]) == itemImage {
if inner, ok := item["image_item"].(map[string]any); ok {
if raw := firstString(inner, "aeskey"); raw != "" {
if key, err := hex.DecodeString(strings.TrimSpace(raw)); err == nil && len(key) == aes.BlockSize {
return key, nil
}
// 十六进制解不出 16 字节就落回 media.aes_key:宁可换一条已证实的路径,
// 也不用一个被静默截断的密钥去解密(官方对非法 hex 是静默截断)。
}
}
}
media := cdnMediaOf(item)
if media == nil {
return nil, ErrNoMediaKey
}
encoded := firstString(media, "aes_key")
if encoded == "" {
return nil, ErrNoMediaKey
}
return parseMediaAesKey(encoded)
}
// inboundMediaKey 在**原始消息载荷**里找出与 resolvedURL 对应的按条 AES 密钥。
//
// 为什么要回原始载荷找:归一化契约 `adapter.UnifiedMessage.Media` 没有承载密钥的字段,
// 而密钥又是**按条**下发的。把密钥塞进 `Media.URL` 或适配器级缓存都会让它有机会流进
// 日志或跨消息串味;改为按需回读这条消息自己的原始 JSON,就既没有共享状态、也没有
// 生命周期问题 —— 该函数只在真的要取某一条附件时才被调用。
//
// 找不到(载荷缺失、引用对不上、没有 aes_key)时返回 ok=false,由调用方决定兜底。
func (a *Adapter) inboundMediaKey(raw []byte, resolvedURL string) ([]byte, bool) {
if len(raw) == 0 || resolvedURL == "" {
return nil, false
}
var msg struct {
ItemList []json.RawMessage `json:"item_list"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
return nil, false
}
for _, r := range msg.ItemList {
var item map[string]any
if err := json.Unmarshal(r, &item); err != nil {
continue
}
ref := mediaRefOf(item)
if ref == "" || a.cdnURL(ref) != resolvedURL {
continue
}
if key, err := mediaAesKeyOf(item); err == nil {
return key, true
}
}
return nil, false
}