Skip to main content

mail4agent_messenger/crypto/
device_tracker.rs

1//! [`DeviceTracker`] -- tracked users, per-user device lists, and the
2//! `/keys/query` half of device-list tracking (plan §3
3//! `crypto/device_tracker.rs`; research doc §3.2/§4.5's "device-list
4//! desync" pitfall).
5//!
6//! # What "tracked" means
7//!
8//! A user becomes tracked the first time this device learns (via `/sync`'s
9//! `device_lists.changed`) that it shares a room with them, or is force-
10//! tracked by a caller ahead of time (e.g. before starting a DM). Every
11//! tracked user carries an `outdated` flag: `true` means "the device list
12//! this store has for them (if any) might be stale, ask `/keys/query`
13//! before trusting it for anything security-sensitive (Olm-encrypting a new
14//! `m.room_key`, sharing to their devices, verification)". See
15//! [`crate::crypto::olm_sessions::OlmSessionManager`]'s module doc for the
16//! one place that matters most.
17//!
18//! # Own devices are tracked like anyone else's
19//!
20//! This account's own other devices are not special-cased anywhere in this
21//! module: `/keys/query`'s `device_keys` map includes the caller's own user
22//! id exactly like any other, and this account needs its own other
23//! devices' keys for the same reason it needs a peer's -- Megolm room keys
24//! are Olm-shared to every one of a room's members' devices, including the
25//! sending device's own siblings (plan §4.2).
26//!
27//! # Why a verified key change is applied, not kept forever
28//!
29//! A device id is minted once by the homeserver. Classic Matrix UI clients
30//! refuse a same-device-id key swap because a compromised server could
31//! substitute keys. This crate serves agent mail: a sealed store can be
32//! lost while the device bearer survives, the server accepts that as an
33//! authenticated identity reset, and peers MUST take the new keys or mail
34//! black-holes forever. [`DeviceTracker::on_keys_query_response`] therefore
35//! applies a same-device-id key change when the new object self-signs, clears
36//! `verified`, keeps `blocked`, and still emits [`DeviceKeyChanged`] so the
37//! core can forget outbound Megolm shares to that device and re-establish
38//! Olm. A device whose self-signature no longer verifies is still dropped,
39//! and a previously-known valid record for that id is kept.
40//!
41//! # `left` users have no store-level "forget"
42//!
43//! [`crate::store::CryptoStore`] (a prior piece) exposes only
44//! `tracked_users`/`mark_user_tracked` -- there is no primitive to delete a
45//! tracked-user record outright. [`DeviceTracker::on_device_lists`]
46//! therefore cannot fully forget a user who no longer shares a room with
47//! this device; it marks an already-tracked `left` user not-outdated (so
48//! `keys_query_request` stops re-fetching them) and otherwise leaves the
49//! record in place. A full purge, if ever needed, is a later piece's job.
50
51use crate::canonical_json;
52use crate::error::MessengerError;
53use crate::ids::{DeviceId, RequestId, UserId};
54use crate::store::CryptoStore;
55use crate::wire::OutgoingRequest;
56use mail4agent_vodozemac::{Curve25519PublicKey, Ed25519PublicKey};
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59use std::collections::{BTreeMap, BTreeSet};
60
61/// One device this store knows about for some user, learned from a
62/// `/keys/query` response whose self-signature verified (plan §3's
63/// `StoredDevice` shape).
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct StoredDevice {
66    /// The device's owner.
67    pub user_id: UserId,
68    /// The device's own id.
69    pub device_id: DeviceId,
70    /// The device's Curve25519 identity key.
71    pub curve25519: Curve25519PublicKey,
72    /// The device's Ed25519 signing key.
73    pub ed25519: Ed25519PublicKey,
74    /// The Matrix E2EE algorithms this device advertises.
75    pub algorithms: Vec<String>,
76    /// The device's self-reported display name, if any.
77    pub display_name: Option<String>,
78    /// Whether the local user trusts this device. Set `true` on first
79    /// sight (TOFU) or when a cross-signing signature checks out; cleared
80    /// on a same-device-id key change.
81    pub verified: bool,
82    /// Whether the local user has blocked this device. Never set `true` by
83    /// this module.
84    pub blocked: bool,
85}
86
87/// A device dropped outright while applying a `/keys/query` response --
88/// never stored (or, if a previously-known valid record existed, left
89/// unchanged rather than overwritten -- see the module doc).
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct DroppedDevice {
92    /// The device's claimed owner.
93    pub user_id: UserId,
94    /// The device's claimed id.
95    pub device_id: DeviceId,
96    /// Why this device was refused.
97    pub reason: String,
98}
99
100/// A previously-known device whose Curve25519 or Ed25519 key changed in a
101/// new `/keys/query` response -- the NEW keys were applied with
102/// `verified = false` (see the module doc). A caller should re-share
103/// Megolm and drop stale Olm sessions for this device.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct DeviceKeyChanged {
106    /// The device's owner.
107    pub user_id: UserId,
108    /// The device whose keys changed.
109    pub device_id: DeviceId,
110}
111
112/// The result of merging one `/keys/query` response into the store.
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct KeysQueryOutcome {
115    /// Users whose tracked device list this response refreshed (no longer
116    /// outdated).
117    pub resolved_users: Vec<UserId>,
118    /// A device this response carried that was accepted (new, or an
119    /// unchanged re-publish of an already-known device).
120    pub accepted_devices: Vec<(UserId, DeviceId)>,
121    /// A device this response carried that failed validation and was never
122    /// stored.
123    pub dropped_devices: Vec<DroppedDevice>,
124    /// A previously-known device whose keys changed -- new keys applied
125    /// (`verified` cleared) and flagged for the caller.
126    pub key_changes: Vec<DeviceKeyChanged>,
127}
128
129/// The stored payload behind [`crate::store::CryptoStore::devices_for_user`]
130/// -- a thin wrapper (rather than a bare `Vec<StoredDevice>`) so a later
131/// field can be added without reshaping the record, matching
132/// `crypto::account`'s own `AccountRecord` convention.
133#[derive(Serialize, Deserialize)]
134struct DeviceListRecord {
135    devices: Vec<StoredDevice>,
136}
137
138/// One device object nested under `/keys/query`'s
139/// `device_keys.{user_id}.{device_id}` (research doc §3.2). Parsed from the
140/// raw [`Value`] so the ORIGINAL bytes (not a re-serialization) are what
141/// gets signature-checked.
142#[derive(Deserialize)]
143struct DeviceKeysObject {
144    user_id: UserId,
145    device_id: DeviceId,
146    #[serde(default)]
147    algorithms: Vec<String>,
148    #[serde(default)]
149    keys: BTreeMap<String, String>,
150    #[serde(default)]
151    unsigned: DeviceKeysUnsigned,
152}
153
154#[derive(Default, Deserialize)]
155struct DeviceKeysUnsigned {
156    #[serde(default)]
157    device_display_name: Option<String>,
158}
159
160/// `/keys/query`'s response shape, the parts this module reads (research
161/// doc §3.2). `failures` is intentionally not modeled: a user present there
162/// simply stays outdated, which already happens by omission (this module
163/// only resolves a user found in `device_keys`).
164#[derive(Deserialize)]
165struct KeysQueryResponseBody {
166    #[serde(default)]
167    device_keys: BTreeMap<UserId, BTreeMap<DeviceId, Value>>,
168}
169
170/// Device-list tracking: outdated bookkeeping, `/keys/query` request
171/// building, and validating `/keys/query` responses. See the module doc for
172/// the security invariants this type enforces. Carries no state of its own
173/// -- every method reads/writes through the caller's [`CryptoStore`].
174pub struct DeviceTracker;
175
176impl DeviceTracker {
177    /// Applies `/sync`'s `device_lists.changed`/`device_lists.left` (plan
178    /// §3, research doc §3.2). See the module doc for what happens to a
179    /// `left` user.
180    pub fn on_device_lists<S: CryptoStore>(
181        store: &mut S,
182        changed: &[UserId],
183        left: &[UserId],
184    ) -> Result<(), MessengerError> {
185        for user_id in changed {
186            store.mark_user_tracked(user_id, true)?;
187        }
188
189        if !left.is_empty() {
190            let currently_tracked: BTreeSet<UserId> =
191                store.tracked_users()?.into_iter().map(|(user_id, _)| user_id).collect();
192            for user_id in left {
193                if currently_tracked.contains(user_id) {
194                    store.mark_user_tracked(user_id, false)?;
195                }
196            }
197        }
198        Ok(())
199    }
200
201    /// Builds a `POST /keys/query` request for every currently-outdated
202    /// tracked user, or `None` if none are outdated.
203    pub fn keys_query_request<S: CryptoStore>(
204        store: &S,
205        id: RequestId,
206    ) -> Result<Option<OutgoingRequest>, MessengerError> {
207        let outdated: Vec<UserId> = store
208            .tracked_users()?
209            .into_iter()
210            .filter_map(|(user_id, outdated)| outdated.then_some(user_id))
211            .collect();
212        if outdated.is_empty() {
213            return Ok(None);
214        }
215
216        let mut device_keys = serde_json::Map::new();
217        for user_id in &outdated {
218            device_keys.insert(user_id.as_str().to_string(), Value::Array(Vec::new()));
219        }
220        let body = serde_json::json!({ "device_keys": Value::Object(device_keys) });
221        Ok(Some(OutgoingRequest::keys_query(id, body)))
222    }
223
224    /// Every device this store currently has stored for `user_id`, or an
225    /// empty list if none.
226    pub fn devices_for_user<S: CryptoStore>(
227        store: &S,
228        user_id: &UserId,
229    ) -> Result<Vec<StoredDevice>, MessengerError> {
230        match store.devices_for_user(user_id)? {
231            Some(bytes) => {
232                let record: DeviceListRecord = serde_json::from_slice(bytes).map_err(|source| {
233                    MessengerError::Crypto(format!("decode stored device list for {user_id}: {source}"))
234                })?;
235                Ok(record.devices)
236            }
237            None => Ok(Vec::new()),
238        }
239    }
240
241    /// Applies a `/keys/query` response body (plan §3): verifies each
242    /// device's self-signature over canonical JSON with its OWN claimed
243    /// Ed25519 key, drops anything that doesn't check out, applies a key
244    /// change on an already-known device id (clears `verified`, keeps
245    /// `blocked`, emits [`DeviceKeyChanged`]), and persists the resulting
246    /// per-user device lists. Marks every user present in `device_keys`
247    /// (even with zero devices) not-outdated.
248    pub fn on_keys_query_response<S: CryptoStore>(
249        store: &mut S,
250        body: &[u8],
251    ) -> Result<KeysQueryOutcome, MessengerError> {
252        let parsed: KeysQueryResponseBody = serde_json::from_slice(body)?;
253        let mut outcome = KeysQueryOutcome::default();
254
255        for (user_id, devices_by_id) in parsed.device_keys {
256            let existing = Self::devices_for_user(store, &user_id)?;
257            let mut existing_by_id: BTreeMap<DeviceId, StoredDevice> =
258                existing.into_iter().map(|device| (device.device_id.clone(), device)).collect();
259            let mut merged: Vec<StoredDevice> = Vec::with_capacity(devices_by_id.len());
260
261            for (device_id, raw) in &devices_by_id {
262                match Self::verify_device(&user_id, device_id, raw) {
263                    Ok(candidate) => match existing_by_id.remove(device_id) {
264                        Some(known)
265                            if known.curve25519 != candidate.curve25519 || known.ed25519 != candidate.ed25519 =>
266                        {
267                            outcome.key_changes.push(DeviceKeyChanged {
268                                user_id: user_id.clone(),
269                                device_id: device_id.clone(),
270                            });
271                            // Authenticated identity reset on the server:
272                            // take the new keys so Megolm/Olm can recover.
273                            // Clear verified; keep an explicit block.
274                            merged.push(StoredDevice {
275                                verified: false,
276                                blocked: known.blocked,
277                                ..candidate
278                            });
279                        }
280                        Some(known) => {
281                            outcome.accepted_devices.push((user_id.clone(), device_id.clone()));
282                            merged.push(StoredDevice {
283                                verified: known.verified,
284                                blocked: known.blocked,
285                                ..candidate
286                            });
287                        }
288                        None => {
289                            // TOFU: first time we see this device id, trust it.
290                            outcome.accepted_devices.push((user_id.clone(), device_id.clone()));
291                            merged.push(StoredDevice {
292                                verified: true,
293                                ..candidate
294                            });
295                        }
296                    },
297                    Err(reason) => {
298                        outcome.dropped_devices.push(DroppedDevice {
299                            user_id: user_id.clone(),
300                            device_id: device_id.clone(),
301                            reason,
302                        });
303                        if let Some(known) = existing_by_id.remove(device_id) {
304                            merged.push(known);
305                        }
306                    }
307                }
308            }
309
310            let record = DeviceListRecord { devices: merged };
311            let bytes = serde_json::to_vec(&record).map_err(|source| {
312                MessengerError::Crypto(format!("encode stored device list for {user_id}: {source}"))
313            })?;
314            store.save_devices(&user_id, bytes)?;
315            store.mark_user_tracked(&user_id, false)?;
316            outcome.resolved_users.push(user_id);
317        }
318
319        Ok(outcome)
320    }
321
322    /// Validates one `device_keys.{user_id}.{device_id}` object: `user_id`/
323    /// `device_id` must match the map keys it was nested under, it must
324    /// carry its own Curve25519/Ed25519 keys, and its self-signature
325    /// (`signatures.{user_id}.ed25519:{device_id}`) must verify against its
326    /// OWN claimed Ed25519 key. `Err` carries a human-readable reason,
327    /// never the underlying error type (kept crate-internal, same
328    /// reasoning as [`MessengerError::Crypto`]).
329    fn verify_device(user_id: &UserId, device_id: &DeviceId, raw: &Value) -> Result<StoredDevice, String> {
330        let parsed: DeviceKeysObject = serde_json::from_value(raw.clone())
331            .map_err(|source| format!("malformed device_keys object: {source}"))?;
332
333        if parsed.user_id != *user_id {
334            return Err(format!(
335                "user_id {:?} in the object does not match the map key {:?}",
336                parsed.user_id.as_str(),
337                user_id.as_str()
338            ));
339        }
340        if parsed.device_id != *device_id {
341            return Err(format!(
342                "device_id {:?} in the object does not match the map key {:?}",
343                parsed.device_id.as_str(),
344                device_id.as_str()
345            ));
346        }
347
348        let ed25519_b64 = parsed
349            .keys
350            .get(&format!("ed25519:{}", device_id.as_str()))
351            .ok_or_else(|| "missing the device's own ed25519 key".to_string())?;
352        let ed25519 =
353            Ed25519PublicKey::from_base64(ed25519_b64).map_err(|source| format!("malformed ed25519 key: {source}"))?;
354
355        let curve25519_b64 = parsed
356            .keys
357            .get(&format!("curve25519:{}", device_id.as_str()))
358            .ok_or_else(|| "missing the device's own curve25519 key".to_string())?;
359        let curve25519 = Curve25519PublicKey::from_base64(curve25519_b64)
360            .map_err(|source| format!("malformed curve25519 key: {source}"))?;
361
362        let key_id = format!("ed25519:{}", device_id.as_str());
363        canonical_json::verify_json_signature(raw, user_id.as_str(), &key_id, &ed25519)
364            .map_err(|source| format!("self-signature failed to verify: {source}"))?;
365
366        Ok(StoredDevice {
367            user_id: user_id.clone(),
368            device_id: device_id.clone(),
369            curve25519,
370            ed25519,
371            algorithms: parsed.algorithms,
372            display_name: parsed.unsigned.device_display_name,
373            verified: false,
374            blocked: false,
375        })
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::store::{InsecurePlainCodecForTests, Store};
383    use mail4agent_vodozemac::olm::Account;
384
385    fn new_store() -> Store<InsecurePlainCodecForTests> {
386        Store::new(DeviceId::parse("OURDEVICE").expect("valid device id"), InsecurePlainCodecForTests)
387    }
388
389    fn user(name: &str) -> UserId {
390        UserId::parse(format!("@{name}:example.org")).expect("valid user id")
391    }
392
393    fn device(name: &str) -> DeviceId {
394        DeviceId::parse(name).expect("valid device id")
395    }
396
397    /// Builds a self-signed `device_keys` object for `(user_id, device_id)`
398    /// using a fresh vodozemac [`Account`], mirroring
399    /// `crypto::account::OlmAccountState::device_keys_json`'s shape exactly
400    /// (this module only ever receives such objects over the wire, never
401    /// builds them itself).
402    fn signed_device_keys(account: &Account, user_id: &UserId, device_id: &DeviceId) -> Value {
403        let identity = account.identity_keys();
404        let mut value = serde_json::json!({
405            "algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
406            "device_id": device_id.as_str(),
407            "user_id": user_id.as_str(),
408            "keys": {
409                format!("curve25519:{}", device_id.as_str()): identity.curve25519.to_base64(),
410                format!("ed25519:{}", device_id.as_str()): identity.ed25519.to_base64(),
411            },
412        });
413        let key_id = format!("ed25519:{}", device_id.as_str());
414        canonical_json::sign_json(&mut value, user_id.as_str(), &key_id, |bytes| account.sign(bytes))
415            .expect("signing a freshly built device_keys object succeeds");
416        value
417    }
418
419    fn keys_query_body(entries: &[(&UserId, &DeviceId, Value)]) -> Vec<u8> {
420        let mut device_keys = serde_json::Map::new();
421        for (user_id, device_id, value) in entries {
422            let by_device = device_keys
423                .entry(user_id.as_str().to_string())
424                .or_insert_with(|| Value::Object(serde_json::Map::new()));
425            by_device
426                .as_object_mut()
427                .expect("just inserted as an object")
428                .insert(device_id.as_str().to_string(), value.clone());
429        }
430        serde_json::to_vec(&serde_json::json!({ "device_keys": device_keys })).expect("valid JSON")
431    }
432
433    #[test]
434    fn device_tracker_marks_outdated_on_sync_device_lists_changed() {
435        let mut store = new_store();
436        let bob = user("bob");
437        assert!(store.tracked_users().expect("no error").is_empty(), "nothing tracked yet");
438
439        DeviceTracker::on_device_lists(&mut store, std::slice::from_ref(&bob), &[]).expect("applies device_lists.changed");
440        assert_eq!(store.tracked_users().expect("no error"), vec![(bob.clone(), true)]);
441
442        let request = DeviceTracker::keys_query_request(&store, RequestId::next(0))
443            .expect("builds a request")
444            .expect("bob is outdated, something to query");
445        assert_eq!(request.body.expect("keys_query has a body")["device_keys"][bob.as_str()], serde_json::json!([]));
446
447        // A `left` notification for an untracked user is a no-op; for an
448        // already-tracked one it clears the outdated flag (module doc: no
449        // store-level "forget" primitive exists).
450        let carol = user("carol");
451        DeviceTracker::on_device_lists(&mut store, &[], std::slice::from_ref(&carol))
452            .expect("left, but never tracked: no-op");
453        assert!(store.tracked_users().expect("no error").iter().all(|(u, _)| *u != carol));
454
455        DeviceTracker::on_device_lists(&mut store, &[], std::slice::from_ref(&bob)).expect("bob left");
456        assert_eq!(store.tracked_users().expect("no error"), vec![(bob, false)]);
457    }
458
459    #[test]
460    fn unsigned_device_keys_are_dropped() {
461        let mut store = new_store();
462        let alice = user("alice");
463        let dev1 = device("DEV1");
464
465        let account = Account::new();
466        let mut tampered = signed_device_keys(&account, &alice, &dev1);
467        // Corrupt the signature -- still present, still base64, but no
468        // longer valid for the payload it is attached to.
469        let sig_path = tampered["signatures"][alice.as_str()]["ed25519:DEV1"]
470            .as_str()
471            .expect("signature present")
472            .to_string();
473        let sig_len = sig_path.len();
474        let mut corrupted = sig_path.into_bytes();
475        corrupted[0] ^= 0xFF;
476        tampered["signatures"][alice.as_str()]["ed25519:DEV1"] =
477            Value::String(mail4agent_vodozemac::base64_encode(&corrupted[..sig_len]));
478
479        let body = keys_query_body(&[(&alice, &dev1, tampered)]);
480        let outcome = DeviceTracker::on_keys_query_response(&mut store, &body).expect("applies response");
481
482        assert!(outcome.accepted_devices.is_empty());
483        assert_eq!(outcome.dropped_devices.len(), 1);
484        assert_eq!(outcome.dropped_devices[0].user_id, alice);
485        assert_eq!(outcome.dropped_devices[0].device_id, dev1);
486        assert!(
487            outcome.dropped_devices[0].reason.contains("signature"),
488            "reason names the problem: {}",
489            outcome.dropped_devices[0].reason
490        );
491
492        assert!(
493            DeviceTracker::devices_for_user(&store, &alice).expect("no error").is_empty(),
494            "an unsigned device is never stored"
495        );
496        assert_eq!(store.tracked_users().expect("no error"), vec![(alice, false)], "the user is still resolved");
497    }
498
499    #[test]
500    fn key_change_on_a_known_device_id_is_applied_and_flagged() {
501        let mut store = new_store();
502        let bob = user("bob");
503        let dev1 = device("DEV1");
504
505        let original_account = Account::new();
506        let first_body =
507            keys_query_body(&[(&bob, &dev1, signed_device_keys(&original_account, &bob, &dev1))]);
508        DeviceTracker::on_keys_query_response(&mut store, &first_body).expect("first query applies");
509        let original =
510            DeviceTracker::devices_for_user(&store, &bob).expect("no error").into_iter().next().expect("stored");
511        assert!(original.verified, "TOFU trusts the first-seen device");
512
513        let reset_account = Account::new();
514        let second_body =
515            keys_query_body(&[(&bob, &dev1, signed_device_keys(&reset_account, &bob, &dev1))]);
516        let outcome = DeviceTracker::on_keys_query_response(&mut store, &second_body).expect("second query applies");
517
518        assert_eq!(outcome.key_changes, vec![DeviceKeyChanged { user_id: bob.clone(), device_id: dev1.clone() }]);
519        assert!(outcome.accepted_devices.is_empty(), "a key change is reported via key_changes, not accepted_devices");
520
521        let stored = DeviceTracker::devices_for_user(&store, &bob).expect("no error").into_iter().next().expect("kept");
522        assert_ne!(stored.curve25519, original.curve25519, "new identity keys are applied");
523        assert_eq!(stored.curve25519, reset_account.identity_keys().curve25519);
524        assert!(!stored.verified, "verification is cleared on a key reset");
525    }
526
527    #[test]
528    fn first_seen_device_is_trusted_once() {
529        let mut store = new_store();
530        let bob = user("bob");
531        let dev1 = device("DEV1");
532        let account = Account::new();
533        let body = keys_query_body(&[(&bob, &dev1, signed_device_keys(&account, &bob, &dev1))]);
534        let outcome = DeviceTracker::on_keys_query_response(&mut store, &body).expect("query");
535        assert_eq!(outcome.accepted_devices, vec![(bob.clone(), dev1.clone())]);
536        let stored = DeviceTracker::devices_for_user(&store, &bob).expect("no error").into_iter().next().expect("stored");
537        assert!(stored.verified, "TOFU marks first sight verified");
538        // Re-publish same keys keeps verified.
539        let again = DeviceTracker::on_keys_query_response(&mut store, &body).expect("again");
540        assert!(again.key_changes.is_empty());
541        let stored = DeviceTracker::devices_for_user(&store, &bob).expect("no error").into_iter().next().expect("stored");
542        assert!(stored.verified, "unchanged re-publish keeps TOFU trust");
543    }
544}