Skip to main content

mail4agent_messenger/crypto/
group_sessions.rs

1//! [`GroupSessionManager`] -- outbound/inbound Megolm group sessions:
2//! lazy creation, rotation, room-key sharing, and decrypt-with-replay-
3//! protection (plan §3 `crypto/group_sessions.rs`; research doc §3.1
4//! Megolm, §3.3 to-device sharing + its security note, §4.5's UTD
5//! taxonomy).
6//!
7//! # Recipients are the caller's job, rotation is this module's
8//!
9//! This module never reads [`crate::room::state::RoomState`] or
10//! [`crate::crypto::device_tracker::DeviceTracker`] directly (keeps
11//! `crypto/` decoupled from `room/`, matching this crate's existing module
12//! boundaries). Every entry point that needs to know "who currently gets
13//! this room's key" takes two plain caller-supplied inputs instead:
14//!
15//! - `member_user_ids`: every user whose membership is currently `join` or
16//!   `invite` (the caller's own `RoomState::members` filtered accordingly --
17//!   task brief's recipient rule: our rooms use `history_visibility:
18//!   shared`, so an invitee may read what is sent while they are invited).
19//! - `known_devices`: the FULL set of devices this account currently has
20//!   on file (via [`crate::crypto::device_tracker::DeviceTracker`]) for
21//!   every user who is either a current member or was previously shared
22//!   with -- **not** pre-filtered to "current members only". This module
23//!   itself filters out blocked devices and non-members to compute the
24//!   actual recipient set; a caller passing an incomplete `known_devices`
25//!   list will see false "device disappeared" rotations, since this
26//!   module can only tell "gone" apart from "just not queried yet" by
27//!   what it is given.
28//!
29//! # Outbound rotation triggers (task brief, binding)
30//!
31//! [`GroupSessionManager::ensure_outbound_session`] creates a fresh
32//! session (new `session_id`, `shared_with` reset to empty) when any of:
33//! no session exists yet; [`RoomEncryptionContent::rotation_period_msgs`]
34//! messages have been encrypted; [`RoomEncryptionContent::rotation_period_ms`]
35//! has elapsed since creation (`now_ms` is caller-supplied -- this crate
36//! never calls `std::time` itself, plan §6.7); a previously-shared user is
37//! no longer in `member_user_ids`; or a previously-shared `(user, device)`
38//! pair is no longer present anywhere in `known_devices` (the device
39//! disappeared from the tracked list entirely -- deleted/logged out, not
40//! merely blocked). A **new** device of an already-shared member, or a
41//! newly joined/invited member, never forces rotation -- they simply
42//! become new entries in `shared_with`, sharing the *current* session at
43//! its *current* ratchet position (they cannot read earlier messages;
44//! this is what [`GroupSession::session_key`] naturally exports).
45//!
46//! # Sharing
47//!
48//! A room key is shared over Olm to-device, batched into `sendToDevice`
49//! request bodies of at most [`MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST`]
50//! devices each (research doc §3.3's fan-out note) --
51//! [`GroupSessionManager::chunk_recipients_for_send_to_device`] plus
52//! [`GroupSessionManager::build_room_key_send_to_device_body`] do this;
53//! every recipient must already have an Olm session established (a
54//! caller's own `/keys/claim` round trip via
55//! [`crate::crypto::olm_sessions::OlmSessionManager`] first, same as any
56//! other to-device send). The outbound session record (including its
57//! updated `shared_with` set) is persisted through
58//! [`crate::store::CryptoStore::save_outbound_group_session`] before
59//! [`GroupSessionManager::ensure_outbound_session`] returns the
60//! [`RoomKeyContent`] to share -- and the actual room message is only ever
61//! produced by a *separate*, later call to
62//! [`GroupSessionManager::encrypt_event`], so a caller naturally cannot
63//! encrypt a message with a session whose sharing state has not already
64//! been persisted.
65//!
66//! # Inbound: bound identity, merge-not-overwrite, replay protection
67//!
68//! [`GroupSessionManager::receive_room_key`] only ever takes an
69//! Olm-verified `(sender_user_id, sender_curve25519, sender_ed25519)`
70//! triple as input -- there is no entry point that accepts a bare
71//! [`crate::wire::events::RawEvent`], so an `m.room_key`-shaped ROOM
72//! event (which could never have been Olm-decrypted) has no path into
73//! this module at all (research doc §3.3's security note: room keys are
74//! never trusted from anywhere but an Olm-decrypted to-device event).
75//! [`GroupSessionManager::accept_room_key_from_to_device`] is the
76//! convenience wrapper over a
77//! [`crate::crypto::olm_sessions::DecryptedToDevice`] (the type only
78//! [`crate::crypto::olm_sessions::OlmSessionManager::decrypt_to_device`]
79//! can construct) that most callers actually use.
80//!
81//! A second `m.room_key` for a session id this device already knows never
82//! regresses it to a worse (higher first-known-index) state --
83//! [`InboundGroupSession::compare`]/[`InboundGroupSession::merge`] decide
84//! whether the incoming share is `Better`/`Worse`/`Equal`/`Unconnected`.
85//! Only `Worse` (the incoming share is a genuine improvement) can ever
86//! change the stored record, and even then only when the incoming share's
87//! sender identity matches the record's EXISTING binding -- once a record
88//! exists, its `bound_user_id`/`bound_sender_curve25519`/
89//! `bound_sender_ed25519` never change for its lifetime
90//! ([`merge_inbound_session`]'s own doc). A connected, lower-index share
91//! from a DIFFERENT sender is ignored outright (kept, not merged, not
92//! rebound) -- a bound session's identity is a security property, not a
93//! cache of "whoever most recently improved this ratchet".
94//!
95//! [`GroupSessionManager::decrypt_event`] additionally checks, in order:
96//! a session exists at all ([`GroupDecryptError::MissingSession`]); the
97//! decrypted plaintext's own `room_id` matches the room the ciphertext
98//! actually arrived in ([`GroupDecryptError::WrongRoom`] -- a cross-room
99//! replay guard); the event's `sender` matches the session's bound user
100//! ([`GroupDecryptError::WrongSender`] -- a session from one user can
101//! never authenticate an event claimed by another); and finally replay
102//! protection over `(session_id, message_index)` -> `(event_id,
103//! origin_server_ts)` ([`GroupDecryptError::Replay`] -- the exact same
104//! event redelivered is fine and is not an error, a DIFFERENT event or
105//! timestamp reusing the same index is).
106
107use crate::crypto::account::OlmAccountState;
108use crate::crypto::device_tracker::StoredDevice;
109use crate::crypto::olm_sessions::{DecryptedToDevice, OlmSessionManager};
110use crate::error::MessengerError;
111use crate::ids::{DeviceId, EventId, RoomId, UserId};
112use crate::store::CryptoStore;
113use crate::wire::events::{
114    ForwardedRoomKeyContent, MegolmEncryptedContent, RelatesTo, RoomEncryptedContent, RoomEncryptionContent, RoomKeyContent,
115};
116use mail4agent_vodozemac::megolm::{
117    DecryptionError as MegolmDecryptionError, ExportedSessionKey, GroupSession, InboundGroupSession, MegolmMessage,
118    SessionConfig, SessionKey, SessionOrdering,
119};
120use mail4agent_vodozemac::{Curve25519PublicKey, DecodeError as VodozemacDecodeError, Ed25519PublicKey};
121use serde::{Deserialize, Serialize};
122use std::collections::{BTreeMap, BTreeSet};
123
124/// The only Megolm algorithm this crate speaks (research doc §3.1).
125const MEGOLM_ALGORITHM: &str = "m.megolm.v1.aes-sha2";
126
127/// The Matrix `sendToDevice` fan-out cap this crate splits large room-key
128/// shares at (research doc §3.3's batching note; task brief: "≤ 250
129/// devices per request").
130pub const MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST: usize = 250;
131
132/// The UI-facing reason a timeline item could not be decrypted (plan
133/// `lib.rs`'s `MessengerEvent::Utd` contract set -- kept an exhaustive,
134/// stable set independent of this crate's own internal error taxonomy so
135/// the UI-contract crate never has to track every internal Megolm/Olm
136/// error variant).
137///
138/// Only [`UtdReason::MissingSession`] and [`UtdReason::Unknown`] are ever
139/// produced by this piece (M7) -- see [`GroupDecryptError::utd_reason`]'s
140/// own doc for the exact mapping and why `Replay`/`WrongRoom`/
141/// `WrongSender` collapse to `Unknown` rather than getting their own
142/// variants here. The `Withheld*`/`SenderIdentityNotVerified` variants are
143/// hooks for later pieces: `crypto::withheld` (M8) produces
144/// `WithheldBlacklisted`/`WithheldUnverified`/`WithheldUnauthorised` from
145/// an `m.room_key.withheld` code, and `crypto::verification`/
146/// `cross_signing` (M8/M10) produce `SenderIdentityNotVerified`.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum UtdReason {
149    /// No inbound session for this `(room, session_id)` exists yet.
150    MissingSession,
151    /// The sender explicitly withheld the key: this device is blocked.
152    /// Never produced by this piece -- a hook for `crypto::withheld` (M8).
153    WithheldBlacklisted,
154    /// The sender explicitly withheld the key: this device is unverified.
155    /// Never produced by this piece -- a hook for `crypto::withheld` (M8).
156    WithheldUnverified,
157    /// The sender explicitly withheld the key: this user is not
158    /// authorised. Never produced by this piece -- a hook for
159    /// `crypto::withheld` (M8).
160    WithheldUnauthorised,
161    /// The sender's identity has not been verified. Never produced by
162    /// this piece -- a hook for `crypto::verification`/`cross_signing`
163    /// (M8/M10).
164    SenderIdentityNotVerified,
165    /// Every other reason this piece's own checks reject a message for
166    /// (a replay, a cross-room or cross-sender mismatch, a malformed
167    /// ciphertext, ...) -- see [`GroupDecryptError::utd_reason`].
168    Unknown,
169}
170
171/// The plaintext a Megolm-encrypted room event actually carries (task
172/// brief's "Encrypt" rule: `{type, content, room_id}`) -- shared shape for
173/// both [`GroupSessionManager::encrypt_event`]'s input and
174/// [`GroupSessionManager::decrypt_event`]'s output. Embedding `room_id`
175/// inside the ciphertext itself (not just relying on whatever room the
176/// wire event happened to arrive in) is what makes the cross-room replay
177/// check in [`GroupSessionManager::decrypt_event`] possible at all.
178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
179pub struct RoomEventPlaintext {
180    /// The inner event's own type (e.g. `"m.room.message"`).
181    #[serde(rename = "type")]
182    pub event_type: String,
183    /// The inner event's own content.
184    pub content: serde_json::Value,
185    /// The room this plaintext was encrypted for.
186    pub room_id: RoomId,
187}
188
189/// The result of [`GroupSessionManager::ensure_outbound_session`].
190#[derive(Debug, Clone)]
191pub struct OutboundSessionUpdate {
192    /// The (possibly just-rotated) outbound session's id.
193    pub session_id: String,
194    /// `true` iff this call created a brand-new session (either none
195    /// existed yet, or a rotation trigger fired).
196    pub rotated: bool,
197    /// The session's key at its current message index (base64
198    /// `SessionKey`): what [`GroupSessionManager::adopt_own_outbound_session`]
199    /// turns into this device's own inbound copy of a just-created session.
200    pub session_key: String,
201    /// Recipients that have never been shared this session
202    /// (`shared_with`, before this call) and must now receive
203    /// `room_key_content` over Olm to-device. Empty when nothing changed.
204    pub new_recipients: Vec<StoredDevice>,
205    /// The `m.room_key` to-device payload for `new_recipients`, or `None`
206    /// when `new_recipients` is empty (nothing to share).
207    pub room_key_content: Option<RoomKeyContent>,
208}
209
210/// The durable record behind [`crate::store::CryptoStore::
211/// outbound_group_session`]/[`crate::store::CryptoStore::
212/// save_outbound_group_session`] -- an opaque blob at that layer; this
213/// module is the only one that interprets it (same convention as
214/// `crypto::account`'s `AccountRecord`).
215#[derive(Serialize, Deserialize)]
216struct OutboundGroupSessionRecord {
217    session: mail4agent_vodozemac::megolm::GroupSessionPickle,
218    /// Wall-clock milliseconds this session was created at (caller-supplied
219    /// `now_ms`, plan §6.7) -- compared against `rotation_period_ms`.
220    created_at_ms: i64,
221    /// Every `(user, device)` this session has ever been shared with.
222    /// Reset to empty on every rotation.
223    shared_with: BTreeSet<(UserId, DeviceId)>,
224}
225
226/// One replay-protection entry: the first `(event_id, origin_server_ts)`
227/// this device ever saw a given Megolm `message_index` decrypted under.
228/// `pub(crate)` alongside [`InboundGroupSessionRecord::replay_index`]
229/// itself, which names this type in its own field type.
230#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
231pub(crate) struct ReplayEntry {
232    event_id: EventId,
233    origin_server_ts: i64,
234}
235
236/// A record of this session having already been uploaded to the server's
237/// key backup. Not yet read or written by anything in this crate -- kept on
238/// [`InboundGroupSessionRecord`] now (rather than added in a later schema
239/// migration) purely so the backup piece that eventually consumes it can
240/// land without another record-format change; `#[serde(default)]` on the
241/// field below is what makes that safe.
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243pub(crate) struct BackedUpMark {
244    /// The backup version (the server's own opaque `version` string, minted
245    /// fresh every time the backup is reset) this mark was recorded
246    /// against -- a session backed up under an OLDER version needs
247    /// re-uploading to a newer one, since the server discards everything
248    /// on a version bump.
249    pub(crate) version: String,
250    /// The session's own [`mail4agent_vodozemac::megolm::InboundGroupSession::
251    /// first_known_index`] at the time it was last backed up. If the
252    /// session is later improved (a lower-index share arrives and merges,
253    /// [`InboundMergeOutcome::Merged`]), this mark goes stale even though
254    /// `version` hasn't changed.
255    pub(crate) first_message_index: u32,
256}
257
258/// The durable record behind [`crate::store::CryptoStore::
259/// inbound_group_session`]/[`crate::store::CryptoStore::
260/// save_inbound_group_session`] -- an opaque blob at that layer, same
261/// convention as [`OutboundGroupSessionRecord`]. `pub(crate)` (fields
262/// included) so a later, sibling module can read/write this same record
263/// shape -- via [`load_inbound_record`]/[`save_inbound_record`]/
264/// [`merge_inbound_session`] below -- rather than this module duplicating a
265/// second, parallel record type.
266#[derive(Serialize, Deserialize)]
267pub(crate) struct InboundGroupSessionRecord {
268    pub(crate) session: mail4agent_vodozemac::megolm::InboundGroupSessionPickle,
269    /// The user this session is bound to -- the Olm-verified sender of
270    /// the `m.room_key` that (most recently, if merged) produced this
271    /// record. A room event whose `sender` differs is never authenticated
272    /// by this session (module doc). Once set, this identity (together
273    /// with `bound_sender_curve25519`/`bound_sender_ed25519`) never
274    /// changes for the lifetime of the record -- see
275    /// [`merge_inbound_session`]'s own doc for why a connected, lower-index
276    /// share from a DIFFERENT sender is ignored rather than merged.
277    pub(crate) bound_user_id: UserId,
278    /// The bound sender device's Curve25519 identity key, base64.
279    pub(crate) bound_sender_curve25519: String,
280    /// The bound sender device's Ed25519 signing key, base64.
281    pub(crate) bound_sender_ed25519: String,
282    /// Every message index this device has already decrypted under this
283    /// session, and what it was first seen as -- module doc's replay
284    /// protection.
285    pub(crate) replay_index: BTreeMap<u32, ReplayEntry>,
286    /// Set once this session has been uploaded to the server's key backup
287    /// -- `#[serde(default)]` so a record persisted before M9a (with no
288    /// such field at all) still deserializes, simply as `None` (never
289    /// backed up yet, from this field's own point of view).
290    #[serde(default)]
291    pub(crate) backed_up: Option<BackedUpMark>,
292}
293
294/// Every way [`GroupSessionManager::decrypt_event`] can fail. See the
295/// module doc's "Inbound" section for the exact check order these
296/// variants correspond to.
297#[derive(Debug, thiserror::Error)]
298pub enum GroupDecryptError {
299    /// No inbound session for `(room_id, session_id)` exists yet -- a
300    /// real, structural UTD (missing key), not a transient one.
301    #[error("no inbound Megolm session for room {room_id} session {session_id}")]
302    MissingSession {
303        /// The room the caller asked to decrypt in.
304        room_id: RoomId,
305        /// The session id the event names.
306        session_id: String,
307    },
308    /// The decrypted plaintext's own `room_id` does not match the room
309    /// the ciphertext actually arrived in -- a cross-room replay attempt.
310    #[error("decrypted payload's room_id {plaintext_room_id} does not match the event's own room {event_room_id}")]
311    WrongRoom {
312        /// The room this event actually arrived in.
313        event_room_id: RoomId,
314        /// The room the decrypted plaintext itself claims.
315        plaintext_room_id: RoomId,
316    },
317    /// The event's `sender` does not match the session's bound user --
318    /// a session from one user can never authenticate an event claimed
319    /// by another.
320    #[error("event sender {event_sender} does not match this session's bound sender {bound_user_id}")]
321    WrongSender {
322        /// The user this session is bound to.
323        bound_user_id: UserId,
324        /// The event's own claimed sender.
325        event_sender: UserId,
326    },
327    /// The same `(session_id, message_index)` was already seen under a
328    /// DIFFERENT event id or timestamp -- a genuine replay. The exact
329    /// same event redelivered is not an error (see this method's own
330    /// doc).
331    #[error("message index {message_index} of session {session_id} was already seen under a different event")]
332    Replay {
333        /// The session whose index was reused.
334        session_id: String,
335        /// The reused message index.
336        message_index: u32,
337    },
338    /// The ciphertext bytes were not a well-formed [`MegolmMessage`].
339    #[error("malformed Megolm ciphertext: {0}")]
340    MalformedCiphertext(#[from] VodozemacDecodeError),
341    /// Megolm's own signature/MAC/ratchet-index check failed.
342    #[error("Megolm decryption failed: {0}")]
343    Vodozemac(#[from] MegolmDecryptionError),
344    /// The decrypted bytes, or a stored record, were not the expected
345    /// JSON shape.
346    #[error("decrypted or stored payload was not valid JSON: {0}")]
347    InvalidPayload(#[from] serde_json::Error),
348    /// A [`crate::store::CryptoStore`] operation failed.
349    #[error("store: {0}")]
350    Store(#[from] crate::store::StoreError),
351}
352
353impl GroupDecryptError {
354    /// Maps this error onto the UI-facing [`UtdReason`] set. Only
355    /// [`GroupDecryptError::MissingSession`] gets its own variant
356    /// ([`UtdReason::MissingSession`]) -- `WrongRoom`, `WrongSender`,
357    /// `Replay`, and every decode/crypto failure collapse to
358    /// [`UtdReason::Unknown`] (task brief's own documented choice): none
359    /// of them are actionable the way "you're missing a key, maybe a
360    /// re-share will fix it" is, and surfacing them precisely to the UI
361    /// would leak internal attack-detection detail (e.g. "this was a
362    /// replay") without giving the user anything useful to do about it.
363    pub fn utd_reason(&self) -> UtdReason {
364        match self {
365            GroupDecryptError::MissingSession { .. } => UtdReason::MissingSession,
366            _ => UtdReason::Unknown,
367        }
368    }
369}
370
371/// Reads and decodes the inbound Megolm session record for
372/// `(room_id, session_id)`, if this store has one -- the shared
373/// load-and-decode step [`GroupSessionManager::receive_room_key`] and (a
374/// later, sibling piece) key-backup import both go through, so neither ever
375/// duplicates the other's decode error message or forgets a field.
376pub(crate) fn load_inbound_record<S: CryptoStore>(
377    store: &S,
378    room_id: &RoomId,
379    session_id: &str,
380) -> Result<Option<InboundGroupSessionRecord>, MessengerError> {
381    let Some(bytes) = store.inbound_group_session(room_id, session_id)? else { return Ok(None) };
382    let record: InboundGroupSessionRecord = serde_json::from_slice(bytes).map_err(|source| {
383        MessengerError::Crypto(format!("decode inbound Megolm session for room {room_id} session {session_id}: {source}"))
384    })?;
385    Ok(Some(record))
386}
387
388/// Encodes and persists `record` as the inbound Megolm session for
389/// `(room_id, session_id)` -- the write-side counterpart to
390/// [`load_inbound_record`], same sharing rationale.
391pub(crate) fn save_inbound_record<S: CryptoStore>(
392    store: &mut S,
393    room_id: &RoomId,
394    session_id: &str,
395    record: &InboundGroupSessionRecord,
396) -> Result<(), MessengerError> {
397    let bytes = serde_json::to_vec(record).map_err(|source| {
398        MessengerError::Crypto(format!("encode inbound Megolm session for room {room_id} session {session_id}: {source}"))
399    })?;
400    store.save_inbound_group_session(room_id, session_id, bytes)?;
401    Ok(())
402}
403
404/// The result of [`merge_inbound_session`] -- every way an incoming inbound
405/// Megolm session share can interact with whatever record (if any) already
406/// exists for the same `(room_id, session_id)` slot.
407pub(crate) enum InboundMergeOutcome {
408    /// No record existed yet -- `record` is brand new, bound to the
409    /// incoming share's own sender identity.
410    New {
411        /// The freshly created record, not yet persisted.
412        record: InboundGroupSessionRecord,
413    },
414    /// A record already existed and is at least as good as the incoming
415    /// share (`Equal`/`Better`), or the incoming share does not even
416    /// connect to it (`Unconnected`, see [`SessionOrdering`]'s own doc) --
417    /// kept verbatim, nothing to persist.
418    Kept,
419    /// The incoming share connects to the existing record and is a genuine
420    /// improvement (`SessionOrdering::Worse` -- see that type's own doc for
421    /// why that name means "the existing session was worse"), AND its
422    /// sender identity matches the existing record's own binding -- merged,
423    /// keeping the record's binding and replay index, adopting the
424    /// incoming share's lower ratchet index.
425    Merged {
426        /// The merged record, not yet persisted.
427        record: InboundGroupSessionRecord,
428    },
429    /// The incoming share connects and is a genuine improvement by ratchet
430    /// index alone, but its sender identity does NOT match the existing
431    /// record's own binding. A bound record's identity is a security
432    /// property, not a cache of "whoever most recently improved this
433    /// ratchet" (module doc) -- so this share is ignored outright and the
434    /// existing record is kept exactly as it was.
435    IgnoredSenderMismatch,
436}
437
438/// Merges an incoming inbound Megolm session share (freshly constructed via
439/// [`InboundGroupSession::new`] from an `m.room_key`, or -- a later, sibling
440/// piece -- [`InboundGroupSession::import`] from a key-backup entry)
441/// against whatever record already exists for the same `(room_id,
442/// session_id)` slot, enforcing the module doc's two rules in one place so
443/// they can never drift between callers: merge-not-overwrite (a later share
444/// never regresses an already-better session), and binding-never-changes
445/// (a record's sender identity, once set, is fixed for its lifetime --
446/// a connected-but-worse share from a different sender is ignored, never
447/// merged, even though its ratchet index alone would otherwise qualify).
448pub(crate) fn merge_inbound_session(
449    existing: Option<InboundGroupSessionRecord>,
450    mut incoming_session: InboundGroupSession,
451    sender_user_id: &UserId,
452    sender_curve25519: Curve25519PublicKey,
453    sender_ed25519: Ed25519PublicKey,
454) -> InboundMergeOutcome {
455    let Some(existing_record) = existing else {
456        return InboundMergeOutcome::New {
457            record: InboundGroupSessionRecord {
458                session: incoming_session.pickle(),
459                bound_user_id: sender_user_id.clone(),
460                bound_sender_curve25519: sender_curve25519.to_base64(),
461                bound_sender_ed25519: sender_ed25519.to_base64(),
462                replay_index: BTreeMap::new(),
463                backed_up: None,
464            },
465        };
466    };
467
468    let InboundGroupSessionRecord { session, bound_user_id, bound_sender_curve25519, bound_sender_ed25519, replay_index, backed_up } =
469        existing_record;
470    let mut existing_session = InboundGroupSession::from_pickle(session);
471
472    match existing_session.compare(&mut incoming_session) {
473        // The stored session is already at least as good, or this share
474        // does not even connect to it -- never let a later share regress
475        // (or hijack) an already-known session (module doc).
476        SessionOrdering::Equal | SessionOrdering::Better | SessionOrdering::Unconnected => InboundMergeOutcome::Kept,
477        SessionOrdering::Worse => {
478            let sender_matches = bound_user_id == *sender_user_id
479                && bound_sender_curve25519 == sender_curve25519.to_base64()
480                && bound_sender_ed25519 == sender_ed25519.to_base64();
481            if sender_matches {
482                // `compare() == Worse` only when `connected()` is true (its
483                // own doc), so `merge` always succeeds here -- the
484                // fallback re-derives an equivalent session from the
485                // incoming share's own pickle, never a load-bearing branch:
486                // even if that invariant were somehow violated, adopting
487                // the incoming session outright (still strictly better by
488                // index than what we had) is still forward progress, never
489                // a panic.
490                let merged = existing_session
491                    .merge(&mut incoming_session)
492                    .unwrap_or_else(|| InboundGroupSession::from_pickle(incoming_session.pickle()));
493                InboundMergeOutcome::Merged {
494                    record: InboundGroupSessionRecord {
495                        session: merged.pickle(),
496                        bound_user_id,
497                        bound_sender_curve25519,
498                        bound_sender_ed25519,
499                        replay_index,
500                        backed_up,
501                    },
502                }
503            } else {
504                // A bound record's identity never changes, even for a
505                // connected, genuinely-lower-index share -- see this
506                // function's own doc and the module doc's "Inbound"
507                // section.
508                InboundMergeOutcome::IgnoredSenderMismatch
509            }
510        }
511    }
512}
513
514/// Outbound/inbound Megolm group-session management. See the module doc
515/// for the security invariants this type enforces. Carries no state of
516/// its own -- every method reads/writes through the caller's
517/// [`CryptoStore`].
518pub struct GroupSessionManager;
519
520impl GroupSessionManager {
521    /// Ensures room `room_id` has a current outbound Megolm session
522    /// reflecting `member_user_ids`/`known_devices`, rotating it first if
523    /// any of the module doc's rotation triggers fire. Returns the
524    /// (possibly new) session's id, whether it rotated, and the room-key
525    /// share (if any) a caller must now fan out over Olm to-device before
526    /// this session can be used to [`GroupSessionManager::encrypt_event`]
527    /// for anyone new.
528    ///
529    /// `our_user_id`/`our_device_id` are excluded from the recipient set
530    /// (a device never Olm-shares its own outbound session key to
531    /// itself -- it already has it) even if present in `known_devices`.
532    #[allow(clippy::too_many_arguments)]
533    pub fn ensure_outbound_session<S: CryptoStore>(
534        store: &mut S,
535        room_id: &RoomId,
536        our_user_id: &UserId,
537        our_device_id: &DeviceId,
538        encryption: &RoomEncryptionContent,
539        member_user_ids: &BTreeSet<UserId>,
540        known_devices: &[StoredDevice],
541        now_ms: i64,
542    ) -> Result<OutboundSessionUpdate, MessengerError> {
543        let existing_bytes = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec);
544
545        let (group_session, created_at_ms, mut shared_with, rotated) = match existing_bytes {
546            None => (GroupSession::new(SessionConfig::version_1()), now_ms, BTreeSet::new(), true),
547            Some(bytes) => {
548                let record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
549                    MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
550                })?;
551                let group_session = GroupSession::from_pickle(record.session);
552                let msgs_exhausted = i64::from(group_session.message_index()) >= encryption.rotation_period_msgs;
553                let time_elapsed = now_ms.saturating_sub(record.created_at_ms) >= encryption.rotation_period_ms;
554                let a_shared_user_left =
555                    record.shared_with.iter().any(|(user_id, _)| !member_user_ids.contains(user_id));
556                let a_shared_device_disappeared = record.shared_with.iter().any(|(user_id, device_id)| {
557                    !known_devices.iter().any(|device| &device.user_id == user_id && &device.device_id == device_id)
558                });
559                if msgs_exhausted || time_elapsed || a_shared_user_left || a_shared_device_disappeared {
560                    (GroupSession::new(SessionConfig::version_1()), now_ms, BTreeSet::new(), true)
561                } else {
562                    (group_session, record.created_at_ms, record.shared_with, false)
563                }
564            }
565        };
566
567        let new_recipients: Vec<StoredDevice> = known_devices
568            .iter()
569            .filter(|device| {
570                member_user_ids.contains(&device.user_id)
571                    && !device.blocked
572                    && !(device.user_id == *our_user_id && device.device_id == *our_device_id)
573            })
574            .filter(|device| !shared_with.contains(&(device.user_id.clone(), device.device_id.clone())))
575            .cloned()
576            .collect();
577
578        let session_id = group_session.session_id();
579        let session_key = group_session.session_key().to_base64();
580        let room_key_content = if new_recipients.is_empty() {
581            None
582        } else {
583            for device in &new_recipients {
584                shared_with.insert((device.user_id.clone(), device.device_id.clone()));
585            }
586            Some(RoomKeyContent {
587                algorithm: MEGOLM_ALGORITHM.to_string(),
588                room_id: room_id.clone(),
589                session_id: session_id.clone(),
590                session_key: session_key.clone(),
591            })
592        };
593
594        if rotated || room_key_content.is_some() {
595            let record = OutboundGroupSessionRecord { session: group_session.pickle(), created_at_ms, shared_with };
596            let bytes = serde_json::to_vec(&record).map_err(|source| {
597                MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
598            })?;
599            store.save_outbound_group_session(room_id, bytes)?;
600        }
601
602        Ok(OutboundSessionUpdate { session_id, rotated, session_key, new_recipients, room_key_content })
603    }
604
605    /// Remove `(user_id, device_id)` from outbound Megolm `shared_with` in
606    /// each of `room_ids`. After an authenticated peer identity reset the
607    /// device id is unchanged, so without this the next
608    /// [`GroupSessionManager::ensure_outbound_session`] would think the
609    /// device already has the room key and never re-share.
610    pub fn forget_shared_device<S: CryptoStore>(
611        store: &mut S,
612        room_ids: &[RoomId],
613        user_id: &UserId,
614        device_id: &DeviceId,
615    ) -> Result<usize, MessengerError> {
616        let mut touched = 0usize;
617        for room_id in room_ids {
618            let Some(bytes) = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec) else {
619                continue;
620            };
621            let mut record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
622                MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
623            })?;
624            let before = record.shared_with.len();
625            record.shared_with.retain(|(u, d)| !(u == user_id && d == device_id));
626            if record.shared_with.len() != before {
627                let out = serde_json::to_vec(&record).map_err(|source| {
628                    MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
629                })?;
630                store.save_outbound_group_session(room_id, out)?;
631                touched += 1;
632            }
633        }
634        Ok(touched)
635    }
636
637    /// Registers a session [`GroupSessionManager::ensure_outbound_session`]
638    /// just created (`update.rotated`) as an INBOUND session of this device
639    /// itself, bound to `our_user_id`/`our_curve25519`/`our_ed25519`. The
640    /// room's echo of a message this device sent is ciphertext like any
641    /// other event, and no session key is ever Olm-shared to its own creator,
642    /// so without this copy every message's author would see their own
643    /// message come back as [`UtdReason::MissingSession`]. A no-op when the
644    /// update did not rotate (the copy already exists from the rotation).
645    pub fn adopt_own_outbound_session<S: CryptoStore>(
646        store: &mut S,
647        room_id: &RoomId,
648        our_user_id: &UserId,
649        our_curve25519: Curve25519PublicKey,
650        our_ed25519: Ed25519PublicKey,
651        update: &OutboundSessionUpdate,
652    ) -> Result<(), MessengerError> {
653        if !update.rotated {
654            return Ok(());
655        }
656        let content = RoomKeyContent {
657            algorithm: MEGOLM_ALGORITHM.to_string(),
658            room_id: room_id.clone(),
659            session_id: update.session_id.clone(),
660            session_key: update.session_key.clone(),
661        };
662        Self::receive_room_key(store, our_user_id, our_curve25519, our_ed25519, &content)
663    }
664
665    /// Encrypts one room event under `room_id`'s current outbound Megolm
666    /// session (must already exist -- call
667    /// [`GroupSessionManager::ensure_outbound_session`] first). Persists
668    /// the ratchet-advanced session before returning, so a crash right
669    /// after this call never reuses the message index it just consumed
670    /// (module doc; this crate's flush-before-send discipline).
671    pub fn encrypt_event<S: CryptoStore>(
672        store: &mut S,
673        room_id: &RoomId,
674        sender_curve25519_b64: &str,
675        sender_device_id: &DeviceId,
676        event_type: &str,
677        content: serde_json::Value,
678        relates_to: Option<RelatesTo>,
679    ) -> Result<MegolmEncryptedContent, MessengerError> {
680        let bytes = store
681            .outbound_group_session(room_id)?
682            .map(<[u8]>::to_vec)
683            .ok_or_else(|| MessengerError::Crypto(format!("no outbound Megolm session for room {room_id} yet")))?;
684        let mut record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
685            MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
686        })?;
687        let mut group_session = GroupSession::from_pickle(record.session);
688
689        let plaintext = RoomEventPlaintext { event_type: event_type.to_string(), content, room_id: room_id.clone() };
690        let plaintext_bytes = serde_json::to_vec(&plaintext)?;
691        let message = group_session.encrypt(plaintext_bytes);
692        let session_id = group_session.session_id();
693
694        record.session = group_session.pickle();
695        let record_bytes = serde_json::to_vec(&record).map_err(|source| {
696            MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
697        })?;
698        store.save_outbound_group_session(room_id, record_bytes)?;
699
700        Ok(MegolmEncryptedContent {
701            ciphertext: message.to_base64(),
702            session_id,
703            sender_key: Some(sender_curve25519_b64.to_string()),
704            device_id: Some(sender_device_id.clone()),
705            relates_to,
706        })
707    }
708
709    /// Splits `recipients` into chunks of at most
710    /// [`MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST`] devices (module doc) --
711    /// a caller builds one `sendToDevice` request per chunk via
712    /// [`GroupSessionManager::build_room_key_send_to_device_body`], each
713    /// with its own `RequestId`/`TxnId` (only the caller's own sequence
714    /// can mint those -- `crate::ids`'s module doc explains why this
715    /// crate never keeps a hidden counter of its own).
716    pub fn chunk_recipients_for_send_to_device(recipients: &[StoredDevice]) -> std::slice::Chunks<'_, StoredDevice> {
717        recipients.chunks(MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST)
718    }
719
720    /// Builds one `sendToDevice` request body (the client-server API's
721    /// `{"messages": {"@user:x": {"DEVICEID": {...}}}}` shape) sharing
722    /// `room_key_content` with every device in `chunk`, Olm-encrypting it
723    /// individually per device. Every device in `chunk` must already have
724    /// an established Olm session (a prior `/keys/claim` round trip via
725    /// [`OlmSessionManager`]) -- this function surfaces the first missing
726    /// one as an error rather than silently dropping it from the fan-out.
727    pub fn build_room_key_send_to_device_body<S: CryptoStore>(
728        store: &mut S,
729        account: &OlmAccountState,
730        our_user_id: &UserId,
731        our_device_id: &DeviceId,
732        chunk: &[StoredDevice],
733        room_key_content: &RoomKeyContent,
734    ) -> Result<serde_json::Value, MessengerError> {
735        let content_value = serde_json::to_value(room_key_content)?;
736        Self::build_encrypted_to_device_body(
737            store,
738            account,
739            our_user_id,
740            our_device_id,
741            chunk,
742            "m.room_key",
743            content_value,
744        )
745    }
746
747    /// Same fan-out as [`GroupSessionManager::build_room_key_send_to_device_body`],
748    /// for any inner to-device type (`m.room_key`, `m.room_key_request`,
749    /// `m.forwarded_room_key`). The outer `sendToDevice` type stays
750    /// `m.room.encrypted`.
751    pub fn build_encrypted_to_device_body<S: CryptoStore>(
752        store: &mut S,
753        account: &OlmAccountState,
754        our_user_id: &UserId,
755        our_device_id: &DeviceId,
756        chunk: &[StoredDevice],
757        event_type: &str,
758        content_value: serde_json::Value,
759    ) -> Result<serde_json::Value, MessengerError> {
760        let mut by_user: BTreeMap<UserId, serde_json::Map<String, serde_json::Value>> = BTreeMap::new();
761        for device in chunk {
762            let encrypted = OlmSessionManager::encrypt_to_device(
763                store,
764                account,
765                our_user_id,
766                our_device_id,
767                device,
768                event_type,
769                content_value.clone(),
770            )?;
771            let wire_content = serde_json::to_value(RoomEncryptedContent::Olm(encrypted))?;
772            by_user.entry(device.user_id.clone()).or_default().insert(device.device_id.as_str().to_string(), wire_content);
773        }
774        let mut top = serde_json::Map::new();
775        for (user_id, devices) in by_user {
776            top.insert(user_id.as_str().to_string(), serde_json::Value::Object(devices));
777        }
778        Ok(serde_json::json!({ "messages": serde_json::Value::Object(top) }))
779    }
780
781    /// The `m.forwarded_room_key` body for an inbound session this store
782    /// holds, exported at its first known index so earlier messages in the
783    /// session still decrypt. `None` when this device has no such session.
784    pub fn forwarded_room_key_content<S: CryptoStore>(
785        store: &S,
786        room_id: &RoomId,
787        session_id: &str,
788        our_curve25519_b64: &str,
789    ) -> Result<Option<ForwardedRoomKeyContent>, MessengerError> {
790        let Some(record) = load_inbound_record(store, room_id, session_id)? else { return Ok(None) };
791        let InboundGroupSessionRecord { session, bound_sender_curve25519, bound_sender_ed25519, .. } = record;
792        let inbound = InboundGroupSession::from_pickle(session);
793        if inbound.session_id() != session_id {
794            return Ok(None);
795        }
796        let exported = inbound.export_at_first_known_index();
797        Ok(Some(ForwardedRoomKeyContent {
798            algorithm: MEGOLM_ALGORITHM.to_string(),
799            room_id: room_id.clone(),
800            session_id: session_id.to_string(),
801            session_key: exported.to_base64(),
802            sender_key: bound_sender_curve25519,
803            sender_claimed_ed25519_key: Some(bound_sender_ed25519),
804            forwarding_curve25519_key_chain: vec![our_curve25519_b64.to_string()],
805        }))
806    }
807
808    /// The current outbound `m.room_key` when its session id matches.
809    /// This key starts at the ratchet's current index, so it does not
810    /// decrypt messages encrypted before that index. Callers prefer
811    /// [`GroupSessionManager::forwarded_room_key_content`].
812    pub fn outbound_room_key_content<S: CryptoStore>(
813        store: &S,
814        room_id: &RoomId,
815        session_id: &str,
816    ) -> Result<Option<RoomKeyContent>, MessengerError> {
817        let Some(bytes) = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec) else { return Ok(None) };
818        let record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
819            MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
820        })?;
821        let session = GroupSession::from_pickle(record.session);
822        if session.session_id() != session_id {
823            return Ok(None);
824        }
825        Ok(Some(RoomKeyContent {
826            algorithm: MEGOLM_ALGORITHM.to_string(),
827            room_id: room_id.clone(),
828            session_id: session_id.to_string(),
829            session_key: session.session_key().to_base64(),
830        }))
831    }
832
833    /// Convenience wrapper over [`GroupSessionManager::receive_room_key`]
834    /// for a caller holding a [`DecryptedToDevice`] -- the type only
835    /// [`OlmSessionManager::decrypt_to_device`] can construct, which is
836    /// what enforces the module doc's "only from an Olm-decrypted
837    /// to-device event" rule structurally rather than by a runtime check.
838    /// Returns `Ok(false)` (a no-op, not an error) for any to-device
839    /// event whose inner type is not `"m.room_key"` -- a caller can call
840    /// this unconditionally on every decrypted to-device event without
841    /// pre-filtering.
842    pub fn accept_room_key_from_to_device<S: CryptoStore>(
843        store: &mut S,
844        decrypted: &DecryptedToDevice,
845    ) -> Result<bool, MessengerError> {
846        if decrypted.event_type != "m.room_key" {
847            return Ok(false);
848        }
849        let content: RoomKeyContent = serde_json::from_value(decrypted.content.clone())?;
850        if content.algorithm != MEGOLM_ALGORITHM {
851            return Ok(false);
852        }
853        Self::receive_room_key(store, &decrypted.sender, decrypted.sender_device_curve25519, decrypted.sender_ed25519, &content)?;
854        Ok(true)
855    }
856
857    /// Accepts an `m.room_key` payload already known to have arrived over
858    /// an Olm-decrypted to-device event, binding the resulting inbound
859    /// session to `sender_user_id`/`sender_curve25519`/`sender_ed25519`.
860    /// See the module doc's "Inbound" section for the merge-not-overwrite
861    /// rule this method enforces when a session for the same id already
862    /// exists.
863    pub fn receive_room_key<S: CryptoStore>(
864        store: &mut S,
865        sender_user_id: &UserId,
866        sender_curve25519: Curve25519PublicKey,
867        sender_ed25519: Ed25519PublicKey,
868        content: &RoomKeyContent,
869    ) -> Result<(), MessengerError> {
870        if content.algorithm != MEGOLM_ALGORITHM {
871            return Ok(());
872        }
873        let session_key = SessionKey::from_base64(&content.session_key)
874            .map_err(|source| MessengerError::Crypto(format!("malformed Megolm session_key: {source}")))?;
875        let incoming_session = InboundGroupSession::new(&session_key, SessionConfig::version_1());
876
877        let existing = load_inbound_record(store, &content.room_id, &content.session_id)?;
878        match merge_inbound_session(existing, incoming_session, sender_user_id, sender_curve25519, sender_ed25519) {
879            // A brand-new record, or a genuine, same-sender improvement --
880            // persist it.
881            InboundMergeOutcome::New { record } | InboundMergeOutcome::Merged { record } => {
882                save_inbound_record(store, &content.room_id, &content.session_id, &record)
883            }
884            // Already at least as good, unconnected, or a lower-index share
885            // from a sender that doesn't match the existing binding --
886            // nothing changes (module doc; `merge_inbound_session`'s own
887            // doc for the binding-never-changes rule).
888            InboundMergeOutcome::Kept | InboundMergeOutcome::IgnoredSenderMismatch => Ok(()),
889        }
890    }
891
892    /// Accepts an `m.forwarded_room_key` already known to have arrived over
893    /// an Olm-decrypted to-device event. The exported key is imported, not
894    /// parsed as an outbound [`SessionKey`]. `bound_*` is the original
895    /// Megolm sender the caller resolved, not merely whoever forwarded.
896    /// Returns whether a new or improved session was stored.
897    pub fn receive_forwarded_room_key<S: CryptoStore>(
898        store: &mut S,
899        bound_user_id: &UserId,
900        bound_curve25519: Curve25519PublicKey,
901        bound_ed25519: Ed25519PublicKey,
902        content: &ForwardedRoomKeyContent,
903    ) -> Result<bool, MessengerError> {
904        if content.algorithm != MEGOLM_ALGORITHM {
905            return Ok(false);
906        }
907        let exported = ExportedSessionKey::from_base64(&content.session_key)
908            .map_err(|source| MessengerError::Crypto(format!("malformed forwarded Megolm session_key: {source}")))?;
909        let incoming = InboundGroupSession::import(&exported, SessionConfig::version_1());
910        if incoming.session_id() != content.session_id {
911            return Ok(false);
912        }
913        let existing = load_inbound_record(store, &content.room_id, &content.session_id)?;
914        match merge_inbound_session(existing, incoming, bound_user_id, bound_curve25519, bound_ed25519) {
915            InboundMergeOutcome::New { record } | InboundMergeOutcome::Merged { record } => {
916                save_inbound_record(store, &content.room_id, &content.session_id, &record)?;
917                Ok(true)
918            }
919            InboundMergeOutcome::Kept | InboundMergeOutcome::IgnoredSenderMismatch => Ok(false),
920        }
921    }
922
923    /// Decrypts one `m.room.encrypted` (Megolm) room event. See the
924    /// module doc's "Inbound" section for the exact check order.
925    pub fn decrypt_event<S: CryptoStore>(
926        store: &mut S,
927        room_id: &RoomId,
928        event_id: &EventId,
929        origin_server_ts: i64,
930        sender: &UserId,
931        content: &MegolmEncryptedContent,
932    ) -> Result<RoomEventPlaintext, GroupDecryptError> {
933        let bytes = store.inbound_group_session(room_id, &content.session_id)?.map(<[u8]>::to_vec).ok_or_else(|| {
934            GroupDecryptError::MissingSession { room_id: room_id.clone(), session_id: content.session_id.clone() }
935        })?;
936        let mut record: InboundGroupSessionRecord = serde_json::from_slice(&bytes)?;
937        let mut session = InboundGroupSession::from_pickle(record.session);
938
939        let message = MegolmMessage::from_base64(&content.ciphertext)?;
940        let decrypted = session.decrypt(&message)?;
941        let plaintext: RoomEventPlaintext = serde_json::from_slice(&decrypted.plaintext)?;
942
943        if plaintext.room_id != *room_id {
944            return Err(GroupDecryptError::WrongRoom {
945                event_room_id: room_id.clone(),
946                plaintext_room_id: plaintext.room_id,
947            });
948        }
949        if record.bound_user_id != *sender {
950            return Err(GroupDecryptError::WrongSender {
951                bound_user_id: record.bound_user_id,
952                event_sender: sender.clone(),
953            });
954        }
955
956        match record.replay_index.get(&decrypted.message_index) {
957            Some(seen) if seen.event_id != *event_id || seen.origin_server_ts != origin_server_ts => {
958                return Err(GroupDecryptError::Replay {
959                    session_id: content.session_id.clone(),
960                    message_index: decrypted.message_index,
961                });
962            }
963            Some(_) => {
964                // The exact same event, redelivered -- fine (module doc).
965            }
966            None => {
967                record
968                    .replay_index
969                    .insert(decrypted.message_index, ReplayEntry { event_id: event_id.clone(), origin_server_ts });
970                record.session = session.pickle();
971                let bytes = serde_json::to_vec(&record)?;
972                store.save_inbound_group_session(room_id, &content.session_id, bytes)?;
973            }
974        }
975
976        Ok(plaintext)
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use crate::room::timeline::{ItemContent, Timeline};
984    use crate::store::{InsecurePlainCodecForTests, Store};
985    use crate::wire::events::RawEvent;
986    use mail4agent_vodozemac::olm::Account;
987
988    fn new_store() -> Store<InsecurePlainCodecForTests> {
989        Store::new(DeviceId::parse("DEV").expect("valid device id"), InsecurePlainCodecForTests)
990    }
991
992    fn user(name: &str) -> UserId {
993        UserId::parse(format!("@{name}:example.org")).expect("valid user id")
994    }
995
996    fn device(name: &str) -> DeviceId {
997        DeviceId::parse(name).expect("valid device id")
998    }
999
1000    fn room(name: &str) -> RoomId {
1001        RoomId::parse(format!("!{name}:example.org")).expect("valid room id")
1002    }
1003
1004    fn stored_device(user_id: &UserId, device_id: &DeviceId) -> StoredDevice {
1005        let identity = Account::new().identity_keys();
1006        StoredDevice {
1007            user_id: user_id.clone(),
1008            device_id: device_id.clone(),
1009            curve25519: identity.curve25519,
1010            ed25519: identity.ed25519,
1011            algorithms: vec!["m.olm.v1.curve25519-aes-sha2".to_string(), "m.megolm.v1.aes-sha2".to_string()],
1012            display_name: None,
1013            verified: false,
1014            blocked: false,
1015        }
1016    }
1017
1018    fn default_encryption() -> RoomEncryptionContent {
1019        RoomEncryptionContent {
1020            algorithm: MEGOLM_ALGORITHM.to_string(),
1021            rotation_period_ms: 604_800_000,
1022            rotation_period_msgs: 100,
1023        }
1024    }
1025
1026    fn plaintext_bytes(room_id: &RoomId, body: &str) -> Vec<u8> {
1027        serde_json::to_vec(&RoomEventPlaintext {
1028            event_type: "m.room.message".to_string(),
1029            content: serde_json::json!({ "body": body }),
1030            room_id: room_id.clone(),
1031        })
1032        .expect("valid JSON")
1033    }
1034
1035    #[test]
1036    fn outbound_session_rotates_after_rotation_period_msgs() {
1037        let mut store = new_store();
1038        let room_id = room("r");
1039        let alice = user("alice");
1040        let alice_dev = device("ALICEDEV");
1041        let bob = user("bob");
1042        let bob_device = stored_device(&bob, &device("BOBDEV"));
1043        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1044        let known = vec![bob_device];
1045        let encryption =
1046            RoomEncryptionContent { algorithm: MEGOLM_ALGORITHM.to_string(), rotation_period_ms: 604_800_000, rotation_period_msgs: 2 };
1047
1048        let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
1049            .expect("create session");
1050        assert!(first.rotated);
1051        assert!(first.room_key_content.is_some());
1052
1053        GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
1054            .expect("encrypt 1");
1055        GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
1056            .expect("encrypt 2");
1057
1058        let second = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
1059            .expect("re-check");
1060        assert!(second.rotated, "message-count threshold reached");
1061        assert_ne!(second.session_id, first.session_id);
1062    }
1063
1064    #[test]
1065    fn outbound_session_rotates_after_rotation_period_ms() {
1066        let mut store = new_store();
1067        let room_id = room("r");
1068        let alice = user("alice");
1069        let alice_dev = device("ALICEDEV");
1070        let bob = user("bob");
1071        let bob_device = stored_device(&bob, &device("BOBDEV"));
1072        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1073        let known = vec![bob_device];
1074        let encryption =
1075            RoomEncryptionContent { algorithm: MEGOLM_ALGORITHM.to_string(), rotation_period_ms: 1_000, rotation_period_msgs: 100 };
1076
1077        let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
1078            .expect("create session");
1079
1080        let not_yet =
1081            GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 500)
1082                .expect("re-check before the period elapses");
1083        assert!(!not_yet.rotated, "rotation_period_ms has not elapsed yet");
1084        assert_eq!(not_yet.session_id, first.session_id);
1085
1086        let elapsed =
1087            GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 1_000)
1088                .expect("re-check after the period elapses");
1089        assert!(elapsed.rotated, "rotation_period_ms has elapsed");
1090        assert_ne!(elapsed.session_id, first.session_id);
1091    }
1092
1093    #[test]
1094    fn outbound_session_rotates_on_member_removed() {
1095        let mut store = new_store();
1096        let room_id = room("r");
1097        let alice = user("alice");
1098        let alice_dev = device("ALICEDEV");
1099        let bob = user("bob");
1100        let bob_device = stored_device(&bob, &device("BOBDEV"));
1101        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1102        let known = vec![bob_device];
1103        let encryption = default_encryption();
1104
1105        let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
1106            .expect("create+share");
1107
1108        let members_after: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
1109        let second =
1110            GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members_after, &known, 0)
1111                .expect("bob removed");
1112        assert!(second.rotated, "a previously-shared user no longer being a member forces rotation");
1113        assert_ne!(second.session_id, first.session_id);
1114        assert!(second.new_recipients.is_empty(), "bob is no longer eligible, nothing new to share");
1115    }
1116
1117    #[test]
1118    fn outbound_session_rotates_when_a_shared_device_disappears() {
1119        let mut store = new_store();
1120        let room_id = room("r");
1121        let alice = user("alice");
1122        let alice_dev = device("ALICEDEV");
1123        let bob = user("bob");
1124        let bob_old_device = stored_device(&bob, &device("BOBOLD"));
1125        let bob_new_device = stored_device(&bob, &device("BOBNEW"));
1126        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1127        let encryption = default_encryption();
1128
1129        let first = GroupSessionManager::ensure_outbound_session(
1130            &mut store,
1131            &room_id,
1132            &alice,
1133            &alice_dev,
1134            &encryption,
1135            &members,
1136            std::slice::from_ref(&bob_old_device),
1137            0,
1138        )
1139        .expect("create+share with the old device");
1140
1141        // Bob's old device disappeared from the tracked device list
1142        // entirely (deleted/logged out) -- only the new one is known now.
1143        let second = GroupSessionManager::ensure_outbound_session(
1144            &mut store,
1145            &room_id,
1146            &alice,
1147            &alice_dev,
1148            &encryption,
1149            &members,
1150            std::slice::from_ref(&bob_new_device),
1151            0,
1152        )
1153        .expect("old device gone");
1154        assert!(second.rotated, "a previously-shared device disappearing forces rotation");
1155        assert_ne!(second.session_id, first.session_id);
1156        assert_eq!(second.new_recipients, vec![bob_new_device]);
1157    }
1158
1159    #[test]
1160    fn new_device_gets_current_session_without_rotation() {
1161        let mut store = new_store();
1162        let room_id = room("r");
1163        let alice = user("alice");
1164        let alice_dev = device("ALICEDEV");
1165        let bob = user("bob");
1166        let bob_device_1 = stored_device(&bob, &device("BOBDEV1"));
1167        let bob_device_2 = stored_device(&bob, &device("BOBDEV2"));
1168        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1169        let encryption = default_encryption();
1170
1171        let first = GroupSessionManager::ensure_outbound_session(
1172            &mut store,
1173            &room_id,
1174            &alice,
1175            &alice_dev,
1176            &encryption,
1177            &members,
1178            std::slice::from_ref(&bob_device_1),
1179            0,
1180        )
1181        .expect("create+share with device 1");
1182
1183        GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
1184            .expect("encrypt one message, advancing the ratchet to index 1");
1185
1186        let known = vec![bob_device_1.clone(), bob_device_2.clone()];
1187        let second = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
1188            .expect("device 2 appears");
1189        assert!(!second.rotated, "a new device of an already-shared member never forces rotation");
1190        assert_eq!(second.session_id, first.session_id);
1191        assert_eq!(second.new_recipients, vec![bob_device_2]);
1192
1193        // The share reflects the CURRENT ratchet position (index 1), not
1194        // the initial one -- device 2 cannot read message 0.
1195        let shared = second.room_key_content.expect("something to share with device 2");
1196        let session_key = SessionKey::from_base64(&shared.session_key).expect("valid session key");
1197        let inbound = InboundGroupSession::new(&session_key, SessionConfig::version_1());
1198        assert_eq!(inbound.first_known_index(), 1);
1199    }
1200
1201    #[test]
1202    fn room_key_from_a_room_event_is_ignored() {
1203        // A room-timeline-shaped `m.room_key` is never treated as key
1204        // material anywhere in this crate: `room::timeline` does not
1205        // special-case it (falls through to `ItemContent::Unknown`), and
1206        // `GroupSessionManager`'s only room-key intake function
1207        // (`receive_room_key`/`accept_room_key_from_to_device`) takes an
1208        // Olm-verified sender identity or a `DecryptedToDevice` -- neither
1209        // of which a bare room event can ever produce.
1210        let event: RawEvent = serde_json::from_value(serde_json::json!({
1211            "event_id": "$x:example.org",
1212            "type": "m.room_key",
1213            "sender": "@mallory:example.org",
1214            "origin_server_ts": 1,
1215            "content": {
1216                "algorithm": "m.megolm.v1.aes-sha2",
1217                "room_id": "!r:example.org",
1218                "session_id": "abc",
1219                "session_key": "irrelevant"
1220            }
1221        }))
1222        .expect("valid raw event shape");
1223
1224        let mut timeline = Timeline::new();
1225        timeline.apply_timeline_batch(&[event], false, None);
1226
1227        assert_eq!(timeline.items().len(), 1);
1228        assert_eq!(timeline.items()[0].content, ItemContent::Unknown);
1229    }
1230
1231    #[test]
1232    fn decrypted_payload_for_another_room_is_rejected() {
1233        let mut alice_store = new_store();
1234        let room_a = room("a");
1235        let room_b = room("b");
1236        let alice = user("alice");
1237        let alice_dev = device("ALICEDEV");
1238        let bob = user("bob");
1239        let bob_device = stored_device(&bob, &device("BOBDEV"));
1240        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1241        let encryption = default_encryption();
1242        let identity = Account::new().identity_keys();
1243
1244        let update = GroupSessionManager::ensure_outbound_session(
1245            &mut alice_store,
1246            &room_a,
1247            &alice,
1248            &alice_dev,
1249            &encryption,
1250            &members,
1251            std::slice::from_ref(&bob_device),
1252            0,
1253        )
1254        .expect("create+share");
1255        let mut room_key_content = update.room_key_content.expect("bob is a new recipient");
1256
1257        let encrypted = GroupSessionManager::encrypt_event(
1258            &mut alice_store,
1259            &room_a,
1260            &identity.curve25519.to_base64(),
1261            &alice_dev,
1262            "m.room.message",
1263            serde_json::json!({ "body": "hi" }),
1264            None,
1265        )
1266        .expect("encrypt under room A");
1267
1268        // Bob's client is fed the room key mislabelled as belonging to
1269        // room B -- the only way it could ever end up filing a genuine
1270        // room-A ciphertext under a session it believes is room B's.
1271        room_key_content.room_id = room_b.clone();
1272        let mut bob_store = new_store();
1273        GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
1274            .expect("receive");
1275
1276        let event_id = EventId::parse("$evt:example.org").expect("valid event id");
1277        let result = GroupSessionManager::decrypt_event(&mut bob_store, &room_b, &event_id, 1, &alice, &encrypted);
1278        assert!(matches!(result, Err(GroupDecryptError::WrongRoom { .. })), "got {result:?}");
1279    }
1280
1281    #[test]
1282    fn session_cannot_authenticate_another_sender() {
1283        let mut alice_store = new_store();
1284        let room_id = room("r");
1285        let alice = user("alice");
1286        let alice_dev = device("ALICEDEV");
1287        let mallory = user("mallory");
1288        let bob = user("bob");
1289        let bob_device = stored_device(&bob, &device("BOBDEV"));
1290        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1291        let encryption = default_encryption();
1292        let identity = Account::new().identity_keys();
1293
1294        let update = GroupSessionManager::ensure_outbound_session(
1295            &mut alice_store,
1296            &room_id,
1297            &alice,
1298            &alice_dev,
1299            &encryption,
1300            &members,
1301            std::slice::from_ref(&bob_device),
1302            0,
1303        )
1304        .expect("create+share");
1305        let room_key_content = update.room_key_content.expect("bob is a new recipient");
1306
1307        let mut bob_store = new_store();
1308        GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
1309            .expect("bob genuinely received this key from alice");
1310
1311        let encrypted = GroupSessionManager::encrypt_event(
1312            &mut alice_store,
1313            &room_id,
1314            &identity.curve25519.to_base64(),
1315            &alice_dev,
1316            "m.room.message",
1317            serde_json::json!({ "body": "hi" }),
1318            None,
1319        )
1320        .expect("encrypt");
1321
1322        // The room event itself claims mallory as the sender.
1323        let event_id = EventId::parse("$evt:example.org").expect("valid event id");
1324        let result = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &mallory, &encrypted);
1325        assert!(matches!(result, Err(GroupDecryptError::WrongSender { .. })), "got {result:?}");
1326    }
1327
1328    #[test]
1329    fn inbound_session_rejects_replayed_message_index() {
1330        let mut alice_store = new_store();
1331        let room_id = room("r");
1332        let alice = user("alice");
1333        let alice_dev = device("ALICEDEV");
1334        let bob = user("bob");
1335        let bob_device = stored_device(&bob, &device("BOBDEV"));
1336        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1337        let encryption = default_encryption();
1338        let identity = Account::new().identity_keys();
1339
1340        let update = GroupSessionManager::ensure_outbound_session(
1341            &mut alice_store,
1342            &room_id,
1343            &alice,
1344            &alice_dev,
1345            &encryption,
1346            &members,
1347            std::slice::from_ref(&bob_device),
1348            0,
1349        )
1350        .expect("create+share");
1351        let room_key_content = update.room_key_content.expect("bob is a new recipient");
1352
1353        let mut bob_store = new_store();
1354        GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
1355            .expect("receive");
1356
1357        let encrypted = GroupSessionManager::encrypt_event(
1358            &mut alice_store,
1359            &room_id,
1360            &identity.curve25519.to_base64(),
1361            &alice_dev,
1362            "m.room.message",
1363            serde_json::json!({ "body": "hi" }),
1364            None,
1365        )
1366        .expect("encrypt");
1367
1368        let event_id_1 = EventId::parse("$e1:example.org").expect("valid event id");
1369        let first = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 100, &alice, &encrypted)
1370            .expect("first decrypt succeeds");
1371        assert_eq!(first.content, serde_json::json!({ "body": "hi" }));
1372
1373        let redelivered = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 100, &alice, &encrypted)
1374            .expect("redelivery of the exact same event is not a replay");
1375        assert_eq!(redelivered.content, serde_json::json!({ "body": "hi" }));
1376
1377        let event_id_2 = EventId::parse("$e2:example.org").expect("valid event id");
1378        let replay = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_2, 200, &alice, &encrypted);
1379        assert!(matches!(replay, Err(GroupDecryptError::Replay { .. })), "got {replay:?}");
1380    }
1381
1382    #[test]
1383    fn later_share_does_not_replace_earlier_index() {
1384        let alice = user("alice");
1385        let identity = Account::new().identity_keys();
1386        let room_id = room("r");
1387
1388        let mut group_session = GroupSession::new(SessionConfig::version_1());
1389        let key_at_0 = group_session.session_key();
1390        let message_0 = group_session.encrypt(plaintext_bytes(&room_id, "msg0"));
1391        let key_at_1 = group_session.session_key();
1392
1393        let content_at_1 = RoomKeyContent {
1394            algorithm: MEGOLM_ALGORITHM.to_string(),
1395            room_id: room_id.clone(),
1396            session_id: group_session.session_id(),
1397            session_key: key_at_1.to_base64(),
1398        };
1399        let content_at_0 = RoomKeyContent { session_key: key_at_0.to_base64(), ..content_at_1.clone() };
1400
1401        let mut bob_store = new_store();
1402        GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &content_at_1)
1403            .expect("receive the later (higher-index) share first");
1404        GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &content_at_0)
1405            .expect("receive the earlier (lower-index) share second");
1406
1407        let encrypted_0 = MegolmEncryptedContent {
1408            ciphertext: message_0.to_base64(),
1409            session_id: group_session.session_id(),
1410            sender_key: None,
1411            device_id: None,
1412            relates_to: None,
1413        };
1414        let event_id = EventId::parse("$evt0:example.org").expect("valid event id");
1415        let decrypted = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &alice, &encrypted_0)
1416            .expect("the earlier share's lower index must have won, so message 0 is decryptable");
1417        assert_eq!(decrypted.content, serde_json::json!({ "body": "msg0" }));
1418    }
1419
1420    #[test]
1421    fn lower_index_share_from_a_different_sender_does_not_rebind() {
1422        let alice = user("alice");
1423        let mallory = user("mallory");
1424        let alice_identity = Account::new().identity_keys();
1425        let mallory_identity = Account::new().identity_keys();
1426        let room_id = room("r");
1427
1428        let mut group_session = GroupSession::new(SessionConfig::version_1());
1429        // Captured BEFORE encrypting -- the index-0 key mallory will
1430        // (falsely) claim to have shared. `message_0` (index 0) stays
1431        // unreachable from the record alice's share below actually
1432        // creates; `message_1` (index 1) is what that record CAN decrypt,
1433        // and is what proves the binding itself never moved to mallory.
1434        let key_at_0 = group_session.session_key();
1435        let message_0 = group_session.encrypt(plaintext_bytes(&room_id, "msg0"));
1436        let key_at_1 = group_session.session_key();
1437        let message_1 = group_session.encrypt(plaintext_bytes(&room_id, "msg1"));
1438
1439        let content_at_1 = RoomKeyContent {
1440            algorithm: MEGOLM_ALGORITHM.to_string(),
1441            room_id: room_id.clone(),
1442            session_id: group_session.session_id(),
1443            session_key: key_at_1.to_base64(),
1444        };
1445        let content_at_0 = RoomKeyContent { session_key: key_at_0.to_base64(), ..content_at_1.clone() };
1446
1447        let mut bob_store = new_store();
1448        // Alice genuinely shares at index 1 first -- this binds the record
1449        // to alice.
1450        GroupSessionManager::receive_room_key(&mut bob_store, &alice, alice_identity.curve25519, alice_identity.ed25519, &content_at_1)
1451            .expect("receive alice's genuine share at index 1");
1452
1453        // A share for the SAME session id, connected and at a genuinely
1454        // lower index, now arrives claiming to be from mallory instead --
1455        // must be ignored outright: no error, but no rebind and no merge.
1456        GroupSessionManager::receive_room_key(
1457            &mut bob_store,
1458            &mallory,
1459            mallory_identity.curve25519,
1460            mallory_identity.ed25519,
1461            &content_at_0,
1462        )
1463        .expect("receiving mallory's share is not itself an error -- it's just ignored");
1464
1465        // The record is still bound to alice, not mallory: an event at
1466        // index 1 (which the record CAN decrypt) claiming mallory as
1467        // sender is rejected on that binding, not on a missing ratchet
1468        // key.
1469        let encrypted_1 = MegolmEncryptedContent {
1470            ciphertext: message_1.to_base64(),
1471            session_id: group_session.session_id(),
1472            sender_key: None,
1473            device_id: None,
1474            relates_to: None,
1475        };
1476        let event_id_1 = EventId::parse("$evt1:example.org").expect("valid event id");
1477        let claimed_mallory = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 1, &mallory, &encrypted_1);
1478        assert!(
1479            matches!(claimed_mallory, Err(GroupDecryptError::WrongSender { .. })),
1480            "record is still bound to alice, not mallory: {claimed_mallory:?}"
1481        );
1482        let claimed_alice = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 1, &alice, &encrypted_1)
1483            .expect("the record is genuinely bound to alice, so an event she sent decrypts fine");
1484        assert_eq!(claimed_alice.content, serde_json::json!({ "body": "msg1" }));
1485
1486        // The record's ratchet is also still at index 1 -- mallory's
1487        // (ignored) lower-index share never merged in, so message 0 (only
1488        // decryptable from index 0) is still unreachable.
1489        let encrypted_0 = MegolmEncryptedContent {
1490            ciphertext: message_0.to_base64(),
1491            session_id: group_session.session_id(),
1492            sender_key: None,
1493            device_id: None,
1494            relates_to: None,
1495        };
1496        let event_id_0 = EventId::parse("$evt0:example.org").expect("valid event id");
1497        let still_index_1 = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_0, 2, &alice, &encrypted_0);
1498        assert!(
1499            matches!(still_index_1, Err(GroupDecryptError::Vodozemac(MegolmDecryptionError::UnknownMessageIndex(..)))),
1500            "the ratchet never advanced to index 0: {still_index_1:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn old_record_without_backed_up_field_deserializes() {
1506        let mut store = new_store();
1507        let room_id = room("r");
1508        let alice = user("alice");
1509        let alice_dev = device("ALICEDEV");
1510        let bob = user("bob");
1511        let bob_device = stored_device(&bob, &device("BOBDEV"));
1512        let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
1513        let encryption = default_encryption();
1514        let identity = Account::new().identity_keys();
1515
1516        let mut alice_store = new_store();
1517        let update = GroupSessionManager::ensure_outbound_session(
1518            &mut alice_store,
1519            &room_id,
1520            &alice,
1521            &alice_dev,
1522            &encryption,
1523            &members,
1524            std::slice::from_ref(&bob_device),
1525            0,
1526        )
1527        .expect("create+share");
1528        let room_key_content = update.room_key_content.expect("bob is a new recipient");
1529
1530        GroupSessionManager::receive_room_key(&mut store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
1531            .expect("receive");
1532
1533        let bytes = store
1534            .inbound_group_session(&room_id, &room_key_content.session_id)
1535            .expect("no error")
1536            .expect("record exists");
1537
1538        // Simulate a record persisted BEFORE the `backed_up` field existed
1539        // at all -- an old writer never wrote the key, not even as `null`.
1540        let mut value: serde_json::Value = serde_json::from_slice(bytes).expect("valid JSON");
1541        value.as_object_mut().expect("a JSON object").remove("backed_up");
1542        let old_bytes = serde_json::to_vec(&value).expect("valid JSON");
1543
1544        let record: InboundGroupSessionRecord =
1545            serde_json::from_slice(&old_bytes).expect("a record without `backed_up` at all still deserializes");
1546        assert_eq!(record.backed_up, None);
1547        assert_eq!(record.bound_user_id, alice);
1548    }
1549
1550    #[test]
1551    fn the_author_decrypts_their_own_message_only_through_the_adopted_outbound_session() {
1552        let mut store = new_store();
1553        let room_id = room("r");
1554        let alice = user("alice");
1555        let alice_dev = device("ALICEDEV");
1556        let members: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
1557        let identity = Account::new().identity_keys();
1558        let event_id = EventId::parse("$own:example.org").expect("valid event id");
1559
1560        let update =
1561            GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &default_encryption(), &members, &[], 0)
1562                .expect("create session");
1563        assert!(update.rotated);
1564        let encrypted = GroupSessionManager::encrypt_event(
1565            &mut store,
1566            &room_id,
1567            &identity.curve25519.to_base64(),
1568            &alice_dev,
1569            "m.room.message",
1570            serde_json::json!({ "body": "mine" }),
1571            None,
1572        )
1573        .expect("encrypt");
1574
1575        // The server echoes the ciphertext back; nothing ever shared the
1576        // session key to its own creator, so it is undecryptable until adopted.
1577        let before = GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted);
1578        assert!(matches!(before, Err(GroupDecryptError::MissingSession { .. })), "got {before:?}");
1579
1580        GroupSessionManager::adopt_own_outbound_session(&mut store, &room_id, &alice, identity.curve25519, identity.ed25519, &update)
1581            .expect("adopt");
1582        let plaintext = GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted).expect("the author decrypts");
1583        assert_eq!(plaintext.content, serde_json::json!({ "body": "mine" }));
1584
1585        // A later call that did not rotate must not touch the adopted copy.
1586        let again =
1587            GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &default_encryption(), &members, &[], 1)
1588                .expect("re-check");
1589        assert!(!again.rotated);
1590        GroupSessionManager::adopt_own_outbound_session(&mut store, &room_id, &alice, identity.curve25519, identity.ed25519, &again)
1591            .expect("no-op");
1592        GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted).expect("still decrypts");
1593    }
1594
1595    #[test]
1596    fn a_forwarded_inbound_session_decrypts_the_original_senders_later_event() {
1597        let mut alice_store = new_store();
1598        let mut bob_store = new_store();
1599        let room_id = room("fwd");
1600        let alice = user("alice");
1601        let alice_dev = device("ALICEDEV");
1602        let members: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
1603        let identity = Account::new().identity_keys();
1604        let event_id = EventId::parse("$fwd:example.org").expect("valid event id");
1605
1606        let update = GroupSessionManager::ensure_outbound_session(
1607            &mut alice_store,
1608            &room_id,
1609            &alice,
1610            &alice_dev,
1611            &default_encryption(),
1612            &members,
1613            &[],
1614            0,
1615        )
1616        .expect("create");
1617        GroupSessionManager::adopt_own_outbound_session(
1618            &mut alice_store,
1619            &room_id,
1620            &alice,
1621            identity.curve25519,
1622            identity.ed25519,
1623            &update,
1624        )
1625        .expect("adopt");
1626        let encrypted = GroupSessionManager::encrypt_event(
1627            &mut alice_store,
1628            &room_id,
1629            &identity.curve25519.to_base64(),
1630            &alice_dev,
1631            "m.room.message",
1632            serde_json::json!({ "body": "later" }),
1633            None,
1634        )
1635        .expect("encrypt");
1636
1637        let forwarded = GroupSessionManager::forwarded_room_key_content(
1638            &alice_store,
1639            &room_id,
1640            &encrypted.session_id,
1641            &identity.curve25519.to_base64(),
1642        )
1643        .expect("export")
1644        .expect("inbound exists at the first index");
1645        let stored = GroupSessionManager::receive_forwarded_room_key(
1646            &mut bob_store,
1647            &alice,
1648            identity.curve25519,
1649            identity.ed25519,
1650            &forwarded,
1651        )
1652        .expect("import");
1653        assert!(stored);
1654
1655        let plaintext =
1656            GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &alice, &encrypted).expect("bob decrypts");
1657        assert_eq!(plaintext.content, serde_json::json!({ "body": "later" }));
1658    }
1659}