Skip to main content

de_mls/conversation/
inbound.rs

1//! Inbound conversation-traffic processing.
2//!
3//! Two layers co-located:
4//!
5//! - The free function [`decode_inbound_payload`] decodes an app-subtopic payload
6//!   into a [`ProcessResult`]. Welcome-subtopic packets are handled at the
7//!   integrator layer (because the invitation path constructs a new
8//!   `MlsService` via the user-supplied factory) and are not routed through
9//!   this function.
10//! - [`Conversation::process_inbound`] is the single entry point for all
11//!   conversation traffic. It owns echo-dedup and the internal
12//!   `dispatch_inbound_result` chain. `LeaveConversation` is terminal:
13//!   `prepare_self_leave` emits `Leaving`, cancels timers, and deletes local
14//!   MLS state; the integrator then removes the registry entry and cleans up
15//!   the consensus scope.
16
17use std::error::Error as StdError;
18use std::sync::Arc;
19
20use openmls_traits::signatures::Signer;
21use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
22
23use hashgraph_like_consensus::protos::consensus::v1::Proposal;
24use prost::Message;
25use tracing::{error, info, warn};
26
27use crate::{
28    ConsensusPlugin, ConversationEvent, PeerScoringPlugin, ProcessResult, ProposalKind,
29    ScoreSnapshot, StewardList, StewardListConfig, StewardListPlugin,
30    conversation::{ConversationQueues, member_set},
31    freeze::{buffer_commit_candidate, compute_commit_hash},
32    mls_crypto::{DecryptResult, MlsService},
33    process_result::NoopReason,
34    protos::de_mls::messages::v1::{
35        AppMessage, ConversationSync, ConversationUpdateRequest, EventMembershipChange,
36        TimingConfig, TypeMembershipChange, app_message, conversation_update_request,
37    },
38};
39
40use crate::{
41    Conversation, ConversationError, ConversationState, consensus::bridge::forward_incoming_vote,
42};
43
44/// Fast-path proposals (`expected_voters_count == 1`) bypass peer voting, so
45/// we restrict them to self-removal. Enforcing that the MLS-authenticated
46/// sender matches the `RemoveMember` target closes the unilateral-removal
47/// vector that an otherwise-free `expected_voters == 1` opens.
48///
49/// Returns `true` when the proposal is allowed. A mismatch (different
50/// target, wrong payload variant, or undecodable) produces `false`.
51fn authorize_fast_path_proposal(proposal: &Proposal, mls_sender: &[u8]) -> bool {
52    if proposal.expected_voters_count != 1 {
53        return true;
54    }
55    if proposal.proposal_owner != mls_sender {
56        return false;
57    }
58    let Ok(request) = ConversationUpdateRequest::decode(proposal.payload.as_slice()) else {
59        return false;
60    };
61    matches!(
62        request.payload,
63        Some(conversation_update_request::Payload::RemoveMember(ref r)) if r.member_id == mls_sender
64    )
65}
66
67/// Process an inbound packet on the app subtopic and decide what action is
68/// needed. Welcome-subtopic packets are handled at the integrator layer.
69pub fn decode_inbound_payload<Pr, M: MlsService>(
70    provider: &Pr,
71    conversation: &mut ConversationQueues,
72    mls: &mut M,
73    payload: &[u8],
74) -> Result<ProcessResult, ConversationError>
75where
76    Pr: OpenMlsProvider,
77    <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
78{
79    // 1. Try the plaintext envelopes (sent as plaintext AppMessage):
80    //    CommitCandidate and MemberWelcome both have to be readable before
81    //    the receiver merges the commit they belong to.
82    if let Ok(app_message) = AppMessage::decode(payload) {
83        match app_message.payload {
84            Some(app_message::Payload::CommitCandidate(candidate)) => {
85                return buffer_commit_candidate(conversation, mls, candidate);
86            }
87            Some(app_message::Payload::MemberWelcome(welcome)) => {
88                if welcome.welcome_bytes.is_empty() {
89                    return Ok(ProcessResult::Noop(NoopReason::EmptyWelcomePayload));
90                }
91                if !conversation
92                    .record_welcome_broadcast(compute_commit_hash(&welcome.welcome_bytes))
93                {
94                    return Ok(ProcessResult::Noop(NoopReason::DuplicateWelcomeBroadcast));
95                }
96                return Ok(ProcessResult::WelcomeBroadcastReceived(Box::new(welcome)));
97            }
98            _ => {}
99        }
100    }
101
102    // 2. MLS-encrypted app messages only — use decrypt_application_only.
103    //    This NEVER stores proposals or processes commits, preventing
104    //    rogue MLS proposals on the app subtopic from polluting state.
105    let res = mls.decrypt_application_only(provider, payload)?;
106
107    match res {
108        DecryptResult::Application(app_bytes, sender) => {
109            let mut app_msg = AppMessage::decode(app_bytes.as_ref())?;
110            // Stamp the MLS-authenticated sender (the verified leaf credential
111            // content) onto conversation messages.
112            if let Some(app_message::Payload::ConversationMessage(cm)) = &mut app_msg.payload {
113                cm.sender_credential = sender.clone();
114            }
115            if let Some(app_message::Payload::Proposal(proposal)) = &app_msg.payload
116                && !authorize_fast_path_proposal(proposal, &sender)
117            {
118                warn!(
119                    conversation = conversation.name(),
120                    proposal_id = proposal.proposal_id,
121                    sender = ?sender,
122                    owner = ?proposal.proposal_owner,
123                    "fast-path proposal rejected: sender is not the self-removal target"
124                );
125                return Ok(ProcessResult::Noop(NoopReason::FastPathRejected));
126            }
127            // Drop BanRequests whose target isn't in the conversation — saves a
128            // useless consensus round.
129            if let Some(app_message::Payload::BanRequest(ban)) = &app_msg.payload
130                && !mls.is_member(&ban.user_to_ban)
131            {
132                info!(
133                    conversation = conversation.name(),
134                    target = ?ban.user_to_ban,
135                    "ban request skipped: target not a member"
136                );
137                return Ok(ProcessResult::Noop(NoopReason::BanTargetNotMember));
138            }
139            app_msg.try_into()
140        }
141        DecryptResult::Removed(_) => Ok(ProcessResult::LeaveConversation),
142        DecryptResult::Ignored => {
143            tracing::debug!(
144                conversation = conversation.name(),
145                "app message ignored (wrong epoch/conversation)"
146            );
147            Ok(ProcessResult::Noop(NoopReason::DecryptIgnored))
148        }
149        _ => {
150            warn!(
151                conversation = conversation.name(),
152                "unexpected MLS message type on app subtopic"
153            );
154            Ok(ProcessResult::Noop(NoopReason::UnexpectedMlsType))
155        }
156    }
157}
158
159/// What [`Conversation::process_inbound`] hands back to the integrator.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum DispatchOutcome {
162    /// No further integrator action required.
163    Done,
164    /// Packet was self-echoed. No action.
165    Dropped,
166    /// The conversation has completed its protocol-side teardown (emitted
167    /// `Leaving`, deleted MLS state). The integrator must remove the
168    /// registry entry and clean up the consensus scope.
169    LeaveRequested,
170}
171
172impl<C, Sc, St> Conversation<C, Sc, St>
173where
174    C: ConsensusPlugin,
175    Sc: PeerScoringPlugin,
176    St: StewardListPlugin,
177{
178    /// Decrypt and dispatch an inbound conversation payload. Drops self-echoes.
179    /// Runs the full dispatch chain internally. Returns
180    /// [`DispatchOutcome::LeaveRequested`] when the conversation has completed
181    /// its protocol-side teardown; the integrator must then remove the registry
182    /// entry and clean up the consensus scope.
183    pub fn process_inbound<Pr>(
184        &mut self,
185        provider: &Pr,
186        sender: &[u8],
187        payload: &[u8],
188        signer: &impl Signer,
189    ) -> Result<DispatchOutcome, ConversationError>
190    where
191        Pr: OpenMlsProvider,
192        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
193    {
194        if sender == self.app_id.as_ref() {
195            return Ok(DispatchOutcome::Dropped);
196        }
197        let result = self.decode_inbound(provider, payload)?;
198        self.dispatch_inbound_result(provider, result, signer)
199    }
200
201    pub fn apply_welcome_sync<Pr>(
202        &mut self,
203        provider: &Pr,
204        sync_bytes: &[u8],
205        signer: &impl Signer,
206    ) -> Result<(), ConversationError>
207    where
208        Pr: OpenMlsProvider,
209        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
210    {
211        if sync_bytes.is_empty() {
212            return Ok(());
213        }
214        let result = self.decode_inbound(provider, sync_bytes)?;
215        self.dispatch_inbound_result(provider, result, signer)?;
216        Ok(())
217    }
218
219    pub(crate) fn dispatch_inbound_result<Pr>(
220        &mut self,
221        provider: &Pr,
222        result: ProcessResult,
223        signer: &impl Signer,
224    ) -> Result<DispatchOutcome, ConversationError>
225    where
226        Pr: OpenMlsProvider,
227        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
228    {
229        match result {
230            ProcessResult::AppMessage(msg) => {
231                self.emit_event(ConversationEvent::AppMessage(*msg));
232                Ok(DispatchOutcome::Done)
233            }
234            ProcessResult::Proposal(proposal) => {
235                self.on_incoming_proposal(*proposal)?;
236                Ok(DispatchOutcome::Done)
237            }
238            ProcessResult::Vote(vote) => {
239                let outcome_applied = self.queues.is_consensus_outcome_applied(vote.proposal_id);
240                forward_incoming_vote::<C>(
241                    &self.conversation_id,
242                    *vote,
243                    &self.services.consensus,
244                    outcome_applied,
245                )?;
246                Ok(DispatchOutcome::Done)
247            }
248            ProcessResult::MembershipChangeReceived(request) => {
249                self.handle_incoming_update_request(provider, *request, signer)?;
250                Ok(DispatchOutcome::Done)
251            }
252            ProcessResult::ConversationUpdated => {
253                self.on_conversation_updated(provider, signer)?;
254                Ok(DispatchOutcome::Done)
255            }
256            ProcessResult::LeaveConversation => {
257                self.prepare_self_leave(provider)?;
258                Ok(DispatchOutcome::LeaveRequested)
259            }
260            ProcessResult::CommitCandidateReceived {
261                steward_id: steward,
262            } => {
263                self.on_commit_candidate_received(provider, &steward, signer)?;
264                Ok(DispatchOutcome::Done)
265            }
266            ProcessResult::ConversationSyncReceived(sync) => {
267                self.on_conversation_sync(*sync)?;
268                Ok(DispatchOutcome::Done)
269            }
270            ProcessResult::WelcomeBroadcastReceived(welcome) => {
271                self.emit_event(ConversationEvent::WelcomeReady {
272                    welcome: *welcome,
273                    minted_locally: false,
274                });
275                Ok(DispatchOutcome::Done)
276            }
277            ProcessResult::Noop(reason) => {
278                tracing::debug!(
279                    conversation = %self.conversation_id,
280                    ?reason,
281                    "inbound dispatched as noop"
282                );
283                Ok(DispatchOutcome::Done)
284            }
285        }
286    }
287
288    /// Before forwarding to consensus, mirror intent into local buffers:
289    /// emergency proposals set the partial-freeze flag and resolve any
290    /// locally-buffered ECP for the same violation; membership-change
291    /// proposals get mirrored into the pending-update buffer so a future
292    /// epoch steward can retry if this round fails.
293    ///
294    /// RFC §"Partial Freeze Semantics" asks that lower-priority proposals
295    /// from peers be DROPPED during an active emergency, not merely locally
296    /// blocked. We don't drop today — the RFC's Δ-synchrony assumption keeps
297    /// divergence windows small. Consensus-service-level priority gating is
298    /// tracked as a backlog item in `docs/ROADMAP.md`.
299    fn on_incoming_proposal(&mut self, proposal: Proposal) -> Result<(), ConversationError> {
300        let decoded = match ConversationUpdateRequest::decode(proposal.payload.as_slice()) {
301            Ok(req) => Some(req),
302            Err(e) => {
303                tracing::debug!(
304                    proposal_id = proposal.proposal_id,
305                    error = %e,
306                    "incoming proposal payload failed to decode; treated as opaque commit"
307                );
308                None
309            }
310        };
311        if let Some(req) = decoded.as_ref() {
312            let current_epoch = self.mls().current_epoch()?;
313            match &req.payload {
314                Some(conversation_update_request::Payload::EmergencyCriteria(_)) => {
315                    self.queues.insert_emergency(proposal.proposal_id);
316                }
317                Some(conversation_update_request::Payload::MemberInvite(_))
318                | Some(conversation_update_request::Payload::RemoveMember(_)) => {
319                    self.queues
320                        .insert_pending_update(req.clone(), current_epoch);
321                }
322                _ => {}
323            }
324        }
325        let proposal_id = proposal.proposal_id;
326        let expected_voters = proposal.expected_voters_count;
327        let kind = decoded
328            .as_ref()
329            .map(ProposalKind::of)
330            .unwrap_or(ProposalKind::Commit);
331
332        let scope = C::Scope::from(self.conversation_id.clone());
333        self.services
334            .consensus
335            .process_incoming_proposal(&scope, proposal)?;
336        // Skip the vote request + auto-vote for fast-path proposals: the
337        // creator's bundled YES already resolved the consensus session, so peers have
338        // nothing to vote on.
339        if expected_voters > 1 {
340            // A votable peer proposal always decodes as a
341            // `ConversationUpdateRequest`; an opaque payload can't be
342            // surfaced for a vote, so only the auto-vote drives it.
343            if let Some(request) = decoded {
344                self.emit_event(ConversationEvent::VoteRequested {
345                    proposal_id,
346                    request,
347                });
348            }
349            let delay = self.config.voting_delay_for(kind);
350            let vote = self.config.liveness_criteria_yes;
351            self.register_auto_vote(proposal_id, delay, vote);
352        }
353        Ok(())
354    }
355
356    /// We just joined via welcome. Runs after `assemble` already put the
357    /// conversation in `Working` and emitted the opening `PhaseChange`, so it
358    /// neither transitions state nor emits a second phase change. Broadcasts a
359    /// system "joined" chat message and seeds scoring with the current member
360    /// set.
361    pub(crate) fn on_joined<Pr>(
362        &mut self,
363        provider: &Pr,
364        signer: &impl Signer,
365    ) -> Result<(), ConversationError>
366    where
367        Pr: OpenMlsProvider,
368        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
369    {
370        let msg: AppMessage = EventMembershipChange {
371            conversation_id: self.conversation_id.clone(),
372            member: self.self_member_id.to_vec(),
373            change_type: TypeMembershipChange::Add as i32,
374        }
375        .into();
376        let conversation_id = self.conversation_id.clone();
377        let mls = self.mls_mut();
378        let members = mls.members().unwrap_or_default();
379        let payload = mls.build_message(provider, signer, &msg)?;
380        self.broadcast(payload);
381        self.sync_scoring_members(&members);
382        info!(conversation = %conversation_id, "joined conversation");
383        Ok(())
384    }
385
386    /// A commit merged. Sync scoring + pending-update buffers, transition to
387    /// Working, and run steward housekeeping (list reconcile, election
388    /// kick-off, buffered-update drain). The commit author's `SuccessfulCommit`
389    /// reward is emitted by `finalize_freeze_round`, not here.
390    fn on_conversation_updated<Pr>(
391        &mut self,
392        provider: &Pr,
393        signer: &impl Signer,
394    ) -> Result<(), ConversationError>
395    where
396        Pr: OpenMlsProvider,
397        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
398    {
399        let mls_members = self.mls().members().unwrap_or_default();
400        self.sync_scoring_members(&mls_members);
401        self.prune_pending_updates_after_commit()?;
402
403        // Transition to Working BEFORE steward checks (election needs Working
404        // state). Reset reelection_round: this commit advanced the epoch,
405        // so whatever retry cycle we were in belongs to the previous epoch.
406        self.services.steward_list.reset_retry();
407        let state = self.current_state();
408        let working_event = if matches!(
409            state,
410            ConversationState::Working
411                | ConversationState::Freezing
412                | ConversationState::Selection
413                | ConversationState::Reelection
414        ) {
415            Some(self.start_working())
416        } else {
417            None
418        };
419
420        self.steward_list_housekeeping(provider, signer)?;
421        self.process_buffered_updates(provider, signer)?;
422        self.maybe_close_recovery_window(provider, signer);
423
424        if let Some(event) = working_event {
425            self.emit_event(ConversationEvent::PhaseChange(event));
426        }
427        Ok(())
428    }
429
430    /// Fire a steward election while `recovery_mode` is set so the next
431    /// list installs and closes the window.
432    fn maybe_close_recovery_window<Pr>(&mut self, provider: &Pr, signer: &impl Signer)
433    where
434        Pr: OpenMlsProvider,
435        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
436    {
437        if !self.is_in_recovery_mode() {
438            return;
439        }
440        if let Err(e) = self.initiate_steward_election(provider, true, signer) {
441            info!(
442                conversation = %self.conversation_id,
443                error = %e,
444                "post-recovery election deferred"
445            );
446        }
447    }
448
449    /// Protocol-side teardown for `LeaveConversation`: emit `Leaving`, cancel
450    /// pending timers, and delete the local MLS state. The integrator removes
451    /// the registry entry and cleans up the consensus scope.
452    fn prepare_self_leave<Pr>(&mut self, provider: &Pr) -> Result<(), ConversationError>
453    where
454        Pr: OpenMlsProvider,
455        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
456    {
457        self.emit_event(ConversationEvent::Leaving);
458        self.cancel_all_auto_votes();
459        // The leave is already committed (`Leaving` emitted); a delete failure
460        // must log and continue, else the caller never reaches
461        // `LeaveRequested` and the entry leaks. The integrator tears the
462        // conversation down right after.
463        if let Err(e) = self.mls_mut().delete(provider) {
464            error!(error = %e, "self-leave: MLS storage delete failed; leaving anyway");
465        }
466        Ok(())
467    }
468
469    /// Peer broadcast a commit candidate. If we were in Working, enter
470    /// Freezing and — if we're a steward — build our own candidate too.
471    fn on_commit_candidate_received<Pr>(
472        &mut self,
473        provider: &Pr,
474        steward: &[u8],
475        signer: &impl Signer,
476    ) -> Result<(), ConversationError>
477    where
478        Pr: OpenMlsProvider,
479        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
480    {
481        tracing::debug!(
482            conversation = %self.conversation_id,
483            steward = ?steward,
484            "candidate received from peer steward"
485        );
486        let state = self.current_state();
487        if state != ConversationState::Working && state != ConversationState::Reelection {
488            return Ok(());
489        }
490
491        let Some(event) = self.start_freezing() else {
492            return Ok(());
493        };
494        let epoch = self.mls().current_epoch()?;
495        self.queues.start_freeze_round(epoch);
496
497        let self_member_id = Arc::clone(&self.self_member_id);
498        let outbound = if self.services.steward_list.is_steward(&self_member_id) {
499            match self.create_commit_candidate(provider, signer, &self_member_id) {
500                Ok(payload) => payload,
501                Err(e) => {
502                    error!(
503                        conversation = %self.conversation_id,
504                        error = %e,
505                        "own commit candidate build failed"
506                    );
507                    None
508                }
509            }
510        } else {
511            None
512        };
513
514        self.emit_event(ConversationEvent::PhaseChange(event));
515        if let Some(payload) = outbound {
516            self.broadcast(payload);
517        }
518        Ok(())
519    }
520
521    /// Apply a steward's `ConversationSync` when we're a joiner without a steward
522    /// list. Validates the proposed list against the members it carries
523    /// (not the full MLS set — the list may have been generated before we
524    /// existed), then applies list + protocol flags + timing + peer scores.
525    fn on_conversation_sync(&mut self, sync: ConversationSync) -> Result<(), ConversationError> {
526        if self.services.steward_list.current_list().is_some() {
527            return Ok(());
528        }
529        let conversation_id = self.conversation_id.clone();
530        let (members, current_epoch) = {
531            let mls = self.mls();
532            (mls.members()?, mls.current_epoch()?)
533        };
534        let local_default_peer_score = self.services.scoring.default_score();
535        if !validate_conversation_sync(
536            &conversation_id,
537            &sync,
538            current_epoch,
539            &members,
540            local_default_peer_score,
541        )? {
542            return Ok(());
543        }
544
545        let sn = sync.steward_members.len();
546        self.apply_conversation_sync_to_entry(&sync)?;
547
548        info!(
549            conversation = %conversation_id,
550            election_epoch = sync.election_epoch,
551            stewards = sn,
552            scores = sync.peer_scores.len(),
553            timing = sync.timing.is_some(),
554            "conversation sync applied"
555        );
556        Ok(())
557    }
558
559    fn apply_conversation_sync_to_entry(
560        &mut self,
561        sync: &ConversationSync,
562    ) -> Result<(), ConversationError> {
563        let mut protocol_config =
564            StewardListConfig::new(sync.sn_min as usize, sync.sn_max as usize)?;
565        protocol_config.allow_subset_candidates = sync.allow_subset_candidates;
566
567        let sn = sync.steward_members.len();
568        self.services.steward_list.set_config(protocol_config);
569        self.services.steward_list.install_list(
570            sync.election_epoch,
571            &sync.steward_members,
572            sn,
573            sync.retry_round,
574        )?;
575        self.services
576            .steward_list
577            .set_max_retries(sync.max_reelection_attempts);
578        self.services
579            .scoring
580            .set_threshold(sync.threshold_peer_score);
581        let snapshot = ScoreSnapshot {
582            diverged: sync
583                .peer_scores
584                .iter()
585                .map(|ps| (ps.member_id.clone(), ps.score))
586                .collect(),
587        };
588        // The ConversationSync sender (an existing steward) holds the same
589        // scores and is the canonical actor for any below-threshold
590        // member in this snapshot — they'll submit
591        // `SCORE_BELOW_THRESHOLD` from their own event chain. Drop our
592        // result to avoid duplicate proposals from joiners.
593        let _ = self.services.scoring.apply_snapshot(&snapshot);
594        self.config.liveness_criteria_yes = sync.liveness_criteria_yes;
595        self.config.pending_update_max_epochs = sync.pending_update_max_epochs;
596        if let Some(timing) = &sync.timing {
597            self.config.apply_timing(timing);
598        }
599        Ok(())
600    }
601}
602
603/// Returns `true` when the sync is acceptable for application. Logs the
604/// rejection reason on `false`.
605///
606/// `members` is the joiner's current MLS member set; ghost stewards
607/// (removed since the list was elected) are tolerated as long as at
608/// least one listed steward is still present.
609///
610/// `local_default_peer_score` is the joiner's configured starting score
611/// for new members (not synced; per-node). Rejecting when it sits at
612/// or below the synced threshold prevents a misconfiguration where every
613/// new member added by this joiner starts already eligible for removal.
614fn validate_conversation_sync(
615    conversation_id: &str,
616    sync: &ConversationSync,
617    current_epoch: u64,
618    members: &[Vec<u8>],
619    local_default_peer_score: i64,
620) -> Result<bool, ConversationError> {
621    if sync.election_epoch > current_epoch {
622        info!(
623            conversation = conversation_id,
624            election_epoch = sync.election_epoch,
625            current_epoch,
626            "conversation sync rejected: election_epoch > current_epoch"
627        );
628        return Ok(false);
629    }
630
631    let members_set = member_set(members);
632    let any_present = sync
633        .steward_members
634        .iter()
635        .any(|s| members_set.contains(s.as_slice()));
636    let ordering_valid = StewardList::validate(
637        &sync.steward_members,
638        sync.election_epoch,
639        conversation_id.as_bytes(),
640        &sync.steward_members,
641        &StewardListConfig::new(sync.sn_min as usize, sync.sn_max as usize)?,
642        sync.retry_round,
643    )?;
644    if !(any_present && ordering_valid) {
645        info!(
646            conversation = conversation_id,
647            any_present,
648            ordering = ordering_valid,
649            "conversation sync rejected: invalid"
650        );
651        return Ok(false);
652    }
653
654    if let Some(timing) = &sync.timing
655        && let Some(zero_field) = first_zero_timing_field(timing)
656    {
657        info!(
658            conversation = conversation_id,
659            field = zero_field,
660            "conversation sync rejected: zero-valued timing field"
661        );
662        return Ok(false);
663    }
664
665    if local_default_peer_score <= sync.threshold_peer_score {
666        info!(
667            conversation = conversation_id,
668            local_default_peer_score,
669            threshold_peer_score = sync.threshold_peer_score,
670            "conversation sync rejected: default_peer_score at or below threshold would mark new members removable on add"
671        );
672        return Ok(false);
673    }
674    Ok(true)
675}
676
677/// Name of the first zero-valued field in `timing`, or `None` if all
678/// fields are non-zero. Zero in any timing field would short-circuit the
679/// timer it drives (consensus_timeout firing immediately,
680/// commit_inactivity breaking the inactivity tracker, etc.).
681fn first_zero_timing_field(timing: &TimingConfig) -> Option<&'static str> {
682    if timing.commit_inactivity_duration_ms == 0 {
683        Some("commit_inactivity_duration_ms")
684    } else if timing.freeze_duration_ms == 0 {
685        Some("freeze_duration_ms")
686    } else if timing.proposal_expiration_ms == 0 {
687        Some("proposal_expiration_ms")
688    } else if timing.consensus_timeout_ms == 0 {
689        Some("consensus_timeout_ms")
690    } else if timing.recovery_inactivity_duration_ms == 0 {
691        Some("recovery_inactivity_duration_ms")
692    } else {
693        None
694    }
695}
696
697#[cfg(test)]
698mod decode_inbound_payload_tests {
699    use super::*;
700    use crate::conversation::self_leave_proposal_id;
701
702    fn member(id: u8) -> Vec<u8> {
703        vec![id; 20]
704    }
705
706    fn remove_payload(member_id: &[u8]) -> Vec<u8> {
707        ConversationUpdateRequest::remove_member(member_id.to_vec()).encode_to_vec()
708    }
709
710    fn proposal_for_self_remove(sender: &[u8], expected_voters: u32) -> Proposal {
711        Proposal {
712            name: "test".into(),
713            payload: remove_payload(sender),
714            proposal_id: self_leave_proposal_id(sender),
715            proposal_owner: sender.to_vec(),
716            votes: Vec::new(),
717            expected_voters_count: expected_voters,
718            round: 1,
719            timestamp: 0,
720            expiration_timestamp: u64::MAX,
721            liveness_criteria_yes: true,
722        }
723    }
724
725    #[test]
726    fn fast_path_allows_self_removal_matching_sender() {
727        let sender = member(1);
728        let proposal = proposal_for_self_remove(&sender, 1);
729        assert!(authorize_fast_path_proposal(&proposal, &sender));
730    }
731
732    #[test]
733    fn fast_path_rejects_target_other_than_sender() {
734        let sender = member(1);
735        let victim = member(2);
736        let mut proposal = proposal_for_self_remove(&victim, 1);
737        proposal.proposal_owner = sender.clone();
738        assert!(!authorize_fast_path_proposal(&proposal, &sender));
739    }
740
741    #[test]
742    fn fast_path_rejects_owner_mismatch() {
743        let sender = member(1);
744        let imposter = member(3);
745        let mut proposal = proposal_for_self_remove(&sender, 1);
746        proposal.proposal_owner = imposter;
747        assert!(!authorize_fast_path_proposal(&proposal, &sender));
748    }
749
750    #[test]
751    fn fast_path_rejects_non_remove_payload() {
752        let sender = member(1);
753        let mut proposal = proposal_for_self_remove(&sender, 1);
754        proposal.payload = vec![0xff; 8]; // garbage
755        assert!(!authorize_fast_path_proposal(&proposal, &sender));
756    }
757
758    #[test]
759    fn expected_voters_gt_one_bypasses_authz() {
760        let sender = member(1);
761        let victim = member(2);
762        let mut proposal = proposal_for_self_remove(&victim, 5);
763        proposal.proposal_owner = sender.clone();
764        // Regular proposals (voters > 1) aren't gated by this check — peer
765        // voting provides the authorization instead.
766        assert!(authorize_fast_path_proposal(&proposal, &sender));
767    }
768}
769
770#[cfg(test)]
771mod conversation_sync_tests {
772    use super::*;
773    use crate::protos::de_mls::messages::v1::TimingConfig;
774
775    fn nonzero_timing() -> TimingConfig {
776        TimingConfig {
777            commit_inactivity_duration_ms: 60_000,
778            freeze_duration_ms: 30_000,
779            proposal_expiration_ms: 3_600_000,
780            consensus_timeout_ms: 30_000,
781            recovery_inactivity_duration_ms: 5_000,
782        }
783    }
784
785    #[test]
786    fn nonzero_timing_passes() {
787        assert!(first_zero_timing_field(&nonzero_timing()).is_none());
788    }
789
790    fn valid_sync_with(threshold: i64) -> ConversationSync {
791        ConversationSync {
792            steward_members: vec![b"alice".to_vec()],
793            election_epoch: 0,
794            sn_min: 1,
795            sn_max: 5,
796            allow_subset_candidates: false,
797            peer_scores: vec![],
798            timing: Some(nonzero_timing()),
799            retry_round: 0,
800            max_reelection_attempts: 1,
801            liveness_criteria_yes: true,
802            threshold_peer_score: threshold,
803            pending_update_max_epochs: 3,
804        }
805    }
806
807    /// Joiner's `default_peer_score` strictly above the synced threshold
808    /// — new members added by this joiner start safely above the bar.
809    #[test]
810    fn validate_accepts_default_above_threshold() {
811        let sync = valid_sync_with(0);
812        assert!(validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 100).unwrap());
813    }
814
815    /// Joiner's `default_peer_score` equal to the threshold — new members
816    /// would start at threshold and `score <= threshold` already qualifies
817    /// them for removal.
818    #[test]
819    fn validate_rejects_default_equal_to_threshold() {
820        let sync = valid_sync_with(50);
821        assert!(!validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 50).unwrap());
822    }
823
824    /// Joiner's `default_peer_score` below the threshold — every new
825    /// member added by this joiner starts removable.
826    #[test]
827    fn validate_rejects_default_below_threshold() {
828        let sync = valid_sync_with(100);
829        assert!(!validate_conversation_sync("g", &sync, 0, &[b"alice".to_vec()], 50).unwrap());
830    }
831
832    #[test]
833    fn each_zero_field_is_detected() {
834        let cases = [
835            (
836                "commit_inactivity_duration_ms",
837                TimingConfig {
838                    commit_inactivity_duration_ms: 0,
839                    ..nonzero_timing()
840                },
841            ),
842            (
843                "freeze_duration_ms",
844                TimingConfig {
845                    freeze_duration_ms: 0,
846                    ..nonzero_timing()
847                },
848            ),
849            (
850                "proposal_expiration_ms",
851                TimingConfig {
852                    proposal_expiration_ms: 0,
853                    ..nonzero_timing()
854                },
855            ),
856            (
857                "consensus_timeout_ms",
858                TimingConfig {
859                    consensus_timeout_ms: 0,
860                    ..nonzero_timing()
861                },
862            ),
863            (
864                "recovery_inactivity_duration_ms",
865                TimingConfig {
866                    recovery_inactivity_duration_ms: 0,
867                    ..nonzero_timing()
868                },
869            ),
870        ];
871        for (name, timing) in cases {
872            assert_eq!(
873                first_zero_timing_field(&timing),
874                Some(name),
875                "expected field {name} to be detected as zero"
876            );
877        }
878    }
879}