package weixin_ilink import ( "context" "crypto/aes" "errors" "fmt" "io" "net/http" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" ) // ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。 var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block") // 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 := decodeBase64Flexible(token) if err != nil { return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err) } if len(b) < aes.BlockSize { return nil, ErrKeyTooShort } 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.botToken()) if err != nil { return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err) } return a.downloadWithKey(ctx, cdnURL, key) } // DownloadMediaWithKey 是 DownloadMedia 的显式按条 AES 密钥版本,用于协议把密钥 // 与媒体引用一并下发的场景。 func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) { if len(key) != aes.BlockSize { return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort) } return a.downloadWithKey(ctx, cdnURL, key) } // downloadWithKey 拉取 CDN 字节并剥掉 AES-ECB 层。 func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) { ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL)) if err != nil { return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err) } plaintext, err := aesECBDecrypt(key, ciphertext) if err != nil { return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err) } return plaintext, nil } // fetchBody 对 rawURL 发 GET 并返回响应体,超过 MaxMediaSize 的一律拒绝,避免单个 // 超大文件耗尽内存。 func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, fmt.Errorf("weixin_ilink: build media request: %w", err) } a.authorizationHeader(req) resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("weixin_ilink: download media: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode) } limit := a.cfg.MaxMediaSize + 1 data, err := io.ReadAll(io.LimitReader(resp.Body, limit)) if err != nil { return nil, fmt.Errorf("weixin_ilink: read media: %w", err) } if int64(len(data)) > a.cfg.MaxMediaSize { return nil, ErrMediaTooLarge } return data, nil } // inlineInboundMedia 把受支持格式的附件(**文件类、图片类、语音类**)取回、解密并内联到 // 消息上(切片 IM-1 / IM-ASR / IM-IMG)。失败不抛出:该附件保持无字节,由 channelbridge 的 // 入站摄取照既有口径给模型一行诚实提示(design/24 §4.4)——本切片绝不为了「让附件能读」 // 而把失败吞掉。 // // 图片走的是**同一条路**:iLink 的 image_item 不带文件名也不带 MIME,因此它的可取性由 // 平台类型 + 字节嗅探判定(`mediafetch.inlineImages`),来源仍受下面这份白名单约束。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a, raw: msg.RawData}) } // ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对 // URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 —— // 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单、按类型的体积上限,以及 // **按条密钥**(raw 承载本条消息的原始载荷,用来回查该附件的 aes_key)。 type ilinkFetcher struct { a *Adapter // raw 是这条消息的原始 iLink 载荷。按条 AES 密钥随附件下发,而归一化契约里没有 // 承载它的字段,因此密钥按需从这里回查(见 inboundMediaKey)。 raw []byte } // Fetch 取回并解密一份微信 iLink 附件(**文件、图片、语音、视频同一条路**)。体积上限与 // 超时都不在这里写数:上限按媒体类型由 `mediafetch.MaxBytesFor` 给出(图片 5 MiB、语音 // 5 MiB、文档类附件 20 MiB —— 一份巨图绝不会按文档的 20 MiB 收下),超时是具名常量 // `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限, // 与本切片的内联上限刻意解耦)。 func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { data, err := f.fetch(ctx, m) // 结局取证(DEBUG,见 inbound_debug.go):取道一个出口,成功与每一种失败(含密钥 // 缺失)都不可能漏记;它只记类型/host/字节数/错误类别,绝不记 URL 与错误原文。 f.a.logInboundMediaFetch(m, data, err) return data, err } // fetch 是 Fetch 的实现体:按条密钥 → 白名单取回 → 剥 AES-ECB 层。它从 Fetch 拆出, // 只为让上面那条结局取证恰好打一次。 func (f ilinkFetcher) fetch(ctx context.Context, m adapter.Media) ([]byte, error) { key, err := f.inboundKey(m) if err != nil { return nil, err } ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{ URL: f.a.cdnURL(m.URL), Allow: f.a.assetAllowlist(), Header: f.a.inboundHeaders(), Client: f.a.httpClient, MaxBytes: mediafetch.MaxBytesFor(m), Timeout: mediafetch.DefaultTimeout, }) if err != nil { return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err) } plaintext, err := aesECBDecrypt(key, ciphertext) if err != nil { return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err) } 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), // 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。 // // 它对**所有**媒体类型一视同仁:文件、图片、语音、视频都经 `ilinkFetcher.Fetch` 取回, // 因此图片 URL(绝对 `full_url` 或 CDN 根下的相对引用)同样受这一份白名单约束 —— // 图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限,不是更宽的网络可达面。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL) if err != nil { return mediafetch.NewAllowlist() } return mediafetch.NewAllowlist(origin) }