use crate::{ProfileError, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fmt, fs,
path::{Component, Path},
};
use zeroize::{Zeroize, Zeroizing};
#[derive(Clone)]
pub struct SecretBytes(Zeroizing<Vec<u8>>);
impl SecretBytes {
pub fn new(bytes: Vec<u8>) -> Self {
Self(Zeroizing::new(bytes))
}
pub(crate) fn expose(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for SecretBytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretBytes([REDACTED])")
}
}
#[derive(Clone, Debug)]
pub struct DatabaseKey {
pub relative_path: String,
pub salt: [u8; 16],
pub(crate) raw_page_key: SecretBytes,
}
impl DatabaseKey {
pub fn new(relative_path: String, salt: [u8; 16], raw_page_key: [u8; 32]) -> Result<Self> {
if !valid_relative_path(&relative_path) {
return Err(ProfileError::InvalidKeysFile);
}
Ok(Self {
relative_path,
salt,
raw_page_key: SecretBytes::new(raw_page_key.to_vec()),
})
}
}
#[derive(Clone)]
pub struct ImageDatKey {
pub(crate) aes: SecretBytes,
pub(crate) xor: u8,
}
impl fmt::Debug for ImageDatKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ImageDatKey([REDACTED])")
}
}
impl ImageDatKey {
pub fn new(aes: [u8; 16], xor: u8) -> Self {
Self {
aes: SecretBytes::new(aes.to_vec()),
xor,
}
}
pub fn derive(uin: u32, username: &str) -> Result<Self> {
use md5::{Digest, Md5};
crate::projection::identity(username)?;
let text = format!("{uin}{username}");
let hex = format!("{:x}", Md5::digest(text.as_bytes()));
let mut aes = [0u8; 16];
aes.copy_from_slice(&hex.as_bytes()[..16]);
let xor = (0..=3)
.map(|s| (uin >> s) as u8)
.find(|x| *x != 0)
.unwrap_or(0);
Ok(Self::new(aes, xor))
}
pub fn from_legacy_cache(value: &str, xor: u8) -> Result<Self> {
if value.len() != 32 || !value.is_ascii() {
return Err(ProfileError::InvalidKeysFile);
}
let mut aes = [0u8; 16];
aes.copy_from_slice(&value.as_bytes()[..16]);
Ok(Self::new(aes, xor))
}
}
#[derive(Clone, Debug)]
pub struct KeyFile {
pub account_binding: String,
pub databases: BTreeMap<String, DatabaseKey>,
pub image_dat: Option<ImageDatKey>,
}
#[derive(Serialize, Deserialize, Zeroize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct EncodedKeyFile {
schema_version: u32,
format: String,
account_binding: String,
#[zeroize(skip)]
sqlcipher: CipherSettings,
databases: Vec<EncodedDatabaseKey>,
image_dat: Option<EncodedImageKey>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CipherSettings {
compatibility: u32,
page_size: u32,
kdf_iterations: u32,
}
#[derive(Serialize, Deserialize, Zeroize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct EncodedDatabaseKey {
relative_path: String,
salt_hex: String,
raw_page_key_base64: String,
}
#[derive(Serialize, Deserialize, Zeroize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct EncodedImageKey {
format_version: u32,
raw_aes128_key_base64: String,
xor_byte: u8,
}
pub(crate) fn valid_relative_path(p: &str) -> bool {
!p.is_empty()
&& !p.contains('\\')
&& !p.contains('\0')
&& p.len() <= 4096
&& Path::new(p)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
impl KeyFile {
pub fn empty(account_binding: impl Into<String>) -> Self {
Self {
account_binding: account_binding.into(),
databases: BTreeMap::new(),
image_dat: None,
}
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let m = fs::symlink_metadata(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ProfileError::KeysMissing
} else {
e.into()
}
})?;
if !m.is_file() || wechat_platform::is_link(&m) || m.len() > 4 * 1024 * 1024 {
return Err(ProfileError::InsecureKeysFile);
}
if !wechat_platform::private_path(path)? {
return Err(ProfileError::InsecureKeysFile);
}
let mut options = wechat_platform::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let opened = options.open(path)?;
let m = opened.metadata()?;
if !m.is_file() || !wechat_platform::private_file(&opened)? {
return Err(ProfileError::InsecureKeysFile);
}
let mut input = Zeroizing::new(Vec::new());
use std::io::Read;
opened.take(4 * 1024 * 1024 + 1).read_to_end(&mut input)?;
if input.len() > 4 * 1024 * 1024 {
return Err(ProfileError::ResourceLimit);
}
Self::from_json(&input)
}
pub fn from_json(input: &[u8]) -> Result<Self> {
if input.len() > 4 * 1024 * 1024 {
return Err(ProfileError::ResourceLimit);
}
let mut e: EncodedKeyFile =
serde_json::from_slice(input).map_err(|_| ProfileError::InvalidKeysFile)?;
let result = (|| {
if e.schema_version != 1
|| e.format != "official-linux"
|| e.account_binding.is_empty()
|| e.account_binding.len() > 512
|| e.account_binding.contains(['/', '\\', '\0'])
|| e.sqlcipher.compatibility != 4
|| e.sqlcipher.page_size != 4096
|| e.sqlcipher.kdf_iterations != 256000
|| e.databases.len() > 4096
{
return Err(ProfileError::InvalidKeysFile);
}
let mut result = Self::empty(e.account_binding.clone());
for d in &e.databases {
let salt: [u8; 16] = hex::decode(&d.salt_hex)
.map_err(|_| ProfileError::InvalidKeysFile)?
.try_into()
.map_err(|_| ProfileError::InvalidKeysFile)?;
let raw = Zeroizing::new(
STANDARD
.decode(&d.raw_page_key_base64)
.map_err(|_| ProfileError::InvalidKeysFile)?,
);
if raw.len() != 32 || !valid_relative_path(&d.relative_path) {
return Err(ProfileError::InvalidKeysFile);
}
if result
.databases
.insert(
d.relative_path.clone(),
DatabaseKey {
relative_path: d.relative_path.clone(),
salt,
raw_page_key: SecretBytes::new(raw.to_vec()),
},
)
.is_some()
{
return Err(ProfileError::InvalidKeysFile);
}
}
if let Some(i) = &e.image_dat {
let aes = Zeroizing::new(
STANDARD
.decode(&i.raw_aes128_key_base64)
.map_err(|_| ProfileError::InvalidKeysFile)?,
);
if i.format_version != 2 || aes.len() != 16 {
return Err(ProfileError::InvalidKeysFile);
}
result.image_dat = Some(ImageDatKey {
aes: SecretBytes::new(aes.to_vec()),
xor: i.xor_byte,
});
}
Ok(result)
})();
e.zeroize();
result
}
pub fn write_new(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
let parent = path.parent().ok_or(ProfileError::InvalidQuery)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let m = fs::metadata(parent)?;
if m.mode() & 0o077 != 0 {
return Err(ProfileError::InsecureKeysFile);
}
}
let mut encoded = EncodedKeyFile {
schema_version: 1,
format: "official-linux".into(),
account_binding: self.account_binding.clone(),
sqlcipher: CipherSettings {
compatibility: 4,
page_size: 4096,
kdf_iterations: 256000,
},
databases: self
.databases
.values()
.map(|d| EncodedDatabaseKey {
relative_path: d.relative_path.clone(),
salt_hex: hex::encode(d.salt),
raw_page_key_base64: STANDARD.encode(d.raw_page_key.expose()),
})
.collect(),
image_dat: self.image_dat.as_ref().map(|i| EncodedImageKey {
format_version: 2,
raw_aes128_key_base64: STANDARD.encode(i.aes.expose()),
xor_byte: i.xor,
}),
};
let mut output = Zeroizing::new(
serde_json::to_vec_pretty(&encoded).map_err(|_| ProfileError::InvalidKeysFile)?,
);
encoded.zeroize();
output.push(b'\n');
let mut f = wechat_platform::private_tempfile_in(parent, ".wx-keys-")?;
use std::io::Write;
f.write_all(&output)?;
f.as_file().sync_all()?;
f.persist_noclobber(path)
.map_err(|e| ProfileError::Io(e.error))?;
wechat_platform::sync_dir(parent)?;
Ok(())
}
}
pub(crate) fn derive_key(password: &[u8], salt: &[u8; 16]) -> SecretBytes {
let mut key = Zeroizing::new(vec![0u8; 32]);
pbkdf2::pbkdf2_hmac::<sha2::Sha512>(password, salt, 256000, &mut key);
SecretBytes::new(key.to_vec())
}
pub(crate) fn verify_first_page(page: &[u8], key: &[u8], salt: &[u8; 16]) -> bool {
use hmac::{Hmac, Mac};
if page.len() < 4096 || key.len() != 32 || &page[..16] != salt {
return false;
}
let hs = salt.map(|v| v ^ 0x3a);
let mut hk = Zeroizing::new([0u8; 32]);
pbkdf2::pbkdf2_hmac::<sha2::Sha512>(key, &hs, 2, &mut *hk);
let Ok(mut mac) = Hmac::<sha2::Sha512>::new_from_slice(&*hk) else {
return false;
};
mac.update(&page[16..4032]);
mac.update(&1u32.to_le_bytes());
mac.verify_slice(&page[4032..4096]).is_ok()
}
pub fn verify_profile_password(
location: &crate::ProfileLocation,
keys: &KeyFile,
password: &[u8],
) -> Result<usize> {
use std::io::Read;
if keys.account_binding != location.account_directory() {
return Err(ProfileError::AccountMismatch);
}
if password.len() != 32 {
return Err(ProfileError::InvalidKeysFile);
}
let mut files = vec![];
crate::snapshot::walk(&location.storage, &mut files, 0, 4096)?;
if files.is_empty() {
return Err(ProfileError::KeysMissing);
}
let mut verified = 0;
for path in files {
let relative = path
.strip_prefix(&location.storage)
.ok()
.and_then(|p| p.to_str())
.ok_or(ProfileError::UnsupportedFile)?
.replace('\\', "/");
let cached = keys
.databases
.get(&relative)
.ok_or_else(|| ProfileError::KeyUnavailable(relative.clone()))?;
let mut options = wechat_platform::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let mut file = options.open(&path)?;
let before = file.metadata()?;
if !before.is_file() {
return Err(ProfileError::UnsupportedFile);
}
let mut page = [0u8; 4096];
file.read_exact(&mut page)
.map_err(|_| ProfileError::KeyMismatch(relative.clone()))?;
let after = fs::metadata(&path)?;
if before.len() != after.len() || before.modified()? != after.modified()? {
return Err(ProfileError::SourceChanged);
}
let mut salt = [0; 16];
salt.copy_from_slice(&page[..16]);
if salt != cached.salt || !verify_first_page(&page, cached.raw_page_key.expose(), &salt) {
return Err(ProfileError::KeyMismatch(relative));
}
let derived = derive_key(password, &salt);
if !openssl::memcmp::eq(derived.expose(), cached.raw_page_key.expose())
|| !verify_first_page(&page, derived.expose(), &salt)
{
return Err(ProfileError::KeyMismatch(relative));
}
verified += 1;
}
Ok(verified)
}
pub fn derive_image_dat_key(uin: u32, username: &str) -> Result<ImageDatKey> {
ImageDatKey::derive(uin, username)
}