package weixin_ilink import ( "context" "crypto/aes" "encoding/base64" "errors" "fmt" "io" "net/http" "strings" ) // ErrKeyTooShort is returned when the inbound derivation cannot yield an AES // key of the required block size. var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block") // deriveInboundKey derives the AES-128 key for an inbound media payload. Per // design/08 §3.3 the key is extracted from the bot_token: base64-decode the // token and take the trailing 16 bytes. This is marked ⚠️ in the reference, so // DownloadMediaWithKey lets callers supply an explicit key when the platform // carries one per item instead. func deriveInboundKey(botToken string) ([]byte, error) { token := strings.TrimSpace(botToken) if token == "" { return nil, ErrInvalidConfig } b, err := base64.StdEncoding.DecodeString(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 } // DownloadMedia downloads an inbound CDN reference and returns the decrypted // plaintext using the key derived from the configured bot_token. It fails with // ErrMediaTooLarge when the ciphertext exceeds MaxMediaSize. func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) { key, err := deriveInboundKey(a.cfg.BotToken) if err != nil { return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err) } return a.downloadWithKey(ctx, cdnURL, key) } // DownloadMediaWithKey is DownloadMedia with an explicit per-item AES key, // used when the protocol delivers the key alongside the media reference. 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 fetches the CDN bytes and removes the AES-ECB layer. 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 GETs rawURL and returns the body, refusing anything over // MaxMediaSize so a single huge file cannot exhaust memory. 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 }