use std::sync::Arc;
use ruma::{
api::client::dehydrated_device::{put_dehydrated_device, DehydratedDeviceData},
assign,
events::AnyToDeviceEvent,
serde::Raw,
DeviceId,
};
use thiserror::Error;
use tracing::{instrument, trace};
use vodozemac::{DehydratedDeviceError, LibolmPickleError};
use crate::{
store::{
types::{Changes, DehydratedDeviceKey, RoomKeyInfo},
CryptoStoreWrapper, MemoryStore, Store,
},
verification::VerificationMachine,
Account, CryptoStoreError, DecryptionSettings, EncryptionSyncChanges, OlmError, OlmMachine,
SignatureError,
};
#[derive(Debug, Error)]
pub enum DehydrationError {
#[error(transparent)]
LegacyPickle(#[from] LibolmPickleError),
#[error(transparent)]
Pickle(#[from] DehydratedDeviceError),
#[error("The pickle key has an invalid length, expected 32 bytes, got {0}")]
PickleKeyLength(usize),
#[error("The self-signing key is missing, can't create a dehydrated device")]
MissingSigningKey(#[from] SignatureError),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Store(#[from] CryptoStoreError),
}
#[derive(Debug)]
pub struct DehydratedDevices {
pub(crate) inner: OlmMachine,
}
impl DehydratedDevices {
pub async fn create(&self) -> Result<DehydratedDevice, DehydrationError> {
let user_id = self.inner.user_id();
let user_identity = self.inner.store().private_identity();
let account = Account::new_dehydrated(user_id);
let store =
Arc::new(CryptoStoreWrapper::new(user_id, account.device_id(), MemoryStore::new()));
let verification_machine = VerificationMachine::new(
account.static_data().clone(),
user_identity.clone(),
store.clone(),
);
let store =
Store::new(account.static_data().clone(), user_identity, store, verification_machine);
store
.save_pending_changes(crate::store::types::PendingChanges { account: Some(account) })
.await?;
Ok(DehydratedDevice { store })
}
pub async fn rehydrate(
&self,
pickle_key: &DehydratedDeviceKey,
device_id: &DeviceId,
device_data: Raw<DehydratedDeviceData>,
) -> Result<RehydratedDevice, DehydrationError> {
let rehydrated =
self.inner.rehydrate(pickle_key.inner.as_ref(), device_id, device_data).await?;
Ok(RehydratedDevice { rehydrated, original: self.inner.to_owned() })
}
pub async fn get_dehydrated_device_pickle_key(
&self,
) -> Result<Option<DehydratedDeviceKey>, DehydrationError> {
Ok(self.inner.store().load_dehydrated_device_pickle_key().await?)
}
pub async fn save_dehydrated_device_pickle_key(
&self,
dehydrated_device_pickle_key: &DehydratedDeviceKey,
) -> Result<(), DehydrationError> {
let changes = Changes {
dehydrated_device_pickle_key: Some(dehydrated_device_pickle_key.clone()),
..Default::default()
};
Ok(self.inner.store().save_changes(changes).await?)
}
pub async fn delete_dehydrated_device_pickle_key(&self) -> Result<(), DehydrationError> {
Ok(self.inner.store().delete_dehydrated_device_pickle_key().await?)
}
}
#[derive(Debug)]
pub struct RehydratedDevice {
rehydrated: OlmMachine,
original: OlmMachine,
}
impl RehydratedDevice {
#[instrument(
skip_all,
fields(
user_id = ?self.original.user_id(),
rehydrated_device_id = ?self.rehydrated.device_id(),
original_device_id = ?self.original.device_id()
)
)]
pub async fn receive_events(
&self,
events: Vec<Raw<AnyToDeviceEvent>>,
decryption_settings: &DecryptionSettings,
) -> Result<Vec<RoomKeyInfo>, OlmError> {
trace!("Receiving events for a rehydrated Device");
let sync_changes = EncryptionSyncChanges {
to_device_events: events,
next_batch_token: None,
one_time_keys_counts: &Default::default(),
changed_devices: &Default::default(),
unused_fallback_keys: None,
};
let mut rehydrated_transaction = self.rehydrated.store().transaction().await;
let (_, changes) = self
.rehydrated
.preprocess_sync_changes(&mut rehydrated_transaction, sync_changes, decryption_settings)
.await?;
let room_keys = &changes.inbound_group_sessions;
let updates = room_keys.iter().map(Into::into).collect();
trace!(room_key_count = room_keys.len(), "Collected room keys from the rehydrated device");
self.original.store().save_inbound_group_sessions(room_keys).await?;
rehydrated_transaction.commit().await?;
self.rehydrated.store().save_changes(changes).await?;
Ok(updates)
}
}
#[derive(Debug)]
pub struct DehydratedDevice {
store: Store,
}
impl DehydratedDevice {
#[instrument(
skip_all, fields(
user_id = ?self.store.static_account().user_id,
device_id = ?self.store.static_account().device_id,
identity_keys = ?self.store.static_account().identity_keys,
)
)]
pub async fn keys_for_upload(
&self,
initial_device_display_name: String,
pickle_key: &DehydratedDeviceKey,
) -> Result<put_dehydrated_device::unstable::Request, DehydrationError> {
let mut transaction = self.store.transaction().await;
let account = transaction.account().await?;
account.generate_fallback_key_if_needed();
let (device_keys, one_time_keys, fallback_keys) = account.keys_for_upload();
let mut device_keys = device_keys
.expect("We should always try to upload device keys for a dehydrated device.");
self.store.private_identity().lock().await.sign_device_keys(&mut device_keys).await?;
trace!("Creating an upload request for a dehydrated device");
let device_id = self.store.static_account().device_id.clone();
let device_data = account.dehydrate(pickle_key.inner.as_ref());
let initial_device_display_name = Some(initial_device_display_name);
transaction.commit().await?;
Ok(
assign!(put_dehydrated_device::unstable::Request::new(device_id, device_data, device_keys.to_raw()), {
one_time_keys, fallback_keys, initial_device_display_name
}),
)
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, iter};
use js_option::JsOption;
use matrix_sdk_test::async_test;
use ruma::{
api::client::{
dehydrated_device::put_dehydrated_device,
keys::get_keys::v3::Response as KeysQueryResponse,
},
assign,
encryption::DeviceKeys,
events::AnyToDeviceEvent,
room_id,
serde::Raw,
user_id, DeviceId, RoomId, TransactionId, UserId,
};
use crate::{
dehydrated_devices::DehydratedDevice,
machine::{
test_helpers::{create_session, get_prepared_machine_test_helper},
tests::to_device_requests_to_content,
},
olm::OutboundGroupSession,
store::types::DehydratedDeviceKey,
types::{events::ToDeviceEvent, DeviceKeys as DeviceKeysType},
utilities::json_convert,
DecryptionSettings, EncryptionSettings, OlmMachine, TrustRequirement,
};
fn pickle_key() -> DehydratedDeviceKey {
DehydratedDeviceKey::from_bytes(&[0u8; 32])
}
fn user_id() -> &'static UserId {
user_id!("@alice:localhost")
}
async fn get_olm_machine() -> OlmMachine {
let (olm_machine, _) = get_prepared_machine_test_helper(user_id(), false).await;
olm_machine.bootstrap_cross_signing(false).await.unwrap();
olm_machine
}
async fn receive_device_keys(
olm_machine: &OlmMachine,
user_id: &UserId,
device_id: &DeviceId,
device_keys: Raw<DeviceKeys>,
) {
let device_keys = BTreeMap::from([(device_id.to_owned(), device_keys)]);
let keys_query_response = assign!(
KeysQueryResponse::new(), {
device_keys: BTreeMap::from([(user_id.to_owned(), device_keys)]),
}
);
olm_machine
.mark_request_as_sent(&TransactionId::new(), &keys_query_response)
.await
.unwrap();
}
async fn send_room_key(
machine: &OlmMachine,
room_id: &RoomId,
recipient: &UserId,
) -> (Raw<AnyToDeviceEvent>, OutboundGroupSession) {
let to_device_requests = machine
.share_room_key(room_id, iter::once(recipient), EncryptionSettings::default())
.await
.unwrap();
let event = ToDeviceEvent::new(
user_id().to_owned(),
to_device_requests_to_content(to_device_requests),
);
let session =
machine.inner.group_session_manager.get_outbound_group_session(room_id).expect(
"An outbound group session should have been created when the room key was shared",
);
(
json_convert(&event)
.expect("We should be able to convert the to-device event into it's Raw variatn"),
session,
)
}
#[async_test]
async fn test_dehydrated_device_creation() {
let olm_machine = get_olm_machine().await;
let dehydrated_device = olm_machine.dehydrated_devices().create().await.unwrap();
let request = dehydrated_device
.keys_for_upload("Foo".to_owned(), &pickle_key())
.await
.expect("We should be able to create a request to upload a dehydrated device");
assert!(
!request.one_time_keys.is_empty(),
"The dehydrated device creation request should contain some one-time keys"
);
assert!(
!request.fallback_keys.is_empty(),
"The dehydrated device creation request should contain some fallback keys"
);
let device_keys: DeviceKeysType = request.device_keys.deserialize_as().unwrap();
assert_eq!(
device_keys.dehydrated,
JsOption::Some(true),
"The device keys of the dehydrated device should be marked as dehydrated."
);
}
#[async_test]
async fn test_dehydrated_device_rehydration() {
let room_id = room_id!("!test:example.org");
let alice = get_olm_machine().await;
let dehydrated_device = alice.dehydrated_devices().create().await.unwrap();
let mut request = dehydrated_device
.keys_for_upload("Foo".to_owned(), &pickle_key())
.await
.expect("We should be able to create a request to upload a dehydrated device");
let (key_id, one_time_key) = request
.one_time_keys
.pop_first()
.expect("The dehydrated device creation request should contain a one-time key");
receive_device_keys(&alice, user_id(), &request.device_id, request.device_keys).await;
create_session(&alice, user_id(), &request.device_id, key_id, one_time_key).await;
let (event, group_session) = send_room_key(&alice, room_id, user_id()).await;
let bob = get_olm_machine().await;
let room_key = bob
.store()
.get_inbound_group_session(room_id, group_session.session_id())
.await
.unwrap();
assert!(
room_key.is_none(),
"We should not have access to the room key that was only sent to the dehydrated device"
);
let rehydrated = bob
.dehydrated_devices()
.rehydrate(&pickle_key(), &request.device_id, request.device_data)
.await
.expect("We should be able to rehydrate the device");
assert_eq!(rehydrated.rehydrated.device_id(), request.device_id);
assert_eq!(rehydrated.original.device_id(), alice.device_id());
let decryption_settings =
DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted };
let ret = rehydrated
.receive_events(vec![event], &decryption_settings)
.await
.expect("We should be able to push to-device events into the rehydrated device");
assert_eq!(ret.len(), 1, "The rehydrated device should have imported a room key");
let room_key = bob
.store()
.get_inbound_group_session(room_id, group_session.session_id())
.await
.unwrap()
.expect("We should now have access to the room key, since the rehydrated device imported it for us");
assert_eq!(
room_key.session_id(),
group_session.session_id(),
"The session ids of the imported room key and the outbound group session should match"
);
}
#[async_test]
async fn test_dehydrated_device_pickle_key_cache() {
let alice = get_olm_machine().await;
let dehydrated_manager = alice.dehydrated_devices();
let stored_key = dehydrated_manager.get_dehydrated_device_pickle_key().await.unwrap();
assert!(stored_key.is_none());
let pickle_key = DehydratedDeviceKey::new().unwrap();
dehydrated_manager.save_dehydrated_device_pickle_key(&pickle_key).await.unwrap();
let stored_key =
dehydrated_manager.get_dehydrated_device_pickle_key().await.unwrap().unwrap();
assert_eq!(stored_key.to_base64(), pickle_key.to_base64());
let dehydrated_device = dehydrated_manager.create().await.unwrap();
let request = dehydrated_device
.keys_for_upload("Foo".to_owned(), &stored_key)
.await
.expect("We should be able to create a request to upload a dehydrated device");
dehydrated_manager
.rehydrate(&stored_key, &request.device_id, request.device_data)
.await
.expect("We should be able to rehydrate the device");
dehydrated_manager
.delete_dehydrated_device_pickle_key()
.await
.expect("Should be able to delete the dehydrated device key");
let stored_key = dehydrated_manager.get_dehydrated_device_pickle_key().await.unwrap();
assert!(stored_key.is_none());
}
#[async_test]
async fn test_legacy_dehydrated_device_rehydration() {
let room_id = room_id!("!test:example.org");
let alice = get_olm_machine().await;
let dehydrated_device = alice.dehydrated_devices().create().await.unwrap();
let mut request =
legacy_dehydrated_device_keys_for_upload(&dehydrated_device, &pickle_key()).await;
let (key_id, one_time_key) = request
.one_time_keys
.pop_first()
.expect("The dehydrated device creation request should contain a one-time key");
let device_id = request.device_id;
receive_device_keys(&alice, user_id(), &device_id, request.device_keys).await;
create_session(&alice, user_id(), &device_id, key_id, one_time_key).await;
let (event, group_session) = send_room_key(&alice, room_id, user_id()).await;
let bob = get_olm_machine().await;
let room_key = bob
.store()
.get_inbound_group_session(room_id, group_session.session_id())
.await
.unwrap();
assert!(
room_key.is_none(),
"We should not have access to the room key that was only sent to the dehydrated device"
);
let rehydrated = bob
.dehydrated_devices()
.rehydrate(&pickle_key(), &device_id, request.device_data)
.await
.expect("We should be able to rehydrate the device");
assert_eq!(rehydrated.rehydrated.device_id(), &device_id);
assert_eq!(rehydrated.original.device_id(), alice.device_id());
let decryption_settings =
DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted };
let ret = rehydrated
.receive_events(vec![event], &decryption_settings)
.await
.expect("We should be able to push to-device events into the rehydrated device");
assert_eq!(ret.len(), 1, "The rehydrated device should have imported a room key");
let room_key = bob
.store()
.get_inbound_group_session(room_id, group_session.session_id())
.await
.unwrap()
.expect("We should now have access to the room key, since the rehydrated device imported it for us");
assert_eq!(
room_key.session_id(),
group_session.session_id(),
"The session ids of the imported room key and the outbound group session should match"
);
}
async fn legacy_dehydrated_device_keys_for_upload(
dehydrated_device: &DehydratedDevice,
pickle_key: &DehydratedDeviceKey,
) -> put_dehydrated_device::unstable::Request {
let mut transaction = dehydrated_device.store.transaction().await;
let account = transaction.account().await.unwrap();
account.generate_fallback_key_if_needed();
let (device_keys, one_time_keys, fallback_keys) = account.keys_for_upload();
let mut device_keys = device_keys.unwrap();
dehydrated_device
.store
.private_identity()
.lock()
.await
.sign_device_keys(&mut device_keys)
.await
.expect("Should be able to cross-sign a device");
let device_id = account.device_id().to_owned();
let device_data = account.legacy_dehydrate(pickle_key.inner.as_ref());
transaction.commit().await.unwrap();
assign!(put_dehydrated_device::unstable::Request::new(device_id, device_data, device_keys.to_raw()), {
one_time_keys, fallback_keys
})
}
}