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.
This commit is contained in:
Matthew
2026-06-26 00:23:35 +01:00
committed by Matthew Hodgson
parent 567463d1fd
commit 82b7c2b95d
3 changed files with 163 additions and 4 deletions

View File

@@ -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<DehydratedDeviceKey> {
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;

View File

@@ -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

View File

@@ -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))
}