diff --git a/Cargo.toml b/Cargo.toml index 6d72d681..b3b74fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ arrayvec = { version = "0.7.6", features = ["serde"] } base64 = "0.22.1" base64ct = { version = "1.6.0", features = ["std", "alloc"] } cbc = { version = "0.1.2", features = ["std"] } -chacha20poly1305 = "0.10.1" +chacha20poly1305 = { version = "0.10.1", features = ["std"] } curve25519-dalek = { version = "4.1.3", default-features = false, features = ["zeroize"] } ed25519-dalek = { version = "2.1.1", default-features = false, features = ["rand_core", "std", "serde", "hazmat", "zeroize"] } getrandom = "0.2.15" diff --git a/afl/rehydrate-device/Cargo.toml b/afl/rehydrate-device/Cargo.toml new file mode 100644 index 00000000..109368bb --- /dev/null +++ b/afl/rehydrate-device/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rehydrate-device" +version = "0.1.0" +publish = false +edition = "2021" + +[dependencies] +afl = "*" + +[dependencies.vodozemac] +path = "../.." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] diff --git a/afl/rehydrate-device/in/dehydrated1 b/afl/rehydrate-device/in/dehydrated1 new file mode 100644 index 00000000..310f40e6 Binary files /dev/null and b/afl/rehydrate-device/in/dehydrated1 differ diff --git a/afl/rehydrate-device/in/dehydrated2 b/afl/rehydrate-device/in/dehydrated2 new file mode 100644 index 00000000..310f40e6 Binary files /dev/null and b/afl/rehydrate-device/in/dehydrated2 differ diff --git a/afl/rehydrate-device/rust-toolchain.toml b/afl/rehydrate-device/rust-toolchain.toml new file mode 100644 index 00000000..5d56faf9 --- /dev/null +++ b/afl/rehydrate-device/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/afl/rehydrate-device/src/main.rs b/afl/rehydrate-device/src/main.rs new file mode 100644 index 00000000..0ad48752 --- /dev/null +++ b/afl/rehydrate-device/src/main.rs @@ -0,0 +1,8 @@ +use afl::fuzz; +use vodozemac::olm::Account; + +fn main() { + fuzz!(|data: &[u8]| { + let _ = Account::from_decrypted_dehydrated_device(data); + }); +} diff --git a/src/lib.rs b/src/lib.rs index 09239671..79879c2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -232,7 +232,6 @@ pub enum PickleError { /// Error type describing the various ways libolm pickles can fail to be /// decoded. -#[cfg(feature = "libolm-compat")] #[derive(Debug, thiserror::Error)] pub enum LibolmPickleError { /// The pickle is missing a valid version. @@ -262,6 +261,30 @@ pub enum LibolmPickleError { Encode(#[from] matrix_pickle::EncodeError), } +/// Error type describing the various ways dehydrated devices can fail to be +/// decoded. +#[derive(Debug, thiserror::Error)] +pub enum DehydratedDeviceError { + /// The pickle is missing a valid version. + #[error("The pickle doesn't contain a version")] + MissingVersion, + /// The pickle has a unsupported version. + #[error("The pickle uses an unsupported version, expected {0}, got {1}")] + Version(u32, u32), + /// Invalid nonce. + #[error("The nonce was invalid")] + InvalidNonce, + /// The pickle wasn't valid base64. + #[error("The pickle wasn't valid base64: {0}")] + Base64(#[from] Base64DecodeError), + /// The pickle could not have been decrypted. + #[error("The pickle couldn't be decrypted: {0}")] + Decryption(#[from] chacha20poly1305::aead::Error), + /// There was an error with the libolm pickle format + #[error(transparent)] + LibolmPickle(#[from] LibolmPickleError), +} + /// Error type describing the different ways message decoding can fail. #[derive(Debug, thiserror::Error)] pub enum DecodeError { diff --git a/src/olm/account/mod.rs b/src/olm/account/mod.rs index fbfa705c..215a01e8 100644 --- a/src/olm/account/mod.rs +++ b/src/olm/account/mod.rs @@ -17,10 +17,15 @@ mod one_time_keys; use std::collections::HashMap; +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit}, + ChaCha20Poly1305, +}; use rand::thread_rng; use serde::{Deserialize, Serialize}; use thiserror::Error; use x25519_dalek::ReusableSecret; +use zeroize::Zeroize; pub use self::one_time_keys::OneTimeKeyGenerationResult; use self::{ @@ -87,6 +92,15 @@ pub struct InboundCreationResult { pub plaintext: Vec, } +/// Return type for the creation of a dehydrated device. +#[derive(Debug)] +pub struct DehydratedDeviceResult { + /// The encrypted dehydrated device, as a base64-encoded string. + pub ciphertext: String, + /// The nonce used for encrypting, as a base64-encoded string. + pub nonce: String, +} + /// An Olm [`Account`] manages all cryptographic keys used on a device. pub struct Account { /// A permanent Ed25519 key used for signing. Also known as the fingerprint @@ -439,6 +453,100 @@ impl Account { pickle.try_into() } + + /// Create a dehydrated device from the account. + /// + /// A dehydrated device is a device that is stored encrypted on the server + /// that can receive messages when the user has no other active devices. + /// Upon login, the user can rehydrate the device (using + /// [`from_dehydrated_device`]) and decrypt the messages sent to the + /// dehydrated device. + /// + /// The account must be a newly-created account that does not have any Olm + /// sessions, since the dehydrated device format does not store sessions. + /// + /// Returns the ciphertext and nonce. `key` is a 256-bit (32-byte) key for + /// encrypting the device. + /// + /// The format used here is defined in + /// [MSC3814](https://github.com/matrix-org/matrix-spec-proposals/pull/3814). + pub fn to_dehydrated_device( + &self, + key: &[u8; 32], + ) -> Result { + use matrix_pickle::Encode; + + use self::dehydrated_device::Pickle; + use crate::{utilities::base64_encode, DehydratedDeviceError, LibolmPickleError}; + + let pickle: Pickle = self.into(); + let mut encoded = pickle + .encode_to_vec() + .map_err(|e| DehydratedDeviceError::LibolmPickle(LibolmPickleError::Encode(e)))?; + let cipher = ChaCha20Poly1305::new(key.into()); + let rng = thread_rng(); + let nonce = ChaCha20Poly1305::generate_nonce(rng); + let ciphertext = cipher.encrypt(&nonce, encoded.as_slice()); + encoded.zeroize(); + let ciphertext = ciphertext?; + + Ok(DehydratedDeviceResult { + ciphertext: base64_encode(ciphertext), + nonce: base64_encode(nonce), + }) + } + + /// Create an [`Account`] object from a dehydrated device. + /// + /// `ciphertext` and `nonce` are the ciphertext and nonce returned by + /// [`to_dehydrated_device`]. `key` is a 256-bit (32-byte) key for + /// decrypting the device, and must be the same key used when + /// [`to_dehydrate_device`] was called. + pub fn from_dehydrated_device( + ciphertext: &str, + nonce: &str, + key: &[u8; 32], + ) -> Result { + use self::dehydrated_device::PICKLE_VERSION; + use crate::utilities::{base64_decode, get_pickle_version}; + + let cipher = ChaCha20Poly1305::new(key.into()); + let ciphertext = base64_decode(ciphertext)?; + let nonce = base64_decode(nonce)?; + if nonce.len() != 12 { + return Err(crate::DehydratedDeviceError::InvalidNonce); + } + let mut plaintext = cipher.decrypt(nonce.as_slice().into(), ciphertext.as_slice())?; + let version = + get_pickle_version(&plaintext).ok_or(crate::DehydratedDeviceError::MissingVersion)?; + if version != PICKLE_VERSION { + return Err(crate::DehydratedDeviceError::Version(PICKLE_VERSION, version)); + } + + let pickle = Self::from_decrypted_dehydrated_device(&plaintext); + plaintext.zeroize(); + pickle + } + + // This function is public for fuzzing, but should not be used by anything + // else + #[doc(hidden)] + pub fn from_decrypted_dehydrated_device( + pickle: &[u8], + ) -> Result { + use std::io::Cursor; + + use matrix_pickle::Decode; + + use self::dehydrated_device::Pickle; + use crate::{DehydratedDeviceError, LibolmPickleError}; + + let mut cursor = Cursor::new(&pickle); + let pickle = Pickle::decode(&mut cursor) + .map_err(|e| DehydratedDeviceError::LibolmPickle(LibolmPickleError::Decode(e)))?; + + pickle.try_into() + } } impl Default for Account { @@ -682,16 +790,171 @@ mod libolm { } } +mod dehydrated_device { + use matrix_pickle::{Decode, DecodeError, Encode, EncodeError}; + use zeroize::{Zeroize, ZeroizeOnDrop}; + + use super::{ + fallback_keys::{FallbackKey, FallbackKeys}, + one_time_keys::OneTimeKeys, + Account, + }; + use crate::{ + types::{Curve25519Keypair, Curve25519SecretKey}, + DehydratedDeviceError, Ed25519Keypair, KeyId, + }; + + #[derive(Encode, Decode, Zeroize, ZeroizeOnDrop)] + pub(crate) struct OneTimeKey { + #[secret] + pub(crate) private_key: Box<[u8; 32]>, + } + + impl From<&OneTimeKey> for FallbackKey { + fn from(key: &OneTimeKey) -> Self { + FallbackKey { + key_id: KeyId(0), + key: Curve25519SecretKey::from_slice(&key.private_key), + published: true, + } + } + } + + impl TryFrom<&FallbackKey> for OneTimeKey { + type Error = (); + + fn try_from(key: &FallbackKey) -> Result { + Ok(OneTimeKey { private_key: key.secret_key().to_bytes() }) + } + } + + #[derive(Zeroize, ZeroizeOnDrop)] + pub(crate) struct OptFallbackKey { + pub(crate) fallback_key: Option, + } + + impl Decode for OptFallbackKey { + fn decode(reader: &mut impl std::io::Read) -> Result { + let present = bool::decode(reader)?; + + let fallback_key = if present { + let fallback_key = OneTimeKey::decode(reader)?; + + Some(fallback_key) + } else { + None + }; + + Ok(Self { fallback_key }) + } + } + + impl Encode for OptFallbackKey { + fn encode(&self, writer: &mut impl std::io::Write) -> Result { + let ret = match &self.fallback_key { + None => false.encode(writer)?, + Some(key) => { + let mut ret = true.encode(writer)?; + ret += key.encode(writer)?; + + ret + } + }; + + Ok(ret) + } + } + + #[derive(Encode, Decode, Zeroize, ZeroizeOnDrop)] + /// Pickle used for dehydrated devices. + /// + /// Dehydrated devices are used for receiving encrypted messages when the + /// user has no other devices logged in, and are defined in + /// [MSC3814](https://github.com/matrix-org/matrix-spec-proposals/pull/3814). + pub(super) struct Pickle { + version: u32, + #[secret] + private_curve25519_key: Box<[u8; 32]>, + #[secret] + private_ed25519_key: Box<[u8; 32]>, + one_time_keys: Vec, + opt_fallback_key: OptFallbackKey, + } + + pub(super) const PICKLE_VERSION: u32 = 1; + + impl From<&Account> for Pickle { + fn from(account: &Account) -> Self { + let one_time_keys: Vec<_> = account + .one_time_keys + .secret_keys() + .iter() + .map(|(_key_id, secret_key)| OneTimeKey { private_key: secret_key.to_bytes() }) + .collect(); + + let fallback_key = + account.fallback_keys.fallback_key.as_ref().and_then(|f| f.try_into().ok()); + + Self { + version: PICKLE_VERSION, + private_curve25519_key: account.diffie_hellman_key.secret_key().to_bytes(), + private_ed25519_key: account + .signing_key + .unexpanded_secret_key() + .expect("Cannot dehydrate an account created from a libolm pickle"), + one_time_keys, + opt_fallback_key: OptFallbackKey { fallback_key }, + } + } + } + + impl TryFrom for Account { + type Error = DehydratedDeviceError; + + fn try_from(pickle: Pickle) -> Result { + use crate::{DehydratedDeviceError, LibolmPickleError}; + let mut one_time_keys = OneTimeKeys::new(); + + for (num, key) in pickle.one_time_keys.iter().enumerate() { + let secret_key = Curve25519SecretKey::from_slice(&key.private_key); + let key_id = KeyId(num as u64); + one_time_keys.insert_secret_key(key_id, secret_key, true); + } + one_time_keys.next_key_id = pickle.one_time_keys.len().try_into().unwrap_or_default(); + + let fallback_keys = FallbackKeys { + key_id: 1, + fallback_key: pickle.opt_fallback_key.fallback_key.as_ref().map(|otk| otk.into()), + previous_fallback_key: None, + }; + + Ok(Self { + signing_key: Ed25519Keypair::from_unexpanded_key(&pickle.private_ed25519_key) + .map_err(|e| { + DehydratedDeviceError::LibolmPickle(LibolmPickleError::PublicKey(e)) + })?, + diffie_hellman_key: Curve25519Keypair::from_secret_key( + &pickle.private_curve25519_key, + ), + one_time_keys, + fallback_keys, + }) + } + } +} + #[cfg(test)] mod test { use anyhow::{bail, Context, Result}; - #[cfg(feature = "libolm-compat")] - use matrix_pickle::Encode; + use assert_matches::assert_matches; + use matrix_pickle::{Decode, Encode}; use olm_rs::{account::OlmAccount, session::OlmMessage as LibolmOlmMessage}; #[cfg(feature = "libolm-compat")] use super::libolm::Pickle; - use super::{Account, InboundCreationResult, SessionConfig, SessionCreationError}; + use super::{ + dehydrated_device, Account, InboundCreationResult, SessionConfig, SessionCreationError, + }; use crate::{ cipher::Mac, olm::{ @@ -1197,4 +1460,144 @@ mod test { Ok(()) } + + #[test] + fn decrypt_with_dehydrated_device() { + let mut alice = Account::new(); + let bob = Account::new(); + let carol = Account::new(); + + alice.generate_one_time_keys(alice.max_number_of_one_time_keys()); + alice.generate_fallback_key(); + + let alice_dehydrated_result = + alice.to_dehydrated_device(&PICKLE_KEY).expect("Should be able to dehydrate device"); + + // encrypt using a one-time key + let mut bob_session = bob.create_outbound_session( + SessionConfig::version_1(), + alice.curve25519_key(), + *alice + .one_time_keys() + .iter() + .next() + .expect("Failed getting alice's OTK, which should never happen here.") + .1, + ); + + // encrypt using a fallback key + let mut carol_session = carol.create_outbound_session( + SessionConfig::version_1(), + alice.curve25519_key(), + *alice + .fallback_key() + .iter() + .next() + .expect("Failed getting alice's fallback key, which should never happen here.") + .1, + ); + + let message = "It's a secret to everybody"; + let bob_olm_message = bob_session.encrypt(message); + let carol_olm_message = carol_session.encrypt(message); + + let mut alice_rehydrated = Account::from_dehydrated_device( + &alice_dehydrated_result.ciphertext, + &alice_dehydrated_result.nonce, + &PICKLE_KEY, + ) + .expect("Should be able to rehydrate device"); + + // make sure we can decrypt both messages + let prekey_message = assert_matches!(bob_olm_message, OlmMessage::PreKey(m) => m); + let InboundCreationResult { session: alice_session, plaintext } = alice_rehydrated + .create_inbound_session(bob.curve25519_key(), &prekey_message) + .expect("Alice should be able to create an inbound session from Bob's pre-key message"); + assert_eq!(alice_session.session_id(), bob_session.session_id()); + assert_eq!(message.as_bytes(), plaintext); + + let prekey_message = assert_matches!(carol_olm_message, OlmMessage::PreKey(m) => m); + let InboundCreationResult { session: alice_session, plaintext } = alice_rehydrated + .create_inbound_session(carol.curve25519_key(), &prekey_message) + .expect( + "Alice should be able to create an inbound session from Carol's pre-key message", + ); + + assert_eq!(alice_session.session_id(), carol_session.session_id()); + assert_eq!(message.as_bytes(), plaintext); + } + + #[test] + fn fails_to_rehydrate_with_wrong_key() { + let mut alice = Account::new(); + + alice.generate_one_time_keys(alice.max_number_of_one_time_keys()); + alice.generate_fallback_key(); + + let alice_dehydrated_result = + alice.to_dehydrated_device(&PICKLE_KEY).expect("Should be able to dehydrate device"); + + assert!(Account::from_dehydrated_device( + &alice_dehydrated_result.ciphertext, + &alice_dehydrated_result.nonce, + &[1; 32], + ) + .is_err()); + + assert!(Account::from_dehydrated_device( + &alice_dehydrated_result.ciphertext, + "WrongNonce123456", + &PICKLE_KEY, + ) + .is_err()); + } + + #[derive(Encode, Decode)] + struct OptFallbackPickleTest { + fallback1: dehydrated_device::OptFallbackKey, + fallback2: dehydrated_device::OptFallbackKey, + } + + #[test] + fn encodes_optional_fallback_key() { + use std::io::Cursor; + + let data_to_pickle = OptFallbackPickleTest { + fallback1: dehydrated_device::OptFallbackKey { + fallback_key: Some(dehydrated_device::OneTimeKey { + private_key: Box::new([1; 32]), + }), + }, + fallback2: dehydrated_device::OptFallbackKey { fallback_key: None }, + }; + + let buffer = Vec::::new(); + let mut cursor = Cursor::new(buffer); + let pickle_length = data_to_pickle.encode(&mut cursor).expect("Can pickle data"); + let pickle = cursor.into_inner(); + assert_eq!(pickle.len(), pickle_length); + + let mut cursor = Cursor::new(&pickle); + let unpickled_data = OptFallbackPickleTest::decode(&mut cursor).expect("Can unpickle"); + + assert!(unpickled_data.fallback1.fallback_key.is_some()); + assert!(unpickled_data.fallback2.fallback_key.is_none()); + } + + #[test] + fn decrypted_dehydration_cycle() { + use dehydrated_device::Pickle; + + let alice = Account::new(); + + let mut encoded = Vec::::new(); + let pickle = Pickle::from(&alice); + let size = pickle.encode(&mut encoded).expect("Should dehydrate"); + assert_eq!(size, encoded.len()); + + let account = + Account::from_decrypted_dehydrated_device(&encoded).expect("Should rehydrate account"); + + assert_eq!(alice.identity_keys(), account.identity_keys()); + } } diff --git a/src/olm/account/one_time_keys.rs b/src/olm/account/one_time_keys.rs index 83c3c92a..893c2449 100644 --- a/src/olm/account/one_time_keys.rs +++ b/src/olm/account/one_time_keys.rs @@ -118,7 +118,6 @@ impl OneTimeKeys { self.insert_secret_key(key_id, key, false) } - #[cfg(feature = "libolm-compat")] pub(crate) const fn secret_keys(&self) -> &BTreeMap { &self.private_keys } diff --git a/src/types/curve25519.rs b/src/types/curve25519.rs index 21f12b1d..31ad6053 100644 --- a/src/types/curve25519.rs +++ b/src/types/curve25519.rs @@ -87,7 +87,6 @@ impl Curve25519Keypair { Self { secret_key, public_key } } - #[cfg(feature = "libolm-compat")] pub fn from_secret_key(key: &[u8; 32]) -> Self { let secret_key = Curve25519SecretKey::from_slice(key); let public_key = Curve25519PublicKey::from(&secret_key); diff --git a/src/types/ed25519.rs b/src/types/ed25519.rs index 9bd9b86a..c1174aed 100644 --- a/src/types/ed25519.rs +++ b/src/types/ed25519.rs @@ -138,6 +138,19 @@ impl Ed25519Keypair { } } + pub(crate) fn from_unexpanded_key(secret_key: &[u8; 32]) -> Result { + let secret_key = SigningKey::from_bytes(secret_key); + let public_key = secret_key.verifying_key(); + Ok(Self { secret_key: secret_key.into(), public_key: Ed25519PublicKey(public_key) }) + } + + pub(crate) fn unexpanded_secret_key(&self) -> Option> { + match &self.secret_key { + SecretKeys::Normal(k) => Some(Box::new(k.to_bytes())), + SecretKeys::Expanded(_) => None, + } + } + #[cfg(feature = "libolm-compat")] pub(crate) fn from_expanded_key(secret_key: &[u8; 64]) -> Result { let secret_key = ExpandedSecretKey::from_bytes(secret_key); @@ -621,4 +634,15 @@ mod tests { let deserialized = serde_json::from_value::(serialized); assert!(deserialized.is_err()); } + + #[test] + fn unexpanded_key_roundtrip_succeeds() { + let key_pair = Ed25519Keypair::new(); + + let unexpanded_key = key_pair.unexpanded_secret_key().expect("Should have unexpanded key"); + let recovered_key_pair = Ed25519Keypair::from_unexpanded_key(unexpanded_key.as_ref()) + .expect("Should create new keypair"); + + assert_eq!(key_pair.public_key().to_base64(), recovered_key_pair.public_key().to_base64()); + } } diff --git a/src/utilities/libolm_compat.rs b/src/utilities/libolm_compat.rs index f6fe726c..6964ce27 100644 --- a/src/utilities/libolm_compat.rs +++ b/src/utilities/libolm_compat.rs @@ -12,14 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. +#[cfg(feature = "libolm-compat")] use std::io::Cursor; +#[cfg(feature = "libolm-compat")] use matrix_pickle::{Decode, Encode}; +#[cfg(feature = "libolm-compat")] use zeroize::{Zeroize, ZeroizeOnDrop}; +#[cfg(feature = "libolm-compat")] use super::{base64_decode, base64_encode}; +#[cfg(feature = "libolm-compat")] use crate::{cipher::Cipher, LibolmPickleError}; +/// Fetch the pickle version from the given pickle source. +pub(crate) fn get_version(source: &[u8]) -> Option { + // Pickle versions are always u32 encoded as a fixed sized integer in + // big endian encoding. + let version = source.get(0..4)?; + Some(u32::from_be_bytes(version.try_into().ok()?)) +} + /// Decrypt and decode the given pickle with the given pickle key. /// /// # Arguments @@ -28,19 +41,12 @@ use crate::{cipher::Cipher, LibolmPickleError}; /// * pickle_key - The key that was used to encrypt the libolm pickle /// * pickle_version - The expected version of the pickle. Unpickling will fail /// if the version in the pickle doesn't match this one. +#[cfg(feature = "libolm-compat")] pub(crate) fn unpickle_libolm>( pickle: &str, pickle_key: &[u8], pickle_version: u32, ) -> Result { - /// Fetch the pickle version from the given pickle source. - fn get_version(source: &[u8]) -> Option { - // Pickle versions are always u32 encoded as a fixed sized integer in - // big endian encoding. - let version = source.get(0..4)?; - Some(u32::from_be_bytes(version.try_into().ok()?)) - } - // libolm pickles are always base64 encoded, so first try to decode. let decoded = base64_decode(pickle)?; @@ -65,6 +71,7 @@ pub(crate) fn unpickle_libolm(pickle: P, pickle_key: &[u8]) -> Result where P: Encode, @@ -78,6 +85,7 @@ where Ok(base64_encode(encrypted)) } +#[cfg(feature = "libolm-compat")] #[derive(Encode, Decode, Zeroize, ZeroizeOnDrop)] pub(crate) struct LibolmEd25519Keypair { pub public_key: [u8; 32], @@ -85,7 +93,7 @@ pub(crate) struct LibolmEd25519Keypair { pub private_key: Box<[u8; 64]>, } -#[cfg(test)] +#[cfg(all(feature = "libolm-compat", test))] mod test { use super::*; diff --git a/src/utilities/mod.rs b/src/utilities/mod.rs index 821c1349..1ad2b5fd 100644 --- a/src/utilities/mod.rs +++ b/src/utilities/mod.rs @@ -13,7 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -#[cfg(feature = "libolm-compat")] mod libolm_compat; pub use base64::DecodeError; @@ -22,6 +21,7 @@ use base64::{ engine::{general_purpose, GeneralPurpose}, Engine, }; +pub(crate) use libolm_compat::get_version as get_pickle_version; #[cfg(feature = "libolm-compat")] pub(crate) use libolm_compat::{pickle_libolm, unpickle_libolm, LibolmEd25519Keypair};