use crate::libsignal::crypto::{
CryptographicMac, DecryptionError as AesCbcDecryptionError, Error as CryptoError,
aes_256_cbc_decrypt_into,
};
use anyhow::{Result, anyhow};
use base64::Engine as _;
use base64::prelude::*;
use hkdf::Hkdf;
use hmac::Hmac;
use hmac::Mac;
use sha2::Sha256;
use thiserror::Error;
use waproto::whatsapp as wa;
use waproto::whatsapp::ExternalBlobReference;
use waproto::whatsapp::message::HistorySyncNotification;
#[derive(Debug, Error)]
pub enum MediaDecryptionError {
#[error("downloaded file is too short to contain MAC")]
PayloadTooShort,
#[error("invalid MAC signature")]
InvalidMac,
#[error("AES-CBC decryption failed")]
Decryption(#[source] AesCbcDecryptionError),
#[error("HMAC initialization failed")]
Mac(#[source] CryptoError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaType {
Image,
Video,
Audio,
Document,
History,
AppState,
Sticker,
StickerPack,
StickerPackThumbnail,
LinkThumbnail,
ProductCatalogImage,
}
impl MediaType {
pub fn app_info(&self) -> &'static str {
match self {
MediaType::Image => "WhatsApp Image Keys",
MediaType::Video => "WhatsApp Video Keys",
MediaType::Audio => "WhatsApp Audio Keys",
MediaType::Document => "WhatsApp Document Keys",
MediaType::History => "WhatsApp History Keys",
MediaType::AppState => "WhatsApp App State Keys",
MediaType::Sticker => "WhatsApp Image Keys",
MediaType::StickerPack => "WhatsApp Sticker Pack Keys",
MediaType::StickerPackThumbnail => "WhatsApp Sticker Pack Thumbnail Keys",
MediaType::LinkThumbnail => "WhatsApp Link Thumbnail Keys",
MediaType::ProductCatalogImage => "WhatsApp Image Keys",
}
}
pub fn mms_type(&self) -> &'static str {
match self {
MediaType::Image | MediaType::Sticker => "image",
MediaType::Video => "video",
MediaType::Audio => "audio",
MediaType::Document => "document",
MediaType::History => "md-msg-hist",
MediaType::AppState => "md-app-state",
MediaType::StickerPack => "sticker-pack",
MediaType::StickerPackThumbnail => "thumbnail-sticker-pack",
MediaType::LinkThumbnail => "thumbnail-link",
MediaType::ProductCatalogImage => "product-catalog-image",
}
}
pub fn upload_path(&self) -> &'static str {
match self {
MediaType::Image | MediaType::Sticker => "/mms/image",
MediaType::Video => "/mms/video",
MediaType::Audio => "/mms/audio",
MediaType::Document => "/mms/document",
MediaType::History => "/mms/md-msg-hist",
MediaType::AppState => "/mms/md-app-state",
MediaType::StickerPack => "/mms/sticker-pack",
MediaType::StickerPackThumbnail => "/mms/thumbnail-sticker-pack",
MediaType::LinkThumbnail => "/mms/thumbnail-link",
MediaType::ProductCatalogImage => "/product/image",
}
}
pub fn is_encrypted(&self) -> bool {
!matches!(self, MediaType::ProductCatalogImage)
}
}
#[derive(Debug, Clone)]
pub enum MediaDecryption {
Encrypted {
media_key: Vec<u8>,
media_type: MediaType,
},
Plaintext { file_sha256: Vec<u8> },
}
pub trait Downloadable: Sync + Send {
fn direct_path(&self) -> Option<&str>;
fn media_key(&self) -> Option<&[u8]>;
fn file_enc_sha256(&self) -> Option<&[u8]>;
fn file_sha256(&self) -> Option<&[u8]>;
fn file_length(&self) -> Option<u64>;
fn app_info(&self) -> MediaType;
fn static_url(&self) -> Option<&str> {
None
}
fn is_encrypted(&self) -> bool {
self.media_key().is_some()
}
}
macro_rules! impl_downloadable {
(@common $file_length_field:ident, $media_type:expr) => {
fn direct_path(&self) -> Option<&str> {
self.direct_path.as_deref()
}
fn media_key(&self) -> Option<&[u8]> {
self.media_key.as_deref()
}
fn file_enc_sha256(&self) -> Option<&[u8]> {
self.file_enc_sha256.as_deref()
}
fn file_sha256(&self) -> Option<&[u8]> {
self.file_sha256.as_deref()
}
fn file_length(&self) -> Option<u64> {
self.$file_length_field
}
fn app_info(&self) -> MediaType {
$media_type
}
};
($type:ty, $media_type:expr, $file_length_field:ident) => {
impl Downloadable for $type {
impl_downloadable!(@common $file_length_field, $media_type);
}
};
($type:ty, $media_type:expr, $file_length_field:ident, static_url) => {
impl Downloadable for $type {
impl_downloadable!(@common $file_length_field, $media_type);
fn static_url(&self) -> Option<&str> {
self.static_url.as_deref()
}
}
};
}
impl_downloadable!(
wa::message::ImageMessage,
MediaType::Image,
file_length,
static_url
);
impl_downloadable!(
wa::message::VideoMessage,
MediaType::Video,
file_length,
static_url
);
impl_downloadable!(
wa::message::DocumentMessage,
MediaType::Document,
file_length
);
impl_downloadable!(wa::message::AudioMessage, MediaType::Audio, file_length);
impl_downloadable!(wa::message::StickerMessage, MediaType::Sticker, file_length);
impl_downloadable!(
wa::message::StickerPackMessage,
MediaType::StickerPack,
file_length
);
impl_downloadable!(ExternalBlobReference, MediaType::AppState, file_size_bytes);
impl_downloadable!(HistorySyncNotification, MediaType::History, file_length);
#[derive(Debug, Clone)]
pub struct DownloadRequest {
pub url: String,
pub decryption: MediaDecryption,
}
pub struct MediaConnection {
pub hosts: Vec<MediaHost>,
pub auth: String,
}
pub struct MediaHost {
pub hostname: String,
}
pub struct DownloadUtils;
impl DownloadUtils {
pub fn prepare_download_requests(
downloadable: &dyn Downloadable,
media_conn: &MediaConnection,
) -> Result<Vec<DownloadRequest>> {
let is_encrypted = downloadable.is_encrypted();
let media_type = downloadable.app_info();
let decryption = if is_encrypted {
let media_key = downloadable
.media_key()
.ok_or_else(|| anyhow!("Missing media_key for encrypted media"))?
.to_vec();
MediaDecryption::Encrypted {
media_key,
media_type,
}
} else {
let file_sha256 = downloadable
.file_sha256()
.ok_or_else(|| anyhow!("Missing file_sha256 for unencrypted media"))?
.to_vec();
MediaDecryption::Plaintext { file_sha256 }
};
if let Some(static_url) = downloadable.static_url() {
return Ok(vec![DownloadRequest {
url: static_url.to_string(),
decryption,
}]);
}
let direct_path = downloadable
.direct_path()
.ok_or_else(|| anyhow!("Missing direct_path"))?;
let token = if is_encrypted {
let hash = downloadable
.file_enc_sha256()
.ok_or_else(|| anyhow!("Missing file_enc_sha256"))?;
BASE64_URL_SAFE_NO_PAD.encode(hash)
} else {
let hash = downloadable
.file_sha256()
.ok_or_else(|| anyhow!("Missing file_sha256 for unencrypted media"))?;
BASE64_URL_SAFE_NO_PAD.encode(hash)
};
let requests = media_conn
.hosts
.iter()
.map(|host| DownloadRequest {
url: format!(
"https://{}{direct_path}?auth={}&token={token}",
host.hostname, media_conn.auth,
),
decryption: decryption.clone(),
})
.collect();
Ok(requests)
}
pub fn validate_plaintext_sha256(data: &[u8], expected_sha256: &[u8]) -> Result<()> {
use sha2::Digest;
let actual = Sha256::digest(data);
if actual.as_slice() != expected_sha256 {
return Err(anyhow!(
"SHA-256 mismatch for plaintext media: expected {}, got {}",
hex::encode(expected_sha256),
hex::encode(actual),
));
}
Ok(())
}
pub fn copy_and_validate_plaintext_to_writer<R: std::io::Read, W: std::io::Write>(
mut reader: R,
expected_sha256: &[u8],
writer: &mut W,
) -> Result<u64> {
use sha2::Digest;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8 * 1024];
let mut total: u64 = 0;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
writer.write_all(&buf[..n])?;
total += n as u64;
}
let actual = hasher.finalize();
if actual.as_slice() != expected_sha256 {
return Err(anyhow!("SHA-256 mismatch for plaintext media"));
}
Ok(total)
}
pub fn decrypt_stream_to_writer<R: std::io::Read, W: std::io::Write>(
mut reader: R,
media_key: &[u8],
app_info: MediaType,
writer: &mut W,
) -> Result<u64> {
use aes::Aes256;
use aes::cipher::KeyInit;
const MAC_SIZE: usize = 10;
const BLOCK: usize = 16;
const CHUNK: usize = 8 * 1024;
fn decrypt_cbc_block(
cblock: &[u8],
cipher: &Aes256,
prev_block: &[u8; BLOCK],
) -> Result<([u8; BLOCK], [u8; BLOCK])> {
use aes::cipher::{Block, BlockCipherDecrypt};
let cblock_arr: [u8; BLOCK] = cblock
.try_into()
.map_err(|_| anyhow!("Invalid block size"))?;
let mut block: Block<Aes256> = cblock_arr.into();
cipher.decrypt_block(&mut block);
let mut decrypted: [u8; BLOCK] = block.into();
for (b, &p) in decrypted.iter_mut().zip(prev_block.iter()) {
*b ^= p;
}
Ok((decrypted, cblock_arr))
}
let (iv, cipher_key, mac_key) = Self::get_media_keys(media_key, app_info)?;
let mut hmac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(&mac_key)
.map_err(|_| anyhow!("Failed to init HMAC"))?;
hmac.update(&iv);
let cipher =
Aes256::new_from_slice(&cipher_key).map_err(|_| anyhow!("Bad AES key length"))?;
let mut bytes_written: u64 = 0;
let mut tail: Vec<u8> = Vec::with_capacity(CHUNK + BLOCK + MAC_SIZE);
let mut prev_block = iv;
let mut read_buf = [0u8; CHUNK];
loop {
let n = reader.read(&mut read_buf)?;
if n == 0 {
break;
}
tail.extend_from_slice(&read_buf[..n]);
if tail.len() > MAC_SIZE + BLOCK {
let mut processable_len = tail.len() - (MAC_SIZE + BLOCK);
processable_len -= processable_len % BLOCK;
if processable_len >= BLOCK {
hmac.update(&tail[..processable_len]);
for cblock in tail[..processable_len].chunks_exact(BLOCK) {
let (decrypted, cblock_arr) =
decrypt_cbc_block(cblock, &cipher, &prev_block)?;
writer.write_all(&decrypted)?;
bytes_written += BLOCK as u64;
prev_block = cblock_arr;
}
tail.drain(..processable_len);
}
}
}
if tail.len() < MAC_SIZE + BLOCK || !(tail.len() - MAC_SIZE).is_multiple_of(BLOCK) {
return Err(anyhow!("Invalid final media size"));
}
let mac_index = tail.len() - MAC_SIZE;
let (final_ciphertext, mac_bytes) = tail.split_at(mac_index);
hmac.update(final_ciphertext);
let expected_mac_full = hmac.finalize().into_bytes();
let expected_mac = &expected_mac_full[..MAC_SIZE];
if mac_bytes != expected_mac {
return Err(anyhow!("MAC mismatch"));
}
let mut final_plain = Vec::with_capacity(final_ciphertext.len());
for cblock in final_ciphertext.chunks_exact(BLOCK) {
let (decrypted, cblock_arr) = decrypt_cbc_block(cblock, &cipher, &prev_block)?;
final_plain.extend_from_slice(&decrypted);
prev_block = cblock_arr;
}
let pad_len = match final_plain.last() {
Some(&v) => v as usize,
None => return Err(anyhow!("Empty plaintext after decrypt")),
};
if pad_len == 0 || pad_len > BLOCK || pad_len > final_plain.len() {
return Err(anyhow!("Invalid PKCS7 padding"));
}
if !final_plain[final_plain.len() - pad_len..]
.iter()
.all(|&b| b as usize == pad_len)
{
return Err(anyhow!("Bad PKCS7 padding bytes"));
}
final_plain.truncate(final_plain.len() - pad_len);
writer.write_all(&final_plain)?;
bytes_written += final_plain.len() as u64;
Ok(bytes_written)
}
pub fn decrypt_stream<R: std::io::Read>(
reader: R,
media_key: &[u8],
app_info: MediaType,
) -> Result<Vec<u8>> {
let mut buf = Vec::new();
Self::decrypt_stream_to_writer(reader, media_key, app_info, &mut buf)?;
Ok(buf)
}
pub fn get_media_keys(
media_key: &[u8],
app_info: MediaType,
) -> Result<([u8; 16], [u8; 32], [u8; 32])> {
let hk = Hkdf::<Sha256>::new(None, media_key);
let mut expanded = [0u8; 112];
hk.expand(app_info.app_info().as_bytes(), &mut expanded)
.map_err(|e| anyhow!("HKDF expand failed: {e}"))?;
let iv: [u8; 16] = expanded[0..16]
.try_into()
.map_err(|_| anyhow!("HKDF output has unexpected length for IV"))?;
let cipher_key: [u8; 32] = expanded[16..48]
.try_into()
.map_err(|_| anyhow!("HKDF output has unexpected length for cipher key"))?;
let mac_key: [u8; 32] = expanded[48..80]
.try_into()
.map_err(|_| anyhow!("HKDF output has unexpected length for MAC key"))?;
Ok((iv, cipher_key, mac_key))
}
pub fn decrypt_cbc(cipher_key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
let mut output = Vec::new();
aes_256_cbc_decrypt_into(ciphertext, cipher_key, iv, &mut output)
.map_err(anyhow::Error::new)?;
Ok(output)
}
pub fn verify_and_decrypt(
encrypted_payload: &[u8],
media_key: &[u8],
media_type: MediaType,
) -> std::result::Result<Vec<u8>, MediaDecryptionError> {
const MAC_SIZE: usize = 10;
if encrypted_payload.len() <= MAC_SIZE {
return Err(MediaDecryptionError::PayloadTooShort);
}
let (ciphertext, received_mac) =
encrypted_payload.split_at(encrypted_payload.len() - MAC_SIZE);
let (iv, cipher_key, mac_key) = Self::get_media_keys(media_key, media_type)?;
let computed_mac_full = {
let mut mac =
CryptographicMac::new("HmacSha256", &mac_key).map_err(MediaDecryptionError::Mac)?;
mac.update(&iv);
mac.update(ciphertext);
mac.finalize()
};
if &computed_mac_full[..MAC_SIZE] != received_mac {
return Err(MediaDecryptionError::InvalidMac);
}
let mut output = Vec::new();
aes_256_cbc_decrypt_into(ciphertext, &cipher_key, &iv, &mut output)
.map_err(MediaDecryptionError::Decryption)?;
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockDownloadable {
direct_path: Option<String>,
static_url: Option<String>,
media_key: Option<Vec<u8>>,
file_sha256: Option<Vec<u8>>,
file_enc_sha256: Option<Vec<u8>>,
media_type: MediaType,
}
impl Downloadable for MockDownloadable {
fn direct_path(&self) -> Option<&str> {
self.direct_path.as_deref()
}
fn media_key(&self) -> Option<&[u8]> {
self.media_key.as_deref()
}
fn file_enc_sha256(&self) -> Option<&[u8]> {
self.file_enc_sha256.as_deref()
}
fn file_sha256(&self) -> Option<&[u8]> {
self.file_sha256.as_deref()
}
fn file_length(&self) -> Option<u64> {
Some(1024)
}
fn app_info(&self) -> MediaType {
self.media_type
}
fn static_url(&self) -> Option<&str> {
self.static_url.as_deref()
}
}
fn mock_media_conn() -> MediaConnection {
MediaConnection {
hosts: vec![
MediaHost {
hostname: "cdn1.example.com".into(),
},
MediaHost {
hostname: "cdn2.example.com".into(),
},
],
auth: "test-auth-token".into(),
}
}
#[test]
fn prepare_requests_encrypted() {
let d = MockDownloadable {
direct_path: Some("/v/t1/media.enc".into()),
static_url: None,
media_key: Some(vec![1; 32]),
file_sha256: Some(vec![2; 32]),
file_enc_sha256: Some(vec![3; 32]),
media_type: MediaType::Image,
};
let reqs = DownloadUtils::prepare_download_requests(&d, &mock_media_conn()).unwrap();
assert_eq!(reqs.len(), 2);
assert!(matches!(
&reqs[0].decryption,
MediaDecryption::Encrypted { media_type, .. } if *media_type == MediaType::Image
));
let expected_token = BASE64_URL_SAFE_NO_PAD.encode([3u8; 32]);
assert!(reqs[0].url.contains(&expected_token));
assert!(reqs[0].url.starts_with("https://cdn1.example.com"));
assert!(reqs[1].url.starts_with("https://cdn2.example.com"));
}
#[test]
fn prepare_requests_plaintext_newsletter() {
let d = MockDownloadable {
direct_path: Some("/newsletter/newsletter-image/abc".into()),
static_url: None,
media_key: None,
file_sha256: Some(vec![4; 32]),
file_enc_sha256: None,
media_type: MediaType::Image,
};
let reqs = DownloadUtils::prepare_download_requests(&d, &mock_media_conn()).unwrap();
assert_eq!(reqs.len(), 2);
assert!(matches!(
&reqs[0].decryption,
MediaDecryption::Plaintext { file_sha256 } if file_sha256 == &vec![4u8; 32]
));
let expected_token = BASE64_URL_SAFE_NO_PAD.encode([4u8; 32]);
assert!(reqs[0].url.contains(&expected_token));
}
#[test]
fn prepare_requests_static_url() {
let d = MockDownloadable {
direct_path: Some("/unused".into()),
static_url: Some("https://static.cdn.example.com/media/abc123".into()),
media_key: None,
file_sha256: Some(vec![5; 32]),
file_enc_sha256: None,
media_type: MediaType::Image,
};
let reqs = DownloadUtils::prepare_download_requests(&d, &mock_media_conn()).unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].url, "https://static.cdn.example.com/media/abc123");
assert!(matches!(
&reqs[0].decryption,
MediaDecryption::Plaintext { .. }
));
}
#[test]
fn prepare_requests_missing_direct_path_no_static_url() {
let d = MockDownloadable {
direct_path: None,
static_url: None,
media_key: Some(vec![1; 32]),
file_sha256: Some(vec![2; 32]),
file_enc_sha256: Some(vec![3; 32]),
media_type: MediaType::Image,
};
let err = DownloadUtils::prepare_download_requests(&d, &mock_media_conn()).unwrap_err();
assert!(err.to_string().contains("Missing direct_path"));
}
#[test]
fn validate_plaintext_sha256_ok() {
use sha2::Digest;
let data = b"test newsletter media content";
let hash = Sha256::digest(data);
assert!(DownloadUtils::validate_plaintext_sha256(data, hash.as_slice()).is_ok());
}
#[test]
fn validate_plaintext_sha256_mismatch() {
let data = b"test newsletter media content";
let wrong_hash = vec![0u8; 32];
let err = DownloadUtils::validate_plaintext_sha256(data, &wrong_hash).unwrap_err();
assert!(err.to_string().contains("SHA-256 mismatch"));
}
#[test]
fn copy_and_validate_plaintext_ok() {
use sha2::Digest;
use std::io::Cursor;
let data = b"streaming newsletter content";
let hash = Sha256::digest(data);
let reader = Cursor::new(data.to_vec());
let mut writer = Vec::new();
let bytes = DownloadUtils::copy_and_validate_plaintext_to_writer(
reader,
hash.as_slice(),
&mut writer,
)
.unwrap();
assert_eq!(bytes, data.len() as u64);
assert_eq!(writer, data);
}
#[test]
fn copy_and_validate_plaintext_mismatch() {
use std::io::Cursor;
let data = b"streaming newsletter content";
let wrong_hash = vec![0u8; 32];
let reader = Cursor::new(data.to_vec());
let mut writer = Vec::new();
let err =
DownloadUtils::copy_and_validate_plaintext_to_writer(reader, &wrong_hash, &mut writer)
.unwrap_err();
assert!(err.to_string().contains("SHA-256 mismatch"));
}
#[test]
fn media_decryption_decryption_preserves_aes_cbc_source() {
let inner = AesCbcDecryptionError::BadKeyOrIv;
let mde = MediaDecryptionError::Decryption(inner);
let src = std::error::Error::source(&mde).expect("source preserved");
let cbc = src
.downcast_ref::<AesCbcDecryptionError>()
.expect("downcasts to AesCbcDecryptionError");
assert!(matches!(cbc, AesCbcDecryptionError::BadKeyOrIv));
}
#[test]
fn media_decryption_mac_preserves_crypto_error_source() {
let inner = CryptoError::UnknownAlgorithm("MAC", "BogusAlg".into());
let mde = MediaDecryptionError::Mac(inner);
let src = std::error::Error::source(&mde).expect("source preserved");
let ce = src
.downcast_ref::<CryptoError>()
.expect("downcasts to CryptoError");
assert!(matches!(ce, CryptoError::UnknownAlgorithm("MAC", _)));
}
}