Skip to main content

mail4agent_messenger/crypto/
olm_sessions.rs

1//! [`OlmSessionManager`] -- per-`(user, device)` Olm 1:1 sessions:
2//! `/keys/claim`, to-device encryption/decryption, and the payload-binding
3//! checks that keep a forwarded/injected key from being trusted (plan §3
4//! `crypto/olm_sessions.rs`; research doc §3.3's security note, §4.5's
5//! "Olm session desync/corruption" pitfall).
6//!
7//! # Session storage: one record per session id, capped and evicted
8//!
9//! [`crate::store::CryptoStore::save_olm_session`] (a prior piece) takes a
10//! `session_id` and REPLACES that session's own record -- it never keeps a
11//! superseded ratchet snapshot around. This is a hard security property,
12//! not a space optimization: an Olm session pickle IS its full ratchet
13//! state, including every chain/message key it has derived and not yet
14//! forgotten, so an un-replaced older snapshot of the SAME session id would
15//! be a live forward-secrecy leak (an already-consumed message key
16//! surviving somewhere the session's own current state has forgotten it)
17//! and a replay vector (that stale snapshot could decrypt a message its
18//! later self has already consumed and would now correctly refuse). The
19//! store also caps how many DISTINCT session ids it keeps per peer curve
20//! key, evicting the least-recently-used one -- see
21//! [`crate::store::CryptoStore::save_olm_session`]'s own doc.
22//!
23//! With that guarantee in place, this module's own job on both sides is
24//! simple, no bookkeeping of its own required:
25//!
26//! - **Encrypting** ("the most recently used session for that device", plan
27//!   §3) is the store's own first-returned entry --
28//!   [`crate::store::CryptoStore::olm_sessions_for_device`] already orders
29//!   its result most-recently-used first.
30//! - **Decrypting** ("try each known session, newest first", plan §3) is a
31//!   plain walk of that same list, in that same order -- at most one
32//!   candidate per distinct session id, each one already the ONLY
33//!   snapshot the store keeps for it, so there is no older, not-yet-
34//!   consumed copy of an already-superseded state left anywhere to
35//!   accidentally resurrect. This is what makes the "a pre-key message for
36//!   an EXISTING session replays through that session, never a second
37//!   inbound session" rule true safely: [`mail4agent_vodozemac::olm::Session::
38//!   decrypt`] does not care whether a message is tagged pre-key or
39//!   normal, it only needs a session whose receiving chains still cover
40//!   that message's ratchet position -- and since only one, current
41//!   snapshot of that session ever exists, its chains always reflect
42//!   everything this module has ever actually consumed from it.
43//!
44//! # Every mutation is persisted before this module returns
45//!
46//! Every path that advances a session's ratchet -- encrypting, decrypting
47//! through an existing session, or establishing a brand-new inbound
48//! session -- calls [`crate::store::CryptoStore::save_olm_session`] (and,
49//! for a new inbound session, [`OlmAccountState::create_inbound_session`]
50//! persists the OTK-consuming account mutation too) before returning the
51//! plaintext or ciphertext to the caller. [`crate::persist::FlushEpoch`]
52//! (a later piece, `outgoing_queue`, M12) is what actually enforces the
53//! barrier -- this module's job is only to make sure it always has
54//! something to enforce: the mutation is marked dirty here, in the same
55//! synchronous call, before any ciphertext that depended on it could leave
56//! the process. Because this crate has no concurrency (`lib.rs`'s own
57//! module doc), encryption for one device pair is sequential by
58//! construction -- there is no interleaving that could reuse a message key
59//! across two different plaintexts, which is exactly the "sending two Olm
60//! messages at the same ratchet position" corruption class research doc
61//! §4.5 warns about.
62//!
63//! # What "out of order" means here, and what a replay looks like
64//!
65//! vodozemac's receiving ratchet tolerates a message arriving out of send
66//! order by stashing skipped message keys (up to its own internal limits)
67//! and consuming the matching one the moment the corresponding message
68//! actually arrives (`mail4agent_vodozemac`'s own `receiver_chain` module). Once a
69//! message key has been used -- whether it was consumed in order or pulled
70//! from the skipped stash -- it is deleted from that session's CURRENT
71//! state, and the store's replace-not-append contract (above) means that
72//! state IS the only copy left anywhere; decrypting the SAME ciphertext
73//! bytes again always fails, first against every already-established
74//! session for that sender (a `DecryptionError` here is simply "try the
75//! next distinct session"), and then -- since a replayed message is still
76//! tagged pre-key if the original was -- against [`OlmAccountState::
77//! create_inbound_session`], which fails too, because the one-time key
78//! that pre-key message names was already consumed the first time a
79//! message from that same session established it. Either way, the net,
80//! externally observable result [`OlmSessionManager::decrypt_to_device`]
81//! gives a caller is an `Err`, never a second copy of the plaintext -- and
82//! this holds even after a full unload/reload of the store from its
83//! persisted records, since there is never a stale snapshot in those
84//! records to reload in the first place.
85//!
86//! # Payload binding
87//!
88//! [`OlmSessionManager::decrypt_to_device`] rejects a successfully-
89//! decrypted plaintext whose JSON payload does not bind to what the outer
90//! event and this account's own identity claim: `recipient` must be this
91//! account's own user id, `recipient_keys.ed25519` must be this account's
92//! own Ed25519 key, `sender` must equal the to-device event's own `sender`
93//! field, and `keys.ed25519` must equal the STORED Ed25519 key of the
94//! device whose Curve25519 key is the envelope's `sender_key` -- never the
95//! payload's own say-so. A `sender_key` this account has no
96//! [`StoredDevice`] for at all marks that user outdated and returns
97//! [`OlmDecryptError::UnknownSenderDevice`] so a caller can retry after a
98//! `/keys/query` round trip, rather than silently trusting an unverified
99//! claim. That lookup runs BEFORE any session is touched (it needs only the
100//! event's own `sender` and the envelope's `sender_key`, both readable
101//! without decrypting): a pre-key message from a not-yet-known device must
102//! not consume its one-time key or advance a ratchet, or the retry after
103//! the `/keys/query` would be a replay of already-consumed state and the
104//! room key inside it would be lost for good.
105
106use crate::canonical_json;
107use crate::crypto::account::OlmAccountState;
108use crate::crypto::device_tracker::{DeviceTracker, StoredDevice};
109use crate::error::MessengerError;
110use crate::ids::{DeviceId, RequestId, UserId};
111use crate::store::CryptoStore;
112use crate::wire::{OlmCiphertextInfo, OlmEncryptedContent, OutgoingRequest};
113use base64::Engine;
114use mail4agent_vodozemac::olm::{OlmMessage, Session, SessionConfig, SessionPickle};
115use mail4agent_vodozemac::{Curve25519PublicKey, Ed25519PublicKey};
116use serde::{Deserialize, Serialize};
117use std::collections::BTreeMap;
118
119/// The `signed_curve25519` key-type name `/keys/claim` uses (research doc
120/// §3.2) -- this crate has exactly one one-time-key algorithm in scope.
121const SIGNED_CURVE25519: &str = "signed_curve25519";
122
123/// Base64 codec for wire-transported ciphertext/key bytes: unpadded
124/// standard alphabet on encode, tolerant of an unexpected padding on
125/// decode -- matches Matrix's own wire convention (and `mail4agent_vodozemac`'s
126/// own internal pickle/message encoding), kept as this crate's own
127/// independent wire-layer codec rather than reusing `mail4agent_vodozemac`'s pub
128/// helpers, which exist for its pickle format, not as a cross-crate wire
129/// utility (see this crate's `Cargo.toml` note on the `base64` dependency).
130fn wire_base64_engine() -> base64::engine::GeneralPurpose {
131    base64::engine::GeneralPurpose::new(
132        &base64::alphabet::STANDARD,
133        base64::engine::general_purpose::NO_PAD
134            .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
135    )
136}
137
138fn pickle_session(session: &Session) -> Result<Vec<u8>, MessengerError> {
139    serde_json::to_vec(&session.pickle())
140        .map_err(|source| MessengerError::Crypto(format!("encode pickled Olm session: {source}")))
141}
142
143fn unpickle_session(bytes: &[u8]) -> Result<Session, MessengerError> {
144    let pickle: SessionPickle = serde_json::from_slice(bytes)
145        .map_err(|source| MessengerError::Crypto(format!("decode pickled Olm session: {source}")))?;
146    Ok(Session::from_pickle(pickle))
147}
148
149/// The Olm plaintext payload every to-device `m.room.encrypted` message
150/// carries (research doc §3.1's "Olm-wrapped" shape) -- built by
151/// [`OlmSessionManager::encrypt_to_device`], validated by
152/// [`OlmSessionManager::decrypt_to_device`].
153#[derive(Serialize, Deserialize)]
154struct OlmPlaintextPayload {
155    #[serde(rename = "type")]
156    event_type: String,
157    content: serde_json::Value,
158    sender: UserId,
159    sender_device: DeviceId,
160    keys: OlmPlaintextKeys,
161    recipient: UserId,
162    recipient_keys: OlmPlaintextKeys,
163}
164
165#[derive(Serialize, Deserialize)]
166struct OlmPlaintextKeys {
167    ed25519: String,
168}
169
170/// `/keys/claim`'s response shape, the parts this module reads (research
171/// doc §3.2): `one_time_keys.{user_id}.{device_id}.{key_id}` -> the
172/// claimed key's own signed JSON object.
173#[derive(Deserialize)]
174struct KeysClaimResponseBody {
175    #[serde(default)]
176    one_time_keys: BTreeMap<UserId, BTreeMap<DeviceId, BTreeMap<String, serde_json::Value>>>,
177}
178
179/// The result of applying a `/keys/claim` response.
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct KeysClaimOutcome {
182    /// Devices a fresh outbound session was established with.
183    pub established: Vec<(UserId, DeviceId)>,
184    /// Devices whose claimed key was refused -- never used to start a
185    /// session.
186    pub refused: Vec<KeysClaimRefusal>,
187}
188
189/// Why [`OlmSessionManager::on_keys_claim_response`] refused a claimed
190/// one-time key.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct KeysClaimRefusal {
193    /// The device the refused key was claimed for.
194    pub user_id: UserId,
195    /// The device the refused key was claimed for.
196    pub device_id: DeviceId,
197    /// Why it was refused.
198    pub reason: String,
199}
200
201/// A successfully decrypted and payload-validated to-device event (plan
202/// §3's field list).
203#[derive(Debug, Clone, PartialEq)]
204pub struct DecryptedToDevice {
205    /// The sender, as bound by the validated payload (equal to the outer
206    /// event's own `sender`).
207    pub sender: UserId,
208    /// The sending device's Curve25519 identity key.
209    pub sender_device_curve25519: Curve25519PublicKey,
210    /// The sending device's Ed25519 signing key.
211    pub sender_ed25519: Ed25519PublicKey,
212    /// The payload's own `type` (the inner to-device event type, e.g.
213    /// `"m.room_key"`).
214    pub event_type: String,
215    /// The payload's own `content`.
216    pub content: serde_json::Value,
217}
218
219/// Every way [`OlmSessionManager::decrypt_to_device`] can fail. Kept
220/// distinct from [`MessengerError`] (rather than folding into
221/// `MessengerError::Crypto`) because a caller must be able to tell
222/// [`OlmDecryptError::UnknownSenderDevice`] -- a retry-after-`/keys/query`
223/// condition, not a hard failure -- apart from every other variant.
224#[derive(Debug, thiserror::Error)]
225pub enum OlmDecryptError {
226    /// The envelope's `ciphertext` map has no entry keyed by this
227    /// account's own Curve25519 identity key -- this message was not
228    /// addressed to this device at all.
229    #[error("the Olm envelope has no ciphertext entry for this device's own curve25519 key")]
230    NotAddressedToUs,
231    /// The ciphertext body was not valid base64, or did not decode to a
232    /// well-formed Olm message.
233    #[error("malformed Olm ciphertext: {0}")]
234    MalformedCiphertext(String),
235    /// No known session for the sender's curve25519 key could decrypt this
236    /// message, and it was not a pre-key message (so no new session could
237    /// be established from it either).
238    #[error("no known Olm session for sender key {0} decrypted this message, and it is not a pre-key message")]
239    NoMatchingSession(String),
240    /// A pre-key message failed to establish a fresh inbound session --
241    /// e.g. the claimed one-time key is unknown to this account (already
242    /// used, or never ours; this is also the symptom of a replayed
243    /// pre-key message once its originating session already exists, see
244    /// the module doc).
245    #[error("failed to establish an inbound Olm session: {0}")]
246    SessionEstablishment(String),
247    /// The sender's `sender_key` (Curve25519) is not a device this
248    /// account has stored keys for yet. `user_id` has been marked
249    /// outdated; retry after a `/keys/query` round trip.
250    #[error("sender device for curve25519 key {curve25519} is not yet known to this account")]
251    UnknownSenderDevice {
252        /// The event's claimed sender.
253        user_id: UserId,
254        /// The unrecognized sender curve25519 key, base64.
255        curve25519: String,
256    },
257    /// The decrypted payload failed a binding check against the outer
258    /// event or this account's own identity.
259    #[error("payload binding check failed: {0}")]
260    PayloadMismatch(String),
261    /// The decrypted bytes were not the expected JSON payload shape.
262    #[error("decrypted payload was not valid JSON: {0}")]
263    InvalidPayload(#[from] serde_json::Error),
264    /// A [`crate::store::CryptoStore`] operation failed.
265    #[error("store: {0}")]
266    Store(#[from] crate::store::StoreError),
267    /// A [`MessengerError`] surfaced by another module this method calls
268    /// into (account persistence, device-list decode, ...).
269    #[error("{0}")]
270    Messenger(#[from] MessengerError),
271}
272
273/// Per-`(user, device)` Olm 1:1 session management. See the module doc for
274/// the storage-selection and payload-binding rules this type enforces.
275/// Carries no state of its own -- every method reads/writes through the
276/// caller's [`CryptoStore`] and [`OlmAccountState`].
277pub struct OlmSessionManager;
278
279impl OlmSessionManager {
280    /// Converts a vodozemac [`OlmMessage`] into this crate's wire shape.
281    pub fn encode_olm_message(message: &OlmMessage) -> OlmCiphertextInfo {
282        let (message_type, ciphertext) = message.to_parts();
283        OlmCiphertextInfo { message_type: message_type as u8, body: wire_base64_engine().encode(ciphertext) }
284    }
285
286    /// Reverses [`OlmSessionManager::encode_olm_message`].
287    pub fn decode_olm_message(info: &OlmCiphertextInfo) -> Result<OlmMessage, MessengerError> {
288        let bytes = wire_base64_engine()
289            .decode(&info.body)
290            .map_err(|source| MessengerError::Crypto(format!("malformed Olm ciphertext body: {source}")))?;
291        OlmMessage::from_parts(info.message_type as usize, &bytes)
292            .map_err(|source| MessengerError::Crypto(format!("malformed Olm message: {source}")))
293    }
294
295    /// Devices from `devices` this account has no Olm session with yet, in
296    /// input order.
297    pub fn sessions_missing_for<'a, S: CryptoStore>(
298        store: &S,
299        devices: &'a [StoredDevice],
300    ) -> Result<Vec<&'a StoredDevice>, MessengerError> {
301        let mut missing = Vec::new();
302        for device in devices {
303            let curve_b64 = device.curve25519.to_base64();
304            if store.olm_sessions_for_device(&curve_b64)?.is_empty() {
305                missing.push(device);
306            }
307        }
308        Ok(missing)
309    }
310
311    /// Builds a `POST /keys/claim` request for `devices`, or `None` if
312    /// `devices` is empty.
313    pub fn keys_claim_request(id: RequestId, devices: &[&StoredDevice]) -> Option<OutgoingRequest> {
314        if devices.is_empty() {
315            return None;
316        }
317        let mut by_user: BTreeMap<UserId, serde_json::Map<String, serde_json::Value>> = BTreeMap::new();
318        for device in devices {
319            by_user.entry(device.user_id.clone()).or_default().insert(
320                device.device_id.as_str().to_string(),
321                serde_json::Value::String(SIGNED_CURVE25519.to_string()),
322            );
323        }
324        let mut top = serde_json::Map::new();
325        for (user_id, device_map) in by_user {
326            top.insert(user_id.as_str().to_string(), serde_json::Value::Object(device_map));
327        }
328        let body = serde_json::json!({ "one_time_keys": serde_json::Value::Object(top) });
329        Some(OutgoingRequest::keys_claim(id, body))
330    }
331
332    /// Applies a `/keys/claim` response: for each of `requested`, verifies
333    /// the claimed key's signature against THAT device's own stored
334    /// Ed25519 key (never a key the response body itself might claim),
335    /// refuses an unsigned/mis-signed/absent key, and otherwise
336    /// establishes and persists a fresh outbound [`Session`] immediately.
337    /// A device present in `requested` but absent from the response
338    /// (nothing left to claim) is silently skipped -- neither established
339    /// nor refused.
340    pub fn on_keys_claim_response<S: CryptoStore>(
341        store: &mut S,
342        account: &OlmAccountState,
343        requested: &[&StoredDevice],
344        body: &[u8],
345    ) -> Result<KeysClaimOutcome, MessengerError> {
346        let parsed: KeysClaimResponseBody = serde_json::from_slice(body)?;
347        let mut outcome = KeysClaimOutcome::default();
348
349        for device in requested {
350            let Some(by_key_id) =
351                parsed.one_time_keys.get(&device.user_id).and_then(|by_device| by_device.get(&device.device_id))
352            else {
353                continue;
354            };
355            let Some((key_id, raw)) = by_key_id.iter().next() else { continue };
356
357            match Self::verify_and_claim(account, device, key_id, raw) {
358                Ok(session) => {
359                    let curve_b64 = device.curve25519.to_base64();
360                    let session_id = session.session_id();
361                    store.save_olm_session(&curve_b64, &session_id, pickle_session(&session)?)?;
362                    outcome.established.push((device.user_id.clone(), device.device_id.clone()));
363                }
364                Err(reason) => outcome.refused.push(KeysClaimRefusal {
365                    user_id: device.user_id.clone(),
366                    device_id: device.device_id.clone(),
367                    reason,
368                }),
369            }
370        }
371
372        Ok(outcome)
373    }
374
375    fn verify_and_claim(
376        account: &OlmAccountState,
377        device: &StoredDevice,
378        key_id: &str,
379        raw: &serde_json::Value,
380    ) -> Result<Session, String> {
381        if !key_id.starts_with(SIGNED_CURVE25519) {
382            return Err(format!("unexpected one-time-key algorithm in key id {key_id:?}"));
383        }
384        let sig_key_id = format!("ed25519:{}", device.device_id.as_str());
385        canonical_json::verify_json_signature(raw, device.user_id.as_str(), &sig_key_id, &device.ed25519)
386            .map_err(|source| format!("claimed one-time key signature failed to verify: {source}"))?;
387
388        let key_b64 = raw
389            .get("key")
390            .and_then(serde_json::Value::as_str)
391            .ok_or_else(|| "claimed one-time key object has no \"key\" field".to_string())?;
392        let one_time_key = Curve25519PublicKey::from_base64(key_b64)
393            .map_err(|source| format!("malformed claimed one-time key: {source}"))?;
394
395        account
396            .create_outbound_session(SessionConfig::version_1(), device.curve25519, one_time_key)
397            .map_err(|source| format!("failed to create outbound Olm session: {source}"))
398    }
399
400    /// Encrypts `content` (an inner to-device event's own `content`,
401    /// tagged `event_type`) for `target`, using the most recently used
402    /// session for that device (see the module doc). Persists the
403    /// ratchet-advanced session before returning.
404    pub fn encrypt_to_device<S: CryptoStore>(
405        store: &mut S,
406        account: &OlmAccountState,
407        our_user_id: &UserId,
408        our_device_id: &DeviceId,
409        target: &StoredDevice,
410        event_type: &str,
411        content: serde_json::Value,
412    ) -> Result<OlmEncryptedContent, MessengerError> {
413        let their_curve_b64 = target.curve25519.to_base64();
414        let sessions = store.olm_sessions_for_device(&their_curve_b64)?;
415        // The store already orders these most-recently-used first (see the
416        // module doc) -- the first entry IS "the most recently used
417        // session for that device".
418        let latest = sessions.into_iter().next().ok_or_else(|| {
419            MessengerError::Crypto(format!(
420                "no Olm session established with device {} of {} yet",
421                target.device_id.as_str(),
422                target.user_id.as_str()
423            ))
424        })?;
425        let mut session = unpickle_session(&latest)?;
426
427        let payload = OlmPlaintextPayload {
428            event_type: event_type.to_string(),
429            content,
430            sender: our_user_id.clone(),
431            sender_device: our_device_id.clone(),
432            keys: OlmPlaintextKeys { ed25519: account.identity_keys().ed25519.to_base64() },
433            recipient: target.user_id.clone(),
434            recipient_keys: OlmPlaintextKeys { ed25519: target.ed25519.to_base64() },
435        };
436        let plaintext = serde_json::to_vec(&payload)?;
437
438        let olm_message = session.encrypt(plaintext).map_err(|source| {
439            MessengerError::Crypto(format!("Olm encrypt to {}: {source}", target.device_id.as_str()))
440        })?;
441
442        // Flush-before-send: the ratchet-advanced session lands on disk in
443        // this same synchronous call, before the ciphertext below is ever
444        // handed to a caller that might enqueue it for sending.
445        let session_id = session.session_id();
446        store.save_olm_session(&their_curve_b64, &session_id, pickle_session(&session)?)?;
447
448        let mut ciphertext = BTreeMap::new();
449        ciphertext.insert(their_curve_b64, Self::encode_olm_message(&olm_message));
450
451        Ok(OlmEncryptedContent { sender_key: account.identity_keys().curve25519.to_base64(), ciphertext })
452    }
453
454    /// Decrypts and payload-validates one to-device `m.room.encrypted`
455    /// (Olm) event. See the module doc for the exact ordering (existing
456    /// sessions before a new inbound one) and binding checks.
457    pub fn decrypt_to_device<S: CryptoStore>(
458        store: &mut S,
459        account: &mut OlmAccountState,
460        our_user_id: &UserId,
461        event_sender: &UserId,
462        content: &OlmEncryptedContent,
463    ) -> Result<DecryptedToDevice, OlmDecryptError> {
464        let our_curve_b64 = account.identity_keys().curve25519.to_base64();
465        let cipher_info = content.ciphertext.get(&our_curve_b64).ok_or(OlmDecryptError::NotAddressedToUs)?;
466        let olm_message = Self::decode_olm_message(cipher_info)
467            .map_err(|source| OlmDecryptError::MalformedCiphertext(source.to_string()))?;
468
469        let sender_curve_b64 = content.sender_key.clone();
470
471        // The sender's device must already be known -- checked before any
472        // session is created or advanced (module doc, "Payload binding").
473        let sender_devices = DeviceTracker::devices_for_user(store, event_sender)?;
474        let Some(sender_device) = sender_devices.iter().find(|device| device.curve25519.to_base64() == sender_curve_b64) else {
475            store.mark_user_tracked(event_sender, true)?;
476            return Err(OlmDecryptError::UnknownSenderDevice { user_id: event_sender.clone(), curve25519: sender_curve_b64 });
477        };
478
479        // Already at most one snapshot per distinct session id, ordered
480        // most-recently-used first (see the module doc) -- a plain walk in
481        // that order is "try each known session, newest first" as-is.
482        let known_sessions: Vec<Vec<u8>> = store.olm_sessions_for_device(&sender_curve_b64)?;
483
484        let mut established: Option<(Session, Vec<u8>)> = None;
485        for pickle_bytes in &known_sessions {
486            let mut session = unpickle_session(pickle_bytes)
487                .map_err(|source| OlmDecryptError::SessionEstablishment(format!("corrupt stored session: {source}")))?;
488            if let Ok(plaintext) = session.decrypt(&olm_message) {
489                established = Some((session, plaintext));
490                break;
491            }
492        }
493
494        let plaintext = match established {
495            Some((session, plaintext)) => {
496                let session_id = session.session_id();
497                store.save_olm_session(&sender_curve_b64, &session_id, pickle_session(&session)?)?;
498                plaintext
499            }
500            None => match &olm_message {
501                OlmMessage::PreKey(pre_key_message) => {
502                    let sender_curve = Curve25519PublicKey::from_base64(&sender_curve_b64).map_err(|source| {
503                        OlmDecryptError::MalformedCiphertext(format!("malformed sender_key: {source}"))
504                    })?;
505                    let result = account
506                        .create_inbound_session(store, SessionConfig::version_1(), sender_curve, pre_key_message)
507                        .map_err(|source| OlmDecryptError::SessionEstablishment(source.to_string()))?;
508                    let session_id = result.session.session_id();
509                    store.save_olm_session(&sender_curve_b64, &session_id, pickle_session(&result.session)?)?;
510                    result.plaintext
511                }
512                OlmMessage::Normal(_) => return Err(OlmDecryptError::NoMatchingSession(sender_curve_b64)),
513            },
514        };
515
516        let payload: OlmPlaintextPayload = serde_json::from_slice(&plaintext)?;
517
518        if payload.recipient != *our_user_id {
519            return Err(OlmDecryptError::PayloadMismatch(format!(
520                "payload recipient {:?} does not match this account's own user id {:?}",
521                payload.recipient.as_str(),
522                our_user_id.as_str()
523            )));
524        }
525        let our_ed25519_b64 = account.identity_keys().ed25519.to_base64();
526        if payload.recipient_keys.ed25519 != our_ed25519_b64 {
527            return Err(OlmDecryptError::PayloadMismatch(
528                "payload recipient_keys.ed25519 does not match this account's own ed25519 key".to_string(),
529            ));
530        }
531        if payload.sender != *event_sender {
532            return Err(OlmDecryptError::PayloadMismatch(format!(
533                "payload sender {:?} does not match the to-device event's own sender {:?}",
534                payload.sender.as_str(),
535                event_sender.as_str()
536            )));
537        }
538
539        if payload.keys.ed25519 != sender_device.ed25519.to_base64() {
540            return Err(OlmDecryptError::PayloadMismatch(
541                "payload keys.ed25519 does not match the stored ed25519 key of the sender device".to_string(),
542            ));
543        }
544
545        Ok(DecryptedToDevice {
546            sender: payload.sender,
547            sender_device_curve25519: sender_device.curve25519,
548            sender_ed25519: sender_device.ed25519,
549            event_type: payload.event_type,
550            content: payload.content,
551        })
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::store::{InsecurePlainCodecForTests, Store};
559
560    fn new_store(device_id: &str) -> Store<InsecurePlainCodecForTests> {
561        Store::new(DeviceId::parse(device_id).expect("valid device id"), InsecurePlainCodecForTests)
562    }
563
564    fn user(name: &str) -> UserId {
565        UserId::parse(format!("@{name}:example.org")).expect("valid user id")
566    }
567
568    fn device(name: &str) -> DeviceId {
569        DeviceId::parse(name).expect("valid device id")
570    }
571
572    /// No fallback-key consumption in scope for these tests -- reports the
573    /// type as still available so `on_sync_counts` calls only exercise the
574    /// one-time-key top-up path.
575    fn fallback_present() -> Vec<String> {
576        vec![SIGNED_CURVE25519.to_string()]
577    }
578
579    fn stored_device(user_id: &UserId, device_id: &DeviceId, account: &OlmAccountState) -> StoredDevice {
580        let identity = account.identity_keys();
581        StoredDevice {
582            user_id: user_id.clone(),
583            device_id: device_id.clone(),
584            curve25519: identity.curve25519,
585            ed25519: identity.ed25519,
586            algorithms: vec!["m.olm.v1.curve25519-aes-sha2".to_string(), "m.megolm.v1.aes-sha2".to_string()],
587            display_name: None,
588            verified: false,
589            blocked: false,
590        }
591    }
592
593    /// Reshapes a real `/keys/upload` request body's `one_time_keys` map
594    /// (built by [`OlmAccountState::keys_upload_request`]) into the
595    /// `/keys/claim` response shape for `(user_id, device_id)`'s FIRST
596    /// one-time key -- the two nest an identically-shaped signed key
597    /// object, only the surrounding map keys differ.
598    fn claim_response_body_from_upload(upload_body: &serde_json::Value, user_id: &UserId, device_id: &DeviceId) -> Vec<u8> {
599        let one_time_keys =
600            upload_body.get("one_time_keys").and_then(serde_json::Value::as_object).expect("upload carries OTKs");
601        let (key_id, key_value) = one_time_keys.iter().next().expect("at least one OTK");
602        serde_json::to_vec(&serde_json::json!({
603            "one_time_keys": { user_id.as_str(): { device_id.as_str(): { key_id: key_value } } }
604        }))
605        .expect("valid JSON")
606    }
607
608    #[test]
609    fn olm_session_established_via_claimed_otk() {
610        let mut alice_store = new_store("ALICEDEV");
611        let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
612
613        let bob_user = user("bob");
614        let bob_device_id = device("BOBDEV");
615        let mut bob_store = new_store("BOBDEV");
616        let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
617        bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
618
619        let upload = bob_account
620            .keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
621            .expect("builds a request")
622            .expect("something to upload");
623        let claim_body =
624            claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
625        let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
626
627        let outcome = OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
628            .expect("applies the claim response");
629
630        assert_eq!(outcome.established, vec![(bob_user, bob_device_id)]);
631        assert!(outcome.refused.is_empty());
632        let sessions = alice_store
633            .olm_sessions_for_device(&bob_account.identity_keys().curve25519.to_base64())
634            .expect("no error");
635        assert_eq!(sessions.len(), 1, "exactly one session established");
636    }
637
638    #[test]
639    fn olm_session_rejects_out_of_order_ratchet_message() {
640        let mut alice_store = new_store("ALICEDEV");
641        let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
642        let alice_user = user("alice");
643        let alice_device_id = device("ALICEDEV");
644
645        let bob_user = user("bob");
646        let bob_device_id = device("BOBDEV");
647        let mut bob_store = new_store("BOBDEV");
648        let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
649        bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
650
651        let upload = bob_account
652            .keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
653            .expect("builds a request")
654            .expect("something to upload");
655        let claim_body =
656            claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
657        let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
658        OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
659            .expect("alice establishes a session with bob");
660
661        // Bob needs to know Alice's device before he can validate a
662        // payload from her.
663        let alice_device_keys =
664            alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
665        let query_body = serde_json::to_vec(&serde_json::json!({
666            "device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
667        }))
668        .expect("valid JSON");
669        DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
670
671        // Alice sends two messages. Neither is ever answered, so both stay
672        // tagged pre-key (module doc) -- Bob still has to be able to
673        // decrypt them out of order.
674        let message_0 = OlmSessionManager::encrypt_to_device(
675            &mut alice_store,
676            &alice_account,
677            &alice_user,
678            &alice_device_id,
679            &bob_device,
680            "m.test",
681            serde_json::json!({ "i": 0 }),
682        )
683        .expect("encrypt message 0");
684        let message_1 = OlmSessionManager::encrypt_to_device(
685            &mut alice_store,
686            &alice_account,
687            &alice_user,
688            &alice_device_id,
689            &bob_device,
690            "m.test",
691            serde_json::json!({ "i": 1 }),
692        )
693        .expect("encrypt message 1");
694
695        // Out of order: message 1 arrives and decrypts first (establishing
696        // Bob's session), then message 0 fills the gap.
697        let decrypted_1 = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_1)
698            .expect("message 1 decrypts, establishing the session");
699        assert_eq!(decrypted_1.content, serde_json::json!({ "i": 1 }));
700
701        let decrypted_0 = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
702            .expect("message 0 decrypts through the same, already-established session");
703        assert_eq!(decrypted_0.content, serde_json::json!({ "i": 0 }));
704
705        // Replay: the exact same ciphertext bytes, decrypted again, must
706        // never yield a second plaintext (module doc's precise definition
707        // of what a replay looks like at the vodozemac layer).
708        let replay = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0);
709        assert!(replay.is_err(), "replaying an already-decrypted message must be rejected");
710        assert!(
711            matches!(replay, Err(OlmDecryptError::SessionEstablishment(_))),
712            "got {replay:?}"
713        );
714    }
715
716    #[test]
717    fn replay_is_rejected_after_reload_from_records() {
718        let mut alice_store = new_store("ALICEDEV");
719        let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
720        let alice_user = user("alice");
721        let alice_device_id = device("ALICEDEV");
722
723        let bob_user = user("bob");
724        let bob_device_id = device("BOBDEV");
725        let mut bob_store = new_store("BOBDEV");
726        let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
727        bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
728
729        let upload = bob_account
730            .keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
731            .expect("builds a request")
732            .expect("something to upload");
733        let claim_body =
734            claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
735        let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
736        OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
737            .expect("alice establishes a session with bob");
738
739        let alice_device_keys =
740            alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
741        let query_body = serde_json::to_vec(&serde_json::json!({
742            "device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
743        }))
744        .expect("valid JSON");
745        DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
746
747        let message_0 = OlmSessionManager::encrypt_to_device(
748            &mut alice_store,
749            &alice_account,
750            &alice_user,
751            &alice_device_id,
752            &bob_device,
753            "m.test",
754            serde_json::json!({ "i": 0 }),
755        )
756        .expect("encrypt message 0");
757
758        OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
759            .expect("message 0 decrypts and establishes bob's session");
760
761        // Flush everything bob has dirtied so far (account, alice's
762        // device, the Olm session) into one set of durable records, then
763        // rebuild a completely fresh `Store`/`OlmAccountState` from just
764        // those records -- simulating a process restart between the first
765        // decrypt and the replay attempt below.
766        let batch = bob_store.take_flush_batch().expect("decrypting dirtied the store");
767        bob_store.ack_flush(batch.id);
768        let mut reloaded_store =
769            Store::load(batch.records, InsecurePlainCodecForTests, bob_device_id.clone()).expect("reload succeeds");
770        let mut reloaded_account =
771            OlmAccountState::load_or_create(&mut reloaded_store).expect("reload reuses the persisted account");
772
773        let replay = OlmSessionManager::decrypt_to_device(
774            &mut reloaded_store,
775            &mut reloaded_account,
776            &bob_user,
777            &alice_user,
778            &message_0,
779        );
780        assert!(replay.is_err(), "a replay must still be rejected after a full store reload");
781        assert!(
782            matches!(replay, Err(OlmDecryptError::SessionEstablishment(_))),
783            "got {replay:?}"
784        );
785    }
786
787    #[test]
788    fn unknown_sender_prekey_message_stays_decryptable_after_the_device_is_learned() {
789        let mut alice_store = new_store("ALICEDEV");
790        let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
791        let alice_user = user("alice");
792        let alice_device_id = device("ALICEDEV");
793
794        let bob_user = user("bob");
795        let bob_device_id = device("BOBDEV");
796        let mut bob_store = new_store("BOBDEV");
797        let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
798        bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
799
800        let upload = bob_account
801            .keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
802            .expect("builds a request")
803            .expect("something to upload");
804        let claim_body =
805            claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
806        let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
807        OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
808            .expect("alice establishes a session with bob");
809        let message_0 = OlmSessionManager::encrypt_to_device(
810            &mut alice_store,
811            &alice_account,
812            &alice_user,
813            &alice_device_id,
814            &bob_device,
815            "m.test",
816            serde_json::json!({ "i": 0 }),
817        )
818        .expect("encrypt message 0");
819
820        // First attempt: bob has never seen alice's device (the live case: the
821        // to-device room key can arrive before any `/keys/query` for her).
822        let first = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0);
823        assert!(matches!(first, Err(OlmDecryptError::UnknownSenderDevice { .. })), "got {first:?}");
824        let alice_curve = alice_account.identity_keys().curve25519.to_base64();
825        assert!(
826            bob_store.olm_sessions_for_device(&alice_curve).expect("no error").is_empty(),
827            "the refused attempt must not create an inbound session (it would consume the one-time key)"
828        );
829
830        // Bob learns alice's device, then retries the SAME message.
831        let alice_device_keys =
832            alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
833        let query_body = serde_json::to_vec(&serde_json::json!({
834            "device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
835        }))
836        .expect("valid JSON");
837        DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
838        let retry = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
839            .expect("the retry decrypts: nothing was consumed by the refused attempt");
840        assert_eq!(retry.content, serde_json::json!({ "i": 0 }));
841    }
842}