use std::error::Error as StdError;
use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
use crate::{
CommitHash, ConversationError, ConversationQueues, FreezeFinalizeResult, FreezeOutcome,
ProcessResult, ScoreEvent, ScoreOp, StewardListPlugin,
conversation::BufferedCommitCandidate,
freeze::round::RoundContext,
mls_crypto::{MlsProposalOutput, MlsService, StagedCandidateResult},
protos::de_mls::messages::v1::{
CommitCandidate, ConversationUpdateRequest, MemberWelcome, ViolationEvidence,
conversation_update_request::Payload,
},
};
enum CandidateOutcome {
Terminal {
outcome: FreezeOutcome,
committer: Vec<u8>,
committed_batch: Vec<ConversationUpdateRequest>,
},
Drop(Option<ScoreOp>),
}
pub(super) fn apply_in_priority_order<Pr, M: MlsService, St: StewardListPlugin>(
provider: &Pr,
conversation: &mut ConversationQueues,
mls: &mut M,
steward: &St,
sorted: Vec<BufferedCommitCandidate>,
ctx: &RoundContext,
self_member_id: &[u8],
) -> Result<FreezeFinalizeResult, ConversationError>
where
Pr: OpenMlsProvider,
<Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
{
let mut score_ops: Vec<ScoreOp> = Vec::new();
let mut own_commit_discarded = false;
let conversation_id = conversation.name().to_owned();
let mut remaining = sorted.into_iter();
while let Some(chosen) = remaining.next() {
let apply_result = if chosen.is_local_candidate {
if own_commit_discarded {
tracing::debug!(
conversation = %conversation_id,
"own pending commit is discarded; skipping local candidate"
);
continue;
}
apply_local_candidate(provider, conversation, mls, chosen, ctx)?
} else {
if !own_commit_discarded && steward.is_steward(self_member_id) {
mls.discard_own_commit(provider)?;
own_commit_discarded = true;
}
apply_incoming_candidate(provider, conversation, mls, steward, chosen, ctx)?
};
match apply_result {
CandidateOutcome::Terminal {
outcome,
committer,
committed_batch,
} => {
record_winner_scores(
&mut score_ops,
&committer,
self_member_id,
ctx,
remaining,
steward,
&conversation_id,
);
return Ok(FreezeFinalizeResult {
outcome,
score_ops,
committed_batch,
});
}
CandidateOutcome::Drop(op) => score_ops.extend(op),
}
}
if !own_commit_discarded {
mls.discard_own_commit(provider)?;
}
conversation.clear_freeze_round();
Ok(FreezeFinalizeResult {
outcome: FreezeOutcome::NoCandidate,
score_ops,
committed_batch: Vec::new(),
})
}
fn record_winner_scores<St: StewardListPlugin>(
score_ops: &mut Vec<ScoreOp>,
committer: &[u8],
self_member_id: &[u8],
ctx: &RoundContext,
losers: impl Iterator<Item = BufferedCommitCandidate>,
steward: &St,
conversation_id: &str,
) {
score_ops.push(ScoreOp {
member_id: committer.to_vec(),
event: ScoreEvent::SuccessfulCommit,
});
if let Some(expected) = ctx.epoch_steward_id.as_deref()
&& expected != committer
&& expected != self_member_id
{
score_ops.push(ScoreOp {
member_id: expected.to_vec(),
event: ScoreEvent::CensorshipInactivity,
});
}
for loser in losers {
let claimed = loser.candidate_msg.steward_member_id;
if steward.is_steward(&claimed) {
score_ops.push(ScoreOp {
member_id: claimed,
event: ScoreEvent::HonestCommitAttempt,
});
} else {
tracing::debug!(
conversation = %conversation_id,
"dropping HonestCommitAttempt: claimed user not on steward list"
);
}
}
}
fn apply_local_candidate<Pr, M: MlsService>(
provider: &Pr,
conversation: &mut ConversationQueues,
mls: &mut M,
chosen: BufferedCommitCandidate,
ctx: &RoundContext,
) -> Result<CandidateOutcome, ConversationError>
where
Pr: OpenMlsProvider,
<Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
{
mls.merge_own_commit(provider)?;
let committed_batch =
finalize_committed_batch(conversation, chosen.commit_hash, mls.current_epoch()?);
let joiner_identities = chosen.joiner_identities;
let welcome = chosen.welcome_bytes.map(|welcome_bytes| MemberWelcome {
welcome_bytes,
conversation_sync_bytes: Vec::new(),
joiner_identities,
});
let result = if ctx.self_remove_pending {
ProcessResult::LeaveConversation
} else {
ProcessResult::ConversationUpdated
};
Ok(CandidateOutcome::Terminal {
outcome: FreezeOutcome::Applied { result, welcome },
committer: chosen.candidate_msg.steward_member_id.clone(),
committed_batch,
})
}
fn apply_incoming_candidate<Pr, M: MlsService, St: StewardListPlugin>(
provider: &Pr,
conversation: &mut ConversationQueues,
mls: &mut M,
steward: &St,
chosen: BufferedCommitCandidate,
ctx: &RoundContext,
) -> Result<CandidateOutcome, ConversationError>
where
Pr: OpenMlsProvider,
<Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
{
let conversation_id = conversation.name().to_owned();
let (commit_sender, self_removed, commit_actions) =
match stage_candidate(provider, mls, &conversation_id, &chosen.candidate_msg, ctx)? {
StagingOutcome::Staged {
commit_sender,
self_removed,
commit_actions,
} => (commit_sender, self_removed, commit_actions),
StagingOutcome::Abort => {
mls.discard_staged_commit(provider)?;
return Ok(CandidateOutcome::Drop(Some(ScoreOp {
member_id: chosen.candidate_msg.steward_member_id,
event: ScoreEvent::MisbehavingCommit,
})));
}
StagingOutcome::Violation(v) => {
mls.discard_staged_commit(provider)?;
return Ok(CandidateOutcome::Drop(v.target_score_op()));
}
};
if let Some(violation) =
check_commit_sender_authorized(conversation, steward, &commit_sender, ctx)
{
mls.discard_staged_commit(provider)?;
return Ok(CandidateOutcome::Drop(violation.target_score_op()));
}
if let Some(violation) =
validate_commit_candidate(conversation, &commit_sender, &commit_actions, ctx)?
{
mls.discard_staged_commit(provider)?;
return Ok(CandidateOutcome::Drop(violation.target_score_op()));
}
mls.merge_staged_commit(provider)?;
let committed_batch =
finalize_committed_batch(conversation, chosen.commit_hash, mls.current_epoch()?);
let result = if self_removed {
ProcessResult::LeaveConversation
} else {
ProcessResult::ConversationUpdated
};
Ok(CandidateOutcome::Terminal {
outcome: FreezeOutcome::Applied {
result,
welcome: None,
},
committer: commit_sender,
committed_batch,
})
}
enum StagingOutcome {
Staged {
commit_sender: Vec<u8>,
self_removed: bool,
commit_actions: Vec<MlsProposalOutput>,
},
Abort,
Violation(ViolationEvidence),
}
fn stage_candidate<Pr, M>(
provider: &Pr,
mls: &mut M,
conversation_id: &str,
candidate: &CommitCandidate,
ctx: &RoundContext,
) -> Result<StagingOutcome, ConversationError>
where
Pr: OpenMlsProvider,
<Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
M: MlsService,
{
let staged_result = mls
.stage_remote_commit(provider, &candidate.mls_proposals, &candidate.commit_message)
.inspect_err(|e| {
tracing::debug!(conversation = conversation_id, error = %e, "candidate failed to stage");
});
let (commit_sender, self_removed, commit_actions) = match staged_result {
Ok(StagedCandidateResult::Staged {
commit_sender,
self_removed,
actions,
}) => (commit_sender, self_removed, actions),
Ok(StagedCandidateResult::BundleSenderMismatch { commit_sender }) => {
tracing::warn!(
conversation = conversation_id,
"violation: bundled proposals don't match the commit sender"
);
return Ok(StagingOutcome::Violation(ViolationEvidence::broken_commit(
commit_sender,
ctx.current_epoch,
"commit bundles proposals not signed by the committer",
)));
}
Ok(StagedCandidateResult::Aborted) | Err(_) => return Ok(StagingOutcome::Abort),
};
if candidate.steward_member_id != commit_sender {
tracing::warn!(
conversation = conversation_id,
"violation: wire steward_member_id doesn't match MLS commit_sender"
);
return Ok(StagingOutcome::Violation(ViolationEvidence::broken_commit(
commit_sender,
ctx.current_epoch,
"commit candidate's steward_member_id doesn't match MLS commit sender",
)));
}
Ok(StagingOutcome::Staged {
commit_sender,
self_removed,
commit_actions,
})
}
fn validate_commit_candidate(
conversation: &ConversationQueues,
sender_id: &[u8],
mls_actions: &[MlsProposalOutput],
ctx: &RoundContext,
) -> Result<Option<ViolationEvidence>, ConversationError> {
let mut expected: Vec<(u8, &[u8])> = conversation
.approved_proposals()
.values()
.filter_map(action_projection_from_request)
.collect();
let mut actual: Vec<(u8, &[u8])> = mls_actions.iter().map(action_projection_from_mls).collect();
expected.sort();
expected.dedup();
actual.sort();
actual.dedup();
if expected == actual {
return Ok(None);
}
tracing::warn!(
conversation = conversation.name(),
actual = ?mls_actions,
expected = ?expected,
"violation: MLS actions don't match voted proposals"
);
Ok(Some(ViolationEvidence::broken_mls_proposal(
sender_id.to_vec(),
ctx.current_epoch,
format!("MLS actions {mls_actions:?} != voted {expected:?}"),
)))
}
fn action_projection_from_request(req: &ConversationUpdateRequest) -> Option<(u8, &[u8])> {
match req.payload.as_ref()? {
Payload::MemberInvite(im) => Some((0, &im.member_id)),
Payload::RemoveMember(rm) => Some((1, &rm.member_id)),
_ => None,
}
}
fn action_projection_from_mls(action: &MlsProposalOutput) -> (u8, &[u8]) {
match action {
MlsProposalOutput::Add(id) => (0, id),
MlsProposalOutput::Remove(id) => (1, id),
}
}
fn check_commit_sender_authorized<St: StewardListPlugin>(
conversation: &ConversationQueues,
steward: &St,
commit_sender: &[u8],
ctx: &RoundContext,
) -> Option<ViolationEvidence> {
if ctx.in_recovery {
return None;
}
steward.current_list()?;
if steward.is_exhausted(ctx.current_epoch) {
return None;
}
if steward.is_steward(commit_sender) {
return None;
}
tracing::warn!(
conversation = conversation.name(),
"violation: commit from unauthorized sender"
);
Some(ViolationEvidence::broken_commit(
commit_sender.to_vec(),
ctx.current_epoch,
"commit from unauthorized sender (not on the steward list)",
))
}
fn finalize_committed_batch(
conversation: &mut ConversationQueues,
commit_hash: CommitHash,
current_epoch: u64,
) -> Vec<ConversationUpdateRequest> {
conversation.insert_committed_hash(commit_hash);
let snapshot = if let Some(target) = conversation.take_urgent_commit_target() {
conversation.drop_approved_removals_for(&target);
Vec::new()
} else {
conversation.drain_approved_proposals()
};
conversation.note_member_joins(&snapshot, current_epoch);
conversation.clear_freeze_round();
snapshot
}