1use crate::{ProfileError, Result};
2use base64::{engine::general_purpose::STANDARD, Engine};
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::BTreeMap,
6 fmt, fs,
7 path::{Component, Path},
8};
9use zeroize::{Zeroize, Zeroizing};
10
11#[derive(Clone)]
12pub struct SecretBytes(Zeroizing<Vec<u8>>);
13impl SecretBytes {
14 pub fn new(bytes: Vec<u8>) -> Self {
15 Self(Zeroizing::new(bytes))
16 }
17 pub(crate) fn expose(&self) -> &[u8] {
18 &self.0
19 }
20}
21impl fmt::Debug for SecretBytes {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 f.write_str("SecretBytes([REDACTED])")
24 }
25}
26
27#[derive(Clone, Debug)]
28pub struct DatabaseKey {
29 pub relative_path: String,
30 pub salt: [u8; 16],
31 pub(crate) raw_page_key: SecretBytes,
32}
33impl DatabaseKey {
34 pub fn new(relative_path: String, salt: [u8; 16], raw_page_key: [u8; 32]) -> Result<Self> {
35 if !valid_relative_path(&relative_path) {
36 return Err(ProfileError::InvalidKeysFile);
37 }
38 Ok(Self {
39 relative_path,
40 salt,
41 raw_page_key: SecretBytes::new(raw_page_key.to_vec()),
42 })
43 }
44}
45
46#[derive(Clone)]
47pub struct ImageDatKey {
48 pub(crate) aes: SecretBytes,
49 pub(crate) xor: u8,
50}
51impl fmt::Debug for ImageDatKey {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.write_str("ImageDatKey([REDACTED])")
54 }
55}
56impl ImageDatKey {
57 pub fn new(aes: [u8; 16], xor: u8) -> Self {
58 Self {
59 aes: SecretBytes::new(aes.to_vec()),
60 xor,
61 }
62 }
63 pub fn derive(uin: u32, username: &str) -> Result<Self> {
67 use md5::{Digest, Md5};
68 crate::projection::identity(username)?;
69 let text = format!("{uin}{username}");
70 let hex = format!("{:x}", Md5::digest(text.as_bytes()));
71 let mut aes = [0u8; 16];
72 aes.copy_from_slice(&hex.as_bytes()[..16]);
73 let xor = (0..=3)
75 .map(|s| (uin >> s) as u8)
76 .find(|x| *x != 0)
77 .unwrap_or(0);
78 Ok(Self::new(aes, xor))
79 }
80 pub fn from_legacy_cache(value: &str, xor: u8) -> Result<Self> {
83 if value.len() != 32 || !value.is_ascii() {
84 return Err(ProfileError::InvalidKeysFile);
85 }
86 let mut aes = [0u8; 16];
87 aes.copy_from_slice(&value.as_bytes()[..16]);
88 Ok(Self::new(aes, xor))
89 }
90}
91
92#[derive(Clone, Debug)]
93pub struct KeyFile {
94 pub account_binding: String,
95 pub databases: BTreeMap<String, DatabaseKey>,
96 pub image_dat: Option<ImageDatKey>,
97}
98
99#[derive(Serialize, Deserialize, Zeroize)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101struct EncodedKeyFile {
102 schema_version: u32,
103 format: String,
104 account_binding: String,
105 #[zeroize(skip)]
106 sqlcipher: CipherSettings,
107 databases: Vec<EncodedDatabaseKey>,
108 image_dat: Option<EncodedImageKey>,
109}
110#[derive(Serialize, Deserialize)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112struct CipherSettings {
113 compatibility: u32,
114 page_size: u32,
115 kdf_iterations: u32,
116}
117#[derive(Serialize, Deserialize, Zeroize)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119struct EncodedDatabaseKey {
120 relative_path: String,
121 salt_hex: String,
122 raw_page_key_base64: String,
123}
124#[derive(Serialize, Deserialize, Zeroize)]
125#[serde(rename_all = "camelCase", deny_unknown_fields)]
126struct EncodedImageKey {
127 format_version: u32,
128 raw_aes128_key_base64: String,
129 xor_byte: u8,
130}
131
132pub(crate) fn valid_relative_path(p: &str) -> bool {
133 !p.is_empty()
134 && !p.contains('\\')
135 && !p.contains('\0')
136 && p.len() <= 4096
137 && Path::new(p)
138 .components()
139 .all(|c| matches!(c, Component::Normal(_)))
140}
141
142impl KeyFile {
143 pub fn empty(account_binding: impl Into<String>) -> Self {
144 Self {
145 account_binding: account_binding.into(),
146 databases: BTreeMap::new(),
147 image_dat: None,
148 }
149 }
150 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
151 let path = path.as_ref();
152 let m = fs::symlink_metadata(path).map_err(|e| {
153 if e.kind() == std::io::ErrorKind::NotFound {
154 ProfileError::KeysMissing
155 } else {
156 e.into()
157 }
158 })?;
159 if !m.is_file() || wechat_platform::is_link(&m) || m.len() > 4 * 1024 * 1024 {
160 return Err(ProfileError::InsecureKeysFile);
161 }
162 if !wechat_platform::private_path(path)? {
163 return Err(ProfileError::InsecureKeysFile);
164 }
165 let mut options = wechat_platform::OpenOptions::new();
166 options.read(true);
167 #[cfg(unix)]
168 {
169 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
170 }
171 let opened = options.open(path)?;
172 let m = opened.metadata()?;
173 if !m.is_file() || !wechat_platform::private_file(&opened)? {
174 return Err(ProfileError::InsecureKeysFile);
175 }
176 let mut input = Zeroizing::new(Vec::new());
177 use std::io::Read;
178 opened.take(4 * 1024 * 1024 + 1).read_to_end(&mut input)?;
179 if input.len() > 4 * 1024 * 1024 {
180 return Err(ProfileError::ResourceLimit);
181 }
182 Self::from_json(&input)
183 }
184 pub fn from_json(input: &[u8]) -> Result<Self> {
185 if input.len() > 4 * 1024 * 1024 {
186 return Err(ProfileError::ResourceLimit);
187 }
188 let mut e: EncodedKeyFile =
189 serde_json::from_slice(input).map_err(|_| ProfileError::InvalidKeysFile)?;
190 let result = (|| {
191 if e.schema_version != 1
192 || e.format != "official-linux"
193 || e.account_binding.is_empty()
194 || e.account_binding.len() > 512
195 || e.account_binding.contains(['/', '\\', '\0'])
196 || e.sqlcipher.compatibility != 4
197 || e.sqlcipher.page_size != 4096
198 || e.sqlcipher.kdf_iterations != 256000
199 || e.databases.len() > 4096
200 {
201 return Err(ProfileError::InvalidKeysFile);
202 }
203 let mut result = Self::empty(e.account_binding.clone());
204 for d in &e.databases {
205 let salt: [u8; 16] = hex::decode(&d.salt_hex)
206 .map_err(|_| ProfileError::InvalidKeysFile)?
207 .try_into()
208 .map_err(|_| ProfileError::InvalidKeysFile)?;
209 let raw = Zeroizing::new(
210 STANDARD
211 .decode(&d.raw_page_key_base64)
212 .map_err(|_| ProfileError::InvalidKeysFile)?,
213 );
214 if raw.len() != 32 || !valid_relative_path(&d.relative_path) {
215 return Err(ProfileError::InvalidKeysFile);
216 }
217 if result
218 .databases
219 .insert(
220 d.relative_path.clone(),
221 DatabaseKey {
222 relative_path: d.relative_path.clone(),
223 salt,
224 raw_page_key: SecretBytes::new(raw.to_vec()),
225 },
226 )
227 .is_some()
228 {
229 return Err(ProfileError::InvalidKeysFile);
230 }
231 }
232 if let Some(i) = &e.image_dat {
233 let aes = Zeroizing::new(
234 STANDARD
235 .decode(&i.raw_aes128_key_base64)
236 .map_err(|_| ProfileError::InvalidKeysFile)?,
237 );
238 if i.format_version != 2 || aes.len() != 16 {
239 return Err(ProfileError::InvalidKeysFile);
240 }
241 result.image_dat = Some(ImageDatKey {
242 aes: SecretBytes::new(aes.to_vec()),
243 xor: i.xor_byte,
244 });
245 }
246 Ok(result)
247 })();
248 e.zeroize();
249 result
250 }
251 pub fn write_new(&self, path: impl AsRef<Path>) -> Result<()> {
254 let path = path.as_ref();
255 let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::MetadataExt;
259 let m = fs::metadata(parent)?;
260 if m.mode() & 0o077 != 0 {
261 return Err(ProfileError::InsecureKeysFile);
262 }
263 }
264 let mut encoded = EncodedKeyFile {
265 schema_version: 1,
266 format: "official-linux".into(),
267 account_binding: self.account_binding.clone(),
268 sqlcipher: CipherSettings {
269 compatibility: 4,
270 page_size: 4096,
271 kdf_iterations: 256000,
272 },
273 databases: self
274 .databases
275 .values()
276 .map(|d| EncodedDatabaseKey {
277 relative_path: d.relative_path.clone(),
278 salt_hex: hex::encode(d.salt),
279 raw_page_key_base64: STANDARD.encode(d.raw_page_key.expose()),
280 })
281 .collect(),
282 image_dat: self.image_dat.as_ref().map(|i| EncodedImageKey {
283 format_version: 2,
284 raw_aes128_key_base64: STANDARD.encode(i.aes.expose()),
285 xor_byte: i.xor,
286 }),
287 };
288 let mut output = Zeroizing::new(
289 serde_json::to_vec_pretty(&encoded).map_err(|_| ProfileError::InvalidKeysFile)?,
290 );
291 encoded.zeroize();
292 output.push(b'\n');
293 let mut f = wechat_platform::private_tempfile_in(parent, ".wx-keys-")?;
294 use std::io::Write;
295 f.write_all(&output)?;
296 f.as_file().sync_all()?;
297 f.persist_noclobber(path)
298 .map_err(|e| ProfileError::Io(e.error))?;
299 wechat_platform::sync_dir(parent)?;
300 Ok(())
301 }
302}
303
304pub(crate) fn derive_key(password: &[u8], salt: &[u8; 16]) -> SecretBytes {
305 let mut key = Zeroizing::new(vec![0u8; 32]);
306 pbkdf2::pbkdf2_hmac::<sha2::Sha512>(password, salt, 256000, &mut key);
307 SecretBytes::new(key.to_vec())
308}
309
310pub(crate) fn verify_first_page(page: &[u8], key: &[u8], salt: &[u8; 16]) -> bool {
311 use hmac::{Hmac, Mac};
312 if page.len() < 4096 || key.len() != 32 || &page[..16] != salt {
313 return false;
314 }
315 let hs = salt.map(|v| v ^ 0x3a);
316 let mut hk = Zeroizing::new([0u8; 32]);
317 pbkdf2::pbkdf2_hmac::<sha2::Sha512>(key, &hs, 2, &mut *hk);
318 let Ok(mut mac) = Hmac::<sha2::Sha512>::new_from_slice(&*hk) else {
319 return false;
320 };
321 mac.update(&page[16..4032]);
322 mac.update(&1u32.to_le_bytes());
323 mac.verify_slice(&page[4032..4096]).is_ok()
324}
325
326pub fn verify_profile_password(
333 location: &crate::ProfileLocation,
334 keys: &KeyFile,
335 password: &[u8],
336) -> Result<usize> {
337 use std::io::Read;
338 if keys.account_binding != location.account_directory() {
339 return Err(ProfileError::AccountMismatch);
340 }
341 if password.len() != 32 {
342 return Err(ProfileError::InvalidKeysFile);
343 }
344 let mut files = vec![];
345 crate::snapshot::walk(&location.storage, &mut files, 0, 4096)?;
346 if files.is_empty() {
347 return Err(ProfileError::KeysMissing);
348 }
349 let mut verified = 0;
350 for path in files {
351 let relative = path
352 .strip_prefix(&location.storage)
353 .ok()
354 .and_then(|p| p.to_str())
355 .ok_or(ProfileError::UnsupportedFile)?
356 .replace('\\', "/");
357 let cached = keys
358 .databases
359 .get(&relative)
360 .ok_or_else(|| ProfileError::KeyUnavailable(relative.clone()))?;
361 let mut options = wechat_platform::OpenOptions::new();
362 options.read(true);
363 #[cfg(unix)]
364 {
365 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
366 }
367 let mut file = options.open(&path)?;
368 let before = file.metadata()?;
369 if !before.is_file() {
370 return Err(ProfileError::UnsupportedFile);
371 }
372 let mut page = [0u8; 4096];
373 file.read_exact(&mut page)
374 .map_err(|_| ProfileError::KeyMismatch(relative.clone()))?;
375 let after = fs::metadata(&path)?;
376 if before.len() != after.len() || before.modified()? != after.modified()? {
377 return Err(ProfileError::SourceChanged);
378 }
379 let mut salt = [0; 16];
380 salt.copy_from_slice(&page[..16]);
381 if salt != cached.salt || !verify_first_page(&page, cached.raw_page_key.expose(), &salt) {
382 return Err(ProfileError::KeyMismatch(relative));
383 }
384 let derived = derive_key(password, &salt);
385 if !openssl::memcmp::eq(derived.expose(), cached.raw_page_key.expose())
386 || !verify_first_page(&page, derived.expose(), &salt)
387 {
388 return Err(ProfileError::KeyMismatch(relative));
389 }
390 verified += 1;
391 }
392 Ok(verified)
393}
394
395pub fn derive_image_dat_key(uin: u32, username: &str) -> Result<ImageDatKey> {
396 ImageDatKey::derive(uin, username)
397}