Sign in Sign up
Branches
master
207 lines (190 loc) · 8.6 KB Raw
// Sticker + sticker-pack message builders, extracted from the Inkwhats WhatsApp
// client. These build the protobuf `Message` payloads that carry an animated
// (Lottie `.was`) sticker and a user-created sticker pack, and upload the media.
// `Writer` is a minimal protobuf writer (field_varint/field_bytes); `MediaEnc`
// is the result of encrypt_media(). Field numbers verified against WhatsApp
// Android 2.26.35.71.


/// Result of media encryption, ready to upload + reference in a message.
pub struct MediaEnc {
    pub enc_file: Vec<u8>, // ciphertext ‖ mac(10) — this is what gets uploaded
    pub media_key: [u8; 32],
    pub file_sha256: [u8; 32],
    pub file_enc_sha256: [u8; 32],
    pub file_length: u64,
}

/// Encrypt a media file. `label` selects the per-type HKDF domain.
pub fn encrypt_media(file: &[u8], label: &[u8], media_key: [u8; 32]) -> MediaEnc {
    let mut expanded = [0u8; 112];
    hkdf::<Sha256>(&[], &media_key, label, &mut expanded);
    let iv: [u8; 16] = expanded[0..16].try_into().unwrap();
    let mut cipher_key = [0u8; 32];
    let mut mac_key = [0u8; 32];
    cipher_key.copy_from_slice(&expanded[16..48]);
    mac_key.copy_from_slice(&expanded[48..80]);

    let ciphertext = cbc_encrypt_pkcs7(&cipher_key, &iv, file);
    // MAC covers iv ‖ ciphertext, truncated to 10 bytes.
    let mut mac_input = Vec::with_capacity(16 + ciphertext.len());
    mac_input.extend_from_slice(&iv);
    mac_input.extend_from_slice(&ciphertext);
    let mac = hmac_sha256(&mac_key, &mac_input);

    let mut enc_file = ciphertext;
    enc_file.extend_from_slice(&mac[..10]);
    let file_enc_sha256 = sha256(&enc_file);

    MediaEnc {
        file_length: file.len() as u64,
        file_sha256: sha256(file),
        file_enc_sha256,
        media_key,
        enc_file,
    }
}

// --------------------------- Message builders ------------------------------

/// A plain text message: `Message { conversation = text }`.

pub const LABEL_STICKER_PACK: &[u8] = b"WhatsApp Sticker Pack Keys";
pub const LABEL_STICKER_PACK_THUMB: &[u8] = b"WhatsApp Sticker Pack Thumbnail Keys";

/// Upload an encrypted blob to `https://<host>/mms/<kind>/<token>` (the kind is
/// the mms type: `sticker`, `sticker-pack`, `thumbnail-sticker-pack`, `audio`, …).
/// Goes through curl like downloads do; the blob is piped on stdin.
/// Returns (url, direct_path).
pub fn upload(host: &str, auth: &str, kind: &str, enc: &MediaEnc) -> Result<(String, String), String> {
    use std::io::Write;
    use std::process::{Command, Stdio};
    let token = super::b64::encode_url_nopad(&enc.file_enc_sha256);
    let url = format!("https://{host}/mms/{kind}/{token}?auth={auth}&token={token}");
    let mut child = Command::new("curl")
        .args(["-s", "--max-time", "120", "-X", "POST", "-H", "Content-Type: application/octet-stream", "--data-binary", "@-", &url])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|e| format!("curl: {e}"))?;
    child.stdin.take().ok_or("curl stdin")?.write_all(&enc.enc_file).map_err(|e| format!("upload: {e}"))?;
    let out = child.wait_with_output().map_err(|e| format!("curl: {e}"))?;
    let resp = String::from_utf8_lossy(&out.stdout).to_string();
    match (json_text(&resp, "url"), json_text(&resp, "direct_path")) {
        (Some(u), Some(d)) => Ok((u.to_string(), d.to_string())),
        _ => Err(format!("{kind} upload refused: {}", &resp[..resp.len().min(160)])),
    }
}

fn json_text<'a>(s: &'a str, key: &str) -> Option<&'a str> {
    let i = s.find(&format!("\"{key}\""))?;
    let rest = &s[i + key.len() + 2..];
    let after = rest[rest.find(':')? + 1..].trim_start();
    let q = after.strip_prefix('"')?;
    Some(&q[..q.find('"')?])
}

fn unix_now() -> std::time::Duration {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default()
}

/// Animated (Lottie) sticker: `Message { stickerMessage = 26 }`, filled the way
/// the official client does. StickerMessage (2.26.35.71): url=1 fileSha256=2
/// fileEncSha256=3 mediaKey=4 mimetype=5 height=6 width=7 directPath=8
/// fileLength=9 mediaKeyTimestamp=10 isAnimated=13 stickerSentTs=18 isAvatar=19
/// isAiSticker=20 isLottie=21 premium=24. The .was is uploaded as mms `sticker`
/// under the image label.
pub fn lottie_sticker_message(url: &str, direct_path: &str, m: &MediaEnc, premium: u32) -> Vec<u8> {
    let now = unix_now();
    let mut s = Writer::new();
    s.field_bytes(1, url.as_bytes());
    s.field_bytes(2, &m.file_sha256);
    s.field_bytes(3, &m.file_enc_sha256);
    s.field_bytes(4, &m.media_key);
    s.field_bytes(5, b"application/was");
    s.field_varint(6, 512);
    s.field_varint(7, 512);
    s.field_bytes(8, direct_path.as_bytes());
    s.field_varint(9, m.file_length);
    s.field_varint(10, now.as_secs());
    s.field_varint(13, 1);
    s.field_varint(18, now.as_millis() as u64);
    s.field_varint(19, 0);
    s.field_varint(20, 0);
    s.field_varint(21, 1);
    s.field_varint(24, premium as u64);
    let mut msg = Writer::new();
    msg.field_bytes(26, &s.into_bytes());
    msg.into_bytes()
}

/// One sticker in a pack zip: its entry name ("<sha>.was"), emojis and alt text.
pub struct PackSticker {
    pub file: String,
    pub emojis: Vec<String>,
    pub label: String,
}

/// A user-created sticker pack as shared in a chat.
pub struct PackInfo {
    pub id: String,
    pub name: String,
    pub publisher: String,
    /// Name of the tray png inside the zip.
    pub tray: String,
    pub stickers: Vec<PackSticker>,
}

impl PackInfo {
    /// Parses the manifest clients send: a header line `id\tname\tpublisher\ttray`,
    /// then one `file\temojis (space separated)\talt text` line per sticker.
    pub fn from_manifest(text: &str) -> Option<PackInfo> {
        let mut lines = text.lines().filter(|l| !l.is_empty());
        let mut h = lines.next()?.split('\t');
        let (id, name, publisher, tray) = (h.next()?, h.next()?, h.next()?, h.next()?);
        let stickers = lines
            .map(|l| {
                let mut c = l.split('\t');
                PackSticker {
                    file: c.next().unwrap_or_default().to_string(),
                    emojis: c.next().unwrap_or_default().split(' ').filter(|e| !e.is_empty()).map(str::to_string).collect(),
                    label: c.next().unwrap_or_default().to_string(),
                }
            })
            .filter(|s| !s.file.is_empty())
            .collect::<Vec<_>>();
        if id.is_empty() || stickers.is_empty() {
            return None;
        }
        Some(PackInfo { id: id.into(), name: name.into(), publisher: publisher.into(), tray: tray.into(), stickers })
    }
}

/// `Message { stickerPackMessage = 86 }`. The zip (stickers + tray png) is uploaded
/// as mms `sticker-pack`, a 252x252 preview as `thumbnail-sticker-pack`, both under
/// one media key. StickerPackMessage (2.26.35.71): stickerPackId=1 name=2
/// publisher=3 stickers=4 fileLength=5 fileSha256=6 fileEncSha256=7 mediaKey=8
/// directPath=9 mediaKeyTimestamp=13 trayIconFileName=14 thumbnailDirectPath=15
/// thumbnailSha256=16 thumbnailEncSha256=17 thumbnailHeight=18 thumbnailWidth=19
/// imageDataHash=20 stickerPackSize=21 stickerPackOrigin=22 (USER_CREATED=2).
/// Sticker: fileName=1 isAnimated=2 emojis=3 (repeated) accessibilityLabel=4
/// isLottie=5 mimetype=6.
pub fn sticker_pack_message(p: &PackInfo, zip: &MediaEnc, zip_path: &str, thumb: &MediaEnc, thumb_path: &str) -> Vec<u8> {
    let mut m = Writer::new();
    m.field_bytes(1, p.id.as_bytes());
    m.field_bytes(2, p.name.as_bytes());
    m.field_bytes(3, p.publisher.as_bytes());
    for st in &p.stickers {
        let mut s = Writer::new();
        s.field_bytes(1, st.file.as_bytes());
        s.field_varint(2, 1);
        for e in &st.emojis {
            s.field_bytes(3, e.as_bytes());
        }
        s.field_bytes(4, st.label.as_bytes());
        s.field_varint(5, 1);
        s.field_bytes(6, b"application/was");
        m.field_bytes(4, &s.into_bytes());
    }
    m.field_varint(5, zip.file_length);
    m.field_bytes(6, &zip.file_sha256);
    m.field_bytes(7, &zip.file_enc_sha256);
    m.field_bytes(8, &zip.media_key);
    m.field_bytes(9, zip_path.as_bytes());
    m.field_varint(13, unix_now().as_secs());
    m.field_bytes(14, p.tray.as_bytes());
    m.field_bytes(15, thumb_path.as_bytes());
    m.field_bytes(16, &thumb.file_sha256);
    m.field_bytes(17, &thumb.file_enc_sha256);
    m.field_varint(18, 252);
    m.field_varint(19, 252);
    m.field_bytes(20, super::b64::encode_url_nopad(&zip.file_sha256).as_bytes());
    m.field_varint(21, zip.file_length);
    m.field_varint(22, 2);
    let mut msg = Writer::new();
    msg.field_bytes(86, &m.into_bytes());
    msg.into_bytes()
}