fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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:
AskaEth committed 2026-10-04 22:33:41 +08:00
1 parent 15181e7063
commit 6cb41a8794
7 files changed
+1047 -52

No files matched your search

@@ -0,0 +1,265 @@
package weixin_ilink
// 本文件锁定微信 iLink 入站附件的**密钥来源与真实字段形态**(2026-10-04 真机缺陷)。
//
// 真机形态(官方 @tencent-weixin/openclaw-weixin 2.4.9 的 src/api/types.ts 与
// src/media/media-download.ts):引用与密钥都在 `<kind>_item.media` 里 ——
// `encrypt_query_param`(或 `full_url`)给地址、`aes_key` 给密钥(图片优先
// `image_item.aeskey`);文件名在 `file_name`,体积在**字符串** `len`。
//
// 旧实现只认平铺的 `file_item.url`,且把**整个 bot_token** 当 base64 去解(自标 ⚠️ 的
// design/08 §3.3 口径)——真实 token 形如 `<12 位>@im.bot:<38 位十六进制>`,在第 12 个
// 字符 `@` 处必失败。下面的夹具因此刻意用「同形但不是真凭据」的 token,让「附件能不能
// 读出来」只取决于按条 aes_key 是否被用上。
import (
"bytes"
"context"
"crypto/aes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// realCdnServer 是假 iLink CDN:记录命中次数与服务端**解码后**的 encrypt_query_param,
// 并按需下发字节。它比共享夹具多记一个查询参数,用来钉住引用必须转义。
type realCdnServer struct {
srv *httptest.Server
hits atomic.Int32
query atomic.Value
body []byte
}
func newRealCdnServer(t *testing.T, body []byte) *realCdnServer {
t.Helper()
s := &realCdnServer{body: body}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.hits.Add(1)
s.query.Store(r.URL.Query().Get("encrypted_query_param"))
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(s.body)
}))
t.Cleanup(s.srv.Close)
return s
}
// seenQuery 返回服务端解码后的 encrypt_query_param。
func (s *realCdnServer) seenQuery() string {
v, _ := s.query.Load().(string)
return v
}
// realShapeBotToken 返回一个**与真机同形**的 bot_token:`<12 位>@im.bot:<38 位十六进制>`。
// 它不是任何真实凭据,只是形状 —— 但足以让「整体 base64 解码 token」这条路必失败。
func realShapeBotToken() string {
return "0123456789ab@im.bot:" + strings.Repeat("0f", 19)
}
// realFileItemRaw 构造一条真机形态的文件消息。
func realFileItemRaw(encParam, aesKeyB64, name, size string) []byte {
item := map[string]any{
"type": itemFile,
"file_item": map[string]any{
"media": map[string]any{"encrypt_query_param": encParam, "aes_key": aesKeyB64},
"file_name": name,
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"len": size,
},
}
return wrapItem(item)
}
// realImageItemRaw 构造一条真机形态的图片消息:十六进制密钥在 `image_item.aeskey`,
// 这里让 `media.aes_key` 带一个**错误**密钥(base64 编码),只有走对优先分支才解得开。
func realImageItemRaw(encParam, aeskeyHex, wrongKeyB64 string) []byte {
item := map[string]any{
"type": itemImage,
"image_item": map[string]any{
"media": map[string]any{"encrypt_query_param": encParam, "aes_key": wrongKeyB64},
"aeskey": aeskeyHex,
},
}
return wrapItem(item)
}
// wrapItem 把单个 item 包成一条入站用户消息。
func wrapItem(item map[string]any) []byte {
raw, err := json.Marshal(map[string]any{
"client_id": "c-1", "from_user_id": "u-1", "message_type": msgTypeUser,
"item_list": []any{item},
})
if err != nil {
panic(err.Error())
}
return raw
}
// newRealCdnAdapter 装配一个「CDN 根指向假服务端、bot_token 为真机形态」的适配器与捕获器。
func newRealCdnAdapter(t *testing.T, s *realCdnServer) (*Adapter, *ilinkInlineCapture) {
t.Helper()
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()}
a := newAdapter(t, cfg, s.srv)
c := &ilinkInlineCapture{}
a.SetMessageHandler(c.fn)
return a, c
}
// TestInboundRealFileItemIsDecryptedWithPerItemKey 是 **Bug A 的回归用例**:真机形态的
// 文件 item(引用在 media.encrypt_query_param、密钥在 media.aes_key)+ 真机形态的
// bot_token(不是 base64)。修好之前,取回在 deriveInboundKey 上就失败,字节为空。
func TestInboundRealFileItemIsDecryptedWithPerItemKey(t *testing.T) {
t.Parallel()
key := keyOf(0x41)
plain := []byte("真机形态的文件正文\n")
// 查询参数里带 `+`/`/`/`=`:不转义的话服务端会把 `+` 解成空格,签名参数就废了。
encParam := "ab+cd/ef="
s := newRealCdnServer(t, encrypted(t, key, plain))
a, c := newRealCdnAdapter(t, s)
_, m := a.convertMessage(realFileItemRaw(encParam, base64.StdEncoding.EncodeToString(key), "1.txt", "24"))
if m == nil {
t.Fatal("convertMessage 没有解析出消息")
}
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if len(msg.Media) != 1 {
t.Fatalf("Media = %d 条, want 1", len(msg.Media))
}
got := msg.Media[0]
if !bytes.Equal(got.Data, plain) {
t.Fatalf("Media.Data = %q, want %q", got.Data, plain)
}
if got.Name != "1.txt" {
t.Fatalf("Name = %q, want 1.txt", got.Name)
}
if got.Size != 24 {
t.Fatalf("Size = %d, want 24(file_item.len 是字符串,必须能解析)", got.Size)
}
if !strings.Contains(got.URL, "/download?encrypted_query_param=") {
t.Fatalf("URL = %q, want 归一后的 CDN 下载地址", got.URL)
}
if s.hits.Load() != 1 {
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
}
if s.seenQuery() != encParam {
t.Fatalf("服务端收到的 encrypt_query_param = %q, want %q(引用必须转义)", s.seenQuery(), encParam)
}
}
// TestInboundRealFileItemAcceptsBase64HexAesKey 覆盖官方 parseAesKey 的第二种编码:
// 文件/语音/视频的 aes_key 是 **base64(32 位十六进制串)**,要先 base64 再解一层十六进制。
func TestInboundRealFileItemAcceptsBase64HexAesKey(t *testing.T) {
t.Parallel()
key := keyOf(0x44)
plain := []byte("十六进制密钥包装形态\n")
s := newRealCdnServer(t, encrypted(t, key, plain))
a, c := newRealCdnAdapter(t, s)
wrapped := base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(key)))
_, m := a.convertMessage(realFileItemRaw("only-param", wrapped, "report.txt", "1024"))
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if !bytes.Equal(msg.Media[0].Data, plain) {
t.Fatalf("Media.Data = %q, want %q", msg.Media[0].Data, plain)
}
}
// TestInboundRealImageItemPrefersImageAeskey 钉住官方注明的优先级:`image_item.aeskey`
// (十六进制)优先于 `media.aes_key`。夹具把 media.aes_key 填成**错的**密钥,因此只有
// 走对分支才解得出正文。图片不进内联链路(mediafetch 只管文件类),所以直接驱动 Fetcher。
func TestInboundRealImageItemPrefersImageAeskey(t *testing.T) {
t.Parallel()
right := keyOf(0x42)
wrong := keyOf(0x99)
plain := []byte("jpeg-bytes\n")
s := newRealCdnServer(t, encrypted(t, right, plain))
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: realShapeBotToken()}
a := newAdapter(t, cfg, s.srv)
raw := realImageItemRaw("img+param=", hex.EncodeToString(right), base64.StdEncoding.EncodeToString(wrong))
_, m := a.convertMessage(raw)
if m == nil || len(m.Media) != 1 {
t.Fatalf("convertMessage 没有解析出图片媒体:%+v", m)
}
got, err := ilinkFetcher{a: a, raw: raw}.Fetch(context.Background(), m.Media[0])
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("解出的字节 = %q, want %q", got, plain)
}
}
// TestInboundFileItemWithoutAesKeyIsHonest 钉住诚实失败:附件没带密钥时,宁可报错让
// 上游给模型一行「读不到」,也绝不用 bot_token 兜底去解出一堆乱码 —— 而且连请求都不该
// 发出去(密钥都不齐,取回来也只会浪费一次传输)。
func TestInboundFileItemWithoutAesKeyIsHonest(t *testing.T) {
t.Parallel()
key := keyOf(0x45)
s := newRealCdnServer(t, encrypted(t, key, []byte("unreachable")))
a, c := newRealCdnAdapter(t, s)
_, m := a.convertMessage(realFileItemRaw("no-key-param", "", "1.txt", "3"))
a.deliver(context.Background(), *m, nil)
msg := c.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("没有密钥却写入了字节:%q", msg.Media[0].Data)
}
if s.hits.Load() != 0 {
t.Fatalf("密钥不齐仍发起了 %d 次请求, want 0", s.hits.Load())
}
if msg.SenderID != "u-1" {
t.Fatalf("失败路径把消息弄丢了:%q", msg.SenderID)
}
}
// TestDeriveInboundKey_AcceptsRawAndURLSafeBase64 钉住兜底路径的编码容忍度:凭据由
// 第三方下发,无 padding 与 URL-safe(`-`/`_`)两种形态都要认 —— 先猜一种再报
// 「illegal base64」正是真机踩过的坑。
func TestDeriveInboundKey_AcceptsRawAndURLSafeBase64(t *testing.T) {
t.Parallel()
key := keyOf(0xFF)
payload := append([]byte("prefix!"), key...)
std := base64.StdEncoding.EncodeToString(payload)
urlSafe := strings.NewReplacer("+", "-", "/", "_", "=", "").Replace(std)
if !strings.ContainsAny(urlSafe, "-_") || urlSafe == std {
t.Fatalf("夹具没有造出 URL-safe 无 padding 形态:%q", std)
}
for name, token := range map[string]string{
"url-safe raw": urlSafe,
"standard raw": strings.TrimRight(std, "="),
"standard std": std,
} {
got, err := deriveInboundKey(token)
if err != nil {
t.Fatalf("%s: deriveInboundKey: %v", name, err)
}
if !bytes.Equal(got, key) {
t.Fatalf("%s: key = %x, want %x", name, got, key)
}
}
}
// TestParseMediaAesKey_RejectsWrongLength 钉住密钥长度校验:长度不对时诚实报错,
// 而不是拿被截断/补零的东西去解密。
func TestParseMediaAesKey_RejectsWrongLength(t *testing.T) {
t.Parallel()
if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 8))); err == nil {
t.Fatal("8 字节的 aes_key 被接受了")
}
if _, err := parseMediaAesKey(""); err == nil {
t.Fatal("空的 aes_key 被接受了")
}
if _, err := parseMediaAesKey(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, aes.BlockSize))); err != nil {
t.Fatalf("16 字节原始密钥被拒:%v", err)
}
}