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` // (十六进制原始密钥),其余类型用 `_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 }