use crate::canonical_json;
use crate::crypto::account::OlmAccountState;
use crate::crypto::device_tracker::{DeviceTracker, StoredDevice};
use crate::error::MessengerError;
use crate::ids::{DeviceId, RequestId, UserId};
use crate::store::CryptoStore;
use crate::wire::{OlmCiphertextInfo, OlmEncryptedContent, OutgoingRequest};
use base64::Engine;
use mail4agent_vodozemac::olm::{OlmMessage, Session, SessionConfig, SessionPickle};
use mail4agent_vodozemac::{Curve25519PublicKey, Ed25519PublicKey};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const SIGNED_CURVE25519: &str = "signed_curve25519";
fn wire_base64_engine() -> base64::engine::GeneralPurpose {
base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::general_purpose::NO_PAD
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
)
}
fn pickle_session(session: &Session) -> Result<Vec<u8>, MessengerError> {
serde_json::to_vec(&session.pickle())
.map_err(|source| MessengerError::Crypto(format!("encode pickled Olm session: {source}")))
}
fn unpickle_session(bytes: &[u8]) -> Result<Session, MessengerError> {
let pickle: SessionPickle = serde_json::from_slice(bytes)
.map_err(|source| MessengerError::Crypto(format!("decode pickled Olm session: {source}")))?;
Ok(Session::from_pickle(pickle))
}
#[derive(Serialize, Deserialize)]
struct OlmPlaintextPayload {
#[serde(rename = "type")]
event_type: String,
content: serde_json::Value,
sender: UserId,
sender_device: DeviceId,
keys: OlmPlaintextKeys,
recipient: UserId,
recipient_keys: OlmPlaintextKeys,
}
#[derive(Serialize, Deserialize)]
struct OlmPlaintextKeys {
ed25519: String,
}
#[derive(Deserialize)]
struct KeysClaimResponseBody {
#[serde(default)]
one_time_keys: BTreeMap<UserId, BTreeMap<DeviceId, BTreeMap<String, serde_json::Value>>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeysClaimOutcome {
pub established: Vec<(UserId, DeviceId)>,
pub refused: Vec<KeysClaimRefusal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeysClaimRefusal {
pub user_id: UserId,
pub device_id: DeviceId,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DecryptedToDevice {
pub sender: UserId,
pub sender_device_curve25519: Curve25519PublicKey,
pub sender_ed25519: Ed25519PublicKey,
pub event_type: String,
pub content: serde_json::Value,
}
#[derive(Debug, thiserror::Error)]
pub enum OlmDecryptError {
#[error("the Olm envelope has no ciphertext entry for this device's own curve25519 key")]
NotAddressedToUs,
#[error("malformed Olm ciphertext: {0}")]
MalformedCiphertext(String),
#[error("no known Olm session for sender key {0} decrypted this message, and it is not a pre-key message")]
NoMatchingSession(String),
#[error("failed to establish an inbound Olm session: {0}")]
SessionEstablishment(String),
#[error("sender device for curve25519 key {curve25519} is not yet known to this account")]
UnknownSenderDevice {
user_id: UserId,
curve25519: String,
},
#[error("payload binding check failed: {0}")]
PayloadMismatch(String),
#[error("decrypted payload was not valid JSON: {0}")]
InvalidPayload(#[from] serde_json::Error),
#[error("store: {0}")]
Store(#[from] crate::store::StoreError),
#[error("{0}")]
Messenger(#[from] MessengerError),
}
pub struct OlmSessionManager;
impl OlmSessionManager {
pub fn encode_olm_message(message: &OlmMessage) -> OlmCiphertextInfo {
let (message_type, ciphertext) = message.to_parts();
OlmCiphertextInfo { message_type: message_type as u8, body: wire_base64_engine().encode(ciphertext) }
}
pub fn decode_olm_message(info: &OlmCiphertextInfo) -> Result<OlmMessage, MessengerError> {
let bytes = wire_base64_engine()
.decode(&info.body)
.map_err(|source| MessengerError::Crypto(format!("malformed Olm ciphertext body: {source}")))?;
OlmMessage::from_parts(info.message_type as usize, &bytes)
.map_err(|source| MessengerError::Crypto(format!("malformed Olm message: {source}")))
}
pub fn sessions_missing_for<'a, S: CryptoStore>(
store: &S,
devices: &'a [StoredDevice],
) -> Result<Vec<&'a StoredDevice>, MessengerError> {
let mut missing = Vec::new();
for device in devices {
let curve_b64 = device.curve25519.to_base64();
if store.olm_sessions_for_device(&curve_b64)?.is_empty() {
missing.push(device);
}
}
Ok(missing)
}
pub fn keys_claim_request(id: RequestId, devices: &[&StoredDevice]) -> Option<OutgoingRequest> {
if devices.is_empty() {
return None;
}
let mut by_user: BTreeMap<UserId, serde_json::Map<String, serde_json::Value>> = BTreeMap::new();
for device in devices {
by_user.entry(device.user_id.clone()).or_default().insert(
device.device_id.as_str().to_string(),
serde_json::Value::String(SIGNED_CURVE25519.to_string()),
);
}
let mut top = serde_json::Map::new();
for (user_id, device_map) in by_user {
top.insert(user_id.as_str().to_string(), serde_json::Value::Object(device_map));
}
let body = serde_json::json!({ "one_time_keys": serde_json::Value::Object(top) });
Some(OutgoingRequest::keys_claim(id, body))
}
pub fn on_keys_claim_response<S: CryptoStore>(
store: &mut S,
account: &OlmAccountState,
requested: &[&StoredDevice],
body: &[u8],
) -> Result<KeysClaimOutcome, MessengerError> {
let parsed: KeysClaimResponseBody = serde_json::from_slice(body)?;
let mut outcome = KeysClaimOutcome::default();
for device in requested {
let Some(by_key_id) =
parsed.one_time_keys.get(&device.user_id).and_then(|by_device| by_device.get(&device.device_id))
else {
continue;
};
let Some((key_id, raw)) = by_key_id.iter().next() else { continue };
match Self::verify_and_claim(account, device, key_id, raw) {
Ok(session) => {
let curve_b64 = device.curve25519.to_base64();
let session_id = session.session_id();
store.save_olm_session(&curve_b64, &session_id, pickle_session(&session)?)?;
outcome.established.push((device.user_id.clone(), device.device_id.clone()));
}
Err(reason) => outcome.refused.push(KeysClaimRefusal {
user_id: device.user_id.clone(),
device_id: device.device_id.clone(),
reason,
}),
}
}
Ok(outcome)
}
fn verify_and_claim(
account: &OlmAccountState,
device: &StoredDevice,
key_id: &str,
raw: &serde_json::Value,
) -> Result<Session, String> {
if !key_id.starts_with(SIGNED_CURVE25519) {
return Err(format!("unexpected one-time-key algorithm in key id {key_id:?}"));
}
let sig_key_id = format!("ed25519:{}", device.device_id.as_str());
canonical_json::verify_json_signature(raw, device.user_id.as_str(), &sig_key_id, &device.ed25519)
.map_err(|source| format!("claimed one-time key signature failed to verify: {source}"))?;
let key_b64 = raw
.get("key")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "claimed one-time key object has no \"key\" field".to_string())?;
let one_time_key = Curve25519PublicKey::from_base64(key_b64)
.map_err(|source| format!("malformed claimed one-time key: {source}"))?;
account
.create_outbound_session(SessionConfig::version_1(), device.curve25519, one_time_key)
.map_err(|source| format!("failed to create outbound Olm session: {source}"))
}
pub fn encrypt_to_device<S: CryptoStore>(
store: &mut S,
account: &OlmAccountState,
our_user_id: &UserId,
our_device_id: &DeviceId,
target: &StoredDevice,
event_type: &str,
content: serde_json::Value,
) -> Result<OlmEncryptedContent, MessengerError> {
let their_curve_b64 = target.curve25519.to_base64();
let sessions = store.olm_sessions_for_device(&their_curve_b64)?;
let latest = sessions.into_iter().next().ok_or_else(|| {
MessengerError::Crypto(format!(
"no Olm session established with device {} of {} yet",
target.device_id.as_str(),
target.user_id.as_str()
))
})?;
let mut session = unpickle_session(&latest)?;
let payload = OlmPlaintextPayload {
event_type: event_type.to_string(),
content,
sender: our_user_id.clone(),
sender_device: our_device_id.clone(),
keys: OlmPlaintextKeys { ed25519: account.identity_keys().ed25519.to_base64() },
recipient: target.user_id.clone(),
recipient_keys: OlmPlaintextKeys { ed25519: target.ed25519.to_base64() },
};
let plaintext = serde_json::to_vec(&payload)?;
let olm_message = session.encrypt(plaintext).map_err(|source| {
MessengerError::Crypto(format!("Olm encrypt to {}: {source}", target.device_id.as_str()))
})?;
let session_id = session.session_id();
store.save_olm_session(&their_curve_b64, &session_id, pickle_session(&session)?)?;
let mut ciphertext = BTreeMap::new();
ciphertext.insert(their_curve_b64, Self::encode_olm_message(&olm_message));
Ok(OlmEncryptedContent { sender_key: account.identity_keys().curve25519.to_base64(), ciphertext })
}
pub fn decrypt_to_device<S: CryptoStore>(
store: &mut S,
account: &mut OlmAccountState,
our_user_id: &UserId,
event_sender: &UserId,
content: &OlmEncryptedContent,
) -> Result<DecryptedToDevice, OlmDecryptError> {
let our_curve_b64 = account.identity_keys().curve25519.to_base64();
let cipher_info = content.ciphertext.get(&our_curve_b64).ok_or(OlmDecryptError::NotAddressedToUs)?;
let olm_message = Self::decode_olm_message(cipher_info)
.map_err(|source| OlmDecryptError::MalformedCiphertext(source.to_string()))?;
let sender_curve_b64 = content.sender_key.clone();
let sender_devices = DeviceTracker::devices_for_user(store, event_sender)?;
let Some(sender_device) = sender_devices.iter().find(|device| device.curve25519.to_base64() == sender_curve_b64) else {
store.mark_user_tracked(event_sender, true)?;
return Err(OlmDecryptError::UnknownSenderDevice { user_id: event_sender.clone(), curve25519: sender_curve_b64 });
};
let known_sessions: Vec<Vec<u8>> = store.olm_sessions_for_device(&sender_curve_b64)?;
let mut established: Option<(Session, Vec<u8>)> = None;
for pickle_bytes in &known_sessions {
let mut session = unpickle_session(pickle_bytes)
.map_err(|source| OlmDecryptError::SessionEstablishment(format!("corrupt stored session: {source}")))?;
if let Ok(plaintext) = session.decrypt(&olm_message) {
established = Some((session, plaintext));
break;
}
}
let plaintext = match established {
Some((session, plaintext)) => {
let session_id = session.session_id();
store.save_olm_session(&sender_curve_b64, &session_id, pickle_session(&session)?)?;
plaintext
}
None => match &olm_message {
OlmMessage::PreKey(pre_key_message) => {
let sender_curve = Curve25519PublicKey::from_base64(&sender_curve_b64).map_err(|source| {
OlmDecryptError::MalformedCiphertext(format!("malformed sender_key: {source}"))
})?;
let result = account
.create_inbound_session(store, SessionConfig::version_1(), sender_curve, pre_key_message)
.map_err(|source| OlmDecryptError::SessionEstablishment(source.to_string()))?;
let session_id = result.session.session_id();
store.save_olm_session(&sender_curve_b64, &session_id, pickle_session(&result.session)?)?;
result.plaintext
}
OlmMessage::Normal(_) => return Err(OlmDecryptError::NoMatchingSession(sender_curve_b64)),
},
};
let payload: OlmPlaintextPayload = serde_json::from_slice(&plaintext)?;
if payload.recipient != *our_user_id {
return Err(OlmDecryptError::PayloadMismatch(format!(
"payload recipient {:?} does not match this account's own user id {:?}",
payload.recipient.as_str(),
our_user_id.as_str()
)));
}
let our_ed25519_b64 = account.identity_keys().ed25519.to_base64();
if payload.recipient_keys.ed25519 != our_ed25519_b64 {
return Err(OlmDecryptError::PayloadMismatch(
"payload recipient_keys.ed25519 does not match this account's own ed25519 key".to_string(),
));
}
if payload.sender != *event_sender {
return Err(OlmDecryptError::PayloadMismatch(format!(
"payload sender {:?} does not match the to-device event's own sender {:?}",
payload.sender.as_str(),
event_sender.as_str()
)));
}
if payload.keys.ed25519 != sender_device.ed25519.to_base64() {
return Err(OlmDecryptError::PayloadMismatch(
"payload keys.ed25519 does not match the stored ed25519 key of the sender device".to_string(),
));
}
Ok(DecryptedToDevice {
sender: payload.sender,
sender_device_curve25519: sender_device.curve25519,
sender_ed25519: sender_device.ed25519,
event_type: payload.event_type,
content: payload.content,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{InsecurePlainCodecForTests, Store};
fn new_store(device_id: &str) -> Store<InsecurePlainCodecForTests> {
Store::new(DeviceId::parse(device_id).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 fallback_present() -> Vec<String> {
vec![SIGNED_CURVE25519.to_string()]
}
fn stored_device(user_id: &UserId, device_id: &DeviceId, account: &OlmAccountState) -> StoredDevice {
let identity = account.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 claim_response_body_from_upload(upload_body: &serde_json::Value, user_id: &UserId, device_id: &DeviceId) -> Vec<u8> {
let one_time_keys =
upload_body.get("one_time_keys").and_then(serde_json::Value::as_object).expect("upload carries OTKs");
let (key_id, key_value) = one_time_keys.iter().next().expect("at least one OTK");
serde_json::to_vec(&serde_json::json!({
"one_time_keys": { user_id.as_str(): { device_id.as_str(): { key_id: key_value } } }
}))
.expect("valid JSON")
}
#[test]
fn olm_session_established_via_claimed_otk() {
let mut alice_store = new_store("ALICEDEV");
let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
let bob_user = user("bob");
let bob_device_id = device("BOBDEV");
let mut bob_store = new_store("BOBDEV");
let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
let upload = bob_account
.keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
.expect("builds a request")
.expect("something to upload");
let claim_body =
claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
let outcome = OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
.expect("applies the claim response");
assert_eq!(outcome.established, vec![(bob_user, bob_device_id)]);
assert!(outcome.refused.is_empty());
let sessions = alice_store
.olm_sessions_for_device(&bob_account.identity_keys().curve25519.to_base64())
.expect("no error");
assert_eq!(sessions.len(), 1, "exactly one session established");
}
#[test]
fn olm_session_rejects_out_of_order_ratchet_message() {
let mut alice_store = new_store("ALICEDEV");
let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
let alice_user = user("alice");
let alice_device_id = device("ALICEDEV");
let bob_user = user("bob");
let bob_device_id = device("BOBDEV");
let mut bob_store = new_store("BOBDEV");
let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
let upload = bob_account
.keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
.expect("builds a request")
.expect("something to upload");
let claim_body =
claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
.expect("alice establishes a session with bob");
let alice_device_keys =
alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
let query_body = serde_json::to_vec(&serde_json::json!({
"device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
}))
.expect("valid JSON");
DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
let message_0 = OlmSessionManager::encrypt_to_device(
&mut alice_store,
&alice_account,
&alice_user,
&alice_device_id,
&bob_device,
"m.test",
serde_json::json!({ "i": 0 }),
)
.expect("encrypt message 0");
let message_1 = OlmSessionManager::encrypt_to_device(
&mut alice_store,
&alice_account,
&alice_user,
&alice_device_id,
&bob_device,
"m.test",
serde_json::json!({ "i": 1 }),
)
.expect("encrypt message 1");
let decrypted_1 = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_1)
.expect("message 1 decrypts, establishing the session");
assert_eq!(decrypted_1.content, serde_json::json!({ "i": 1 }));
let decrypted_0 = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
.expect("message 0 decrypts through the same, already-established session");
assert_eq!(decrypted_0.content, serde_json::json!({ "i": 0 }));
let replay = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0);
assert!(replay.is_err(), "replaying an already-decrypted message must be rejected");
assert!(
matches!(replay, Err(OlmDecryptError::SessionEstablishment(_))),
"got {replay:?}"
);
}
#[test]
fn replay_is_rejected_after_reload_from_records() {
let mut alice_store = new_store("ALICEDEV");
let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
let alice_user = user("alice");
let alice_device_id = device("ALICEDEV");
let bob_user = user("bob");
let bob_device_id = device("BOBDEV");
let mut bob_store = new_store("BOBDEV");
let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
let upload = bob_account
.keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
.expect("builds a request")
.expect("something to upload");
let claim_body =
claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
.expect("alice establishes a session with bob");
let alice_device_keys =
alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
let query_body = serde_json::to_vec(&serde_json::json!({
"device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
}))
.expect("valid JSON");
DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
let message_0 = OlmSessionManager::encrypt_to_device(
&mut alice_store,
&alice_account,
&alice_user,
&alice_device_id,
&bob_device,
"m.test",
serde_json::json!({ "i": 0 }),
)
.expect("encrypt message 0");
OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
.expect("message 0 decrypts and establishes bob's session");
let batch = bob_store.take_flush_batch().expect("decrypting dirtied the store");
bob_store.ack_flush(batch.id);
let mut reloaded_store =
Store::load(batch.records, InsecurePlainCodecForTests, bob_device_id.clone()).expect("reload succeeds");
let mut reloaded_account =
OlmAccountState::load_or_create(&mut reloaded_store).expect("reload reuses the persisted account");
let replay = OlmSessionManager::decrypt_to_device(
&mut reloaded_store,
&mut reloaded_account,
&bob_user,
&alice_user,
&message_0,
);
assert!(replay.is_err(), "a replay must still be rejected after a full store reload");
assert!(
matches!(replay, Err(OlmDecryptError::SessionEstablishment(_))),
"got {replay:?}"
);
}
#[test]
fn unknown_sender_prekey_message_stays_decryptable_after_the_device_is_learned() {
let mut alice_store = new_store("ALICEDEV");
let alice_account = OlmAccountState::load_or_create(&mut alice_store).expect("create alice's account");
let alice_user = user("alice");
let alice_device_id = device("ALICEDEV");
let bob_user = user("bob");
let bob_device_id = device("BOBDEV");
let mut bob_store = new_store("BOBDEV");
let mut bob_account = OlmAccountState::load_or_create(&mut bob_store).expect("create bob's account");
bob_account.on_sync_counts(&mut bob_store, 0, &fallback_present()).expect("top up bob's OTKs");
let upload = bob_account
.keys_upload_request(RequestId::next(0), &bob_user, &bob_device_id)
.expect("builds a request")
.expect("something to upload");
let claim_body =
claim_response_body_from_upload(&upload.body.expect("upload has a body"), &bob_user, &bob_device_id);
let bob_device = stored_device(&bob_user, &bob_device_id, &bob_account);
OlmSessionManager::on_keys_claim_response(&mut alice_store, &alice_account, &[&bob_device], &claim_body)
.expect("alice establishes a session with bob");
let message_0 = OlmSessionManager::encrypt_to_device(
&mut alice_store,
&alice_account,
&alice_user,
&alice_device_id,
&bob_device,
"m.test",
serde_json::json!({ "i": 0 }),
)
.expect("encrypt message 0");
let first = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0);
assert!(matches!(first, Err(OlmDecryptError::UnknownSenderDevice { .. })), "got {first:?}");
let alice_curve = alice_account.identity_keys().curve25519.to_base64();
assert!(
bob_store.olm_sessions_for_device(&alice_curve).expect("no error").is_empty(),
"the refused attempt must not create an inbound session (it would consume the one-time key)"
);
let alice_device_keys =
alice_account.device_keys_json(&alice_user, &alice_device_id).expect("sign alice's device_keys");
let query_body = serde_json::to_vec(&serde_json::json!({
"device_keys": { alice_user.as_str(): { alice_device_id.as_str(): alice_device_keys } }
}))
.expect("valid JSON");
DeviceTracker::on_keys_query_response(&mut bob_store, &query_body).expect("bob learns alice's device");
let retry = OlmSessionManager::decrypt_to_device(&mut bob_store, &mut bob_account, &bob_user, &alice_user, &message_0)
.expect("the retry decrypts: nothing was consumed by the refused attempt");
assert_eq!(retry.content, serde_json::json!({ "i": 0 }));
}
}