Skip to main content

wire_desktop_core/
lib.rs

1//! **Wire desktop** (Electron) messenger reader.
2//!
3//! Wire desktop is an Electron wrapper over the Wire web client; all of its
4//! evidence lives in the Chromium **IndexedDB** store
5//! (`IndexedDB/https_app.wire.com_0.indexeddb.leveldb`), organised as Dexie
6//! object stores. This crate sits on top of the Wave-2 reader
7//! [`chromium_storage_indexeddb`]: it takes the generic decoded IndexedDB
8//! records and interprets them into typed Wire records
9//! ([`WireRecord`]) and a chronological [`timeline`].
10//!
11//! It is a **reader**, not an analyzer — it exposes structure and records and
12//! emits no findings (those live in `wire-desktop-forensic`).
13//!
14//! # Where the bytes are
15//!
16//! The profile path and the app's encryption posture come from the fleet
17//! KNOWLEDGE leaf [`forensicnomicon_core::messenger_desktop`] (spec `"Wire"`) —
18//! this crate never re-hardcodes them. [`read_profile`] resolves the IndexedDB
19//! store under a Wire profile base directory using that spec.
20//!
21//! # Encrypted content — fail loud, never fabricate
22//!
23//! Wire encrypts message content client-side (Proteus). That key is **not** in
24//! the Chromium OS Safe Storage, so it is not recoverable from this artifact.
25//! Encrypted message bodies are surfaced as [`PayloadState::Encrypted`] with
26//! their cleartext metadata (conversation, sender, time) intact; asking for the
27//! plaintext returns a typed [`WireError::EncryptedPayloadUnrecoverable`] rather
28//! than plausible-but-wrong bytes.
29//!
30//! References: hunjison, *Forensic Analysis of Wire Messenger in Windows OS*
31//! (the `https_app.wire.com_0.indexeddb.leveldb` store + the `otr_key`).
32
33#![forbid(unsafe_code)]
34#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
35
36mod error;
37mod record;
38mod timeline;
39
40pub use error::WireError;
41pub use record::{
42    interpret_records, ObjectStoreSummary, PayloadState, WireRecord, WireRecordKind, WireStore,
43};
44pub use timeline::{timeline, TimelineEntry};
45
46use forensicnomicon_core::messenger_desktop::{self, MessengerSpec, StoreRole};
47use std::path::{Path, PathBuf};
48
49/// The Wire desktop artifact spec from the fleet KNOWLEDGE leaf.
50#[must_use]
51pub fn wire_spec() -> Option<&'static MessengerSpec> {
52    messenger_desktop::spec("Wire")
53}
54
55/// Read and interpret a Wire IndexedDB store directory
56/// (`…/https_app.wire.com_0.indexeddb.leveldb`).
57///
58/// Reads every record (including tombstones) via [`chromium_storage_indexeddb`]
59/// and interprets them into a [`WireStore`].
60pub fn read_store(dir: &Path) -> Result<WireStore, WireError> {
61    let records = chromium_storage_indexeddb::read_dir(dir).map_err(|e| WireError::Read {
62        path: dir.display().to_string(),
63        detail: e.to_string(),
64    })?;
65    Ok(interpret_records(&records))
66}
67
68/// Read a Wire IndexedDB store from a **profile base directory** by resolving the
69/// store's relative path from the forensicnomicon Wire spec (never a hardcoded
70/// path here).
71///
72/// `base` is the Electron `userData` directory for Wire (e.g.
73/// `~/Library/Application Support/Wire`). Returns
74/// [`WireError::StoreNotFound`] when the resolved store directory is absent.
75pub fn read_profile(base: &Path) -> Result<WireStore, WireError> {
76    // forensicnomicon-core always ships the Wire spec; the None arm is a
77    // defensive guard for a future catalog that drops it (cov:unreachable).
78    let spec = wire_spec().ok_or_else(|| WireError::StoreNotFound {
79        base: base.display().to_string(),            // cov:unreachable
80        relative: "<Wire spec missing>".to_string(), // cov:unreachable
81    })?; // cov:unreachable
82    let relative = spec
83        .store(StoreRole::Messages)
84        .map_or("IndexedDB", |s| s.relative_path);
85    let dir: PathBuf = base.join(relative);
86    if !dir.is_dir() {
87        return Err(WireError::StoreNotFound {
88            base: base.display().to_string(),
89            relative: relative.to_string(),
90        });
91    }
92    read_store(&dir)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn wire_spec_resolves_from_knowledge_leaf() {
101        let spec = wire_spec().expect("Wire spec present in forensicnomicon-core");
102        assert_eq!(spec.app, "Wire");
103        // The Messages store path is the IndexedDB LevelDB directory.
104        let msgs = spec.store(StoreRole::Messages).expect("messages store");
105        assert!(msgs.relative_path.contains(".indexeddb.leveldb"));
106    }
107
108    #[test]
109    fn read_profile_fails_loud_when_store_absent() {
110        let tmp = std::env::temp_dir().join("wire-desktop-core-nonexistent-profile-xyz");
111        let err = read_profile(&tmp).expect_err("absent store must fail loud");
112        assert!(matches!(err, WireError::StoreNotFound { .. }));
113        assert!(err.to_string().contains(".indexeddb.leveldb"));
114    }
115
116    #[test]
117    fn read_profile_reads_a_store_at_the_spec_relative_path() {
118        // Lay out a profile whose Messages-store relative path (from the Wire
119        // spec) contains the committed minted store, and read it end to end.
120        let spec = wire_spec().expect("spec");
121        let relative = spec
122            .store(StoreRole::Messages)
123            .expect("messages")
124            .relative_path;
125
126        let base = std::env::temp_dir().join(format!("wire-profile-{}", std::process::id()));
127        let dest = base.join(relative);
128        std::fs::create_dir_all(&dest).expect("mkdir profile store");
129        let src = std::path::PathBuf::from(concat!(
130            env!("CARGO_MANIFEST_DIR"),
131            "/../tests/data/wire-indexeddb/http_127.0.0.1_8731.indexeddb.leveldb"
132        ));
133        for entry in std::fs::read_dir(&src).expect("read fixture dir") {
134            let entry = entry.expect("entry");
135            std::fs::copy(entry.path(), dest.join(entry.file_name())).expect("copy fixture file");
136        }
137
138        let store = read_profile(&base).expect("read_profile happy path");
139        assert!(store
140            .records
141            .iter()
142            .any(|r| r.text.as_deref() == Some("meet at 9")));
143
144        std::fs::remove_dir_all(&base).ok();
145    }
146
147    #[test]
148    fn read_store_surfaces_reader_errors() {
149        let missing = std::env::temp_dir().join("wire-desktop-core-no-such-store-dir");
150        let err = read_store(&missing).expect_err("missing dir must error");
151        assert!(matches!(err, WireError::Read { .. }));
152        assert!(err.to_string().contains("no-such-store-dir"));
153    }
154}