core_crypto/mls/conversation/
commit_delay.rs

1use itertools::Itertools;
2use log::{debug, trace};
3use openmls::prelude::LeafNodeIndex;
4
5use super::MlsConversation;
6
7/// These constants intend to ramp up the delay and flatten the curve for later positions
8const DELAY_RAMP_UP_MULTIPLIER: f32 = 120.0;
9const DELAY_RAMP_UP_SUB: u64 = 106;
10const DELAY_POS_LINEAR_INCR: u64 = 15;
11const DELAY_POS_LINEAR_RANGE: std::ops::RangeInclusive<u64> = 1..=3;
12
13impl MlsConversation {
14    /// Helps consumer by providing a deterministic delay in seconds for him to commit its pending proposal.
15    /// It depends on the index of the client in the ratchet tree
16    /// * `self_index` - ratchet tree index of self client
17    /// * `epoch` - current group epoch
18    /// * `nb_members` - number of clients in the group
19    pub fn compute_next_commit_delay(&self) -> Option<u64> {
20        use openmls::messages::proposals::Proposal;
21
22        if self.group.pending_proposals().next().is_none() {
23            trace!("No pending proposals, no delay needed");
24            return None;
25        }
26
27        let removed_index = self
28            .group
29            .pending_proposals()
30            .filter_map(|proposal| {
31                if let Proposal::Remove(remove_proposal) = proposal.proposal() {
32                    Some(remove_proposal.removed())
33                } else {
34                    None
35                }
36            })
37            .collect::<Vec<LeafNodeIndex>>();
38
39        let self_index = self.group.own_leaf_index();
40        debug!(removed_index:? = removed_index, self_index:? = self_index; "Indexes");
41        // Find a remove proposal that concerns us
42        let is_self_removed = removed_index.contains(&self_index);
43
44        // If our own client has been removed, don't commit
45        if is_self_removed {
46            debug!("Self removed from group, no delay needed");
47            return None;
48        }
49
50        let epoch = self.group.epoch().as_u64();
51
52        // Position in array among non-blank leaf node indices
53        let self_position = self
54            .group
55            .members()
56            .find_position(|member| member.index == self_index)
57            .map(|pos| pos.0 as u64)
58            .expect("self_index should be in members since we already checked for self-removal");
59        let removed_indices_to_the_left = removed_index
60            .iter()
61            .filter(|index| index.u32() < self_index.u32())
62            .count() as u64;
63
64        // This shifts our own self-position to the left (tree-wise) from as many as there was removed members that have
65        // a smaller leaf index than us (older members)
66        let own_index = self_position - removed_indices_to_the_left;
67
68        // Post-commit visualization of the number of members after remove proposals
69        let nb_members = (self.group.members().count() as u64).saturating_sub(removed_index.len() as u64);
70
71        Some(Self::calculate_delay(own_index, epoch, nb_members))
72    }
73
74    fn calculate_delay(self_index: u64, epoch: u64, nb_members: u64) -> u64 {
75        let position = if nb_members > 0 {
76            ((epoch % nb_members) + (self_index % nb_members)) % nb_members + 1
77        } else {
78            1
79        };
80
81        if DELAY_POS_LINEAR_RANGE.contains(&position) {
82            position.saturating_sub(1) * DELAY_POS_LINEAR_INCR
83        } else {
84            (((position as f32).ln() * DELAY_RAMP_UP_MULTIPLIER) as u64).saturating_sub(DELAY_RAMP_UP_SUB)
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::test_utils::*;
93
94    fn assert_delays_are_unique(delays: &[(&str, Option<u64>)]) {
95        for (i, (name_a, delay_a)) in delays.iter().enumerate() {
96            assert!(delay_a.is_some(), "{name_a} should have a delay");
97            for (name_b, delay_b) in &delays[i + 1..] {
98                assert_ne!(
99                    delay_a, delay_b,
100                    "{name_a} and {name_b} have the same commit delay; all delays: {delays:?}"
101                );
102            }
103        }
104    }
105
106    #[test]
107    fn calculate_delay_single() {
108        let (self_index, epoch, nb_members) = (0, 0, 1);
109        let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
110        assert_eq!(delay, 0);
111    }
112
113    #[test]
114    fn calculate_delay_max() {
115        let (self_index, epoch, nb_members) = (u64::MAX, u64::MAX, u64::MAX);
116        let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
117        assert_eq!(delay, 0);
118    }
119
120    #[test]
121    fn calculate_delay_min() {
122        let (self_index, epoch, nb_members) = (u64::MIN, u64::MIN, u64::MAX);
123        let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
124        assert_eq!(delay, 0);
125    }
126
127    #[test]
128    fn calculate_delay_zero_members() {
129        let (self_index, epoch, nb_members) = (0, 0, u64::MIN);
130        let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
131        assert_eq!(delay, 0);
132    }
133
134    #[test]
135    fn calculate_delay_min_max() {
136        let (self_index, epoch, nb_members) = (u64::MIN, u64::MAX, u64::MAX);
137        let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
138        assert_eq!(delay, 0);
139    }
140
141    #[test]
142    fn calculate_delay_n() {
143        let epoch = 1;
144        let nb_members = 10;
145
146        let indexes_delays = [
147            (0, 15),
148            (1, 30),
149            (2, 60),
150            (3, 87),
151            (4, 109),
152            (5, 127),
153            (6, 143),
154            (7, 157),
155            (8, 170),
156            (9, 0),
157            // wrong but it shouldn't cause problems
158            (10, 15),
159        ];
160
161        for (self_index, expected_delay) in indexes_delays {
162            let delay = MlsConversation::calculate_delay(self_index, epoch, nb_members);
163            assert_eq!(delay, expected_delay);
164        }
165    }
166
167    #[apply(all_cred_cipher)]
168    async fn calculate_delay_creator_removed(case: TestContext) {
169        let [alice, bob, charlie] = case.sessions().await;
170        Box::pin(async move {
171            let conversation = case
172                .create_conversation([&alice, &bob])
173                .await
174                .invite_notify([&charlie])
175                .await;
176            assert_eq!(conversation.member_count().await, 3);
177
178            let proposal_guard = conversation.remove_proposal(&alice).await;
179            let (proposal_guard, result) = proposal_guard.notify_member_fallible(&bob).await;
180            let bob_decrypted_message = result.unwrap();
181            let (_, result) = proposal_guard.notify_member_fallible(&charlie).await;
182            let charlie_decrypted_message = result.unwrap();
183
184            let bob_hypothetical_position = 0;
185            let charlie_hypothetical_position = 1;
186
187            assert_eq!(
188                bob_decrypted_message.delay,
189                Some(DELAY_POS_LINEAR_INCR * bob_hypothetical_position)
190            );
191
192            assert_eq!(
193                charlie_decrypted_message.delay,
194                Some(DELAY_POS_LINEAR_INCR * charlie_hypothetical_position)
195            );
196        })
197        .await;
198    }
199
200    #[apply(all_cred_cipher)]
201    async fn calculate_delay_is_unique_with_blank_leaves(case: TestContext) {
202        let [alice, bob, charlie, dave, eve, frank] = case.sessions().await;
203        // leaf indices: alice 0, bob 1, charlie 2, dave 3, eve 4
204        let conversation = case
205            .create_conversation([&alice, &bob, &charlie, &dave, &eve])
206            .await
207            // removing bob leaves a blank leaf at index 1
208            .remove_notify(&bob)
209            .await;
210        assert_eq!(conversation.member_count().await, 4);
211
212        // any pending proposal which isn't a remove will do
213        let mut proposal_guard = conversation.invite_proposal(&frank).await;
214        let mut delays = vec![(
215            "alice",
216            proposal_guard
217                .conversation()
218                .guard_of(&alice)
219                .await
220                .conversation_mut()
221                .await
222                .compute_next_commit_delay(),
223        )];
224        for (name, member) in [("charlie", &charlie), ("dave", &dave), ("eve", &eve)] {
225            let (guard, result) = proposal_guard.notify_member_fallible(member).await;
226            proposal_guard = guard;
227            delays.push((name, result.unwrap().delay));
228        }
229
230        assert_delays_are_unique(&delays);
231    }
232
233    #[apply(all_cred_cipher)]
234    async fn calculate_delay_with_blank_leaves_ignores_removed_members_to_the_right(case: TestContext) {
235        let [alice, bob, charlie, dave, eve] = case.sessions().await;
236        // leaf indices: alice 0, bob 1, charlie 2, dave 3, eve 4
237        let conversation = case
238            .create_conversation([&alice, &bob, &charlie, &dave, &eve])
239            .await
240            // removing bob and charlie leaves blank leaves at indices 1 and 2
241            .remove_notify(&bob)
242            .await
243            .remove_notify(&charlie)
244            .await;
245        assert_eq!(conversation.member_count().await, 3);
246
247        // eve is to the right of dave, so her removal must not shift dave's position
248        let proposal_guard = conversation.remove_proposal(&eve).await;
249        let alice_delay = proposal_guard
250            .conversation()
251            .guard_of(&alice)
252            .await
253            .conversation_mut()
254            .await
255            .compute_next_commit_delay();
256        let (_, result) = proposal_guard.notify_member_fallible(&dave).await;
257        let dave_delay = result.unwrap().delay;
258        let delays = vec![("alice", alice_delay), ("dave", dave_delay)];
259
260        // post-commit, the members are alice and dave, at positions 0 and 1
261        assert_delays_are_unique(&delays);
262    }
263}