diff --git a/crates/xmtp_mls/src/groups/app_data/component_source.rs b/crates/xmtp_mls/src/groups/app_data/component_source.rs index f0f76b6876..243d432f3c 100644 --- a/crates/xmtp_mls/src/groups/app_data/component_source.rs +++ b/crates/xmtp_mls/src/groups/app_data/component_source.rs @@ -274,6 +274,11 @@ pub(crate) fn component_type(id: ComponentId) -> Option { | 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 diff --git a/crates/xmtp_mls/src/groups/app_data/mod.rs b/crates/xmtp_mls/src/groups/app_data/mod.rs index f9b8e3dd4b..ab6d25553e 100644 --- a/crates/xmtp_mls/src/groups/app_data/mod.rs +++ b/crates/xmtp_mls/src/groups/app_data/mod.rs @@ -283,12 +283,11 @@ pub(crate) fn stage_app_data_propose_and_commit( // 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. + // Step 1: publish the standalone proposal. This adds the proposal + // to the local pending-proposal store AND returns the wire-form + // MlsMessageOut so the caller can broadcast it. 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)?; @@ -326,7 +325,6 @@ pub(crate) fn stage_app_data_propose_and_commit( 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" ); diff --git a/crates/xmtp_mls/src/groups/error.rs b/crates/xmtp_mls/src/groups/error.rs index 371a08c5c3..caceb9f589 100644 --- a/crates/xmtp_mls/src/groups/error.rs +++ b/crates/xmtp_mls/src/groups/error.rs @@ -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 @@ -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 diff --git a/crates/xmtp_mls/src/groups/external_commit_policy.rs b/crates/xmtp_mls/src/groups/external_commit_policy.rs new file mode 100644 index 0000000000..ea98306b2d --- /dev/null +++ b/crates/xmtp_mls/src/groups/external_commit_policy.rs @@ -0,0 +1,422 @@ +//! External-commit policy lookup helpers. +//! +//! An incoming MLS External Commit (RFC 9420 §12.4.3.2) is gated by the +//! **master switch** — the `EXTERNAL_COMMIT_POLICY` well-known +//! component, decoded into [`ExternalCommitPolicyV1`]. Carries +//! `allow_external_commit` plus the time-window controls +//! (`expires_at_ns`, `expire_in_ns`). Per XIP-82, +//! `allow_external_commit = true` itself authorizes the joiner's +//! `GROUP_MEMBERSHIP` self-entry insert — no separate per-component +//! grant exists. +//! +//! The switch defaults to "deny" when absent — this module surfaces +//! `Option<…>`/`bool` from "absent" rather than synthesizing a default +//! struct, so callers can route on whether the admin has ever opted in. +//! +//! The MLS-spec invariants (exactly one ExternalInit, joiner credential +//! binding on Adds, no by-reference proposals, no SelfRemove) are +//! hardcoded in the validator (see L-7); this module only covers the +//! AppData-resident policy. + +use openmls::group::MlsGroup as OpenMlsGroup; +use prost::Message; +use xmtp_mls_common::{ + app_data::component_id::ComponentId, + invite::payload::{MIN_EXTERNAL_GROUP_ID_LEN, SYMMETRIC_KEY_LEN, validate_service_pointer}, +}; +use xmtp_proto::xmtp::mls::message_contents::{ + ExternalCommitPolicyEntry, ExternalCommitPolicyV1, ServicePointer, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, +}; + +use crate::groups::app_data::component_source::ComponentSourceError; + +/// Caller-tunable settings for `MlsGroup::enable_external_commits`. The +/// freshly-generated `symmetric_key` / `external_group_id` are NOT here — +/// they are minted by the enable call itself (CSPRNG) and returned as +/// [`ExternalInviteCoordinates`]. +#[derive(Debug, Clone, Default)] +pub struct ExternalInviteSettings { + /// Wall-clock campaign expiry (ns since UNIX epoch); `0` = none. + /// Per-invite: cleared by revoke. + pub expires_at_ns: u64, + /// Max staleness of the GroupInfo referenced by an external commit; + /// `0` = none. Durable setting: survives revoke. + pub expire_in_ns: u64, + /// Concurrent cap on members admitted via the active invite; `0` = + /// unlimited. Durable setting: survives revoke. + pub max_uses: u32, + /// Service locations members use to keep the invite blob fresh + /// across epoch advances. Empty = member-driven refresh off + /// (only the issuer and past scanners can refresh). + pub refresh_pointers: Vec, +} + +/// The invite coordinates minted by `MlsGroup::enable_external_commits` — +/// exactly what the QR payload carries alongside the per-QR service +/// pointer. +#[derive(Clone)] +pub struct ExternalInviteCoordinates { + /// Fresh 32-byte ChaCha20Poly1305 key wrapping the GroupInfo blob. + pub symmetric_key: [u8; SYMMETRIC_KEY_LEN], + /// Fresh service-slot identifier. + pub external_group_id: Vec, +} + +impl std::fmt::Debug for ExternalInviteCoordinates { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The key is the secret; the slot id is service-visible by + // design and safe to log. + f.debug_struct("ExternalInviteCoordinates") + .field("symmetric_key", &"") + .field("external_group_id", &hex::encode(&self.external_group_id)) + .finish() + } +} + +/// Violations of the XIP-82 field-coupling invariants on an +/// `EXTERNAL_COMMIT_POLICY` value. Enforced setter-side (the high-level +/// APIs refuse to queue a violating proposal) AND receive-side as a +/// post-state invariant (validators reject a commit whose resulting +/// policy state violates them) — both checks are pure functions of the +/// proposed value, so every member converges. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ExternalCommitPolicyError { + /// Enabled policy without a `symmetric_key`. + #[error("enabled policy requires symmetric_key")] + MissingSymmetricKey, + /// `symmetric_key.material` length is not exactly 32 bytes. + #[error("symmetric_key.material must be {SYMMETRIC_KEY_LEN} bytes (got {0})")] + InvalidSymmetricKeyLength(usize), + /// Enabled policy whose `external_group_id` is shorter than the + /// 4-byte floor. + #[error("external_group_id must be at least {MIN_EXTERNAL_GROUP_ID_LEN} bytes (got {0})")] + InvalidExternalGroupIdLength(usize), + /// A `refresh_pointers` entry is present but carries no location + /// variant, or an https location fails validation. + #[error("invalid refresh_pointer: {0}")] + InvalidRefreshPointer(String), + /// Disabled policy retains per-invite state. The revoke invariant: + /// `allow_external_commit == false` implies `symmetric_key` ABSENT, + /// `external_group_id` empty, `expires_at_ns` 0, and + /// `refresh_pointers` empty — a revoked policy serializes to nothing + /// but the durable settings (`expire_in_ns`, `max_uses`), + /// byte-identical to a policy that never had an invite. Lingering + /// state is a trap: a stale key could be revived by a careless + /// re-enable, stale pointers re-adopted, and a stale absolute + /// `expires_at_ns` would silently mis-bound the next campaign. + #[error("disabled policy must leave per-invite field absent: {field}")] + PerInviteFieldNotCleared { + /// Which per-invite field was left populated. + field: &'static str, + }, +} + +/// Validate the XIP-82 field-coupling invariants on a policy value. +/// Pure function of the value. +pub(crate) fn validate_policy_v1( + policy: &ExternalCommitPolicyV1, +) -> Result<(), ExternalCommitPolicyError> { + if policy.allow_external_commit { + let key = policy + .symmetric_key + .as_ref() + .ok_or(ExternalCommitPolicyError::MissingSymmetricKey)?; + if key.material.len() != SYMMETRIC_KEY_LEN { + return Err(ExternalCommitPolicyError::InvalidSymmetricKeyLength( + key.material.len(), + )); + } + if policy.external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN { + return Err(ExternalCommitPolicyError::InvalidExternalGroupIdLength( + policy.external_group_id.len(), + )); + } + for pointer in &policy.refresh_pointers { + validate_service_pointer(pointer) + .map_err(|e| ExternalCommitPolicyError::InvalidRefreshPointer(e.to_string()))?; + } + } else { + // Revoke / disabled post-state: every per-invite field absent. + // An empty SymmetricKey submessage is the forbidden second + // representable state — only full absence is the cleared + // encoding. + if policy.symmetric_key.is_some() { + return Err(ExternalCommitPolicyError::PerInviteFieldNotCleared { + field: "symmetric_key", + }); + } + if !policy.external_group_id.is_empty() { + return Err(ExternalCommitPolicyError::PerInviteFieldNotCleared { + field: "external_group_id", + }); + } + if policy.expires_at_ns != 0 { + return Err(ExternalCommitPolicyError::PerInviteFieldNotCleared { + field: "expires_at_ns", + }); + } + if !policy.refresh_pointers.is_empty() { + return Err(ExternalCommitPolicyError::PerInviteFieldNotCleared { + field: "refresh_pointers", + }); + } + } + Ok(()) +} + +/// Read the `EXTERNAL_COMMIT_POLICY` component from the group's AppData +/// dictionary. Returns: +/// +/// - `Ok(Some(policy))` — entry is present and decoded. +/// - `Ok(None)` — entry is absent, or the dict has no recognizable +/// version variant (defensive: unknown variants treated as absent). +/// - `Err(_)` — registry / extension decode failed. +// +// Consumed by `revoke_external_commits` (durable-settings preservation) +// and by the L-7 validator (`ValidatedCommit::from_external_commit`). +pub(crate) fn load_external_commit_policy( + mls_group: &OpenMlsGroup, +) -> Result, ComponentSourceError> { + let Some(bytes) = mls_group + .extensions() + .app_data_dictionary() + .and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }) + else { + return Ok(None); + }; + + let entry = ExternalCommitPolicyEntry::decode(bytes).map_err(|e| { + ComponentSourceError::MalformedComponentValue { + component_id: ComponentId::EXTERNAL_COMMIT_POLICY, + reason: format!("ExternalCommitPolicyEntry decode: {e}"), + } + })?; + + // Unknown future variant — treat as default-disabled rather than + // failing hard. Newer clients understand the variant; older ones + // fail closed. + Ok(entry.version.map(|ExternalCommitPolicyVersion::V1(v1)| v1)) +} + +/// Convenience: true iff the group has opted into accepting external +/// commits via `EXTERNAL_COMMIT_POLICY.v1.allow_external_commit`. +/// +/// This is the cheap first-line check the validator runs before any +/// per-proposal evaluation. It does NOT enforce the time-window fields +/// (`expires_at_ns` / `expire_in_ns`) — the validator consults the full +/// policy via [`load_external_commit_policy`] for those, because they +/// require additional context (wall-clock time and GroupInfo export +/// timestamp) the helper itself doesn't have. +/// +/// Returns `false` on absent entry, decode failure, or any policy +/// shape that doesn't set the bit. Fails closed. +// +// Consumed by the L-7 validator. Dead-allowed until L-7 lands. +#[allow(dead_code)] +pub(crate) fn is_external_commit_allowed(mls_group: &OpenMlsGroup) -> bool { + load_external_commit_policy(mls_group) + .ok() + .flatten() + .map(|policy| policy.allow_external_commit) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + //! Round-trip + absence coverage for the policy lookup helpers. + use super::*; + use openmls::extensions::{ + AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions, + }; + + fn encode_policy(v1: ExternalCommitPolicyV1) -> Vec { + ExternalCommitPolicyEntry { + version: Some(ExternalCommitPolicyVersion::V1(v1)), + } + .encode_to_vec() + } + + fn extensions_with_policy_bytes(bytes: Vec) -> Extensions { + let mut dict = AppDataDictionary::new(); + let _ = dict.insert(ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), bytes); + Extensions::from_vec(vec![Extension::AppDataDictionary( + AppDataDictionaryExtension::new(dict), + )]) + .expect("AppDataDictionary is a valid GroupContext extension") + } + + #[xmtp_common::test(unwrap_try = true)] + fn empty_dict_treated_as_disabled() { + let extensions: Extensions = + Extensions::from_vec(vec![]).unwrap(); + let dict_entry = extensions.app_data_dictionary().and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }); + assert!(dict_entry.is_none(), "no dict entry should be present"); + } + + #[xmtp_common::test(unwrap_try = true)] + fn malformed_entry_surfaces_decode_error() { + let extensions = extensions_with_policy_bytes(vec![0xFF; 16]); + let bytes = extensions + .app_data_dictionary() + .and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }) + .unwrap(); + let err = ExternalCommitPolicyEntry::decode(bytes); + assert!(err.is_err(), "malformed bytes must fail to decode"); + } + + /// A well-formed enabled policy, reused by the invariant tests. + fn enabled_policy() -> ExternalCommitPolicyV1 { + use xmtp_proto::xmtp::mls::message_contents::SymmetricKey; + ExternalCommitPolicyV1 { + allow_external_commit: true, + expires_at_ns: 1_700_000_000_000_000_000, + expire_in_ns: 60_000_000_000, + symmetric_key: Some(SymmetricKey { + material: vec![0x11u8; 32], + }), + external_group_id: vec![0x22u8; 16], + max_uses: 5, + refresh_pointers: vec![], + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_allows_external_commit() { + let v1 = enabled_policy(); + let bytes = encode_policy(v1.clone()); + let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap(); + match decoded.version { + Some(ExternalCommitPolicyVersion::V1(v)) => { + assert!(v.allow_external_commit); + assert_eq!(v, v1); + } + None => panic!("decoded entry has no version variant"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn invariants_accept_enabled_and_revoked_shapes() { + // Well-formed enabled policy passes. + validate_policy_v1(&enabled_policy())?; + + // A clean revoke passes — and durable settings surviving the + // revoke are legal (only per-invite fields must be absent). + let revoked = ExternalCommitPolicyV1 { + allow_external_commit: false, + expire_in_ns: 60_000_000_000, + max_uses: 5, + ..Default::default() + }; + validate_policy_v1(&revoked)?; + } + + #[xmtp_common::test(unwrap_try = true)] + fn invariants_reject_malformed_enabled_policies() { + use xmtp_proto::xmtp::mls::message_contents::SymmetricKey; + + let mut missing_key = enabled_policy(); + missing_key.symmetric_key = None; + assert_eq!( + validate_policy_v1(&missing_key), + Err(ExternalCommitPolicyError::MissingSymmetricKey) + ); + + let mut short_key = enabled_policy(); + short_key.symmetric_key = Some(SymmetricKey { + material: vec![0u8; 31], + }); + assert_eq!( + validate_policy_v1(&short_key), + Err(ExternalCommitPolicyError::InvalidSymmetricKeyLength(31)) + ); + + let mut short_id = enabled_policy(); + short_id.external_group_id = vec![0u8; 3]; + assert_eq!( + validate_policy_v1(&short_id), + Err(ExternalCommitPolicyError::InvalidExternalGroupIdLength(3)) + ); + + // A refresh pointer with no location variant fails closed. + let mut empty_pointer = enabled_policy(); + empty_pointer.refresh_pointers = + vec![xmtp_proto::xmtp::mls::message_contents::ServicePointer { location: None }]; + assert!(matches!( + validate_policy_v1(&empty_pointer), + Err(ExternalCommitPolicyError::InvalidRefreshPointer(_)) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn invariants_reject_lingering_per_invite_state_on_revoke() { + use xmtp_proto::xmtp::mls::message_contents::SymmetricKey; + + // An EMPTY SymmetricKey submessage is the forbidden second + // representable state — absence is the only cleared encoding. + let cases: Vec<(&str, ExternalCommitPolicyV1)> = vec![ + ( + "symmetric_key", + ExternalCommitPolicyV1 { + symmetric_key: Some(SymmetricKey { material: vec![] }), + ..Default::default() + }, + ), + ( + "external_group_id", + ExternalCommitPolicyV1 { + external_group_id: vec![0x22u8; 16], + ..Default::default() + }, + ), + ( + "expires_at_ns", + ExternalCommitPolicyV1 { + expires_at_ns: 1, + ..Default::default() + }, + ), + ( + "refresh_pointers", + ExternalCommitPolicyV1 { + refresh_pointers: vec![ + xmtp_mls_common::invite::payload::opaque_service_pointer(b"x".to_vec()), + ], + ..Default::default() + }, + ), + ]; + for (field, policy) in cases { + assert_eq!( + validate_policy_v1(&policy), + Err(ExternalCommitPolicyError::PerInviteFieldNotCleared { field }), + "expected {field} to be rejected" + ); + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_default_disabled() { + // Zero-valued ExternalCommitPolicyV1 must decode back unchanged. + let v1 = ExternalCommitPolicyV1::default(); + let bytes = encode_policy(v1); + let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap(); + match decoded.version { + Some(ExternalCommitPolicyVersion::V1(v)) => { + assert!(!v.allow_external_commit); + assert_eq!(v.expires_at_ns, 0); + assert_eq!(v.expire_in_ns, 0); + } + None => panic!("decoded entry has no version variant"), + } + } +} diff --git a/crates/xmtp_mls/src/groups/intents.rs b/crates/xmtp_mls/src/groups/intents.rs index cd1f5ae2ad..685a9fc5a8 100644 --- a/crates/xmtp_mls/src/groups/intents.rs +++ b/crates/xmtp_mls/src/groups/intents.rs @@ -1070,10 +1070,6 @@ impl From for Vec { version: Some(AppDataUpdateVersion::V1(AppDataUpdateDataV1 { component_id: intent.component_id as u32, payload: intent.payload, - // Multi-component atomic writes (XIP-82 enable atomicity) - // populate this in the external-commit stack; the generic - // single-component intent carries none. - additional_updates: vec![], })), } .encode_to_vec() @@ -1174,7 +1170,6 @@ mod app_data_update_intent_tests { version: Some(AppDataUpdateVersion::V1(AppDataUpdateDataV1 { component_id: u16::MAX as u32 + 1, payload: vec![], - additional_updates: vec![], })), } .encode_to_vec(); diff --git a/crates/xmtp_mls/src/groups/mod.rs b/crates/xmtp_mls/src/groups/mod.rs index bc2014bc54..b473bcda03 100644 --- a/crates/xmtp_mls/src/groups/mod.rs +++ b/crates/xmtp_mls/src/groups/mod.rs @@ -2,6 +2,8 @@ pub mod app_data; pub mod commit_log; pub mod commit_log_key; mod error; +pub mod external_commit_policy; +pub use external_commit_policy::{ExternalInviteCoordinates, ExternalInviteSettings}; pub mod group_membership; pub mod group_permissions; pub mod intents; @@ -2136,6 +2138,144 @@ where Ok(()) } + /// Set the full `EXTERNAL_COMMIT_POLICY` well-known component for + /// this group — master switch + time-window controls for MLS + /// External Commits per RFC 9420 §12.4.3.2 (the QR-invite flow). + /// + /// Low-level: the supplied policy is validated against the XIP-82 + /// field-coupling invariants and queued as-is. Prefer + /// [`MlsGroup::enable_external_commits`] / + /// [`MlsGroup::revoke_external_commits`], which mint fresh invite + /// coordinates and preserve durable settings. + /// + /// Writes via the generic `AppDataUpdate(EXTERNAL_COMMIT_POLICY)` + /// intent with `AppDataUpdateOp::Replace` semantics. Requires the + /// group to be migrated to AppData. The component's + /// `permissions.update_policy` (super-admin-only by default) gates + /// who can flip the bits. + pub async fn set_external_commit_policy( + &self, + policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + ) -> Result<(), GroupError> { + external_commit_policy::validate_policy_v1(&policy)?; + self.queue_external_commit_policy(policy).await + } + + /// Encode + queue an `EXTERNAL_COMMIT_POLICY` write as a + /// single-component `AppDataUpdate` intent. + async fn queue_external_commit_policy( + &self, + policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + ) -> Result<(), GroupError> { + use xmtp_mls_common::app_data::component_id::ComponentId; + self.ensure_not_paused().await?; + + // Encode the policy proto into the wire-form bytes that go on + // both the local intent and the eventual AppDataUpdate proposal. + let policy_bytes = { + use prost::Message; + use xmtp_proto::xmtp::mls::message_contents::{ + ExternalCommitPolicyEntry, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, + }; + ExternalCommitPolicyEntry { + version: Some(ExternalCommitPolicyVersion::V1(policy)), + } + .encode_to_vec() + }; + + let intent_data = crate::groups::intents::AppDataUpdateIntentData::new( + ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), + policy_bytes, + ); + let intent = QueueIntent::app_data_update() + .data(Vec::::from(intent_data)) + .queue(self)?; + + let _ = self.sync_until_intent_resolved(intent.id).await?; + Ok(()) + } + + /// Enable MLS External Commits (the QR-invite flow) for this group. + /// A single-component `EXTERNAL_COMMIT_POLICY` write carrying: + /// + /// - `allow_external_commit = true` with a freshly CSPRNG-generated + /// `symmetric_key` + `external_group_id`. Uniform randomness is + /// what guarantees a re-enable never revives a revoked key — no + /// key-history tracking exists anywhere. + /// - The caller's [`ExternalInviteSettings`] (campaign expiry, + /// staleness bound, `max_uses`, refresh pointers). + /// + /// Per XIP-82, the switch itself authorizes the joiner's + /// `GROUP_MEMBERSHIP` self-entry insert — no separate registry + /// grant is written. + /// + /// Returns the [`ExternalInviteCoordinates`] the QR payload carries + /// (alongside the per-QR service pointer, which is intentionally not + /// group state). + pub async fn enable_external_commits( + &self, + settings: ExternalInviteSettings, + ) -> Result { + use xmtp_mls_common::invite::payload::{ + generate_external_group_id, generate_symmetric_key, + }; + use xmtp_proto::xmtp::mls::message_contents::SymmetricKey; + + let symmetric_key = generate_symmetric_key(); + let external_group_id = generate_external_group_id().to_vec(); + + let policy = xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 { + allow_external_commit: true, + expires_at_ns: settings.expires_at_ns, + expire_in_ns: settings.expire_in_ns, + symmetric_key: Some(SymmetricKey { + material: symmetric_key.to_vec(), + }), + external_group_id: external_group_id.clone(), + max_uses: settings.max_uses, + refresh_pointers: settings.refresh_pointers, + }; + external_commit_policy::validate_policy_v1(&policy)?; + + self.queue_external_commit_policy(policy).await?; + Ok(ExternalInviteCoordinates { + symmetric_key, + external_group_id, + }) + } + + /// Revoke MLS External Commits for this group. The revoke leaves + /// every per-invite field absent — `symmetric_key`, + /// `external_group_id`, `expires_at_ns`, `refresh_pointers` — so the + /// resulting policy serializes to nothing but the durable settings + /// (`expire_in_ns`, `max_uses`, preserved from the current state), + /// byte-identical to a policy that never had an invite. Existing + /// members reject any subsequent external commit; this is the + /// recommended kill switch (a key-only rotation, by contrast, races + /// in-flight joins and does not hold against a member who kept the + /// old key). + pub async fn revoke_external_commits(&self) -> Result<(), GroupError> { + let current = self + .load_mls_group_with_lock(self.context.mls_storage(), |mls_group| { + external_commit_policy::load_external_commit_policy(&mls_group) + .map_err(GroupError::from) + })? + .unwrap_or_default(); + + let policy = xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 { + allow_external_commit: false, + expires_at_ns: 0, + expire_in_ns: current.expire_in_ns, + symmetric_key: None, + external_group_id: Vec::new(), + max_uses: current.max_uses, + refresh_pointers: Vec::new(), + }; + external_commit_policy::validate_policy_v1(&policy)?; + self.queue_external_commit_policy(policy).await + } + fn min_protocol_version_from_extensions( mutable_metadata: &GroupMutableMetadata, ) -> Option { diff --git a/crates/xmtp_mls/src/groups/validated_commit.rs b/crates/xmtp_mls/src/groups/validated_commit.rs index 4ce547dee4..5a987d736a 100644 --- a/crates/xmtp_mls/src/groups/validated_commit.rs +++ b/crates/xmtp_mls/src/groups/validated_commit.rs @@ -124,6 +124,13 @@ pub enum CommitValidationError { #[error(transparent)] ComponentSource(#[from] super::app_data::component_source::ComponentSourceError), + /// An `EXTERNAL_COMMIT_POLICY` write violates the XIP-82 + /// field-coupling invariants (post-state check: enable requires a + /// 32-byte key + slot id; revoke leaves every per-invite field + /// absent). Convergent: a pure function of the proposed value. + #[error(transparent)] + ExternalCommitPolicy(#[from] super::external_commit_policy::ExternalCommitPolicyError), + /// All bootstrap-commit-validator failures. The bootstrap path runs /// only during the one-time AppData migration; isolating its many /// failure modes in a sub-enum keeps the steady-state validator's @@ -1574,6 +1581,12 @@ fn validate_app_data_update_proposals_in_commit( // re-walk the admin lists and re-parse the credential for every one. let mut participants: HashMap = HashMap::new(); + // Capture the LAST `EXTERNAL_COMMIT_POLICY` write in the commit for + // the XIP-82 post-state invariant. For a Bytes component the Update + // payload IS the post-state value, and within one commit the last + // write wins (matching the accumulate order on apply). + let mut policy_write: Option> = None; + for queued in proposals { let app_data = queued.app_data_update_proposal(); let proposer_leaf = app_data_update_proposer_leaf(queued.sender())?; @@ -1590,19 +1603,75 @@ fn validate_app_data_update_proposals_in_commit( } }; + let component_id = ComponentId::from(app_data.component_id()); validate_one_app_data_update( - ComponentId::from(app_data.component_id()), + component_id, app_data.operation(), ActorAuthority::from(proposer), &proposer.inbox_id, registry, openmls_group, )?; + + if let openmls::messages::proposals::AppDataUpdateOperation::Update(payload) = + app_data.operation() + && component_id == ComponentId::EXTERNAL_COMMIT_POLICY + { + policy_write = Some(payload.as_slice().to_vec()); + } + } + + if let Some(policy_bytes) = policy_write { + validate_external_commit_policy_post_state(&policy_bytes)?; } Ok(()) } +/// XIP-82 post-state invariant on `EXTERNAL_COMMIT_POLICY` writes, +/// enforced on every commit that carries one (member senders; the +/// external-commit validator path has its own checks): +/// +/// * The proposed policy value satisfies the field-coupling invariants — +/// enabled ⇒ 32-byte key + ≥4-byte slot id (+ well-formed refresh +/// pointers); disabled ⇒ every per-invite field absent. +/// +/// The joiner-self-entry insert needs no separate grant — per XIP-82, +/// `allow_external_commit = true` itself authorizes it. +/// +/// Convergent by construction: a pure function of the commit's own +/// proposal payload, which every member shares. +fn validate_external_commit_policy_post_state( + policy_bytes: &[u8], +) -> Result<(), CommitValidationError> { + use super::external_commit_policy::validate_policy_v1; + use prost::Message; + use xmtp_mls_common::app_data::component_id::ComponentId; + use xmtp_proto::xmtp::mls::message_contents::{ + ExternalCommitPolicyEntry, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, + }; + + let entry = ExternalCommitPolicyEntry::decode(policy_bytes).map_err(|e| { + CommitValidationError::ComponentSource( + super::app_data::component_source::ComponentSourceError::MalformedComponentValue { + component_id: ComponentId::EXTERNAL_COMMIT_POLICY, + reason: format!("ExternalCommitPolicyEntry decode: {e}"), + }, + ) + })?; + // Unknown future variant: older validators cannot check invariants + // they don't understand — same unknown-variant tolerance as the + // policy reader (a newer client validates it; this client fails + // closed at external-commit time regardless). + let Some(ExternalCommitPolicyVersion::V1(policy)) = entry.version else { + return Ok(()); + }; + + validate_policy_v1(&policy)?; + Ok(()) +} + /// Extracts the [`CommitParticipant`] from the [`LeafNodeIndex`] pub(super) fn extract_commit_participant( leaf_index: &LeafNodeIndex, diff --git a/crates/xmtp_mls_common/src/app_data/component_id.rs b/crates/xmtp_mls_common/src/app_data/component_id.rs index 7856131455..d5c7800117 100644 --- a/crates/xmtp_mls_common/src/app_data/component_id.rs +++ b/crates/xmtp_mls_common/src/app_data/component_id.rs @@ -82,6 +82,14 @@ impl ComponentId { pub const APP_DATA: Self = Self(0x8009); pub const MIN_SUPPORTED_PROTOCOL_VERSION: Self = Self(0x800A); pub const COMMIT_LOG_SIGNER: Self = Self(0x800B); + /// Group-wide external-commit policy component. Carries + /// `allow_external_commit` (defense-in-depth master switch for MLS + /// External Commits, RFC 9420 §12.4.3.2) plus `expires_at_ns` + /// (wall-clock auto-disable) and `expire_in_ns` (max staleness of + /// the referenced GroupInfo). Runtime-toggleable via + /// AppDataUpdate(EXTERNAL_COMMIT_POLICY); super-admin-only update + /// by default. + pub const EXTERNAL_COMMIT_POLICY: Self = Self(0x800C); // === Well-Known Immutable XMTP Component IDs (counting down from 0xBFFF) ===