Skip to main content

wire_desktop_core/
record.rs

1//! Typed Wire records and the interpretation of IndexedDB records into them.
2//!
3//! Wire desktop is an Electron wrapper over the Wire web client; all evidence
4//! lives in the Chromium IndexedDB (`https_app.wire.com_0.indexeddb.leveldb`),
5//! organised as Dexie object stores. This module maps each generic
6//! [`IndexedDbRecord`] (already decoded by [`chromium_storage_indexeddb`]) onto a
7//! typed [`WireRecord`] by the object-store it came from.
8//!
9//! The object-store names are Wire web-client schema knowledge (Dexie stores
10//! `conversations`, `events`, `users`, `clients`); the profile path and the
11//! encryption posture come from the fleet KNOWLEDGE leaf
12//! [`forensicnomicon_core::messenger_desktop`].
13//!
14//! # Encrypted content
15//!
16//! Message bodies are frequently client-side encrypted (Proteus). Wire's message
17//! key is **not** stored in the Chromium OS Safe Storage, so it is not
18//! recoverable from this artifact. An encrypted payload is surfaced as
19//! [`PayloadState::Encrypted`] with its cleartext metadata (conversation,
20//! sender, time) intact; asking for its plaintext fails loud (see
21//! [`WireRecord::decrypted_text`]) rather than fabricating bytes.
22
23use crate::error::WireError;
24use chromium_storage_indexeddb::{IdbKey, IndexedDbRecord, RecordValue, V8Value};
25
26/// Which Wire object store a record came from — the Dexie store name mapped to a
27/// forensic role.
28#[non_exhaustive]
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WireRecordKind {
31    /// `conversations` — conversation/group metadata.
32    Conversation,
33    /// `events` — message and system events (the chat log).
34    Event,
35    /// `users` — the contact roster.
36    User,
37    /// `clients` — registered devices/clients.
38    Client,
39    /// An object store this reader does not map to a Wire role.
40    Unknown,
41}
42
43impl WireRecordKind {
44    /// Map a Dexie object-store name to its Wire role.
45    #[must_use]
46    pub fn from_store_name(name: &str) -> WireRecordKind {
47        match name {
48            "conversations" => WireRecordKind::Conversation,
49            "events" => WireRecordKind::Event,
50            "users" => WireRecordKind::User,
51            "clients" => WireRecordKind::Client,
52            _ => WireRecordKind::Unknown,
53        }
54    }
55
56    /// A stable label for the kind.
57    #[must_use]
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            WireRecordKind::Conversation => "conversation",
61            WireRecordKind::Event => "event",
62            WireRecordKind::User => "user",
63            WireRecordKind::Client => "client",
64            WireRecordKind::Unknown => "unknown",
65        }
66    }
67}
68
69/// The recoverability state of a record's content.
70#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum PayloadState {
73    /// The content is in cleartext (metadata records, or a message whose body
74    /// was extractable).
75    Cleartext,
76    /// The content is client-side encrypted and its key is not recoverable from
77    /// this artifact.
78    Encrypted {
79        /// The encryption scheme (Wire uses Proteus for message content).
80        scheme: &'static str,
81        /// Why the plaintext is unrecoverable.
82        reason: &'static str,
83    },
84    /// The value could not be decoded from its Blink/V8 blob upstream.
85    Undecoded {
86        /// The upstream decode error, verbatim.
87        error: String,
88    },
89}
90
91/// One interpreted Wire record.
92///
93/// Fields absent from the source record stay `None`; only fields the record
94/// actually carries are populated. `#[non_exhaustive]` so new fields do not
95/// break consumers.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq)]
98pub struct WireRecord {
99    /// The object-store name the record came from.
100    pub store: String,
101    /// The Wire role of the store.
102    pub kind: WireRecordKind,
103    /// The record's primary key, rendered as text.
104    pub primary_key: String,
105    /// The record's own `id` field, if present.
106    pub id: Option<String>,
107    /// The conversation id the record belongs to, if present.
108    pub conversation: Option<String>,
109    /// The sender/author (`from`) of an event, if present.
110    pub sender: Option<String>,
111    /// The event/record timestamp as stored (ISO-8601 string), if present.
112    pub time: Option<String>,
113    /// The Wire event `type` (e.g. `conversation.message-add`), if present.
114    pub message_type: Option<String>,
115    /// A human name — conversation name, user display name, or device model.
116    pub name: Option<String>,
117    /// The cleartext message body, when one was recoverable.
118    pub text: Option<String>,
119    /// Whether the content is cleartext, encrypted, or undecodable.
120    pub payload: PayloadState,
121    /// LevelDB sequence number (write ordering).
122    pub seq: u64,
123    /// `true` if this record is a deletion tombstone recovered from the store.
124    pub deleted: bool,
125}
126
127impl WireRecord {
128    /// The recoverable plaintext of this record's message body.
129    ///
130    /// Returns the cleartext body for a [`PayloadState::Cleartext`] record.
131    /// For an encrypted or undecodable payload it **fails loud** with a typed
132    /// error — it never fabricates plaintext.
133    pub fn decrypted_text(&self) -> Result<&str, WireError> {
134        match &self.payload {
135            PayloadState::Cleartext => Ok(self.text.as_deref().unwrap_or("")),
136            PayloadState::Encrypted { .. } => Err(WireError::EncryptedPayloadUnrecoverable),
137            PayloadState::Undecoded { error } => Err(WireError::UndecodedValue(error.clone())),
138        }
139    }
140
141    /// Whether this record's content is client-side encrypted and unrecoverable.
142    #[must_use]
143    pub fn is_encrypted(&self) -> bool {
144        matches!(self.payload, PayloadState::Encrypted { .. })
145    }
146}
147
148/// Interpret a slice of decoded IndexedDB records into a typed [`WireStore`].
149///
150/// Every record is passed through as a [`WireRecord`] (store, role, primary key,
151/// sequence, tombstone flag, and decode-state payload); per-store field
152/// extraction is applied by the `fill_*` handlers. A per-object-store summary is
153/// rolled up alongside.
154#[must_use]
155pub fn interpret_records(records: &[IndexedDbRecord]) -> WireStore {
156    let mut out = Vec::with_capacity(records.len());
157    let mut summaries: Vec<ObjectStoreSummary> = Vec::new();
158
159    for r in records {
160        let store = r.object_store.as_deref().unwrap_or("");
161        let wr = interpret_one(store, r);
162
163        if !store.is_empty() {
164            let enc = usize::from(wr.is_encrypted());
165            let del = usize::from(wr.deleted);
166            match summaries.iter_mut().find(|s| s.name == store) {
167                Some(sum) => {
168                    sum.records += 1;
169                    sum.encrypted_payloads += enc;
170                    sum.deleted += del;
171                }
172                None => summaries.push(ObjectStoreSummary {
173                    name: store.to_string(),
174                    kind: wr.kind,
175                    records: 1,
176                    encrypted_payloads: enc,
177                    deleted: del,
178                }),
179            }
180        }
181        out.push(wr);
182    }
183
184    WireStore {
185        object_stores: summaries,
186        records: out,
187    }
188}
189
190/// Interpret one decoded IndexedDB record into a [`WireRecord`].
191///
192/// Builds the base record (store, role, primary key, seq, tombstone flag,
193/// decode-state payload) then dispatches to the per-store field extractor.
194fn interpret_one(store: &str, r: &IndexedDbRecord) -> WireRecord {
195    let kind = WireRecordKind::from_store_name(store);
196    let mut wr = WireRecord {
197        store: store.to_string(),
198        kind,
199        primary_key: render_key(&r.key),
200        id: None,
201        conversation: None,
202        sender: None,
203        time: None,
204        message_type: None,
205        name: None,
206        text: None,
207        payload: base_payload(&r.value),
208        seq: r.seq,
209        deleted: r.deleted,
210    };
211
212    if let RecordValue::V8(v) = &r.value {
213        match kind {
214            WireRecordKind::Conversation => fill_conversation(&mut wr, v),
215            WireRecordKind::Event => fill_event(&mut wr, v),
216            WireRecordKind::User => fill_user(&mut wr, v),
217            WireRecordKind::Client => fill_client(&mut wr, v),
218            WireRecordKind::Unknown => {}
219        }
220    }
221
222    wr
223}
224
225/// Extract conversation metadata: the conversation id (its own `id`) and the
226/// display `name`.
227fn fill_conversation(wr: &mut WireRecord, v: &V8Value) {
228    wr.id = obj_field(v, "id").and_then(as_text);
229    wr.conversation = wr.id.clone();
230    wr.name = obj_field(v, "name").and_then(as_text);
231}
232
233/// Extract event metadata (conversation, sender, time, type) and, when the body
234/// is in cleartext, the message text. Encrypted-payload classification is added
235/// by the encrypted-event cycle.
236fn fill_event(wr: &mut WireRecord, v: &V8Value) {
237    wr.id = obj_field(v, "id").and_then(as_text);
238    wr.conversation = obj_field(v, "conversation").and_then(as_text);
239    wr.sender = obj_field(v, "from")
240        .or_else(|| obj_field(v, "sender"))
241        .and_then(as_text);
242    wr.time = obj_field(v, "time").and_then(as_text);
243    wr.message_type = obj_field(v, "type").and_then(as_text);
244
245    if let Some(text) = message_text(v) {
246        wr.text = Some(text);
247        wr.payload = PayloadState::Cleartext;
248    } else if is_encrypted_payload(v) {
249        // Wire message content is Proteus-encrypted client-side; its key is not
250        // in the Chromium OS Safe Storage, so we mark it unrecoverable rather
251        // than fabricate plaintext.
252        wr.payload = PayloadState::Encrypted {
253            scheme: "Proteus",
254            reason: "Wire message key is not held in the Chromium OS Safe Storage",
255        };
256    }
257}
258
259/// Whether an event value carries an opaque/ciphered body with no cleartext.
260///
261/// General structural rule (not tied to any one fixture): a top-level or nested
262/// `data` [`V8Value::ArrayBuffer`], or a documented ciphertext-marker field
263/// (`cipher_text` / `cipherText` / `otr` / `encrypted`) at the top level or
264/// under `data`.
265fn is_encrypted_payload(v: &V8Value) -> bool {
266    const CIPHER_MARKERS: [&str; 4] = ["cipher_text", "cipherText", "otr", "encrypted"];
267
268    if matches!(v, V8Value::ArrayBuffer(_)) {
269        return true;
270    }
271    let data = obj_field(v, "data");
272    if matches!(data, Some(V8Value::ArrayBuffer(_))) {
273        return true;
274    }
275    CIPHER_MARKERS
276        .iter()
277        .any(|m| obj_field(v, m).is_some() || data.is_some_and(|d| obj_field(d, m).is_some()))
278}
279
280/// Extract a cleartext message body from an event value: the `content`/`text`
281/// field of the nested `data` object, or a top-level `content`/`text` field.
282/// Returns `None` when no cleartext body is present (e.g. an encrypted or a
283/// pure-system event).
284fn message_text(v: &V8Value) -> Option<String> {
285    if let Some(data) = obj_field(v, "data") {
286        if let Some(t) = obj_field(data, "content")
287            .or_else(|| obj_field(data, "text"))
288            .and_then(as_text)
289        {
290            return Some(t);
291        }
292    }
293    obj_field(v, "content")
294        .or_else(|| obj_field(v, "text"))
295        .and_then(as_text)
296}
297
298/// Extract user metadata: the user `id` and the display `name`.
299fn fill_user(wr: &mut WireRecord, v: &V8Value) {
300    wr.id = obj_field(v, "id").and_then(as_text);
301    wr.name = obj_field(v, "name").and_then(as_text);
302}
303
304/// Extract client/device metadata: the client `id` and a device label (the
305/// `model`, falling back to the `class` or `label`).
306fn fill_client(wr: &mut WireRecord, v: &V8Value) {
307    wr.id = obj_field(v, "id").and_then(as_text);
308    wr.name = obj_field(v, "model")
309        .or_else(|| obj_field(v, "class"))
310        .or_else(|| obj_field(v, "label"))
311        .and_then(as_text);
312}
313
314/// The interpreted Wire store: a per-object-store summary plus every record.
315#[non_exhaustive]
316#[derive(Debug, Clone, Default, PartialEq)]
317pub struct WireStore {
318    /// One summary per named object store found.
319    pub object_stores: Vec<ObjectStoreSummary>,
320    /// Every interpreted record, in source order.
321    pub records: Vec<WireRecord>,
322}
323
324impl WireStore {
325    /// Records belonging to `store`.
326    pub fn records_in<'a>(&'a self, store: &'a str) -> impl Iterator<Item = &'a WireRecord> {
327        self.records.iter().filter(move |r| r.store == store)
328    }
329
330    /// All message/system event records.
331    pub fn events(&self) -> impl Iterator<Item = &WireRecord> {
332        self.records
333            .iter()
334            .filter(|r| r.kind == WireRecordKind::Event)
335    }
336
337    /// Event records whose content is client-side encrypted (unrecoverable).
338    pub fn encrypted_events(&self) -> impl Iterator<Item = &WireRecord> {
339        self.events().filter(|r| r.is_encrypted())
340    }
341}
342
343/// A per-object-store roll-up.
344#[non_exhaustive]
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct ObjectStoreSummary {
347    /// The Dexie object-store name.
348    pub name: String,
349    /// Its Wire role.
350    pub kind: WireRecordKind,
351    /// How many records it holds (live + tombstoned).
352    pub records: usize,
353    /// How many of those records carry an encrypted, unrecoverable payload.
354    pub encrypted_payloads: usize,
355    /// How many are deletion tombstones.
356    pub deleted: usize,
357}
358
359// ─── value helpers (shared by the per-store fill_* handlers) ─────────────────
360
361/// Render an [`IdbKey`] to a stable text form for the record's `primary_key`.
362pub(crate) fn render_key(key: &IdbKey) -> String {
363    match key {
364        IdbKey::String(s) => s.clone(),
365        IdbKey::Number(n) | IdbKey::Date(n) => n.to_string(),
366        IdbKey::Binary(b) => format!("0x{}", hex(b)),
367        IdbKey::Array(items) => {
368            let parts: Vec<String> = items.iter().map(render_key).collect();
369            format!("[{}]", parts.join(","))
370        }
371        IdbKey::Null => "null".to_string(),
372        IdbKey::Min => "min".to_string(),
373        IdbKey::Invalid(b) => format!("invalid:0x{}", hex(b)),
374    }
375}
376
377fn hex(bytes: &[u8]) -> String {
378    let mut s = String::with_capacity(bytes.len() * 2);
379    for b in bytes {
380        s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
381        s.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap_or('0'));
382    }
383    s
384}
385
386/// A record value that decoded to a V8 object; `None` for non-objects.
387pub(crate) fn obj_field<'a>(v: &'a V8Value, key: &str) -> Option<&'a V8Value> {
388    match v {
389        V8Value::Object(kv) => kv.iter().find(|(k, _)| k == key).map(|(_, val)| val),
390        _ => None,
391    }
392}
393
394/// Render a scalar V8 value to text; `None` for containers/binary.
395pub(crate) fn as_text(v: &V8Value) -> Option<String> {
396    match v {
397        V8Value::String(s) | V8Value::StringObject(s) | V8Value::BigInt(s) => Some(s.clone()),
398        V8Value::Int(i) => Some(i.to_string()),
399        V8Value::Double(d) | V8Value::Date(d) | V8Value::NumberObject(d) => Some(d.to_string()),
400        V8Value::Bool(b) => Some(b.to_string()),
401        _ => None,
402    }
403}
404
405/// The decode-state of a record value, independent of its object-store role.
406pub(crate) fn base_payload(value: &RecordValue) -> PayloadState {
407    match value {
408        RecordValue::V8(_) => PayloadState::Cleartext,
409        RecordValue::Undecoded { error, .. } => PayloadState::Undecoded {
410            error: error.clone(),
411        },
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::error::WireError;
419
420    #[test]
421    fn render_key_covers_every_idbkey_variant() {
422        assert_eq!(render_key(&IdbKey::String("k".into())), "k");
423        assert_eq!(render_key(&IdbKey::Number(3.0)), "3");
424        assert_eq!(render_key(&IdbKey::Date(5.0)), "5");
425        assert_eq!(render_key(&IdbKey::Binary(vec![0xab, 0x0f])), "0xab0f");
426        assert_eq!(render_key(&IdbKey::Null), "null");
427        assert_eq!(render_key(&IdbKey::Min), "min");
428        assert_eq!(render_key(&IdbKey::Invalid(vec![0x01])), "invalid:0x01");
429        assert_eq!(
430            render_key(&IdbKey::Array(vec![
431                IdbKey::String("a".into()),
432                IdbKey::Number(1.0)
433            ])),
434            "[a,1]"
435        );
436    }
437
438    #[test]
439    fn as_text_covers_scalar_and_container_variants() {
440        assert_eq!(as_text(&V8Value::String("s".into())).as_deref(), Some("s"));
441        assert_eq!(
442            as_text(&V8Value::StringObject("o".into())).as_deref(),
443            Some("o")
444        );
445        assert_eq!(as_text(&V8Value::BigInt("9".into())).as_deref(), Some("9"));
446        assert_eq!(as_text(&V8Value::Int(7)).as_deref(), Some("7"));
447        assert_eq!(as_text(&V8Value::Double(2.0)).as_deref(), Some("2"));
448        assert_eq!(as_text(&V8Value::Date(4.0)).as_deref(), Some("4"));
449        assert_eq!(as_text(&V8Value::NumberObject(6.0)).as_deref(), Some("6"));
450        assert_eq!(as_text(&V8Value::Bool(true)).as_deref(), Some("true"));
451        assert_eq!(as_text(&V8Value::Null), None);
452        assert_eq!(as_text(&V8Value::Array(vec![])), None);
453    }
454
455    #[test]
456    fn obj_field_returns_none_for_non_objects_and_missing_keys() {
457        assert!(obj_field(&V8Value::Null, "x").is_none());
458        let o = V8Value::Object(vec![("a".into(), V8Value::Int(1))]);
459        assert!(obj_field(&o, "missing").is_none());
460        assert!(obj_field(&o, "a").is_some());
461    }
462
463    #[test]
464    fn base_payload_maps_decode_state() {
465        assert_eq!(
466            base_payload(&RecordValue::V8(V8Value::Null)),
467            PayloadState::Cleartext
468        );
469        let u = RecordValue::Undecoded {
470            raw: vec![0xff],
471            error: "boom".into(),
472        };
473        assert!(matches!(base_payload(&u), PayloadState::Undecoded { .. }));
474    }
475
476    #[test]
477    fn kind_mapping_and_labels() {
478        assert_eq!(
479            WireRecordKind::from_store_name("conversations"),
480            WireRecordKind::Conversation
481        );
482        assert_eq!(
483            WireRecordKind::from_store_name("events"),
484            WireRecordKind::Event
485        );
486        assert_eq!(
487            WireRecordKind::from_store_name("users"),
488            WireRecordKind::User
489        );
490        assert_eq!(
491            WireRecordKind::from_store_name("clients"),
492            WireRecordKind::Client
493        );
494        assert_eq!(
495            WireRecordKind::from_store_name("keys"),
496            WireRecordKind::Unknown
497        );
498        for k in [
499            WireRecordKind::Conversation,
500            WireRecordKind::Event,
501            WireRecordKind::User,
502            WireRecordKind::Client,
503            WireRecordKind::Unknown,
504        ] {
505            assert!(!k.as_str().is_empty());
506        }
507    }
508
509    fn rec(payload: PayloadState, text: Option<&str>) -> WireRecord {
510        WireRecord {
511            store: "events".into(),
512            kind: WireRecordKind::Event,
513            primary_key: "k".into(),
514            id: None,
515            conversation: None,
516            sender: None,
517            time: None,
518            message_type: None,
519            name: None,
520            text: text.map(str::to_string),
521            payload,
522            seq: 0,
523            deleted: false,
524        }
525    }
526
527    #[test]
528    fn decrypted_text_returns_or_fails_loud_per_payload() {
529        assert_eq!(
530            rec(PayloadState::Cleartext, Some("hi"))
531                .decrypted_text()
532                .unwrap(),
533            "hi"
534        );
535        // Cleartext with no text yields an empty body, not an error.
536        assert_eq!(
537            rec(PayloadState::Cleartext, None).decrypted_text().unwrap(),
538            ""
539        );
540        let enc = rec(
541            PayloadState::Encrypted {
542                scheme: "Proteus",
543                reason: "no key",
544            },
545            None,
546        );
547        assert!(matches!(
548            enc.decrypted_text(),
549            Err(WireError::EncryptedPayloadUnrecoverable)
550        ));
551        let und = rec(
552            PayloadState::Undecoded {
553                error: "bad".into(),
554            },
555            None,
556        );
557        assert!(matches!(
558            und.decrypted_text(),
559            Err(WireError::UndecodedValue(_))
560        ));
561    }
562
563    #[test]
564    fn is_encrypted_payload_covers_top_level_and_marker_paths() {
565        // Bare ArrayBuffer value (top-level).
566        assert!(is_encrypted_payload(&V8Value::ArrayBuffer(vec![1, 2])));
567        // Top-level cipher-marker field.
568        let top_marker = V8Value::Object(vec![("otr".into(), V8Value::String("x".into()))]);
569        assert!(is_encrypted_payload(&top_marker));
570        // data.encrypted marker.
571        let nested = V8Value::Object(vec![(
572            "data".into(),
573            V8Value::Object(vec![("encrypted".into(), V8Value::Bool(true))]),
574        )]);
575        assert!(is_encrypted_payload(&nested));
576        // A plain cleartext object is not encrypted.
577        let plain = V8Value::Object(vec![("content".into(), V8Value::String("hi".into()))]);
578        assert!(!is_encrypted_payload(&plain));
579    }
580
581    #[test]
582    fn interpret_skips_summary_for_unnamed_store() {
583        // A record with no object_store name is still passed through, but not
584        // counted in any object-store summary.
585        let r = IndexedDbRecord {
586            database_id: 0,
587            object_store_id: 0,
588            database: None,
589            object_store: None,
590            key: IdbKey::Null,
591            value: RecordValue::V8(V8Value::Null),
592            seq: 0,
593            deleted: false,
594        };
595        let store = interpret_records(&[r]);
596        assert_eq!(store.records.len(), 1);
597        assert!(store.object_stores.is_empty());
598        assert!(store.records_in("").next().is_some());
599    }
600}