use crate::catalog::types::Platform;
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppKind {
Electron,
Native,
}
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreRole {
Messages,
Account,
Contacts,
Attachments,
MediaCache,
EncryptionKey,
}
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreFormat {
SqlCipher,
EncryptedSqlite,
Sqlite,
ChromiumLevelDb,
ChromiumSimpleCache,
Json,
EncryptedFiles,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessengerStore {
pub role: StoreRole,
pub relative_path: &'static str,
pub format: StoreFormat,
pub encrypted: bool,
pub platforms: &'static [Platform],
pub note: &'static str,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProfilePath {
pub platform: Platform,
pub base_dir: &'static str,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebClient {
pub origin: &'static str,
pub alt_origins: &'static [&'static str],
pub note: &'static str,
pub sources: &'static [&'static str],
}
impl WebClient {
#[must_use]
pub fn indexeddb_dir(&self) -> String {
let (scheme, host) = self
.origin
.split_once("://")
.unwrap_or(("https", self.origin));
format!("IndexedDB/{scheme}_{host}_0.indexeddb.leveldb")
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessengerSpec {
pub app: &'static str,
pub app_kind: AppKind,
pub profiles: &'static [ProfilePath],
pub stores: &'static [MessengerStore],
pub web: Option<WebClient>,
pub note: &'static str,
pub sources: &'static [&'static str],
}
impl MessengerSpec {
#[must_use]
pub fn base_dir(&self, platform: Platform) -> Option<&'static str> {
self.profiles
.iter()
.find(|p| p.platform == platform)
.map(|p| p.base_dir)
}
#[must_use]
pub fn store(&self, role: StoreRole) -> Option<&'static MessengerStore> {
self.stores.iter().find(|s| s.role == role)
}
}
const ALL_PLATFORMS: &[Platform] = &[Platform::Windows, Platform::MacOS, Platform::Linux];
pub const DESKTOP_MESSENGERS: &[MessengerSpec] = &[
MessengerSpec {
app: "Signal Desktop",
app_kind: AppKind::Electron,
profiles: &[
ProfilePath {
platform: Platform::Windows,
base_dir: r"%AppData%\Signal",
},
ProfilePath {
platform: Platform::MacOS,
base_dir: "~/Library/Application Support/Signal",
},
ProfilePath {
platform: Platform::Linux,
base_dir: "~/.config/Signal",
},
],
stores: &[
MessengerStore {
role: StoreRole::Messages,
relative_path: "sql/db.sqlite",
format: StoreFormat::SqlCipher,
encrypted: true,
platforms: ALL_PLATFORMS,
note: "SQLCipher (use SQLCipher 4 defaults); `messages` table = chat body, `conversations` table = contacts/groups.",
},
MessengerStore {
role: StoreRole::Contacts,
relative_path: "sql/db.sqlite",
format: StoreFormat::SqlCipher,
encrypted: true,
platforms: ALL_PLATFORMS,
note: "Same DB as messages; the `conversations` table holds contact/group rows.",
},
MessengerStore {
role: StoreRole::EncryptionKey,
relative_path: "config.json",
format: StoreFormat::Json,
encrypted: true,
platforms: ALL_PLATFORMS,
note: "Legacy: plaintext `key`. Modern: `encryptedKey` wrapped by the OS keystore via `Local State` (Windows DPAPI / macOS Keychain 'Signal Safe Storage' / Linux libsecret).",
},
MessengerStore {
role: StoreRole::Attachments,
relative_path: "attachments.noindex",
format: StoreFormat::EncryptedFiles,
encrypted: true,
platforms: ALL_PLATFORMS,
note: "Per-attachment key derived from the SQLCipher master key.",
},
],
web: None,
note: "Also carries Chromium `Local Storage/leveldb` and `IndexedDB/file__0.indexeddb.leveldb` app-state stores.",
sources: &["https://www.alexbilz.com/post/2021-06-07-forensic-artifacts-signal-desktop/"],
},
MessengerSpec {
app: "Discord",
app_kind: AppKind::Electron,
profiles: &[
ProfilePath {
platform: Platform::Windows,
base_dir: r"%AppData%\discord",
},
ProfilePath {
platform: Platform::MacOS,
base_dir: "~/Library/Application Support/discord",
},
ProfilePath {
platform: Platform::Linux,
base_dir: "~/.config/discord",
},
],
stores: &[
MessengerStore {
role: StoreRole::Account,
relative_path: "Local Storage/leveldb",
format: StoreFormat::ChromiumLevelDb,
encrypted: true,
platforms: ALL_PLATFORMS,
note: "Auth token in the `.ldb`/`.log` files (DPAPI-protected in newer clients); prime info-stealer target. Test-build variants live under `discordptb` / `discordcanary`.",
},
MessengerStore {
role: StoreRole::MediaCache,
relative_path: "Cache/Cache_Data",
format: StoreFormat::ChromiumSimpleCache,
encrypted: false,
platforms: ALL_PLATFORMS,
note: "Chromium Simple Cache: cached attachments, media, webhook URLs and API JSON. Survives message/channel/server deletion.",
},
],
web: Some(WebClient {
origin: "https://discord.com",
alt_origins: &["https://ptb.discord.com", "https://canary.discord.com"],
note: "In a browser profile: the auth token/app state is in the shared `Local Storage/leveldb` (keys prefixed with the origin), per-origin `IndexedDB/https_discord.com_0.indexeddb.leveldb`, and cached media in the browser's Simple Cache. Same formats as the desktop app, under the browser profile root.",
sources: &[
"https://asec.ahnlab.com/en/24512/",
"https://www.forensafe.com/blogs/discord.html",
],
}),
note: "No local message database — chats are fetched from the server and only cached; recoverable message evidence is the Simple Cache, not a chat DB.",
sources: &[
"https://www.forensafe.com/blogs/discord.html",
"https://asec.ahnlab.com/en/24512/",
"https://sankara-ns.medium.com/simple-forensic-analysis-on-discord-in-windows-10-d530506dcd81",
],
},
MessengerSpec {
app: "Wire",
app_kind: AppKind::Electron,
profiles: &[
ProfilePath {
platform: Platform::Windows,
base_dir: r"%AppData%\Wire",
},
ProfilePath {
platform: Platform::MacOS,
base_dir: "~/Library/Application Support/Wire",
},
ProfilePath {
platform: Platform::Linux,
base_dir: "~/.config/Wire",
},
],
stores: &[
MessengerStore {
role: StoreRole::Messages,
relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
format: StoreFormat::ChromiumLevelDb,
encrypted: false,
platforms: ALL_PLATFORMS,
note: "Chat logs in the IndexedDB object stores: conversation id, sender, timestamp, message body.",
},
MessengerStore {
role: StoreRole::Account,
relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
format: StoreFormat::ChromiumLevelDb,
encrypted: false,
platforms: ALL_PLATFORMS,
note: "Device class/model, verification status and account domain live in the same IndexedDB.",
},
MessengerStore {
role: StoreRole::EncryptionKey,
relative_path: "IndexedDB/https_app.wire.com_0.indexeddb.leveldb",
format: StoreFormat::ChromiumLevelDb,
encrypted: false,
platforms: ALL_PLATFORMS,
note: "`otr_key` (stored as decimal, convert to hex) decrypts attachments.",
},
],
web: Some(WebClient {
origin: "https://app.wire.com",
alt_origins: &[],
note: "Same IndexedDB object stores as the desktop app (the Electron wrapper points at this origin), under the browser profile root instead of `userData`: `IndexedDB/https_app.wire.com_0.indexeddb.leveldb`.",
sources: &["https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS"],
}),
note: "Electron wrapper over the Wire web client; all evidence is in the Chromium IndexedDB.",
sources: &["https://velog.io/@hunjison/Forensic-Analysis-of-Wire-Messenger-in-Windows-OS"],
},
MessengerSpec {
app: "WhatsApp Desktop",
app_kind: AppKind::Native,
profiles: &[
ProfilePath {
platform: Platform::Windows,
base_dir: r"%LocalAppData%\Packages\5319275A.WhatsAppDesktop_cv1g1gvanyjgm\LocalState",
},
ProfilePath {
platform: Platform::MacOS,
base_dir: "~/Library/Containers/desktop.WhatsApp",
},
],
stores: &[
MessengerStore {
role: StoreRole::Messages,
relative_path: "genericStorageDB",
format: StoreFormat::EncryptedSqlite,
encrypted: true,
platforms: &[Platform::Windows],
note: "WebView2 arch: `genericStorageDB` holds messages, SEE + DPAPI-NG encrypted. Older UWP arch used SEE-encrypted SQLite with `nondb_settings[0-9]{2}.dat` key files.",
},
MessengerStore {
role: StoreRole::EncryptionKey,
relative_path: "Session.db",
format: StoreFormat::EncryptedSqlite,
encrypted: true,
platforms: &[Platform::Windows],
note: "`Session.db`/`session.db-wal` store the session clientKeys; per-session `nativeSettings.db` holds further key material (DPAPI-NG protected).",
},
],
web: Some(WebClient {
origin: "https://web.whatsapp.com",
alt_origins: &[],
note: "WhatsApp Web keeps chats/contacts in the browser's per-origin `IndexedDB/https_web.whatsapp.com_0.indexeddb.leveldb` (model-storage object stores, Blink/V8-serialized values). There is NO native Linux desktop client, so on Linux this browser-profile store is the only WhatsApp artifact.",
sources: &[
"https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3",
],
}),
note: "macOS Catalyst client stores chats in a Core Data SQLite under the container; the exact desktop DB path was not confirmed by the cited sources. No native Linux desktop client — see the `web` client for Linux (and browser-based) coverage.",
sources: &[
"https://medium.com/@alberto.magno/whatsapp-desktop-and-web-live-forensics-4n6-233f640e9fb3",
"https://belkasoft.com/whatsapp_forensics_on_computers",
],
},
];
#[must_use]
pub fn spec(app: &str) -> Option<&'static MessengerSpec> {
DESKTOP_MESSENGERS.iter().find(|m| m.app == app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_four_messengers_present() {
assert!(spec("Signal Desktop").is_some());
assert!(spec("Discord").is_some());
assert!(spec("Wire").is_some());
assert!(spec("WhatsApp Desktop").is_some());
assert!(spec("does-not-exist").is_none());
assert!(DESKTOP_MESSENGERS.len() >= 4);
}
#[test]
fn signal_paths_and_stores() {
let s = spec("Signal Desktop").expect("signal spec");
assert_eq!(s.app_kind, AppKind::Electron);
assert_eq!(s.base_dir(Platform::Windows), Some(r"%AppData%\Signal"));
assert_eq!(
s.base_dir(Platform::MacOS),
Some("~/Library/Application Support/Signal")
);
assert_eq!(s.base_dir(Platform::Linux), Some("~/.config/Signal"));
let msgs = s.store(StoreRole::Messages).expect("signal messages");
assert_eq!(msgs.relative_path, "sql/db.sqlite");
assert_eq!(msgs.format, StoreFormat::SqlCipher);
assert!(msgs.encrypted);
let key = s.store(StoreRole::EncryptionKey).expect("signal key");
assert_eq!(key.relative_path, "config.json");
assert_eq!(key.format, StoreFormat::Json);
}
#[test]
fn discord_has_no_local_message_db() {
let d = spec("Discord").expect("discord spec");
assert_eq!(d.app_kind, AppKind::Electron);
assert!(d.store(StoreRole::Messages).is_none());
assert!(
d.note.contains("server") || d.note.contains("cache") || d.note.contains("no local"),
"discord note must explain the missing message DB: {:?}",
d.note
);
let token = d.store(StoreRole::Account).expect("discord token");
assert_eq!(token.relative_path, "Local Storage/leveldb");
assert_eq!(token.format, StoreFormat::ChromiumLevelDb);
let cache = d.store(StoreRole::MediaCache).expect("discord cache");
assert_eq!(cache.format, StoreFormat::ChromiumSimpleCache);
}
#[test]
fn wire_stores_messages_in_indexeddb() {
let w = spec("Wire").expect("wire spec");
assert_eq!(w.app_kind, AppKind::Electron);
let msgs = w.store(StoreRole::Messages).expect("wire messages");
assert!(msgs.relative_path.contains("wire.com"));
assert!(msgs.relative_path.contains(".indexeddb.leveldb"));
assert_eq!(msgs.format, StoreFormat::ChromiumLevelDb);
assert!(w.store(StoreRole::EncryptionKey).is_some(), "otr_key store");
}
#[test]
fn whatsapp_is_native_and_encrypted() {
let wa = spec("WhatsApp Desktop").expect("whatsapp spec");
assert_eq!(wa.app_kind, AppKind::Native);
assert!(wa
.base_dir(Platform::Windows)
.expect("wa windows")
.contains("5319275A.WhatsAppDesktop"));
assert_eq!(
wa.base_dir(Platform::MacOS),
Some("~/Library/Containers/desktop.WhatsApp")
);
assert_eq!(wa.base_dir(Platform::Linux), None);
let msgs = wa.store(StoreRole::Messages).expect("wa messages");
assert!(msgs.encrypted);
assert_eq!(msgs.format, StoreFormat::EncryptedSqlite);
assert_eq!(msgs.platforms, &[Platform::Windows]);
}
#[test]
fn signal_has_no_web_client() {
let s = spec("Signal Desktop").expect("signal spec");
assert!(s.web.is_none());
}
#[test]
fn web_clients_carry_the_right_origin() {
assert_eq!(
spec("Discord").expect("discord").web.expect("web").origin,
"https://discord.com"
);
assert_eq!(
spec("Wire").expect("wire").web.expect("web").origin,
"https://app.wire.com"
);
assert_eq!(
spec("WhatsApp Desktop")
.expect("wa")
.web
.expect("web")
.origin,
"https://web.whatsapp.com"
);
}
#[test]
fn web_indexeddb_dir_follows_chromium_origin_naming() {
let d = spec("Discord").expect("discord").web.expect("web");
assert_eq!(
d.indexeddb_dir(),
"IndexedDB/https_discord.com_0.indexeddb.leveldb"
);
let wa = spec("WhatsApp Desktop").expect("wa").web.expect("web");
assert_eq!(
wa.indexeddb_dir(),
"IndexedDB/https_web.whatsapp.com_0.indexeddb.leveldb"
);
}
#[test]
fn wire_web_and_desktop_indexeddb_paths_coincide() {
let w = spec("Wire").expect("wire");
let desktop_msgs = w.store(StoreRole::Messages).expect("wire messages");
let web = w.web.expect("wire web");
assert_eq!(desktop_msgs.relative_path, web.indexeddb_dir());
}
#[test]
fn every_web_client_cites_https_sources() {
for m in DESKTOP_MESSENGERS {
if let Some(w) = m.web {
assert!(!w.sources.is_empty(), "{} web has no sources", m.app);
for url in w.sources {
assert!(
url.starts_with("https://"),
"{} web source is not https: {url}",
m.app
);
}
}
}
}
#[test]
fn relative_paths_use_forward_slashes() {
for m in DESKTOP_MESSENGERS {
for s in m.stores {
assert!(
!s.relative_path.contains('\\'),
"{}/{:?} relative_path must use '/': {:?}",
m.app,
s.role,
s.relative_path
);
}
}
}
#[test]
fn every_spec_cites_https_sources() {
for m in DESKTOP_MESSENGERS {
assert!(!m.sources.is_empty(), "{} has no sources", m.app);
for url in m.sources {
assert!(
url.starts_with("https://"),
"{} source is not an https URL: {url}",
m.app
);
}
}
}
}