mirror of
https://github.com/matrix-org/matrix-rust-sdk.git
synced 2026-07-30 09:26:35 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user