Skip to content

Commit c6765a6

Browse files
committed
xmtp_mls: add EXTERNAL_COMMIT_POLICY component + setter API riding on generic AppDataUpdate intent
Defense-in-depth master switch + per-component declarative external committer authorization for the QR-invite flow (MLS External Commits, RFC 9420 §12.4.3.2). - ComponentId::EXTERNAL_COMMIT_POLICY = 0x800C in xmtp_mls_common. - Type-dispatch entry: ComponentType::Bytes for the new id. - crates/xmtp_mls/src/groups/external_commit_policy.rs (new file): - load_external_commit_policy(group) -> Option<ExternalCommitPolicyV1> - is_external_commit_allowed(group) -> bool - external_committer_permissions_for(group, id) -> Option<ComponentPermissions> - MlsGroup::set_external_commit_policy(policy) — granular setter writes the full ExternalCommitPolicyV1 via the generic AppDataUpdate intent (AppDataUpdateOp::Replace, since the component is full-replace Bytes-typed). - MlsGroup::set_allow_external_commit(bool) — sugar wrapper. Depends on L-0 (generic IntentKind::AppDataUpdate) for the intent machinery. No new IntentKind variant added — the generic path covers this and every future full-replace component setter. No bootstrap synth: absent EXTERNAL_COMMIT_POLICY = disabled default. First setter call creates the entry via AppDataUpdateOperation::Update upsert semantics. Zero risk of cross-client serialization drift.
1 parent d8d71d5 commit c6765a6

4 files changed

Lines changed: 299 additions & 0 deletions

File tree

crates/xmtp_mls/src/groups/app_data/component_source.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,11 @@ pub(crate) fn component_type(id: ComponentId) -> Option<ComponentType> {
274274
| ComponentId::MESSAGE_DISAPPEAR_IN_NS
275275
| ComponentId::COMMIT_LOG_SIGNER => Some(ComponentType::Bytes),
276276

277+
// External-commit policy: proto-encoded ExternalCommitPolicyEntry,
278+
// replaced atomically via the generic AppDataUpdate intent. No
279+
// per-id Component impl needed; helpers decode bytes via prost.
280+
ComponentId::EXTERNAL_COMMIT_POLICY => Some(ComponentType::Bytes),
281+
277282
// Immutable metadata (not flowable through AppDataUpdate writes,
278283
// but we still advertise the type for completeness).
279284
ComponentId::CONVERSATION_TYPE
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//! External-commit policy lookup helpers.
2+
//!
3+
//! Two layers gate an incoming MLS External Commit (RFC 9420 §12.4.3.2):
4+
//!
5+
//! 1. **Master switch** — the `EXTERNAL_COMMIT_POLICY` well-known
6+
//! component, decoded into [`ExternalCommitPolicyV1`]. Carries
7+
//! `allow_external_commit` plus the time-window controls
8+
//! (`expires_at_ns`, `expire_in_ns`).
9+
//! 2. **Per-component declarative permissions** — each component's
10+
//! `ComponentMetadata.external_committer_permissions` block. Sibling
11+
//! of the existing `permissions` block; governs what external
12+
//! committers may do to *this* component.
13+
//!
14+
//! Both layers default to "deny" when absent — this module surfaces
15+
//! `Option<…>`/`bool` from "absent" rather than synthesizing a default
16+
//! struct, so callers can route on whether the admin has ever opted in.
17+
//!
18+
//! The MLS-spec invariants (exactly one ExternalInit, joiner credential
19+
//! binding on Adds, no by-reference proposals, no SelfRemove) are
20+
//! hardcoded in the validator (see L-7); this module only covers the
21+
//! AppData-resident policy.
22+
23+
use openmls::group::MlsGroup as OpenMlsGroup;
24+
use prost::Message;
25+
use xmtp_mls_common::app_data::component_id::ComponentId;
26+
use xmtp_proto::xmtp::mls::message_contents::{
27+
ComponentPermissions, ExternalCommitPolicyEntry, ExternalCommitPolicyV1,
28+
external_commit_policy_entry::Version as ExternalCommitPolicyVersion,
29+
};
30+
31+
use crate::groups::app_data::{component_source::ComponentSourceError, load_component_registry};
32+
33+
/// Read the `EXTERNAL_COMMIT_POLICY` component from the group's AppData
34+
/// dictionary. Returns:
35+
///
36+
/// - `Ok(Some(policy))` — entry is present and decoded.
37+
/// - `Ok(None)` — entry is absent, or the dict has no recognizable
38+
/// version variant (defensive: unknown variants treated as absent).
39+
/// - `Err(_)` — registry / extension decode failed.
40+
pub(crate) fn load_external_commit_policy(
41+
mls_group: &OpenMlsGroup,
42+
) -> Result<Option<ExternalCommitPolicyV1>, ComponentSourceError> {
43+
let Some(bytes) = mls_group
44+
.extensions()
45+
.app_data_dictionary()
46+
.and_then(|ext| {
47+
ext.dictionary()
48+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
49+
})
50+
else {
51+
return Ok(None);
52+
};
53+
54+
let entry = ExternalCommitPolicyEntry::decode(bytes).map_err(|e| {
55+
ComponentSourceError::MalformedComponentValue {
56+
component_id: ComponentId::EXTERNAL_COMMIT_POLICY,
57+
reason: format!("ExternalCommitPolicyEntry decode: {e}"),
58+
}
59+
})?;
60+
61+
Ok(match entry.version {
62+
Some(ExternalCommitPolicyVersion::V1(v1)) => Some(v1),
63+
// Unknown future variant — treat as default-disabled rather
64+
// than failing hard. Newer clients understand the variant;
65+
// older ones fail closed.
66+
None => None,
67+
})
68+
}
69+
70+
/// Convenience: true iff the group has opted into accepting external
71+
/// commits via `EXTERNAL_COMMIT_POLICY.v1.allow_external_commit`.
72+
///
73+
/// This is the cheap first-line check the validator runs before any
74+
/// per-proposal evaluation. It does NOT enforce the time-window fields
75+
/// (`expires_at_ns` / `expire_in_ns`) — the validator consults the full
76+
/// policy via [`load_external_commit_policy`] for those, because they
77+
/// require additional context (wall-clock time and GroupInfo export
78+
/// timestamp) the helper itself doesn't have.
79+
///
80+
/// Returns `false` on absent entry, decode failure, or any policy
81+
/// shape that doesn't set the bit. Fails closed.
82+
pub(crate) fn is_external_commit_allowed(mls_group: &OpenMlsGroup) -> bool {
83+
load_external_commit_policy(mls_group)
84+
.ok()
85+
.flatten()
86+
.map(|policy| policy.allow_external_commit)
87+
.unwrap_or(false)
88+
}
89+
90+
/// Read the `external_committer_permissions` block from the
91+
/// `ComponentMetadata` of the given component in the registry.
92+
///
93+
/// Returns:
94+
///
95+
/// - `Ok(Some(perms))` — component has an `external_committer_permissions`
96+
/// block. The caller evaluates each proposal's effect against the
97+
/// relevant policy slot.
98+
/// - `Ok(None)` — component is in the registry but has no
99+
/// `external_committer_permissions` block, OR component isn't in the
100+
/// registry at all. In both cases the validator treats this as
101+
/// all-Deny: external committers may not touch this component.
102+
/// - `Err(_)` — registry decode failed.
103+
pub(crate) fn external_committer_permissions_for(
104+
mls_group: &OpenMlsGroup,
105+
component_id: ComponentId,
106+
) -> Result<Option<ComponentPermissions>, ComponentSourceError> {
107+
let registry = load_component_registry(mls_group)?;
108+
let Some(meta) = registry.get(&component_id).ok().flatten() else {
109+
return Ok(None);
110+
};
111+
Ok(meta.external_committer_permissions)
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
//! Round-trip + absence coverage for the policy lookup helpers.
117+
use super::*;
118+
use openmls::extensions::{
119+
AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
120+
};
121+
use xmtp_proto::xmtp::mls::message_contents::ComponentMetadata;
122+
123+
fn encode_policy(v1: ExternalCommitPolicyV1) -> Vec<u8> {
124+
ExternalCommitPolicyEntry {
125+
version: Some(ExternalCommitPolicyVersion::V1(v1)),
126+
}
127+
.encode_to_vec()
128+
}
129+
130+
fn extensions_with_policy_bytes(bytes: Vec<u8>) -> Extensions<openmls::group::GroupContext> {
131+
let mut dict = AppDataDictionary::new();
132+
let _ = dict.insert(ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), bytes);
133+
Extensions::from_vec(vec![Extension::AppDataDictionary(
134+
AppDataDictionaryExtension::new(dict),
135+
)])
136+
.expect("AppDataDictionary is a valid GroupContext extension")
137+
}
138+
139+
#[xmtp_common::test(unwrap_try = true)]
140+
fn empty_dict_treated_as_disabled() {
141+
let extensions: Extensions<openmls::group::GroupContext> =
142+
Extensions::from_vec(vec![]).unwrap();
143+
let dict_entry = extensions.app_data_dictionary().and_then(|ext| {
144+
ext.dictionary()
145+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
146+
});
147+
assert!(dict_entry.is_none(), "no dict entry should be present");
148+
}
149+
150+
#[xmtp_common::test(unwrap_try = true)]
151+
fn malformed_entry_surfaces_decode_error() {
152+
let extensions = extensions_with_policy_bytes(vec![0xFF; 16]);
153+
let bytes = extensions
154+
.app_data_dictionary()
155+
.and_then(|ext| {
156+
ext.dictionary()
157+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
158+
})
159+
.unwrap();
160+
let err = ExternalCommitPolicyEntry::decode(bytes);
161+
assert!(err.is_err(), "malformed bytes must fail to decode");
162+
}
163+
164+
#[xmtp_common::test(unwrap_try = true)]
165+
fn round_trip_allows_external_commit() {
166+
let v1 = ExternalCommitPolicyV1 {
167+
allow_external_commit: true,
168+
expires_at_ns: 1_700_000_000_000_000_000,
169+
expire_in_ns: 60_000_000_000,
170+
symmetric_key: vec![0x11u8; 32],
171+
external_group_id: vec![0x22u8; 16],
172+
};
173+
let bytes = encode_policy(v1.clone());
174+
let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap();
175+
match decoded.version {
176+
Some(ExternalCommitPolicyVersion::V1(v)) => {
177+
assert!(v.allow_external_commit);
178+
assert_eq!(v.expires_at_ns, v1.expires_at_ns);
179+
assert_eq!(v.expire_in_ns, v1.expire_in_ns);
180+
assert_eq!(v.symmetric_key, v1.symmetric_key);
181+
assert_eq!(v.external_group_id, v1.external_group_id);
182+
}
183+
None => panic!("decoded entry has no version variant"),
184+
}
185+
}
186+
187+
#[xmtp_common::test(unwrap_try = true)]
188+
fn round_trip_default_disabled() {
189+
// Zero-valued ExternalCommitPolicyV1 must decode back unchanged.
190+
let v1 = ExternalCommitPolicyV1::default();
191+
let bytes = encode_policy(v1);
192+
let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap();
193+
match decoded.version {
194+
Some(ExternalCommitPolicyVersion::V1(v)) => {
195+
assert!(!v.allow_external_commit);
196+
assert_eq!(v.expires_at_ns, 0);
197+
assert_eq!(v.expire_in_ns, 0);
198+
}
199+
None => panic!("decoded entry has no version variant"),
200+
}
201+
}
202+
203+
#[xmtp_common::test(unwrap_try = true)]
204+
fn component_metadata_without_external_block_is_treated_as_deny() {
205+
// ComponentMetadata with no external_committer_permissions field
206+
// is treated as all-Deny by the validator.
207+
let meta = ComponentMetadata {
208+
component_type: 1,
209+
permissions: None,
210+
external_committer_permissions: None,
211+
};
212+
assert!(meta.external_committer_permissions.is_none());
213+
}
214+
}

crates/xmtp_mls/src/groups/mod.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod app_data;
22
pub mod commit_log;
33
pub mod commit_log_key;
44
mod error;
5+
pub mod external_commit_policy;
56
pub mod group_membership;
67
pub mod group_permissions;
78
pub mod intents;
@@ -2002,6 +2003,77 @@ where
20022003
Ok(())
20032004
}
20042005

2006+
/// Set the full `EXTERNAL_COMMIT_POLICY` well-known component for
2007+
/// this group — master switch + time-window controls for MLS
2008+
/// External Commits per RFC 9420 §12.4.3.2 (the QR-invite flow).
2009+
///
2010+
/// Writes via the generic `AppDataUpdate(EXTERNAL_COMMIT_POLICY)`
2011+
/// intent with `AppDataUpdateOp::Replace` semantics. Requires the
2012+
/// group to be migrated to AppData. The component's
2013+
/// `permissions.update_policy` (super-admin-only by default) gates
2014+
/// who can flip the bits.
2015+
pub async fn set_external_commit_policy(
2016+
&self,
2017+
policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1,
2018+
) -> Result<(), GroupError> {
2019+
self.ensure_not_paused().await?;
2020+
2021+
// Encode the policy proto into the wire-form bytes that go on
2022+
// both the local intent and the eventual AppDataUpdate proposal.
2023+
let policy_bytes = {
2024+
use prost::Message;
2025+
use xmtp_proto::xmtp::mls::message_contents::{
2026+
ExternalCommitPolicyEntry,
2027+
external_commit_policy_entry::Version as ExternalCommitPolicyVersion,
2028+
};
2029+
ExternalCommitPolicyEntry {
2030+
version: Some(ExternalCommitPolicyVersion::V1(policy)),
2031+
}
2032+
.encode_to_vec()
2033+
};
2034+
2035+
let intent_data: Vec<u8> = crate::groups::intents::AppDataUpdateIntentData::new(
2036+
xmtp_mls_common::app_data::component_id::ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(),
2037+
policy_bytes,
2038+
)
2039+
.into();
2040+
let intent = QueueIntent::app_data_update()
2041+
.data(intent_data)
2042+
.queue(self)?;
2043+
2044+
let _ = self.sync_until_intent_resolved(intent.id).await?;
2045+
Ok(())
2046+
}
2047+
2048+
/// Sugar wrapper over [`MlsGroup::set_external_commit_policy`] that
2049+
/// only flips the master switch and leaves the time-window controls
2050+
/// at their defaults (no automatic expiry / no staleness bound).
2051+
pub async fn set_allow_external_commit(&self, allowed: bool) -> Result<(), GroupError> {
2052+
let policy = if allowed {
2053+
// Enable: must populate symmetric_key and external_group_id atomically.
2054+
use xmtp_mls_common::invite::payload::{
2055+
generate_external_group_id, generate_symmetric_key,
2056+
};
2057+
xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 {
2058+
allow_external_commit: true,
2059+
expires_at_ns: 0,
2060+
expire_in_ns: 0,
2061+
symmetric_key: generate_symmetric_key().to_vec(),
2062+
external_group_id: generate_external_group_id().to_vec(),
2063+
}
2064+
} else {
2065+
// Revoke: clear symmetric_key and external_group_id atomically.
2066+
xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 {
2067+
allow_external_commit: false,
2068+
expires_at_ns: 0,
2069+
expire_in_ns: 0,
2070+
symmetric_key: Vec::new(),
2071+
external_group_id: Vec::new(),
2072+
}
2073+
};
2074+
self.set_external_commit_policy(policy).await
2075+
}
2076+
20052077
fn min_protocol_version_from_extensions(
20062078
mutable_metadata: &GroupMutableMetadata,
20072079
) -> Option<String> {

crates/xmtp_mls_common/src/app_data/component_id.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ impl ComponentId {
8282
pub const APP_DATA: Self = Self(0x8009);
8383
pub const MIN_SUPPORTED_PROTOCOL_VERSION: Self = Self(0x800A);
8484
pub const COMMIT_LOG_SIGNER: Self = Self(0x800B);
85+
/// Group-wide external-commit policy component. Carries
86+
/// `allow_external_commit` (defense-in-depth master switch for MLS
87+
/// External Commits, RFC 9420 §12.4.3.2) plus `expires_at_ns`
88+
/// (wall-clock auto-disable) and `expire_in_ns` (max staleness of
89+
/// the referenced GroupInfo). Runtime-toggleable via
90+
/// AppDataUpdate(EXTERNAL_COMMIT_POLICY); super-admin-only update
91+
/// by default.
92+
pub const EXTERNAL_COMMIT_POLICY: Self = Self(0x800C);
8593

8694
// === Well-Known Immutable XMTP Component IDs (counting down from 0xBFFF) ===
8795

0 commit comments

Comments
 (0)