Files
Cyrene_v2/plugins/channels/weixin_ilink/media_key_test.go
T
AskaEth e792baa316
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(channels): 修正 6 处指向已不存在 design/08 节号的注释(只改注释)
2026-10-05 11:29:54 +08:00

268 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,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)
}
}