From a09204081d50aaad3f78dc54117002cacabd888b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Thu, 15 Aug 2024 16:12:40 +0200 Subject: [PATCH 1/3] feat: Add support for the libolm PkEncryption feature This patch introduces support for the libolm PkEncryption/PkDecryption concepts, ensuring bug-for-bug compatibility with libolm. Notably, the libolm implementation has a known flaw that leaves ciphertext unauthenticated, as documented in the Matrix spec [1]. To address this, the feature is gated behind a feature flag to better inform users of this issue. [1]: https://spec.matrix.org/v1.11/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 Changelog: Add support for the libolm PkEncryption feature. This allows Matrix clients to implement the [m.megolm_backup.v1.curve25519-aes-sha2](https://spec.matrix.org/v1.11/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2) room key backup algorithm. Please note that this algorithm contains a critical flaw and should only be used for compatibility reasons. --- Cargo.toml | 1 + src/cipher/key.rs | 8 +- src/cipher/mod.rs | 8 +- src/lib.rs | 2 + src/pk_encryption.rs | 347 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 src/pk_encryption.rs diff --git a/Cargo.toml b/Cargo.toml index 951c6579..ad07a1ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ default = ["libolm-compat"] js = ["getrandom/js"] strict-signatures = [] libolm-compat = [] +insecure-pk-encryption = [] # The low-level-api feature exposes extra APIs that are only useful in advanced # use cases and require extra care to use. low-level-api = [] diff --git a/src/cipher/key.rs b/src/cipher/key.rs index dc5aa0eb..0afed958 100644 --- a/src/cipher/key.rs +++ b/src/cipher/key.rs @@ -28,7 +28,7 @@ type Aes256Iv = GenericArray::IvSize>; type HmacSha256Key = [u8; 32]; #[derive(Zeroize, ZeroizeOnDrop)] -struct ExpandedKeys(Box<[u8; 80]>); +pub(crate) struct ExpandedKeys(Box<[u8; 80]>); impl ExpandedKeys { const OLM_HKDF_INFO: &'static [u8] = b"OLM_KEYS"; @@ -47,7 +47,7 @@ impl ExpandedKeys { Self::new_helper(pickle_key, b"Pickle") } - fn new_helper(message_key: &[u8], info: &[u8]) -> Self { + pub(crate) fn new_helper(message_key: &[u8], info: &[u8]) -> Self { let mut expanded_keys = [0u8; 80]; let hkdf: Hkdf = Hkdf::new(Some(&[0]), message_key); @@ -59,7 +59,7 @@ impl ExpandedKeys { } #[derive(Zeroize, ZeroizeOnDrop)] -pub(super) struct CipherKeys { +pub(crate) struct CipherKeys { aes_key: Box<[u8; 32]>, aes_iv: Box<[u8; 16]>, mac_key: Box<[u8; 32]>, @@ -85,7 +85,7 @@ impl CipherKeys { Self::from_expanded_keys(expanded_keys) } - fn from_expanded_keys(expanded_keys: ExpandedKeys) -> Self { + pub(crate) fn from_expanded_keys(expanded_keys: ExpandedKeys) -> Self { let mut aes_key = Box::new([0u8; 32]); let mut mac_key = Box::new([0u8; 32]); let mut aes_iv = Box::new([0u8; 16]); diff --git a/src/cipher/mod.rs b/src/cipher/mod.rs index 50c023f8..9d53ef3c 100644 --- a/src/cipher/mod.rs +++ b/src/cipher/mod.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod key; +pub(crate) mod key; use aes::{ cipher::{ @@ -27,9 +27,9 @@ use key::CipherKeys; use sha2::Sha256; use thiserror::Error; -type Aes256CbcEnc = cbc::Encryptor; -type Aes256CbcDec = cbc::Decryptor; -type HmacSha256 = Hmac; +pub(crate) type Aes256CbcEnc = cbc::Encryptor; +pub(crate) type Aes256CbcDec = cbc::Decryptor; +pub(crate) type HmacSha256 = Hmac; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Mac(pub(crate) [u8; Self::LENGTH]); diff --git a/src/lib.rs b/src/lib.rs index 4ea863f8..8736f467 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -217,6 +217,8 @@ pub mod ecies; pub mod hazmat; pub mod megolm; pub mod olm; +#[cfg(feature = "insecure-pk-encryption")] +pub mod pk_encryption; pub mod sas; pub use base64::DecodeError as Base64DecodeError; diff --git a/src/pk_encryption.rs b/src/pk_encryption.rs new file mode 100644 index 00000000..9f20d82c --- /dev/null +++ b/src/pk_encryption.rs @@ -0,0 +1,347 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! ☣️ Compat support for libolm's PkEncryption and PkDecryption +//! +//! This implements the `m.megolm_backup.v1.curve25519-aes-sha2` described in +//! the Matrix [spec]. This is a hybrid encryption scheme utilizing Curve25519 +//! and AES-CBC. X25519 ECDH is performed between an ephemeral key pair and a +//! long-lived backup key pair to establish a shared secret, from which +//! symmetric encryption and message authentication (MAC) keys are derived. +//! +//! **WARNING**: Please note the algorithm contains a critical flaw and does not +//! provide authentication of the ciphertext. +//! +//! # Examples +//! +//! ``` +//! use anyhow::Result; +//! use vodozemac::pk_encryption::{PkDecryption, PkEncryption}; +//! +//! fn main() -> Result<()> { +//! let plaintext = b"It's a secret to everybody"; +//! +//! let decryption = PkDecryption::new(); +//! let encryption = PkEncryption::from_key(decryption.public_key()); +//! +//! let message = encryption.encrypt(plaintext); +//! let decrypted = decryption.decrypt(&message)?; +//! +//! assert_eq!(decrypted.as_slice(), plaintext); +//! +//! Ok(()) +//! } +//! ``` +//! +//! [spec]: https://spec.matrix.org/v1.11/client-server-api/#backup-algorithm-mmegolm_backupv1curve25519-aes-sha2 + +use aes::cipher::{ + block_padding::{Pkcs7, UnpadError}, + BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _, +}; +use hmac::{digest::MacError, Mac as _}; +use thiserror::Error; + +use crate::{ + base64_decode, + cipher::{ + key::{CipherKeys, ExpandedKeys}, + Aes256CbcDec, Aes256CbcEnc, HmacSha256, Mac, + }, + Curve25519PublicKey, Curve25519SecretKey, KeyError, +}; + +/// An error type describing failures which can happen during the decryption +/// step. +#[derive(Debug, Error)] +pub enum Error { + /// The message has invalid [Pkcs7] padding. + #[error("failed to decrypt, invalid padding: {0}")] + InvalidPadding(#[from] UnpadError), + /// The message failed to be authenticated. + #[error("the MAC of the ciphertext didn't pass validation: {0}")] + Mac(#[from] MacError), +} + +/// An error type describing failures which can happen during the decoding of an +/// encrypted [`Message`]. +#[derive(Debug, Error)] +pub enum MessageDecodeError { + /// One of the message parts wasn't valid Base64. + #[error(transparent)] + Base64(#[from] crate::Base64DecodeError), + /// The ephemeral Curve25519 key isn't valid. + #[error(transparent)] + Key(#[from] KeyError), +} + +/// A message that was encrypted using a [`PkEncryption`] object. +#[derive(Debug)] +pub struct Message { + /// The ciphertext of the message. + pub ciphertext: Vec, + /// The message authentication code of the message. + /// + /// **WARNING**: As stated in the module description, this does not + /// authenticate the message. + pub mac: Vec, + /// The ephemeral [`Curve25519PublicKey`] used to derive the individual + /// message key. + pub ephemeral_key: Curve25519PublicKey, +} + +impl Message { + /// Attempt to decode a PkEncryption [`Message`] from a Base64-encoded + /// triplet of ciphertext, MAC, and ephemeral key. + pub fn from_base64( + ciphertext: &str, + mac: &str, + ephemeral_key: &str, + ) -> Result { + Ok(Self { + ciphertext: base64_decode(ciphertext)?, + mac: base64_decode(mac)?, + ephemeral_key: Curve25519PublicKey::from_base64(ephemeral_key)?, + }) + } +} + +/// The decryption component of the PkEncryption support. +/// +/// The public key can be shared with others, allowing them to encrypt messages +/// which can be decrypted using the corresponding private key. +pub struct PkDecryption { + secret_key: Curve25519SecretKey, + public_key: Curve25519PublicKey, +} + +impl PkDecryption { + /// Create a new random [`PkDecryption`] object. + /// + /// This contains a fresh [`Curve25519SecretKey`] which is used as a + /// long-term key to derive individual message keys and effectively serves + /// as the decryption secret. + pub fn new() -> Self { + let secret_key = Curve25519SecretKey::new(); + let public_key = Curve25519PublicKey::from(&secret_key); + + Self { secret_key, public_key } + } + + /// Create a [`PkDecryption`] object from a [`Curve25519SecretKey`] key. + /// + /// The [`Curve25519SecretKey`] will be used as the long-term key to derive + /// individual message keys. + pub fn from_key(secret_key: Curve25519SecretKey) -> Self { + let public_key = Curve25519PublicKey::from(&secret_key); + Self { secret_key, public_key } + } + + /// Get the [`Curve25519SecretKey`] of this [`PkDecryption`] object. + /// + /// If persistence is required, securely serialize and store this key. It + /// can be used to reconstruct the [`PkDecryption`] object for decrypting + /// associated messages. + pub const fn secret_key(&self) -> &Curve25519SecretKey { + &self.secret_key + } + + /// Get the associated ephemeral [`Curve25519PublicKey`]. This key can be + /// used to reconstruct the [`PkEncryption`] object to encrypt messages. + pub const fn public_key(&self) -> Curve25519PublicKey { + self.public_key + } + + /// Decrypt a [`Message`] which was encrypted for this [`PkDecryption`] + /// object. + pub fn decrypt(&self, message: &Message) -> Result, Error> { + let shared_secret = self.secret_key.diffie_hellman(&message.ephemeral_key); + + let expanded_keys = ExpandedKeys::new_helper(shared_secret.as_bytes(), b""); + let cipher_keys = CipherKeys::from_expanded_keys(expanded_keys); + + let hmac = HmacSha256::new_from_slice(cipher_keys.mac_key()) + .expect("We should be able to create a Hmac object from a 32 byte key"); + + // BUG: This is a know issue, we check the MAC of an empty message instead of + // updating the `hmac` object with the ciphertext bytes. + hmac.verify_truncated_left(&message.mac)?; + + let cipher = Aes256CbcDec::new(cipher_keys.aes_key(), cipher_keys.iv()); + let decrypted = cipher.decrypt_padded_vec_mut::(&message.ciphertext)?; + + Ok(decrypted) + } +} + +impl Default for PkDecryption { + fn default() -> Self { + Self::new() + } +} + +/// The encryption component of PkEncryption support. +/// +/// This struct can be created from a [`Curve25519PublicKey`] corresponding to +/// a [`PkDecryption`] object, allowing encryption of messages for that object. +pub struct PkEncryption { + public_key: Curve25519PublicKey, +} + +impl PkEncryption { + /// Create a new [`PkEncryption`] object from a [`Curve25519PublicKey`]. + /// + /// The public key should be obtained from an existing [`PkDecryption`] + /// object. + pub const fn from_key(public_key: Curve25519PublicKey) -> Self { + Self { public_key } + } + + /// Encrypt a message using this [`PkEncryption`] object. + pub fn encrypt(&self, message: &[u8]) -> Message { + let ephemeral_key = Curve25519SecretKey::new(); + let shared_secret = ephemeral_key.diffie_hellman(&self.public_key); + + let expanded_keys = ExpandedKeys::new_helper(shared_secret.as_bytes(), b""); + let cipher_keys = CipherKeys::from_expanded_keys(expanded_keys); + + let cipher = Aes256CbcEnc::new(cipher_keys.aes_key(), cipher_keys.iv()); + let ciphertext = cipher.encrypt_padded_vec_mut::(message); + + let hmac = HmacSha256::new_from_slice(cipher_keys.mac_key()) + .expect("We should be able to create a Hmac object from a 32 byte key"); + + // BUG: This is a know issue, we create a MAC of an empty message instead of + // updating the `hmac` object with the ciphertext bytes. + let mut mac = hmac.finalize().into_bytes().to_vec(); + mac.truncate(Mac::TRUNCATED_LEN); + + Message { ciphertext, mac, ephemeral_key: Curve25519PublicKey::from(&ephemeral_key) } + } +} + +impl From<&PkDecryption> for PkEncryption { + fn from(value: &PkDecryption) -> Self { + Self::from_key(value.public_key()) + } +} + +impl From for PkEncryption { + fn from(public_key: Curve25519PublicKey) -> Self { + Self { public_key } + } +} + +#[cfg(test)] +mod tests { + use olm_rs::pk::{OlmPkDecryption, OlmPkEncryption, PkMessage}; + + use super::{Message, MessageDecodeError, PkDecryption, PkEncryption}; + use crate::{base64_encode, Curve25519PublicKey, Curve25519SecretKey}; + + /// Conversion from the libolm type to the vodozemac type. To make some + /// tests easier on the eyes. + impl TryFrom for Message { + type Error = MessageDecodeError; + + fn try_from(value: PkMessage) -> Result { + Self::from_base64(&value.ciphertext, &value.mac, &value.ephemeral_key) + } + } + + /// Conversion from the vodozemac type to the libolm type, in a similar + /// manner to the above [TryFrom] implementation. + impl From for PkMessage { + fn from(val: Message) -> Self { + PkMessage { + ciphertext: base64_encode(val.ciphertext), + mac: base64_encode(val.mac), + ephemeral_key: val.ephemeral_key.to_base64(), + } + } + } + + #[test] + fn decrypt_libolm_encrypted_message() { + let decryptor = PkDecryption::new(); + let public_key = decryptor.public_key(); + let encryptor = OlmPkEncryption::new(&public_key.to_base64()); + + let message = "It's a secret to everybody"; + + let encrypted = encryptor.encrypt(message); + let encrypted = + encrypted.try_into().expect("We should be able to decode a message libolm created"); + + let decrypted = decryptor + .decrypt(&encrypted) + .expect("We should be able to decrypt a message libolm encrypted"); + + assert_eq!( + message.as_bytes(), + decrypted, + "The plaintext should match the decrypted message" + ); + } + + #[test] + fn encrypt_for_libolm_pk_decryption() { + let decryptor = OlmPkDecryption::new(); + let public_key = Curve25519PublicKey::from_base64(decryptor.public_key()) + .expect("libolm should provide us with a valid Curve25519 public key"); + let encryptor = PkEncryption::from_key(public_key); + + let message = "It's a secret to everybody"; + + let encrypted = encryptor.encrypt(message.as_ref()); + let encrypted = encrypted.into(); + + let decrypted = decryptor + .decrypt(encrypted) + .expect("We should be able to decrypt a message vodozemac encrypted using libolm"); + + assert_eq!(message, decrypted, "The plaintext should match the decrypted message"); + } + + #[test] + fn encryption_roundtrip() { + let decryptor = PkDecryption::new(); + let public_key = decryptor.public_key(); + let encryptor = PkEncryption::from_key(public_key); + + let message = "It's a secret to everybody"; + + let encrypted = encryptor.encrypt(message.as_ref()); + let decrypted = decryptor + .decrypt(&encrypted) + .expect("We should be able to decrypt a message we encrypted"); + + assert_eq!(message.as_ref(), decrypted, "The plaintext should match the decrypted message"); + } + + #[test] + fn from_bytes() { + let decryption = PkDecryption::default(); + let bytes = decryption.secret_key().to_bytes(); + + let secret_key = Curve25519SecretKey::from_slice(&bytes); + let restored = PkDecryption::from_key(secret_key); + + assert_eq!( + decryption.public_key(), + restored.public_key(), + "The public keys of the restored and original PK decryption should match" + ); + } +} From 2c7dd60b8031a82a6f8f9d972413bd35d10b06b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 2 Sep 2024 14:56:09 +0200 Subject: [PATCH 2/3] ci: Enable all features for our clippy run --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f429fa11..507b0c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --all-features -- -D warnings test: name: ${{ matrix.target.name }} ${{ matrix.channel }} From 484317b2cb52431b6a6fdcfc745a5e2538ae15a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 2 Sep 2024 16:14:28 +0200 Subject: [PATCH 3/3] Constify some more functions --- src/olm/messages/pre_key.rs | 2 +- src/olm/session/message_key.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/olm/messages/pre_key.rs b/src/olm/messages/pre_key.rs index ce659b30..5a89ebba 100644 --- a/src/olm/messages/pre_key.rs +++ b/src/olm/messages/pre_key.rs @@ -153,7 +153,7 @@ impl PreKeyMessage { /// Create a new pre-key message from the session keys and standard message. #[cfg(feature = "low-level-api")] - pub fn wrap(session_keys: SessionKeys, message: Message) -> Self { + pub const fn wrap(session_keys: SessionKeys, message: Message) -> Self { PreKeyMessage::new(session_keys, message) } diff --git a/src/olm/session/message_key.rs b/src/olm/session/message_key.rs index 602610a3..1c690e50 100644 --- a/src/olm/session/message_key.rs +++ b/src/olm/session/message_key.rs @@ -95,13 +95,13 @@ impl MessageKey { /// Get the message key's ratchet key. #[cfg(feature = "low-level-api")] - pub fn ratchet_key(&self) -> RatchetPublicKey { + pub const fn ratchet_key(&self) -> RatchetPublicKey { self.ratchet_key } /// Get the message key's index. #[cfg(feature = "low-level-api")] - pub fn index(&self) -> u64 { + pub const fn index(&self) -> u64 { self.index } }