From 82b7c2b95de09080c0adfb5f7c7f7d7f205d64e4 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 26 Jun 2026 00:23:35 +0100 Subject: [PATCH] Gossip the dehydrated device pickle key (MSC3814) when verifying a device Make the dehydrated device pickle key a gossipable secret so a user's own verified devices can share it instead of each reading it from Secret Storage: export_secret/import_secret handle the custom secret name org.matrix.msc3814 (loading/saving Changes.dehydrated_device_pickle_key), and accept_secret already routes received secrets to import_secret. On verification, request_missing_secrets also requests the dehydration key if we don't have it, so verifying a device hands it the key. Fixes element-hq/element-meta#2703. --- .../src/dehydrated_devices.rs | 36 +++++- crates/matrix-sdk-crypto/src/store/mod.rs | 122 +++++++++++++++++- .../matrix-sdk-crypto/src/verification/mod.rs | 9 ++ 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/dehydrated_devices.rs b/crates/matrix-sdk-crypto/src/dehydrated_devices.rs index 5d1ff0faf..069f081af 100644 --- a/crates/matrix-sdk-crypto/src/dehydrated_devices.rs +++ b/crates/matrix-sdk-crypto/src/dehydrated_devices.rs @@ -52,7 +52,7 @@ use ruma::{ }; use thiserror::Error; use tracing::{instrument, trace}; -use vodozemac::{DehydratedDeviceError, LibolmPickleError}; +use vodozemac::{DehydratedDeviceError, LibolmPickleError, base64_decode}; use crate::{ Account, CryptoStoreError, DecryptionSettings, EncryptionSyncChanges, OlmError, OlmMachine, @@ -64,6 +64,19 @@ use crate::{ verification::VerificationMachine, }; +/// The name under which the dehydrated device pickle key (MSC3814) is gossiped +/// between a user's own verified devices. It matches the Secret Storage +/// account-data key that clients (e.g. matrix-js-sdk) use to store the same +/// key, so the two representations stay consistent. +pub(crate) const DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME: &str = "org.matrix.msc3814"; + +/// Decode a base64-encoded dehydrated device pickle key (MSC3814), as gossiped +/// between a user's devices or stored in Secret Storage. Returns `None` if the +/// payload is not valid base64 or not a valid key. +pub(crate) fn decode_dehydrated_device_pickle_key(secret: &str) -> Option { + base64_decode(secret).ok().and_then(|bytes| DehydratedDeviceKey::from_slice(&bytes).ok()) +} + /// Error type for device dehydration issues. #[derive(Debug, Error)] pub enum DehydrationError { @@ -498,6 +511,27 @@ mod tests { ) } + #[test] + fn test_decode_dehydrated_device_pickle_key() { + use vodozemac::base64_encode; + + use super::decode_dehydrated_device_pickle_key; + + // A valid base64-encoded key decodes back to the same key. + let key = DehydratedDeviceKey::new(); + let decoded = + decode_dehydrated_device_pickle_key(&key.to_base64()).expect("the key should decode"); + assert_eq!(decoded.to_base64(), key.to_base64()); + + // Invalid base64 is rejected. + assert!(decode_dehydrated_device_pickle_key("not valid base64!").is_none()); + + // Valid base64 of the wrong length is rejected. + assert!( + decode_dehydrated_device_pickle_key(&base64_encode([0u8; 10].as_slice())).is_none() + ); + } + #[async_test] async fn test_dehydrated_device_creation() { let olm_machine = get_olm_machine().await; diff --git a/crates/matrix-sdk-crypto/src/store/mod.rs b/crates/matrix-sdk-crypto/src/store/mod.rs index 1904a517d..0ce3b1a54 100644 --- a/crates/matrix-sdk-crypto/src/store/mod.rs +++ b/crates/matrix-sdk-crypto/src/store/mod.rs @@ -112,13 +112,16 @@ pub use memorystore::MemoryStore; pub use traits::{CryptoStore, DynCryptoStore, IntoCryptoStore}; use self::caches::{SequenceNumber, StoreCache, StoreCacheGuard, UsersForKeyQuery}; -use crate::types::{ - events::room_key_withheld::RoomKeyWithheldContent, room_history::RoomKeyBundle, -}; pub use crate::{ dehydrated_devices::DehydrationError, gossiping::{GossipRequest, SecretInfo}, }; +use crate::{ + dehydrated_devices::{ + DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME, decode_dehydrated_device_pickle_key, + }, + types::{events::room_key_withheld::RoomKeyWithheldContent, room_history::RoomKeyBundle}, +}; /// A wrapper for our CryptoStore trait object. /// @@ -929,6 +932,15 @@ impl Store { None } } + // The dehydrated device pickle key (MSC3814), gossiped between a user's own verified + // devices (element-meta#2703). It is stored under a custom secret name, so + // it falls through to the catch-all arm and is matched on its string here. + name if name.as_str() == DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME => self + .inner + .store + .load_dehydrated_device_pickle_key() + .await? + .map(|key| key.to_base64()), name => { warn!(secret = ?name, "Unknown secret was requested"); None @@ -1136,6 +1148,24 @@ impl Store { // it will stay until it either gets overwritten // or the user accepts the secret. } + // The dehydrated device pickle key (MSC3814). Decode it and store it so a + // freshly-verified device can manage/rehydrate the dehydrated device + // without reading Secret Storage (element-meta#2703). + name if name.as_str() == DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME => { + match decode_dehydrated_device_pickle_key(&secret.event.content.secret) { + Some(key) => { + let changes = Changes { + dehydrated_device_pickle_key: Some(key), + ..Default::default() + }; + self.save_changes(changes).await?; + info!("Successfully imported the dehydrated device pickle key"); + } + None => { + warn!("Received an invalid dehydrated device pickle key, ignoring it"); + } + } + } name => { warn!(secret = ?name, "Tried to import an unknown secret"); } @@ -2225,6 +2255,92 @@ mod tests { assert!(pickle_key.is_err()); } + #[async_test] + async fn test_dehydrated_device_pickle_key_is_a_gossipable_secret() { + use ruma::{TransactionId, events::secret::request::SecretName}; + use vodozemac::Ed25519Keypair; + + use crate::{ + dehydrated_devices::DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME, + gossiping::{GossipRequest, GossippedSecret}, + store::types::Changes, + types::events::{ + olm_v1::{DecryptedSecretSendEvent, OlmV1Keys}, + secret_send::SecretSendContent, + }, + }; + + // Build a gossiped secret carrying `secret` under the dehydration key's secret + // name. The sender/recipient identities are irrelevant to importing + // this secret, so they are arbitrary. + fn gossiped_dehydration_key(secret: String) -> GossippedSecret { + let secret_name = SecretName::from(DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME); + let request_id = TransactionId::new(); + let user = user_id!("@alice:localhost").to_owned(); + let ed25519 = Ed25519Keypair::new().public_key(); + GossippedSecret { + secret_name: secret_name.clone(), + gossip_request: GossipRequest { + request_recipient: user.clone(), + request_id: request_id.clone(), + info: secret_name.into(), + sent_out: true, + }, + event: DecryptedSecretSendEvent { + sender: user.clone(), + recipient: user, + keys: OlmV1Keys { ed25519 }, + recipient_keys: OlmV1Keys { ed25519 }, + sender_device_keys: None, + content: SecretSendContent::new(request_id, secret), + }, + } + } + + let alice = OlmMachine::new(user_id!("@alice:localhost"), device_id!("ALICE")).await; + let secret_name = SecretName::from(DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME); + + // With no key stored, the secret cannot be exported. + assert!(alice.store().export_secret(&secret_name).await.unwrap().is_none()); + + // Once stored, it is exported as base64 so a requesting device can be given it. + let key = DehydratedDeviceKey::new(); + alice + .store() + .save_changes(Changes { + dehydrated_device_pickle_key: Some(key.clone()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!( + alice.store().export_secret(&secret_name).await.unwrap().as_deref(), + Some(key.to_base64().as_str()), + ); + + // A gossiped copy is imported straight into storage, not parked in the secret + // inbox. + let bob = OlmMachine::new(user_id!("@bob:localhost"), device_id!("BOB")).await; + assert!(bob.store().load_dehydrated_device_pickle_key().await.unwrap().is_none()); + bob.store().import_secret(&gossiped_dehydration_key(key.to_base64())).await.unwrap(); + assert_eq!( + bob.store().load_dehydrated_device_pickle_key().await.unwrap().map(|k| k.to_base64()), + Some(key.to_base64()), + ); + assert!( + bob.store().get_secrets_from_inbox(&secret_name).await.unwrap().is_empty(), + "the dehydration key should be stored directly, not put in the secret inbox" + ); + + // An invalid payload is ignored rather than stored. + let eve = OlmMachine::new(user_id!("@eve:localhost"), device_id!("EVE")).await; + eve.store() + .import_secret(&gossiped_dehydration_key("not a valid key".to_owned())) + .await + .unwrap(); + assert!(eve.store().load_dehydrated_device_pickle_key().await.unwrap().is_none()); + } + #[async_test] async fn test_build_room_key_bundle() { // Given: Alice has sent a number of room keys to Bob, including some in the diff --git a/crates/matrix-sdk-crypto/src/verification/mod.rs b/crates/matrix-sdk-crypto/src/verification/mod.rs index 00ad8dda9..f3fbd05d4 100644 --- a/crates/matrix-sdk-crypto/src/verification/mod.rs +++ b/crates/matrix-sdk-crypto/src/verification/mod.rs @@ -611,6 +611,15 @@ impl IdentitiesBeingVerified { secrets.push(ruma::events::secret::request::SecretName::RecoveryKey); } + // Also request the dehydrated device pickle key (MSC3814) if we don't have it, + // so that verifying a device hands it the dehydration key too + // (element-meta#2703). + if self.store.inner.load_dehydrated_device_pickle_key().await?.is_none() { + secrets.push(ruma::events::secret::request::SecretName::from( + crate::dehydrated_devices::DEHYDRATED_DEVICE_PICKLE_KEY_SECRET_NAME, + )); + } + Ok(GossipMachine::request_missing_secrets(self.user_id(), secrets)) }