fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
Bug A(入站取不到字节)真因两层:① 引用位置错——真实在 `file_item.media.encrypt_query_param`,旧 mediaOf 只扫平铺 url/cdn_url; ② 密钥来源错——官方口径是**随每条附件下发**的 `*_item.media.aes_key` (图片优先 `image_item.aeskey`),旧代码却把整个 bot_token 当 base64 取尾 16 字节, 而真机 token 形如 `<id>@im.bot:<38hex>`,第 12 字符 `@` 处即非法。 (对照证据:官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码,只读未入库; design/08 §3.3 的「token base64 尾 16 字节」据此判定为错误口径,待文档更正。) Bug B(出站):① sendToOne 遇媒体构建失败直接 return ⇒ 连文本一起丢(改为逐条跳过 + 带来源 WARN;全部媒体失败仍发文本,无文本且全挂才诚实报错);② getUploadURL 发空 body 且只认 4 个猜测键 ⇒ 补齐官方请求体字段 + 依次认 upload_full_url / upload_param。 测试:media_key_test(6 条,真机形态夹具 + 转义钉住)+ send_media_failure_test(6 条)。 变异 6 条(M-A1/A2/A3/B1/B3a/B3b)均先确认变异体可编译、贴红行、还原复绿。 诚实边界:出站媒体**仍发不出去**(PUT vs 官方 POST、响应头 x-encrypted-param、 nativeMediaItem 嵌套形状三项未做)—— 故意只修到「拿到上传地址」,不扩大切片。
This commit is contained in:
1 parent
15181e7063
commit
6cb41a8794
7 files changed
+1047
-52
No files matched your search
@@ -3,7 +3,6 @@ package weixin_ilink
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,15 +16,22 @@ import (
|
||||
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
|
||||
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
|
||||
|
||||
// deriveInboundKey 为入站媒体负载推导 AES-128 密钥。按 design/08 §3.3,密钥从
|
||||
// bot_token 中提取:先 base64 解码 token,再取末尾 16 字节。参考实现把这一步标记为
|
||||
// ⚠️,因此 DownloadMediaWithKey 允许调用方在平台按每条媒体下发密钥时显式传入密钥。
|
||||
// deriveInboundKey 用 bot_token 推导 AES-128 密钥:base64 解码 token,取末尾 16 字节。
|
||||
//
|
||||
// ⚠️ 这是**兜底**,不是主路径。真机核对的结论见 media_key.go 的文件头:官方包从不
|
||||
// 从 bot_token 推导媒体密钥(密钥按条随附件下发),而真实 bot_token 形如
|
||||
// `<12 位>@im.bot:<38 位十六进制>`、根本不是 base64 —— 对它调用本函数**必然**失败
|
||||
// (第 12 个字符 `@` 处非法)。保留它只为「token 确实以 base64 下发」的部署与
|
||||
// `DownloadMedia` 的既有契约。
|
||||
//
|
||||
// 解码容忍 URL-safe 与无 padding 的 base64 变体:凭据由第三方下发,形态不由我们决定,
|
||||
// 先猜一种再报「illegal base64」正是真机踩过的那个坑。
|
||||
func deriveInboundKey(botToken string) ([]byte, error) {
|
||||
token := strings.TrimSpace(botToken)
|
||||
if token == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(token)
|
||||
b, err := decodeBase64Flexible(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
|
||||
}
|
||||
@@ -35,10 +41,20 @@ func deriveInboundKey(botToken string) ([]byte, error) {
|
||||
return b[len(b)-aes.BlockSize:], nil
|
||||
}
|
||||
|
||||
// botToken 返回已配置的 bot token 快照(凭据只在锁内读,绝不进日志)。
|
||||
func (a *Adapter) botToken() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.cfg.BotToken
|
||||
}
|
||||
|
||||
// DownloadMedia 下载一条入站 CDN 引用,并用从已配置 bot_token 推导出的密钥返回
|
||||
// 明文。密文超过 MaxMediaSize 时以 ErrMediaTooLarge 失败。
|
||||
//
|
||||
// 只有 URL、没有原始消息载荷的调用方走这条路径(按条密钥无从查起);轮询投递出来的
|
||||
// 附件走 ilinkFetcher,那条路径优先用平台随附件下发的密钥。
|
||||
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
|
||||
key, err := deriveInboundKey(a.cfg.BotToken)
|
||||
key, err := deriveInboundKey(a.botToken())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
|
||||
}
|
||||
@@ -98,22 +114,28 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error)
|
||||
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
|
||||
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a})
|
||||
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData})
|
||||
}
|
||||
|
||||
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
|
||||
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
|
||||
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单与独立的体积上限。
|
||||
type ilinkFetcher struct{ a *Adapter }
|
||||
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、独立的体积上限,以及
|
||||
// **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。
|
||||
type ilinkFetcher struct {
|
||||
a *Adapter
|
||||
// raw 是这条消息的原始 iLink 载荷。按条 AES 密钥随附件下发,而归一化契约里没有
|
||||
// 承载它的字段,因此密钥按需从这里回查(见 inboundMediaKey)。
|
||||
raw []byte
|
||||
}
|
||||
|
||||
// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由
|
||||
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
|
||||
// `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限,
|
||||
// 与本切片的文档上限刻意解耦)。
|
||||
func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
key, err := deriveInboundKey(f.a.cfg.BotToken)
|
||||
key, err := f.inboundKey(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: f.a.cdnURL(m.URL),
|
||||
@@ -132,6 +154,20 @@ func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// inboundKey 决定本次解密用哪个密钥:**优先平台随附件下发的按条 aes_key**(官方包的
|
||||
// 入站口径),只有这条消息确实没带密钥时才退回 bot_token 推导的兜底。两条都拿不到时
|
||||
// 诚实报错 —— 绝不拿一个来路不明的密钥去解,那只会解出乱码。
|
||||
func (f ilinkFetcher) inboundKey(m adapter.Media) ([]byte, error) {
|
||||
if key, ok := f.a.inboundMediaKey(f.raw, m.URL); ok {
|
||||
return key, nil
|
||||
}
|
||||
key, err := deriveInboundKey(f.a.botToken())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: no per-item aes_key and bot_token fallback: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// assetAllowlist 给出入站附件取回的来源白名单:**只有已配置的 CDN 根**(默认
|
||||
// `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用
|
||||
// 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute),
|
||||
|
||||
Reference in new issue
Block a user