Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the dehydrated device format implemented by vodozemac #4421

Merged
merged 10 commits into from
Jan 22, 2025
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ tracing-core = { git = "https://github.com/element-hq/tracing.git", rev = "ca943
tracing-subscriber = { git = "https://github.com/element-hq/tracing.git", rev = "ca9431f74d37c9d3b5e6a9f35b2c706711dab7dd" }
tracing-appender = { git = "https://github.com/element-hq/tracing.git", rev = "ca9431f74d37c9d3b5e6a9f35b2c706711dab7dd" }
paranoid-android = { git = "https://github.com/element-hq/paranoid-android.git", rev = "69388ac5b4afeed7be4401c70ce17f6d9a2cf19b" }
vodozemac = { git = "https://github.com/uhoreg/vodozemac.git", branch = "dehydration_format" }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@poljar do we need to make a new release of vodozemac, or should I just set the vodozemac dependency to a specific git rev?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can pin to the git rev, I'll make a release when we need it for the SDK release.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pinned to the git rev. What do I need to do to make https://github.com/matrix-org/matrix-rust-sdk/actions/runs/12876768825/job/35900251251?pr=4421 happy?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an exception here:

allow-git = [
# A patch override for the bindings fixing a bug for Android before upstream
# releases a new version.
"https://github.com/element-hq/tracing.git",
# Sam as for the tracing dependency.
"https://github.com/element-hq/paranoid-android.git",
# Well, it's Ruma.
"https://github.com/ruma/ruma",
# A patch override for the bindings: https://github.com/rodrimati1992/const_panic/pull/10
"https://github.com/jplatte/const_panic",
# A patch override for the bindings: https://github.com/smol-rs/async-compat/pull/22
"https://github.com/jplatte/async-compat",
]
.


[workspace.lints.rust]
rust_2018_idioms = "warn"
Expand Down
7 changes: 6 additions & 1 deletion bindings/matrix-sdk-crypto-ffi/src/dehydrated_devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use crate::{CryptoStoreError, DehydratedDeviceKey};
#[uniffi(flat_error)]
pub enum DehydrationError {
#[error(transparent)]
Pickle(#[from] matrix_sdk_crypto::vodozemac::LibolmPickleError),
Pickle(#[from] matrix_sdk_crypto::vodozemac::DehydratedDeviceError),
#[error(transparent)]
LegacyPickle(#[from] matrix_sdk_crypto::vodozemac::LibolmPickleError),
#[error(transparent)]
MissingSigningKey(#[from] matrix_sdk_crypto::SignatureError),
#[error(transparent)]
Expand All @@ -35,6 +37,9 @@ impl From<matrix_sdk_crypto::dehydrated_devices::DehydrationError> for Dehydrati
match value {
matrix_sdk_crypto::dehydrated_devices::DehydrationError::Json(e) => Self::Json(e),
matrix_sdk_crypto::dehydrated_devices::DehydrationError::Pickle(e) => Self::Pickle(e),
matrix_sdk_crypto::dehydrated_devices::DehydrationError::LegacyPickle(e) => {
Self::LegacyPickle(e)
}
matrix_sdk_crypto::dehydrated_devices::DehydrationError::MissingSigningKey(e) => {
Self::MissingSigningKey(e)
}
Expand Down
127 changes: 88 additions & 39 deletions crates/matrix-sdk-crypto/src/dehydrated_devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,16 @@

use std::sync::Arc;

use hkdf::Hkdf;
use ruma::{
api::client::dehydrated_device::{put_dehydrated_device, DehydratedDeviceData},
assign,
events::AnyToDeviceEvent,
serde::Raw,
DeviceId,
};
use sha2::Sha256;
use thiserror::Error;
use tracing::{instrument, trace};
use vodozemac::LibolmPickleError;
use vodozemac::{DehydratedDeviceError, LibolmPickleError};

use crate::{
store::{Changes, CryptoStoreWrapper, DehydratedDeviceKey, MemoryStore, RoomKeyInfo, Store},
Expand All @@ -65,9 +63,13 @@ use crate::{
/// Error type for device dehydration issues.
#[derive(Debug, Error)]
pub enum DehydrationError {
/// The legacy dehydrated device could not be unpickled.
#[error(transparent)]
LegacyPickle(#[from] LibolmPickleError),

/// The dehydrated device could not be unpickled.
#[error(transparent)]
Pickle(#[from] LibolmPickleError),
Pickle(#[from] DehydratedDeviceError),

/// The pickle key has an invalid length
#[error("The pickle key has an invalid length, expected 32 bytes, got {0}")]
Expand Down Expand Up @@ -140,8 +142,8 @@ impl DehydratedDevices {
device_id: &DeviceId,
device_data: Raw<DehydratedDeviceData>,
) -> Result<RehydratedDevice, DehydrationError> {
let pickle_key = expand_pickle_key(pickle_key.inner.as_ref(), device_id);
let rehydrated = self.inner.rehydrate(&pickle_key, device_id, device_data).await?;
let rehydrated =
self.inner.rehydrate(pickle_key.inner.as_ref(), device_id, device_data).await?;

Ok(RehydratedDevice { rehydrated, original: self.inner.to_owned() })
}
Expand Down Expand Up @@ -373,10 +375,8 @@ impl DehydratedDevice {

trace!("Creating an upload request for a dehydrated device");

let pickle_key =
expand_pickle_key(pickle_key.inner.as_ref(), &self.store.static_account().device_id);
let device_id = self.store.static_account().device_id.clone();
let device_data = account.dehydrate(&pickle_key);
let device_data = account.dehydrate(pickle_key.inner.as_ref());
let initial_device_display_name = Some(initial_device_display_name);

transaction.commit().await?;
Expand All @@ -389,36 +389,6 @@ impl DehydratedDevice {
}
}

/// We're using the libolm-compatible pickle format and its encryption scheme.
///
/// The libolm pickle encryption scheme uses HKDF to deterministically expand an
/// input key material, usually 32 bytes, into a AES key, MAC key, and the
/// initialization vector (IV).
///
/// This means that the same input key material will always end up producing the
/// same AES key, and IV.
///
/// This encryption scheme is used in the Olm double ratchet and was designed to
/// minimize the size of the ciphertext. As a tradeof, it requires a unique
/// input key material for each plaintext that gets encrypted, otherwise IV
/// reuse happens.
///
/// To combat the IV reuse, we're going to create a per-dehydrated-device unique
/// pickle key by expanding the key itself with the device ID used as the salt.
fn expand_pickle_key(key: &[u8; 32], device_id: &DeviceId) -> Box<[u8; 32]> {
// TODO: Perhaps we should put this into vodozemac with a new pickle
// minimalistic pickle format using the [`matrix_pickle`] crate.
//
// [`matrix_pickle`]: https://docs.rs/matrix-pickle/latest/matrix_pickle/
let kdf: Hkdf<Sha256> = Hkdf::new(Some(device_id.as_bytes()), key);
let mut key = Box::new([0u8; 32]);

kdf.expand(b"dehydrated-device-pickle-key", key.as_mut_slice())
.expect("We should be able to expand the 32 byte pickle key");

key
}

#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, iter};
Expand Down Expand Up @@ -647,4 +617,83 @@ mod tests {
let stored_key = dehydrated_manager.get_dehydrated_device_pickle_key().await.unwrap();
assert!(stored_key.is_none());
}

/// Test that we can rehydrate an older version of dehydrated device
#[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 transaction = dehydrated_device.store.transaction().await;
let account = transaction.account().await.unwrap();
account.generate_fallback_key_if_needed();

let (device_keys, mut one_time_keys, _fallback_keys) = account.keys_for_upload();
let device_keys = device_keys.unwrap();

let device_data = account.legacy_dehydrate(&pickle_key().inner);
let device_id = account.device_id().to_owned();
transaction.commit().await.unwrap();

let (key_id, one_time_key) = one_time_keys
.pop_first()
.expect("The dehydrated device creation request should contain a one-time key");

// Ensure that we know about the public keys of the dehydrated device.
receive_device_keys(&alice, user_id(), &device_id, device_keys.to_raw()).await;
// Create a 1-to-1 Olm session with the dehydrated device.
create_session(&alice, user_id(), &device_id, key_id, one_time_key).await;

// Send a room key to the dehydrated device.
let (event, group_session) = send_room_key(&alice, room_id, user_id()).await;

// Let's now create a new `OlmMachine` which doesn't know about the room key.
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"
);

// Rehydrate the device.
let rehydrated = bob
.dehydrated_devices()
.rehydrate(&pickle_key(), &device_id, 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());

// Push the to-device event containing the room key into the rehydrated device.
let ret = rehydrated
.receive_events(vec![event])
.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");

// The `OlmMachine` now does know about the room key since the rehydrated device
// shared it with us.
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"
);
}
}
65 changes: 60 additions & 5 deletions crates/matrix-sdk-crypto/src/olm/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ use std::{
time::Duration,
};

use hkdf::Hkdf;
use js_option::JsOption;
#[cfg(test)]
use ruma::api::client::dehydrated_device::DehydratedDeviceV1;
use ruma::{
api::client::{
dehydrated_device::{DehydratedDeviceData, DehydratedDeviceV1},
dehydrated_device::{DehydratedDeviceData, DehydratedDeviceV2},
keys::{
upload_keys,
upload_signatures::v3::{Request as SignatureUploadRequest, SignedKeys},
Expand Down Expand Up @@ -692,12 +695,15 @@ impl Account {
}

pub(crate) fn dehydrate(&self, pickle_key: &[u8; 32]) -> Raw<DehydratedDeviceData> {
let device_pickle = self
let dehydration_result = self
.inner
.to_libolm_pickle(pickle_key)
.to_dehydrated_device(pickle_key)
.expect("We should be able to convert a freshly created Account into a libolm pickle");

let data = DehydratedDeviceData::V1(DehydratedDeviceV1::new(device_pickle));
let data = DehydratedDeviceData::V2(DehydratedDeviceV2::new(
dehydration_result.ciphertext,
dehydration_result.nonce,
));
Raw::from_json(to_raw_value(&data).expect("Couldn't serialize our dehydrated device data"))
}

Expand All @@ -711,7 +717,14 @@ impl Account {

match data {
DehydratedDeviceData::V1(d) => {
let account = InnerAccount::from_libolm_pickle(&d.device_pickle, pickle_key)?;
let pickle_key = expand_legacy_pickle_key(pickle_key, device_id);
let account =
InnerAccount::from_libolm_pickle(&d.device_pickle, pickle_key.as_ref())?;
Ok(Self::new_helper(account, user_id, device_id))
}
DehydratedDeviceData::V2(d) => {
let account =
InnerAccount::from_dehydrated_device(&d.device_pickle, &d.nonce, pickle_key)?;
Ok(Self::new_helper(account, user_id, device_id))
}
_ => Err(DehydrationError::Json(serde_json::Error::custom(format!(
Expand All @@ -721,6 +734,20 @@ impl Account {
}
}

/// Produce a dehydrated device using a format described in an older version
/// of MSC3814.
#[cfg(test)]
pub(crate) fn legacy_dehydrate(&self, pickle_key: &[u8; 32]) -> Raw<DehydratedDeviceData> {
let pickle_key = expand_legacy_pickle_key(pickle_key, &self.device_id);
let device_pickle = self
.inner
.to_libolm_pickle(pickle_key.as_ref())
.expect("We should be able to convert a freshly created Account into a libolm pickle");

let data = DehydratedDeviceData::V1(DehydratedDeviceV1::new(device_pickle));
Raw::from_json(to_raw_value(&data).expect("Couldn't serialize our dehydrated device data"))
}

/// Restore an account from a previously pickled one.
///
/// # Arguments
Expand Down Expand Up @@ -1484,6 +1511,34 @@ impl PartialEq for Account {
}
}

/// Expand the pickle key for an older version of dehydrated devices
///
/// The `org.matrix.msc3814.v1.olm` variant of dehydrated devices used the
/// libolm Account pickle format for the dehydrated device. The libolm pickle
/// encryption scheme uses HKDF to deterministically expand an input key
/// material, usually 32 bytes, into a AES key, MAC key, and the initialization
/// vector (IV).
///
/// This means that the same input key material will always end up producing the
/// same AES key, and IV.
///
/// This encryption scheme is used in the Olm double ratchet and was designed to
/// minimize the size of the ciphertext. As a tradeof, it requires a unique
/// input key material for each plaintext that gets encrypted, otherwise IV
/// reuse happens.
///
/// To combat the IV reuse, we're going to create a per-dehydrated-device unique
/// pickle key by expanding the key itself with the device ID used as the salt.
fn expand_legacy_pickle_key(key: &[u8; 32], device_id: &DeviceId) -> Box<[u8; 32]> {
let kdf: Hkdf<Sha256> = Hkdf::new(Some(device_id.as_bytes()), key);
let mut key = Box::new([0u8; 32]);

kdf.expand(b"dehydrated-device-pickle-key", key.as_mut_slice())
.expect("We should be able to expand the 32 byte pickle key");

key
}

#[cfg(test)]
mod tests {
use std::{
Expand Down
Loading