use crate::crypto::account::OlmAccountState;
use crate::crypto::device_tracker::StoredDevice;
use crate::crypto::olm_sessions::{DecryptedToDevice, OlmSessionManager};
use crate::error::MessengerError;
use crate::ids::{DeviceId, EventId, RoomId, UserId};
use crate::store::CryptoStore;
use crate::wire::events::{
ForwardedRoomKeyContent, MegolmEncryptedContent, RelatesTo, RoomEncryptedContent, RoomEncryptionContent, RoomKeyContent,
};
use mail4agent_vodozemac::megolm::{
DecryptionError as MegolmDecryptionError, ExportedSessionKey, GroupSession, InboundGroupSession, MegolmMessage,
SessionConfig, SessionKey, SessionOrdering,
};
use mail4agent_vodozemac::{Curve25519PublicKey, DecodeError as VodozemacDecodeError, Ed25519PublicKey};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
const MEGOLM_ALGORITHM: &str = "m.megolm.v1.aes-sha2";
pub const MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST: usize = 250;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UtdReason {
MissingSession,
WithheldBlacklisted,
WithheldUnverified,
WithheldUnauthorised,
SenderIdentityNotVerified,
Unknown,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RoomEventPlaintext {
#[serde(rename = "type")]
pub event_type: String,
pub content: serde_json::Value,
pub room_id: RoomId,
}
#[derive(Debug, Clone)]
pub struct OutboundSessionUpdate {
pub session_id: String,
pub rotated: bool,
pub session_key: String,
pub new_recipients: Vec<StoredDevice>,
pub room_key_content: Option<RoomKeyContent>,
}
#[derive(Serialize, Deserialize)]
struct OutboundGroupSessionRecord {
session: mail4agent_vodozemac::megolm::GroupSessionPickle,
created_at_ms: i64,
shared_with: BTreeSet<(UserId, DeviceId)>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ReplayEntry {
event_id: EventId,
origin_server_ts: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct BackedUpMark {
pub(crate) version: String,
pub(crate) first_message_index: u32,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct InboundGroupSessionRecord {
pub(crate) session: mail4agent_vodozemac::megolm::InboundGroupSessionPickle,
pub(crate) bound_user_id: UserId,
pub(crate) bound_sender_curve25519: String,
pub(crate) bound_sender_ed25519: String,
pub(crate) replay_index: BTreeMap<u32, ReplayEntry>,
#[serde(default)]
pub(crate) backed_up: Option<BackedUpMark>,
}
#[derive(Debug, thiserror::Error)]
pub enum GroupDecryptError {
#[error("no inbound Megolm session for room {room_id} session {session_id}")]
MissingSession {
room_id: RoomId,
session_id: String,
},
#[error("decrypted payload's room_id {plaintext_room_id} does not match the event's own room {event_room_id}")]
WrongRoom {
event_room_id: RoomId,
plaintext_room_id: RoomId,
},
#[error("event sender {event_sender} does not match this session's bound sender {bound_user_id}")]
WrongSender {
bound_user_id: UserId,
event_sender: UserId,
},
#[error("message index {message_index} of session {session_id} was already seen under a different event")]
Replay {
session_id: String,
message_index: u32,
},
#[error("malformed Megolm ciphertext: {0}")]
MalformedCiphertext(#[from] VodozemacDecodeError),
#[error("Megolm decryption failed: {0}")]
Vodozemac(#[from] MegolmDecryptionError),
#[error("decrypted or stored payload was not valid JSON: {0}")]
InvalidPayload(#[from] serde_json::Error),
#[error("store: {0}")]
Store(#[from] crate::store::StoreError),
}
impl GroupDecryptError {
pub fn utd_reason(&self) -> UtdReason {
match self {
GroupDecryptError::MissingSession { .. } => UtdReason::MissingSession,
_ => UtdReason::Unknown,
}
}
}
pub(crate) fn load_inbound_record<S: CryptoStore>(
store: &S,
room_id: &RoomId,
session_id: &str,
) -> Result<Option<InboundGroupSessionRecord>, MessengerError> {
let Some(bytes) = store.inbound_group_session(room_id, session_id)? else { return Ok(None) };
let record: InboundGroupSessionRecord = serde_json::from_slice(bytes).map_err(|source| {
MessengerError::Crypto(format!("decode inbound Megolm session for room {room_id} session {session_id}: {source}"))
})?;
Ok(Some(record))
}
pub(crate) fn save_inbound_record<S: CryptoStore>(
store: &mut S,
room_id: &RoomId,
session_id: &str,
record: &InboundGroupSessionRecord,
) -> Result<(), MessengerError> {
let bytes = serde_json::to_vec(record).map_err(|source| {
MessengerError::Crypto(format!("encode inbound Megolm session for room {room_id} session {session_id}: {source}"))
})?;
store.save_inbound_group_session(room_id, session_id, bytes)?;
Ok(())
}
pub(crate) enum InboundMergeOutcome {
New {
record: InboundGroupSessionRecord,
},
Kept,
Merged {
record: InboundGroupSessionRecord,
},
IgnoredSenderMismatch,
}
pub(crate) fn merge_inbound_session(
existing: Option<InboundGroupSessionRecord>,
mut incoming_session: InboundGroupSession,
sender_user_id: &UserId,
sender_curve25519: Curve25519PublicKey,
sender_ed25519: Ed25519PublicKey,
) -> InboundMergeOutcome {
let Some(existing_record) = existing else {
return InboundMergeOutcome::New {
record: InboundGroupSessionRecord {
session: incoming_session.pickle(),
bound_user_id: sender_user_id.clone(),
bound_sender_curve25519: sender_curve25519.to_base64(),
bound_sender_ed25519: sender_ed25519.to_base64(),
replay_index: BTreeMap::new(),
backed_up: None,
},
};
};
let InboundGroupSessionRecord { session, bound_user_id, bound_sender_curve25519, bound_sender_ed25519, replay_index, backed_up } =
existing_record;
let mut existing_session = InboundGroupSession::from_pickle(session);
match existing_session.compare(&mut incoming_session) {
SessionOrdering::Equal | SessionOrdering::Better | SessionOrdering::Unconnected => InboundMergeOutcome::Kept,
SessionOrdering::Worse => {
let sender_matches = bound_user_id == *sender_user_id
&& bound_sender_curve25519 == sender_curve25519.to_base64()
&& bound_sender_ed25519 == sender_ed25519.to_base64();
if sender_matches {
let merged = existing_session
.merge(&mut incoming_session)
.unwrap_or_else(|| InboundGroupSession::from_pickle(incoming_session.pickle()));
InboundMergeOutcome::Merged {
record: InboundGroupSessionRecord {
session: merged.pickle(),
bound_user_id,
bound_sender_curve25519,
bound_sender_ed25519,
replay_index,
backed_up,
},
}
} else {
InboundMergeOutcome::IgnoredSenderMismatch
}
}
}
}
pub struct GroupSessionManager;
impl GroupSessionManager {
#[allow(clippy::too_many_arguments)]
pub fn ensure_outbound_session<S: CryptoStore>(
store: &mut S,
room_id: &RoomId,
our_user_id: &UserId,
our_device_id: &DeviceId,
encryption: &RoomEncryptionContent,
member_user_ids: &BTreeSet<UserId>,
known_devices: &[StoredDevice],
now_ms: i64,
) -> Result<OutboundSessionUpdate, MessengerError> {
let existing_bytes = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec);
let (group_session, created_at_ms, mut shared_with, rotated) = match existing_bytes {
None => (GroupSession::new(SessionConfig::version_1()), now_ms, BTreeSet::new(), true),
Some(bytes) => {
let record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
})?;
let group_session = GroupSession::from_pickle(record.session);
let msgs_exhausted = i64::from(group_session.message_index()) >= encryption.rotation_period_msgs;
let time_elapsed = now_ms.saturating_sub(record.created_at_ms) >= encryption.rotation_period_ms;
let a_shared_user_left =
record.shared_with.iter().any(|(user_id, _)| !member_user_ids.contains(user_id));
let a_shared_device_disappeared = record.shared_with.iter().any(|(user_id, device_id)| {
!known_devices.iter().any(|device| &device.user_id == user_id && &device.device_id == device_id)
});
if msgs_exhausted || time_elapsed || a_shared_user_left || a_shared_device_disappeared {
(GroupSession::new(SessionConfig::version_1()), now_ms, BTreeSet::new(), true)
} else {
(group_session, record.created_at_ms, record.shared_with, false)
}
}
};
let new_recipients: Vec<StoredDevice> = known_devices
.iter()
.filter(|device| {
member_user_ids.contains(&device.user_id)
&& !device.blocked
&& !(device.user_id == *our_user_id && device.device_id == *our_device_id)
})
.filter(|device| !shared_with.contains(&(device.user_id.clone(), device.device_id.clone())))
.cloned()
.collect();
let session_id = group_session.session_id();
let session_key = group_session.session_key().to_base64();
let room_key_content = if new_recipients.is_empty() {
None
} else {
for device in &new_recipients {
shared_with.insert((device.user_id.clone(), device.device_id.clone()));
}
Some(RoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: session_id.clone(),
session_key: session_key.clone(),
})
};
if rotated || room_key_content.is_some() {
let record = OutboundGroupSessionRecord { session: group_session.pickle(), created_at_ms, shared_with };
let bytes = serde_json::to_vec(&record).map_err(|source| {
MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
})?;
store.save_outbound_group_session(room_id, bytes)?;
}
Ok(OutboundSessionUpdate { session_id, rotated, session_key, new_recipients, room_key_content })
}
pub fn forget_shared_device<S: CryptoStore>(
store: &mut S,
room_ids: &[RoomId],
user_id: &UserId,
device_id: &DeviceId,
) -> Result<usize, MessengerError> {
let mut touched = 0usize;
for room_id in room_ids {
let Some(bytes) = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec) else {
continue;
};
let mut record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
})?;
let before = record.shared_with.len();
record.shared_with.retain(|(u, d)| !(u == user_id && d == device_id));
if record.shared_with.len() != before {
let out = serde_json::to_vec(&record).map_err(|source| {
MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
})?;
store.save_outbound_group_session(room_id, out)?;
touched += 1;
}
}
Ok(touched)
}
pub fn adopt_own_outbound_session<S: CryptoStore>(
store: &mut S,
room_id: &RoomId,
our_user_id: &UserId,
our_curve25519: Curve25519PublicKey,
our_ed25519: Ed25519PublicKey,
update: &OutboundSessionUpdate,
) -> Result<(), MessengerError> {
if !update.rotated {
return Ok(());
}
let content = RoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: update.session_id.clone(),
session_key: update.session_key.clone(),
};
Self::receive_room_key(store, our_user_id, our_curve25519, our_ed25519, &content)
}
pub fn encrypt_event<S: CryptoStore>(
store: &mut S,
room_id: &RoomId,
sender_curve25519_b64: &str,
sender_device_id: &DeviceId,
event_type: &str,
content: serde_json::Value,
relates_to: Option<RelatesTo>,
) -> Result<MegolmEncryptedContent, MessengerError> {
let bytes = store
.outbound_group_session(room_id)?
.map(<[u8]>::to_vec)
.ok_or_else(|| MessengerError::Crypto(format!("no outbound Megolm session for room {room_id} yet")))?;
let mut record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
})?;
let mut group_session = GroupSession::from_pickle(record.session);
let plaintext = RoomEventPlaintext { event_type: event_type.to_string(), content, room_id: room_id.clone() };
let plaintext_bytes = serde_json::to_vec(&plaintext)?;
let message = group_session.encrypt(plaintext_bytes);
let session_id = group_session.session_id();
record.session = group_session.pickle();
let record_bytes = serde_json::to_vec(&record).map_err(|source| {
MessengerError::Crypto(format!("encode outbound Megolm session for room {room_id}: {source}"))
})?;
store.save_outbound_group_session(room_id, record_bytes)?;
Ok(MegolmEncryptedContent {
ciphertext: message.to_base64(),
session_id,
sender_key: Some(sender_curve25519_b64.to_string()),
device_id: Some(sender_device_id.clone()),
relates_to,
})
}
pub fn chunk_recipients_for_send_to_device(recipients: &[StoredDevice]) -> std::slice::Chunks<'_, StoredDevice> {
recipients.chunks(MAX_DEVICES_PER_SEND_TO_DEVICE_REQUEST)
}
pub fn build_room_key_send_to_device_body<S: CryptoStore>(
store: &mut S,
account: &OlmAccountState,
our_user_id: &UserId,
our_device_id: &DeviceId,
chunk: &[StoredDevice],
room_key_content: &RoomKeyContent,
) -> Result<serde_json::Value, MessengerError> {
let content_value = serde_json::to_value(room_key_content)?;
Self::build_encrypted_to_device_body(
store,
account,
our_user_id,
our_device_id,
chunk,
"m.room_key",
content_value,
)
}
pub fn build_encrypted_to_device_body<S: CryptoStore>(
store: &mut S,
account: &OlmAccountState,
our_user_id: &UserId,
our_device_id: &DeviceId,
chunk: &[StoredDevice],
event_type: &str,
content_value: serde_json::Value,
) -> Result<serde_json::Value, MessengerError> {
let mut by_user: BTreeMap<UserId, serde_json::Map<String, serde_json::Value>> = BTreeMap::new();
for device in chunk {
let encrypted = OlmSessionManager::encrypt_to_device(
store,
account,
our_user_id,
our_device_id,
device,
event_type,
content_value.clone(),
)?;
let wire_content = serde_json::to_value(RoomEncryptedContent::Olm(encrypted))?;
by_user.entry(device.user_id.clone()).or_default().insert(device.device_id.as_str().to_string(), wire_content);
}
let mut top = serde_json::Map::new();
for (user_id, devices) in by_user {
top.insert(user_id.as_str().to_string(), serde_json::Value::Object(devices));
}
Ok(serde_json::json!({ "messages": serde_json::Value::Object(top) }))
}
pub fn forwarded_room_key_content<S: CryptoStore>(
store: &S,
room_id: &RoomId,
session_id: &str,
our_curve25519_b64: &str,
) -> Result<Option<ForwardedRoomKeyContent>, MessengerError> {
let Some(record) = load_inbound_record(store, room_id, session_id)? else { return Ok(None) };
let InboundGroupSessionRecord { session, bound_sender_curve25519, bound_sender_ed25519, .. } = record;
let inbound = InboundGroupSession::from_pickle(session);
if inbound.session_id() != session_id {
return Ok(None);
}
let exported = inbound.export_at_first_known_index();
Ok(Some(ForwardedRoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: session_id.to_string(),
session_key: exported.to_base64(),
sender_key: bound_sender_curve25519,
sender_claimed_ed25519_key: Some(bound_sender_ed25519),
forwarding_curve25519_key_chain: vec![our_curve25519_b64.to_string()],
}))
}
pub fn outbound_room_key_content<S: CryptoStore>(
store: &S,
room_id: &RoomId,
session_id: &str,
) -> Result<Option<RoomKeyContent>, MessengerError> {
let Some(bytes) = store.outbound_group_session(room_id)?.map(<[u8]>::to_vec) else { return Ok(None) };
let record: OutboundGroupSessionRecord = serde_json::from_slice(&bytes).map_err(|source| {
MessengerError::Crypto(format!("decode outbound Megolm session for room {room_id}: {source}"))
})?;
let session = GroupSession::from_pickle(record.session);
if session.session_id() != session_id {
return Ok(None);
}
Ok(Some(RoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: session_id.to_string(),
session_key: session.session_key().to_base64(),
}))
}
pub fn accept_room_key_from_to_device<S: CryptoStore>(
store: &mut S,
decrypted: &DecryptedToDevice,
) -> Result<bool, MessengerError> {
if decrypted.event_type != "m.room_key" {
return Ok(false);
}
let content: RoomKeyContent = serde_json::from_value(decrypted.content.clone())?;
if content.algorithm != MEGOLM_ALGORITHM {
return Ok(false);
}
Self::receive_room_key(store, &decrypted.sender, decrypted.sender_device_curve25519, decrypted.sender_ed25519, &content)?;
Ok(true)
}
pub fn receive_room_key<S: CryptoStore>(
store: &mut S,
sender_user_id: &UserId,
sender_curve25519: Curve25519PublicKey,
sender_ed25519: Ed25519PublicKey,
content: &RoomKeyContent,
) -> Result<(), MessengerError> {
if content.algorithm != MEGOLM_ALGORITHM {
return Ok(());
}
let session_key = SessionKey::from_base64(&content.session_key)
.map_err(|source| MessengerError::Crypto(format!("malformed Megolm session_key: {source}")))?;
let incoming_session = InboundGroupSession::new(&session_key, SessionConfig::version_1());
let existing = load_inbound_record(store, &content.room_id, &content.session_id)?;
match merge_inbound_session(existing, incoming_session, sender_user_id, sender_curve25519, sender_ed25519) {
InboundMergeOutcome::New { record } | InboundMergeOutcome::Merged { record } => {
save_inbound_record(store, &content.room_id, &content.session_id, &record)
}
InboundMergeOutcome::Kept | InboundMergeOutcome::IgnoredSenderMismatch => Ok(()),
}
}
pub fn receive_forwarded_room_key<S: CryptoStore>(
store: &mut S,
bound_user_id: &UserId,
bound_curve25519: Curve25519PublicKey,
bound_ed25519: Ed25519PublicKey,
content: &ForwardedRoomKeyContent,
) -> Result<bool, MessengerError> {
if content.algorithm != MEGOLM_ALGORITHM {
return Ok(false);
}
let exported = ExportedSessionKey::from_base64(&content.session_key)
.map_err(|source| MessengerError::Crypto(format!("malformed forwarded Megolm session_key: {source}")))?;
let incoming = InboundGroupSession::import(&exported, SessionConfig::version_1());
if incoming.session_id() != content.session_id {
return Ok(false);
}
let existing = load_inbound_record(store, &content.room_id, &content.session_id)?;
match merge_inbound_session(existing, incoming, bound_user_id, bound_curve25519, bound_ed25519) {
InboundMergeOutcome::New { record } | InboundMergeOutcome::Merged { record } => {
save_inbound_record(store, &content.room_id, &content.session_id, &record)?;
Ok(true)
}
InboundMergeOutcome::Kept | InboundMergeOutcome::IgnoredSenderMismatch => Ok(false),
}
}
pub fn decrypt_event<S: CryptoStore>(
store: &mut S,
room_id: &RoomId,
event_id: &EventId,
origin_server_ts: i64,
sender: &UserId,
content: &MegolmEncryptedContent,
) -> Result<RoomEventPlaintext, GroupDecryptError> {
let bytes = store.inbound_group_session(room_id, &content.session_id)?.map(<[u8]>::to_vec).ok_or_else(|| {
GroupDecryptError::MissingSession { room_id: room_id.clone(), session_id: content.session_id.clone() }
})?;
let mut record: InboundGroupSessionRecord = serde_json::from_slice(&bytes)?;
let mut session = InboundGroupSession::from_pickle(record.session);
let message = MegolmMessage::from_base64(&content.ciphertext)?;
let decrypted = session.decrypt(&message)?;
let plaintext: RoomEventPlaintext = serde_json::from_slice(&decrypted.plaintext)?;
if plaintext.room_id != *room_id {
return Err(GroupDecryptError::WrongRoom {
event_room_id: room_id.clone(),
plaintext_room_id: plaintext.room_id,
});
}
if record.bound_user_id != *sender {
return Err(GroupDecryptError::WrongSender {
bound_user_id: record.bound_user_id,
event_sender: sender.clone(),
});
}
match record.replay_index.get(&decrypted.message_index) {
Some(seen) if seen.event_id != *event_id || seen.origin_server_ts != origin_server_ts => {
return Err(GroupDecryptError::Replay {
session_id: content.session_id.clone(),
message_index: decrypted.message_index,
});
}
Some(_) => {
}
None => {
record
.replay_index
.insert(decrypted.message_index, ReplayEntry { event_id: event_id.clone(), origin_server_ts });
record.session = session.pickle();
let bytes = serde_json::to_vec(&record)?;
store.save_inbound_group_session(room_id, &content.session_id, bytes)?;
}
}
Ok(plaintext)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::room::timeline::{ItemContent, Timeline};
use crate::store::{InsecurePlainCodecForTests, Store};
use crate::wire::events::RawEvent;
use mail4agent_vodozemac::olm::Account;
fn new_store() -> Store<InsecurePlainCodecForTests> {
Store::new(DeviceId::parse("DEV").expect("valid device id"), InsecurePlainCodecForTests)
}
fn user(name: &str) -> UserId {
UserId::parse(format!("@{name}:example.org")).expect("valid user id")
}
fn device(name: &str) -> DeviceId {
DeviceId::parse(name).expect("valid device id")
}
fn room(name: &str) -> RoomId {
RoomId::parse(format!("!{name}:example.org")).expect("valid room id")
}
fn stored_device(user_id: &UserId, device_id: &DeviceId) -> StoredDevice {
let identity = Account::new().identity_keys();
StoredDevice {
user_id: user_id.clone(),
device_id: device_id.clone(),
curve25519: identity.curve25519,
ed25519: identity.ed25519,
algorithms: vec!["m.olm.v1.curve25519-aes-sha2".to_string(), "m.megolm.v1.aes-sha2".to_string()],
display_name: None,
verified: false,
blocked: false,
}
}
fn default_encryption() -> RoomEncryptionContent {
RoomEncryptionContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
rotation_period_ms: 604_800_000,
rotation_period_msgs: 100,
}
}
fn plaintext_bytes(room_id: &RoomId, body: &str) -> Vec<u8> {
serde_json::to_vec(&RoomEventPlaintext {
event_type: "m.room.message".to_string(),
content: serde_json::json!({ "body": body }),
room_id: room_id.clone(),
})
.expect("valid JSON")
}
#[test]
fn outbound_session_rotates_after_rotation_period_msgs() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let known = vec![bob_device];
let encryption =
RoomEncryptionContent { algorithm: MEGOLM_ALGORITHM.to_string(), rotation_period_ms: 604_800_000, rotation_period_msgs: 2 };
let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
.expect("create session");
assert!(first.rotated);
assert!(first.room_key_content.is_some());
GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
.expect("encrypt 1");
GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
.expect("encrypt 2");
let second = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
.expect("re-check");
assert!(second.rotated, "message-count threshold reached");
assert_ne!(second.session_id, first.session_id);
}
#[test]
fn outbound_session_rotates_after_rotation_period_ms() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let known = vec![bob_device];
let encryption =
RoomEncryptionContent { algorithm: MEGOLM_ALGORITHM.to_string(), rotation_period_ms: 1_000, rotation_period_msgs: 100 };
let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
.expect("create session");
let not_yet =
GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 500)
.expect("re-check before the period elapses");
assert!(!not_yet.rotated, "rotation_period_ms has not elapsed yet");
assert_eq!(not_yet.session_id, first.session_id);
let elapsed =
GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 1_000)
.expect("re-check after the period elapses");
assert!(elapsed.rotated, "rotation_period_ms has elapsed");
assert_ne!(elapsed.session_id, first.session_id);
}
#[test]
fn outbound_session_rotates_on_member_removed() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let known = vec![bob_device];
let encryption = default_encryption();
let first = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
.expect("create+share");
let members_after: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
let second =
GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members_after, &known, 0)
.expect("bob removed");
assert!(second.rotated, "a previously-shared user no longer being a member forces rotation");
assert_ne!(second.session_id, first.session_id);
assert!(second.new_recipients.is_empty(), "bob is no longer eligible, nothing new to share");
}
#[test]
fn outbound_session_rotates_when_a_shared_device_disappears() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_old_device = stored_device(&bob, &device("BOBOLD"));
let bob_new_device = stored_device(&bob, &device("BOBNEW"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let first = GroupSessionManager::ensure_outbound_session(
&mut store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_old_device),
0,
)
.expect("create+share with the old device");
let second = GroupSessionManager::ensure_outbound_session(
&mut store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_new_device),
0,
)
.expect("old device gone");
assert!(second.rotated, "a previously-shared device disappearing forces rotation");
assert_ne!(second.session_id, first.session_id);
assert_eq!(second.new_recipients, vec![bob_new_device]);
}
#[test]
fn new_device_gets_current_session_without_rotation() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device_1 = stored_device(&bob, &device("BOBDEV1"));
let bob_device_2 = stored_device(&bob, &device("BOBDEV2"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let first = GroupSessionManager::ensure_outbound_session(
&mut store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_device_1),
0,
)
.expect("create+share with device 1");
GroupSessionManager::encrypt_event(&mut store, &room_id, "alice-curve", &alice_dev, "m.room.message", serde_json::json!({}), None)
.expect("encrypt one message, advancing the ratchet to index 1");
let known = vec![bob_device_1.clone(), bob_device_2.clone()];
let second = GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &encryption, &members, &known, 0)
.expect("device 2 appears");
assert!(!second.rotated, "a new device of an already-shared member never forces rotation");
assert_eq!(second.session_id, first.session_id);
assert_eq!(second.new_recipients, vec![bob_device_2]);
let shared = second.room_key_content.expect("something to share with device 2");
let session_key = SessionKey::from_base64(&shared.session_key).expect("valid session key");
let inbound = InboundGroupSession::new(&session_key, SessionConfig::version_1());
assert_eq!(inbound.first_known_index(), 1);
}
#[test]
fn room_key_from_a_room_event_is_ignored() {
let event: RawEvent = serde_json::from_value(serde_json::json!({
"event_id": "$x:example.org",
"type": "m.room_key",
"sender": "@mallory:example.org",
"origin_server_ts": 1,
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!r:example.org",
"session_id": "abc",
"session_key": "irrelevant"
}
}))
.expect("valid raw event shape");
let mut timeline = Timeline::new();
timeline.apply_timeline_batch(&[event], false, None);
assert_eq!(timeline.items().len(), 1);
assert_eq!(timeline.items()[0].content, ItemContent::Unknown);
}
#[test]
fn decrypted_payload_for_another_room_is_rejected() {
let mut alice_store = new_store();
let room_a = room("a");
let room_b = room("b");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let identity = Account::new().identity_keys();
let update = GroupSessionManager::ensure_outbound_session(
&mut alice_store,
&room_a,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_device),
0,
)
.expect("create+share");
let mut room_key_content = update.room_key_content.expect("bob is a new recipient");
let encrypted = GroupSessionManager::encrypt_event(
&mut alice_store,
&room_a,
&identity.curve25519.to_base64(),
&alice_dev,
"m.room.message",
serde_json::json!({ "body": "hi" }),
None,
)
.expect("encrypt under room A");
room_key_content.room_id = room_b.clone();
let mut bob_store = new_store();
GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
.expect("receive");
let event_id = EventId::parse("$evt:example.org").expect("valid event id");
let result = GroupSessionManager::decrypt_event(&mut bob_store, &room_b, &event_id, 1, &alice, &encrypted);
assert!(matches!(result, Err(GroupDecryptError::WrongRoom { .. })), "got {result:?}");
}
#[test]
fn session_cannot_authenticate_another_sender() {
let mut alice_store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let mallory = user("mallory");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let identity = Account::new().identity_keys();
let update = GroupSessionManager::ensure_outbound_session(
&mut alice_store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_device),
0,
)
.expect("create+share");
let room_key_content = update.room_key_content.expect("bob is a new recipient");
let mut bob_store = new_store();
GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
.expect("bob genuinely received this key from alice");
let encrypted = GroupSessionManager::encrypt_event(
&mut alice_store,
&room_id,
&identity.curve25519.to_base64(),
&alice_dev,
"m.room.message",
serde_json::json!({ "body": "hi" }),
None,
)
.expect("encrypt");
let event_id = EventId::parse("$evt:example.org").expect("valid event id");
let result = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &mallory, &encrypted);
assert!(matches!(result, Err(GroupDecryptError::WrongSender { .. })), "got {result:?}");
}
#[test]
fn inbound_session_rejects_replayed_message_index() {
let mut alice_store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let identity = Account::new().identity_keys();
let update = GroupSessionManager::ensure_outbound_session(
&mut alice_store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_device),
0,
)
.expect("create+share");
let room_key_content = update.room_key_content.expect("bob is a new recipient");
let mut bob_store = new_store();
GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
.expect("receive");
let encrypted = GroupSessionManager::encrypt_event(
&mut alice_store,
&room_id,
&identity.curve25519.to_base64(),
&alice_dev,
"m.room.message",
serde_json::json!({ "body": "hi" }),
None,
)
.expect("encrypt");
let event_id_1 = EventId::parse("$e1:example.org").expect("valid event id");
let first = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 100, &alice, &encrypted)
.expect("first decrypt succeeds");
assert_eq!(first.content, serde_json::json!({ "body": "hi" }));
let redelivered = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 100, &alice, &encrypted)
.expect("redelivery of the exact same event is not a replay");
assert_eq!(redelivered.content, serde_json::json!({ "body": "hi" }));
let event_id_2 = EventId::parse("$e2:example.org").expect("valid event id");
let replay = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_2, 200, &alice, &encrypted);
assert!(matches!(replay, Err(GroupDecryptError::Replay { .. })), "got {replay:?}");
}
#[test]
fn later_share_does_not_replace_earlier_index() {
let alice = user("alice");
let identity = Account::new().identity_keys();
let room_id = room("r");
let mut group_session = GroupSession::new(SessionConfig::version_1());
let key_at_0 = group_session.session_key();
let message_0 = group_session.encrypt(plaintext_bytes(&room_id, "msg0"));
let key_at_1 = group_session.session_key();
let content_at_1 = RoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: group_session.session_id(),
session_key: key_at_1.to_base64(),
};
let content_at_0 = RoomKeyContent { session_key: key_at_0.to_base64(), ..content_at_1.clone() };
let mut bob_store = new_store();
GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &content_at_1)
.expect("receive the later (higher-index) share first");
GroupSessionManager::receive_room_key(&mut bob_store, &alice, identity.curve25519, identity.ed25519, &content_at_0)
.expect("receive the earlier (lower-index) share second");
let encrypted_0 = MegolmEncryptedContent {
ciphertext: message_0.to_base64(),
session_id: group_session.session_id(),
sender_key: None,
device_id: None,
relates_to: None,
};
let event_id = EventId::parse("$evt0:example.org").expect("valid event id");
let decrypted = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &alice, &encrypted_0)
.expect("the earlier share's lower index must have won, so message 0 is decryptable");
assert_eq!(decrypted.content, serde_json::json!({ "body": "msg0" }));
}
#[test]
fn lower_index_share_from_a_different_sender_does_not_rebind() {
let alice = user("alice");
let mallory = user("mallory");
let alice_identity = Account::new().identity_keys();
let mallory_identity = Account::new().identity_keys();
let room_id = room("r");
let mut group_session = GroupSession::new(SessionConfig::version_1());
let key_at_0 = group_session.session_key();
let message_0 = group_session.encrypt(plaintext_bytes(&room_id, "msg0"));
let key_at_1 = group_session.session_key();
let message_1 = group_session.encrypt(plaintext_bytes(&room_id, "msg1"));
let content_at_1 = RoomKeyContent {
algorithm: MEGOLM_ALGORITHM.to_string(),
room_id: room_id.clone(),
session_id: group_session.session_id(),
session_key: key_at_1.to_base64(),
};
let content_at_0 = RoomKeyContent { session_key: key_at_0.to_base64(), ..content_at_1.clone() };
let mut bob_store = new_store();
GroupSessionManager::receive_room_key(&mut bob_store, &alice, alice_identity.curve25519, alice_identity.ed25519, &content_at_1)
.expect("receive alice's genuine share at index 1");
GroupSessionManager::receive_room_key(
&mut bob_store,
&mallory,
mallory_identity.curve25519,
mallory_identity.ed25519,
&content_at_0,
)
.expect("receiving mallory's share is not itself an error -- it's just ignored");
let encrypted_1 = MegolmEncryptedContent {
ciphertext: message_1.to_base64(),
session_id: group_session.session_id(),
sender_key: None,
device_id: None,
relates_to: None,
};
let event_id_1 = EventId::parse("$evt1:example.org").expect("valid event id");
let claimed_mallory = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 1, &mallory, &encrypted_1);
assert!(
matches!(claimed_mallory, Err(GroupDecryptError::WrongSender { .. })),
"record is still bound to alice, not mallory: {claimed_mallory:?}"
);
let claimed_alice = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_1, 1, &alice, &encrypted_1)
.expect("the record is genuinely bound to alice, so an event she sent decrypts fine");
assert_eq!(claimed_alice.content, serde_json::json!({ "body": "msg1" }));
let encrypted_0 = MegolmEncryptedContent {
ciphertext: message_0.to_base64(),
session_id: group_session.session_id(),
sender_key: None,
device_id: None,
relates_to: None,
};
let event_id_0 = EventId::parse("$evt0:example.org").expect("valid event id");
let still_index_1 = GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id_0, 2, &alice, &encrypted_0);
assert!(
matches!(still_index_1, Err(GroupDecryptError::Vodozemac(MegolmDecryptionError::UnknownMessageIndex(..)))),
"the ratchet never advanced to index 0: {still_index_1:?}"
);
}
#[test]
fn old_record_without_backed_up_field_deserializes() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let bob = user("bob");
let bob_device = stored_device(&bob, &device("BOBDEV"));
let members: BTreeSet<UserId> = [alice.clone(), bob.clone()].into_iter().collect();
let encryption = default_encryption();
let identity = Account::new().identity_keys();
let mut alice_store = new_store();
let update = GroupSessionManager::ensure_outbound_session(
&mut alice_store,
&room_id,
&alice,
&alice_dev,
&encryption,
&members,
std::slice::from_ref(&bob_device),
0,
)
.expect("create+share");
let room_key_content = update.room_key_content.expect("bob is a new recipient");
GroupSessionManager::receive_room_key(&mut store, &alice, identity.curve25519, identity.ed25519, &room_key_content)
.expect("receive");
let bytes = store
.inbound_group_session(&room_id, &room_key_content.session_id)
.expect("no error")
.expect("record exists");
let mut value: serde_json::Value = serde_json::from_slice(bytes).expect("valid JSON");
value.as_object_mut().expect("a JSON object").remove("backed_up");
let old_bytes = serde_json::to_vec(&value).expect("valid JSON");
let record: InboundGroupSessionRecord =
serde_json::from_slice(&old_bytes).expect("a record without `backed_up` at all still deserializes");
assert_eq!(record.backed_up, None);
assert_eq!(record.bound_user_id, alice);
}
#[test]
fn the_author_decrypts_their_own_message_only_through_the_adopted_outbound_session() {
let mut store = new_store();
let room_id = room("r");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let members: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
let identity = Account::new().identity_keys();
let event_id = EventId::parse("$own:example.org").expect("valid event id");
let update =
GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &default_encryption(), &members, &[], 0)
.expect("create session");
assert!(update.rotated);
let encrypted = GroupSessionManager::encrypt_event(
&mut store,
&room_id,
&identity.curve25519.to_base64(),
&alice_dev,
"m.room.message",
serde_json::json!({ "body": "mine" }),
None,
)
.expect("encrypt");
let before = GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted);
assert!(matches!(before, Err(GroupDecryptError::MissingSession { .. })), "got {before:?}");
GroupSessionManager::adopt_own_outbound_session(&mut store, &room_id, &alice, identity.curve25519, identity.ed25519, &update)
.expect("adopt");
let plaintext = GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted).expect("the author decrypts");
assert_eq!(plaintext.content, serde_json::json!({ "body": "mine" }));
let again =
GroupSessionManager::ensure_outbound_session(&mut store, &room_id, &alice, &alice_dev, &default_encryption(), &members, &[], 1)
.expect("re-check");
assert!(!again.rotated);
GroupSessionManager::adopt_own_outbound_session(&mut store, &room_id, &alice, identity.curve25519, identity.ed25519, &again)
.expect("no-op");
GroupSessionManager::decrypt_event(&mut store, &room_id, &event_id, 1, &alice, &encrypted).expect("still decrypts");
}
#[test]
fn a_forwarded_inbound_session_decrypts_the_original_senders_later_event() {
let mut alice_store = new_store();
let mut bob_store = new_store();
let room_id = room("fwd");
let alice = user("alice");
let alice_dev = device("ALICEDEV");
let members: BTreeSet<UserId> = [alice.clone()].into_iter().collect();
let identity = Account::new().identity_keys();
let event_id = EventId::parse("$fwd:example.org").expect("valid event id");
let update = GroupSessionManager::ensure_outbound_session(
&mut alice_store,
&room_id,
&alice,
&alice_dev,
&default_encryption(),
&members,
&[],
0,
)
.expect("create");
GroupSessionManager::adopt_own_outbound_session(
&mut alice_store,
&room_id,
&alice,
identity.curve25519,
identity.ed25519,
&update,
)
.expect("adopt");
let encrypted = GroupSessionManager::encrypt_event(
&mut alice_store,
&room_id,
&identity.curve25519.to_base64(),
&alice_dev,
"m.room.message",
serde_json::json!({ "body": "later" }),
None,
)
.expect("encrypt");
let forwarded = GroupSessionManager::forwarded_room_key_content(
&alice_store,
&room_id,
&encrypted.session_id,
&identity.curve25519.to_base64(),
)
.expect("export")
.expect("inbound exists at the first index");
let stored = GroupSessionManager::receive_forwarded_room_key(
&mut bob_store,
&alice,
identity.curve25519,
identity.ed25519,
&forwarded,
)
.expect("import");
assert!(stored);
let plaintext =
GroupSessionManager::decrypt_event(&mut bob_store, &room_id, &event_id, 1, &alice, &encrypted).expect("bob decrypts");
assert_eq!(plaintext.content, serde_json::json!({ "body": "later" }));
}
}