From 44cec5b0dc87849273b2eda36c9ba56d740d0927 Mon Sep 17 00:00:00 2001 From: Ellen Arteca Date: Tue, 4 Feb 2025 23:47:24 -0800 Subject: [PATCH] Group API to encrypt/decrypt using the leaf-node HPKE keys (#248) * Adding functionality for a group member to HPKE encrypt a message to another member * formatting * adding a flag "non_domain_separated_hpke_encrypt_decrypt" to gate out the non-domain-separated HPKE encrypt/decrypt for members of a group * implementing safe encrypt/decrypt with context * adding comments * refactoring into helper functionsto avoid duplicating logic * fixing componentoperation * fixing the async build, fixing nostd build, fixing `the trait `FfiType` is not implemented for `HpkeCiphertext`` * fixing various build errors * efficiency refactor of componentoperationlabel * update version number --------- Co-authored-by: Ellen Arteca --- mls-rs-core/src/crypto.rs | 4 + mls-rs/Cargo.toml | 3 +- mls-rs/src/group/component_operation.rs | 26 ++ mls-rs/src/group/mod.rs | 372 ++++++++++++++++++++++++ 4 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 mls-rs/src/group/component_operation.rs diff --git a/mls-rs-core/src/crypto.rs b/mls-rs-core/src/crypto.rs index 83a78a59..7ee9d61b 100644 --- a/mls-rs-core/src/crypto.rs +++ b/mls-rs-core/src/crypto.rs @@ -21,6 +21,10 @@ pub mod test_suite; #[derive(Clone, PartialEq, Eq, MlsSize, MlsEncode, MlsDecode)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "ffi", not(test)), + safer_ffi_gen::ffi_type(clone, opaque) +)] /// Ciphertext produced by [`CipherSuiteProvider::hpke_seal`] pub struct HpkeCiphertext { #[mls_codec(with = "mls_rs_codec::byte_vec")] diff --git a/mls-rs/Cargo.toml b/mls-rs/Cargo.toml index 348012e9..ffc08c93 100644 --- a/mls-rs/Cargo.toml +++ b/mls-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mls-rs" -version = "0.44.1" +version = "0.44.2" edition = "2021" description = "An implementation of Messaging Layer Security (RFC 9420)" homepage = "https://github.com/awslabs/mls-rs" @@ -30,6 +30,7 @@ out_of_order = ["private_message"] prior_epoch = [] by_ref_proposal = [] psk = [] +non_domain_separated_hpke_encrypt_decrypt = [] x509 = ["mls-rs-core/x509", "dep:mls-rs-identity-x509"] rfc_compliant = ["private_message", "custom_proposal", "out_of_order", "psk", "x509", "prior_epoch", "by_ref_proposal", "mls-rs-core/rfc_compliant"] last_resort_key_package_ext = ["mls-rs-core/last_resort_key_package_ext"] diff --git a/mls-rs/src/group/component_operation.rs b/mls-rs/src/group/component_operation.rs new file mode 100644 index 00000000..88f39ec0 --- /dev/null +++ b/mls-rs/src/group/component_operation.rs @@ -0,0 +1,26 @@ +use crate::client::MlsError; +use alloc::vec::Vec; +use mls_rs_codec::{MlsEncode, MlsSize}; + +pub type ComponentID = u32; + +#[derive(Clone, Debug, PartialEq, MlsSize, MlsEncode)] +pub struct ComponentOperationLabel<'a> { + label: &'static [u8], + component_id: ComponentID, + context: &'a [u8], +} + +impl<'a> ComponentOperationLabel<'a> { + pub fn new(component_id: u32, context: &'a [u8]) -> Self { + Self { + label: b"MLS 1.0 Application", + component_id, + context, + } + } + + pub fn get_bytes(&self) -> Result, MlsError> { + self.mls_encode_to_vec().map_err(Into::into) + } +} diff --git a/mls-rs/src/group/mod.rs b/mls-rs/src/group/mod.rs index 5f73ebb8..2fc993c3 100644 --- a/mls-rs/src/group/mod.rs +++ b/mls-rs/src/group/mod.rs @@ -60,6 +60,7 @@ use crate::psk::{ #[cfg(feature = "private_message")] use ciphertext_processor::*; +use component_operation::{ComponentID, ComponentOperationLabel}; use confirmation_tag::*; use framing::*; use key_schedule::*; @@ -111,6 +112,7 @@ pub use self::message_processor::CachedProposal; mod ciphertext_processor; mod commit; +pub mod component_operation; pub(crate) mod confirmation_tag; pub(crate) mod epoch; pub(crate) mod framing; @@ -602,6 +604,153 @@ where self.context().epoch } + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + async fn hpke_encrypt_to_recipient_with_generic_context( + &self, + recipient_index: u32, + context_info: &[u8], + associated_data: Option<&[u8]>, + plaintext: &[u8], + ) -> Result { + let member_leaf_node = self + .group_state() + .public_tree + .get_leaf_node(LeafIndex(recipient_index))?; + let member_public_key = &member_leaf_node.public_key; + let hpke_ciphertext = self + .cipher_suite_provider + .hpke_seal(member_public_key, context_info, associated_data, plaintext) + .await + .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?; + Ok(hpke_ciphertext) + } + + /// HPKE encrypts a message to the member at the specified `recipient_index` in the group. + /// + /// Takes `context_info`, `associated_data`, and `plaintext`. + /// Returns `ciphertext` and `kem_output` inside `HpkeCiphertext`. + /// + /// WARNING: The message sender is not authenticated. + #[cfg(all(feature = "non_domain_separated_hpke_encrypt_decrypt", feature = "ffi"))] + #[cfg_attr( + not(mls_build_async), + maybe_async::must_be_sync, + safer_ffi_gen::safer_ffi_gen_ignore + )] + pub async fn hpke_encrypt_to_recipient( + &self, + recipient_index: u32, + context_info: &[u8], + associated_data: Option<&[u8]>, + plaintext: &[u8], + ) -> Result { + self.hpke_encrypt_to_recipient_with_generic_context( + recipient_index, + context_info, + associated_data, + plaintext, + ) + .await + } + + /// HPKE encrypts a message to the member at the specified `recipient_index` in the group. + /// + /// Takes a `component_id` and `context` to construct a `ComponentOperationLabel`, to be used as + /// the HPKE seal context, to ensure domain separation. + /// Also takes an `associated_data` and `plaintext`. + /// Returns `ciphertext` and `kem_output` inside `HpkeCiphertext`. + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + pub async fn safe_encrypt_with_context_to_recipient( + &self, + recipient_index: u32, + component_id: ComponentID, + context: &[u8], + associated_data: Option<&[u8]>, + plaintext: &[u8], + ) -> Result { + let component_operation_label = ComponentOperationLabel::new(component_id, context); + self.hpke_encrypt_to_recipient_with_generic_context( + recipient_index, + &component_operation_label.get_bytes()?, + associated_data, + plaintext, + ) + .await + } + + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + async fn hpke_decrypt_for_current_member_with_generic_context( + &self, + context_info: &[u8], + associated_data: Option<&[u8]>, + hpke_ciphertext: HpkeCiphertext, + ) -> Result, MlsError> { + let self_private_key = &self.private_tree.secret_keys[0] + .as_ref() + .ok_or(MlsError::InvalidTreeKemPrivateKey)?; + let self_public_key = &self.current_user_leaf_node()?.public_key; + let plaintext = self + .cipher_suite_provider + .hpke_open( + &hpke_ciphertext, + self_private_key, + self_public_key, + context_info, + associated_data, + ) + .await + .map_err(|e| MlsError::CryptoProviderError(e.into_any_error()))?; + Ok(plaintext) + } + + /// HPKE decrypts a message sent to the current member. + /// + /// Takes `HpkeCiphertext` generated by `hpke_encrypt_to_recipient` intended for the + /// current member. + /// + /// WARNING: The message sender is not authenticated. + #[cfg(all(feature = "non_domain_separated_hpke_encrypt_decrypt", feature = "ffi"))] + #[cfg_attr( + not(mls_build_async), + // all(feature = "ffi", not(test)), + maybe_async::must_be_sync, + safer_ffi_gen::safer_ffi_gen_ignore + )] + pub async fn hpke_decrypt_for_current_member( + &self, + context_info: &[u8], + associated_data: Option<&[u8]>, + hpke_ciphertext: HpkeCiphertext, + ) -> Result, MlsError> { + self.hpke_decrypt_for_current_member_with_generic_context( + context_info, + associated_data, + hpke_ciphertext, + ) + .await + } + + /// HPKE decrypts a message sent to the current member. + /// + /// Takes `HpkeCiphertext` generated by `hpke_encrypt_to_recipient` intended for the + /// current member. + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] + pub async fn safe_decrypt_with_context_for_current_member( + &self, + component_id: ComponentID, + context: &[u8], + associated_data: Option<&[u8]>, + hpke_ciphertext: HpkeCiphertext, + ) -> Result, MlsError> { + let component_operation_label = ComponentOperationLabel::new(component_id, context); + self.hpke_decrypt_for_current_member_with_generic_context( + &component_operation_label.get_bytes()?, + associated_data, + hpke_ciphertext, + ) + .await + } + /// Index within the group's state for the local group instance. /// /// This index corresponds to indexes in content descriptions within @@ -2052,6 +2201,7 @@ mod tests { client::test_utils::{test_client_with_key_pkg_custom, TEST_CUSTOM_PROPOSAL_TYPE}, client_builder::{ClientBuilder, MlsConfig}, group::{ + component_operation::ComponentID, mls_rules::{CommitDirection, CommitSource}, proposal_filter::ProposalBundle, }, @@ -2304,6 +2454,228 @@ mod tests { ); } + #[cfg(feature = "non_domain_separated_hpke_encrypt_decrypt")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn test_hpke_encrypt_decrypt() { + let (alice_group, bob_group) = + test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await; + let receiver_index = alice_group.current_member_index(); + let sender_index = bob_group.current_member_index(); + + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let plaintext = b"message"; + + let hpke_ciphertext = bob_group + .hpke_encrypt_to_recipient(receiver_index, &context_info, None, plaintext) + .await + .unwrap(); + let hpke_decrypted = alice_group + .hpke_decrypt_for_current_member(&context_info, None, hpke_ciphertext) + .await + .unwrap(); + + assert_eq!(plaintext.to_vec(), hpke_decrypted); + } + + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn safe_context_test_hpke_encrypt_decrypt() { + let component_id: ComponentID = 1; + let (alice_group, bob_group) = + test_two_member_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE, true).await; + let receiver_index = alice_group.current_member_index(); + let sender_index = bob_group.current_member_index(); + + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let plaintext = b"message"; + + let hpke_ciphertext = bob_group + .safe_encrypt_with_context_to_recipient( + receiver_index, + component_id, + &context_info, + None, + plaintext, + ) + .await + .unwrap(); + let hpke_decrypted = alice_group + .safe_decrypt_with_context_for_current_member( + component_id, + &context_info, + None, + hpke_ciphertext, + ) + .await + .unwrap(); + + assert_eq!(plaintext.to_vec(), hpke_decrypted); + } + + #[cfg(feature = "non_domain_separated_hpke_encrypt_decrypt")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn test_hpke_non_recipient_cant_decrypt() { + let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; + let (mut bob, _) = alice.join("bob").await; + let (carol, commit) = alice.join("carol").await; + + // Apply the commit that adds carol + bob.process_incoming_message(commit).await.unwrap(); + + let receiver_index = alice.current_member_index(); + let sender_index = bob.current_member_index(); + + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let plaintext = b"message"; + + let hpke_ciphertext = bob + .hpke_encrypt_to_recipient(receiver_index, &context_info, None, plaintext) + .await + .unwrap(); + + // different recipient tries to decrypt + let hpke_decrypted = carol + .hpke_decrypt_for_current_member(&context_info, None, hpke_ciphertext) + .await; + + // should fail because carol can't decrypt the message encrypted for alice + assert_matches!(hpke_decrypted, Err(MlsError::CryptoProviderError(_))); + } + + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn safe_context_test_hpke_non_recipient_cant_decrypt() { + let component_id: ComponentID = 345; + let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; + let (mut bob, _) = alice.join("bob").await; + let (carol, commit) = alice.join("carol").await; + + // Apply the commit that adds carol + bob.process_incoming_message(commit).await.unwrap(); + + let receiver_index = alice.current_member_index(); + let sender_index = bob.current_member_index(); + + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let plaintext = b"message"; + + let hpke_ciphertext = bob + .safe_encrypt_with_context_to_recipient( + receiver_index, + component_id, + &context_info, + None, + plaintext, + ) + .await + .unwrap(); + + // different recipient tries to decrypt + let hpke_decrypted = carol + .safe_decrypt_with_context_for_current_member( + component_id, + &context_info, + None, + hpke_ciphertext, + ) + .await; + + // should fail because carol can't decrypt the message encrypted for alice + assert_matches!(hpke_decrypted, Err(MlsError::CryptoProviderError(_))); + } + + #[cfg(feature = "non_domain_separated_hpke_encrypt_decrypt")] + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn test_hpke_can_decrypt_after_group_changes() { + let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; + let (mut bob, _) = alice.join("bob").await; + + let receiver_index = alice.current_member_index(); + let sender_index = bob.current_member_index(); + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let associated_data: Vec = vec![1, 2, 3, 4]; + let plaintext = b"message"; + + // encrypt the message to alice + let hpke_ciphertext = bob + .hpke_encrypt_to_recipient( + receiver_index, + &context_info, + Some(&associated_data), + plaintext, + ) + .await + .unwrap(); + + // add carol to the group + let (_carol, commit) = alice.join("carol").await; + bob.process_incoming_message(commit).await.unwrap(); + + // make sure alice can still decrypt + let hpke_decrypted = alice + .hpke_decrypt_for_current_member(&context_info, Some(&associated_data), hpke_ciphertext) + .await + .unwrap(); + assert_eq!(plaintext.to_vec(), hpke_decrypted); + } + + #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))] + async fn safe_context_test_hpke_can_decrypt_after_group_changes() { + let component_id: ComponentID = 2; + let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await; + let (mut bob, _) = alice.join("bob").await; + + let receiver_index = alice.current_member_index(); + let sender_index = bob.current_member_index(); + let context_info: Vec = vec![ + receiver_index.try_into().unwrap(), + sender_index.try_into().unwrap(), + ]; + let associated_data: Vec = vec![1, 2, 3, 4]; + let plaintext = b"message"; + + // encrypt the message to alice + let hpke_ciphertext = bob + .safe_encrypt_with_context_to_recipient( + receiver_index, + component_id, + &context_info, + Some(&associated_data), + plaintext, + ) + .await + .unwrap(); + + // add carol to the group + let (_carol, commit) = alice.join("carol").await; + bob.process_incoming_message(commit).await.unwrap(); + + // make sure alice can still decrypt + let hpke_decrypted = alice + .safe_decrypt_with_context_for_current_member( + component_id, + &context_info, + Some(&associated_data), + hpke_ciphertext, + ) + .await + .unwrap(); + assert_eq!(plaintext.to_vec(), hpke_decrypted); + } + #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)] async fn test_two_member_group( protocol_version: ProtocolVersion,