Skip to main content

wechat_profile/
snapshot.rs

1use crate::{
2    keys::{derive_key, verify_first_page, SecretBytes},
3    DatabaseInventory, DatabaseKey, DatabaseState, KeyFile, ProfileError, Result,
4};
5use rusqlite::{Connection, OpenFlags};
6use sha2::{Digest, Sha256};
7use std::{
8    collections::BTreeMap,
9    fs,
10    io::{Read, Write},
11    path::{Path, PathBuf},
12    sync::{Arc, Mutex, MutexGuard, TryLockError},
13    time::Duration,
14};
15use zeroize::Zeroizing;
16
17#[derive(Clone, Debug)]
18pub struct ProfileLocation {
19    pub(crate) root: PathBuf,
20    pub(crate) storage: PathBuf,
21    pub(crate) account: String,
22}
23impl ProfileLocation {
24    /// Select one account root containing db_storage, or db_storage itself. A
25    /// whole WeChat home with multiple accounts is never resolved implicitly.
26    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
27        let p = fs::canonicalize(path).map_err(|_| ProfileError::InvalidProfile)?;
28        let root = if p.file_name().is_some_and(|n| n == "db_storage") {
29            p.parent()
30                .ok_or(ProfileError::InvalidProfile)?
31                .to_path_buf()
32        } else {
33            p
34        };
35        let storage = root.join("db_storage");
36        if !root.is_dir()
37            || !storage.is_dir()
38            || fs::symlink_metadata(&storage)?.file_type().is_symlink()
39        {
40            return Err(ProfileError::InvalidProfile);
41        }
42        let account = root
43            .file_name()
44            .and_then(|s| s.to_str())
45            .ok_or(ProfileError::InvalidProfile)?
46            .to_string();
47        Ok(Self {
48            root,
49            storage,
50            account,
51        })
52    }
53    pub fn root(&self) -> &Path {
54        &self.root
55    }
56    pub fn account_directory(&self) -> &str {
57        &self.account
58    }
59}
60
61#[derive(Clone, Debug)]
62pub struct ReadOptions {
63    /// This is a caller assertion, not an operating-system guarantee. Advisory
64    /// locks cannot stop an unmanaged official client from writing the profile.
65    pub stopped_or_consistent_snapshot: bool,
66    /// Intended for explicit plaintext exports and synthetic fixtures only.
67    pub allow_plaintext: bool,
68    pub max_database_bytes: u64,
69    pub max_total_bytes: u64,
70    pub max_databases: usize,
71    pub max_blob_bytes: usize,
72    pub query_timeout: Duration,
73}
74impl Default for ReadOptions {
75    fn default() -> Self {
76        Self {
77            stopped_or_consistent_snapshot: false,
78            allow_plaintext: false,
79            max_database_bytes: 2 * 1024 * 1024 * 1024,
80            max_total_bytes: 8 * 1024 * 1024 * 1024,
81            max_databases: 4096,
82            max_blob_bytes: 16 * 1024 * 1024,
83            query_timeout: Duration::from_secs(10),
84        }
85    }
86}
87
88pub(crate) struct Snapshot {
89    pub location: ProfileLocation,
90    pub keys: KeyFile,
91    pub options: ReadOptions,
92    pub generation: String,
93    pub inventory: Vec<DatabaseInventory>,
94    pub paths: BTreeMap<String, PathBuf>,
95    pub(crate) _temp: Option<wechat_platform::PrivateTempDir>,
96    pub(crate) owned: BTreeMap<String, Arc<Mutex<Connection>>>,
97    pub(crate) pinned: Option<PinnedTransactions>,
98}
99
100pub(crate) struct PinnedTransactions {
101    pub(crate) connections: BTreeMap<String, Vec<Arc<Mutex<Connection>>>>,
102}
103impl Drop for PinnedTransactions {
104    fn drop(&mut self) {
105        for connections in self.connections.values() {
106            for connection in connections {
107                if let Ok(connection) = connection.lock() {
108                    connection.progress_handler(0, None::<fn() -> bool>);
109                    let _ = connection.execute_batch("ROLLBACK");
110                }
111            }
112        }
113    }
114}
115
116pub(crate) enum ReadConnection<'a> {
117    Copied(Connection),
118    Owned(MutexGuard<'a, Connection>),
119    Pinned(MutexGuard<'a, Connection>),
120}
121impl std::ops::Deref for ReadConnection<'_> {
122    type Target = Connection;
123    fn deref(&self) -> &Connection {
124        match self {
125            Self::Copied(connection) => connection,
126            Self::Owned(connection) | Self::Pinned(connection) => connection,
127        }
128    }
129}
130impl Drop for ReadConnection<'_> {
131    fn drop(&mut self) {
132        if let Self::Owned(connection) = self {
133            connection.progress_handler(0, None::<fn() -> bool>);
134            let _ = connection.execute_batch("ROLLBACK");
135        }
136    }
137}
138
139fn relative(root: &Path, p: &Path) -> Result<String> {
140    p.strip_prefix(root)
141        .ok()
142        .and_then(|v| v.to_str())
143        .map(|v| v.replace('\\', "/"))
144        .ok_or(ProfileError::UnsupportedFile)
145}
146pub(crate) fn walk(root: &Path, out: &mut Vec<PathBuf>, depth: usize, max: usize) -> Result<()> {
147    if depth > 16 {
148        return Err(ProfileError::ResourceLimit);
149    }
150    let mut entries = fs::read_dir(root)?.collect::<std::result::Result<Vec<_>, _>>()?;
151    entries.sort_by_key(|e| e.file_name());
152    for e in entries {
153        let m = fs::symlink_metadata(e.path())?;
154        if wechat_platform::is_link(&m) || (!m.is_file() && !m.is_dir()) {
155            return Err(ProfileError::UnsupportedFile);
156        }
157        if m.is_dir() {
158            walk(&e.path(), out, depth + 1, max)?;
159        } else if e.path().extension().is_some_and(|s| s == "db") {
160            out.push(e.path());
161            if out.len() > max {
162                return Err(ProfileError::ResourceLimit);
163            }
164        }
165    }
166    Ok(())
167}
168fn hash_copy(
169    source: &Path,
170    destination: Option<&Path>,
171    remaining: &mut u64,
172    max: u64,
173) -> Result<(u64, String)> {
174    let m = fs::symlink_metadata(source)?;
175    if !m.is_file() || wechat_platform::is_link(&m) {
176        return Err(ProfileError::UnsupportedFile);
177    }
178    if m.len() > max || m.len() > *remaining {
179        return Err(ProfileError::ResourceLimit);
180    }
181    let mut options = wechat_platform::OpenOptions::new();
182    options.read(true);
183    #[cfg(unix)]
184    {
185        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
186    }
187    let mut from = options.open(source)?;
188    if !from.metadata()?.is_file() {
189        return Err(ProfileError::UnsupportedFile);
190    }
191    let mut to = if let Some(destination) = destination {
192        let mut options = wechat_platform::OpenOptions::new();
193        options.write(true).create_new(true);
194        #[cfg(unix)]
195        {
196            options
197                .mode(0o600)
198                .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
199        }
200        Some(options.open(destination)?)
201    } else {
202        None
203    };
204    let mut h = Sha256::new();
205    let mut bytes = 0u64;
206    let mut buf = [0u8; 65536];
207    loop {
208        let n = from.read(&mut buf)?;
209        if n == 0 {
210            break;
211        }
212        bytes += n as u64;
213        if bytes > max || bytes > *remaining {
214            return Err(ProfileError::ResourceLimit);
215        }
216        h.update(&buf[..n]);
217        if let Some(f) = &mut to {
218            f.write_all(&buf[..n])?;
219        }
220    }
221    *remaining -= bytes;
222    Ok((bytes, hex::encode(h.finalize())))
223}
224fn candidates(storage: &Path, options: &ReadOptions) -> Result<Vec<PathBuf>> {
225    let mut dbs = Vec::new();
226    walk(storage, &mut dbs, 0, options.max_databases)?;
227    if dbs.is_empty() {
228        return Err(ProfileError::InvalidProfile);
229    }
230    let mut files = Vec::new();
231    for p in dbs {
232        files.push(p.clone());
233        for suffix in ["-wal", "-shm"] {
234            let x = PathBuf::from(format!("{}{suffix}", p.display()));
235            if x.try_exists()? {
236                files.push(x);
237            }
238        }
239    }
240    Ok(files)
241}
242
243impl Snapshot {
244    pub fn create(location: ProfileLocation, keys: KeyFile, options: ReadOptions) -> Result<Self> {
245        if !options.stopped_or_consistent_snapshot {
246            return Err(ProfileError::SnapshotRequired);
247        }
248        if keys.account_binding != location.account {
249            return Err(ProfileError::AccountMismatch);
250        }
251        if options.max_blob_bytes == 0
252            || options.max_blob_bytes > 64 * 1024 * 1024
253            || options.max_databases == 0
254            || options.max_databases > 4096
255            || options.query_timeout.is_zero()
256        {
257            return Err(ProfileError::InvalidQuery);
258        }
259        let temp = wechat_platform::private_tempdir("agent-wechat-read-")?;
260        // macOS and custom TMPDIRs may reach this private directory through a
261        // symlink. Resolve its root before opening files with SQLITE_OPEN_NOFOLLOW.
262        let temp_root = fs::canonicalize(temp.path())?;
263        #[cfg(unix)]
264        {
265            use std::os::unix::fs::PermissionsExt;
266            fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))?;
267        }
268        let mut manifest = BTreeMap::new();
269        let mut total = options.max_total_bytes;
270        let files = candidates(&location.storage, &options)?;
271        for source in &files {
272            let rel = relative(&location.storage, source)?;
273            let dest = temp_root.join(&rel);
274            fs::create_dir_all(dest.parent().ok_or(ProfileError::UnsupportedFile)?)?;
275            let (bytes, hash) =
276                hash_copy(source, Some(&dest), &mut total, options.max_database_bytes)?;
277            manifest.insert(rel, (bytes, hash));
278        }
279        // Re-inventory catches newly created/deleted WAL files as well as main
280        // changes. A stable double scan still requires the caller's quiescence.
281        if files != candidates(&location.storage, &options)? {
282            return Err(ProfileError::SourceChanged);
283        }
284        let mut total = options.max_total_bytes;
285        for source in &files {
286            let rel = relative(&location.storage, source)?;
287            if manifest.get(&rel)
288                != Some(&hash_copy(
289                    source,
290                    None,
291                    &mut total,
292                    options.max_database_bytes,
293                )?)
294            {
295                return Err(ProfileError::SourceChanged);
296            }
297        }
298        let mut h = Sha256::new();
299        h.update(location.root.as_os_str().as_encoded_bytes());
300        for (rel, (bytes, digest)) in &manifest {
301            h.update(rel.as_bytes());
302            h.update(bytes.to_le_bytes());
303            h.update(digest.as_bytes());
304        }
305        let generation = hex::encode(h.finalize());
306        let mut inventory = Vec::new();
307        let mut paths = BTreeMap::new();
308        for (rel, (bytes, _)) in &manifest {
309            if !rel.ends_with(".db") {
310                continue;
311            }
312            let p = temp_root.join(rel);
313            let mut prefix = [0u8; 16];
314            let mut f = fs::File::open(&p)?;
315            let n = f.read(&mut prefix)?;
316            let plain = n == 16 && prefix == *b"SQLite format 3\0";
317            let mut dbh = Sha256::new();
318            dbh.update(manifest.get(rel).unwrap().1.as_bytes());
319            if let Some((_, digest)) = manifest.get(&format!("{rel}-wal")) {
320                dbh.update(digest.as_bytes());
321            }
322            inventory.push(DatabaseInventory {
323                relative_path: rel.clone(),
324                bytes: *bytes,
325                plaintext: plain,
326                has_wal: manifest.contains_key(&format!("{rel}-wal")),
327                generation: hex::encode(dbh.finalize()),
328                state: DatabaseState::Readable,
329            });
330            paths.insert(rel.clone(), p);
331        }
332        let mut result = Self {
333            location,
334            keys,
335            options,
336            generation,
337            inventory,
338            paths,
339            _temp: Some(temp),
340            owned: BTreeMap::new(),
341            pinned: None,
342        };
343        for i in 0..result.inventory.len() {
344            let rel = result.inventory[i].relative_path.clone();
345            let state = match result.open(&rel) {
346                Ok(_) => DatabaseState::Readable,
347                Err(ProfileError::KeyUnavailable(_)) => DatabaseState::KeyUnavailable,
348                Err(ProfileError::KeyMismatch(_)) => DatabaseState::KeyMismatch,
349                Err(e) => return Err(e),
350            };
351            result.inventory[i].state = state;
352        }
353        Ok(result)
354    }
355    pub fn open(&self, rel: &str) -> Result<ReadConnection<'_>> {
356        if let Some(pinned) = &self.pinned {
357            let connections = pinned
358                .connections
359                .get(rel)
360                .ok_or(ProfileError::InvalidQuery)?;
361            for connection in connections {
362                match connection.try_lock() {
363                    Ok(connection) => return Ok(ReadConnection::Pinned(connection)),
364                    Err(TryLockError::WouldBlock) => continue,
365                    Err(TryLockError::Poisoned(_)) => return Err(ProfileError::Database),
366                }
367            }
368            // Never fall back to a fresh connection with a different snapshot.
369            return Err(ProfileError::ResourceLimit);
370        }
371        if let Some(connection) = self.owned.get(rel) {
372            let connection = match connection.try_lock() {
373                Ok(connection) => connection,
374                Err(TryLockError::WouldBlock) => {
375                    // Some compound readers nest a second query of the same
376                    // database. It must not wait on its own checked-out handle.
377                    return Ok(ReadConnection::Copied(self.open_connection(rel, true)?));
378                }
379                Err(TryLockError::Poisoned(_)) => return Err(ProfileError::Database),
380            };
381            let deadline = std::time::Instant::now() + self.options.query_timeout;
382            connection.progress_handler(1000, Some(move || std::time::Instant::now() > deadline));
383            connection.execute_batch("BEGIN DEFERRED")?;
384            return Ok(ReadConnection::Owned(connection));
385        }
386        Ok(ReadConnection::Copied(
387            self.open_connection(rel, self._temp.is_none())?,
388        ))
389    }
390    pub(crate) fn open_connection(&self, rel: &str, read_only: bool) -> Result<Connection> {
391        let path = self.paths.get(rel).ok_or(ProfileError::InvalidQuery)?;
392        let entry = self
393            .inventory
394            .iter()
395            .find(|d| d.relative_path == rel)
396            .ok_or(ProfileError::InvalidQuery)?;
397        let c = Connection::open_with_flags(
398            path,
399            (if read_only {
400                OpenFlags::SQLITE_OPEN_READ_ONLY
401            } else {
402                OpenFlags::SQLITE_OPEN_READ_WRITE
403            }) | OpenFlags::SQLITE_OPEN_NO_MUTEX
404                | OpenFlags::SQLITE_OPEN_NOFOLLOW,
405        )?;
406        c.busy_timeout(Duration::from_millis(1000))?;
407        if entry.plaintext {
408            if !self.options.allow_plaintext {
409                return Err(ProfileError::KeyUnavailable(rel.into()));
410            }
411        } else {
412            let k = self
413                .keys
414                .databases
415                .get(rel)
416                .ok_or_else(|| ProfileError::KeyUnavailable(rel.into()))?;
417            let mut page = [0u8; 4096];
418            fs::File::open(path)?
419                .read_exact(&mut page)
420                .map_err(|_| ProfileError::KeyMismatch(rel.into()))?;
421            if !verify_first_page(&page, k.raw_page_key.expose(), &k.salt) {
422                return Err(ProfileError::KeyMismatch(rel.into()));
423            }
424            configure_cipher(&c, &k.raw_page_key, rel)?;
425        }
426        c.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
427            r.get::<_, i64>(0)
428        })
429        .map_err(|_| ProfileError::DatabaseOpen(rel.into()))?;
430        c.execute_batch(
431            "PRAGMA query_only=ON; PRAGMA temp_store=MEMORY; PRAGMA trusted_schema=OFF;",
432        )?;
433        c.set_limit(
434            rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH,
435            self.options.max_blob_bytes as i32,
436        );
437        c.set_limit(
438            rusqlite::limits::Limit::SQLITE_LIMIT_SQL_LENGTH,
439            1024 * 1024,
440        );
441        c.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_ATTACHED, 0);
442        let deadline = std::time::Instant::now() + self.options.query_timeout;
443        c.progress_handler(1000, Some(move || std::time::Instant::now() > deadline));
444        Ok(c)
445    }
446}
447
448pub(crate) fn configure_cipher(c: &Connection, key: &SecretBytes, rel: &str) -> Result<()> {
449    let available = c
450        .query_row("PRAGMA cipher_version", [], |r| r.get::<_, String>(0))
451        .ok()
452        .is_some_and(|s| !s.is_empty());
453    if !available {
454        return Err(ProfileError::SqlcipherUnavailable);
455    }
456    let sql = Zeroizing::new(format!(
457        "PRAGMA key = \"x'{}'\"; PRAGMA cipher_compatibility=4; PRAGMA cipher_page_size=4096;",
458        hex::encode(key.expose())
459    ));
460    c.execute_batch(&sql)
461        .map_err(|_| ProfileError::DatabaseOpen(rel.into()))
462}
463
464/// Export only validated per-file keys from an explicitly supplied existing
465/// account password. No password discovery, fallback, account switch, or source
466/// mutation takes place; the password itself is not retained in the result.
467pub fn keys_from_password(
468    location: ProfileLocation,
469    password: &[u8],
470    mut options: ReadOptions,
471) -> Result<KeyFile> {
472    if password.len() != 32 {
473        return Err(ProfileError::InvalidKeysFile);
474    }
475    options.allow_plaintext = false;
476    // Capture first, then derive on the private copy. Missing keys are expected.
477    let snap = Snapshot::create(
478        location.clone(),
479        KeyFile::empty(location.account.clone()),
480        options,
481    )?;
482    let mut keys = KeyFile::empty(location.account.clone());
483    for (rel, path) in &snap.paths {
484        let mut page = [0u8; 4096];
485        fs::File::open(path)?
486            .read_exact(&mut page)
487            .map_err(|_| ProfileError::KeyMismatch(rel.clone()))?;
488        if &page[..16] == b"SQLite format 3\0" {
489            return Err(ProfileError::KeyMismatch(rel.clone()));
490        }
491        let mut salt = [0u8; 16];
492        salt.copy_from_slice(&page[..16]);
493        let key = derive_key(password, &salt);
494        if !verify_first_page(&page, key.expose(), &salt) {
495            return Err(ProfileError::KeyMismatch(rel.clone()));
496        }
497        let c = Connection::open(path).map_err(|_| ProfileError::DatabaseOpen(rel.clone()))?;
498        configure_cipher(&c, &key, rel)?;
499        c.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
500            r.get::<_, i64>(0)
501        })
502        .map_err(|_| ProfileError::DatabaseOpen(rel.clone()))?;
503        keys.databases.insert(
504            rel.clone(),
505            DatabaseKey {
506                relative_path: rel.clone(),
507                salt,
508                raw_page_key: key,
509            },
510        );
511    }
512    Ok(keys)
513}
514
515/// Read only the matching authorized cached key records from a coherent copied
516/// agent database. This never calls the old database initializer/migrator or any
517/// key acquisition process. The agent token is an unlock passphrase, not an
518/// exported profile key or network credential.
519pub fn keys_from_agent_cache(
520    location: ProfileLocation,
521    agent_database: &Path,
522    agent_token: &[u8],
523    session_id: Option<&str>,
524    options: ReadOptions,
525) -> Result<KeyFile> {
526    if !options.stopped_or_consistent_snapshot {
527        return Err(ProfileError::SnapshotRequired);
528    }
529    if agent_token.is_empty() || agent_token.len() > 16384 {
530        return Err(ProfileError::InvalidKeysFile);
531    }
532    let pass = std::str::from_utf8(agent_token)
533        .map_err(|_| ProfileError::InvalidKeysFile)?
534        .trim();
535    if pass.is_empty() || pass.contains('\0') {
536        return Err(ProfileError::InvalidKeysFile);
537    }
538    let temp = wechat_platform::private_tempdir("agent-wechat-key-export-")?;
539    let copied = temp.path().join("agent.db");
540    let mut hashes = BTreeMap::new();
541    let mut total = options.max_total_bytes;
542    #[cfg(unix)]
543    {
544        use std::os::unix::fs::PermissionsExt;
545        fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))?;
546    }
547    for suffix in ["", "-wal", "-shm"] {
548        let source = PathBuf::from(format!("{}{suffix}", agent_database.display()));
549        if source.try_exists()? {
550            let dest = PathBuf::from(format!("{}{suffix}", copied.display()));
551            hashes.insert(
552                source.clone(),
553                hash_copy(&source, Some(&dest), &mut total, options.max_database_bytes)?,
554            );
555        }
556    }
557    if !hashes.contains_key(agent_database) {
558        return Err(ProfileError::KeysMissing);
559    }
560    let mut total = options.max_total_bytes;
561    for (source, hash) in &hashes {
562        if &hash_copy(source, None, &mut total, options.max_database_bytes)? != hash {
563            return Err(ProfileError::SourceChanged);
564        }
565    }
566    for suffix in ["", "-wal", "-shm"] {
567        let source = PathBuf::from(format!("{}{suffix}", agent_database.display()));
568        if source.try_exists()? != hashes.contains_key(&source) {
569            return Err(ProfileError::SourceChanged);
570        }
571    }
572    let c = Connection::open(&copied)?;
573    if c.query_row("PRAGMA cipher_version", [], |r| r.get::<_, String>(0))
574        .ok()
575        .is_none()
576    {
577        return Err(ProfileError::SqlcipherUnavailable);
578    }
579    let sql=Zeroizing::new(format!("PRAGMA key='{}'; PRAGMA query_only=ON; PRAGMA temp_store=MEMORY; PRAGMA trusted_schema=OFF;",pass.replace('\'',"''")));
580    c.execute_batch(&sql)
581        .map_err(|_| ProfileError::InvalidKeysFile)?;
582    c.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, 1024 * 1024);
583    let deadline = std::time::Instant::now() + options.query_timeout;
584    c.progress_handler(1000, Some(move || std::time::Instant::now() > deadline));
585    let mut s=c.prepare("SELECT DISTINCT session_id FROM wechat_keys WHERE account_dir=?1 ORDER BY session_id LIMIT 3").map_err(|_|ProfileError::InvalidKeysFile)?;
586    let sessions = s
587        .query_map([&location.account], |r| r.get::<_, String>(0))
588        .map_err(|_| ProfileError::InvalidKeysFile)?
589        .collect::<std::result::Result<Vec<_>, _>>()
590        .map_err(|_| ProfileError::InvalidKeysFile)?;
591    let selected = match session_id {
592        Some(id) if sessions.iter().any(|s| s == id) => id,
593        None if sessions.len() == 1 => &sessions[0],
594        _ => return Err(ProfileError::InvalidKeysFile),
595    };
596    let mut s=c.prepare("SELECT db_name,hex_key FROM wechat_keys WHERE account_dir=?1 AND session_id=?2 LIMIT 4097").map_err(|_|ProfileError::InvalidKeysFile)?;
597    let mut records = Zeroizing::new(
598        s.query_map(rusqlite::params![location.account, selected], |r| {
599            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
600        })
601        .map_err(|_| ProfileError::InvalidKeysFile)?
602        .collect::<std::result::Result<Vec<_>, _>>()
603        .map_err(|_| ProfileError::InvalidKeysFile)?,
604    );
605    if records.len() > 4096 {
606        return Err(ProfileError::ResourceLimit);
607    }
608    let mut keys = KeyFile::empty(location.account.clone());
609    let snap = Snapshot::create(location.clone(), keys.clone(), options.clone())?;
610    for (name, key) in records.iter() {
611        if name.starts_with('_') {
612            continue;
613        }
614        let matching = snap
615            .paths
616            .iter()
617            .filter(|(p, _)| p.rsplit('/').next() == Some(name.as_str()))
618            .collect::<Vec<_>>();
619        if matching.len() != 1 {
620            continue;
621        }
622        let (rel, path) = matching[0];
623        let mut page = [0u8; 4096];
624        fs::File::open(path)?.read_exact(&mut page)?;
625        let salt: [u8; 16] = page[..16]
626            .try_into()
627            .map_err(|_| ProfileError::InvalidKeysFile)?;
628        let key = SecretBytes::new(hex::decode(key).map_err(|_| ProfileError::InvalidKeysFile)?);
629        if key.expose().len() != 32 || !verify_first_page(&page, key.expose(), &salt) {
630            return Err(ProfileError::KeyMismatch(rel.clone()));
631        }
632        let test = Connection::open(path)?;
633        configure_cipher(&test, &key, rel)?;
634        test.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
635            r.get::<_, i64>(0)
636        })
637        .map_err(|_| ProfileError::KeyMismatch(rel.clone()))?;
638        keys.databases.insert(
639            rel.clone(),
640            DatabaseKey {
641                relative_path: rel.clone(),
642                salt,
643                raw_page_key: key,
644            },
645        );
646    }
647    let aes = records
648        .iter()
649        .filter(|(n, _)| n == "_image_aes")
650        .collect::<Vec<_>>();
651    let xor = records
652        .iter()
653        .filter(|(n, _)| n == "_image_xor")
654        .collect::<Vec<_>>();
655    if aes.len() > 1 || xor.len() > 1 {
656        return Err(ProfileError::InvalidKeysFile);
657    }
658    if let (Some(a), Some(x)) = (aes.first(), xor.first()) {
659        let x = u8::from_str_radix(&x.1, 16).map_err(|_| ProfileError::InvalidKeysFile)?;
660        keys.image_dat = Some(crate::ImageDatKey::from_legacy_cache(&a.1, x)?);
661    }
662    for (_, v) in records.iter_mut() {
663        zeroize::Zeroize::zeroize(v);
664    }
665    if keys.databases.is_empty() {
666        return Err(ProfileError::KeyUnavailable(
667            "authorized agent cache".into(),
668        ));
669    }
670    Ok(keys)
671}