package weixin_ilink // 本文件锁定微信 iLink 入站附件的**密钥来源与真实字段形态**(2026-10-04 真机缺陷)。 // // 真机形态(官方 @tencent-weixin/openclaw-weixin 2.4.9 的 src/api/types.ts 与 // src/media/media-download.ts):引用与密钥都在 `_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,2026-10-05 已在 §4 的 weixin-ilink 条目更正为「按条密钥」) // ——真实 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 填成**错的**密钥,因此只有 // 走对分支才解得出正文。本用例钉的是**取回层**的密钥选择(图片现在也走内联链路,但这里的 // 载荷不是可嗅探的真图字节,走内联会被字节裁决挡下),所以直接驱动 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) } }