1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
// 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()
}