Skip to main content

wechat_profile/
keys.rs

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    /// Inspected Linux V2 DAT format key. This public identity derivation is
64    /// separate from database passwords and authenticated network credentials.
65    /// The AES key uses sixteen lowercase-hex ASCII bytes, not decoded MD5.
66    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        // Native code tries bit shifts0,1,2,3 (not byte shifts).
74        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    /// Convert the older authorized cache representation: first 16 ASCII bytes
81    /// of the 32-character cache string, not hex-decoding that string.
82    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    /// The caller must explicitly choose a protected output. This is the only
252    /// serializer: secret material never implements public Serialize or Display.
253    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
326/// Confirm a server-issued account database password against every actual DB
327/// header and the already authorized page-key map. Only 4096-byte headers are
328/// read; SQLite never opens these files, and no key/password bytes are returned.
329/// The caller must hold exclusive profile ownership or supply a stopped copy.
330/// Missing coverage, a different salt generation or one mismatched key fails
331/// closed. This does not rekey, repair, replace or switch the attached profile.
332pub 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}