1use crate::{
5 keys::{derive_key, verify_first_page},
6 DatabaseKey, KeyFile, ProfileError, ProfileLocation, Result,
7};
8use rusqlite::Connection;
9use serde::Serialize;
10use std::{fs, io::Read, path::Path};
11
12pub(crate) const LAYOUTS: &[(&str, &str)] = &[
13 ("contact/contact.db", include_str!("layout/contact.db.sql")),
14 ("session/session.db", include_str!("layout/session.db.sql")),
15 (
16 "message/message_0.db",
17 include_str!("layout/message_0.db.sql"),
18 ),
19 ("message/media_0.db", include_str!("layout/media_0.db.sql")),
20 (
21 "message/message_resource.db",
22 include_str!("layout/message_resource.db.sql"),
23 ),
24 (
25 "hardlink/hardlink.db",
26 include_str!("layout/hardlink.db.sql"),
27 ),
28 (
29 "head_image/head_image.db",
30 include_str!("layout/head_image.db.sql"),
31 ),
32];
33#[derive(Clone, Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct InitializedLayout {
36 pub databases: Vec<String>,
37 pub encrypted: bool,
38 pub official_startup_verified: bool,
39 pub unsupported: Vec<String>,
40}
41pub fn initialize_profile(
48 path: impl AsRef<Path>,
49 password: &[u8],
50) -> Result<(ProfileLocation, KeyFile, InitializedLayout)> {
51 if password.len() != 32 {
52 return Err(ProfileError::InvalidKeysFile);
53 }
54 let target = path.as_ref();
55 let parent = target.parent().ok_or(ProfileError::InvalidProfile)?;
56 let name = target
57 .file_name()
58 .and_then(|s| s.to_str())
59 .ok_or(ProfileError::InvalidProfile)?;
60 if name.is_empty() || name.len() > 512 || name.contains(['/', '\\', '\0']) {
61 return Err(ProfileError::InvalidProfile);
62 }
63 if target.exists() && (!target.is_dir() || fs::read_dir(target)?.next().is_some()) {
64 return Err(ProfileError::InvalidProfile);
65 }
66 let parent = fs::canonicalize(parent)?;
67 let staging = wechat_platform::private_tempdir_in(&parent, ".profile-create-")?.keep();
68 let root = staging.as_path();
69 let mut keys = KeyFile::empty(name);
70 let mut databases = vec![];
71 for (relative, sql) in LAYOUTS {
72 let file = root.join("db_storage").join(relative);
73 fs::create_dir_all(file.parent().ok_or(ProfileError::InvalidProfile)?)?;
74 let c = Connection::open(&file)?;
75 let available = c
76 .query_row("PRAGMA cipher_version", [], |r| r.get::<_, String>(0))
77 .ok()
78 .is_some_and(|v| !v.is_empty());
79 if !available {
80 return Err(ProfileError::SqlcipherUnavailable);
81 }
82 let rc = unsafe {
85 rusqlite::ffi::sqlite3_key(c.handle(), password.as_ptr().cast(), password.len() as i32)
86 };
87 if rc != rusqlite::ffi::SQLITE_OK {
88 return Err(ProfileError::Database);
89 }
90 c.execute_batch("PRAGMA cipher_compatibility=4;PRAGMA cipher_page_size=4096;PRAGMA kdf_iter=256000;PRAGMA synchronous=FULL;PRAGMA journal_mode=DELETE;")?;
91 c.execute_batch(sql)?;
92 if relative.ends_with("hardlink.db") {
93 c.execute("INSERT INTO db_info(Key,ValueInt64,ValueDouble,ValueStdStr,ValueBlob)VALUES('table_version',4,0,'',x'')",[])?;
96 }
97 c.query_row("PRAGMA integrity_check", [], |r| r.get::<_, String>(0))
98 .and_then(|s| {
99 if s == "ok" {
100 Ok(())
101 } else {
102 Err(rusqlite::Error::InvalidQuery)
103 }
104 })?;
105 drop(c);
106 let mut page = [0u8; 4096];
107 fs::File::open(&file)?.read_exact(&mut page)?;
108 let mut salt = [0; 16];
109 salt.copy_from_slice(&page[..16]);
110 let raw = derive_key(password, &salt);
111 if !verify_first_page(&page, raw.expose(), &salt) {
112 return Err(ProfileError::KeyMismatch((*relative).into()));
113 }
114 let array: [u8; 32] = raw
115 .expose()
116 .try_into()
117 .map_err(|_| ProfileError::InvalidKeysFile)?;
118 keys.databases.insert(
119 (*relative).into(),
120 DatabaseKey::new((*relative).into(), salt, array)?,
121 );
122 #[cfg(unix)]
123 {
124 use std::os::unix::fs::PermissionsExt;
125 fs::set_permissions(&file, fs::Permissions::from_mode(0o600))?;
126 }
127 wechat_platform::sync_file(&file)?;
128 databases.push((*relative).into());
129 }
130 let destination = parent.join(name);
133 publish_staging(staging, &destination)?;
134 wechat_platform::sync_dir(&parent)?;
135 let location = ProfileLocation::new(&destination)?;
136 Ok((
137 location,
138 keys,
139 InitializedLayout {
140 databases,
141 encrypted: true,
142 official_startup_verified: false,
143 unsupported: vec![
144 "custom FTS tokenizer/index initialization".into(),
145 "trusted login/MMKV/device bootstrap".into(),
146 "ordinary subsystem schemas not in this adapter".into(),
147 ],
148 },
149 ))
150}
151
152fn publish_staging(source: std::path::PathBuf, target: &Path) -> Result<()> {
153 sync_created_directories(&source, 0)?;
156 publish_directory(&source, target)?;
157 if source.exists() {
158 fs::remove_dir(&source)?;
159 }
160 Ok(())
161}
162fn sync_created_directories(path: &Path, depth: usize) -> Result<()> {
163 if depth > 16 {
164 return Err(ProfileError::ResourceLimit);
165 }
166 for e in fs::read_dir(path)? {
167 let e = e?;
168 let m = fs::symlink_metadata(e.path())?;
169 if wechat_platform::is_link(&m) {
170 return Err(ProfileError::UnsupportedFile);
171 } else if m.is_dir() {
172 sync_created_directories(&e.path(), depth + 1)?;
173 }
174 }
175 wechat_platform::sync_dir(path)?;
176 Ok(())
177}
178fn publish_directory(source: &Path, target: &Path) -> Result<()> {
179 #[cfg(unix)]
180 {
181 use std::os::unix::ffi::OsStrExt;
182 let exists = target.exists();
183 if exists {
184 let m = fs::symlink_metadata(target)?;
185 if !m.is_dir() || wechat_platform::is_link(&m) || fs::read_dir(target)?.next().is_some()
186 {
187 return Err(ProfileError::InvalidProfile);
188 }
189 }
190 let a = std::ffi::CString::new(source.as_os_str().as_bytes())
191 .map_err(|_| ProfileError::InvalidProfile)?;
192 let b = std::ffi::CString::new(target.as_os_str().as_bytes())
193 .map_err(|_| ProfileError::InvalidProfile)?;
194 #[cfg(target_os = "linux")]
196 let rename = |exchange| unsafe {
197 libc::renameat2(
198 libc::AT_FDCWD,
199 a.as_ptr(),
200 libc::AT_FDCWD,
201 b.as_ptr(),
202 if exchange {
203 libc::RENAME_EXCHANGE
204 } else {
205 libc::RENAME_NOREPLACE
206 },
207 )
208 };
209 #[cfg(target_os = "macos")]
210 let rename = |exchange| unsafe {
211 libc::renamex_np(
212 a.as_ptr(),
213 b.as_ptr(),
214 if exchange {
215 libc::RENAME_SWAP
216 } else {
217 libc::RENAME_EXCL
218 },
219 )
220 };
221 if rename(exists) != 0 {
222 return Err(std::io::Error::last_os_error().into());
223 }
224 let displaced_changed = if exists {
225 let metadata = fs::symlink_metadata(source)?;
226 !metadata.is_dir()
227 || wechat_platform::is_link(&metadata)
228 || fs::read_dir(source)?.next().is_some()
229 } else {
230 false
231 };
232 if displaced_changed {
233 let rc = rename(true);
236 if rc != 0 {
237 return Err(ProfileError::SourceChanged);
238 }
239 return Err(ProfileError::SourceChanged);
240 }
241 Ok(())
242 }
243 #[cfg(windows)]
244 {
245 if !target.try_exists()? {
246 wechat_platform::publish_directory(source, target)?;
247 return Ok(());
248 }
249 let metadata = fs::symlink_metadata(target)?;
250 if !metadata.is_dir()
251 || wechat_platform::is_link(&metadata)
252 || fs::read_dir(target)?.next().is_some()
253 {
254 return Err(ProfileError::InvalidProfile);
255 }
256 let displaced = wechat_platform::private_tempdir_in(
260 target.parent().ok_or(ProfileError::InvalidProfile)?,
261 ".profile-displaced-",
262 )?
263 .keep();
264 fs::remove_dir(&displaced)?;
265 wechat_platform::publish_directory(target, &displaced)?;
266 let metadata = fs::symlink_metadata(&displaced)?;
267 if !metadata.is_dir()
268 || wechat_platform::is_link(&metadata)
269 || fs::read_dir(&displaced)?.next().is_some()
270 {
271 let _ = wechat_platform::publish_directory(&displaced, target);
272 return Err(ProfileError::SourceChanged);
273 }
274 if let Err(error) = wechat_platform::publish_directory(source, target) {
275 let _ = wechat_platform::publish_directory(&displaced, target);
276 return Err(error.into());
277 }
278 fs::remove_dir(&displaced)?;
279 Ok(())
280 }
281}
282#[cfg(test)]
283mod publication_tests {
284 use super::*;
285
286 #[test]
287 fn staged_profile_replaces_an_empty_reservation() {
288 let temporary = tempfile::tempdir().unwrap();
289 let source = temporary.path().join("staged");
290 let target = temporary.path().join("profile");
291 fs::create_dir(&source).unwrap();
292 fs::create_dir(&target).unwrap();
293 fs::write(source.join("created"), b"new profile").unwrap();
294 publish_staging(source.clone(), &target).unwrap();
295 assert_eq!(fs::read(target.join("created")).unwrap(), b"new profile");
296 assert!(!source.exists());
297 assert_eq!(fs::read_dir(temporary.path()).unwrap().count(), 1);
298 }
299
300 #[test]
301 fn content_added_after_staging_is_preserved_on_failed_publication() {
302 let temporary = tempfile::tempdir().unwrap();
303 let source = temporary.path().join("staged");
304 let target = temporary.path().join("profile");
305 fs::create_dir(&source).unwrap();
306 fs::create_dir(&target).unwrap();
307 fs::write(source.join("created"), b"new profile").unwrap();
308 fs::write(target.join("unknown"), b"another writer").unwrap();
309 assert!(publish_staging(source.clone(), &target).is_err());
310 assert_eq!(fs::read(target.join("unknown")).unwrap(), b"another writer");
311 assert_eq!(fs::read(source.join("created")).unwrap(), b"new profile");
312 assert!(!target.join("created").exists());
313 }
314}
315pub fn official_account_directory(username: &str, uin: u32) -> Result<String> {
319 use md5::{Digest, Md5};
320 if uin == 0
321 || username.is_empty()
322 || matches!(username, "." | "..")
323 || username.len() > 256
324 || !username
325 .bytes()
326 .all(|c| c.is_ascii_alphanumeric() || b"_.@-".contains(&c))
327 {
328 return Err(ProfileError::InvalidMetadata);
329 }
330 let suffix = format!("{:x}", Md5::digest(uin.to_string().as_bytes()));
331 Ok(format!("{username}_{}", &suffix[..4]))
332}
333pub struct OfficialHomeBootstrap {
336 pub username: String,
337 pub uin: u32,
338 pub device_id: Vec<u8>,
339 pub global_values: Vec<(String, Option<Vec<u8>>)>,
342}
343#[derive(Clone, Debug, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub struct OfficialHomeReport {
346 pub account_directory: String,
347 pub identity_store_relative_path: String,
348 pub layout: InitializedLayout,
349 pub saved_login_initialization_complete: bool,
350 pub database_password_hydration_verified: bool,
351}
352fn initialize_official_home_impl(
356 home: impl AsRef<Path>,
357 input: &OfficialHomeBootstrap,
358 password: &[u8],
359 format_key: &[u8],
360 saved_login: Option<&crate::SavedLoginProjection<'_>>,
361) -> Result<(ProfileLocation, KeyFile, OfficialHomeReport)> {
362 use crate::{create_mmkv_v4, mmkv_string_value, stage_mmkv_values};
363 if format_key.len() != 16 || input.device_id.len() < 16 || input.device_id.len() > 256 {
364 return Err(ProfileError::InvalidMetadata);
365 }
366 let account = official_account_directory(&input.username, input.uin)?;
367 let home = home.as_ref();
368 let parent = fs::canonicalize(home.parent().ok_or(ProfileError::InvalidProfile)?)?;
369 let name = home.file_name().ok_or(ProfileError::InvalidProfile)?;
370 if home.exists() && (!home.is_dir() || fs::read_dir(home)?.next().is_some()) {
371 return Err(ProfileError::InvalidProfile);
372 }
373 let stage = wechat_platform::private_tempdir_in(&parent, ".official-home-create-")?.keep();
374 let files = stage.join("xwechat_files");
375 fs::create_dir(&files)?;
376 let account_path = files.join(&account);
377 let (_, mut keys, layout) = initialize_profile(&account_path, password)?;
378 keys.image_dat = Some(crate::ImageDatKey::derive(input.uin, &input.username)?);
379 let global = files.join("all_users/config");
380 fs::create_dir_all(&global)?;
381 let mut iv = [0; 16];
382 openssl::rand::rand_bytes(&mut iv).map_err(|_| ProfileError::InvalidMetadata)?;
383 let seed = create_mmkv_v4(format_key, iv, 65536)?;
384 let mut scalar = vec![];
385 let mut n = u64::from(input.uin);
386 while n >= 128 {
387 scalar.push((n as u8 & 127) | 128);
388 n >>= 7
389 }
390 scalar.push(n as u8);
391 let mut values = vec![
392 (
393 "mmkv_key_user_name".into(),
394 Some(mmkv_string_value(input.username.as_bytes())?.to_vec()),
395 ),
396 ("mmkv_key_latest_login_uin".into(), Some(scalar)),
397 (
398 "key_wechat_device_id".into(),
399 Some(mmkv_string_value(&input.device_id)?.to_vec()),
400 ),
401 ];
402 let reserved = [
403 "mmkv_key_user_name",
404 "mmkv_key_latest_login_uin",
405 "key_wechat_device_id",
406 ];
407 if input
408 .global_values
409 .iter()
410 .any(|(k, _)| reserved.contains(&k.as_str()))
411 {
412 return Err(ProfileError::InvalidMetadata);
413 }
414 values.extend(input.global_values.clone());
415 if let Some(login) = saved_login {
416 values.extend(crate::login_storage::saved_login_values(
417 &input.username,
418 login,
419 )?);
420 }
421 let populated = stage_mmkv_values(seed.data(), seed.metadata(), format_key, &values);
422 use zeroize::Zeroize;
423 values.zeroize();
424 let populated = populated?;
425 fn put(path: &Path, bytes: &[u8]) -> Result<()> {
426 use std::io::Write;
427 let mut o = wechat_platform::OpenOptions::new();
428 o.write(true).create_new(true);
429 #[cfg(unix)]
430 {
431 o.mode(0o600);
432 }
433 let mut f = o.open(path)?;
434 f.write_all(bytes)?;
435 f.sync_all()?;
436 Ok(())
437 }
438 put(&global.join("global_config"), populated.data())?;
439 put(&global.join("global_config.crc"), populated.metadata())?;
440 let account_config = account_path.join("config");
443 fs::create_dir(&account_config)?;
444 openssl::rand::rand_bytes(&mut iv).map_err(|_| ProfileError::InvalidMetadata)?;
445 let account_key = crate::account_mmkv_key(&input.username)?;
446 let account_seed = create_mmkv_v4(&account_key, iv, 65536)?;
447 put(&account_config.join("login_configv2"), account_seed.data())?;
448 put(
449 &account_config.join("login_configv2.crc"),
450 account_seed.metadata(),
451 )?;
452 let identity = crate::read_mmkv_identity(populated.data(), populated.metadata(), format_key)?;
453 if identity.username != input.username
454 || identity.uin != input.uin
455 || identity.device_id != input.device_id
456 {
457 return Err(ProfileError::InvalidMetadata);
458 }
459 if let Some(login) = saved_login {
460 let legacy = stage.join(".xwechat/login").join(&input.username);
463 fs::create_dir_all(&legacy)?;
464 put(&legacy.join("key_info.dat"), login.key_info)?;
465 }
466 let destination = parent.join(name);
467 publish_staging(stage, &destination)?;
468 wechat_platform::sync_dir(&parent)?;
469 let location = ProfileLocation::new(destination.join("xwechat_files").join(&account))?;
470 Ok((
471 location,
472 keys,
473 OfficialHomeReport {
474 account_directory: account,
475 identity_store_relative_path: "xwechat_files/all_users/config/global_config".into(),
476 layout,
477 saved_login_initialization_complete: false,
478 database_password_hydration_verified: false,
479 },
480 ))
481}
482
483pub fn initialize_official_home(
485 home: impl AsRef<Path>,
486 input: &OfficialHomeBootstrap,
487 password: &[u8],
488 format_key: &[u8],
489) -> Result<(ProfileLocation, KeyFile, OfficialHomeReport)> {
490 initialize_official_home_impl(home, input, password, format_key, None)
491}
492pub fn initialize_official_home_with_login(
496 home: impl AsRef<Path>,
497 input: &OfficialHomeBootstrap,
498 password: &[u8],
499 format_key: &[u8],
500 saved_login: &crate::SavedLoginProjection<'_>,
501) -> Result<(ProfileLocation, KeyFile, OfficialHomeReport)> {
502 initialize_official_home_impl(home, input, password, format_key, Some(saved_login))
503}
504
505pub fn ensure_managed_layout_version(
510 location: &ProfileLocation,
511 keys: &KeyFile,
512 options: &crate::WriteOptions,
513) -> Result<bool> {
514 use rusqlite::OptionalExtension;
515 crate::projection::ownership(location, keys, options)?;
516 let db = crate::writer::find_database(location, "hardlink.db")?;
517 let mut c = crate::writer::open_write(location, &db, keys, options.allow_plaintext)?;
518 for table in [
519 "image_hardlink_info_v4",
520 "file_hardlink_info_v4",
521 "video_hardlink_info_v4",
522 "dir2id",
523 ] {
524 crate::projection::check_known_table(&c, table)?;
525 }
526 crate::projection::check_known_table(&c, "db_info")?;
527 let tx = c.transaction()?;
528 let version = tx
529 .query_row(
530 "SELECT ValueInt64 FROM db_info WHERE Key='table_version'",
531 [],
532 |r| r.get::<_, Option<i64>>(0),
533 )
534 .optional()?;
535 match version {
536 Some(Some(4)) => return Ok(false),
537 Some(_) => return Err(ProfileError::InvalidMetadata),
538 None => {}
539 }
540 let count: i64 = tx.query_row("SELECT count(*) FROM db_info", [], |r| r.get(0))?;
541 if count != 0 {
542 return Err(ProfileError::WritesUnsupported);
543 }
544 tx.execute("INSERT INTO db_info(Key,ValueInt64,ValueDouble,ValueStdStr,ValueBlob)VALUES('table_version',4,0,'',x'')",[])?;
545 tx.commit()?;
546 Ok(true)
547}