Skip to content

Commit 67cd34e

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 e369a4e commit 67cd34e

4 files changed

Lines changed: 308 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: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
//
41+
// Consumed by the L-7 validator (`ValidatedCommit::from_external_commit`).
42+
// Stays dead-allowed at this PR until L-7 lands.
43+
#[allow(dead_code)]
44+
pub(crate) fn load_external_commit_policy(
45+
mls_group: &OpenMlsGroup,
46+
) -> Result<Option<ExternalCommitPolicyV1>, ComponentSourceError> {
47+
let Some(bytes) = mls_group
48+
.extensions()
49+
.app_data_dictionary()
50+
.and_then(|ext| {
51+
ext.dictionary()
52+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
53+
})
54+
else {
55+
return Ok(None);
56+
};
57+
58+
let entry = ExternalCommitPolicyEntry::decode(bytes).map_err(|e| {
59+
ComponentSourceError::MalformedComponentValue {
60+
component_id: ComponentId::EXTERNAL_COMMIT_POLICY,
61+
reason: format!("ExternalCommitPolicyEntry decode: {e}"),
62+
}
63+
})?;
64+
65+
// Unknown future variant — treat as default-disabled rather than
66+
// failing hard. Newer clients understand the variant; older ones
67+
// fail closed.
68+
Ok(entry
69+
.version
70+
.map(|ExternalCommitPolicyVersion::V1(v1)| v1))
71+
}
72+
73+
/// Convenience: true iff the group has opted into accepting external
74+
/// commits via `EXTERNAL_COMMIT_POLICY.v1.allow_external_commit`.
75+
///
76+
/// This is the cheap first-line check the validator runs before any
77+
/// per-proposal evaluation. It does NOT enforce the time-window fields
78+
/// (`expires_at_ns` / `expire_in_ns`) — the validator consults the full
79+
/// policy via [`load_external_commit_policy`] for those, because they
80+
/// require additional context (wall-clock time and GroupInfo export
81+
/// timestamp) the helper itself doesn't have.
82+
///
83+
/// Returns `false` on absent entry, decode failure, or any policy
84+
/// shape that doesn't set the bit. Fails closed.
85+
//
86+
// Consumed by the L-7 validator. Dead-allowed until L-7 lands.
87+
#[allow(dead_code)]
88+
pub(crate) fn is_external_commit_allowed(mls_group: &OpenMlsGroup) -> bool {
89+
load_external_commit_policy(mls_group)
90+
.ok()
91+
.flatten()
92+
.map(|policy| policy.allow_external_commit)
93+
.unwrap_or(false)
94+
}
95+
96+
/// Read the `external_committer_permissions` block from the
97+
/// `ComponentMetadata` of the given component in the registry.
98+
///
99+
/// Returns:
100+
///
101+
/// - `Ok(Some(perms))` — component has an `external_committer_permissions`
102+
/// block. The caller evaluates each proposal's effect against the
103+
/// relevant policy slot.
104+
/// - `Ok(None)` — component is in the registry but has no
105+
/// `external_committer_permissions` block, OR component isn't in the
106+
/// registry at all. In both cases the validator treats this as
107+
/// all-Deny: external committers may not touch this component.
108+
/// - `Err(_)` — registry decode failed.
109+
//
110+
// Consumed by the L-7 validator. Dead-allowed until L-7 lands.
111+
#[allow(dead_code)]
112+
pub(crate) fn external_committer_permissions_for(
113+
mls_group: &OpenMlsGroup,
114+
component_id: ComponentId,
115+
) -> Result<Option<ComponentPermissions>, ComponentSourceError> {
116+
let registry = load_component_registry(mls_group)?;
117+
let Some(meta) = registry.get(&component_id).ok().flatten() else {
118+
return Ok(None);
119+
};
120+
Ok(meta.external_committer_permissions)
121+
}
122+
123+
#[cfg(test)]
124+
mod tests {
125+
//! Round-trip + absence coverage for the policy lookup helpers.
126+
use super::*;
127+
use openmls::extensions::{
128+
AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions,
129+
};
130+
use xmtp_proto::xmtp::mls::message_contents::ComponentMetadata;
131+
132+
fn encode_policy(v1: ExternalCommitPolicyV1) -> Vec<u8> {
133+
ExternalCommitPolicyEntry {
134+
version: Some(ExternalCommitPolicyVersion::V1(v1)),
135+
}
136+
.encode_to_vec()
137+
}
138+
139+
fn extensions_with_policy_bytes(bytes: Vec<u8>) -> Extensions<openmls::group::GroupContext> {
140+
let mut dict = AppDataDictionary::new();
141+
let _ = dict.insert(ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), bytes);
142+
Extensions::from_vec(vec![Extension::AppDataDictionary(
143+
AppDataDictionaryExtension::new(dict),
144+
)])
145+
.expect("AppDataDictionary is a valid GroupContext extension")
146+
}
147+
148+
#[xmtp_common::test(unwrap_try = true)]
149+
fn empty_dict_treated_as_disabled() {
150+
let extensions: Extensions<openmls::group::GroupContext> =
151+
Extensions::from_vec(vec![]).unwrap();
152+
let dict_entry = extensions.app_data_dictionary().and_then(|ext| {
153+
ext.dictionary()
154+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
155+
});
156+
assert!(dict_entry.is_none(), "no dict entry should be present");
157+
}
158+
159+
#[xmtp_common::test(unwrap_try = true)]
160+
fn malformed_entry_surfaces_decode_error() {
161+
let extensions = extensions_with_policy_bytes(vec![0xFF; 16]);
162+
let bytes = extensions
163+
.app_data_dictionary()
164+
.and_then(|ext| {
165+
ext.dictionary()
166+
.get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16())
167+
})
168+
.unwrap();
169+
let err = ExternalCommitPolicyEntry::decode(bytes);
170+
assert!(err.is_err(), "malformed bytes must fail to decode");
171+
}
172+
173+
#[xmtp_common::test(unwrap_try = true)]
174+
fn round_trip_allows_external_commit() {
175+
let v1 = ExternalCommitPolicyV1 {
176+
allow_external_commit: true,
177+
expires_at_ns: 1_700_000_000_000_000_000,
178+
expire_in_ns: 60_000_000_000,
179+
symmetric_key: vec![0x11u8; 32],
180+
external_group_id: vec![0x22u8; 16],
181+
};
182+
let bytes = encode_policy(v1.clone());
183+
let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap();
184+
match decoded.version {
185+
Some(ExternalCommitPolicyVersion::V1(v)) => {
186+
assert!(v.allow_external_commit);
187+
assert_eq!(v.expires_at_ns, v1.expires_at_ns);
188+
assert_eq!(v.expire_in_ns, v1.expire_in_ns);
189+
assert_eq!(v.symmetric_key, v1.symmetric_key);
190+
assert_eq!(v.external_group_id, v1.external_group_id);
191+
}
192+
None => panic!("decoded entry has no version variant"),
193+
}
194+
}
195+
196+
#[xmtp_common::test(unwrap_try = true)]
197+
fn round_trip_default_disabled() {
198+
// Zero-valued ExternalCommitPolicyV1 must decode back unchanged.
199+
let v1 = ExternalCommitPolicyV1::default();
200+
let bytes = encode_policy(v1);
201+
let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap();
202+
match decoded.version {
203+
Some(ExternalCommitPolicyVersion::V1(v)) => {
204+
assert!(!v.allow_external_commit);
205+
assert_eq!(v.expires_at_ns, 0);
206+
assert_eq!(v.expire_in_ns, 0);
207+
}
208+
None => panic!("decoded entry has no version variant"),
209+
}
210+
}
211+
212+
#[xmtp_common::test(unwrap_try = true)]
213+
fn component_metadata_without_external_block_is_treated_as_deny() {
214+
// ComponentMetadata with no external_committer_permissions field
215+
// is treated as all-Deny by the validator.
216+
let meta = ComponentMetadata {
217+
component_type: 1,
218+
permissions: None,
219+
external_committer_permissions: None,
220+
};
221+
assert!(meta.external_committer_permissions.is_none());
222+
}
223+
}

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;
@@ -2121,6 +2122,77 @@ where
21212122
Ok(())
21222123
}
21232124

2125+
/// Set the full `EXTERNAL_COMMIT_POLICY` well-known component for
2126+
/// this group — master switch + time-window controls for MLS
2127+
/// External Commits per RFC 9420 §12.4.3.2 (the QR-invite flow).
2128+
///
2129+
/// Writes via the generic `AppDataUpdate(EXTERNAL_COMMIT_POLICY)`
2130+
/// intent with `AppDataUpdateOp::Replace` semantics. Requires the
2131+
/// group to be migrated to AppData. The component's
2132+
/// `permissions.update_policy` (super-admin-only by default) gates
2133+
/// who can flip the bits.
2134+
pub async fn set_external_commit_policy(
2135+
&self,
2136+
policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1,
2137+
) -> Result<(), GroupError> {
2138+
self.ensure_not_paused().await?;
2139+
2140+
// Encode the policy proto into the wire-form bytes that go on
2141+
// both the local intent and the eventual AppDataUpdate proposal.
2142+
let policy_bytes = {
2143+
use prost::Message;
2144+
use xmtp_proto::xmtp::mls::message_contents::{
2145+
ExternalCommitPolicyEntry,
2146+
external_commit_policy_entry::Version as ExternalCommitPolicyVersion,
2147+
};
2148+
ExternalCommitPolicyEntry {
2149+
version: Some(ExternalCommitPolicyVersion::V1(policy)),
2150+
}
2151+
.encode_to_vec()
2152+
};
2153+
2154+
let intent_data: Vec<u8> = crate::groups::intents::AppDataUpdateIntentData::new(
2155+
xmtp_mls_common::app_data::component_id::ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(),
2156+
policy_bytes,
2157+
)
2158+
.into();
2159+
let intent = QueueIntent::app_data_update()
2160+
.data(intent_data)
2161+
.queue(self)?;
2162+
2163+
let _ = self.sync_until_intent_resolved(intent.id).await?;
2164+
Ok(())
2165+
}
2166+
2167+
/// Sugar wrapper over [`MlsGroup::set_external_commit_policy`] that
2168+
/// only flips the master switch and leaves the time-window controls
2169+
/// at their defaults (no automatic expiry / no staleness bound).
2170+
pub async fn set_allow_external_commit(&self, allowed: bool) -> Result<(), GroupError> {
2171+
let policy = if allowed {
2172+
// Enable: must populate symmetric_key and external_group_id atomically.
2173+
use xmtp_mls_common::invite::payload::{
2174+
generate_external_group_id, generate_symmetric_key,
2175+
};
2176+
xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 {
2177+
allow_external_commit: true,
2178+
expires_at_ns: 0,
2179+
expire_in_ns: 0,
2180+
symmetric_key: generate_symmetric_key().to_vec(),
2181+
external_group_id: generate_external_group_id().to_vec(),
2182+
}
2183+
} else {
2184+
// Revoke: clear symmetric_key and external_group_id atomically.
2185+
xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 {
2186+
allow_external_commit: false,
2187+
expires_at_ns: 0,
2188+
expire_in_ns: 0,
2189+
symmetric_key: Vec::new(),
2190+
external_group_id: Vec::new(),
2191+
}
2192+
};
2193+
self.set_external_commit_policy(policy).await
2194+
}
2195+
21242196
fn min_protocol_version_from_extensions(
21252197
mutable_metadata: &GroupMutableMetadata,
21262198
) -> 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)