| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- // Copyright (c) 2021 Tulir Asokan
- //
- // This Source Code Form is subject to the terms of the Mozilla Public
- // License, v. 2.0. If a copy of the MPL was not distributed with this
- // file, You can obtain one at http://mozilla.org/MPL/2.0/.
- package whatsmeow
- import (
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "strings"
- "time"
- "go.mau.fi/util/retryafter"
- "google.golang.org/protobuf/proto"
- "google.golang.org/protobuf/reflect/protoreflect"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waMediaTransport"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/util/cbcutil"
- "go.mau.fi/whatsmeow/util/hkdfutil"
- )
- // MediaType represents a type of uploaded file on WhatsApp.
- // The value is the key which is used as a part of generating the encryption keys.
- type MediaType string
- // The known media types
- const (
- MediaImage MediaType = "WhatsApp Image Keys"
- MediaVideo MediaType = "WhatsApp Video Keys"
- MediaAudio MediaType = "WhatsApp Audio Keys"
- MediaDocument MediaType = "WhatsApp Document Keys"
- MediaHistory MediaType = "WhatsApp History Keys"
- MediaAppState MediaType = "WhatsApp App State Keys"
- MediaStickerPack MediaType = "WhatsApp Sticker Pack Keys"
- MediaLinkThumbnail MediaType = "WhatsApp Link Thumbnail Keys"
- )
- // DownloadableMessage represents a protobuf message that contains attachment info.
- //
- // All of the downloadable messages inside a Message struct implement this interface
- // (ImageMessage, VideoMessage, AudioMessage, DocumentMessage, StickerMessage).
- type DownloadableMessage interface {
- GetDirectPath() string
- GetMediaKey() []byte
- GetFileSHA256() []byte
- GetFileEncSHA256() []byte
- }
- type MediaTypeable interface {
- GetMediaType() MediaType
- }
- // DownloadableThumbnail represents a protobuf message that contains a thumbnail attachment.
- //
- // This is primarily meant for link preview thumbnails in ExtendedTextMessage.
- type DownloadableThumbnail interface {
- proto.Message
- GetThumbnailDirectPath() string
- GetThumbnailSHA256() []byte
- GetThumbnailEncSHA256() []byte
- GetMediaKey() []byte
- }
- // All the message types that are intended to be downloadable
- var (
- _ DownloadableMessage = (*waE2E.ImageMessage)(nil)
- _ DownloadableMessage = (*waE2E.AudioMessage)(nil)
- _ DownloadableMessage = (*waE2E.VideoMessage)(nil)
- _ DownloadableMessage = (*waE2E.DocumentMessage)(nil)
- _ DownloadableMessage = (*waE2E.StickerMessage)(nil)
- _ DownloadableMessage = (*waE2E.StickerPackMessage)(nil)
- _ DownloadableMessage = (*waHistorySync.StickerMetadata)(nil)
- _ DownloadableMessage = (*waE2E.HistorySyncNotification)(nil)
- _ DownloadableMessage = (*waServerSync.ExternalBlobReference)(nil)
- _ DownloadableThumbnail = (*waE2E.ExtendedTextMessage)(nil)
- )
- type downloadableMessageWithLength interface {
- DownloadableMessage
- GetFileLength() uint64
- }
- type downloadableMessageWithSizeBytes interface {
- DownloadableMessage
- GetFileSizeBytes() uint64
- }
- type downloadableMessageWithURL interface {
- DownloadableMessage
- GetURL() string
- }
- var classToMediaType = map[protoreflect.Name]MediaType{
- "ImageMessage": MediaImage,
- "AudioMessage": MediaAudio,
- "VideoMessage": MediaVideo,
- "DocumentMessage": MediaDocument,
- "StickerMessage": MediaImage,
- "StickerMetadata": MediaImage,
- "StickerPackMessage": MediaStickerPack,
- "HistorySyncNotification": MediaHistory,
- "ExternalBlobReference": MediaAppState,
- }
- var classToThumbnailMediaType = map[protoreflect.Name]MediaType{
- "ExtendedTextMessage": MediaLinkThumbnail,
- }
- var mediaTypeToMMSType = map[MediaType]string{
- MediaImage: "image",
- MediaAudio: "audio",
- MediaVideo: "video",
- MediaDocument: "document",
- MediaHistory: "md-msg-hist",
- MediaAppState: "md-app-state",
- MediaStickerPack: "sticker-pack",
- MediaLinkThumbnail: "thumbnail-link",
- }
- // DownloadAny loops through the downloadable parts of the given message and downloads the first non-nil item.
- //
- // Deprecated: it's recommended to find the specific message type you want to download manually and use the Download method instead.
- func (cli *Client) DownloadAny(ctx context.Context, msg *waE2E.Message) (data []byte, err error) {
- if msg == nil {
- return nil, ErrNothingDownloadableFound
- }
- switch {
- case msg.ImageMessage != nil:
- return cli.Download(ctx, msg.ImageMessage)
- case msg.VideoMessage != nil:
- return cli.Download(ctx, msg.VideoMessage)
- case msg.AudioMessage != nil:
- return cli.Download(ctx, msg.AudioMessage)
- case msg.DocumentMessage != nil:
- return cli.Download(ctx, msg.DocumentMessage)
- case msg.StickerMessage != nil:
- return cli.Download(ctx, msg.StickerMessage)
- default:
- return nil, ErrNothingDownloadableFound
- }
- }
- func getSize(msg DownloadableMessage) int {
- switch sized := msg.(type) {
- case downloadableMessageWithLength:
- return int(sized.GetFileLength())
- case downloadableMessageWithSizeBytes:
- return int(sized.GetFileSizeBytes())
- default:
- return -1
- }
- }
- // ReturnDownloadWarnings controls whether the Download function returns non-fatal validation warnings.
- // Currently, these include [ErrFileLengthMismatch] and [ErrInvalidMediaSHA256].
- var ReturnDownloadWarnings = true
- // DownloadThumbnail downloads a thumbnail from a message.
- //
- // This is primarily intended for downloading link preview thumbnails, which are in ExtendedTextMessage:
- //
- // var msg *waE2E.Message
- // ...
- // thumbnailImageBytes, err := cli.DownloadThumbnail(msg.GetExtendedTextMessage())
- func (cli *Client) DownloadThumbnail(ctx context.Context, msg DownloadableThumbnail) ([]byte, error) {
- mediaType, ok := classToThumbnailMediaType[msg.ProtoReflect().Descriptor().Name()]
- if !ok {
- return nil, fmt.Errorf("%w '%s'", ErrUnknownMediaType, string(msg.ProtoReflect().Descriptor().Name()))
- } else if len(msg.GetThumbnailDirectPath()) > 0 {
- return cli.DownloadMediaWithPath(ctx, msg.GetThumbnailDirectPath(), msg.GetThumbnailEncSHA256(), msg.GetThumbnailSHA256(), msg.GetMediaKey(), -1, mediaType, mediaTypeToMMSType[mediaType])
- } else {
- return nil, ErrNoURLPresent
- }
- }
- // GetMediaType returns the MediaType value corresponding to the given protobuf message.
- func GetMediaType(msg DownloadableMessage) MediaType {
- protoReflecter, ok := msg.(proto.Message)
- if !ok {
- mediaTypeable, ok := msg.(MediaTypeable)
- if !ok {
- return ""
- }
- return mediaTypeable.GetMediaType()
- }
- return classToMediaType[protoReflecter.ProtoReflect().Descriptor().Name()]
- }
- // Download downloads the attachment from the given protobuf message.
- //
- // The attachment is a specific part of a Message protobuf struct, not the message itself, e.g.
- //
- // var msg *waE2E.Message
- // ...
- // imageData, err := cli.Download(msg.GetImageMessage())
- //
- // You can also use DownloadAny to download the first non-nil sub-message.
- func (cli *Client) Download(ctx context.Context, msg DownloadableMessage) ([]byte, error) {
- if cli == nil {
- return nil, ErrClientIsNil
- }
- mediaType := GetMediaType(msg)
- if mediaType == "" {
- return nil, fmt.Errorf("%w %T", ErrUnknownMediaType, msg)
- }
- urlable, ok := msg.(downloadableMessageWithURL)
- var url string
- var isWebWhatsappNetURL bool
- if ok {
- url = urlable.GetURL()
- isWebWhatsappNetURL = strings.HasPrefix(url, "https://web.whatsapp.net")
- }
- if len(url) > 0 && !isWebWhatsappNetURL {
- return cli.downloadAndDecrypt(ctx, url, msg.GetMediaKey(), mediaType, getSize(msg), msg.GetFileEncSHA256(), msg.GetFileSHA256())
- } else if len(msg.GetDirectPath()) > 0 {
- return cli.DownloadMediaWithPath(ctx, msg.GetDirectPath(), msg.GetFileEncSHA256(), msg.GetFileSHA256(), msg.GetMediaKey(), getSize(msg), mediaType, mediaTypeToMMSType[mediaType])
- } else {
- if isWebWhatsappNetURL {
- cli.Log.Warnf("Got a media message with a web.whatsapp.net URL (%s) and no direct path", url)
- }
- return nil, ErrNoURLPresent
- }
- }
- func (cli *Client) DownloadFB(
- ctx context.Context,
- transport *waMediaTransport.WAMediaTransport_Integral,
- mediaType MediaType,
- ) ([]byte, error) {
- return cli.DownloadMediaWithPath(ctx, transport.GetDirectPath(), transport.GetFileEncSHA256(), transport.GetFileSHA256(), transport.GetMediaKey(), -1, mediaType, mediaTypeToMMSType[mediaType])
- }
- // DownloadMediaWithPath downloads an attachment by manually specifying the path and encryption details.
- func (cli *Client) DownloadMediaWithPath(
- ctx context.Context,
- directPath string,
- encFileHash, fileHash, mediaKey []byte,
- fileLength int,
- mediaType MediaType,
- mmsType string,
- ) (data []byte, err error) {
- if !strings.HasPrefix(directPath, "/") {
- return nil, fmt.Errorf("media download path does not start with slash: %s", directPath)
- }
- var mediaConn *MediaConn
- mediaConn, err = cli.refreshMediaConn(ctx, false)
- if err != nil {
- return nil, fmt.Errorf("failed to refresh media connections: %w", err)
- }
- if len(mmsType) == 0 {
- mmsType = mediaTypeToMMSType[mediaType]
- }
- for i, host := range mediaConn.Hosts {
- // TODO omit hash for unencrypted media?
- mediaURL := fmt.Sprintf("https://%s%s&hash=%s&mms-type=%s&__wa-mms=", host.Hostname, directPath, base64.URLEncoding.EncodeToString(encFileHash), mmsType)
- data, err = cli.downloadAndDecrypt(ctx, mediaURL, mediaKey, mediaType, fileLength, encFileHash, fileHash)
- if err == nil ||
- errors.Is(err, ErrFileLengthMismatch) ||
- errors.Is(err, ErrInvalidMediaSHA256) ||
- errors.Is(err, ErrMediaDownloadFailedWith403) ||
- errors.Is(err, ErrMediaDownloadFailedWith404) ||
- errors.Is(err, ErrMediaDownloadFailedWith410) ||
- errors.Is(err, context.Canceled) {
- return
- } else if i >= len(mediaConn.Hosts)-1 {
- return nil, fmt.Errorf("failed to download media from last host: %w", err)
- }
- cli.Log.Warnf("Failed to download media: %s, trying with next host...", err)
- }
- return
- }
- func (cli *Client) downloadAndDecrypt(
- ctx context.Context,
- url string,
- mediaKey []byte,
- appInfo MediaType,
- fileLength int,
- fileEncSHA256,
- fileSHA256 []byte,
- ) (data []byte, err error) {
- iv, cipherKey, macKey, _ := getMediaKeys(mediaKey, appInfo)
- var ciphertext, mac []byte
- if ciphertext, mac, err = cli.downloadPossiblyEncryptedMediaWithRetries(ctx, url, fileEncSHA256); err != nil {
- } else if mediaKey == nil && fileEncSHA256 == nil && mac == nil {
- // Unencrypted media, just return the downloaded data
- data = ciphertext
- } else if err = validateMedia(iv, ciphertext, macKey, mac); err != nil {
- } else if data, err = cbcutil.Decrypt(cipherKey, iv, ciphertext); err != nil {
- err = fmt.Errorf("failed to decrypt file: %w", err)
- } else if ReturnDownloadWarnings {
- if fileLength >= 0 && len(data) != fileLength {
- err = fmt.Errorf("%w: expected %d, got %d", ErrFileLengthMismatch, fileLength, len(data))
- } else if len(fileSHA256) == 32 && sha256.Sum256(data) != *(*[32]byte)(fileSHA256) {
- err = ErrInvalidMediaSHA256
- }
- }
- return
- }
- func getMediaKeys(mediaKey []byte, appInfo MediaType) (iv, cipherKey, macKey, refKey []byte) {
- mediaKeyExpanded := hkdfutil.SHA256(mediaKey, nil, []byte(appInfo), 112)
- return mediaKeyExpanded[:16], mediaKeyExpanded[16:48], mediaKeyExpanded[48:80], mediaKeyExpanded[80:]
- }
- func shouldRetryMediaDownload(err error) bool {
- if errors.Is(err, context.Canceled) {
- return false
- }
- var netErr net.Error
- var httpErr DownloadHTTPError
- return errors.As(err, &netErr) ||
- strings.HasPrefix(err.Error(), "stream error:") || // hacky check for http2 errors
- (errors.As(err, &httpErr) && retryafter.Should(httpErr.StatusCode, true))
- }
- func (cli *Client) downloadPossiblyEncryptedMediaWithRetries(ctx context.Context, url string, checksum []byte) (file, mac []byte, err error) {
- for retryNum := 0; retryNum < 5; retryNum++ {
- if checksum == nil {
- file, err = cli.downloadMedia(ctx, url)
- } else {
- file, mac, err = cli.downloadEncryptedMedia(ctx, url, checksum)
- }
- if err == nil || !shouldRetryMediaDownload(err) {
- return
- }
- retryDuration := time.Duration(retryNum+1) * time.Second
- var httpErr DownloadHTTPError
- if errors.As(err, &httpErr) {
- retryDuration = retryafter.Parse(httpErr.Response.Header.Get("Retry-After"), retryDuration)
- }
- cli.Log.Warnf("Failed to download media due to network error: %v, retrying in %s...", err, retryDuration)
- select {
- case <-ctx.Done():
- return nil, nil, ctx.Err()
- case <-time.After(retryDuration):
- }
- }
- return
- }
- func (cli *Client) doMediaDownloadRequest(ctx context.Context, url string) (*http.Response, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to prepare request: %w", err)
- }
- req.Header.Set("Origin", socket.Origin)
- req.Header.Set("Referer", socket.Origin+"/")
- if cli.MessengerConfig != nil {
- req.Header.Set("User-Agent", cli.MessengerConfig.UserAgent)
- }
- // TODO user agent for whatsapp downloads?
- resp, err := cli.mediaHTTP.Do(req)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- _ = resp.Body.Close()
- return nil, DownloadHTTPError{Response: resp}
- }
- return resp, nil
- }
- func (cli *Client) downloadMedia(ctx context.Context, url string) ([]byte, error) {
- resp, err := cli.doMediaDownloadRequest(ctx, url)
- if err != nil {
- return nil, err
- }
- data, err := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- return data, err
- }
- const mediaHMACLength = 10
- func (cli *Client) downloadEncryptedMedia(ctx context.Context, url string, checksum []byte) (file, mac []byte, err error) {
- data, err := cli.downloadMedia(ctx, url)
- if err != nil {
- return
- } else if len(data) <= mediaHMACLength {
- err = ErrTooShortFile
- return
- }
- file, mac = data[:len(data)-mediaHMACLength], data[len(data)-mediaHMACLength:]
- if len(checksum) == 32 && sha256.Sum256(data) != *(*[32]byte)(checksum) {
- err = ErrInvalidMediaEncSHA256
- }
- return
- }
- func validateMedia(iv, file, macKey, mac []byte) error {
- h := hmac.New(sha256.New, macKey)
- h.Write(iv)
- h.Write(file)
- if !hmac.Equal(h.Sum(nil)[:mediaHMACLength], mac) {
- return ErrInvalidMediaHMAC
- }
- return nil
- }
|