diff --git a/crates/quicproquo-core/src/group.rs b/crates/quicproquo-core/src/group.rs index 02e5db5..9168207 100644 --- a/crates/quicproquo-core/src/group.rs +++ b/crates/quicproquo-core/src/group.rs @@ -53,6 +53,30 @@ use crate::{ const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; +// ── Return types ───────────────────────────────────────────────────────────── + +/// Result of processing an incoming MLS message. +#[derive(Debug)] +pub enum ReceivedMessage { + /// Decrypted application-layer plaintext. + Application(Vec), + /// Group state changed (a Commit was merged, epoch advanced). + StateChanged, + /// We were removed from the group by another member. + SelfRemoved, +} + +/// Like [`ReceivedMessage`] but includes the sender's credential identity bytes. +#[derive(Debug)] +pub enum ReceivedMessageWithSender { + /// `(sender_identity, plaintext)`. + Application(Vec, Vec), + /// Group state changed (a Commit was merged). + StateChanged, + /// We were removed from the group. + SelfRemoved, +} + // ── GroupMember ─────────────────────────────────────────────────────────────── /// Per-client MLS state: identity keypair, crypto backend, and optional group. @@ -258,6 +282,122 @@ impl GroupMember { Ok((commit_bytes, welcome_bytes)) } + /// Remove a member by their credential identity (Ed25519 public key bytes). + /// + /// Produces a Commit that must be sent to all remaining members. + /// This method merges the pending Commit locally (advancing the epoch). + /// + /// # Returns + /// + /// `commit_bytes` — TLS-encoded MLS Commit message. + pub fn remove_member( + &mut self, + member_identity: &[u8], + ) -> Result, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let leaf_index = group + .members() + .find(|m| m.credential.identity() == member_identity) + .map(|m| m.index) + .ok_or_else(|| CoreError::Mls("member not found in group".into()))?; + + let (commit_out, _welcome_option, _group_info) = group + .remove_members(&self.backend, self.identity.as_ref(), &[leaf_index]) + .map_err(|e| CoreError::Mls(format!("remove_members: {e:?}")))?; + + group + .merge_pending_commit(&self.backend) + .map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?; + + commit_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}"))) + } + + /// Propose removing ourselves from the group (self-leave). + /// + /// Returns a proposal message that must be sent to other group members. + /// Another member must then commit the pending proposals for the removal + /// to take effect. + pub fn leave_group(&mut self) -> Result, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let proposal_out = group + .leave_group(&self.backend, self.identity.as_ref()) + .map_err(|e| CoreError::Mls(format!("leave_group: {e:?}")))?; + + proposal_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("proposal serialise: {e:?}"))) + } + + /// Commit all pending proposals (stored via `store_pending_proposal`). + /// + /// Returns a Commit message and optionally a Welcome (if an Add was pending). + pub fn commit_pending_proposals( + &mut self, + ) -> Result<(Vec, Option>), CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let (commit_out, welcome_option, _group_info) = group + .commit_to_pending_proposals(&self.backend, self.identity.as_ref()) + .map_err(|e| CoreError::Mls(format!("commit_to_pending_proposals: {e:?}")))?; + + group + .merge_pending_commit(&self.backend) + .map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?; + + let commit_bytes = commit_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}")))?; + + let welcome_bytes = welcome_option + .map(|w| { + w.to_bytes() + .map_err(|e| CoreError::Mls(format!("welcome serialise: {e:?}"))) + }) + .transpose()?; + + Ok((commit_bytes, welcome_bytes)) + } + + /// Propose a self-update (key rotation). + /// + /// Returns a Proposal message to send to all group members. + /// Another member (or self) must then commit the pending proposals. + pub fn propose_self_update(&mut self) -> Result, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let (proposal_out, _ref) = group + .propose_self_update(&self.backend, self.identity.as_ref(), None) + .map_err(|e| CoreError::Mls(format!("propose_self_update: {e:?}")))?; + + proposal_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("proposal serialise: {e:?}"))) + } + + /// Whether there are pending proposals waiting to be committed. + pub fn has_pending_proposals(&self) -> bool { + self.group + .as_ref() + .map(|g| g.pending_proposals().next().is_some()) + .unwrap_or(false) + } + /// Join an existing MLS group from a TLS-encoded Welcome message. /// /// The caller must have previously called [`generate_key_package`] on @@ -320,14 +460,15 @@ impl GroupMember { /// /// # Returns /// - /// - `Ok(Some(plaintext))` for Application messages. - /// - `Ok(None)` for Commit messages (group state is updated internally). + /// - [`ReceivedMessage::Application`] for decrypted application messages. + /// - [`ReceivedMessage::StateChanged`] for Commits and Proposals (group state updated). + /// - [`ReceivedMessage::SelfRemoved`] if the Commit removed us from the group. /// /// # Errors /// /// Returns [`CoreError::Mls`] if the message is malformed, fails /// authentication, or the group state is inconsistent. - pub fn receive_message(&mut self, mut bytes: &[u8]) -> Result>, CoreError> { + pub fn receive_message(&mut self, mut bytes: &[u8]) -> Result { let group = self .group .as_mut() @@ -348,22 +489,35 @@ impl GroupMember { .map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?; match processed.into_content() { - ProcessedMessageContent::ApplicationMessage(app) => Ok(Some(app.into_bytes())), + ProcessedMessageContent::ApplicationMessage(app) => { + Ok(ReceivedMessage::Application(app.into_bytes())) + } ProcessedMessageContent::StagedCommitMessage(staged) => { - // Merge the Commit into the local state (epoch advances). + // Check if this commit removes us. + let own_index = group.own_leaf_index(); + let self_removed = staged.remove_proposals().any(|queued| { + queued.remove_proposal().removed() == own_index + }); + group .merge_staged_commit(&self.backend, *staged) .map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?; - Ok(None) + + if self_removed { + self.group = None; + Ok(ReceivedMessage::SelfRemoved) + } else { + Ok(ReceivedMessage::StateChanged) + } } // Proposals are stored for a later Commit; nothing to return yet. ProcessedMessageContent::ProposalMessage(proposal) => { group.store_pending_proposal(*proposal); - Ok(None) + Ok(ReceivedMessage::StateChanged) } ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => { group.store_pending_proposal(*proposal); - Ok(None) + Ok(ReceivedMessage::StateChanged) } } } @@ -371,14 +525,11 @@ impl GroupMember { /// Process an incoming TLS-encoded MLS message and return sender identity + plaintext for application messages. /// /// Same as [`receive_message`], but for Application messages returns - /// `Some((sender_identity_bytes, plaintext))` so the client can display who sent the message. - /// `sender_identity_bytes` is the MLS credential identity (e.g. Ed25519 public key for Basic credential). - /// - /// Returns `Ok(None)` for Commit and Proposal messages (group state is updated internally). + /// `(sender_identity_bytes, plaintext)` so the client can display who sent the message. pub fn receive_message_with_sender( &mut self, mut bytes: &[u8], - ) -> Result, Vec)>, CoreError> { + ) -> Result { let group = self .group .as_mut() @@ -401,21 +552,32 @@ impl GroupMember { match processed.into_content() { ProcessedMessageContent::ApplicationMessage(app) => { - Ok(Some((sender_identity, app.into_bytes()))) + Ok(ReceivedMessageWithSender::Application(sender_identity, app.into_bytes())) } ProcessedMessageContent::StagedCommitMessage(staged) => { + let own_index = group.own_leaf_index(); + let self_removed = staged.remove_proposals().any(|queued| { + queued.remove_proposal().removed() == own_index + }); + group .merge_staged_commit(&self.backend, *staged) .map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?; - Ok(None) + + if self_removed { + self.group = None; + Ok(ReceivedMessageWithSender::SelfRemoved) + } else { + Ok(ReceivedMessageWithSender::StateChanged) + } } ProcessedMessageContent::ProposalMessage(proposal) => { group.store_pending_proposal(*proposal); - Ok(None) + Ok(ReceivedMessageWithSender::StateChanged) } ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => { group.store_pending_proposal(*proposal); - Ok(None) + Ok(ReceivedMessageWithSender::StateChanged) } } } diff --git a/crates/quicproquo-core/src/keystore.rs b/crates/quicproquo-core/src/keystore.rs index 2123fc5..881bc8a 100644 --- a/crates/quicproquo-core/src/keystore.rs +++ b/crates/quicproquo-core/src/keystore.rs @@ -5,11 +5,7 @@ use std::{ sync::RwLock, }; -use openmls_rust_crypto::RustCrypto; -use openmls_traits::{ - key_store::{MlsEntity, OpenMlsKeyStore}, - OpenMlsCryptoProvider, -}; +use openmls_traits::key_store::{MlsEntity, OpenMlsKeyStore}; /// A disk-backed key store implementing `OpenMlsKeyStore`. /// @@ -106,42 +102,3 @@ impl OpenMlsKeyStore for DiskKeyStore { } } -/// Crypto provider that couples RustCrypto with a disk-backed key store. -#[derive(Debug)] -pub struct StoreCrypto { - crypto: RustCrypto, - key_store: DiskKeyStore, -} - -impl StoreCrypto { - pub fn new(key_store: DiskKeyStore) -> Self { - Self { - crypto: RustCrypto::default(), - key_store, - } - } -} - -impl Default for StoreCrypto { - fn default() -> Self { - Self::new(DiskKeyStore::ephemeral()) - } -} - -impl OpenMlsCryptoProvider for StoreCrypto { - type CryptoProvider = RustCrypto; - type RandProvider = RustCrypto; - type KeyStoreProvider = DiskKeyStore; - - fn crypto(&self) -> &Self::CryptoProvider { - &self.crypto - } - - fn rand(&self) -> &Self::RandProvider { - &self.crypto - } - - fn key_store(&self) -> &Self::KeyStoreProvider { - &self.key_store - } -}