Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions crates/xmtp_configuration/src/common/mls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ pub const MLS_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::Mls10;

pub const WELCOME_HPKE_LABEL: &str = "MLS_WELCOME";

/// HPKE domain-separation label for external-invite GroupInfo payloads
/// wrapped via [`payload_encryption::wrap_payload_hpke`]. Distinct from
/// [`WELCOME_HPKE_LABEL`] to prevent cross-protocol oracle attacks.
///
/// The v1 external-invite flow uses symmetric AEAD encryption only (see
/// `xmtp_mls_common::invite::encrypted_group_info`), but the label is
/// reserved here so a future HPKE-based external-invite path can adopt it
/// without churning the public API.
///
/// [`payload_encryption::wrap_payload_hpke`]: https://docs.rs/xmtp_mls_common/latest/xmtp_mls_common/mls_ext/payload_encryption/fn.wrap_payload_hpke.html
pub const XMTP_EXTERNAL_INVITE_LABEL: &str = "XMTP_EXTERNAL_INVITE";

pub const MAX_GROUP_SYNC_RETRIES: usize = 3;

pub const MAX_INTENT_PUBLISH_ATTEMPTS: usize = 3;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,7 @@ mod tests {
version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 {
sequence_id: seq,
failed_installations: failed,
admitted_via_external_group_id: vec![],
})),
}
}
Expand Down
46 changes: 40 additions & 6 deletions crates/xmtp_mls/src/groups/app_data/component_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ pub(crate) fn component_type(id: ComponentId) -> Option<ComponentType> {
| ComponentId::MESSAGE_DISAPPEAR_IN_NS
| ComponentId::COMMIT_LOG_SIGNER => Some(ComponentType::Bytes),

// External-commit policy: proto-encoded ExternalCommitPolicyEntry,
// replaced atomically via the generic AppDataUpdate intent. No
// per-id Component impl needed; helpers decode bytes via prost.
ComponentId::EXTERNAL_COMMIT_POLICY => Some(ComponentType::Bytes),

// Immutable metadata (not flowable through AppDataUpdate writes,
// but we still advertise the type for completeness).
ComponentId::CONVERSATION_TYPE
Expand Down Expand Up @@ -1034,12 +1039,27 @@ pub(crate) struct GroupMetadataReturn {
/// receive-side validator to bridge the dict-stored membership back
/// into the existing `GroupMembership` Rust type without rewriting
/// every caller.
pub(crate) fn read_group_membership_from_dict(
/// Read and decode the per-inbox `GROUP_MEMBERSHIP` entries from the
/// AppData dictionary, keeping the full `GroupMembershipEntry` shape
/// (including `admitted_via_external_group_id` — XIP-82 max-uses
/// accounting and tag preservation read it; the legacy-flattening
/// [`read_group_membership_from_dict`] drops it).
///
/// Returns `None` on unmigrated groups (the legacy extension is
/// authoritative there and carries no per-entry state) and when the
/// dict has no `GROUP_MEMBERSHIP` entry.
pub(crate) fn read_group_membership_entries(
extensions: &Extensions<GroupContext>,
) -> Result<Option<xmtp_proto::xmtp::mls::message_contents::GroupMembership>, ComponentSourceError>
{
) -> Result<
Option<
std::collections::BTreeMap<
InboxId,
xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry,
>,
>,
ComponentSourceError,
> {
use xmtp_mls_common::app_data::migration::decode_group_membership_dict;
use xmtp_proto::xmtp::mls::message_contents::GroupMembership as GroupMembershipProto;

// Gate on the unified migration predicate so a stray
// `GROUP_MEMBERSHIP` dict entry on a pre-bootstrap group can't
Expand All @@ -1060,12 +1080,23 @@ pub(crate) fn read_group_membership_from_dict(
return Ok(None);
};

let entries = decode_group_membership_dict(bytes).map_err(|e| {
decode_group_membership_dict(bytes).map(Some).map_err(|e| {
ComponentSourceError::MalformedComponentValue {
component_id: ComponentId::GROUP_MEMBERSHIP,
reason: format!("TlsMap decode: {e}"),
}
})?;
})
}

pub(crate) fn read_group_membership_from_dict(
extensions: &Extensions<GroupContext>,
) -> Result<Option<xmtp_proto::xmtp::mls::message_contents::GroupMembership>, ComponentSourceError>
{
use xmtp_proto::xmtp::mls::message_contents::GroupMembership as GroupMembershipProto;

let Some(entries) = read_group_membership_entries(extensions)? else {
return Ok(None);
};

// Flatten per-inbox GroupMembershipEntryV1 back into the legacy
// proto shape: members (inbox_id → sequence_id), failed_installations
Expand Down Expand Up @@ -2375,6 +2406,7 @@ mod tests {
version: Some(Version::V1(GroupMembershipEntryV1 {
sequence_id: 1,
failed_installations: vec![],
admitted_via_external_group_id: vec![],
})),
},
);
Expand All @@ -2399,6 +2431,7 @@ mod tests {
version: Some(Version::V1(GroupMembershipEntryV1 {
sequence_id: 7,
failed_installations: vec![vec![0xA1; 16]],
admitted_via_external_group_id: vec![],
})),
},
);
Expand All @@ -2408,6 +2441,7 @@ mod tests {
version: Some(Version::V1(GroupMembershipEntryV1 {
sequence_id: 42,
failed_installations: vec![vec![0xB1; 16]],
admitted_via_external_group_id: vec![],
})),
},
);
Expand Down
4 changes: 4 additions & 0 deletions crates/xmtp_mls/src/groups/app_data/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ async fn build_partitioned_group_membership<C: XmtpSharedContext>(
version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 {
sequence_id: *seq,
failed_installations: failed,
// Every pre-migration member was added via Welcome /
// legacy flows, never an external commit, so the
// admitted-via tag is correctly absent for all of them.
admitted_via_external_group_id: vec![],
})),
},
);
Expand Down
51 changes: 40 additions & 11 deletions crates/xmtp_mls/src/groups/app_data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,33 @@ pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
component_id: ComponentId,
payload: Vec<u8>,
) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
let (mut proposal_msgs, bundle) = stage_app_data_propose_many_and_commit(
mls_group,
provider,
signer,
vec![(component_id, payload)],
)?;
let proposal_msg = proposal_msgs
.pop()
.expect("one update stages exactly one proposal");
Ok((proposal_msg, bundle))
}

/// Multi-component variant of [`stage_app_data_propose_and_commit`]:
/// stages one standalone `AppDataUpdate` proposal **per update**, then a
/// single commit that consumes them all. Used by writes whose invariants
/// couple components atomically — enabling `EXTERNAL_COMMIT_POLICY` must
/// establish the `GROUP_MEMBERSHIP` external-committer grant in the same
/// commit (XIP-82 enable atomicity).
///
/// Returns the proposal messages in update order; the caller publishes
/// all of them followed by the commit, in that order, in one batch.
pub(crate) fn stage_app_data_propose_many_and_commit<Provider: OpenMlsProvider>(
mls_group: &mut OpenMlsGroup,
provider: &Provider,
signer: &impl openmls_traits::signatures::Signer,
updates: Vec<(ComponentId, Vec<u8>)>,
) -> Result<(Vec<MlsMessageOut>, CommitMessageBundle), GroupAppDataError<Provider::StorageError>> {
// Lazy-batching: we deliberately do NOT block on pre-existing
// pending proposals. This helper queues a new `AppDataUpdate` then
// commits via `consume_proposal_store(true)`, sweeping whatever
Expand All @@ -283,15 +310,18 @@ pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
// information than the on-wire commit, but the producer of each
// folded-in proposal already accepted that outcome by leaving it
// pending instead of issuing its own commit.
let openmls_id = component_id.as_u16();
let operation = AppDataUpdateOperation::Update(payload.into());

// Step 1: publish a standalone proposal. This adds the proposal to
// the local pending-proposal store AND returns the wire-form
// MlsMessageOut for the proposal so the caller can broadcast it.
let (proposal_msg, _proposal_ref) = mls_group
.propose_app_data_update(provider, signer, openmls_id, operation)
.map_err(GroupAppDataError::Propose)?;
// Step 1: publish one standalone proposal per update. Each call adds
// the proposal to the local pending-proposal store AND returns the
// wire-form MlsMessageOut so the caller can broadcast it.
let mut proposal_msgs = Vec::with_capacity(updates.len());
for (component_id, payload) in updates {
let openmls_id = component_id.as_u16();
let operation = AppDataUpdateOperation::Update(payload.into());
let (proposal_msg, _proposal_ref) = mls_group
.propose_app_data_update(provider, signer, openmls_id, operation)
.map_err(GroupAppDataError::Propose)?;
proposal_msgs.push(proposal_msg);
}

// Step 2: compute the per-component dict updates by sweeping every
// `AppDataUpdate` proposal currently in the store. The store may
Expand Down Expand Up @@ -326,7 +356,6 @@ pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
let app_data_updates =
accumulate_app_data_updates(mls_group, pending_iter).inspect_err(|e| {
tracing::error!(
component_id = %component_id,
error = %e,
"Failed to compute AppDataUpdates for standalone propose+commit"
);
Expand All @@ -346,7 +375,7 @@ pub(crate) fn stage_app_data_propose_and_commit<Provider: OpenMlsProvider>(
.build(provider.rand(), provider.crypto(), signer, |_| true)?
.stage_commit(provider)?;

Ok((proposal_msg, bundle))
Ok((proposal_msgs, bundle))
}

/// Errors surfaced by [`stage_app_data_propose_and_commit`].
Expand Down
39 changes: 25 additions & 14 deletions crates/xmtp_mls/src/groups/app_data/sender_intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ use xmtp_proto::xmtp::mls::message_contents::{
};

use super::component_source::{ComponentSourceError, metadata_field_to_component_id};
use super::{load_component_registry, stage_app_data_propose_and_commit};
use super::{
load_component_registry, stage_app_data_propose_and_commit,
stage_app_data_propose_many_and_commit,
};
use crate::{
context::XmtpSharedContext,
groups::{
Expand Down Expand Up @@ -268,19 +271,25 @@ pub(crate) fn apply_app_data_update_intent(
) -> Result<PublishIntentData, GroupError> {
let storage = context.mls_storage();

let component_id = ComponentId::new(intent_data.component_id);
let payload = intent_data.payload;
// Primary update first, then any coupled writes that must land in
// the same commit (XIP-82 enable atomicity: policy + GROUP_MEMBERSHIP
// grant). Each becomes its own standalone proposal; the commit sweeps
// them all.
let mut updates = Vec::with_capacity(1 + intent_data.additional_updates.len());
updates.push((
ComponentId::new(intent_data.component_id),
intent_data.payload,
));
for (component_id, payload) in intent_data.additional_updates {
updates.push((ComponentId::new(component_id), payload));
}

let ((proposal_msg, bundle), staged_commit, group_epoch) = generate_commit_with_rollback(
let ((proposal_msgs, bundle), staged_commit, group_epoch) = generate_commit_with_rollback(
storage,
openmls_group,
move |group, provider| -> Result<_, GroupError> {
Ok(stage_app_data_propose_and_commit(
group,
provider,
&signer,
component_id,
payload,
Ok(stage_app_data_propose_many_and_commit(
group, provider, &signer, updates,
)?)
},
)?;
Expand All @@ -290,11 +299,13 @@ pub(crate) fn apply_app_data_update_intent(
welcome.is_none(),
"AppDataUpdate intent must not produce a welcome"
);
let mut payloads_to_publish = Vec::with_capacity(proposal_msgs.len() + 1);
for proposal_msg in proposal_msgs {
payloads_to_publish.push(proposal_msg.tls_serialize_detached()?);
}
payloads_to_publish.push(commit.tls_serialize_detached()?);
Ok(PublishIntentData {
payloads_to_publish: vec![
proposal_msg.tls_serialize_detached()?,
commit.tls_serialize_detached()?,
],
payloads_to_publish,
staged_commit,
post_commit_action: None,
should_send_push_notification,
Expand Down
7 changes: 7 additions & 0 deletions crates/xmtp_mls/src/groups/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ pub enum GroupError {
/// AppDataUpdate path. Not retryable.
#[error("component source error: {0}")]
ComponentSource(#[from] super::app_data::component_source::ComponentSourceError),
/// An `EXTERNAL_COMMIT_POLICY` value violates the XIP-82
/// field-coupling invariants (enable requires key + slot id; revoke
/// leaves every per-invite field absent). Not retryable — the caller
/// supplied a malformed policy.
#[error("external commit policy error: {0}")]
ExternalCommitPolicy(#[from] super::external_commit_policy::ExternalCommitPolicyError),
/// AppData commit error.
///
/// Failed to build or stage a commit that bundles an inline AppDataUpdate
Expand Down Expand Up @@ -587,6 +593,7 @@ impl RetryableError for GroupError {
Self::MinVersionDowngrade { .. } => false,
Self::InvalidMinVersion { .. } => false,
Self::ComponentSource(_) => false,
Self::ExternalCommitPolicy(_) => false,
Self::AppDataCommit(e) => e.is_retryable(),
// Bootstrap synthesis can fail on a transient identity-update
// API blip — delegate to the inner error so we retry on
Expand Down
Loading
Loading