Skip to main content

wechat_profile/
initialize.rs

1//! Fresh creation of the inspected ordinary SQLCipher schemas. No source profile
2//! or vendor process is used. Custom FTS and saved-login initialization are not
3//! inferred from ordinary database creation.
4use 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}
41/// Creates a NEW account root atomically. The caller supplies an existing/new
42/// 32-byte binary database password explicitly and stores returned keys separately.
43/// For an official home this is the accepted account AuthSect field 9 payload;
44/// generating another password cannot substitute for that server-issued value.
45/// This initializes ordinary native schemas; it does not claim a fully trusted
46/// login profile or manufacture custom-tokenizer indexes.
47pub 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        // sqlite3_key accepts binary passphrases, avoiding SQL interpolation and any
83        // accidental raw-key interpretation of an x'...' SQL literal.
84        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            // The schema uses the observed v4 hardlink tables. Mark that version
94            // explicitly; stock-scan completion and native UUID remain unset.
95            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    // Refuse a destination created while staging. persist_noclobber for directory
131    // publication is provided by renameat2(RENAME_NOREPLACE) on Linux.
132    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    // Preserve a failed publication for diagnosis. In particular never let a
154    // TempDir destructor delete an unexpected displaced directory after a race.
155    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        // Both platforms can exchange an existing empty home atomically.
195        #[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            // Never remove unexpected data introduced by an unmanaged writer. Restore
234            // the source tree before returning; the caller's exclusive lease is required.
235            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        // Windows cannot exchange directories. Move the empty reservation aside,
257        // then publish without replacing anything created by an unmanaged writer.
258        // Keep displaced bytes on any race or failed restoration.
259        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}
315/// Observed directory naming in the inspected Linux profile. The suffix is the
316/// first four lowercase MD5 hex digits of decimal UIN, not a username hash. The
317/// complete fresh-home takeover still needs a same-build compatibility check.
318pub 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}
333/// Credentials and additional raw MMKV values are caller supplied; this type has
334/// no Debug/Serialize so a bootstrap request cannot enter ordinary logs.
335pub struct OfficialHomeBootstrap {
336    pub username: String,
337    pub uin: u32,
338    pub device_id: Vec<u8>,
339    /// Additional already encoded values, e.g. verified saved-login records. Unknown
340    /// defaults are not fabricated. Identity keys are reserved and cannot override.
341    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}
352/// Create a fresh full official HOME layout, publishing only to a nonexistent or
353/// empty managed target. Never overwrites a populated or external profile.
354/// Returned account location is suitable for existing readers/projections.
355fn 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    // Account-local v2 storage uses MD5(username), not the global format key.
441    // An empty new store has no inherited cursor; only verified apply commits add it.
442    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        // The inspected selector explicitly falls back to this raw legacy file
461        // when key_info.db is absent. We do not manufacture its opaque key_md5.
462        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
483/// Minimal ordinary HOME and explicit identity, without saved credentials.
484pub 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}
492/// Fresh official-format HOME with server-issued saved credentials. Known global
493/// records and native legacy key-info fallback are initialized, without guessing
494/// a key_md5 value. Same-build official startup acceptance remains a runtime check.
495pub 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
505/// Repair only the omitted known v4 marker of this adapter's receipt-bound
506/// managed bootstrap. The coordinator must establish that provenance first.
507/// No schema migration, UUID generation, stock-scan completion or foreign
508/// profile adoption is implied by this helper.
509pub 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}