158 lines
6.2 KiB
Go
158 lines
6.2 KiB
Go
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
|
||
}
|