diff --git a/Cargo.lock b/Cargo.lock index 03b9794a11..60be302dc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14831,6 +14831,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "tls_codec", + "url", "wasm-bindgen-test", "xmtp-workspace-hack", "xmtp_common", diff --git a/crates/xmtp_configuration/src/common/mls.rs b/crates/xmtp_configuration/src/common/mls.rs index f9edef4913..128aee257c 100644 --- a/crates/xmtp_configuration/src/common/mls.rs +++ b/crates/xmtp_configuration/src/common/mls.rs @@ -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; diff --git a/crates/xmtp_mls/src/groups/app_data/bootstrap_validator.rs b/crates/xmtp_mls/src/groups/app_data/bootstrap_validator.rs index 5c13af2545..d8f8390c8b 100644 --- a/crates/xmtp_mls/src/groups/app_data/bootstrap_validator.rs +++ b/crates/xmtp_mls/src/groups/app_data/bootstrap_validator.rs @@ -840,6 +840,7 @@ mod tests { version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 { sequence_id: seq, failed_installations: failed, + admitted_via_external_group_id: vec![], })), } } 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 43427348e4..24e2b88e66 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 @@ -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, -) -> Result, 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 @@ -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, +) -> Result, 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 @@ -2375,6 +2406,7 @@ mod tests { version: Some(Version::V1(GroupMembershipEntryV1 { sequence_id: 1, failed_installations: vec![], + admitted_via_external_group_id: vec![], })), }, ); @@ -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![], })), }, ); @@ -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![], })), }, ); diff --git a/crates/xmtp_mls/src/groups/app_data/migration.rs b/crates/xmtp_mls/src/groups/app_data/migration.rs index deda78d55d..e5c810e8cd 100644 --- a/crates/xmtp_mls/src/groups/app_data/migration.rs +++ b/crates/xmtp_mls/src/groups/app_data/migration.rs @@ -285,6 +285,10 @@ async fn build_partitioned_group_membership( 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![], })), }, ); diff --git a/crates/xmtp_mls/src/groups/app_data/mod.rs b/crates/xmtp_mls/src/groups/app_data/mod.rs index f9b8e3dd4b..f32e3f734b 100644 --- a/crates/xmtp_mls/src/groups/app_data/mod.rs +++ b/crates/xmtp_mls/src/groups/app_data/mod.rs @@ -268,6 +268,33 @@ pub(crate) fn stage_app_data_propose_and_commit( component_id: ComponentId, payload: Vec, ) -> Result<(MlsMessageOut, CommitMessageBundle), GroupAppDataError> { + 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( + mls_group: &mut OpenMlsGroup, + provider: &Provider, + signer: &impl openmls_traits::signatures::Signer, + updates: Vec<(ComponentId, Vec)>, +) -> Result<(Vec, CommitMessageBundle), GroupAppDataError> { // 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 @@ -283,15 +310,18 @@ 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. - 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 @@ -326,7 +356,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" ); @@ -346,7 +375,7 @@ pub(crate) fn stage_app_data_propose_and_commit( .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`]. diff --git a/crates/xmtp_mls/src/groups/app_data/sender_intents.rs b/crates/xmtp_mls/src/groups/app_data/sender_intents.rs index c2aed2ea20..03e0852e0e 100644 --- a/crates/xmtp_mls/src/groups/app_data/sender_intents.rs +++ b/crates/xmtp_mls/src/groups/app_data/sender_intents.rs @@ -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::{ @@ -268,19 +271,25 @@ pub(crate) fn apply_app_data_update_intent( ) -> Result { 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, )?) }, )?; @@ -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, 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..5f21038753 --- /dev/null +++ b/crates/xmtp_mls/src/groups/external_commit_policy.rs @@ -0,0 +1,579 @@ +//! External-commit policy lookup helpers. +//! +//! Two layers gate an incoming MLS External Commit (RFC 9420 §12.4.3.2): +//! +//! 1. **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`). +//! 2. **Per-component declarative permissions** — each component's +//! `ComponentMetadata.external_committer_permissions` block. Sibling +//! of the existing `permissions` block; governs what external +//! committers may do to *this* component. +//! +//! Both layers default 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 tls_codec::VLBytes; +use xmtp_mls_common::{ + app_data::{ + component_id::ComponentId, components::tls_map_components::ComponentRegistryComponent, + typed::Component, + }, + invite::payload::{MIN_EXTERNAL_GROUP_ID_LEN, SYMMETRIC_KEY_LEN, validate_service_pointer}, + tls_map::TlsMapDelta, +}; +use xmtp_proto::xmtp::mls::message_contents::{ + ComponentPermissions, ExternalCommitPolicyEntry, ExternalCommitPolicyV1, + MetadataPolicy as MetadataPolicyProto, ServicePointer, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, + metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy}, +}; + +use crate::groups::app_data::{component_source::ComponentSourceError, load_component_registry}; + +/// 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 (+ post-state registry), 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, + }, + /// Enabled policy in a group whose `GROUP_MEMBERSHIP` + /// `ComponentMetadata.external_committer_permissions` does not admit + /// a joiner inserting its own entry. Every conforming external + /// commit is structurally required to write that entry, so without + /// the grant the switch is on but every join dead-ends at + /// validation. The enabling commit MUST establish the grant. + #[error("enabled policy requires the GROUP_MEMBERSHIP external-committer insert grant")] + MissingMembershipGrant, +} + +/// Validate the XIP-82 field-coupling invariants on a policy value. +/// Pure function of the value; the cross-component grant check is +/// separate (see [`grant_admits_joiner_insert`]) because it needs the +/// post-state registry. +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(()) +} + +/// Whether a `GROUP_MEMBERSHIP` `external_committer_permissions` block +/// admits a joiner inserting its own entry: the block must be present +/// with `insert_policy` of base `Allow`. (The external committer is by +/// definition neither admin nor super-admin at validation time, so any +/// stricter base policy denies it; the validator's atomic-shape checks +/// — own entry only — bound what "Allow" can do.) +pub(crate) fn grant_admits_joiner_insert(perms: Option<&ComponentPermissions>) -> bool { + matches!( + perms + .and_then(|p| p.insert_policy.as_ref()) + .and_then(|policy| policy.kind.as_ref()), + Some(MetadataPolicyKind::Base(base)) + if *base == MetadataBasePolicy::Allow as i32 + ) +} + +/// Build the `AppDataUpdate(COMPONENT_REGISTRY)` payload that grants +/// external committers `insert` access to their own `GROUP_MEMBERSHIP` +/// entry, preserving everything else on the component's metadata. +/// +/// The enable commit ALWAYS carries this write, even when the current +/// registry already admits the insert: an enable racing a concurrent +/// grant removal would otherwise land grant-less and be rejected by +/// every validator (post-state invariant) with no way for the intent +/// retry to recover. Update / delete stay untouched (absent = +/// all-Deny), so an external committer still cannot rewrite or remove +/// entries; the validator's atomic-shape checks bound the insert to the +/// joiner's own entry. +/// +/// Known lost-update window (same class as the existing +/// `update_permission` path): the payload snapshots the component's +/// full `ComponentMetadata` at queue time, so a concurrent metadata +/// write to GROUP_MEMBERSHIP that lands between queue and commit is +/// clobbered (last writer wins). All members apply the same delta, so +/// state converges; residual-delta computation for registry writes is +/// the documented follow-on for the generic AppDataUpdate path. +pub(crate) fn build_membership_grant_registry_payload( + mls_group: &OpenMlsGroup, +) -> Result, ComponentSourceError> { + let registry = load_component_registry(mls_group)?; + let mut metadata = registry + .get(&ComponentId::GROUP_MEMBERSHIP) + .map_err(|e| ComponentSourceError::MalformedComponentValue { + component_id: ComponentId::GROUP_MEMBERSHIP, + reason: format!("registry get failed: {e}"), + })? + .ok_or_else(|| ComponentSourceError::MalformedComponentValue { + component_id: ComponentId::GROUP_MEMBERSHIP, + reason: "registry has no entry for GROUP_MEMBERSHIP".into(), + })?; + + let mut perms = metadata + .external_committer_permissions + .clone() + .unwrap_or_default(); + perms.insert_policy = Some(MetadataPolicyProto { + kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)), + }); + metadata.external_committer_permissions = Some(perms); + + let delta = TlsMapDelta::::new().update( + ComponentId::GROUP_MEMBERSHIP, + VLBytes::new(metadata.encode_to_vec()), + ); + ::encode_mutation(&delta) + .map_err(ComponentSourceError::from) +} + +/// 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. +// +// The validator (`ValidatedCommit::from_external_commit`) reads the +// full policy via `load_external_commit_policy` instead; this stays as +// the cheap pre-check for the L-8 ingestion dispatch. Dead-allowed +// until L-8 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) +} + +/// Read the `external_committer_permissions` block from the +/// `ComponentMetadata` of the given component in the registry. +/// +/// Returns: +/// +/// - `Ok(Some(perms))` — component has an `external_committer_permissions` +/// block. The caller evaluates each proposal's effect against the +/// relevant policy slot. +/// - `Ok(None)` — component is in the registry but has no +/// `external_committer_permissions` block, OR component isn't in the +/// registry at all. In both cases the validator treats this as +/// all-Deny: external committers may not touch this component. +/// - `Err(_)` — registry decode failed. +// +// Consumed by the L-7 validator (check 10). +pub(crate) fn external_committer_permissions_for( + mls_group: &OpenMlsGroup, + component_id: ComponentId, +) -> Result, ComponentSourceError> { + let registry = load_component_registry(mls_group)?; + let Some(meta) = registry.get(&component_id).ok().flatten() else { + return Ok(None); + }; + Ok(meta.external_committer_permissions) +} + +#[cfg(test)] +mod tests { + //! Round-trip + absence coverage for the policy lookup helpers. + use super::*; + use openmls::extensions::{ + AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions, + }; + use xmtp_proto::xmtp::mls::message_contents::ComponentMetadata; + + 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 grant_check_requires_insert_allow() { + use xmtp_proto::xmtp::mls::message_contents::MetadataPolicy as MetadataPolicyProto; + + // Absent block: deny. + assert!(!grant_admits_joiner_insert(None)); + // Block without insert policy: deny. + assert!(!grant_admits_joiner_insert(Some( + &ComponentPermissions::default() + ))); + // Insert Deny: deny. + let deny = ComponentPermissions { + insert_policy: Some(MetadataPolicyProto { + kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Deny as i32)), + }), + ..Default::default() + }; + assert!(!grant_admits_joiner_insert(Some(&deny))); + // Insert Allow: admit. + let allow = ComponentPermissions { + insert_policy: Some(MetadataPolicyProto { + kind: Some(MetadataPolicyKind::Base(MetadataBasePolicy::Allow as i32)), + }), + ..Default::default() + }; + assert!(grant_admits_joiner_insert(Some(&allow))); + } + + #[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"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn component_metadata_without_external_block_is_treated_as_deny() { + // ComponentMetadata with no external_committer_permissions field + // is treated as all-Deny by the validator. + let meta = ComponentMetadata { + component_type: 1, + permissions: None, + external_committer_permissions: None, + }; + assert!(meta.external_committer_permissions.is_none()); + } +} diff --git a/crates/xmtp_mls/src/groups/intents.rs b/crates/xmtp_mls/src/groups/intents.rs index 685a9fc5a8..6769f5ccf4 100644 --- a/crates/xmtp_mls/src/groups/intents.rs +++ b/crates/xmtp_mls/src/groups/intents.rs @@ -1033,6 +1033,14 @@ pub struct AppDataUpdateIntentData { /// Verbatim AppDataUpdate proposal payload. See the type-level /// docs for the per-`ComponentType` interpretation. pub payload: Vec, + /// Further component writes that MUST land in the same commit as + /// the primary update — each becomes its own standalone + /// `AppDataUpdate` proposal, swept into one commit. 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). Empty for ordinary single-component writes. + pub additional_updates: Vec<(u16, Vec)>, } impl AppDataUpdateIntentData { @@ -1042,8 +1050,16 @@ impl AppDataUpdateIntentData { Self { component_id, payload, + additional_updates: vec![], } } + + /// Attach a further component write that must land in the same + /// commit as the primary update. + pub fn with_additional_update(mut self, component_id: u16, payload: Vec) -> Self { + self.additional_updates.push((component_id, payload)); + self + } } // Wire format: prost-encoded `xmtp.mls.database.AppDataUpdateData` proto @@ -1063,13 +1079,22 @@ impl From for Vec { fn from(intent: AppDataUpdateIntentData) -> Self { use prost::Message; use xmtp_proto::xmtp::mls::database::{ - AppDataUpdateData, app_data_update_data::V1 as AppDataUpdateDataV1, + AppDataUpdateData, app_data_update_data::Update as AppDataUpdateExtra, + app_data_update_data::V1 as AppDataUpdateDataV1, app_data_update_data::Version as AppDataUpdateVersion, }; AppDataUpdateData { version: Some(AppDataUpdateVersion::V1(AppDataUpdateDataV1 { component_id: intent.component_id as u32, payload: intent.payload, + additional_updates: intent + .additional_updates + .into_iter() + .map(|(component_id, payload)| AppDataUpdateExtra { + component_id: component_id as u32, + payload, + }) + .collect(), })), } .encode_to_vec() @@ -1107,9 +1132,23 @@ impl TryFrom<&[u8]> for AppDataUpdateIntentData { v1.component_id )) })?; + let additional_updates = v1 + .additional_updates + .into_iter() + .map(|extra| { + let id = u16::try_from(extra.component_id).map_err(|_| { + IntentError::Generic(format!( + "AppDataUpdateIntentData additional component_id {} exceeds u16 range", + extra.component_id + )) + })?; + Ok((id, extra.payload)) + }) + .collect::, IntentError>>()?; Ok(Self { component_id, payload: v1.payload, + additional_updates, }) } } @@ -1170,6 +1209,7 @@ 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/mls_ext/decrypted_welcome.rs b/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs index 669e54ab98..2253966468 100644 --- a/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs +++ b/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs @@ -103,18 +103,18 @@ impl DecryptedWelcome { } }; - let decrypted_welcome_data = unwrap_payload_symmetric( - v1.data.as_slice(), - aead_type, - &decrypted_welcome_pointer.encryption_key, - &decrypted_welcome_pointer.data_nonce, - )?; - let decrypted_welcome_metadata = unwrap_payload_symmetric( - v1.welcome_metadata.as_slice(), - aead_type, - &decrypted_welcome_pointer.encryption_key, - &decrypted_welcome_pointer.welcome_metadata_nonce, - )?; + let decrypted_welcome_data = unwrap_payload_symmetric() + .data(v1.data.as_slice()) + .aead_type(aead_type) + .symmetric_key(&decrypted_welcome_pointer.encryption_key) + .nonce(&decrypted_welcome_pointer.data_nonce) + .call()?; + let decrypted_welcome_metadata = unwrap_payload_symmetric() + .data(v1.welcome_metadata.as_slice()) + .aead_type(aead_type) + .symmetric_key(&decrypted_welcome_pointer.encryption_key) + .nonce(&decrypted_welcome_pointer.welcome_metadata_nonce) + .call()?; let welcome = deserialize_welcome(&decrypted_welcome_data)?; let welcome_metadata = Some(decrypted_welcome_metadata.as_slice()) .filter(|data| !data.is_empty()) diff --git a/crates/xmtp_mls/src/groups/mls_sync.rs b/crates/xmtp_mls/src/groups/mls_sync.rs index 3ea0f10fec..4f43aa9012 100644 --- a/crates/xmtp_mls/src/groups/mls_sync.rs +++ b/crates/xmtp_mls/src/groups/mls_sync.rs @@ -3350,6 +3350,7 @@ where let payload = build_group_membership_app_data_payload( &old_group_membership, &new_membership, + openmls_group.extensions(), )?; let (proposal_msg, _) = openmls_group .propose_app_data_update( @@ -4066,18 +4067,18 @@ where } let aead_type = crate::groups::mls_ext::WelcomePointersExtension::preferred_type(); - let data = wrap_payload_symmetric( - &action.welcome_message, - aead_type, - symmetric_key.as_ref(), - data_nonce.as_ref(), - )?; - let welcome_metadata = wrap_payload_symmetric( - &welcome_metadata_bytes, - aead_type, - symmetric_key.as_ref(), - welcome_metadata_nonce.as_ref(), - )?; + let data = wrap_payload_symmetric() + .data(&action.welcome_message) + .aead_type(aead_type) + .symmetric_key(symmetric_key.as_ref()) + .nonce(data_nonce.as_ref()) + .call()?; + let welcome_metadata = wrap_payload_symmetric() + .data(&welcome_metadata_bytes) + .aead_type(aead_type) + .symmetric_key(symmetric_key.as_ref()) + .nonce(welcome_metadata_nonce.as_ref()) + .call()?; let welcome_pointee = WelcomeMessageInput { version: Some(WelcomeMessageInputVersion::V1(WelcomeMessageInputV1 { diff --git a/crates/xmtp_mls/src/groups/mls_sync/update_group_membership.rs b/crates/xmtp_mls/src/groups/mls_sync/update_group_membership.rs index 604cc278f3..7a7d6b6309 100644 --- a/crates/xmtp_mls/src/groups/mls_sync/update_group_membership.rs +++ b/crates/xmtp_mls/src/groups/mls_sync/update_group_membership.rs @@ -45,26 +45,51 @@ use xmtp_proto::xmtp::mls::message_contents::{GroupMembershipEntry, group_member /// updates intentionally don't propagate failed_installations changes /// over the AppData path; the worst case is a slightly noisier retry /// loop. Future enhancement once a clearer attribution path exists. +/// +/// `admitted_via_external_group_id` is the exception to the +/// rewrite-from-scratch shape: the tag is write-once (XIP-82) and +/// validators reject any member commit that sets, clears, or alters +/// it, so the Update arm carries the existing entry's tag through +/// from `extensions` (the group's pre-commit AppData dictionary). +/// Inserted entries are untagged — only the external-commit join path +/// ever sets the tag. pub(crate) fn build_group_membership_app_data_payload( old: &GroupMembership, new: &GroupMembership, + extensions: &Extensions, ) -> Result, GroupError> { + let existing_entries = + crate::groups::app_data::component_source::read_group_membership_entries(extensions) + .map_err(GroupError::ComponentSource)? + .unwrap_or_default(); + let existing_tag = |inbox_id: &InboxId| -> Vec { + existing_entries + .get(inbox_id) + .and_then(|entry| entry.version.as_ref()) + .map(|group_membership_entry::Version::V1(v1)| { + v1.admitted_via_external_group_id.clone() + }) + .unwrap_or_default() + }; + let mut delta = TlsMapDelta::::new(); // Inserts and updates: walk new.members, classify against old. for (inbox_id_str, &sequence_id) in new.members.iter() { - let entry = encode_membership_entry(sequence_id)?; match old.members.get(inbox_id_str) { None => { - // New inbox: Insert. + // New inbox: Insert, never tagged. let inbox_id = InboxId::from_hex(inbox_id_str) .map_err(|e| GroupError::ComponentSource(e.into()))?; + let entry = encode_membership_entry(sequence_id, vec![])?; delta = delta.insert(inbox_id, VLBytes::new(entry)); } Some(&old_seq) if old_seq != sequence_id => { - // Existing inbox with bumped sequence_id: Update. + // Existing inbox with bumped sequence_id: Update, + // carrying the write-once tag through unchanged. let inbox_id = InboxId::from_hex(inbox_id_str) .map_err(|e| GroupError::ComponentSource(e.into()))?; + let entry = encode_membership_entry(sequence_id, existing_tag(&inbox_id))?; delta = delta.update(inbox_id, VLBytes::new(entry)); } _ => { @@ -90,13 +115,20 @@ pub(crate) fn build_group_membership_app_data_payload( } /// Encode a per-inbox `GroupMembershipEntry::V1` value with the given -/// `sequence_id` and an empty `failed_installations` list. -fn encode_membership_entry(sequence_id: u64) -> Result, GroupError> { +/// `sequence_id`, an empty `failed_installations` list, and the given +/// write-once `admitted_via_external_group_id` (empty ≡ absent — +/// proto3 scalar default; the caller passes the existing entry's tag +/// for rewrites, empty for fresh inserts). +fn encode_membership_entry( + sequence_id: u64, + admitted_via_external_group_id: Vec, +) -> Result, GroupError> { let entry = GroupMembershipEntry { version: Some(group_membership_entry::Version::V1( group_membership_entry::V1 { sequence_id, failed_installations: vec![], + admitted_via_external_group_id, }, )), }; @@ -203,6 +235,7 @@ pub(crate) async fn apply_update_group_membership_intent( Some(build_group_membership_app_data_payload( &old_group_membership, &new_group_membership, + &extensions, )?) } else { None diff --git a/crates/xmtp_mls/src/groups/mod.rs b/crates/xmtp_mls/src/groups/mod.rs index bc2014bc54..9aaa67839f 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,159 @@ 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, with no coupled + /// registry write. Prefer [`MlsGroup::enable_external_commits`] / + /// [`MlsGroup::revoke_external_commits`], which mint fresh invite + /// coordinates, preserve durable settings, and establish the + /// `GROUP_MEMBERSHIP` external-committer grant atomically. + /// + /// 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, None).await + } + + /// Encode + queue an `EXTERNAL_COMMIT_POLICY` write, optionally + /// coupled with an `AppDataUpdate(COMPONENT_REGISTRY)` payload that + /// MUST land in the same commit (XIP-82 enable atomicity). + async fn queue_external_commit_policy( + &self, + policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + registry_payload: Option>, + ) -> 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 mut intent_data = crate::groups::intents::AppDataUpdateIntentData::new( + ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), + policy_bytes, + ); + if let Some(payload) = registry_payload { + intent_data = intent_data + .with_additional_update(ComponentId::COMPONENT_REGISTRY.as_u16(), payload); + } + 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, + /// in one commit (XIP-82 enable atomicity): + /// + /// - `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). + /// - The `GROUP_MEMBERSHIP` external-committer insert grant, written + /// in the same commit when not already present — every conforming + /// external commit must write the joiner's own membership entry, + /// so without the grant the switch would be on but every join + /// would dead-end at validation. + /// + /// 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)?; + + let registry_payload = + self.load_mls_group_with_lock(self.context.mls_storage(), |mls_group| { + external_commit_policy::build_membership_grant_registry_payload(&mls_group) + .map_err(GroupError::from) + })?; + + self.queue_external_commit_policy(policy, Some(registry_payload)) + .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, None).await + } + fn min_protocol_version_from_extensions( mutable_metadata: &GroupMutableMetadata, ) -> Option { diff --git a/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs b/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs index aa5c80f077..1ec7122250 100644 --- a/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs +++ b/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs @@ -277,34 +277,39 @@ fn test_welcome_pointer_encryption_round_trip() { let aead_type = available_types.supported_aead_types.first().unwrap(); // Test encryption - let encrypted_welcome_data = - wrap_payload_symmetric(&welcome_data, *aead_type, &symmetric_key, &data_nonce).unwrap(); - let encrypted_welcome_metadata = wrap_payload_symmetric( - &welcome_metadata_bytes, - *aead_type, - &symmetric_key, - &metadata_nonce, - ) - .unwrap(); + let encrypted_welcome_data = wrap_payload_symmetric() + .data(&welcome_data) + .aead_type(*aead_type) + .symmetric_key(&symmetric_key) + .nonce(&data_nonce) + .call() + .unwrap(); + let encrypted_welcome_metadata = wrap_payload_symmetric() + .data(&welcome_metadata_bytes) + .aead_type(*aead_type) + .symmetric_key(&symmetric_key) + .nonce(&metadata_nonce) + .call() + .unwrap(); // Verify encryption worked (data should be different) assert_ne!(encrypted_welcome_data, welcome_data); assert_ne!(encrypted_welcome_metadata, welcome_metadata_bytes); // Test decryption - let decrypted_welcome_data = unwrap_payload_symmetric( - &encrypted_welcome_data, - *aead_type, - &symmetric_key, - &data_nonce, - ) - .unwrap(); - let decrypted_welcome_metadata = unwrap_payload_symmetric( - &encrypted_welcome_metadata, - *aead_type, - &symmetric_key, - &metadata_nonce, - ) - .unwrap(); + let decrypted_welcome_data = unwrap_payload_symmetric() + .data(&encrypted_welcome_data) + .aead_type(*aead_type) + .symmetric_key(&symmetric_key) + .nonce(&data_nonce) + .call() + .unwrap(); + let decrypted_welcome_metadata = unwrap_payload_symmetric() + .data(&encrypted_welcome_metadata) + .aead_type(*aead_type) + .symmetric_key(&symmetric_key) + .nonce(&metadata_nonce) + .call() + .unwrap(); // Verify decryption worked (data should match original) assert_eq!(decrypted_welcome_data, welcome_data); @@ -595,22 +600,24 @@ async fn test_welcome_pointer_task_retry_resolution() { Ok::<_, crate::groups::GroupError>(action) }) .await?; - let data = wrap_payload_symmetric( - &send_welcome_action.welcome_message, - WelcomePointersExtension::preferred_type(), - &welcome_pointer_v1.encryption_key, - &welcome_pointer_v1.data_nonce, - ) - .unwrap(); - let welcome_metadata = wrap_payload_symmetric( - WelcomeMetadata { message_cursor: 0 } - .encode_to_vec() - .as_slice(), - WelcomePointersExtension::preferred_type(), - &welcome_pointer_v1.encryption_key, - &welcome_pointer_v1.welcome_metadata_nonce, - ) - .unwrap(); + let data = wrap_payload_symmetric() + .data(&send_welcome_action.welcome_message) + .aead_type(WelcomePointersExtension::preferred_type()) + .symmetric_key(&welcome_pointer_v1.encryption_key) + .nonce(&welcome_pointer_v1.data_nonce) + .call() + .unwrap(); + let welcome_metadata = wrap_payload_symmetric() + .data( + WelcomeMetadata { message_cursor: 0 } + .encode_to_vec() + .as_slice(), + ) + .aead_type(WelcomePointersExtension::preferred_type()) + .symmetric_key(&welcome_pointer_v1.encryption_key) + .nonce(&welcome_pointer_v1.welcome_metadata_nonce) + .call() + .unwrap(); let welcome_data = xmtp_proto::xmtp::mls::api::v1::WelcomeMessageInput { version: Some( diff --git a/crates/xmtp_mls/src/groups/validated_commit.rs b/crates/xmtp_mls/src/groups/validated_commit.rs index 4ce547dee4..07beaae69d 100644 --- a/crates/xmtp_mls/src/groups/validated_commit.rs +++ b/crates/xmtp_mls/src/groups/validated_commit.rs @@ -15,7 +15,7 @@ use openmls::{ credentials::{BasicCredential, Credential as OpenMlsCredential, errors::BasicCredentialError}, extensions::{Extension, Extensions, UnknownExtension}, group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit}, - messages::proposals::{Proposal, ProposalType}, + messages::proposals::{AppDataUpdateOperation, Proposal, ProposalOrRefType, ProposalType}, prelude::{LeafNodeIndex, Sender}, treesync::LeafNode, }; @@ -42,7 +42,7 @@ use xmtp_proto::types::GroupId; use xmtp_proto::xmtp::{ identity::MlsCredential, mls::message_contents::{ - GroupMembershipChanges, GroupUpdated as GroupUpdatedProto, + ExternalCommitPolicyV1, GroupMembershipChanges, GroupUpdated as GroupUpdatedProto, group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto}, }, }; @@ -60,7 +60,9 @@ pub enum CommitValidationError { InvalidVersionFormat(String), #[error("Minimum supported protocol version {0} exceeds current version")] ProtocolVersionTooLow(String), - // TODO: We will need to relax this once we support external joins + // External joins do not flow through this variant — they are routed + // separately into [`ValidatedCommit::from_external_commit`], which + // builds its own actor/participant view from the joiner's path leaf. #[error("Actor not a member of the group")] ActorNotMember, #[error("Subject not a member of the group")] @@ -124,6 +126,15 @@ 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 + the GROUP_MEMBERSHIP external-committer + /// grant; revoke leaves every per-invite field absent). Convergent: + /// a pure function of the proposed value and the commit's own + /// registry write. + #[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 @@ -132,6 +143,160 @@ pub enum CommitValidationError { Bootstrap(#[from] super::app_data::bootstrap_validator::BootstrapValidationError), #[error(transparent)] Conversion(#[from] xmtp_proto::ConversionError), + + // ────────────────────────────────────────────────────────────────── + // External-commit validation failures (L-7). + // + // These are surfaced exclusively by + // [`ValidatedCommit::from_external_commit`] and its helpers. New + // variants are appended here so unrelated PRs that add other + // CommitValidationError variants don't conflict on the same line + // range. + // ────────────────────────────────────────────────────────────────── + /// The group's permission policy has `allow_external_commit = false` + /// — external joins are not accepted on this group. + #[error("external commits are not allowed on this group")] + ExternalCommitNotAllowed, + /// The commit was routed to the external-commit validator but its + /// framing sender is not `Sender::NewMemberCommit`. Either the + /// caller dispatched incorrectly or the commit is malformed. + #[error("external-commit validator invoked on non-NewMemberCommit sender")] + ExternalCommitNotNewMemberCommit, + /// An external commit must carry exactly one `ExternalInit` + /// proposal (RFC 9420 §12.4.3.2). The staged commit had none. + #[error("external commit is missing the required ExternalInit proposal")] + ExternalCommitMissingExternalInit, + /// An external commit must carry exactly one `ExternalInit` + /// proposal — this commit carried more than one. + #[error("external commit carried multiple ExternalInit proposals")] + ExternalCommitMultipleExternalInit, + /// RFC 9420 §12.4.3.2: external commits MUST NOT include any + /// proposals by reference. + #[error("external commit included a proposal by reference")] + ExternalCommitByReferenceProposalsForbidden, + /// External commits carry the joiner's leaf in the update path + /// — this commit had no update path, so we cannot identify the + /// joiner and refuse to accept the commit. + #[error("external commit is missing the joiner's update path leaf")] + ExternalCommitMissingPathLeaf, + /// An `Add` proposal in the external commit referenced a key + /// package whose credential inbox id differs from the joiner's + /// path-leaf inbox id. libxmtp v1 only allows external commits to + /// add installations belonging to the same inbox as the joiner — + /// this prevents a joiner from smuggling unrelated members in + /// under cover of an external commit. + #[error("Add proposal in external commit references a different inbox id")] + CrossInboxAddInExternalCommit, + /// External commits must register the joiner in the AppData + /// `GROUP_MEMBERSHIP` component via exactly one `AppDataUpdate` + /// proposal — this commit carried none. + #[error("external commit is missing the GROUP_MEMBERSHIP AppDataUpdate")] + ExternalCommitAppDataUpdateMissing, + /// External commits must register the joiner in the AppData + /// `GROUP_MEMBERSHIP` component via exactly one `AppDataUpdate` + /// proposal — this commit carried more than one. + #[error("external commit carried multiple AppDataUpdate proposals")] + ExternalCommitAppDataUpdateMultiple, + /// The single AppDataUpdate proposal in this external commit + /// targets a component other than `GROUP_MEMBERSHIP`. Only the + /// membership registration is permitted — broader AppData writes + /// are not allowed at join time. + #[error("external commit's AppDataUpdate must target GROUP_MEMBERSHIP")] + ExternalCommitAppDataUpdateWrongComponent, + /// The joiner's AppDataUpdate proposal mutates a `GROUP_MEMBERSHIP` + /// entry that is not their own inbox. Joiners may only insert their + /// own membership entry through an external commit. + #[error("external commit's AppDataUpdate is out of scope for the joiner")] + ExternalCommitAppDataUpdateOutOfScope, + /// The wire-form payload of the joiner's AppDataUpdate + /// (`TlsMapDelta`) failed to decode. Treat as a + /// terminal wire-format violation so the commit is rejected rather + /// than silently accepted. + #[error("external commit's AppDataUpdate payload is malformed: {0}")] + ExternalCommitAppDataUpdatePayloadMalformed(String), + /// The "resync" flavor of external commit (where the joiner removes + /// a stale prior leaf with a SelfRemove proposal) is not supported + /// in v1. + #[error("resync external commits are not supported in v1")] + ResyncExternalCommitNotSupported, + /// External commits must not carry a `GroupContextExtensions` + /// proposal — post-AppData migration, GCE updates are not a + /// legitimate join-time operation. + #[error("external commit must not carry GroupContextExtensions proposals")] + ExternalCommitGceForbidden, + /// The external commit carried a proposal type that is never legal + /// in an external commit (e.g. PreSharedKey, Update, Remove, ReInit, + /// Custom, AppEphemeral, _AppAck). PSKs are called out by XIP-82 + /// explicitly: a non-member has no pre-shared key it can + /// legitimately reference with the group, so admitting them is + /// gratuitous attack surface in v1. + #[error("external commit carried unsupported proposal type: {0:?}")] + ExternalCommitUnsupportedProposalType(ProposalType), + /// The joiner's inbox id is already in the group — an existing + /// ratchet-tree leaf or an existing `GROUP_MEMBERSHIP` entry + /// carries it. An existing member cannot re-add itself via external + /// commit; among other things, that would let it rewrite — or + /// re-tag — its own membership entry (XIP-82 check 4). + #[error("external commit joiner {inbox_id} is already a member")] + ExternalCommitJoinerAlreadyMember { inbox_id: String }, + /// The commit's delivery-service envelope timestamp exceeds the + /// policy's absolute campaign expiry (`expires_at_ns`, XIP-82 + /// check 8). Envelope-timestamp based so every member reaches the + /// same verdict regardless of when it syncs the commit. + #[error("external commit envelope ts {commit_envelope_ns} past invite expiry {expires_at_ns}")] + ExternalCommitInviteExpired { + expires_at_ns: u64, + commit_envelope_ns: u64, + }, + /// The commit landed more than `expire_in_ns` after the current + /// epoch's start (XIP-82 check 9) — the blob the joiner used is + /// stale beyond the group's staleness window. Both timestamps are + /// delivery-service envelope timestamps. + #[error("external commit is {age_ns}ns into the epoch, past staleness bound {expire_in_ns}ns")] + ExternalCommitInviteStale { age_ns: u64, expire_in_ns: u64 }, + /// `GROUP_MEMBERSHIP`'s `external_committer_permissions` block does + /// not admit the joiner's self-entry insert (XIP-82 check 10). The + /// enable path is required to establish this grant atomically, so + /// hitting this on an enabled policy means the group state predates + /// that invariant or was manipulated. + #[error("GROUP_MEMBERSHIP external_committer_permissions does not admit the joiner insert")] + ExternalCommitInsertNotAdmitted, + /// The joiner's new `GROUP_MEMBERSHIP` entry does not record + /// `admitted_via_external_group_id` equal to the active + /// `external_group_id` (XIP-82 check 11). Required on every + /// external commit — whatever `max_uses` is — so a later policy + /// change to a finite cap starts from accurate data. + #[error("external commit's membership entry does not record the active external_group_id")] + ExternalCommitAdmittedViaTagMismatch, + /// Admitting this joiner would exceed the policy's concurrent + /// per-invite cap: `max_uses` entries already carry the active + /// `external_group_id` tag (XIP-82 check 12). Counted from the + /// shared pre-commit `GROUP_MEMBERSHIP` state so every member — + /// including ones that joined after the invite was issued — + /// computes the same count. + #[error("invite max_uses ({max_uses}) exhausted")] + ExternalCommitMaxUsesExhausted { max_uses: u32 }, + /// A member-sender commit set, cleared, or altered + /// `admitted_via_external_group_id` on a `GROUP_MEMBERSHIP` entry. + /// The tag is write-once: set exactly once by the admitting + /// external commit and immutable for the life of the entry — + /// otherwise an invited member could untag itself and free a + /// `max_uses` slot at will. Entry rewrites for unrelated reasons + /// (an installation change, say) must carry the tag through + /// unchanged; the field disappears only when the entry itself does. + #[error( + "admitted_via_external_group_id is write-once; member commit altered it for {inbox_id}" + )] + AdmittedViaTagImmutable { inbox_id: String }, + /// A `GROUP_MEMBERSHIP` entry value (the prost-encoded + /// `GroupMembershipEntry`) failed to decode, or decoded to an + /// unknown version, while enforcing the XIP-82 tag rules. Fail + /// closed: an undecodable entry could otherwise smuggle a tag + /// rewrite past the write-once check (replacing a tagged V1 entry + /// with an "unknown version" blob frees the max_uses slot from + /// every V1 reader's perspective). + #[error("GROUP_MEMBERSHIP entry malformed: {0}")] + GroupMembershipEntryMalformed(String), } impl RetryableError for CommitValidationError { @@ -349,6 +514,24 @@ pub struct ValidatedCommit { /// two paths is a security risk (a steady-state tightening that misses /// the bootstrap path would let a sender smuggle a PSK proposal through /// a bootstrap-shaped commit). +/// Delivery-service envelope timestamps backing XIP-82's two +/// external-commit time bounds (checks 8 and 9). Both are envelope +/// timestamps — never a validator's wall clock at processing time — so +/// a member that syncs a commit a week later reaches the same verdict +/// as one that processed it immediately. +#[derive(Debug, Clone, Copy)] +pub struct ExternalCommitTimestamps { + /// Envelope timestamp of the external commit itself (ns). + pub commit_envelope_ns: u64, + /// Envelope timestamp of the message by which this client entered + /// or observed the current epoch (ns): the epoch's commit for + /// members that processed it, the corresponding `Welcome` for + /// members added at that epoch, the group-creation timestamp at + /// the initial epoch. These differ across members only by publish + /// latency — which is why `expire_in_ns` is a coarse bound. + pub epoch_started_at_ns: u64, +} + fn reject_psk_proposals(staged_commit: &StagedCommit) -> Result<(), CommitValidationError> { if staged_commit.psk_proposals().any(|_| true) { return Err(CommitValidationError::NoPSKSupport); @@ -829,6 +1012,181 @@ impl ValidatedCommit { dm_members: immutable_metadata.dm_members, }) } + + /// Validate a `Sender::NewMemberCommit`-flavored MLS commit (external + /// commit) carrying an atomic "join the group" payload, asserting + /// XIP-82's receive-side check set: + /// + /// 1. The framing sender is `Sender::NewMemberCommit`. + /// 2. Exactly one `ExternalInit` proposal is present (RFC 9420 + /// §12.4.3.2). + /// 3. Every `Add` proposal's KeyPackage credential carries the same + /// inbox id as the joiner's path leaf (anti-smuggling — a joiner + /// can add only its own installations). + /// 4. The joiner's inbox id is not already in the group: no + /// existing ratchet-tree leaf and no existing `GROUP_MEMBERSHIP` + /// entry. (Re-adding yourself would let you rewrite — or re-tag + /// — your own membership entry.) + /// 5. Exactly one `AppDataUpdate` proposal is present, inserting + /// exactly the joiner's own `GROUP_MEMBERSHIP` entry + /// (cross-layer invariant: tree-membership ↔ AppData-membership + /// coupled in one commit). + /// 6. No forbidden proposal types: nothing by reference, no + /// `Remove`/`SelfRemove` (the "resync" flavor is not v1), no + /// PSKs (a non-member has no PSK it can legitimately reference + /// with the group), no `GroupContextExtensions`, nothing else. + /// 7. `EXTERNAL_COMMIT_POLICY.allow_external_commit` is `true`. + /// 8. The commit's envelope timestamp does not exceed the policy's + /// `expires_at_ns` (when set). + /// 9. When the policy's `expire_in_ns != 0`: the commit's envelope + /// timestamp is within `expire_in_ns` of the current epoch's + /// start. + /// 10. `GROUP_MEMBERSHIP`'s `external_committer_permissions` block + /// admits the joiner's self-entry insert. + /// 11. The inserted entry records `admitted_via_external_group_id` + /// equal to the active `external_group_id` — on every external + /// commit, whatever `max_uses` is, so a later policy change to + /// a finite cap starts from accurate data. + /// 12. When the policy's `max_uses != 0`: strictly fewer than + /// `max_uses` current entries carry the active tag. + /// + /// Returns a `ValidatedCommit` whose `actor` is the joiner (built + /// from the path leaf), `added_inboxes`/`added_installations` + /// reflect the joiner's additions, and `removed_inboxes` is empty. + /// + /// Group-state inputs (policy, membership entries, the permission + /// grant) are read from `openmls_group`'s **pre-merge** extensions, + /// which every member shares. The two time bounds come from + /// `timestamps`, which the caller (L-8 — `mls_sync`) sources from + /// delivery-service envelopes — never a wall clock — so every + /// member reaches the same verdict regardless of when it processes + /// the commit. The accept/reject decision is deterministic and + /// convergent across the membership. + pub fn from_external_commit( + staged_commit: &StagedCommit, + openmls_group: &OpenMlsGroup, + sender: &Sender, + immutable_metadata: &GroupMetadata, + mutable_metadata: &GroupMutableMetadata, + timestamps: ExternalCommitTimestamps, + ) -> Result { + // Check 1: framing sender must be NewMemberCommit. Defensive + // double-check; the wider mls_sync dispatch should already have + // routed by sender, but layering the check here means the + // validator is safe to call directly from tests and tomorrow's + // refactors can't accidentally hand us a Sender::Member commit. + enforce_external_commit_sender(sender)?; + + // Check 7: policy gate — short-circuit before any structural + // work. An absent component and an unknown future version both + // come back `None` from the loader and fail closed here; an + // undecodable entry surfaces as `ComponentSource` (also a + // rejection). + let policy = super::external_commit_policy::load_external_commit_policy(openmls_group)?; + let policy = enforce_external_commit_policy(policy.as_ref())?; + + // Checks 8 + 9: envelope-timestamp time bounds. + enforce_external_commit_time_bounds(policy, ×tamps)?; + + // Check 10: the GROUP_MEMBERSHIP external-committer grant. The + // enable path establishes this atomically with the policy + // write (post-state invariant in + // `validate_external_commit_policy_post_state`), so an enabled + // policy without the grant means the group state predates that + // invariant or was manipulated — reject. + let perms = super::external_commit_policy::external_committer_permissions_for( + openmls_group, + xmtp_mls_common::app_data::component_id::ComponentId::GROUP_MEMBERSHIP, + )?; + if !super::external_commit_policy::grant_admits_joiner_insert(perms.as_ref()) { + return Err(CommitValidationError::ExternalCommitInsertNotAdmitted); + } + + // Identify the joiner via the path leaf so the remaining rules + // can assert their inbox-id binding against a single source of + // truth. + let joiner_leaf = staged_commit + .update_path_leaf_node() + .ok_or(CommitValidationError::ExternalCommitMissingPathLeaf)?; + let joiner_inbox_id = inbox_id_from_credential(joiner_leaf.credential())?; + let joiner_participant = + CommitParticipant::from_leaf_node(joiner_leaf, immutable_metadata, mutable_metadata)?; + + // Pre-commit GROUP_MEMBERSHIP entries, decoded once and shared + // by the already-member check (4) and the max_uses count (12). + let membership = super::app_data::component_source::read_group_membership_entries( + openmls_group.extensions(), + )? + .unwrap_or_default(); + + // Check 4 (tree half): no current leaf may carry the joiner's + // inbox id. The pre-merge tree is what `members()` iterates. + let tree_inbox_ids = openmls_group + .members() + .map(|member| inbox_id_from_credential(&member.credential)) + .collect::, _>>()?; + enforce_joiner_not_already_member(&joiner_inbox_id, &tree_inbox_ids, &membership)?; + + // Check 12: concurrent per-invite cap, counted from the shared + // pre-commit membership state so every member — including one + // that joined after the invite was issued — computes the same + // count. + enforce_max_uses(policy, &membership)?; + + // Walk the proposal set once, categorizing as we go. Returns a + // summary the per-rule helpers consume; iterating once also + // means we never have to re-walk for a different lens. + let summary = collect_external_commit_proposals(staged_commit)?; + + // Checks 2 + 6: structural shape — counts and forbidden + // proposal types. + enforce_external_commit_structure(&summary)?; + + // Check 3: Add proposals must bind to the joiner's inbox id. + let added_installations = enforce_adds_bind_to_joiner(&summary, &joiner_inbox_id)?; + + // Checks 5 + 11: the AppDataUpdate must insert exactly the + // joiner's own GROUP_MEMBERSHIP entry, tagged with the active + // external_group_id. + enforce_app_data_update_scope( + summary + .app_data_update + .expect("structure check guarantees Some when no failure"), + &joiner_inbox_id, + &policy.external_group_id, + )?; + + // `added_inboxes` carries the joiner exactly once. We populate + // `proposer: None` because the proposer attribution machinery + // is built around `Sender::Member` leaf indices — the joiner + // is not a member yet, so the right shape is to elide the + // proposer rather than to attribute it to themselves with a + // pre-commit leaf index that doesn't yet exist in the tree. + let added_inboxes = vec![build_inbox( + &joiner_inbox_id, + immutable_metadata, + mutable_metadata, + )]; + + let installations_changed = !added_installations.is_empty(); + + Ok(Self { + actor: joiner_participant, + // External commits have no by-reference proposers from + // existing members — the joiner is the sole authoring + // party. We surface the same participant in `proposers` + // for downstream consumers that look there for "who + // wrote this commit". + proposers: Vec::new(), + added_inboxes, + removed_inboxes: Vec::new(), + readded_installations: HashSet::new(), + metadata_validation_info: MutableMetadataValidationInfo::default(), + installations_changed, + permissions_changed: false, + dm_members: immutable_metadata.dm_members.clone(), + }) + } } impl From for GroupMembershipChanges { @@ -1383,6 +1741,23 @@ pub(super) fn validate_one_app_data_update_with_old_value( })?; } + // XIP-82 write-once rule: a member-sender write to GROUP_MEMBERSHIP + // must carry every entry's `admitted_via_external_group_id` through + // unchanged. Only this path needs the rule — the external-commit + // validator (`from_external_commit`) never reaches here, and + // `app_data_update_proposer_leaf` has already rejected non-member + // senders. + if component_id == xmtp_mls_common::app_data::component_id::ComponentId::GROUP_MEMBERSHIP { + enforce_admitted_via_write_once(operation, old_value).inspect_err(|err| { + tracing::warn!( + proposer_inbox_id, + component_id = %component_id, + error = %err, + "AppDataUpdate proposal rejected: admitted_via tag is write-once" + ); + })?; + } + // Two dispatch shapes: // // - **Known component**: expand via the per-id `Component` impl @@ -1574,6 +1949,13 @@ 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 write to each of the two components the XIP-82 + // post-state invariant couples. 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; + let mut registry_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 +1972,166 @@ 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() + { + if component_id == ComponentId::EXTERNAL_COMMIT_POLICY { + policy_write = Some(payload.as_slice().to_vec()); + } else if component_id == ComponentId::COMPONENT_REGISTRY { + registry_write = Some(payload.as_slice().to_vec()); + } + } + } + + if let Some(policy_bytes) = policy_write { + validate_external_commit_policy_post_state( + &policy_bytes, + registry_write.as_deref(), + registry, + )?; } 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. +/// * Enabled ⇒ the **post-state** `GROUP_MEMBERSHIP` +/// `ComponentMetadata.external_committer_permissions` admits the +/// joiner-self-entry insert. Post-state means a `COMPONENT_REGISTRY` +/// write carried by the same commit counts — the enable commit is +/// REQUIRED to establish the grant when absent. +/// +/// Convergent by construction: both checks are pure functions of the +/// commit's own proposal payloads plus the pre-commit registry, which +/// every member shares. +fn validate_external_commit_policy_post_state( + policy_bytes: &[u8], + registry_write: Option<&[u8]>, + pre_registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry, +) -> Result<(), CommitValidationError> { + use super::external_commit_policy::{ + ExternalCommitPolicyError, grant_admits_joiner_insert, validate_policy_v1, + }; + use prost::Message; + use xmtp_mls_common::app_data::component_id::ComponentId; + use xmtp_mls_common::tls_map::{TlsMapDelta, TlsMapMutation}; + use xmtp_proto::xmtp::mls::message_contents::{ + ComponentMetadata, ExternalCommitPolicyEntry, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, + }; + + let malformed = |component_id, reason: String| { + CommitValidationError::ComponentSource( + super::app_data::component_source::ComponentSourceError::MalformedComponentValue { + component_id, + reason, + }, + ) + }; + + let entry = ExternalCommitPolicyEntry::decode(policy_bytes).map_err(|e| { + malformed( + ComponentId::EXTERNAL_COMMIT_POLICY, + 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)?; + + if !policy.allow_external_commit { + return Ok(()); + } + + // Post-state GROUP_MEMBERSHIP metadata: a registry write in the same + // commit supersedes the pre-commit registry entry (last write wins, + // matching apply order). A Delete of the GROUP_MEMBERSHIP entry in + // the same commit counts as post-state ABSENT — it must not fall + // back to the (stale) pre-commit grant. + enum InCommit { + Untouched, + Written(ComponentMetadata), + Deleted, + } + let in_commit_metadata = match registry_write { + Some(delta_bytes) => { + use tls_codec::Deserialize; + let delta = + TlsMapDelta::::tls_deserialize_exact(delta_bytes) + .map_err(|e| { + malformed( + ComponentId::COMPONENT_REGISTRY, + format!("registry delta decode: {e}"), + ) + })?; + delta + .mutations + .iter() + .rev() + .find_map(|mutation| match mutation { + TlsMapMutation::Insert { key, value } + | TlsMapMutation::Update { key, value } + if *key == ComponentId::GROUP_MEMBERSHIP => + { + Some(ComponentMetadata::decode(value.as_slice()).map(InCommit::Written)) + } + TlsMapMutation::Delete { key } if *key == ComponentId::GROUP_MEMBERSHIP => { + Some(Ok(InCommit::Deleted)) + } + _ => None, + }) + .transpose() + .map_err(|e| { + malformed( + ComponentId::GROUP_MEMBERSHIP, + format!("ComponentMetadata decode: {e}"), + ) + })? + .unwrap_or(InCommit::Untouched) + } + None => InCommit::Untouched, + }; + let membership_metadata = match in_commit_metadata { + InCommit::Written(meta) => Some(meta), + InCommit::Deleted => None, + InCommit::Untouched => pre_registry + .get(&ComponentId::GROUP_MEMBERSHIP) + .ok() + .flatten(), + }; + + if !grant_admits_joiner_insert( + membership_metadata + .as_ref() + .and_then(|m| m.external_committer_permissions.as_ref()), + ) { + return Err(ExternalCommitPolicyError::MissingMembershipGrant.into()); + } + Ok(()) +} + /// Extracts the [`CommitParticipant`] from the [`LeafNodeIndex`] pub(super) fn extract_commit_participant( leaf_index: &LeafNodeIndex, @@ -1620,7 +2149,17 @@ pub(super) fn extract_commit_participant( mutable_metadata, )) } else { - // TODO: Handle external joins/commits + // External joins/commits don't flow through this helper. The + // joiner's leaf is not in the tree at the pre-commit snapshot + // captured by `member_at`, so callers on the external-commit + // path build their participant directly from the staged + // commit's `update_path_leaf_node` via + // [`CommitParticipant::from_leaf_node`]. Reaching this branch + // means someone routed a `Sender::NewMemberCommit` proposal + // through the member-only validator, which is a programmer + // error rather than a peer-attributable failure — surface it + // as `ActorNotMember` so the caller treats the commit as + // rejected. Err(CommitValidationError::ActorNotMember) } } @@ -2274,6 +2813,1272 @@ impl FromWith for GroupUpdatedProto { } } +// ============================================================================= +// External-commit validator helpers (L-7). +// +// Pure functions extracted from `ValidatedCommit::from_external_commit` so the +// per-rule logic can be unit-tested without the considerable scaffolding +// required to construct a real `StagedCommit`. The orchestrator stays +// readable and the rules stay individually pinned. +// ============================================================================= + +/// Categorized view of the proposals carried by an external commit. +/// +/// Populated by [`collect_external_commit_proposals`]: a single pass over +/// `staged_commit.queued_proposals()` that fans out into typed buckets so +/// downstream rule-checks operate on Rust references rather than re-walking +/// the queue. +struct ExternalCommitProposalSummary<'a> { + /// Number of `ExternalInit` proposals seen. Must be exactly 1. + external_init_count: usize, + /// All Add proposals, by-value. + adds: Vec<&'a openmls::messages::proposals::AddProposal>, + /// The single AppDataUpdate proposal, if exactly one was present. + app_data_update: Option<&'a openmls::messages::proposals::AppDataUpdateProposal>, + /// Number of AppDataUpdate proposals seen — pulled out separately + /// so the structure check can distinguish "missing" vs "too many". + app_data_update_count: usize, + /// True if a `SelfRemove` proposal was seen — drives the + /// resync-not-supported rejection. + saw_self_remove: bool, + /// True if a `GroupContextExtensions` proposal was seen. + saw_gce: bool, + /// The proposal type of the first encountered "other" proposal + /// (PreSharedKey/Update/Remove/ReInit/Custom/AppEphemeral/_AppAck), + /// if any. Captured for inclusion in the + /// `ExternalCommitUnsupportedProposalType` error payload. PSKs are + /// forbidden per XIP-82: a non-member has no pre-shared key it can + /// legitimately reference with the group, so admitting them is + /// gratuitous attack surface in v1. + first_unsupported: Option, + /// True if any proposal carried `ProposalOrRefType::Reference`. + saw_by_reference: bool, +} + +impl<'a> ExternalCommitProposalSummary<'a> { + fn new() -> Self { + Self { + external_init_count: 0, + adds: Vec::new(), + app_data_update: None, + app_data_update_count: 0, + saw_self_remove: false, + saw_gce: false, + first_unsupported: None, + saw_by_reference: false, + } + } +} + +/// Reject if the group has not opted into accepting MLS External +/// Commits (XIP-82 check 7). This is the first state gate — +/// short-circuiting here means a denied-policy group never pays for +/// proposal-shape walks. +/// +/// `policy` is the decoded AppData-resident `EXTERNAL_COMMIT_POLICY` +/// entry (`None` = absent or unrecognized version). Fails closed on +/// anything but an explicit `allow_external_commit = true`; returns +/// the policy so the caller's later checks (time bounds, tag, +/// max_uses) read the same decoded value. +fn enforce_external_commit_policy( + policy: Option<&ExternalCommitPolicyV1>, +) -> Result<&ExternalCommitPolicyV1, CommitValidationError> { + match policy { + Some(policy) if policy.allow_external_commit => Ok(policy), + _ => Err(CommitValidationError::ExternalCommitNotAllowed), + } +} + +/// XIP-82 checks 8 + 9: the two envelope-timestamp time bounds. +/// +/// Check 8 — absolute campaign expiry: when `expires_at_ns` is set +/// (non-zero), the commit's envelope timestamp must not exceed it. +/// +/// Check 9 — per-epoch staleness: when `expire_in_ns` is non-zero, the +/// commit must land within `expire_in_ns` of the current epoch's +/// start. `saturating_sub` covers the benign skew case where the +/// epoch-start envelope was published marginally after the commit's +/// (cross-node clock skew): the age clamps to zero rather than +/// underflowing into an astronomically large value that would reject +/// every join. +fn enforce_external_commit_time_bounds( + policy: &ExternalCommitPolicyV1, + timestamps: &ExternalCommitTimestamps, +) -> Result<(), CommitValidationError> { + if policy.expires_at_ns != 0 && timestamps.commit_envelope_ns > policy.expires_at_ns { + return Err(CommitValidationError::ExternalCommitInviteExpired { + expires_at_ns: policy.expires_at_ns, + commit_envelope_ns: timestamps.commit_envelope_ns, + }); + } + if policy.expire_in_ns != 0 { + let age_ns = timestamps + .commit_envelope_ns + .saturating_sub(timestamps.epoch_started_at_ns); + if age_ns > policy.expire_in_ns { + return Err(CommitValidationError::ExternalCommitInviteStale { + age_ns, + expire_in_ns: policy.expire_in_ns, + }); + } + } + Ok(()) +} + +/// XIP-82 check 4: the joiner must not already be in the group — on +/// either layer of the cross-coupled membership state. An existing +/// member re-adding itself via external commit could rewrite (or +/// re-tag) its own membership entry; recovering a broken installation +/// is the deferred resync flavor, not v1. +fn enforce_joiner_not_already_member( + joiner_inbox_id: &str, + tree_inbox_ids: &HashSet, + membership: &std::collections::BTreeMap< + xmtp_mls_common::inbox_id::InboxId, + xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry, + >, +) -> Result<(), CommitValidationError> { + if tree_inbox_ids.contains(joiner_inbox_id) { + return Err(CommitValidationError::ExternalCommitJoinerAlreadyMember { + inbox_id: joiner_inbox_id.to_string(), + }); + } + // A credential whose inbox id doesn't parse as hex can't be a + // member of anything — but reject it explicitly rather than + // treating it as "not present". + let joiner_key = xmtp_mls_common::inbox_id::InboxId::from_hex(joiner_inbox_id) + .map_err(|_| CommitValidationError::InboxValidationFailed(joiner_inbox_id.to_string()))?; + if membership.contains_key(&joiner_key) { + return Err(CommitValidationError::ExternalCommitJoinerAlreadyMember { + inbox_id: joiner_inbox_id.to_string(), + }); + } + Ok(()) +} + +/// XIP-82 check 12: when the policy caps concurrent invited members +/// (`max_uses != 0`), the number of current `GROUP_MEMBERSHIP` entries +/// tagged with the active `external_group_id` must be strictly less +/// than the cap — the commit that would create the (`max_uses`+1)-th +/// concurrent invited member is rejected. Counted from the shared +/// pre-commit group state (never replayed from commit history) so +/// every member computes it identically. Removing a tagged member +/// frees its slot; the tag disappears with the entry. +fn enforce_max_uses( + policy: &ExternalCommitPolicyV1, + membership: &std::collections::BTreeMap< + xmtp_mls_common::inbox_id::InboxId, + xmtp_proto::xmtp::mls::message_contents::GroupMembershipEntry, + >, +) -> Result<(), CommitValidationError> { + use xmtp_proto::xmtp::mls::message_contents::group_membership_entry::Version; + + if policy.max_uses == 0 { + return Ok(()); + } + // Defensive: an enabled policy always carries a ≥4-byte + // external_group_id (write-time invariant), but if state predating + // that invariant slipped through, matching the empty id would + // count every Welcome-added member (whose tag is absent ≡ empty). + if policy.external_group_id.is_empty() { + return Ok(()); + } + let used = membership + .values() + .filter(|entry| match &entry.version { + Some(Version::V1(v1)) => v1.admitted_via_external_group_id == policy.external_group_id, + // `decode_group_membership_dict` rejects version-less + // entries before we get here; an unmatchable future + // variant counting as untagged is the conservative + // direction (undercounts, never blocks legitimate joins + // spuriously). + None => false, + }) + .count(); + if used >= policy.max_uses as usize { + return Err(CommitValidationError::ExternalCommitMaxUsesExhausted { + max_uses: policy.max_uses, + }); + } + Ok(()) +} + +/// Reject if the commit's framing sender is not `Sender::NewMemberCommit`. +/// Defensive: the wider message dispatch should route by sender before +/// reaching this validator. We also assert against `Sender::Member` here +/// so an attacker who somehow gets a member-authored commit dispatched +/// to the external path can't bypass the `Sender::Member` validator's +/// stricter membership/permission checks. +fn enforce_external_commit_sender(sender: &Sender) -> Result<(), CommitValidationError> { + match sender { + Sender::NewMemberCommit => Ok(()), + _ => Err(CommitValidationError::ExternalCommitNotNewMemberCommit), + } +} + +/// Single-pass categorization of every proposal in `staged_commit`. +/// +/// Returns an `ExternalCommitProposalSummary` whose buckets the +/// downstream rule-checks consume. Surfaces only one error of its own: +/// it never accepts a by-reference proposal (RFC 9420 §12.4.3.2) and +/// flags the first sighting so the caller can reject the commit +/// wholesale. +fn collect_external_commit_proposals( + staged_commit: &StagedCommit, +) -> Result, CommitValidationError> { + let mut summary = ExternalCommitProposalSummary::new(); + + for queued in staged_commit.queued_proposals() { + // Rule 4: no by-reference proposals. Captured here rather than + // in the structure check because the proposal_or_ref_type only + // exists on `QueuedProposal`, not on the post-categorization + // typed bucket — so it has to happen during the walk. + if matches!(queued.proposal_or_ref_type(), ProposalOrRefType::Reference) { + summary.saw_by_reference = true; + } + + match queued.proposal() { + Proposal::ExternalInit(_) => { + summary.external_init_count += 1; + } + Proposal::Add(add) => { + summary.adds.push(add.as_ref()); + } + Proposal::AppDataUpdate(app_data) => { + summary.app_data_update_count += 1; + if summary.app_data_update.is_none() { + summary.app_data_update = Some(app_data.as_ref()); + } + } + Proposal::SelfRemove => { + summary.saw_self_remove = true; + } + Proposal::GroupContextExtensions(_) => { + summary.saw_gce = true; + } + other => { + if summary.first_unsupported.is_none() { + summary.first_unsupported = Some(other.proposal_type()); + } + } + } + } + + Ok(summary) +} + +/// Apply rules 3, 8, 9, 10 against the categorized proposal summary. +/// +/// Order matters: we surface the most-specific failure first so error +/// messages and tests pin a single canonical reason per violation +/// shape. "Missing ExternalInit" is the strongest "this commit is not +/// an external commit at all" signal, so it comes ahead of "wrong +/// proposal type" failures. +fn enforce_external_commit_structure( + summary: &ExternalCommitProposalSummary<'_>, +) -> Result<(), CommitValidationError> { + // RFC 9420 §12.4.3.2: no by-reference proposals. + if summary.saw_by_reference { + return Err(CommitValidationError::ExternalCommitByReferenceProposalsForbidden); + } + + // ExternalInit count: exactly one. + match summary.external_init_count { + 0 => return Err(CommitValidationError::ExternalCommitMissingExternalInit), + 1 => {} + _ => return Err(CommitValidationError::ExternalCommitMultipleExternalInit), + } + + // Forbidden proposal kinds, in order of specificity. + if summary.saw_self_remove { + return Err(CommitValidationError::ResyncExternalCommitNotSupported); + } + if summary.saw_gce { + return Err(CommitValidationError::ExternalCommitGceForbidden); + } + if let Some(unsupported) = summary.first_unsupported { + return Err(CommitValidationError::ExternalCommitUnsupportedProposalType(unsupported)); + } + + // AppDataUpdate count: exactly one. + match summary.app_data_update_count { + 0 => return Err(CommitValidationError::ExternalCommitAppDataUpdateMissing), + 1 => {} + _ => return Err(CommitValidationError::ExternalCommitAppDataUpdateMultiple), + } + + Ok(()) +} + +/// Apply rule 6: every Add proposal's KeyPackage credential MUST carry +/// the same inbox id as the joiner's path leaf. +/// +/// Returns the set of installation ids added (signature keys from the +/// Add proposals' leaf nodes) for population into the resulting +/// `ValidatedCommit`. The path-leaf itself is the joiner's "primary" +/// installation; whether that signature key is also represented as an +/// Add proposal is up to the sender — we do not deduplicate here. +fn enforce_adds_bind_to_joiner( + summary: &ExternalCommitProposalSummary<'_>, + joiner_inbox_id: &str, +) -> Result>, CommitValidationError> { + let mut added_installations: HashSet> = HashSet::new(); + for add in &summary.adds { + let leaf = add.key_package().leaf_node(); + let inbox_id = inbox_id_from_credential(leaf.credential())?; + if inbox_id != joiner_inbox_id { + return Err(CommitValidationError::CrossInboxAddInExternalCommit); + } + added_installations.insert(leaf.signature_key().as_slice().to_vec()); + } + Ok(added_installations) +} + +/// Apply XIP-82 checks 5 + 11: the single AppDataUpdate proposal MUST +/// target the `GROUP_MEMBERSHIP` component, and its +/// `TlsMapDelta` payload MUST consist of exactly one +/// `Insert` of the joiner's own inbox id, whose entry value records +/// `admitted_via_external_group_id` equal to the active +/// `external_group_id`. +/// +/// Insert-only is not an extra restriction beyond the XIP's "modifying +/// only the joiner's entry": check 4 guarantees the joiner has no +/// existing entry, and `TlsMapDelta` apply semantics fail an +/// `Update`/`Delete` on a missing key — so any other mutation shape +/// could never merge anyway. Rejecting it here keeps the failure +/// structured and pre-merge on every member. A `Remove` operation +/// (wiping the entire component) is likewise not a join-time +/// operation. +/// +/// The joiner's `ActorAuthority` (non-admin, non-super-admin) is *not* +/// checked here against `validate_one_app_data_update`: the joiner's +/// write is admitted by the `external_committer_permissions` block +/// (check 10), the symmetric twin of the member-facing `permissions`, +/// and bounded to their own entry by this scope check. +fn enforce_app_data_update_scope( + proposal: &openmls::messages::proposals::AppDataUpdateProposal, + joiner_inbox_id: &str, + active_external_group_id: &[u8], +) -> Result<(), CommitValidationError> { + use tls_codec::Deserialize as TlsDeserialize; + use xmtp_mls_common::app_data::component_id::ComponentId; + use xmtp_mls_common::inbox_id::InboxId; + use xmtp_mls_common::tls_map::{TlsMapDelta, TlsMapMutation}; + use xmtp_proto::xmtp::mls::message_contents::{ + GroupMembershipEntry, group_membership_entry::Version as GroupMembershipEntryVersion, + }; + + if ComponentId::from(proposal.component_id()) != ComponentId::GROUP_MEMBERSHIP { + return Err(CommitValidationError::ExternalCommitAppDataUpdateWrongComponent); + } + + let payload = match proposal.operation() { + AppDataUpdateOperation::Update(bytes) => bytes, + AppDataUpdateOperation::Remove => { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + } + }; + + // Parse the delta. Treat any decoding failure as a wire-format + // violation rather than letting it surface as a silent "no + // mutations to check". + let delta = + TlsMapDelta::::tls_deserialize_exact(payload.as_slice()) + .map_err(|e| { + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(e.to_string()) + })?; + + // Parse the joiner's inbox-id string once and compare by raw + // bytes. We avoid re-encoding each mutation's key back to a hex + // string in the hot path. + let joiner_id = InboxId::from_hex(joiner_inbox_id).map_err(|e| { + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(format!( + "joiner inbox id is not valid hex: {e}" + )) + })?; + + // Exactly one mutation, and it must be the joiner's own Insert. + // (Covers the degenerate empty delta too — a joiner that didn't + // actually register themselves.) + let [TlsMapMutation::Insert { key, value }] = delta.mutations.as_slice() else { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + }; + if key != &joiner_id { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + } + + // Check 11: the inserted entry must record the invite it was + // admitted under. Required on every external commit — whatever + // `max_uses` is — so a later policy change to a finite cap starts + // from accurate data. Fail closed on an undecodable or + // unknown-version entry: we could not verify the tag, and an + // opaque blob in the membership map would hide the tag from every + // V1 reader's max_uses count. + let entry = GroupMembershipEntry::decode(value.as_slice()) + .map_err(|e| CommitValidationError::GroupMembershipEntryMalformed(e.to_string()))?; + let Some(GroupMembershipEntryVersion::V1(v1)) = entry.version else { + return Err(CommitValidationError::GroupMembershipEntryMalformed( + "unknown GroupMembershipEntry version".into(), + )); + }; + if v1.admitted_via_external_group_id != active_external_group_id { + return Err(CommitValidationError::ExternalCommitAdmittedViaTagMismatch); + } + + Ok(()) +} + +/// XIP-82 write-once enforcement for `admitted_via_external_group_id` +/// on member-sender `GROUP_MEMBERSHIP` writes: the tag is set exactly +/// once by the admitting external commit and is immutable for the life +/// of the entry. A member commit that sets, clears, or alters it — its +/// own entry included — is rejected; otherwise an invited member could +/// untag itself and free a `max_uses` slot at will. Entry rewrites for +/// unrelated reasons (an installation change bumping `sequence_id`, +/// say) must carry the tag through unchanged. Deleting an entry is the +/// one legal way its tag disappears (member removal frees the slot). +/// +/// Runs only on the member-sender validation path +/// ([`validate_one_app_data_update_with_old_value`]); the external +/// commit that legitimately *sets* the tag is validated by +/// [`enforce_app_data_update_scope`] instead and never reaches this +/// check. +fn enforce_admitted_via_write_once( + operation: &openmls::messages::proposals::AppDataUpdateOperation, + old_value: Option<&[u8]>, +) -> Result<(), CommitValidationError> { + use tls_codec::Deserialize as TlsDeserialize; + use xmtp_mls_common::app_data::migration::decode_group_membership_dict; + use xmtp_mls_common::inbox_id::InboxId; + use xmtp_mls_common::tls_map::{TlsMapDelta, TlsMapMutation}; + use xmtp_proto::xmtp::mls::message_contents::{ + GroupMembershipEntry, group_membership_entry::Version as GroupMembershipEntryVersion, + }; + + let AppDataUpdateOperation::Update(payload) = operation else { + // `Remove` wipes the whole component — every entry (and its + // tag) disappears together, the same way a single entry's tag + // goes away with a Delete mutation. Whether the wipe itself is + // permitted is the registry `delete_policy`'s call, not this + // check's. + return Ok(()); + }; + + let delta = + TlsMapDelta::::tls_deserialize_exact(payload.as_slice()) + .map_err(|e| { + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(e.to_string()) + })?; + + // Decode the pre-commit entries the mutations rewrite. Fail closed + // on malformed prior state: this is consensus state produced by + // validated commits, so a decode failure means something is deeply + // wrong — skipping the check would be the only way a tag rewrite + // could slip through. + let old_entries = match old_value { + Some(bytes) => decode_group_membership_dict(bytes) + .map_err(|e| CommitValidationError::GroupMembershipEntryMalformed(e.to_string()))?, + None => Default::default(), + }; + + for mutation in &delta.mutations { + let (key, new_value) = match mutation { + TlsMapMutation::Insert { key, value } | TlsMapMutation::Update { key, value } => { + (key, value) + } + TlsMapMutation::Delete { .. } => continue, + }; + // Fail closed on an undecodable or unknown-version new value: + // accepting an opaque blob over a tagged V1 entry would hide + // the tag from every V1 reader and free the max_uses slot. + let new_entry = GroupMembershipEntry::decode(new_value.as_slice()) + .map_err(|e| CommitValidationError::GroupMembershipEntryMalformed(e.to_string()))?; + let Some(GroupMembershipEntryVersion::V1(new_v1)) = new_entry.version else { + return Err(CommitValidationError::GroupMembershipEntryMalformed( + "unknown GroupMembershipEntry version".into(), + )); + }; + // Absent tag ≡ empty bytes (proto3 scalar default — there is + // exactly one cleared encoding), so a fresh Insert compares + // against empty: members may only ever add untagged entries. + let old_tag: &[u8] = old_entries + .get(key) + .and_then(|entry| entry.version.as_ref()) + .map(|GroupMembershipEntryVersion::V1(v1)| v1.admitted_via_external_group_id.as_slice()) + .unwrap_or(&[]); + if new_v1.admitted_via_external_group_id != old_tag { + return Err(CommitValidationError::AdmittedViaTagImmutable { + inbox_id: key.to_hex(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod external_commit_validator_tests { + //! Pins the rule-by-rule behavior of + //! [`ValidatedCommit::from_external_commit`]. Each helper is exercised + //! directly so the tests don't have to construct a real + //! `StagedCommit` (which requires a full MLS group, identity, and + //! crypto provider). The orchestrator function itself is exercised + //! indirectly via integration tests in L-8/L-10/L-11. + use super::*; + use openmls::messages::proposals::AppDataUpdateProposal; + use std::collections::BTreeMap; + use tls_codec::{Serialize as TlsSerialize, VLBytes}; + use xmtp_mls_common::app_data::component_id::ComponentId as XmtpComponentId; + use xmtp_mls_common::inbox_id::{INBOX_ID_BYTE_LEN, InboxId}; + use xmtp_mls_common::tls_map::TlsMapDelta; + use xmtp_proto::xmtp::mls::message_contents::{ + GroupMembershipEntry, SymmetricKey, + group_membership_entry::{V1 as MembershipEntryV1, Version as MembershipEntryVersion}, + }; + + /// The active invite slot id used across these tests. + const ACTIVE_SLOT: &[u8] = b"slot-aaaa"; + + /// Build a hex inbox-id string with a stable seed byte so different + /// test inboxes are easy to compare visually. + fn make_inbox_id_hex(seed: u8) -> String { + hex::encode([seed; INBOX_ID_BYTE_LEN]) + } + + fn make_inbox_id(seed: u8) -> InboxId { + InboxId::from_bytes([seed; INBOX_ID_BYTE_LEN]) + } + + /// An enabled policy with the canonical coordinates these tests + /// assert against. Field-coupling-valid per `validate_policy_v1`. + fn enabled_policy() -> ExternalCommitPolicyV1 { + ExternalCommitPolicyV1 { + allow_external_commit: true, + symmetric_key: Some(SymmetricKey { + material: vec![7u8; 32], + }), + external_group_id: ACTIVE_SLOT.to_vec(), + ..Default::default() + } + } + + /// Encode a `GroupMembershipEntry::V1` value with the given + /// admitted-via tag (empty = untagged / Welcome-added). + fn entry_bytes(tag: &[u8]) -> Vec { + GroupMembershipEntry { + version: Some(MembershipEntryVersion::V1(MembershipEntryV1 { + sequence_id: 1, + failed_installations: vec![], + admitted_via_external_group_id: tag.to_vec(), + })), + } + .encode_to_vec() + } + + /// A decoded membership map with one entry per `(seed, tag)`. + fn membership_map(entries: &[(u8, &[u8])]) -> BTreeMap { + entries + .iter() + .map(|(seed, tag)| { + ( + make_inbox_id(*seed), + GroupMembershipEntry::decode(entry_bytes(tag).as_slice()).expect("decode"), + ) + }) + .collect() + } + + /// Encode a `TlsMapDelta` of `(InboxId, VLBytes)` mutations to the + /// wire payload an `AppDataUpdate::Update` proposal carries. + fn encode_membership_delta(delta: &TlsMapDelta) -> Vec { + delta.tls_serialize_detached().expect("delta serialize") + } + + /// Build an `AppDataUpdate(GROUP_MEMBERSHIP, Update())` + /// proposal — the canonical shape produced by the L-10/L-11 sender: + /// one Insert of the joiner's entry, tagged with the active slot. + fn well_formed_membership_update_for(joiner: InboxId) -> AppDataUpdateProposal { + let entry: VLBytes = entry_bytes(ACTIVE_SLOT).into(); + let delta = TlsMapDelta::::new().insert(joiner, entry); + let bytes = encode_membership_delta(&delta); + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes) + } + + // ── enforce_external_commit_policy ─────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_when_allow_external_commit_is_false() { + let policy = ExternalCommitPolicyV1::default(); + let err = enforce_external_commit_policy(Some(&policy)) + .expect_err("disabled policy must reject external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotAllowed + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_when_policy_absent() { + let err = enforce_external_commit_policy(None) + .expect_err("absent policy component must fail closed"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotAllowed + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_when_allow_external_commit_is_true() { + let policy = enabled_policy(); + assert!(enforce_external_commit_policy(Some(&policy)).is_ok()); + } + + // ── enforce_external_commit_time_bounds ────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_when_no_time_bounds_set() { + // expires_at_ns == 0 and expire_in_ns == 0: no bound applies, + // whatever the envelope timestamps say. + let policy = enabled_policy(); + let ts = ExternalCommitTimestamps { + commit_envelope_ns: u64::MAX, + epoch_started_at_ns: 0, + }; + assert!(enforce_external_commit_time_bounds(&policy, &ts).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_commit_past_absolute_expiry() { + let policy = ExternalCommitPolicyV1 { + expires_at_ns: 1_000, + ..enabled_policy() + }; + let ts = ExternalCommitTimestamps { + commit_envelope_ns: 1_001, + epoch_started_at_ns: 0, + }; + let err = enforce_external_commit_time_bounds(&policy, &ts) + .expect_err("commit envelope past expires_at_ns must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitInviteExpired { + expires_at_ns: 1_000, + commit_envelope_ns: 1_001, + } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_commit_at_exact_absolute_expiry() { + // Bound is "does not exceed": equality is still inside. + let policy = ExternalCommitPolicyV1 { + expires_at_ns: 1_000, + ..enabled_policy() + }; + let ts = ExternalCommitTimestamps { + commit_envelope_ns: 1_000, + epoch_started_at_ns: 0, + }; + assert!(enforce_external_commit_time_bounds(&policy, &ts).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_commit_past_staleness_window() { + let policy = ExternalCommitPolicyV1 { + expire_in_ns: 500, + ..enabled_policy() + }; + let ts = ExternalCommitTimestamps { + commit_envelope_ns: 2_000, + epoch_started_at_ns: 1_000, + }; + let err = enforce_external_commit_time_bounds(&policy, &ts) + .expect_err("commit landing past epoch_start + expire_in_ns must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitInviteStale { + age_ns: 1_000, + expire_in_ns: 500, + } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_commit_within_staleness_window() { + let policy = ExternalCommitPolicyV1 { + expire_in_ns: 500, + ..enabled_policy() + }; + let ts = ExternalCommitTimestamps { + commit_envelope_ns: 1_400, + epoch_started_at_ns: 1_000, + }; + assert!(enforce_external_commit_time_bounds(&policy, &ts).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn staleness_age_clamps_on_publish_latency_skew() { + // The epoch-start envelope can postdate the commit's by publish + // latency; the age clamps to zero instead of underflowing into + // a rejection. + let policy = ExternalCommitPolicyV1 { + expire_in_ns: 500, + ..enabled_policy() + }; + let ts = ExternalCommitTimestamps { + commit_envelope_ns: 999, + epoch_started_at_ns: 1_000, + }; + assert!(enforce_external_commit_time_bounds(&policy, &ts).is_ok()); + } + + // ── enforce_joiner_not_already_member ──────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_joiner_with_existing_tree_leaf() { + let joiner_hex = make_inbox_id_hex(0x11); + let tree: HashSet = [joiner_hex.clone()].into(); + let membership = membership_map(&[]); + let err = enforce_joiner_not_already_member(&joiner_hex, &tree, &membership) + .expect_err("existing tree leaf must reject the re-join"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitJoinerAlreadyMember { .. } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_joiner_with_existing_membership_entry() { + let joiner_hex = make_inbox_id_hex(0x11); + let tree: HashSet = HashSet::new(); + let membership = membership_map(&[(0x11, b"")]); + let err = enforce_joiner_not_already_member(&joiner_hex, &tree, &membership) + .expect_err("existing GROUP_MEMBERSHIP entry must reject the re-join"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitJoinerAlreadyMember { .. } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_genuinely_new_joiner() { + let joiner_hex = make_inbox_id_hex(0x11); + let tree: HashSet = [make_inbox_id_hex(0x22)].into(); + let membership = membership_map(&[(0x22, b"")]); + assert!(enforce_joiner_not_already_member(&joiner_hex, &tree, &membership).is_ok()); + } + + // ── enforce_max_uses ───────────────────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn max_uses_zero_is_unlimited() { + let policy = enabled_policy(); + let membership = membership_map(&[(0x11, ACTIVE_SLOT), (0x22, ACTIVE_SLOT)]); + assert!(enforce_max_uses(&policy, &membership).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn max_uses_admits_below_cap() { + let policy = ExternalCommitPolicyV1 { + max_uses: 2, + ..enabled_policy() + }; + let membership = membership_map(&[(0x11, ACTIVE_SLOT)]); + assert!(enforce_max_uses(&policy, &membership).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn max_uses_rejects_at_cap() { + let policy = ExternalCommitPolicyV1 { + max_uses: 2, + ..enabled_policy() + }; + let membership = membership_map(&[(0x11, ACTIVE_SLOT), (0x22, ACTIVE_SLOT)]); + let err = enforce_max_uses(&policy, &membership) + .expect_err("the (max_uses+1)-th concurrent invited member must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitMaxUsesExhausted { max_uses: 2 } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn max_uses_ignores_untagged_and_other_slot_entries() { + // Welcome-added members (empty tag) and members admitted under + // a previous, rotated slot don't consume the active cap. + let policy = ExternalCommitPolicyV1 { + max_uses: 2, + ..enabled_policy() + }; + let membership = membership_map(&[(0x11, b""), (0x22, b"slot-old"), (0x33, ACTIVE_SLOT)]); + assert!(enforce_max_uses(&policy, &membership).is_ok()); + } + + // ── enforce_external_commit_sender ─────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_new_member_commit_sender() { + assert!(enforce_external_commit_sender(&Sender::NewMemberCommit).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_member_sender() { + let err = enforce_external_commit_sender(&Sender::Member(LeafNodeIndex::new(0))) + .expect_err("Sender::Member must be rejected on the external path"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotNewMemberCommit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_new_member_proposal_sender() { + let err = enforce_external_commit_sender(&Sender::NewMemberProposal) + .expect_err("Sender::NewMemberProposal must be rejected on the external path"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotNewMemberCommit + )); + } + + // ── enforce_external_commit_structure ──────────────────────────── + + fn summary_for_structure_test() -> ExternalCommitProposalSummary<'static> { + // We don't need real proposal references for the structure + // check — the counts and flags are what gate the verdict. The + // `adds` and `app_data_update` fields stay empty; the + // structure check only looks at counts. + ExternalCommitProposalSummary::new() + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_without_external_init_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 0; + summary.app_data_update_count = 1; + let err = enforce_external_commit_structure(&summary).expect_err("0 ExternalInit"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitMissingExternalInit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_two_external_init_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 2; + summary.app_data_update_count = 1; + let err = enforce_external_commit_structure(&summary).expect_err("2 ExternalInit"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitMultipleExternalInit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_by_reference_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_by_reference = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("by-reference proposals must be rejected (RFC 9420 §12.4.3.2)"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitByReferenceProposalsForbidden + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_self_remove_proposal_resync_flavor() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_self_remove = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("resync flavor must be rejected"); + assert!(matches!( + err, + CommitValidationError::ResyncExternalCommitNotSupported + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_group_context_extensions_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_gce = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("GCE proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitGceForbidden + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_update_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.first_unsupported = Some(ProposalType::Update); + let err = enforce_external_commit_structure(&summary) + .expect_err("Update proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitUnsupportedProposalType(ProposalType::Update) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_remove_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.first_unsupported = Some(ProposalType::Remove); + let err = enforce_external_commit_structure(&summary) + .expect_err("Remove proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitUnsupportedProposalType(ProposalType::Remove) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_when_no_app_data_update() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 0; + let err = enforce_external_commit_structure(&summary) + .expect_err("missing AppDataUpdate must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateMissing + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_two_app_data_update_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 2; + let err = enforce_external_commit_structure(&summary) + .expect_err("multiple AppDataUpdates must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateMultiple + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_canonical_external_commit_shape() { + // 1 ExternalInit + 1 AppDataUpdate + 0 unsupported + 0 ref → ok + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + assert!(enforce_external_commit_structure(&summary).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_psk_proposal() { + // XIP-82: PSKs are forbidden in external commits — a non-member + // has no pre-shared key it can legitimately reference with the + // group. They land in `first_unsupported` like any other + // illegal proposal type. + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.first_unsupported = Some(ProposalType::PreSharedKey); + let err = enforce_external_commit_structure(&summary) + .expect_err("PSK proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitUnsupportedProposalType( + ProposalType::PreSharedKey + ) + )); + } + + // ── enforce_app_data_update_scope ──────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_app_data_update_scoped_to_joiner() { + let joiner_hex = make_inbox_id_hex(0x11); + let proposal = well_formed_membership_update_for(make_inbox_id(0x11)); + assert!(enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_for_other_inbox() { + let joiner_hex = make_inbox_id_hex(0x11); + // Insert someone else's entry — scope mismatch. + let proposal = well_formed_membership_update_for(make_inbox_id(0x22)); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("delta keyed by a different inbox must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_wrong_component() { + let joiner_hex = make_inbox_id_hex(0x11); + // Same delta payload but on COMPONENT_REGISTRY — wrong component. + let entry: VLBytes = entry_bytes(ACTIVE_SLOT).into(); + let delta = TlsMapDelta::::new().insert(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::COMPONENT_REGISTRY.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("non-GROUP_MEMBERSHIP target must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateWrongComponent + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_remove_op() { + let joiner_hex = make_inbox_id_hex(0x11); + let proposal = AppDataUpdateProposal::remove(XmtpComponentId::GROUP_MEMBERSHIP.as_u16()); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("Remove op on GROUP_MEMBERSHIP must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_empty_delta() { + let joiner_hex = make_inbox_id_hex(0x11); + let delta = TlsMapDelta::::new(); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("empty delta must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_malformed_payload() { + let joiner_hex = make_inbox_id_hex(0x11); + // Two truncation bytes — not a valid TlsMapDelta. + let proposal = AppDataUpdateProposal::update( + XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), + vec![0xffu8, 0x00], + ); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("malformed payload must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(_) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_mixed_inbox_mutations() { + let joiner_hex = make_inbox_id_hex(0x11); + let entry: VLBytes = entry_bytes(ACTIVE_SLOT).into(); + // Mutation 1: joiner's own entry (legal in isolation). + // Mutation 2: someone else's entry. Two mutations also trip the + // exactly-one rule on their own. + let delta = TlsMapDelta::::new() + .insert(make_inbox_id(0x11), entry.clone()) + .insert(make_inbox_id(0x99), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("any non-joiner mutation must trip the scope check"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_non_insert_mutation() { + let joiner_hex = make_inbox_id_hex(0x11); + // An Update mutation could never merge anyway (check 4 + // guarantees no existing entry, and TlsMap Update fails on a + // missing key) — the validator rejects it structurally. + let entry: VLBytes = entry_bytes(ACTIVE_SLOT).into(); + let delta = TlsMapDelta::::new().update(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("non-Insert mutation must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_untagged_joiner_entry() { + // Check 11: the tag is required on every external commit, even + // when max_uses is 0 — an untagged entry would start a future + // finite-cap policy from an undercount. + let joiner_hex = make_inbox_id_hex(0x11); + let entry: VLBytes = entry_bytes(b"").into(); + let delta = TlsMapDelta::::new().insert(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("entry without the admitted_via tag must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAdmittedViaTagMismatch + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_wrong_slot_tag() { + // A tag naming a rotated / different slot doesn't satisfy + // check 11 — only the active external_group_id does. + let joiner_hex = make_inbox_id_hex(0x11); + let entry: VLBytes = entry_bytes(b"slot-old").into(); + let delta = TlsMapDelta::::new().insert(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("entry tagged with a non-active slot must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAdmittedViaTagMismatch + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_unknown_version_joiner_entry() { + // An empty value decodes as GroupMembershipEntry { version: + // None } — an unverifiable entry must fail closed. + let joiner_hex = make_inbox_id_hex(0x11); + let entry: VLBytes = Vec::::new().into(); + let delta = TlsMapDelta::::new().insert(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex, ACTIVE_SLOT) + .expect_err("version-less membership entry must fail closed"); + assert!(matches!( + err, + CommitValidationError::GroupMembershipEntryMalformed(_) + )); + } + + // ── enforce_admitted_via_write_once ────────────────────────────── + + /// Serialize a full membership map to the stored-bytes shape + /// `old_value` carries (TLS-serialized `TlsMap`). + fn membership_map_bytes(entries: &[(u8, &[u8])]) -> Vec { + use xmtp_mls_common::tls_map::TlsMap; + let map: TlsMap = entries + .iter() + .map(|(seed, tag)| (make_inbox_id(*seed), VLBytes::new(entry_bytes(tag)))) + .collect(); + map.tls_serialize_detached().expect("map serialize") + } + + fn member_update_op(mutations: TlsMapDelta) -> AppDataUpdateOperation { + AppDataUpdateOperation::Update(encode_membership_delta(&mutations).into()) + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_accepts_tag_carried_through() { + // The canonical sequence-bump rewrite: same tag, new payload. + let old = membership_map_bytes(&[(0x11, ACTIVE_SLOT)]); + let delta = TlsMapDelta::::new() + .update(make_inbox_id(0x11), entry_bytes(ACTIVE_SLOT).into()); + assert!(enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_accepts_untagged_rewrite() { + let old = membership_map_bytes(&[(0x11, b"")]); + let delta = TlsMapDelta::::new() + .update(make_inbox_id(0x11), entry_bytes(b"").into()); + assert!(enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_rejects_tag_clear() { + // The headline attack: an invited member untagging itself to + // free a max_uses slot. + let old = membership_map_bytes(&[(0x11, ACTIVE_SLOT)]); + let delta = TlsMapDelta::::new() + .update(make_inbox_id(0x11), entry_bytes(b"").into()); + let err = enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)) + .expect_err("clearing the tag must be rejected"); + assert!(matches!( + err, + CommitValidationError::AdmittedViaTagImmutable { .. } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_rejects_tag_set_by_member() { + // Members may only ever add untagged entries — tagging is the + // external commit's exclusive move. + let old = membership_map_bytes(&[(0x11, b"")]); + let delta = TlsMapDelta::::new() + .insert(make_inbox_id(0x22), entry_bytes(ACTIVE_SLOT).into()); + let err = enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)) + .expect_err("a member setting a tag on a fresh entry must be rejected"); + assert!(matches!( + err, + CommitValidationError::AdmittedViaTagImmutable { .. } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_rejects_tag_alteration() { + let old = membership_map_bytes(&[(0x11, ACTIVE_SLOT)]); + let delta = TlsMapDelta::::new() + .update(make_inbox_id(0x11), entry_bytes(b"slot-bbbb").into()); + let err = enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)) + .expect_err("altering the tag must be rejected"); + assert!(matches!( + err, + CommitValidationError::AdmittedViaTagImmutable { .. } + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_allows_entry_delete() { + // Member removal is the one legal way a tag disappears — it + // frees the max_uses slot by design. + let old = membership_map_bytes(&[(0x11, ACTIVE_SLOT)]); + let delta = TlsMapDelta::::new().delete(make_inbox_id(0x11)); + assert!(enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn write_once_rejects_unknown_version_rewrite() { + // Replacing a tagged V1 entry with an opaque unknown-version + // blob would hide the tag from every V1 reader — fail closed. + let old = membership_map_bytes(&[(0x11, ACTIVE_SLOT)]); + let delta = TlsMapDelta::::new() + .update(make_inbox_id(0x11), Vec::::new().into()); + let err = enforce_admitted_via_write_once(&member_update_op(delta), Some(&old)) + .expect_err("version-less rewrite of a tagged entry must fail closed"); + assert!(matches!( + err, + CommitValidationError::GroupMembershipEntryMalformed(_) + )); + } +} + #[cfg(test)] mod permission_on_receive_tests { //! Pins the receive-side permission check on `AppDataUpdate` diff --git a/crates/xmtp_mls_common/Cargo.toml b/crates/xmtp_mls_common/Cargo.toml index 1d9c6ee27a..c613d70e1c 100644 --- a/crates/xmtp_mls_common/Cargo.toml +++ b/crates/xmtp_mls_common/Cargo.toml @@ -22,6 +22,7 @@ prost.workspace = true serde.workspace = true thiserror.workspace = true tls_codec.workspace = true +url.workspace = true xmtp-workspace-hack.workspace = true xmtp_common.workspace = true xmtp_configuration.workspace = true 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) === diff --git a/crates/xmtp_mls_common/src/app_data/migration.rs b/crates/xmtp_mls_common/src/app_data/migration.rs index 21c4a9c172..9e9e803e68 100644 --- a/crates/xmtp_mls_common/src/app_data/migration.rs +++ b/crates/xmtp_mls_common/src/app_data/migration.rs @@ -1169,6 +1169,7 @@ mod tests { GroupMembershipEntryV1 { sequence_id: 42, failed_installations: vec![vec![0xAA; 16]], + admitted_via_external_group_id: vec![], }, ); entries.insert( @@ -1176,6 +1177,7 @@ mod tests { GroupMembershipEntryV1 { sequence_id: 99, failed_installations: vec![], + admitted_via_external_group_id: vec![], }, ); let entries = entries @@ -1257,6 +1259,7 @@ mod tests { version: Some(GroupMembershipEntryVersion::V1(GroupMembershipEntryV1 { sequence_id: 7, failed_installations: vec![], + admitted_via_external_group_id: vec![], })), }; snapshot diff --git a/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs b/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs new file mode 100644 index 0000000000..8f16e8ea17 --- /dev/null +++ b/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs @@ -0,0 +1,580 @@ +//! Encryption/decryption helpers for GroupInfo blobs stored on an external +//! service as part of the QR-invite flow. The blob envelope is the proto +//! [`EncryptedGroupInfoBlob`]; the underlying AEAD is ChaCha20Poly1305 via +//! [`payload_encryption::wrap_payload_symmetric`]. +//! +//! [`wrap_group_info`] is a builder that generates a fresh nonce for every +//! call by default — callers MUST NOT reuse a `(key, nonce)` pair across +//! distinct ciphertexts (the AEAD security argument collapses otherwise). +//! Tests and explicit nonce management scenarios may pass `.nonce(...)` to +//! override. +//! +//! The blob's cleartext metadata (epoch, group_state_hash, expires_at_ns) +//! is supplied by the caller because computing it requires the live +//! `MlsGroup` (epoch + tree hash) and an admin policy decision (expiry). +//! These fields are not derivable inside the pure-codec helpers. They travel +//! in the clear but are bound into the AEAD as associated data (see +//! [`blob_aad`]), so tampering with any of them — e.g. resetting +//! `expires_at_ns` to `0` to defeat expiry — is rejected at unwrap instead of +//! being silently trusted. +//! +//! [`payload_encryption::wrap_payload_symmetric`]: crate::mls_ext::payload_encryption::wrap_payload_symmetric +//! [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob + +use thiserror::Error; +use xmtp_proto::xmtp::mls::message_contents::{ + EncryptedGroupInfoBlob, EncryptedGroupInfoBlobV1, GroupStateHash, + encrypted_group_info_blob::Version as EncryptedGroupInfoBlobVersion, +}; + +use crate::invite::payload::NONCE_LEN; +use crate::mls_ext::payload_encryption::{ + UnwrapPayloadError, WrapPayloadError, unwrap_payload_symmetric, wrap_payload_symmetric, +}; + +/// Length in bytes of `GroupStateHash.digest`: the output length of the hash +/// bound to the group's MLS ciphersuite (32 bytes under XMTP's current +/// ciphersuite). The submessage does not constrain length, so wrap and +/// unwrap both enforce it here. +pub const GROUP_STATE_HASH_LEN: usize = 32; + +/// Errors returned by [`wrap_group_info`] and [`unwrap_group_info`]. +#[derive(Debug, Error)] +pub enum EncryptedGroupInfoError { + /// The blob's `version` oneof carries a variant this build does not + /// recognize, or is unset entirely. + #[error("unsupported or missing EncryptedGroupInfoBlob version")] + UnsupportedVersion, + /// The blob's `nonce` field had a length other than [`NONCE_LEN`] bytes. + #[error("nonce must be exactly {NONCE_LEN} bytes (got {0})")] + InvalidNonceLength(usize), + /// The blob's `group_state_hash` submessage was absent. + #[error("group_state_hash is required on an EncryptedGroupInfoBlob")] + MissingGroupStateHash, + /// `group_state_hash.digest` had a length other than + /// [`GROUP_STATE_HASH_LEN`] bytes. + #[error("group_state_hash.digest must be exactly {GROUP_STATE_HASH_LEN} bytes (got {0})")] + InvalidGroupStateHashLength(usize), + /// The blob's `expires_at_ns` is non-zero and `<= now_ns`. + #[error("blob expired at {expires_at_ns} ns; current time {now_ns} ns")] + Expired { + /// Wall-clock expiry encoded in the blob. + expires_at_ns: u64, + /// Wall-clock time the caller used for the check. + now_ns: u64, + }, + /// The underlying AEAD wrap step failed. + #[error("wrap failed: {0}")] + Wrap(#[from] WrapPayloadError), + /// The underlying AEAD unwrap step failed (wrong key, tampered ciphertext, + /// etc.). + #[error("unwrap failed: {0}")] + Unwrap(#[from] UnwrapPayloadError), +} + +/// Canonical associated-data encoding binding the blob's cleartext metadata +/// (`epoch`, `expires_at_ns`, `group_state_hash`) to the ciphertext. The same +/// bytes are fed to the AEAD at wrap and unwrap time, so tampering with any of +/// these envelope fields makes [`unwrap_group_info`] reject the blob. +/// +/// The layout is pinned by the proto / XIP-82: `epoch` and `expires_at_ns` as +/// 8-byte big-endian, then the digest bytes. The fixed-width fields come +/// first and the digest is the remainder, so the encoding is unambiguous +/// without a length prefix (digest length is itself enforced to +/// [`GROUP_STATE_HASH_LEN`]). +fn blob_aad(epoch: u64, expires_at_ns: u64, group_state_hash: &[u8]) -> Vec { + let mut aad = Vec::with_capacity(8 + 8 + group_state_hash.len()); + aad.extend_from_slice(&epoch.to_be_bytes()); + aad.extend_from_slice(&expires_at_ns.to_be_bytes()); + aad.extend_from_slice(group_state_hash); + aad +} + +/// Compute a blob's **effective** `expires_at_ns` from the two policy bounds +/// that apply at wrap time: the earlier of the policy's absolute campaign +/// expiry (`policy_expires_at_ns`) and the staleness deadline +/// (`epoch_began_at_ns + expire_in_ns`, saturating). A bound of `0` means +/// "no bound" and drops out of the min; the result is `0` only when neither +/// bound is set. +/// +/// Folding the staleness bound into the blob's single expiry field means the +/// joiner's one expiry check also skips candidates that validators would +/// reject as stale (no "zombie joins"), and the service's TTL-based GC +/// naturally collects staleness-dead blobs. +/// +/// * `policy_expires_at_ns` — `EXTERNAL_COMMIT_POLICY.expires_at_ns`. +/// * `epoch_began_at_ns` — delivery-service envelope timestamp of the commit +/// that began the wrapped GroupInfo's epoch. +/// * `expire_in_ns` — `EXTERNAL_COMMIT_POLICY.expire_in_ns`. +pub fn effective_expires_at_ns( + policy_expires_at_ns: u64, + epoch_began_at_ns: u64, + expire_in_ns: u64, +) -> u64 { + let staleness_deadline = if expire_in_ns == 0 { + 0 + } else { + epoch_began_at_ns.saturating_add(expire_in_ns) + }; + match (policy_expires_at_ns, staleness_deadline) { + (0, deadline) => deadline, + (campaign, 0) => campaign, + (campaign, deadline) => campaign.min(deadline), + } +} + +/// Wrap plaintext bytes (a TLS-serialized `MlsMessageOut(GroupInfo)`) into an +/// [`EncryptedGroupInfoBlob`] using the provided symmetric key. +/// +/// `epoch` and `group_state_hash` are required envelope metadata; both are +/// `u64`s by-name to make a swap with `expires_at_ns` a builder-method error +/// rather than a silent positional mistake. `nonce` defaults to a fresh +/// CSPRNG-generated nonce per call (the ChaCha20Poly1305 nonce-uniqueness +/// requirement); tests with deterministic-nonce needs may override via +/// `.nonce(...)`. `expires_at_ns` defaults to `0` (no expiry). +/// +/// * `epoch` — current MLS epoch of the GroupInfo being wrapped. Joiner-side +/// metadata (prefer the freshest candidate; consistency-check the decrypted +/// GroupInfo) — a conformant service never orders or evicts by it. +/// * `group_state_hash` — digest of the wrapped GroupInfo's epoch state +/// (`digest(GroupContext)` under the group's ciphersuite); exactly +/// [`GROUP_STATE_HASH_LEN`] bytes. The joiner verifies it against the +/// decrypted GroupInfo; a member uses it to recognise an idempotent +/// re-upload. +/// * `expires_at_ns` — the blob's *effective* wall-clock expiry. `0` means no +/// expiry. Callers fold the policy's staleness bound in via +/// [`effective_expires_at_ns`]. +/// * `nonce` — explicit nonce. ChaCha20Poly1305 security requires that no +/// `(key, nonce)` pair ever encrypts two distinct ciphertexts. Default +/// behavior calls [`crate::invite::payload::generate_nonce`] per call. +/// +/// # Example +/// +/// ```ignore +/// // Default usage — fresh nonce, no expiry: +/// let blob = wrap_group_info() +/// .plaintext(&group_info_bytes) +/// .key(&symmetric_key) +/// .epoch(group.epoch()) +/// .group_state_hash(group.epoch_authenticator()?.to_vec()) +/// .call()?; +/// +/// // With expiry + deterministic nonce (tests): +/// let blob = wrap_group_info() +/// .plaintext(&group_info_bytes) +/// .key(&symmetric_key) +/// .nonce(deterministic_nonce) +/// .epoch(7) +/// .group_state_hash(state_hash) +/// .expires_at_ns(deadline_ns) +/// .call()?; +/// ``` +/// +/// [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob +#[bon::builder] +pub fn wrap_group_info( + plaintext: &[u8], + key: &[u8; 32], + #[builder(default = crate::invite::payload::generate_nonce())] nonce: [u8; NONCE_LEN], + epoch: u64, + group_state_hash: Vec, + #[builder(default = 0)] expires_at_ns: u64, +) -> Result { + if group_state_hash.len() != GROUP_STATE_HASH_LEN { + return Err(EncryptedGroupInfoError::InvalidGroupStateHashLength( + group_state_hash.len(), + )); + } + let aad = blob_aad(epoch, expires_at_ns, &group_state_hash); + let ciphertext = wrap_payload_symmetric() + .data(plaintext) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(key) + .nonce(&nonce) + .aad(&aad) + .call()?; + + Ok(EncryptedGroupInfoBlob { + version: Some(EncryptedGroupInfoBlobVersion::V1( + EncryptedGroupInfoBlobV1 { + nonce: nonce.to_vec(), + ciphertext, + epoch, + group_state_hash: Some(GroupStateHash { + digest: group_state_hash, + }), + expires_at_ns, + }, + )), + }) +} + +/// Unwrap an [`EncryptedGroupInfoBlob`] using the symmetric key. Verifies the +/// envelope version, nonce length, and (when `now_ns` is `Some`) wall-clock +/// expiry before decryption. +/// +/// Returns the plaintext + a borrowed reference to the unwrapped V1 envelope +/// so callers can inspect `epoch` / `group_state_hash` after decryption. +/// +/// * [`EncryptedGroupInfoError::UnsupportedVersion`] for an unset or +/// unrecognised version oneof. +/// * [`EncryptedGroupInfoError::InvalidNonceLength`] if `nonce.len() != NONCE_LEN`. +/// * [`EncryptedGroupInfoError::Expired`] when `now_ns` is supplied and the +/// blob's `expires_at_ns` is non-zero and `<= now_ns`. +/// * [`EncryptedGroupInfoError::Unwrap`] for any AEAD-level failure. +pub fn unwrap_group_info<'a>( + blob: &'a EncryptedGroupInfoBlob, + key: &[u8; 32], + now_ns: Option, +) -> Result<(Vec, &'a EncryptedGroupInfoBlobV1), EncryptedGroupInfoError> { + let v1 = match &blob.version { + Some(EncryptedGroupInfoBlobVersion::V1(v1)) => v1, + None => return Err(EncryptedGroupInfoError::UnsupportedVersion), + }; + if v1.nonce.len() != NONCE_LEN { + return Err(EncryptedGroupInfoError::InvalidNonceLength(v1.nonce.len())); + } + let digest = &v1 + .group_state_hash + .as_ref() + .ok_or(EncryptedGroupInfoError::MissingGroupStateHash)? + .digest; + if digest.len() != GROUP_STATE_HASH_LEN { + return Err(EncryptedGroupInfoError::InvalidGroupStateHashLength( + digest.len(), + )); + } + if let Some(now) = now_ns + && v1.expires_at_ns != 0 + && now >= v1.expires_at_ns + { + return Err(EncryptedGroupInfoError::Expired { + expires_at_ns: v1.expires_at_ns, + now_ns: now, + }); + } + + let aad = blob_aad(v1.epoch, v1.expires_at_ns, digest); + let plaintext = unwrap_payload_symmetric() + .data(&v1.ciphertext) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(key) + .nonce(&v1.nonce) + .aad(&aad) + .call()?; + Ok((plaintext, v1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Deterministic 32-byte digest fixture. + fn digest(fill: u8) -> Vec { + vec![fill; GROUP_STATE_HASH_LEN] + } + + fn fixture_wrap(key: &[u8; 32], plaintext: &[u8]) -> EncryptedGroupInfoBlob { + wrap_group_info() + .plaintext(plaintext) + .key(key) + .epoch(1) + .group_state_hash(digest(0xD1)) + .call() + .unwrap() + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_default_nonce() { + let key = [0x11u8; 32]; + let plaintext = b"the quick brown fox jumps over the lazy dog"; + + let blob = fixture_wrap(&key, plaintext); + let (recovered, v1) = unwrap_group_info(&blob, &key, None)?; + assert_eq!(recovered.as_slice(), plaintext.as_slice()); + assert_eq!(v1.epoch, 1); + assert_eq!( + v1.group_state_hash, + Some(GroupStateHash { + digest: digest(0xD1) + }) + ); + assert_eq!(v1.expires_at_ns, 0); + assert_eq!(v1.nonce.len(), NONCE_LEN); + assert_ne!(v1.ciphertext.as_slice(), plaintext.as_slice()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_explicit_nonce() { + let key = [0x22u8; 32]; + let nonce = [0x33u8; NONCE_LEN]; + let plaintext = b"explicit nonce path"; + + let blob = wrap_group_info() + .plaintext(plaintext) + .key(&key) + .nonce(nonce) + .epoch(7) + .group_state_hash(digest(0xD2)) + .call()?; + let (recovered, v1) = unwrap_group_info(&blob, &key, None)?; + assert_eq!(v1.nonce.as_slice(), nonce.as_slice()); + assert_eq!(v1.epoch, 7); + assert_eq!(recovered.as_slice(), plaintext.as_slice()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn wrap_rejects_wrong_digest_length() { + let err = wrap_group_info() + .plaintext(b"short digest") + .key(&[0xc0u8; 32]) + .epoch(1) + .group_state_hash(vec![0xD3; GROUP_STATE_HASH_LEN - 1]) + .call() + .unwrap_err(); + assert!( + matches!( + err, + EncryptedGroupInfoError::InvalidGroupStateHashLength(len) + if len == GROUP_STATE_HASH_LEN - 1 + ), + "expected InvalidGroupStateHashLength, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn unwrap_rejects_missing_or_short_digest() { + let key = [0xc4u8; 32]; + let mut blob = fixture_wrap(&key, b"digest checks"); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.group_state_hash = None; + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!(matches!( + err, + EncryptedGroupInfoError::MissingGroupStateHash + )); + + let mut blob = fixture_wrap(&key, b"digest checks"); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.group_state_hash = Some(GroupStateHash { + digest: vec![0xD4; GROUP_STATE_HASH_LEN + 1], + }); + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!(matches!( + err, + EncryptedGroupInfoError::InvalidGroupStateHashLength(len) + if len == GROUP_STATE_HASH_LEN + 1 + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn effective_expiry_math() { + // Neither bound set. + assert_eq!(effective_expires_at_ns(0, 1_000, 0), 0); + // Campaign bound only. + assert_eq!(effective_expires_at_ns(5_000, 1_000, 0), 5_000); + // Staleness bound only. + assert_eq!(effective_expires_at_ns(0, 1_000, 250), 1_250); + // Both: earlier wins, in either order. + assert_eq!(effective_expires_at_ns(5_000, 1_000, 250), 1_250); + assert_eq!(effective_expires_at_ns(1_100, 1_000, 250), 1_100); + // Saturation: a huge expire_in_ns must not wrap around to a tiny + // (already-passed) deadline. + assert_eq!( + effective_expires_at_ns(0, u64::MAX - 10, 250), + u64::MAX, + "staleness deadline saturates instead of wrapping" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn missing_version_rejected() { + let blob = EncryptedGroupInfoBlob { version: None }; + let err = unwrap_group_info(&blob, &[0u8; 32], None).unwrap_err(); + assert!(matches!(err, EncryptedGroupInfoError::UnsupportedVersion)); + } + + #[xmtp_common::test(unwrap_try = true)] + fn nonce_too_short_rejected() { + let key = [0x55u8; 32]; + let plaintext = b"short-nonce payload"; + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.nonce.truncate(NONCE_LEN - 1); + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + match err { + EncryptedGroupInfoError::InvalidNonceLength(len) => { + assert_eq!(len, NONCE_LEN - 1); + } + other => panic!("expected InvalidNonceLength, got {other:?}"), + } + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.nonce.push(0); + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + match err { + EncryptedGroupInfoError::InvalidNonceLength(len) => { + assert_eq!(len, NONCE_LEN + 1); + } + other => panic!("expected InvalidNonceLength, got {other:?}"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn wrong_key_fails_unwrap() { + let key_a = [0x66u8; 32]; + let key_b = [0x77u8; 32]; + let plaintext = b"key A wrote me"; + + let blob = fixture_wrap(&key_a, plaintext); + let err = unwrap_group_info(&blob, &key_b, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn tampered_ciphertext_fails_unwrap() { + let key = [0x88u8; 32]; + let plaintext = b"do not tamper with me, monkey"; + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + assert!(!v1.ciphertext.is_empty()); + v1.ciphertext[0] ^= 0x01; + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn tampered_epoch_fails_unwrap() { + let key = [0xc1u8; 32]; + let mut blob = fixture_wrap(&key, b"epoch is authenticated"); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.epoch ^= 0xff; + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn tampered_group_state_hash_fails_unwrap() { + let key = [0xc2u8; 32]; + let mut blob = fixture_wrap(&key, b"state hash is authenticated"); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version + && let Some(ref mut hash) = v1.group_state_hash + { + hash.digest[0] ^= 0x01; + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn tampered_expiry_fails_unwrap() { + let key = [0xc3u8; 32]; + // Wrapped with a real deadline... + let mut blob = wrap_group_info() + .plaintext(b"expiry is authenticated") + .key(&key) + .epoch(1) + .group_state_hash(digest(0xD5)) + .expires_at_ns(100) + .call()?; + // ...which an attacker resets to 0 ("never expires") to bypass it. + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.expires_at_ns = 0; + } + // The pre-decryption expiry check now passes (0 = no expiry), but the AAD + // no longer matches the original `expires_at_ns`, so unwrap rejects it. + let err = unwrap_group_info(&blob, &key, Some(1_000)).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn fresh_nonces_differ() { + let key = [0x99u8; 32]; + let plaintext = b"same plaintext, different nonces please"; + + let blob1 = fixture_wrap(&key, plaintext); + let blob2 = fixture_wrap(&key, plaintext); + + let (_, v1_a) = unwrap_group_info(&blob1, &key, None)?; + let (_, v1_b) = unwrap_group_info(&blob2, &key, None)?; + assert_ne!(v1_a.nonce, v1_b.nonce, "fresh nonces must differ"); + assert_ne!( + v1_a.ciphertext, v1_b.ciphertext, + "ciphertexts must differ when nonces differ" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn expired_blob_rejected_when_now_supplied() { + let key = [0xaau8; 32]; + let plaintext = b"expires at 100"; + let blob = wrap_group_info() + .plaintext(plaintext) + .key(&key) + .epoch(1) + .group_state_hash(digest(0xD6)) + .expires_at_ns(100) + .call()?; + + // Not yet expired. + assert!(unwrap_group_info(&blob, &key, Some(99)).is_ok()); + + // At and after expiry. + for now in [100u64, 101, u64::MAX] { + let err = unwrap_group_info(&blob, &key, Some(now)).unwrap_err(); + match err { + EncryptedGroupInfoError::Expired { + expires_at_ns, + now_ns, + } => { + assert_eq!(expires_at_ns, 100); + assert_eq!(now_ns, now); + } + other => panic!("expected Expired, got {other:?}"), + } + } + + // None bypasses expiry enforcement entirely. + assert!(unwrap_group_info(&blob, &key, None).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn zero_expiry_means_no_expiry() { + let key = [0xbbu8; 32]; + let plaintext = b"never expires"; + let blob = wrap_group_info() + .plaintext(plaintext) + .key(&key) + .epoch(1) + .group_state_hash(digest(0xD7)) + .call()?; + + // Even with a `now_ns` supplied, an explicit zero expiry never fails. + assert!(unwrap_group_info(&blob, &key, Some(0)).is_ok()); + assert!(unwrap_group_info(&blob, &key, Some(u64::MAX)).is_ok()); + } +} diff --git a/crates/xmtp_mls_common/src/invite/mod.rs b/crates/xmtp_mls_common/src/invite/mod.rs index 8d43e9b259..f720a3bfca 100644 --- a/crates/xmtp_mls_common/src/invite/mod.rs +++ b/crates/xmtp_mls_common/src/invite/mod.rs @@ -2,9 +2,10 @@ //! commits. //! //! The [`payload`] module provides helpers for the -//! [`ExternalInvitePayload`] proto. The encryption envelope lives in the -//! sibling `encrypted_group_info` module (added by a separate PR). +//! [`ExternalInvitePayload`] proto. The [`encrypted_group_info`] module +//! provides the encryption envelope. //! //! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload +pub mod encrypted_group_info; pub mod payload; diff --git a/crates/xmtp_mls_common/src/invite/payload.rs b/crates/xmtp_mls_common/src/invite/payload.rs index 0faf1cc147..11b1799942 100644 --- a/crates/xmtp_mls_common/src/invite/payload.rs +++ b/crates/xmtp_mls_common/src/invite/payload.rs @@ -5,6 +5,8 @@ //! //! * fresh symmetric keys / nonces / external-group-ids from the workspace CSPRNG //! * recognising / unwrapping the `oneof version { V1 v1 }` envelope +//! * validating the typed fields ([`SymmetricKey`] length, [`ServicePointer`] +//! shape and `https` scheme) //! * a [`build_payload`] convenience constructor //! //! The actual encryption of the [`GroupInfo`] blob is performed by the @@ -18,12 +20,15 @@ use thiserror::Error; use xmtp_proto::xmtp::mls::message_contents::{ - ExternalInvitePayload, ExternalInvitePayloadV1, + ExternalInvitePayload, ExternalInvitePayloadV1, ServicePointer, SymmetricKey, external_invite_payload::Version as ExternalInvitePayloadVersion, + service_pointer::Location as ServiceLocation, }; /// Length in bytes of the ChaCha20Poly1305 key used to wrap the encrypted -/// `GroupInfo` blob referenced by an [`ExternalInvitePayload`]. +/// `GroupInfo` blob referenced by an [`ExternalInvitePayload`]. The +/// [`SymmetricKey`] submessage does not constrain length, so validators and +/// setters enforce it here. pub const SYMMETRIC_KEY_LEN: usize = 32; /// Length in bytes of the ChaCha20Poly1305 nonce used alongside @@ -54,16 +59,33 @@ pub enum InvitePayloadError { /// Observed length. len: usize, }, - /// `symmetric_key` was not exactly [`SYMMETRIC_KEY_LEN`] bytes. - #[error("symmetric_key must be exactly {SYMMETRIC_KEY_LEN} bytes (got {0})")] + /// `symmetric_key` was absent. A payload without the key cannot decrypt + /// anything; absence is only a legal encoding on the *policy* component + /// (where it means "no active invite"), never on the payload. + #[error("symmetric_key is required on an external-invite payload")] + MissingSymmetricKey, + /// `symmetric_key.material` was not exactly [`SYMMETRIC_KEY_LEN`] bytes. + #[error("symmetric_key.material must be exactly {SYMMETRIC_KEY_LEN} bytes (got {0})")] InvalidSymmetricKeyLength(usize), + /// `service_pointer` was present but its `location` oneof was unset. + /// A pointer with no location gives the joiner no fetch target: fail + /// closed, like an unrecognized version. (A payload with the field + /// entirely ABSENT is fine — that means application-resolved.) + #[error("service_pointer is present but carries no location (fail closed)")] + EmptyServicePointer, + /// `service_pointer.https_url` failed to parse as a URL, or its scheme + /// was not `https`. + #[error("service_pointer.https_url is invalid: {0}")] + InvalidHttpsUrl(String), } /// Generate a fresh 32-byte symmetric key from the workspace CSPRNG. /// /// The key is intended for use with ChaCha20Poly1305 when wrapping the /// encrypted GroupInfo blob referenced by the resulting -/// [`ExternalInvitePayload`]. +/// [`ExternalInvitePayload`]. Uniform randomness is also what guarantees a +/// re-enabled invite never revives a previously-used key — there is no +/// key-history tracking anywhere. pub fn generate_symmetric_key() -> [u8; SYMMETRIC_KEY_LEN] { xmtp_common::rand_array::() } @@ -73,6 +95,10 @@ pub fn generate_symmetric_key() -> [u8; SYMMETRIC_KEY_LEN] { /// Intended for use with ChaCha20Poly1305 alongside a key produced by /// [`generate_symmetric_key`]. The nonce is *not* stored in the payload /// itself — it lives next to the ciphertext in the encrypted GroupInfo blob. +/// Nonces MUST come from this (or an equivalent CSPRNG) source on every +/// encryption: many independent writers encrypt under the same long-lived +/// key, so deterministic (counter) schemes would collide across writers and +/// reuse a nonce. pub fn generate_nonce() -> [u8; NONCE_LEN] { xmtp_common::rand_array::() } @@ -87,8 +113,58 @@ pub fn generate_external_group_id() -> [u8; RECOMMENDED_EXTERNAL_GROUP_ID_LEN] { xmtp_common::rand_array::() } +/// Build a [`ServicePointer`] from an `https` URL, validating it parses and +/// carries the `https` scheme. +pub fn https_service_pointer(url: &str) -> Result { + validate_https_url(url)?; + Ok(ServicePointer { + location: Some(ServiceLocation::HttpsUrl(url.to_string())), + }) +} + +/// Build a [`ServicePointer`] from application-defined opaque bytes (NFC +/// tags, custom resolver schemes, …). Opaque to libxmtp; no validation +/// beyond carrying *a* location. +pub fn opaque_service_pointer(bytes: Vec) -> ServicePointer { + ServicePointer { + location: Some(ServiceLocation::Opaque(bytes)), + } +} + +fn validate_https_url(raw: &str) -> Result<(), InvitePayloadError> { + let parsed = + url::Url::parse(raw).map_err(|e| InvitePayloadError::InvalidHttpsUrl(e.to_string()))?; + if parsed.scheme() != "https" { + return Err(InvitePayloadError::InvalidHttpsUrl(format!( + "scheme must be https (got {})", + parsed.scheme() + ))); + } + Ok(()) +} + +/// Validate a [`ServicePointer`]: exactly one `location` variant must be +/// set, and an `https_url` location must parse with the `https` scheme. +/// +/// Note the asymmetry with the *field* being absent on a payload: an absent +/// `service_pointer` means the application resolves the service out-of-band +/// and is legal; a present-but-empty pointer is a parse failure. +pub fn validate_service_pointer(pointer: &ServicePointer) -> Result<(), InvitePayloadError> { + match &pointer.location { + None => Err(InvitePayloadError::EmptyServicePointer), + Some(ServiceLocation::HttpsUrl(raw)) => validate_https_url(raw), + Some(ServiceLocation::Opaque(_)) => Ok(()), + } +} + /// Validate that `payload.version` carries a recognised variant and that -/// the V1 fields meet their length requirements. +/// the V1 fields meet their requirements: +/// +/// * `service_pointer` — absent is legal (application-resolved); present +/// requires a location variant ([`validate_service_pointer`]). +/// * `external_group_id` — at least [`MIN_EXTERNAL_GROUP_ID_LEN`] bytes. +/// * `symmetric_key` — present with exactly [`SYMMETRIC_KEY_LEN`] bytes of +/// `material`. /// /// Currently the only recognised variant is V1. Future versions extend the /// oneof; unknown variants are rejected (fail closed). @@ -99,14 +175,21 @@ pub fn validate( Some(ExternalInvitePayloadVersion::V1(v1)) => v1, None => return Err(InvitePayloadError::UnsupportedVersion), }; + if let Some(pointer) = &v1.service_pointer { + validate_service_pointer(pointer)?; + } if v1.external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN { return Err(InvitePayloadError::InvalidExternalGroupIdLength { len: v1.external_group_id.len(), }); } - if v1.symmetric_key.len() != SYMMETRIC_KEY_LEN { + let key = v1 + .symmetric_key + .as_ref() + .ok_or(InvitePayloadError::MissingSymmetricKey)?; + if key.material.len() != SYMMETRIC_KEY_LEN { return Err(InvitePayloadError::InvalidSymmetricKeyLength( - v1.symmetric_key.len(), + key.material.len(), )); } Ok(v1) @@ -115,8 +198,10 @@ pub fn validate( /// Build an [`ExternalInvitePayload`] wrapping a [`ExternalInvitePayloadV1`] /// with the supplied fields. /// -/// * `service_pointer` — application-defined opaque bytes describing where -/// the encrypted GroupInfo blob can be fetched. +/// * `service_pointer` — where the encrypted GroupInfo blob can be fetched +/// ([`https_service_pointer`] / [`opaque_service_pointer`]). `None` means +/// application-resolved: the scanning app already knows how to reach its +/// service, and the QR carries no fetch target at all. /// * `external_group_id` — service-slot identifier carried on the wire and /// verified by the joiner against the group's /// `EXTERNAL_COMMIT_POLICY.external_group_id` after joining. MUST be at @@ -126,10 +211,13 @@ pub fn validate( /// * `symmetric_key` — typically the output of [`generate_symmetric_key`]. /// Length is type-enforced. pub fn build_payload( - service_pointer: Vec, + service_pointer: Option, external_group_id: Vec, symmetric_key: [u8; SYMMETRIC_KEY_LEN], ) -> Result { + if let Some(pointer) = &service_pointer { + validate_service_pointer(pointer)?; + } if external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN { return Err(InvitePayloadError::InvalidExternalGroupIdLength { len: external_group_id.len(), @@ -139,7 +227,9 @@ pub fn build_payload( version: Some(ExternalInvitePayloadVersion::V1(ExternalInvitePayloadV1 { service_pointer, external_group_id, - symmetric_key: symmetric_key.to_vec(), + symmetric_key: Some(SymmetricKey { + material: symmetric_key.to_vec(), + }), })), }) } @@ -150,7 +240,7 @@ mod tests { fn well_formed_payload() -> ExternalInvitePayload { build_payload( - b"https://invites.example/abc".to_vec(), + Some(https_service_pointer("https://invites.example/abc").expect("valid https url")), generate_external_group_id().to_vec(), [0x42u8; SYMMETRIC_KEY_LEN], ) @@ -185,10 +275,53 @@ mod tests { fn validate_accepts_well_formed_v1() { let payload = well_formed_payload(); let v1 = validate(&payload)?; - assert_eq!(v1.symmetric_key.len(), SYMMETRIC_KEY_LEN); + assert_eq!( + v1.symmetric_key.as_ref().unwrap().material.len(), + SYMMETRIC_KEY_LEN + ); assert!(v1.external_group_id.len() >= MIN_EXTERNAL_GROUP_ID_LEN); } + #[xmtp_common::test(unwrap_try = true)] + fn validate_accepts_absent_service_pointer() { + // Absent pointer = application-resolved service: the scanning app + // knows its own endpoint and the QR carries no fetch target. + let payload = build_payload( + None, + generate_external_group_id().to_vec(), + [0x42u8; SYMMETRIC_KEY_LEN], + )?; + let v1 = validate(&payload)?; + assert!(v1.service_pointer.is_none()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn validate_rejects_present_but_empty_service_pointer() { + // Present-but-empty is distinguishable from absent on the wire and + // gives the joiner no fetch target: fail closed. + let mut payload = well_formed_payload(); + if let Some(ExternalInvitePayloadVersion::V1(ref mut v1)) = payload.version { + v1.service_pointer = Some(ServicePointer { location: None }); + } + assert_eq!( + validate(&payload), + Err(InvitePayloadError::EmptyServicePointer) + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn https_pointer_rejects_non_https_and_garbage() { + assert!(matches!( + https_service_pointer("http://invites.example/abc"), + Err(InvitePayloadError::InvalidHttpsUrl(_)) + )); + assert!(matches!( + https_service_pointer("not a url"), + Err(InvitePayloadError::InvalidHttpsUrl(_)) + )); + assert!(https_service_pointer("https://invites.example/abc").is_ok()); + } + #[xmtp_common::test(unwrap_try = true)] fn validate_rejects_missing_version() { let payload = ExternalInvitePayload { version: None }; @@ -201,7 +334,7 @@ mod tests { #[xmtp_common::test(unwrap_try = true)] fn build_payload_rejects_short_external_group_id() { let result = build_payload( - b"svc".to_vec(), + None, vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN - 1], [0x42u8; SYMMETRIC_KEY_LEN], ); @@ -221,9 +354,11 @@ mod tests { // untrusted peers. let payload = ExternalInvitePayload { version: Some(ExternalInvitePayloadVersion::V1(ExternalInvitePayloadV1 { - service_pointer: b"svc".to_vec(), + service_pointer: Some(opaque_service_pointer(b"svc".to_vec())), external_group_id: vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN - 1], - symmetric_key: vec![0x42u8; SYMMETRIC_KEY_LEN], + symmetric_key: Some(SymmetricKey { + material: vec![0x42u8; SYMMETRIC_KEY_LEN], + }), })), }; assert_eq!( @@ -234,11 +369,25 @@ mod tests { ); } + #[xmtp_common::test(unwrap_try = true)] + fn validate_rejects_missing_symmetric_key() { + let mut payload = well_formed_payload(); + if let Some(ExternalInvitePayloadVersion::V1(ref mut v1)) = payload.version { + v1.symmetric_key = None; + } + assert_eq!( + validate(&payload), + Err(InvitePayloadError::MissingSymmetricKey) + ); + } + #[xmtp_common::test(unwrap_try = true)] fn validate_rejects_wrong_symmetric_key_length() { let mut payload = well_formed_payload(); if let Some(ExternalInvitePayloadVersion::V1(ref mut v1)) = payload.version { - v1.symmetric_key = vec![0u8; SYMMETRIC_KEY_LEN - 1]; + v1.symmetric_key = Some(SymmetricKey { + material: vec![0u8; SYMMETRIC_KEY_LEN - 1], + }); } assert_eq!( validate(&payload), @@ -250,14 +399,19 @@ mod tests { #[xmtp_common::test(unwrap_try = true)] fn build_payload_round_trip() { - let service_pointer = b"https://invites.example/abc".to_vec(); + let pointer = https_service_pointer("https://invites.example/abc")?; let external_group_id = generate_external_group_id().to_vec(); let key = [0x42u8; SYMMETRIC_KEY_LEN]; - let payload = build_payload(service_pointer.clone(), external_group_id.clone(), key)?; + let payload = build_payload(Some(pointer.clone()), external_group_id.clone(), key)?; let v1 = validate(&payload)?; - assert_eq!(v1.service_pointer, service_pointer); + assert_eq!(v1.service_pointer, Some(pointer)); assert_eq!(v1.external_group_id, external_group_id); - assert_eq!(v1.symmetric_key, key.to_vec()); + assert_eq!( + v1.symmetric_key, + Some(SymmetricKey { + material: key.to_vec() + }) + ); } } diff --git a/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs b/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs index e357509364..14dfc8814d 100644 --- a/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs +++ b/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs @@ -148,28 +148,71 @@ pub fn unwrap_payload_hpke( /// Wrap a payload with symmetric AEAD encryption (caller-supplied key + nonce). /// +/// All four byte-slice arguments (`data`, `symmetric_key`, `nonce`, `aad`) +/// have the same Rust type, so the builder-form call is the only signature: +/// it forces every input to be named at the call site and makes a parameter +/// swap surface as a wrong-name error. +/// +/// `aad` (additional authenticated data) is optional and defaults to the empty +/// slice. When provided, the identical bytes MUST be supplied to +/// [`unwrap_payload_symmetric`] or decryption fails — use this to authenticate +/// envelope fields (nonce, version, etc.) that travel alongside the ciphertext +/// in the clear so they can't be tampered with undetected. +/// /// Domain separation is handled by construction: callers MUST scope the /// symmetric key to a single use-case. +/// +/// # Example +/// +/// ```ignore +/// let ciphertext = wrap_payload_symmetric() +/// .data(payload) +/// .aead_type(AeadType::ChaCha20Poly1305) +/// .symmetric_key(&key) +/// .nonce(&nonce) +/// .aad(&envelope_header) // optional; omit to bind no AAD +/// .call()?; +/// ``` +#[bon::builder] pub fn wrap_payload_symmetric( data: &[u8], aead_type: openmls::prelude::AeadType, symmetric_key: &[u8], nonce: &[u8], + #[builder(default = &[])] aad: &[u8], ) -> Result, WrapPayloadError> { (*LIBCRUX_CRYPTO_PROVIDER) - .aead_encrypt(aead_type, symmetric_key, data, nonce, &[]) + .aead_encrypt(aead_type, symmetric_key, data, nonce, aad) .map_err(Into::into) } /// Unwrap a payload that was wrapped with [`wrap_payload_symmetric`]. +/// +/// `aad` MUST byte-for-byte match what was passed at wrap time (or be omitted +/// on both sides). Mismatch surfaces as a decryption failure, not a wrong-AAD +/// error — the AEAD construction doesn't distinguish. +/// +/// # Example +/// +/// ```ignore +/// let plaintext = unwrap_payload_symmetric() +/// .data(&ciphertext) +/// .aead_type(AeadType::ChaCha20Poly1305) +/// .symmetric_key(&key) +/// .nonce(&nonce) +/// .aad(&envelope_header) +/// .call()?; +/// ``` +#[bon::builder] pub fn unwrap_payload_symmetric( data: &[u8], aead_type: openmls::prelude::AeadType, symmetric_key: &[u8], nonce: &[u8], + #[builder(default = &[])] aad: &[u8], ) -> Result, UnwrapPayloadError> { (*LIBCRUX_CRYPTO_PROVIDER) - .aead_decrypt(aead_type, symmetric_key, data, nonce, &[]) + .aead_decrypt(aead_type, symmetric_key, data, nonce, aad) .map_err(Into::into) } @@ -364,20 +407,20 @@ mod tests { let nonce = xmtp_common::rand_array::<12>(); let data = xmtp_common::rand_array::<1000>(); - let wrapped = wrap_payload_symmetric( - &data, - openmls::prelude::AeadType::ChaCha20Poly1305, - &symmetric_key, - &nonce, - ) - .unwrap(); - let unwrapped = unwrap_payload_symmetric( - &wrapped, - openmls::prelude::AeadType::ChaCha20Poly1305, - &symmetric_key, - &nonce, - ) - .unwrap(); + let wrapped = wrap_payload_symmetric() + .data(&data) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .call() + .unwrap(); + let unwrapped = unwrap_payload_symmetric() + .data(&wrapped) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .call() + .unwrap(); assert_eq!(data.as_slice(), unwrapped.as_slice()); } @@ -388,19 +431,94 @@ mod tests { let nonce = xmtp_common::rand_array::<12>(); let data = xmtp_common::rand_array::<1000>(); - let wrapped = wrap_payload_symmetric( - &data, - openmls::prelude::AeadType::ChaCha20Poly1305, - &symmetric_key, - &nonce, - ) - .unwrap(); - unwrap_payload_symmetric( - &wrapped, - openmls::prelude::AeadType::ChaCha20Poly1305, - &wrong_key, - &nonce, - ) - .unwrap_err(); + let wrapped = wrap_payload_symmetric() + .data(&data) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .call() + .unwrap(); + unwrap_payload_symmetric() + .data(&wrapped) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&wrong_key) + .nonce(&nonce) + .call() + .unwrap_err(); + } + + #[xmtp_common::test] + fn round_trip_symmetric_with_aad() { + let symmetric_key = xmtp_common::rand_array::<32>(); + let nonce = xmtp_common::rand_array::<12>(); + let data = xmtp_common::rand_array::<1000>(); + let aad = b"envelope-header-v1".as_slice(); + + let wrapped = wrap_payload_symmetric() + .data(&data) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .aad(aad) + .call() + .unwrap(); + let unwrapped = unwrap_payload_symmetric() + .data(&wrapped) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .aad(aad) + .call() + .unwrap(); + assert_eq!(data.as_slice(), unwrapped.as_slice()); + } + + #[xmtp_common::test] + fn symmetric_wrong_aad_fails() { + let symmetric_key = xmtp_common::rand_array::<32>(); + let nonce = xmtp_common::rand_array::<12>(); + let data = xmtp_common::rand_array::<1000>(); + + let wrapped = wrap_payload_symmetric() + .data(&data) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .aad(b"envelope-header-v1".as_slice()) + .call() + .unwrap(); + unwrap_payload_symmetric() + .data(&wrapped) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .aad(b"envelope-header-v2".as_slice()) + .call() + .unwrap_err(); + } + + #[xmtp_common::test] + fn symmetric_missing_aad_at_unwrap_fails() { + // Wrapping with an AAD and unwrapping without one (or vice versa) MUST + // fail — the AEAD construction treats absent AAD as a distinct binding. + let symmetric_key = xmtp_common::rand_array::<32>(); + let nonce = xmtp_common::rand_array::<12>(); + let data = xmtp_common::rand_array::<1000>(); + + let wrapped = wrap_payload_symmetric() + .data(&data) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .aad(b"present".as_slice()) + .call() + .unwrap(); + unwrap_payload_symmetric() + .data(&wrapped) + .aead_type(openmls::prelude::AeadType::ChaCha20Poly1305) + .symmetric_key(&symmetric_key) + .nonce(&nonce) + .call() + .unwrap_err(); } } diff --git a/crates/xmtp_proto/proto_version b/crates/xmtp_proto/proto_version index aba460fee9..ebe53774f5 100644 --- a/crates/xmtp_proto/proto_version +++ b/crates/xmtp_proto/proto_version @@ -1 +1 @@ -e6f640a8994dad1779da0280b15be482c0bb4bb8 +160192a2f8bbff4214f69618922bccfc2e23d18d diff --git a/crates/xmtp_proto/src/gen/proto_descriptor.bin b/crates/xmtp_proto/src/gen/proto_descriptor.bin index b2d195784b..b0b7b4e3fd 100644 Binary files a/crates/xmtp_proto/src/gen/proto_descriptor.bin and b/crates/xmtp_proto/src/gen/proto_descriptor.bin differ diff --git a/crates/xmtp_proto/src/gen/xmtp.message_api.v1.rs b/crates/xmtp_proto/src/gen/xmtp.message_api.v1.rs index d3fb9f6be9..6882242c90 100644 --- a/crates/xmtp_proto/src/gen/xmtp.message_api.v1.rs +++ b/crates/xmtp_proto/src/gen/xmtp.message_api.v1.rs @@ -1,54 +1,4 @@ // This file is @generated by prost-build. -/// Token is used by clients to prove to the nodes -/// that they are serving a specific wallet. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Token { - /// identity key signed by a wallet - #[prost(message, optional, tag = "1")] - pub identity_key: ::core::option::Option, - /// encoded bytes of AuthData - #[prost(bytes = "vec", tag = "2")] - pub auth_data_bytes: ::prost::alloc::vec::Vec, - /// identity key signature of AuthData bytes - #[prost(message, optional, tag = "3")] - pub auth_data_signature: ::core::option::Option< - super::super::message_contents::Signature, - >, -} -impl ::prost::Name for Token { - const NAME: &'static str = "Token"; - const PACKAGE: &'static str = "xmtp.message_api.v1"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_api.v1.Token".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_api.v1.Token".into() - } -} -/// AuthData carries token parameters that are authenticated -/// by the identity key signature. -/// It is embedded in the Token structure as bytes -/// so that the bytes don't need to be reconstructed -/// to verify the token signature. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct AuthData { - /// address of the wallet - #[prost(string, tag = "1")] - pub wallet_addr: ::prost::alloc::string::String, - /// time when the token was generated/signed - #[prost(uint64, tag = "2")] - pub created_ns: u64, -} -impl ::prost::Name for AuthData { - const NAME: &'static str = "AuthData"; - const PACKAGE: &'static str = "xmtp.message_api.v1"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_api.v1.AuthData".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_api.v1.AuthData".into() - } -} /// This is based off of the go-waku Index type, but with the /// receiverTime and pubsubTopic removed for simplicity. /// Both removed fields are optional @@ -768,3 +718,53 @@ pub mod message_api_server { const NAME: &'static str = SERVICE_NAME; } } +/// Token is used by clients to prove to the nodes +/// that they are serving a specific wallet. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Token { + /// identity key signed by a wallet + #[prost(message, optional, tag = "1")] + pub identity_key: ::core::option::Option, + /// encoded bytes of AuthData + #[prost(bytes = "vec", tag = "2")] + pub auth_data_bytes: ::prost::alloc::vec::Vec, + /// identity key signature of AuthData bytes + #[prost(message, optional, tag = "3")] + pub auth_data_signature: ::core::option::Option< + super::super::message_contents::Signature, + >, +} +impl ::prost::Name for Token { + const NAME: &'static str = "Token"; + const PACKAGE: &'static str = "xmtp.message_api.v1"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.message_api.v1.Token".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.message_api.v1.Token".into() + } +} +/// AuthData carries token parameters that are authenticated +/// by the identity key signature. +/// It is embedded in the Token structure as bytes +/// so that the bytes don't need to be reconstructed +/// to verify the token signature. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AuthData { + /// address of the wallet + #[prost(string, tag = "1")] + pub wallet_addr: ::prost::alloc::string::String, + /// time when the token was generated/signed + #[prost(uint64, tag = "2")] + pub created_ns: u64, +} +impl ::prost::Name for AuthData { + const NAME: &'static str = "AuthData"; + const PACKAGE: &'static str = "xmtp.message_api.v1"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.message_api.v1.AuthData".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.message_api.v1.AuthData".into() + } +} diff --git a/crates/xmtp_proto/src/gen/xmtp.message_contents.rs b/crates/xmtp_proto/src/gen/xmtp.message_contents.rs index 1362df0280..5e4f142c79 100644 --- a/crates/xmtp_proto/src/gen/xmtp.message_contents.rs +++ b/crates/xmtp_proto/src/gen/xmtp.message_contents.rs @@ -922,6 +922,87 @@ impl ::prost::Name for PrivatePreferencesPayload { "/xmtp.message_contents.PrivatePreferencesPayload".into() } } +/// The message that will be signed by the Client and returned inside the +/// `action_body` field of the FrameAction message +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FrameActionBody { + /// The URL of the frame that was clicked + /// May be different from `post_url` + #[prost(string, tag = "1")] + pub frame_url: ::prost::alloc::string::String, + /// The 1-indexed button that was clicked + #[prost(int32, tag = "2")] + pub button_index: i32, + /// Timestamp of the click in milliseconds since the epoch + #[deprecated] + #[prost(uint64, tag = "3")] + pub timestamp: u64, + /// A unique identifier for the conversation, not tied to anything on the + /// network. Will not match the topic or conversation_id + #[prost(string, tag = "4")] + pub opaque_conversation_identifier: ::prost::alloc::string::String, + /// Unix timestamp + #[prost(uint32, tag = "5")] + pub unix_timestamp: u32, + /// Input text from a text input field + #[prost(string, tag = "6")] + pub input_text: ::prost::alloc::string::String, + /// A state serialized to a string (for example via JSON.stringify()). Maximum 4096 bytes. + #[prost(string, tag = "7")] + pub state: ::prost::alloc::string::String, + /// A 0x wallet address + #[prost(string, tag = "8")] + pub address: ::prost::alloc::string::String, + /// A hash from a transaction + #[prost(string, tag = "9")] + pub transaction_id: ::prost::alloc::string::String, +} +impl ::prost::Name for FrameActionBody { + const NAME: &'static str = "FrameActionBody"; + const PACKAGE: &'static str = "xmtp.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.message_contents.FrameActionBody".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.message_contents.FrameActionBody".into() + } +} +/// The outer payload that will be sent as the `messageBytes` in the +/// `trusted_data` part of the Frames message +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FrameAction { + #[deprecated] + #[prost(message, optional, tag = "1")] + pub signature: ::core::option::Option, + /// The SignedPublicKeyBundle of the signer, used to link the XMTP signature + /// with a blockchain account through a chain of signatures. + #[deprecated] + #[prost(message, optional, tag = "2")] + pub signed_public_key_bundle: ::core::option::Option, + /// Serialized FrameActionBody message, so that the signature verification can + /// happen on a byte-perfect representation of the message + #[prost(bytes = "vec", tag = "3")] + pub action_body: ::prost::alloc::vec::Vec, + /// The installation signature + #[prost(bytes = "vec", tag = "4")] + pub installation_signature: ::prost::alloc::vec::Vec, + /// The public installation id used to sign. + #[prost(bytes = "vec", tag = "5")] + pub installation_id: ::prost::alloc::vec::Vec, + /// The inbox id of the installation used to sign. + #[prost(string, tag = "6")] + pub inbox_id: ::prost::alloc::string::String, +} +impl ::prost::Name for FrameAction { + const NAME: &'static str = "FrameAction"; + const PACKAGE: &'static str = "xmtp.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.message_contents.FrameAction".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.message_contents.FrameAction".into() + } +} /// ContentTypeId is used to identify the type of content stored in a Message. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ContentTypeId { @@ -1036,51 +1117,6 @@ impl Compression { } } } -/// Composite is used to implement xmtp.org/composite content type -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Composite { - #[prost(message, repeated, tag = "1")] - pub parts: ::prost::alloc::vec::Vec, -} -/// Nested message and enum types in `Composite`. -pub mod composite { - /// Part represents one section of a composite message - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct Part { - #[prost(oneof = "part::Element", tags = "1, 2")] - pub element: ::core::option::Option, - } - /// Nested message and enum types in `Part`. - pub mod part { - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Element { - #[prost(message, tag = "1")] - Part(super::super::EncodedContent), - #[prost(message, tag = "2")] - Composite(super::super::Composite), - } - } - impl ::prost::Name for Part { - const NAME: &'static str = "Part"; - const PACKAGE: &'static str = "xmtp.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.Composite.Part".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.Composite.Part".into() - } - } -} -impl ::prost::Name for Composite { - const NAME: &'static str = "Composite"; - const PACKAGE: &'static str = "xmtp.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.Composite".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.Composite".into() - } -} /// LEGACY: User key bundle V1 using PublicKeys. /// The PublicKeys MUST be signed. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -1140,110 +1176,67 @@ impl ::prost::Name for ContactBundle { "/xmtp.message_contents.ContactBundle".into() } } -/// EciesMessage is a wrapper for ECIES encrypted payloads +/// SignedPayload is a wrapper for a signature and a payload #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EciesMessage { - #[prost(oneof = "ecies_message::Version", tags = "1")] - pub version: ::core::option::Option, -} -/// Nested message and enum types in `EciesMessage`. -pub mod ecies_message { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Version { - /// Expected to be an ECIES encrypted SignedPayload - #[prost(bytes, tag = "1")] - V1(::prost::alloc::vec::Vec), - } +pub struct SignedPayload { + #[prost(bytes = "vec", tag = "1")] + pub payload: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "2")] + pub signature: ::core::option::Option, } -impl ::prost::Name for EciesMessage { - const NAME: &'static str = "EciesMessage"; +impl ::prost::Name for SignedPayload { + const NAME: &'static str = "SignedPayload"; const PACKAGE: &'static str = "xmtp.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.EciesMessage".into() + "xmtp.message_contents.SignedPayload".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.EciesMessage".into() + "/xmtp.message_contents.SignedPayload".into() } } -/// The message that will be signed by the Client and returned inside the -/// `action_body` field of the FrameAction message -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct FrameActionBody { - /// The URL of the frame that was clicked - /// May be different from `post_url` - #[prost(string, tag = "1")] - pub frame_url: ::prost::alloc::string::String, - /// The 1-indexed button that was clicked - #[prost(int32, tag = "2")] - pub button_index: i32, - /// Timestamp of the click in milliseconds since the epoch - #[deprecated] - #[prost(uint64, tag = "3")] - pub timestamp: u64, - /// A unique identifier for the conversation, not tied to anything on the - /// network. Will not match the topic or conversation_id - #[prost(string, tag = "4")] - pub opaque_conversation_identifier: ::prost::alloc::string::String, - /// Unix timestamp - #[prost(uint32, tag = "5")] - pub unix_timestamp: u32, - /// Input text from a text input field - #[prost(string, tag = "6")] - pub input_text: ::prost::alloc::string::String, - /// A state serialized to a string (for example via JSON.stringify()). Maximum 4096 bytes. - #[prost(string, tag = "7")] - pub state: ::prost::alloc::string::String, - /// A 0x wallet address - #[prost(string, tag = "8")] - pub address: ::prost::alloc::string::String, - /// A hash from a transaction - #[prost(string, tag = "9")] - pub transaction_id: ::prost::alloc::string::String, +/// Composite is used to implement xmtp.org/composite content type +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Composite { + #[prost(message, repeated, tag = "1")] + pub parts: ::prost::alloc::vec::Vec, } -impl ::prost::Name for FrameActionBody { - const NAME: &'static str = "FrameActionBody"; - const PACKAGE: &'static str = "xmtp.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.FrameActionBody".into() +/// Nested message and enum types in `Composite`. +pub mod composite { + /// Part represents one section of a composite message + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Part { + #[prost(oneof = "part::Element", tags = "1, 2")] + pub element: ::core::option::Option, } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.FrameActionBody".into() + /// Nested message and enum types in `Part`. + pub mod part { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Element { + #[prost(message, tag = "1")] + Part(super::super::EncodedContent), + #[prost(message, tag = "2")] + Composite(super::super::Composite), + } + } + impl ::prost::Name for Part { + const NAME: &'static str = "Part"; + const PACKAGE: &'static str = "xmtp.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.message_contents.Composite.Part".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.message_contents.Composite.Part".into() + } } } -/// The outer payload that will be sent as the `messageBytes` in the -/// `trusted_data` part of the Frames message -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct FrameAction { - #[deprecated] - #[prost(message, optional, tag = "1")] - pub signature: ::core::option::Option, - /// The SignedPublicKeyBundle of the signer, used to link the XMTP signature - /// with a blockchain account through a chain of signatures. - #[deprecated] - #[prost(message, optional, tag = "2")] - pub signed_public_key_bundle: ::core::option::Option, - /// Serialized FrameActionBody message, so that the signature verification can - /// happen on a byte-perfect representation of the message - #[prost(bytes = "vec", tag = "3")] - pub action_body: ::prost::alloc::vec::Vec, - /// The installation signature - #[prost(bytes = "vec", tag = "4")] - pub installation_signature: ::prost::alloc::vec::Vec, - /// The public installation id used to sign. - #[prost(bytes = "vec", tag = "5")] - pub installation_id: ::prost::alloc::vec::Vec, - /// The inbox id of the installation used to sign. - #[prost(string, tag = "6")] - pub inbox_id: ::prost::alloc::string::String, -} -impl ::prost::Name for FrameAction { - const NAME: &'static str = "FrameAction"; +impl ::prost::Name for Composite { + const NAME: &'static str = "Composite"; const PACKAGE: &'static str = "xmtp.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.FrameAction".into() + "xmtp.message_contents.Composite".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.FrameAction".into() + "/xmtp.message_contents.Composite".into() } } /// Message header is encoded separately as the bytes are also used @@ -1397,21 +1390,28 @@ impl ::prost::Name for DecodedMessage { "/xmtp.message_contents.DecodedMessage".into() } } -/// SignedPayload is a wrapper for a signature and a payload +/// EciesMessage is a wrapper for ECIES encrypted payloads #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SignedPayload { - #[prost(bytes = "vec", tag = "1")] - pub payload: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag = "2")] - pub signature: ::core::option::Option, +pub struct EciesMessage { + #[prost(oneof = "ecies_message::Version", tags = "1")] + pub version: ::core::option::Option, } -impl ::prost::Name for SignedPayload { - const NAME: &'static str = "SignedPayload"; +/// Nested message and enum types in `EciesMessage`. +pub mod ecies_message { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Version { + /// Expected to be an ECIES encrypted SignedPayload + #[prost(bytes, tag = "1")] + V1(::prost::alloc::vec::Vec), + } +} +impl ::prost::Name for EciesMessage { + const NAME: &'static str = "EciesMessage"; const PACKAGE: &'static str = "xmtp.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.message_contents.SignedPayload".into() + "xmtp.message_contents.EciesMessage".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.message_contents.SignedPayload".into() + "/xmtp.message_contents.EciesMessage".into() } } diff --git a/crates/xmtp_proto/src/gen/xmtp.mls.database.rs b/crates/xmtp_proto/src/gen/xmtp.mls.database.rs index 7b3795c241..19bcfaad4a 100644 --- a/crates/xmtp_proto/src/gen/xmtp.mls.database.rs +++ b/crates/xmtp_proto/src/gen/xmtp.mls.database.rs @@ -1,4 +1,72 @@ // This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Task { + #[prost(oneof = "task::Task", tags = "1, 2, 3")] + pub task: ::core::option::Option, +} +/// Nested message and enum types in `Task`. +pub mod task { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Task { + #[prost(message, tag = "1")] + ProcessWelcomePointer(super::super::message_contents::WelcomePointer), + #[prost(message, tag = "2")] + SendSyncArchive(super::SendSyncArchive), + #[prost(message, tag = "3")] + ProcessPendingSelfRemove(super::ProcessPendingSelfRemove), + } +} +impl ::prost::Name for Task { + const NAME: &'static str = "Task"; + const PACKAGE: &'static str = "xmtp.mls.database"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.database.Task".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.database.Task".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SendSyncArchive { + #[prost(message, optional, tag = "1")] + pub options: ::core::option::Option, + #[prost(bytes = "vec", tag = "2")] + pub sync_group_id: ::prost::alloc::vec::Vec, + #[prost(string, optional, tag = "3")] + pub pin: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, tag = "4")] + pub server_url: ::prost::alloc::string::String, +} +impl ::prost::Name for SendSyncArchive { + const NAME: &'static str = "SendSyncArchive"; + const PACKAGE: &'static str = "xmtp.mls.database"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.database.SendSyncArchive".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.database.SendSyncArchive".into() + } +} +/// Durable TaskRunner intent: process a group's pending self-remove requests +/// (build the MLS RemoveProposal/Commit to evict members who sent a LeaveRequest, +/// then clean up the pending-remove list). Enqueued in the same DB transaction as +/// the pending_remove row so it survives restart; runs on the TaskRunner with +/// retry/backoff. group_id is the target conversation. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ProcessPendingSelfRemove { + #[prost(bytes = "vec", tag = "1")] + pub group_id: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for ProcessPendingSelfRemove { + const NAME: &'static str = "ProcessPendingSelfRemove"; + const PACKAGE: &'static str = "xmtp.mls.database"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.database.ProcessPendingSelfRemove".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.database.ProcessPendingSelfRemove".into() + } +} /// The data required to publish a message #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SendMessageData { @@ -433,7 +501,7 @@ impl ::prost::Name for UpdatePermissionData { /// /// Never appears on the MLS wire; lives only in the local intents /// table. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct AppDataUpdateData { /// Versioned envelope. New variants are added as new oneof entries; /// readers that don't recognize a variant fail closed. @@ -442,8 +510,30 @@ pub struct AppDataUpdateData { } /// Nested message and enum types in `AppDataUpdateData`. pub mod app_data_update_data { - /// v1 payload shape. + /// One additional component write carried by the same commit as the + /// primary V1 update. Same field semantics as V1's component_id / + /// payload. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct Update { + /// u16 component_id widened to u32 (proto has no u16). + #[prost(uint32, tag = "1")] + pub component_id: u32, + /// Verbatim on-wire AppDataUpdate proposal payload. + #[prost(bytes = "vec", tag = "2")] + pub payload: ::prost::alloc::vec::Vec, + } + impl ::prost::Name for Update { + const NAME: &'static str = "Update"; + const PACKAGE: &'static str = "xmtp.mls.database"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.database.AppDataUpdateData.Update".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.database.AppDataUpdateData.Update".into() + } + } + /// v1 payload shape. + #[derive(Clone, PartialEq, ::prost::Message)] pub struct V1 { /// u16 component_id widened to u32 (proto has no u16). The /// dispatcher narrows back to u16 at decode time. @@ -455,6 +545,17 @@ pub mod app_data_update_data { /// comment above). #[prost(bytes = "vec", tag = "2")] pub payload: ::prost::alloc::vec::Vec, + /// Further component writes that MUST land in the same commit as + /// the primary update — each becomes its own standalone + /// AppDataUpdate proposal, swept into one commit. Used by writes + /// whose invariants couple components atomically: enabling + /// EXTERNAL_COMMIT_POLICY must establish the GROUP_MEMBERSHIP + /// external_committer_permissions grant in the same commit + /// (XIP-82 enable atomicity). Old readers ignore the field + /// (additive), but the intents table is local-only so mixed + /// versions never read each other's rows. + #[prost(message, repeated, tag = "3")] + pub additional_updates: ::prost::alloc::vec::Vec, } impl ::prost::Name for V1 { const NAME: &'static str = "V1"; @@ -468,7 +569,7 @@ pub mod app_data_update_data { } /// Versioned envelope. New variants are added as new oneof entries; /// readers that don't recognize a variant fail closed. - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Version { #[prost(message, tag = "1")] V1(V1), @@ -781,71 +882,3 @@ impl PermissionPolicyOption { } } } -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Task { - #[prost(oneof = "task::Task", tags = "1, 2, 3")] - pub task: ::core::option::Option, -} -/// Nested message and enum types in `Task`. -pub mod task { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Task { - #[prost(message, tag = "1")] - ProcessWelcomePointer(super::super::message_contents::WelcomePointer), - #[prost(message, tag = "2")] - SendSyncArchive(super::SendSyncArchive), - #[prost(message, tag = "3")] - ProcessPendingSelfRemove(super::ProcessPendingSelfRemove), - } -} -impl ::prost::Name for Task { - const NAME: &'static str = "Task"; - const PACKAGE: &'static str = "xmtp.mls.database"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.database.Task".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.database.Task".into() - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SendSyncArchive { - #[prost(message, optional, tag = "1")] - pub options: ::core::option::Option, - #[prost(bytes = "vec", tag = "2")] - pub sync_group_id: ::prost::alloc::vec::Vec, - #[prost(string, optional, tag = "3")] - pub pin: ::core::option::Option<::prost::alloc::string::String>, - #[prost(string, tag = "4")] - pub server_url: ::prost::alloc::string::String, -} -impl ::prost::Name for SendSyncArchive { - const NAME: &'static str = "SendSyncArchive"; - const PACKAGE: &'static str = "xmtp.mls.database"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.database.SendSyncArchive".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.database.SendSyncArchive".into() - } -} -/// Durable TaskRunner intent: process a group's pending self-remove requests -/// (build the MLS RemoveProposal/Commit to evict members who sent a LeaveRequest, -/// then clean up the pending-remove list). Enqueued in the same DB transaction as -/// the pending_remove row so it survives restart; runs on the TaskRunner with -/// retry/backoff. group_id is the target conversation. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ProcessPendingSelfRemove { - #[prost(bytes = "vec", tag = "1")] - pub group_id: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for ProcessPendingSelfRemove { - const NAME: &'static str = "ProcessPendingSelfRemove"; - const PACKAGE: &'static str = "xmtp.mls.database"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.database.ProcessPendingSelfRemove".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.database.ProcessPendingSelfRemove".into() - } -} diff --git a/crates/xmtp_proto/src/gen/xmtp.mls.database.serde.rs b/crates/xmtp_proto/src/gen/xmtp.mls.database.serde.rs index 54bff52738..f23861c29b 100644 --- a/crates/xmtp_proto/src/gen/xmtp.mls.database.serde.rs +++ b/crates/xmtp_proto/src/gen/xmtp.mls.database.serde.rs @@ -585,6 +585,125 @@ impl<'de> serde::Deserialize<'de> for AppDataUpdateData { deserializer.deserialize_struct("xmtp.mls.database.AppDataUpdateData", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for app_data_update_data::Update { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.component_id != 0 { + len += 1; + } + if !self.payload.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.mls.database.AppDataUpdateData.Update", len)?; + if self.component_id != 0 { + struct_ser.serialize_field("component_id", &self.component_id)?; + } + if !self.payload.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("payload", pbjson::private::base64::encode(&self.payload).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for app_data_update_data::Update { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "component_id", + "componentId", + "payload", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + ComponentId, + Payload, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl serde::de::Visitor<'_> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "componentId" | "component_id" => Ok(GeneratedField::ComponentId), + "payload" => Ok(GeneratedField::Payload), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = app_data_update_data::Update; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.mls.database.AppDataUpdateData.Update") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut component_id__ = None; + let mut payload__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::ComponentId => { + if component_id__.is_some() { + return Err(serde::de::Error::duplicate_field("componentId")); + } + component_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Payload => { + if payload__.is_some() { + return Err(serde::de::Error::duplicate_field("payload")); + } + payload__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(app_data_update_data::Update { + component_id: component_id__.unwrap_or_default(), + payload: payload__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("xmtp.mls.database.AppDataUpdateData.Update", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for app_data_update_data::V1 { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -599,6 +718,9 @@ impl serde::Serialize for app_data_update_data::V1 { if !self.payload.is_empty() { len += 1; } + if !self.additional_updates.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("xmtp.mls.database.AppDataUpdateData.V1", len)?; if self.component_id != 0 { struct_ser.serialize_field("component_id", &self.component_id)?; @@ -608,6 +730,9 @@ impl serde::Serialize for app_data_update_data::V1 { #[allow(clippy::needless_borrows_for_generic_args)] struct_ser.serialize_field("payload", pbjson::private::base64::encode(&self.payload).as_str())?; } + if !self.additional_updates.is_empty() { + struct_ser.serialize_field("additional_updates", &self.additional_updates)?; + } struct_ser.end() } } @@ -621,12 +746,15 @@ impl<'de> serde::Deserialize<'de> for app_data_update_data::V1 { "component_id", "componentId", "payload", + "additional_updates", + "additionalUpdates", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { ComponentId, Payload, + AdditionalUpdates, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -651,6 +779,7 @@ impl<'de> serde::Deserialize<'de> for app_data_update_data::V1 { match value { "componentId" | "component_id" => Ok(GeneratedField::ComponentId), "payload" => Ok(GeneratedField::Payload), + "additionalUpdates" | "additional_updates" => Ok(GeneratedField::AdditionalUpdates), _ => Ok(GeneratedField::__SkipField__), } } @@ -672,6 +801,7 @@ impl<'de> serde::Deserialize<'de> for app_data_update_data::V1 { { let mut component_id__ = None; let mut payload__ = None; + let mut additional_updates__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ComponentId => { @@ -690,6 +820,12 @@ impl<'de> serde::Deserialize<'de> for app_data_update_data::V1 { Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) ; } + GeneratedField::AdditionalUpdates => { + if additional_updates__.is_some() { + return Err(serde::de::Error::duplicate_field("additionalUpdates")); + } + additional_updates__ = Some(map_.next_value()?); + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -698,6 +834,7 @@ impl<'de> serde::Deserialize<'de> for app_data_update_data::V1 { Ok(app_data_update_data::V1 { component_id: component_id__.unwrap_or_default(), payload: payload__.unwrap_or_default(), + additional_updates: additional_updates__.unwrap_or_default(), }) } } diff --git a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.content_types.rs b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.content_types.rs index 375080f5d2..de3b0d73b7 100644 --- a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.content_types.rs +++ b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.content_types.rs @@ -1,112 +1,4 @@ // This file is @generated by prost-build. -/// DeleteMessage message type -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct DeleteMessage { - /// ID of the message to delete - #[prost(string, tag = "1")] - pub message_id: ::prost::alloc::string::String, -} -impl ::prost::Name for DeleteMessage { - const NAME: &'static str = "DeleteMessage"; - const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.content_types.DeleteMessage".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.content_types.DeleteMessage".into() - } -} -/// EditMessage message type -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct EditMessage { - /// ID of the message to edit - #[prost(string, tag = "1")] - pub message_id: ::prost::alloc::string::String, - /// The new content for the message - #[prost(message, optional, tag = "2")] - pub edited_content: ::core::option::Option, -} -impl ::prost::Name for EditMessage { - const NAME: &'static str = "EditMessage"; - const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.content_types.EditMessage".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.content_types.EditMessage".into() - } -} -/// LeaveRequest message type -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct LeaveRequest { - /// A serialized AuthenticatedNote containing the sender's signed, member-only verifiable statement - #[prost(bytes = "vec", optional, tag = "1")] - pub authenticated_note: ::core::option::Option<::prost::alloc::vec::Vec>, -} -impl ::prost::Name for LeaveRequest { - const NAME: &'static str = "LeaveRequest"; - const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.content_types.LeaveRequest".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.content_types.LeaveRequest".into() - } -} -/// MultiRemoteAttachment message type -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MultiRemoteAttachment { - /// Array of attachment information - #[prost(message, repeated, tag = "1")] - pub attachments: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for MultiRemoteAttachment { - const NAME: &'static str = "MultiRemoteAttachment"; - const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.content_types.MultiRemoteAttachment".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.content_types.MultiRemoteAttachment".into() - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct RemoteAttachmentInfo { - /// The SHA256 hash of the remote content - #[prost(string, tag = "1")] - pub content_digest: ::prost::alloc::string::String, - /// A 32 byte array for decrypting the remote content payload - #[prost(bytes = "vec", tag = "2")] - pub secret: ::prost::alloc::vec::Vec, - /// A byte array for the nonce used to encrypt the remote content payload - #[prost(bytes = "vec", tag = "3")] - pub nonce: ::prost::alloc::vec::Vec, - /// A byte array for the salt used to encrypt the remote content payload - #[prost(bytes = "vec", tag = "4")] - pub salt: ::prost::alloc::vec::Vec, - /// The scheme of the URL. Must be " - #[prost(string, tag = "5")] - pub scheme: ::prost::alloc::string::String, - /// The URL of the remote content - #[prost(string, tag = "6")] - pub url: ::prost::alloc::string::String, - /// The size of the encrypted content in bytes (max size of 4GB) - #[prost(uint32, optional, tag = "7")] - pub content_length: ::core::option::Option, - /// The filename of the remote content - #[prost(string, optional, tag = "8")] - pub filename: ::core::option::Option<::prost::alloc::string::String>, -} -impl ::prost::Name for RemoteAttachmentInfo { - const NAME: &'static str = "RemoteAttachmentInfo"; - const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.content_types.RemoteAttachmentInfo".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.content_types.RemoteAttachmentInfo".into() - } -} /// Reaction message type #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ReactionV2 { @@ -200,6 +92,94 @@ impl ReactionSchema { } } } +/// DeleteMessage message type +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteMessage { + /// ID of the message to delete + #[prost(string, tag = "1")] + pub message_id: ::prost::alloc::string::String, +} +impl ::prost::Name for DeleteMessage { + const NAME: &'static str = "DeleteMessage"; + const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.content_types.DeleteMessage".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.content_types.DeleteMessage".into() + } +} +/// MultiRemoteAttachment message type +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MultiRemoteAttachment { + /// Array of attachment information + #[prost(message, repeated, tag = "1")] + pub attachments: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for MultiRemoteAttachment { + const NAME: &'static str = "MultiRemoteAttachment"; + const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.content_types.MultiRemoteAttachment".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.content_types.MultiRemoteAttachment".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoteAttachmentInfo { + /// The SHA256 hash of the remote content + #[prost(string, tag = "1")] + pub content_digest: ::prost::alloc::string::String, + /// A 32 byte array for decrypting the remote content payload + #[prost(bytes = "vec", tag = "2")] + pub secret: ::prost::alloc::vec::Vec, + /// A byte array for the nonce used to encrypt the remote content payload + #[prost(bytes = "vec", tag = "3")] + pub nonce: ::prost::alloc::vec::Vec, + /// A byte array for the salt used to encrypt the remote content payload + #[prost(bytes = "vec", tag = "4")] + pub salt: ::prost::alloc::vec::Vec, + /// The scheme of the URL. Must be " + #[prost(string, tag = "5")] + pub scheme: ::prost::alloc::string::String, + /// The URL of the remote content + #[prost(string, tag = "6")] + pub url: ::prost::alloc::string::String, + /// The size of the encrypted content in bytes (max size of 4GB) + #[prost(uint32, optional, tag = "7")] + pub content_length: ::core::option::Option, + /// The filename of the remote content + #[prost(string, optional, tag = "8")] + pub filename: ::core::option::Option<::prost::alloc::string::String>, +} +impl ::prost::Name for RemoteAttachmentInfo { + const NAME: &'static str = "RemoteAttachmentInfo"; + const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.content_types.RemoteAttachmentInfo".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.content_types.RemoteAttachmentInfo".into() + } +} +/// LeaveRequest message type +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LeaveRequest { + /// A serialized AuthenticatedNote containing the sender's signed, member-only verifiable statement + #[prost(bytes = "vec", optional, tag = "1")] + pub authenticated_note: ::core::option::Option<::prost::alloc::vec::Vec>, +} +impl ::prost::Name for LeaveRequest { + const NAME: &'static str = "LeaveRequest"; + const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.content_types.LeaveRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.content_types.LeaveRequest".into() + } +} /// WalletSendCalls represents parameters for sending wallet calls #[derive(Clone, PartialEq, ::prost::Message)] pub struct WalletSendCalls { @@ -262,3 +242,23 @@ impl ::prost::Name for Call { "/xmtp.mls.message_contents.content_types.Call".into() } } +/// EditMessage message type +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EditMessage { + /// ID of the message to edit + #[prost(string, tag = "1")] + pub message_id: ::prost::alloc::string::String, + /// The new content for the message + #[prost(message, optional, tag = "2")] + pub edited_content: ::core::option::Option, +} +impl ::prost::Name for EditMessage { + const NAME: &'static str = "EditMessage"; + const PACKAGE: &'static str = "xmtp.mls.message_contents.content_types"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.content_types.EditMessage".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.content_types.EditMessage".into() + } +} diff --git a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.rs b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.rs index 01bb177c28..944366e21a 100644 --- a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.rs +++ b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.rs @@ -1,93 +1,4 @@ // This file is @generated by prost-build. -/// PlaintextCommitLogEntry indicates whether a commit was successful or not, -/// when applied on top of the indicated `last_epoch_authenticator`. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct PlaintextCommitLogEntry { - /// The group_id of the group that the commit belongs to. - #[prost(bytes = "vec", tag = "1")] - pub group_id: ::prost::alloc::vec::Vec, - /// The sequence ID of the commit payload being validated. - #[prost(uint64, tag = "2")] - pub commit_sequence_id: u64, - /// The encryption state before the commit was applied. - #[prost(bytes = "vec", tag = "3")] - pub last_epoch_authenticator: ::prost::alloc::vec::Vec, - /// Indicates whether the commit was successful, or why it failed. - #[prost(enumeration = "CommitResult", tag = "4")] - pub commit_result: i32, - /// The epoch number after the commit was applied, if successful. - #[prost(uint64, tag = "5")] - pub applied_epoch_number: u64, - /// The encryption state after the commit was applied, if successful. - #[prost(bytes = "vec", tag = "6")] - pub applied_epoch_authenticator: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for PlaintextCommitLogEntry { - const NAME: &'static str = "PlaintextCommitLogEntry"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PlaintextCommitLogEntry".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PlaintextCommitLogEntry".into() - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct CommitLogEntry { - #[prost(uint64, tag = "1")] - pub sequence_id: u64, - #[prost(bytes = "vec", tag = "2")] - pub serialized_commit_log_entry: ::prost::alloc::vec::Vec, - #[prost(message, optional, tag = "3")] - pub signature: ::core::option::Option< - super::super::identity::associations::RecoverableEd25519Signature, - >, -} -impl ::prost::Name for CommitLogEntry { - const NAME: &'static str = "CommitLogEntry"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.CommitLogEntry".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.CommitLogEntry".into() - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum CommitResult { - Unspecified = 0, - Applied = 1, - WrongEpoch = 2, - Undecryptable = 3, - Invalid = 4, -} -impl CommitResult { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "COMMIT_RESULT_UNSPECIFIED", - Self::Applied => "COMMIT_RESULT_APPLIED", - Self::WrongEpoch => "COMMIT_RESULT_WRONG_EPOCH", - Self::Undecryptable => "COMMIT_RESULT_UNDECRYPTABLE", - Self::Invalid => "COMMIT_RESULT_INVALID", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "COMMIT_RESULT_UNSPECIFIED" => Some(Self::Unspecified), - "COMMIT_RESULT_APPLIED" => Some(Self::Applied), - "COMMIT_RESULT_WRONG_EPOCH" => Some(Self::WrongEpoch), - "COMMIT_RESULT_UNDECRYPTABLE" => Some(Self::Undecryptable), - "COMMIT_RESULT_INVALID" => Some(Self::Invalid), - _ => None, - } - } -} /// A WelcomePointer is used to point to the welcome message for several installations at once to save overhead #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct WelcomePointer { @@ -275,307 +186,974 @@ impl WelcomeWrapperAlgorithm { } } } -/// Message for group mutable metadata -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GroupMutablePermissionsV1 { - #[prost(message, optional, tag = "1")] - pub policies: ::core::option::Option, +/// A symmetric AEAD key. In every v1 envelope this is a 32-byte +/// ChaCha20Poly1305 key. The protobuf submessage does not itself constrain +/// length, so validators and setters MUST check that `material` is exactly +/// 32 bytes for v1; empty or wrong-length `material` is invalid. The +/// algorithm is fixed by the enclosing envelope version, not carried here — +/// a new algorithm is a new envelope version, never an in-message tag that +/// could disagree with the version. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SymmetricKey { + #[prost(bytes = "vec", tag = "1")] + pub material: ::prost::alloc::vec::Vec, } -impl ::prost::Name for GroupMutablePermissionsV1 { - const NAME: &'static str = "GroupMutablePermissionsV1"; +impl ::prost::Name for SymmetricKey { + const NAME: &'static str = "SymmetricKey"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMutablePermissionsV1".into() + "xmtp.mls.message_contents.SymmetricKey".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMutablePermissionsV1".into() - } -} -/// The set of policies that govern the group -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct PolicySet { - #[prost(message, optional, tag = "1")] - pub add_member_policy: ::core::option::Option, - #[prost(message, optional, tag = "2")] - pub remove_member_policy: ::core::option::Option, - #[prost(map = "string, message", tag = "3")] - pub update_metadata_policy: ::std::collections::HashMap< - ::prost::alloc::string::String, - MetadataPolicy, - >, - #[prost(message, optional, tag = "4")] - pub add_admin_policy: ::core::option::Option, - #[prost(message, optional, tag = "5")] - pub remove_admin_policy: ::core::option::Option, - #[prost(message, optional, tag = "6")] - pub update_permissions_policy: ::core::option::Option, + "/xmtp.mls.message_contents.SymmetricKey".into() + } +} +/// A digest of MLS group state at a single epoch. The hash function is the +/// one bound to the group's MLS ciphersuite (one ciphersuite per group, so +/// the algorithm is never ambiguous and is not carried on the wire). v1 +/// pins the preimage precisely: the TLS-serialized `GroupContext` of the +/// referenced epoch — which itself binds `epoch`, `tree_hash`, and +/// `confirmed_transcript_hash`. MLS is deterministic, so every member at an +/// epoch derives an identical `GroupContext` and therefore an identical +/// digest; equal digests mean identical group state. Like SymmetricKey, the +/// submessage does not constrain length: validators and setters MUST check +/// that `digest` is exactly the ciphersuite's hash output length (32 bytes +/// under XMTP's current ciphersuite); any other length is invalid. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GroupStateHash { + #[prost(bytes = "vec", tag = "1")] + pub digest: ::prost::alloc::vec::Vec, } -impl ::prost::Name for PolicySet { - const NAME: &'static str = "PolicySet"; +impl ::prost::Name for GroupStateHash { + const NAME: &'static str = "GroupStateHash"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PolicySet".into() + "xmtp.mls.message_contents.GroupStateHash".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PolicySet".into() + "/xmtp.mls.message_contents.GroupStateHash".into() } } -/// A policy that governs adding/removing members or installations -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MembershipPolicy { - #[prost(oneof = "membership_policy::Kind", tags = "1, 2, 3")] - pub kind: ::core::option::Option, +/// Where the EncryptedGroupInfoBlob is hosted. A typed transport with a +/// validated happy path plus an application-defined escape hatch. Lives in +/// the invite payload (the QR) and, optionally, in +/// EXTERNAL_COMMIT_POLICY.refresh_pointers so members can keep slots fresh. +/// Exactly one `location` variant MUST be set; a ServicePointer with no +/// variant set (the empty oneof) gives the joiner no fetch target and MUST +/// be treated as a parse failure (fail-closed), like an unrecognized +/// version. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ServicePointer { + #[prost(oneof = "service_pointer::Location", tags = "1, 2")] + pub location: ::core::option::Option, } -/// Nested message and enum types in `MembershipPolicy`. -pub mod membership_policy { - /// Combine multiple policies. All must evaluate to true - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct AndCondition { - #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, - } - impl ::prost::Name for AndCondition { - const NAME: &'static str = "AndCondition"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MembershipPolicy.AndCondition".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MembershipPolicy.AndCondition".into() - } - } - /// Combine multiple policies. Any must evaluate to true - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct AnyCondition { - #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, - } - impl ::prost::Name for AnyCondition { - const NAME: &'static str = "AnyCondition"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MembershipPolicy.AnyCondition".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MembershipPolicy.AnyCondition".into() - } - } - /// Base policy - #[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - ::prost::Enumeration - )] - #[repr(i32)] - pub enum BasePolicy { - Unspecified = 0, - Allow = 1, - Deny = 2, - AllowIfAdminOrSuperAdmin = 3, - AllowIfSuperAdmin = 4, - } - impl BasePolicy { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "BASE_POLICY_UNSPECIFIED", - Self::Allow => "BASE_POLICY_ALLOW", - Self::Deny => "BASE_POLICY_DENY", - Self::AllowIfAdminOrSuperAdmin => { - "BASE_POLICY_ALLOW_IF_ADMIN_OR_SUPER_ADMIN" - } - Self::AllowIfSuperAdmin => "BASE_POLICY_ALLOW_IF_SUPER_ADMIN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), - "BASE_POLICY_ALLOW" => Some(Self::Allow), - "BASE_POLICY_DENY" => Some(Self::Deny), - "BASE_POLICY_ALLOW_IF_ADMIN_OR_SUPER_ADMIN" => { - Some(Self::AllowIfAdminOrSuperAdmin) - } - "BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => Some(Self::AllowIfSuperAdmin), - _ => None, - } - } - } - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Kind { - #[prost(enumeration = "BasePolicy", tag = "1")] - Base(i32), - #[prost(message, tag = "2")] - AndCondition(AndCondition), - #[prost(message, tag = "3")] - AnyCondition(AnyCondition), +/// Nested message and enum types in `ServicePointer`. +pub mod service_pointer { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Location { + /// RFC 3986 https URI. libxmtp parses and syntactically validates it. + /// Clients MUST require the https scheme, MUST NOT follow redirects to + /// other schemes, and MUST ignore credentials embedded in the URL. + #[prost(string, tag = "1")] + HttpsUrl(::prost::alloc::string::String), + /// Application-defined opaque bytes for non-URL transports (NFC tags, + /// custom resolver schemes, etc.). Opaque to libxmtp. + #[prost(bytes, tag = "2")] + Opaque(::prost::alloc::vec::Vec), } } -impl ::prost::Name for MembershipPolicy { - const NAME: &'static str = "MembershipPolicy"; +impl ::prost::Name for ServicePointer { + const NAME: &'static str = "ServicePointer"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MembershipPolicy".into() + "xmtp.mls.message_contents.ServicePointer".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MembershipPolicy".into() + "/xmtp.mls.message_contents.ServicePointer".into() } } -/// A policy that governs updating metadata -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct MetadataPolicy { - #[prost(oneof = "metadata_policy::Kind", tags = "1, 2, 3")] - pub kind: ::core::option::Option, -} -/// Nested message and enum types in `MetadataPolicy`. -pub mod metadata_policy { - /// Combine multiple policies. All must evaluate to true - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct AndCondition { - #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, - } - impl ::prost::Name for AndCondition { - const NAME: &'static str = "AndCondition"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MetadataPolicy.AndCondition".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MetadataPolicy.AndCondition".into() - } - } - /// Combine multiple policies. Any must evaluate to true - #[derive(Clone, PartialEq, ::prost::Message)] - pub struct AnyCondition { - #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, +/// v1 shape of the shareable invite payload for QR-code or link-based +/// joining of an XMTP group via an MLS external commit. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExternalInvitePayloadV1 { + /// Typed pointer to where the encrypted blob is hosted (HTTPS URL or + /// application-defined opaque bytes). Per-QR; not stored in group state. + /// + /// MAY be ABSENT: an application whose scanner already knows how to + /// reach its service (a first-party reader with a baked-in endpoint) + /// omits the pointer entirely and resolves the service out-of-band. + /// This keeps the fetch target out of the QR — a leaked payload then + /// reveals no service location. When the field IS present, exactly one + /// `location` variant MUST be set (see ServicePointer): present-but- + /// empty is a parse failure; absent means application-resolved. + #[prost(message, optional, tag = "1")] + pub service_pointer: ::core::option::Option, + /// Identifier for the service slot holding the encrypted blob. Format + /// is application-defined (UUID, snowflake, short slot key, etc.) and + /// opaque to libxmtp; the only constraint is that the value is unique + /// within the chosen service. Decoupled from the MLS group_id — + /// rotation may keep this stable (overwrite the same slot) or change + /// it (new slot on the service); the admin chooses per invite. + /// + /// MUST be at least 4 bytes (collision-avoidance floor for tiny + /// services). RECOMMENDED: 16 random bytes when no application- + /// specific scheme is in use. Maximum length is not capped by the + /// protocol; applications should bound it to fit their QR / link + /// transport. + /// + /// After joining, the joiner verifies this matches + /// `EXTERNAL_COMMIT_POLICY.external_group_id` in the group state as + /// defense-in-depth against a stale or swapped QR. + #[prost(bytes = "vec", tag = "2")] + pub external_group_id: ::prost::alloc::vec::Vec, + /// ChaCha20Poly1305 key (32 bytes in v1) used to wrap the GroupInfo. + /// Matches `EXTERNAL_COMMIT_POLICY.symmetric_key` in the group state. + #[prost(message, optional, tag = "3")] + pub symmetric_key: ::core::option::Option, +} +impl ::prost::Name for ExternalInvitePayloadV1 { + const NAME: &'static str = "ExternalInvitePayloadV1"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.ExternalInvitePayloadV1".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.ExternalInvitePayloadV1".into() + } +} +/// Versioned envelope for the shareable invite payload. The application +/// embeds the serialized bytes in whatever transport it prefers (hex, +/// base64, raw QR, NFC, etc.) and stores the corresponding +/// EncryptedGroupInfoBlob on an external service keyed by the v1 payload's +/// `external_group_id`. +/// +/// New wire-format variants are added as new oneof entries; readers that +/// don't recognize a variant treat the invite as unparseable and fail +/// closed (no implicit downgrade). +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ExternalInvitePayload { + #[prost(oneof = "external_invite_payload::Version", tags = "1")] + pub version: ::core::option::Option, +} +/// Nested message and enum types in `ExternalInvitePayload`. +pub mod external_invite_payload { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Version { + #[prost(message, tag = "1")] + V1(super::ExternalInvitePayloadV1), + } +} +impl ::prost::Name for ExternalInvitePayload { + const NAME: &'static str = "ExternalInvitePayload"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.ExternalInvitePayload".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.ExternalInvitePayload".into() + } +} +/// v1 shape of the encrypted-GroupInfo envelope. +/// +/// `epoch` and `group_state_hash` are plaintext metadata. They are NOT a +/// trust anchor for the service — both are uploader-asserted, so a +/// conformant service MUST NOT use them to evict entries or "pick a +/// winner" (doing so turns a forged high `epoch` into a permanent slot +/// wedge). A conformant service instead retains the most recent N uploads +/// (RECOMMENDED N >= 4), evicting by arrival order (FIFO), and coalesces +/// only byte-identical re-uploads (equal across ALL fields — the plaintext +/// metadata alone is attacker-copyable). The roles of the metadata are +/// joiner-side: +/// +/// * `epoch` lets the joiner sort the retained candidates and prefer +/// the freshest, and lets the joiner reject a blob whose claimed +/// epoch disagrees with the GroupInfo it decrypts. +/// +/// * `group_state_hash` lets the joiner confirm, after decrypting, that +/// the blob's metadata matches the wrapped GroupInfo, and lets a +/// member recognize an idempotent re-upload. +/// +/// The trust anchor is the joiner's key + MLS validation, not the +/// metadata: the joiner AEAD-decrypts each candidate and parses it to a +/// GroupInfo whose internal epoch and `digest(GroupContext)` MUST equal +/// the blob's `epoch` and `group_state_hash` before it will join. A blob +/// that fails either check is discarded. +/// +/// All plaintext metadata is additionally bound into the AEAD as +/// associated data. v1 AAD = `epoch` || `expires_at_ns` (each 8-byte +/// big-endian) || `group_state_hash.digest`. A writer without the key +/// therefore cannot mutate any metadata field of a genuine ciphertext — +/// re-uploading a captured blob with, say, an extended `expires_at_ns` +/// fails the unwrap. A key-holder can still re-mint a blob with arbitrary +/// metadata; the post-decrypt consistency check and the validator-side +/// policy bounds cover that case. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct EncryptedGroupInfoBlobV1 { + /// 12 bytes; ChaCha20Poly1305 nonce specific to this ciphertext. MUST + /// be generated uniformly at random from a cryptographically secure + /// source for every encryption; deterministic (counter-based) schemes + /// are forbidden. Many independent writers encrypt under the same + /// long-lived key (every joiner refreshes the blob), and two writers' + /// counters colliding would reuse a nonce — which in ChaCha20Poly1305 + /// reuses the cipher stream and leaks the Poly1305 forgery key for + /// that nonce. + #[prost(bytes = "vec", tag = "1")] + pub nonce: ::prost::alloc::vec::Vec, + /// wrap_payload_symmetric output: AEAD ciphertext over the serialized + /// MlsMessageOut(GroupInfo), with the v1 AAD (see the message comment). + #[prost(bytes = "vec", tag = "2")] + pub ciphertext: ::prost::alloc::vec::Vec, + /// MLS group epoch of the wrapped GroupInfo. Plaintext, uploader- + /// asserted. Used by the joiner to prefer the freshest candidate and to + /// consistency-check the decrypted GroupInfo; NOT used by the service + /// to order or evict. Joiner verifies against the decrypted GroupInfo + /// before joining. + #[prost(uint64, tag = "3")] + pub epoch: u64, + /// Digest of the wrapped GroupInfo's epoch state (see GroupStateHash: a + /// digest over the canonical `GroupContext` under the group's MLS + /// ciphersuite). Plaintext. The joiner verifies it equals + /// `digest(GroupContext)` of the decrypted GroupInfo; a member uses it + /// to recognize an idempotent re-upload. Not used for service ordering. + #[prost(message, optional, tag = "4")] + pub group_state_hash: ::core::option::Option, + /// Wall-clock expiry of this blob, in nanoseconds since UNIX epoch. + /// 0 means no expiry. The service uses this as a TTL hint and MAY + /// garbage-collect blobs past their `expires_at_ns` autonomously. + /// The joining client also enforces this — refuses to join from an + /// expired blob even if the service is still serving it. + /// + /// This is the blob's EFFECTIVE expiry, computed by the uploader as the + /// earlier of the two policy bounds that apply at wrap time (saturating; + /// a bound of 0 means "no bound" and drops out of the min): + /// + /// min(EXTERNAL_COMMIT_POLICY.expires_at_ns, // campaign end + /// epoch_start_ns + EXTERNAL_COMMIT_POLICY.expire_in_ns) + /// + /// where epoch_start_ns is the delivery-service envelope timestamp of + /// the commit that began the wrapped GroupInfo's epoch (known to every + /// uploader). Folding the staleness bound in here means the joiner's + /// single expiry check also skips candidates that validators would + /// reject as stale — avoiding a "zombie join" where the joiner + /// publishes a commit every member rejects and believes it joined a + /// group that never accepted it — and the service's TTL-based GC + /// naturally collects staleness-dead blobs. 0 only when neither policy + /// bound is set. + /// + /// AAD-bound: a writer without the key cannot alter a genuine blob's + /// expiry (see the message comment). A key-holder can re-mint with a + /// later value, so this is a TTL / UX bound, not a security bound + /// against key-holders — the authoritative bounds are the policy + /// fields, enforced by validators against envelope timestamps. + #[prost(uint64, tag = "5")] + pub expires_at_ns: u64, +} +impl ::prost::Name for EncryptedGroupInfoBlobV1 { + const NAME: &'static str = "EncryptedGroupInfoBlobV1"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.EncryptedGroupInfoBlobV1".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.EncryptedGroupInfoBlobV1".into() + } +} +/// Versioned envelope wrapping a single GroupInfo TLS-serialized bytes +/// under an AEAD scheme (ChaCha20Poly1305 in v1) with a fresh random nonce +/// per re-encryption and all plaintext metadata bound as associated data +/// (see EncryptedGroupInfoBlobV1). Stored on the external service; joiners +/// upload a refreshed blob (fresh nonce) after each successful join, and +/// the slot retains the most recent uploads. +/// +/// New variants represent breaking wire-format changes (different AEAD, +/// different metadata layout). Readers that don't recognize a variant +/// fail closed — the joiner cannot attempt MLS state transitions against +/// a blob it can't validate. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct EncryptedGroupInfoBlob { + #[prost(oneof = "encrypted_group_info_blob::Version", tags = "1")] + pub version: ::core::option::Option, +} +/// Nested message and enum types in `EncryptedGroupInfoBlob`. +pub mod encrypted_group_info_blob { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Version { + #[prost(message, tag = "1")] + V1(super::EncryptedGroupInfoBlobV1), + } +} +impl ::prost::Name for EncryptedGroupInfoBlob { + const NAME: &'static str = "EncryptedGroupInfoBlob"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.EncryptedGroupInfoBlob".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.EncryptedGroupInfoBlob".into() + } +} +/// Message for group mutable metadata +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GroupMutableMetadataV1 { + /// Map to store various metadata attributes (Group name, etc.) + #[prost(map = "string, string", tag = "1")] + pub attributes: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, + #[prost(message, optional, tag = "2")] + pub admin_list: ::core::option::Option, + /// Creator starts as only super_admin + /// Only super_admin can add/remove other super_admin + #[prost(message, optional, tag = "3")] + pub super_admin_list: ::core::option::Option, +} +impl ::prost::Name for GroupMutableMetadataV1 { + const NAME: &'static str = "GroupMutableMetadataV1"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.GroupMutableMetadataV1".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.GroupMutableMetadataV1".into() + } +} +/// Wrapper around a list of repeated Inbox Ids +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Inboxes { + #[prost(string, repeated, tag = "1")] + pub inbox_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +impl ::prost::Name for Inboxes { + const NAME: &'static str = "Inboxes"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.Inboxes".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.Inboxes".into() + } +} +/// PlaintextCommitLogEntry indicates whether a commit was successful or not, +/// when applied on top of the indicated `last_epoch_authenticator`. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PlaintextCommitLogEntry { + /// The group_id of the group that the commit belongs to. + #[prost(bytes = "vec", tag = "1")] + pub group_id: ::prost::alloc::vec::Vec, + /// The sequence ID of the commit payload being validated. + #[prost(uint64, tag = "2")] + pub commit_sequence_id: u64, + /// The encryption state before the commit was applied. + #[prost(bytes = "vec", tag = "3")] + pub last_epoch_authenticator: ::prost::alloc::vec::Vec, + /// Indicates whether the commit was successful, or why it failed. + #[prost(enumeration = "CommitResult", tag = "4")] + pub commit_result: i32, + /// The epoch number after the commit was applied, if successful. + #[prost(uint64, tag = "5")] + pub applied_epoch_number: u64, + /// The encryption state after the commit was applied, if successful. + #[prost(bytes = "vec", tag = "6")] + pub applied_epoch_authenticator: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for PlaintextCommitLogEntry { + const NAME: &'static str = "PlaintextCommitLogEntry"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PlaintextCommitLogEntry".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PlaintextCommitLogEntry".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CommitLogEntry { + #[prost(uint64, tag = "1")] + pub sequence_id: u64, + #[prost(bytes = "vec", tag = "2")] + pub serialized_commit_log_entry: ::prost::alloc::vec::Vec, + #[prost(message, optional, tag = "3")] + pub signature: ::core::option::Option< + super::super::identity::associations::RecoverableEd25519Signature, + >, +} +impl ::prost::Name for CommitLogEntry { + const NAME: &'static str = "CommitLogEntry"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.CommitLogEntry".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.CommitLogEntry".into() + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum CommitResult { + Unspecified = 0, + Applied = 1, + WrongEpoch = 2, + Undecryptable = 3, + Invalid = 4, +} +impl CommitResult { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "COMMIT_RESULT_UNSPECIFIED", + Self::Applied => "COMMIT_RESULT_APPLIED", + Self::WrongEpoch => "COMMIT_RESULT_WRONG_EPOCH", + Self::Undecryptable => "COMMIT_RESULT_UNDECRYPTABLE", + Self::Invalid => "COMMIT_RESULT_INVALID", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "COMMIT_RESULT_UNSPECIFIED" => Some(Self::Unspecified), + "COMMIT_RESULT_APPLIED" => Some(Self::Applied), + "COMMIT_RESULT_WRONG_EPOCH" => Some(Self::WrongEpoch), + "COMMIT_RESULT_UNDECRYPTABLE" => Some(Self::Undecryptable), + "COMMIT_RESULT_INVALID" => Some(Self::Invalid), + _ => None, + } + } +} +/// Extension data for proposal support in group context. +/// When present in the group context extensions, indicates the group +/// uses proposal-by-reference flow. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ProposalSupport { + #[prost(uint32, tag = "1")] + pub version: u32, +} +impl ::prost::Name for ProposalSupport { + const NAME: &'static str = "ProposalSupport"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.ProposalSupport".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.ProposalSupport".into() + } +} +/// ContentTypeId is used to identify the type of content stored in a Message. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ContentTypeId { + /// authority governing this content type + #[prost(string, tag = "1")] + pub authority_id: ::prost::alloc::string::String, + /// type identifier + #[prost(string, tag = "2")] + pub type_id: ::prost::alloc::string::String, + /// major version of the type + #[prost(uint32, tag = "3")] + pub version_major: u32, + /// minor version of the type + #[prost(uint32, tag = "4")] + pub version_minor: u32, +} +impl ::prost::Name for ContentTypeId { + const NAME: &'static str = "ContentTypeId"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.ContentTypeId".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.ContentTypeId".into() + } +} +/// EncodedContent bundles the content with metadata identifying its type +/// and parameters required for correct decoding and presentation of the content. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct EncodedContent { + /// content type identifier used to match the payload with + /// the correct decoding machinery + #[prost(message, optional, tag = "1")] + pub r#type: ::core::option::Option, + /// optional encoding parameters required to correctly decode the content + #[prost(map = "string, string", tag = "2")] + pub parameters: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, + /// optional fallback description of the content that can be used in case + /// the client cannot decode or render the content + #[prost(string, optional, tag = "3")] + pub fallback: ::core::option::Option<::prost::alloc::string::String>, + /// optional compression; the value indicates algorithm used to + /// compress the encoded content bytes + #[prost(enumeration = "Compression", optional, tag = "5")] + pub compression: ::core::option::Option, + /// encoded content itself + #[prost(bytes = "vec", tag = "4")] + pub content: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for EncodedContent { + const NAME: &'static str = "EncodedContent"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.EncodedContent".into() } - impl ::prost::Name for AnyCondition { - const NAME: &'static str = "AnyCondition"; + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.EncodedContent".into() + } +} +/// A PlaintextEnvelope is the outermost payload that gets encrypted by MLS +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PlaintextEnvelope { + /// Selector which declares which version of the EncodedContent this + /// PlaintextEnvelope is + #[prost(oneof = "plaintext_envelope::Content", tags = "1, 2")] + pub content: ::core::option::Option, +} +/// Nested message and enum types in `PlaintextEnvelope`. +pub mod plaintext_envelope { + /// Version 1 of the encrypted envelope + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct V1 { + /// Expected to be EncodedContent + #[prost(bytes = "vec", tag = "1")] + pub content: ::prost::alloc::vec::Vec, + /// A unique value that can be used to ensure that the same content can + /// produce different hashes. May be the sender timestamp. + #[prost(string, tag = "2")] + pub idempotency_key: ::prost::alloc::string::String, + } + impl ::prost::Name for V1 { + const NAME: &'static str = "V1"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MetadataPolicy.AnyCondition".into() + "xmtp.mls.message_contents.PlaintextEnvelope.V1".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MetadataPolicy.AnyCondition".into() + "/xmtp.mls.message_contents.PlaintextEnvelope.V1".into() } } - /// Base policy - #[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - ::prost::Enumeration - )] - #[repr(i32)] - pub enum MetadataBasePolicy { - Unspecified = 0, - Allow = 1, - Deny = 2, - AllowIfAdmin = 3, - AllowIfSuperAdmin = 4, + /// Version 2 of the encrypted envelope + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct V2 { + /// A unique value that can be used to ensure that the same content can + /// produce different hashes. May be the sender timestamp. + #[prost(string, tag = "1")] + pub idempotency_key: ::prost::alloc::string::String, + #[prost(oneof = "v2::MessageType", tags = "2, 3, 4, 5")] + pub message_type: ::core::option::Option, } - impl MetadataBasePolicy { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "METADATA_BASE_POLICY_UNSPECIFIED", - Self::Allow => "METADATA_BASE_POLICY_ALLOW", - Self::Deny => "METADATA_BASE_POLICY_DENY", - Self::AllowIfAdmin => "METADATA_BASE_POLICY_ALLOW_IF_ADMIN", - Self::AllowIfSuperAdmin => "METADATA_BASE_POLICY_ALLOW_IF_SUPER_ADMIN", - } + /// Nested message and enum types in `V2`. + pub mod v2 { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum MessageType { + /// Expected to be EncodedContent + #[prost(bytes, tag = "2")] + Content(::prost::alloc::vec::Vec), + /// Initiator sends a request to receive sync payload + #[prost(message, tag = "3")] + DeviceSyncRequest( + super::super::super::super::device_sync::content::DeviceSyncRequest, + ), + /// Some other authorized installation sends a reply with a link to payload + #[prost(message, tag = "4")] + DeviceSyncReply( + super::super::super::super::device_sync::content::DeviceSyncReply, + ), + /// A serialized user preference update + #[prost(message, tag = "5")] + UserPreferenceUpdate( + super::super::super::super::device_sync::content::V1UserPreferenceUpdate, + ), } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "METADATA_BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), - "METADATA_BASE_POLICY_ALLOW" => Some(Self::Allow), - "METADATA_BASE_POLICY_DENY" => Some(Self::Deny), - "METADATA_BASE_POLICY_ALLOW_IF_ADMIN" => Some(Self::AllowIfAdmin), - "METADATA_BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => { - Some(Self::AllowIfSuperAdmin) - } - _ => None, - } + } + impl ::prost::Name for V2 { + const NAME: &'static str = "V2"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PlaintextEnvelope.V2".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PlaintextEnvelope.V2".into() + } + } + /// Selector which declares which version of the EncodedContent this + /// PlaintextEnvelope is + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Content { + #[prost(message, tag = "1")] + V1(V1), + #[prost(message, tag = "2")] + V2(V2), + } +} +impl ::prost::Name for PlaintextEnvelope { + const NAME: &'static str = "PlaintextEnvelope"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PlaintextEnvelope".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PlaintextEnvelope".into() + } +} +/// Recognized compression algorithms +/// protolint:disable ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Compression { + Deflate = 0, + Gzip = 1, +} +impl Compression { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Deflate => "COMPRESSION_DEFLATE", + Self::Gzip => "COMPRESSION_GZIP", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "COMPRESSION_DEFLATE" => Some(Self::Deflate), + "COMPRESSION_GZIP" => Some(Self::Gzip), + _ => None, } } +} +/// v1 external-commit-policy payload. +/// +/// The fields split into two classes. DURABLE SETTINGS (`expire_in_ns`, +/// `max_uses`) describe the group's posture toward ANY invite and survive +/// a revoke. PER-INVITE fields (`symmetric_key`, `external_group_id`, +/// `expires_at_ns`, `refresh_pointers`) describe the currently-active +/// invite and are cleared by a revoke. +/// +/// Field-coupling invariants enforced by libxmtp when applying an +/// AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal: +/// +/// * When `allow_external_commit` transitions to true: `symmetric_key` +/// and `external_group_id` MUST be populated (meeting their length +/// requirements) in the same proposal. The transitions are atomic — +/// there is no window where the bit is on but the invite coordinates +/// are unset. The enabling commit MUST also leave GROUP_MEMBERSHIP's +/// `ComponentMetadata.external_committer_permissions` admitting a +/// joiner writing its own entry (populating it in the same commit +/// when absent): every conforming external commit is structurally +/// required to write that entry, so without the grant the switch is +/// on but every join dead-ends at validation. The grant is an +/// ordinary permissions block — not a protocol-baked default — so +/// applications can evolve it later like any other permissions +/// surface. +/// +/// * When `allow_external_commit` transitions to false (revoke): every +/// per-invite field MUST be ABSENT from the resulting policy — not +/// serialized at all. For the proto3 scalar and repeated fields this +/// is the only cleared state there is (defaults — empty bytes, 0, +/// empty list — are never serialized; "empty" and "absent" are the +/// same wire state). The message-typed `symmetric_key` is the one +/// field with explicit presence and therefore a second representable +/// state, which is forbidden: an empty SymmetricKey submessage (or +/// empty `material`) is invalid — absence is the only cleared +/// encoding. Net effect: a revoked policy serializes to nothing but +/// the durable settings, byte-identical to a policy that never had +/// an invite. Validators enforce this as a state invariant — +/// `allow_external_commit == false` implies all four absent — so +/// stale state cannot linger (a lingering key could be revived by a +/// careless re-enable; lingering pointers re-adopted; a lingering +/// absolute `expires_at_ns` would silently mis-bound the next +/// campaign) and a re-enable mints everything fresh. +/// +/// * On every enable (first or re-enable): the new `symmetric_key` +/// MUST be freshly generated from a cryptographically secure random +/// source. Uniform 256-bit randomness guarantees — up to negligible +/// probability — that it differs from every previously-used key, +/// with no key-history tracking; validators do not (and cannot) +/// audit this rule, so it binds the setter. Reusing a revoked key +/// would re-validate every QR ever printed under that key, defeating +/// the revocation. The new `external_group_id` SHOULD also differ. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ExternalCommitPolicyV1 { + /// Master switch for MLS External Commits adding new members. + /// Required for the QR-invite flow. Defaults to false; admins + /// (super-admin by default) opt in via + /// AppDataUpdate(EXTERNAL_COMMIT_POLICY). + /// + /// See the field-coupling invariants in the message-level comment + /// above: enabling MUST populate symmetric_key + external_group_id; + /// revoking (true → false) MUST leave every per-invite field absent. + #[prost(bool, tag = "1")] + pub allow_external_commit: bool, + /// Wall-clock auto-disable timestamp (ns since UNIX epoch). + /// 0 = no automatic expiry. After this timestamp the validator + /// rejects all external commits regardless of `allow_external_commit`. + /// Lets admins issue time-bounded invite campaigns without having to + /// come back and flip the bit manually. Validators evaluate this + /// against the external commit's delivery-service envelope timestamp — + /// never the validator's wall clock at processing time — so members + /// that sync at different times reach the same verdict. + /// + /// Per-invite, not a durable setting: revoking clears it to 0 — i.e. + /// absent on the wire; proto3 never serializes defaults (see the + /// field-coupling invariants — an absolute campaign end left behind + /// would silently mis-bound the next campaign). While enabled, 0 + /// remains a legal value (no automatic expiry). + #[prost(uint64, tag = "2")] + pub expires_at_ns: u64, + /// Maximum staleness of the GroupInfo referenced by an external + /// commit, in nanoseconds. 0 = no staleness limit. Validators reject + /// an external commit when its envelope timestamp minus the current + /// epoch's start timestamp exceeds this value (the epoch-start is the + /// envelope timestamp of the message by which the validator entered or + /// observed the current epoch — the epoch's commit, or the Welcome + /// published together with it). Narrows the replay window for + /// stolen-blob attacks and forces re-export frequency. A coarse bound: + /// SHOULD be set to values (minutes or more) for which publish-latency + /// skew across members is immaterial. + /// + /// Durable setting: survives a revoke (it describes the group's + /// staleness posture for any invite, not the current campaign). + #[prost(uint64, tag = "3")] + pub expire_in_ns: u64, + /// ChaCha20Poly1305 key (32 bytes in v1) used to wrap the + /// EncryptedGroupInfoBlob for the currently-active invite. Carried in + /// the group state so any member (especially a just-joined external + /// committer) can re-export GroupInfo and re-upload a refreshed blob + /// under the same key after a join — without this, a printed QR / link + /// would die the moment the issuing admin went offline. + /// + /// The QR carries the same key. Rotation = admin sets a new value here + /// in a single AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal AND + /// issues a new QR carrying the matching key; old QR holders' keys no + /// longer decrypt blobs wrapped after the rotation. + /// + /// `material` MUST be exactly 32 bytes when populated (v1); the + /// SymmetricKey submessage does not enforce this, so validators and + /// setters MUST check it. The field being ABSENT is the canonical "no + /// active invite" encoding (an empty submessage or empty `material` is + /// invalid) — and MUST coincide with `allow_external_commit == false` + /// (see the field-coupling invariants at the top of this message). + /// Revoking the invite MUST clear this field; re-enabling MUST + /// populate it with a value freshly generated from a cryptographically + /// secure random source — uniform randomness guarantees distinctness + /// from every prior key without any key-history tracking. + /// + /// Note: the per-QR service_pointer is application-defined and travels + /// in the QR — joiners use the pointer from the QR they scanned (a + /// scanner cannot read group state before decrypting the blob). + /// Members keep slots fresh via the optional `refresh_pointers` list + /// below. + #[prost(message, optional, tag = "4")] + pub symmetric_key: ::core::option::Option, + /// Identifier for the service slot holding the active invite's + /// encrypted blob. Application-defined opaque bytes (UUID, snowflake, + /// short slot key, etc.); decoupled from the MLS group_id. Admins + /// MAY rotate the symmetric_key while keeping this stable (overwrite + /// the same slot on the service) or change both together (new slot, + /// leaves the old slot orphaned for application-side GC). + /// + /// The QR carries the same value. The joiner verifies that the QR's + /// `external_group_id` equals this field after joining, as + /// defense-in-depth against a stale or swapped QR. Mismatch indicates + /// the admin rotated to a new slot after the QR was minted; the + /// joining client MUST NOT upload a refreshed blob on mismatch (it + /// would land on an orphaned slot, or be encrypted under a rotated + /// key). + /// + /// MUST be at least 4 bytes when populated (collision-avoidance floor + /// for tiny services). RECOMMENDED: 16 random bytes when no + /// application-specific scheme is in use. Absent means no active + /// invite (proto3 bytes: an empty value is never serialized, so + /// "empty" and "absent" are the same wire state) — and MUST coincide + /// with `allow_external_commit == false` (see the field-coupling + /// invariants at the top of this message). Revoking the invite MUST + /// clear this field; re-enabling SHOULD use a freshly-generated value + /// (reusing a prior `external_group_id` is permitted only when the + /// admin intends to overwrite the old service slot — typically the + /// admin generates a new value to leave the prior slot orphaned). + #[prost(bytes = "vec", tag = "5")] + pub external_group_id: ::prost::alloc::vec::Vec, + /// Maximum number of members concurrently admitted to the group via + /// the currently-active invite. 0 = unlimited; 1 = a single active + /// invited member at a time. + /// + /// Enforced by every validating member, NOT by the service. Each + /// external committer tags its own GROUP_MEMBERSHIP entry with the + /// `external_group_id` it joined under (see + /// GroupMembershipEntry.V1.admitted_via_external_group_id); the live + /// use-count is the number of current GROUP_MEMBERSHIP entries + /// carrying the active `external_group_id`. A member rejects an + /// external commit when that count is already >= max_uses. Because the + /// count is read from GROUP_MEMBERSHIP in the shared group state — not + /// replayed from commit history — every member, INCLUDING one that + /// joined after the invite was issued, computes the same value and + /// converges. No change to the atomic external-commit shape: the + /// joiner writes only its own GROUP_MEMBERSHIP entry. + /// + /// Semantics are CONCURRENT, not total-ever: removing an invited + /// member drops its entry and frees a slot. The count is scoped to + /// `external_group_id`, so rotating to a new slot id starts a fresh + /// budget (entries under the old id no longer count); a same-slot + /// `symmetric_key`-only rotation does NOT reset it. To make an invite + /// truly one-shot, revoke it (allow_external_commit = false) or rotate + /// the `external_group_id` after the join. + /// + /// max_uses is intentionally scoped per-invite (per- + /// `external_group_id`): it throttles a single invite/slot, by design. + /// It is NOT a cap on the total inboxes in the group; a cap on how + /// many inboxes may join a group is a SEPARATE setting, not max_uses. + /// Durable setting: survives a revoke. + #[prost(uint32, tag = "6")] + pub max_uses: u32, + /// Service locations members use to keep the active invite's blob + /// fresh. Optional. When populated, every member re-wraps and + /// re-uploads the blob after epoch advances (jittered, check-before- + /// write — see XIP-82 "Keeping the blob fresh") — the poster on the + /// wall stays joinable while any one member is online. When empty, + /// only the issuing admin and past scanners (who know a pointer from + /// their own QR) can refresh, or the application drives refresh itself + /// against application-resolved pointers; that mode preserves pointer + /// secrecy from group state at a liveness cost. + /// + /// Listing pointers here places fetch locations in group state: every + /// member — including every future removed member — learns them. The + /// per-QR `service_pointer` is unaffected: scanners cannot read group + /// state before decrypting the blob, so the QR (or the app's own + /// knowledge) always carries the fetch target for joining. + /// + /// Per-invite, not a durable setting: revoking clears the list — i.e. + /// absent on the wire; repeated fields have no separate presence, an + /// empty list is simply not serialized (see the field-coupling + /// invariants). Re-enabling SHOULD populate fresh locations. While + /// enabled, an empty list remains the legal opt-out of member-driven + /// refresh. + #[prost(message, repeated, tag = "7")] + pub refresh_pointers: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for ExternalCommitPolicyV1 { + const NAME: &'static str = "ExternalCommitPolicyV1"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.ExternalCommitPolicyV1".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.ExternalCommitPolicyV1".into() + } +} +/// Versioned envelope. New variants are added as new oneof variants; +/// readers that don't recognize a variant treat the policy as default +/// (all fields zero) per the standard unknown-variant tolerance rules. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ExternalCommitPolicyEntry { + #[prost(oneof = "external_commit_policy_entry::Version", tags = "1")] + pub version: ::core::option::Option, +} +/// Nested message and enum types in `ExternalCommitPolicyEntry`. +pub mod external_commit_policy_entry { #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Kind { - #[prost(enumeration = "MetadataBasePolicy", tag = "1")] - Base(i32), - #[prost(message, tag = "2")] - AndCondition(AndCondition), - #[prost(message, tag = "3")] - AnyCondition(AnyCondition), + pub enum Version { + #[prost(message, tag = "1")] + V1(super::ExternalCommitPolicyV1), } } -impl ::prost::Name for MetadataPolicy { - const NAME: &'static str = "MetadataPolicy"; +impl ::prost::Name for ExternalCommitPolicyEntry { + const NAME: &'static str = "ExternalCommitPolicyEntry"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MetadataPolicy".into() + "xmtp.mls.message_contents.ExternalCommitPolicyEntry".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MetadataPolicy".into() + "/xmtp.mls.message_contents.ExternalCommitPolicyEntry".into() } } -/// A policy that governs updating permissions +/// Message for group mutable metadata #[derive(Clone, PartialEq, ::prost::Message)] -pub struct PermissionsUpdatePolicy { - #[prost(oneof = "permissions_update_policy::Kind", tags = "1, 2, 3")] - pub kind: ::core::option::Option, +pub struct GroupMutablePermissionsV1 { + #[prost(message, optional, tag = "1")] + pub policies: ::core::option::Option, } -/// Nested message and enum types in `PermissionsUpdatePolicy`. -pub mod permissions_update_policy { +impl ::prost::Name for GroupMutablePermissionsV1 { + const NAME: &'static str = "GroupMutablePermissionsV1"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.GroupMutablePermissionsV1".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.GroupMutablePermissionsV1".into() + } +} +/// The set of policies that govern the group +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PolicySet { + #[prost(message, optional, tag = "1")] + pub add_member_policy: ::core::option::Option, + #[prost(message, optional, tag = "2")] + pub remove_member_policy: ::core::option::Option, + #[prost(map = "string, message", tag = "3")] + pub update_metadata_policy: ::std::collections::HashMap< + ::prost::alloc::string::String, + MetadataPolicy, + >, + #[prost(message, optional, tag = "4")] + pub add_admin_policy: ::core::option::Option, + #[prost(message, optional, tag = "5")] + pub remove_admin_policy: ::core::option::Option, + #[prost(message, optional, tag = "6")] + pub update_permissions_policy: ::core::option::Option, +} +impl ::prost::Name for PolicySet { + const NAME: &'static str = "PolicySet"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PolicySet".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PolicySet".into() + } +} +/// A policy that governs adding/removing members or installations +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MembershipPolicy { + #[prost(oneof = "membership_policy::Kind", tags = "1, 2, 3")] + pub kind: ::core::option::Option, +} +/// Nested message and enum types in `MembershipPolicy`. +pub mod membership_policy { /// Combine multiple policies. All must evaluate to true #[derive(Clone, PartialEq, ::prost::Message)] pub struct AndCondition { #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, + pub policies: ::prost::alloc::vec::Vec, } impl ::prost::Name for AndCondition { const NAME: &'static str = "AndCondition"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PermissionsUpdatePolicy.AndCondition".into() + "xmtp.mls.message_contents.MembershipPolicy.AndCondition".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PermissionsUpdatePolicy.AndCondition".into() + "/xmtp.mls.message_contents.MembershipPolicy.AndCondition".into() } } /// Combine multiple policies. Any must evaluate to true #[derive(Clone, PartialEq, ::prost::Message)] pub struct AnyCondition { #[prost(message, repeated, tag = "1")] - pub policies: ::prost::alloc::vec::Vec, + pub policies: ::prost::alloc::vec::Vec, } impl ::prost::Name for AnyCondition { const NAME: &'static str = "AnyCondition"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PermissionsUpdatePolicy.AnyCondition".into() + "xmtp.mls.message_contents.MembershipPolicy.AnyCondition".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PermissionsUpdatePolicy.AnyCondition".into() + "/xmtp.mls.message_contents.MembershipPolicy.AnyCondition".into() } } /// Base policy @@ -589,733 +1167,519 @@ pub mod permissions_update_policy { PartialOrd, Ord, ::prost::Enumeration - )] - #[repr(i32)] - pub enum PermissionsBasePolicy { - Unspecified = 0, - Deny = 1, - AllowIfAdmin = 2, - AllowIfSuperAdmin = 3, - } - impl PermissionsBasePolicy { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "PERMISSIONS_BASE_POLICY_UNSPECIFIED", - Self::Deny => "PERMISSIONS_BASE_POLICY_DENY", - Self::AllowIfAdmin => "PERMISSIONS_BASE_POLICY_ALLOW_IF_ADMIN", - Self::AllowIfSuperAdmin => "PERMISSIONS_BASE_POLICY_ALLOW_IF_SUPER_ADMIN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "PERMISSIONS_BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), - "PERMISSIONS_BASE_POLICY_DENY" => Some(Self::Deny), - "PERMISSIONS_BASE_POLICY_ALLOW_IF_ADMIN" => Some(Self::AllowIfAdmin), - "PERMISSIONS_BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => { - Some(Self::AllowIfSuperAdmin) - } - _ => None, - } - } - } - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Kind { - #[prost(enumeration = "PermissionsBasePolicy", tag = "1")] - Base(i32), - #[prost(message, tag = "2")] - AndCondition(AndCondition), - #[prost(message, tag = "3")] - AnyCondition(AnyCondition), - } -} -impl ::prost::Name for PermissionsUpdatePolicy { - const NAME: &'static str = "PermissionsUpdatePolicy"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PermissionsUpdatePolicy".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PermissionsUpdatePolicy".into() - } -} -/// Per-component permission policy with separate rules for insert, update, -/// and delete operations. -/// -/// Insert and update are separate because some components need different -/// permission levels for creating vs modifying entries. For example, group -/// membership allows any member to update (installations/sequence ID) but -/// only admins to insert (add a new member). -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ComponentPermissions { - /// Policy for inserting a new value (component does not yet exist) - #[prost(message, optional, tag = "1")] - pub insert_policy: ::core::option::Option, - /// Policy for updating an existing value - #[prost(message, optional, tag = "2")] - pub update_policy: ::core::option::Option, - /// Policy for deleting a value - #[prost(message, optional, tag = "3")] - pub delete_policy: ::core::option::Option, -} -impl ::prost::Name for ComponentPermissions { - const NAME: &'static str = "ComponentPermissions"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ComponentPermissions".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ComponentPermissions".into() - } -} -/// Metadata describing a component: its data type and permission policies. -/// -/// Stored as the value in the component registry (ComponentId 0x8000). -/// Each registered component has one of these describing what kind of data -/// it holds and who can insert, update, or delete it. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ComponentMetadata { - /// The data structure type of the component's value - #[prost(enumeration = "ComponentType", tag = "1")] - pub component_type: i32, - /// Permission policies for this component, evaluated against regular - /// (member-issued) commits. - #[prost(message, optional, tag = "2")] - pub permissions: ::core::option::Option, - /// Permission policies for this component, evaluated against MLS External - /// Commits (RFC 9420 §12.4.3.2). Absent / unset is equivalent to all-Deny: - /// external committers cannot touch this component. Each component opts in - /// explicitly by setting this field. Combined with the EXTERNAL_COMMIT_POLICY - /// master switch (`allow_external_commit`), this is the per-component declarative - /// authorization for external-commit-driven joins. - #[prost(message, optional, tag = "3")] - pub external_committer_permissions: ::core::option::Option, -} -impl ::prost::Name for ComponentMetadata { - const NAME: &'static str = "ComponentMetadata"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ComponentMetadata".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ComponentMetadata".into() - } -} -/// The data structure type of a component's value -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ComponentType { - Unspecified = 0, - /// Opaque bytes, replaced atomically - Bytes = 1, - /// A utf-8 encoded string, replaced atomically - String = 2, - /// A TlsMap\ supporting key-level insert/update/delete via deltas - TlsMapBytesBytes = 3, - /// A TlsMap\ supporting key-level insert/update/delete via deltas - TlsMapInboxIdBytes = 4, - /// A TlsSet supporting insert/remove/remove-by-hash via deltas - TlsSetBytes = 5, - /// A TlsSet supporting insert/remove/remove-by-hash via deltas - TlsSetInboxId = 6, -} -impl ComponentType { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "COMPONENT_TYPE_UNSPECIFIED", - Self::Bytes => "COMPONENT_TYPE_BYTES", - Self::String => "COMPONENT_TYPE_STRING", - Self::TlsMapBytesBytes => "COMPONENT_TYPE_TLS_MAP_BYTES_BYTES", - Self::TlsMapInboxIdBytes => "COMPONENT_TYPE_TLS_MAP_INBOX_ID_BYTES", - Self::TlsSetBytes => "COMPONENT_TYPE_TLS_SET_BYTES", - Self::TlsSetInboxId => "COMPONENT_TYPE_TLS_SET_INBOX_ID", - } + )] + #[repr(i32)] + pub enum BasePolicy { + Unspecified = 0, + Allow = 1, + Deny = 2, + AllowIfAdminOrSuperAdmin = 3, + AllowIfSuperAdmin = 4, } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "COMPONENT_TYPE_UNSPECIFIED" => Some(Self::Unspecified), - "COMPONENT_TYPE_BYTES" => Some(Self::Bytes), - "COMPONENT_TYPE_STRING" => Some(Self::String), - "COMPONENT_TYPE_TLS_MAP_BYTES_BYTES" => Some(Self::TlsMapBytesBytes), - "COMPONENT_TYPE_TLS_MAP_INBOX_ID_BYTES" => Some(Self::TlsMapInboxIdBytes), - "COMPONENT_TYPE_TLS_SET_BYTES" => Some(Self::TlsSetBytes), - "COMPONENT_TYPE_TLS_SET_INBOX_ID" => Some(Self::TlsSetInboxId), - _ => None, + impl BasePolicy { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "BASE_POLICY_UNSPECIFIED", + Self::Allow => "BASE_POLICY_ALLOW", + Self::Deny => "BASE_POLICY_DENY", + Self::AllowIfAdminOrSuperAdmin => { + "BASE_POLICY_ALLOW_IF_ADMIN_OR_SUPER_ADMIN" + } + Self::AllowIfSuperAdmin => "BASE_POLICY_ALLOW_IF_SUPER_ADMIN", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), + "BASE_POLICY_ALLOW" => Some(Self::Allow), + "BASE_POLICY_DENY" => Some(Self::Deny), + "BASE_POLICY_ALLOW_IF_ADMIN_OR_SUPER_ADMIN" => { + Some(Self::AllowIfAdminOrSuperAdmin) + } + "BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => Some(Self::AllowIfSuperAdmin), + _ => None, + } } } -} -/// ContentTypeId is used to identify the type of content stored in a Message. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ContentTypeId { - /// authority governing this content type - #[prost(string, tag = "1")] - pub authority_id: ::prost::alloc::string::String, - /// type identifier - #[prost(string, tag = "2")] - pub type_id: ::prost::alloc::string::String, - /// major version of the type - #[prost(uint32, tag = "3")] - pub version_major: u32, - /// minor version of the type - #[prost(uint32, tag = "4")] - pub version_minor: u32, -} -impl ::prost::Name for ContentTypeId { - const NAME: &'static str = "ContentTypeId"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ContentTypeId".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ContentTypeId".into() + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(enumeration = "BasePolicy", tag = "1")] + Base(i32), + #[prost(message, tag = "2")] + AndCondition(AndCondition), + #[prost(message, tag = "3")] + AnyCondition(AnyCondition), } } -/// EncodedContent bundles the content with metadata identifying its type -/// and parameters required for correct decoding and presentation of the content. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct EncodedContent { - /// content type identifier used to match the payload with - /// the correct decoding machinery - #[prost(message, optional, tag = "1")] - pub r#type: ::core::option::Option, - /// optional encoding parameters required to correctly decode the content - #[prost(map = "string, string", tag = "2")] - pub parameters: ::std::collections::HashMap< - ::prost::alloc::string::String, - ::prost::alloc::string::String, - >, - /// optional fallback description of the content that can be used in case - /// the client cannot decode or render the content - #[prost(string, optional, tag = "3")] - pub fallback: ::core::option::Option<::prost::alloc::string::String>, - /// optional compression; the value indicates algorithm used to - /// compress the encoded content bytes - #[prost(enumeration = "Compression", optional, tag = "5")] - pub compression: ::core::option::Option, - /// encoded content itself - #[prost(bytes = "vec", tag = "4")] - pub content: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for EncodedContent { - const NAME: &'static str = "EncodedContent"; +impl ::prost::Name for MembershipPolicy { + const NAME: &'static str = "MembershipPolicy"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.EncodedContent".into() + "xmtp.mls.message_contents.MembershipPolicy".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.EncodedContent".into() + "/xmtp.mls.message_contents.MembershipPolicy".into() } } -/// A PlaintextEnvelope is the outermost payload that gets encrypted by MLS -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct PlaintextEnvelope { - /// Selector which declares which version of the EncodedContent this - /// PlaintextEnvelope is - #[prost(oneof = "plaintext_envelope::Content", tags = "1, 2")] - pub content: ::core::option::Option, +/// A policy that governs updating metadata +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MetadataPolicy { + #[prost(oneof = "metadata_policy::Kind", tags = "1, 2, 3")] + pub kind: ::core::option::Option, } -/// Nested message and enum types in `PlaintextEnvelope`. -pub mod plaintext_envelope { - /// Version 1 of the encrypted envelope - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct V1 { - /// Expected to be EncodedContent - #[prost(bytes = "vec", tag = "1")] - pub content: ::prost::alloc::vec::Vec, - /// A unique value that can be used to ensure that the same content can - /// produce different hashes. May be the sender timestamp. - #[prost(string, tag = "2")] - pub idempotency_key: ::prost::alloc::string::String, +/// Nested message and enum types in `MetadataPolicy`. +pub mod metadata_policy { + /// Combine multiple policies. All must evaluate to true + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct AndCondition { + #[prost(message, repeated, tag = "1")] + pub policies: ::prost::alloc::vec::Vec, } - impl ::prost::Name for V1 { - const NAME: &'static str = "V1"; + impl ::prost::Name for AndCondition { + const NAME: &'static str = "AndCondition"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PlaintextEnvelope.V1".into() + "xmtp.mls.message_contents.MetadataPolicy.AndCondition".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PlaintextEnvelope.V1".into() + "/xmtp.mls.message_contents.MetadataPolicy.AndCondition".into() } } - /// Version 2 of the encrypted envelope - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct V2 { - /// A unique value that can be used to ensure that the same content can - /// produce different hashes. May be the sender timestamp. - #[prost(string, tag = "1")] - pub idempotency_key: ::prost::alloc::string::String, - #[prost(oneof = "v2::MessageType", tags = "2, 3, 4, 5")] - pub message_type: ::core::option::Option, - } - /// Nested message and enum types in `V2`. - pub mod v2 { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum MessageType { - /// Expected to be EncodedContent - #[prost(bytes, tag = "2")] - Content(::prost::alloc::vec::Vec), - /// Initiator sends a request to receive sync payload - #[prost(message, tag = "3")] - DeviceSyncRequest( - super::super::super::super::device_sync::content::DeviceSyncRequest, - ), - /// Some other authorized installation sends a reply with a link to payload - #[prost(message, tag = "4")] - DeviceSyncReply( - super::super::super::super::device_sync::content::DeviceSyncReply, - ), - /// A serialized user preference update - #[prost(message, tag = "5")] - UserPreferenceUpdate( - super::super::super::super::device_sync::content::V1UserPreferenceUpdate, - ), - } + /// Combine multiple policies. Any must evaluate to true + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct AnyCondition { + #[prost(message, repeated, tag = "1")] + pub policies: ::prost::alloc::vec::Vec, } - impl ::prost::Name for V2 { - const NAME: &'static str = "V2"; + impl ::prost::Name for AnyCondition { + const NAME: &'static str = "AnyCondition"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PlaintextEnvelope.V2".into() + "xmtp.mls.message_contents.MetadataPolicy.AnyCondition".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PlaintextEnvelope.V2".into() + "/xmtp.mls.message_contents.MetadataPolicy.AnyCondition".into() } } - /// Selector which declares which version of the EncodedContent this - /// PlaintextEnvelope is - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Content { - #[prost(message, tag = "1")] - V1(V1), + /// Base policy + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum MetadataBasePolicy { + Unspecified = 0, + Allow = 1, + Deny = 2, + AllowIfAdmin = 3, + AllowIfSuperAdmin = 4, + } + impl MetadataBasePolicy { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "METADATA_BASE_POLICY_UNSPECIFIED", + Self::Allow => "METADATA_BASE_POLICY_ALLOW", + Self::Deny => "METADATA_BASE_POLICY_DENY", + Self::AllowIfAdmin => "METADATA_BASE_POLICY_ALLOW_IF_ADMIN", + Self::AllowIfSuperAdmin => "METADATA_BASE_POLICY_ALLOW_IF_SUPER_ADMIN", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "METADATA_BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), + "METADATA_BASE_POLICY_ALLOW" => Some(Self::Allow), + "METADATA_BASE_POLICY_DENY" => Some(Self::Deny), + "METADATA_BASE_POLICY_ALLOW_IF_ADMIN" => Some(Self::AllowIfAdmin), + "METADATA_BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => { + Some(Self::AllowIfSuperAdmin) + } + _ => None, + } + } + } + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(enumeration = "MetadataBasePolicy", tag = "1")] + Base(i32), #[prost(message, tag = "2")] - V2(V2), + AndCondition(AndCondition), + #[prost(message, tag = "3")] + AnyCondition(AnyCondition), } } -impl ::prost::Name for PlaintextEnvelope { - const NAME: &'static str = "PlaintextEnvelope"; +impl ::prost::Name for MetadataPolicy { + const NAME: &'static str = "MetadataPolicy"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.PlaintextEnvelope".into() + "xmtp.mls.message_contents.MetadataPolicy".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.PlaintextEnvelope".into() + "/xmtp.mls.message_contents.MetadataPolicy".into() } } -/// Recognized compression algorithms -/// protolint:disable ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum Compression { - Deflate = 0, - Gzip = 1, +/// A policy that governs updating permissions +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PermissionsUpdatePolicy { + #[prost(oneof = "permissions_update_policy::Kind", tags = "1, 2, 3")] + pub kind: ::core::option::Option, } -impl Compression { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Deflate => "COMPRESSION_DEFLATE", - Self::Gzip => "COMPRESSION_GZIP", - } +/// Nested message and enum types in `PermissionsUpdatePolicy`. +pub mod permissions_update_policy { + /// Combine multiple policies. All must evaluate to true + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct AndCondition { + #[prost(message, repeated, tag = "1")] + pub policies: ::prost::alloc::vec::Vec, } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "COMPRESSION_DEFLATE" => Some(Self::Deflate), - "COMPRESSION_GZIP" => Some(Self::Gzip), - _ => None, + impl ::prost::Name for AndCondition { + const NAME: &'static str = "AndCondition"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PermissionsUpdatePolicy.AndCondition".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PermissionsUpdatePolicy.AndCondition".into() } } -} -/// v1 external-commit-policy payload. -/// Field-coupling invariants enforced by libxmtp when applying an -/// AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal: -/// -/// * When `allow_external_commit` transitions to true: `symmetric_key` -/// and `external_group_id` MUST be populated (non-empty, meeting -/// their length requirements) in the same proposal. The two -/// transitions are atomic — there is no window where the bit is on -/// but the invite coordinates are unset. -/// -/// * When `allow_external_commit` transitions to false (revoke): -/// `symmetric_key` and `external_group_id` MUST be cleared (set to -/// empty bytes) in the same proposal. Leaving stale coordinates in -/// the group state after revoke would let a future re-enable -/// accidentally revive a previously-distributed key. -/// -/// * On re-enable (false → true after a prior revoke): the new -/// `symmetric_key` MUST differ from every previously-used value for -/// this group, and the new `external_group_id` SHOULD differ as -/// well. Reusing a revoked key would re-validate every QR ever -/// printed under that key, defeating the revocation. Admin clients -/// are responsible for generating fresh material on each enable. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalCommitPolicyV1 { - /// Master switch for MLS External Commits adding new members. - /// Required for the QR-invite flow. Defaults to false; admins - /// (super-admin by default) opt in via - /// AppDataUpdate(EXTERNAL_COMMIT_POLICY). - /// - /// See the field-coupling invariants in the message-level comment - /// above: enabling MUST populate symmetric_key + external_group_id; - /// revoking (true → false) MUST clear them. - #[prost(bool, tag = "1")] - pub allow_external_commit: bool, - /// Wall-clock auto-disable timestamp (ns since UNIX epoch). - /// 0 = no automatic expiry. After this timestamp the validator - /// rejects all external commits regardless of `allow_external_commit`. - /// Lets admins issue time-bounded invite campaigns without having to - /// come back and flip the bit manually. - #[prost(uint64, tag = "2")] - pub expires_at_ns: u64, - /// Maximum staleness of the GroupInfo referenced by an external - /// commit, in nanoseconds since GroupInfo export. 0 = no staleness - /// limit. External commits whose referenced GroupInfo was exported - /// more than `expire_in_ns` ago are rejected. Narrows the replay - /// window for stolen-blob attacks and forces re-export frequency. - #[prost(uint64, tag = "3")] - pub expire_in_ns: u64, - /// 32-byte ChaCha20Poly1305 key used to wrap the EncryptedGroupInfoBlob - /// for the currently-active invite. Carried in the group state so any - /// member (especially a just-joined external committer) can re-export - /// GroupInfo and re-upload a refreshed blob under the same key after a - /// join — without this, a printed QR / link would die the moment the - /// issuing admin went offline. - /// - /// The QR carries the same key bytes. Rotation = admin sets a new value - /// here in a single AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal AND - /// issues a new QR carrying the matching key; old QR holders' keys no - /// longer decrypt blobs the service serves under the rotated slot. - /// - /// Length MUST be exactly 32 bytes when populated. Empty (zero-length) - /// means no active invite — and MUST coincide with - /// `allow_external_commit == false` (see the field-coupling invariants - /// at the top of this message). Revoking the invite MUST clear this - /// field; re-enabling MUST populate it with a freshly-generated value - /// distinct from any previously-used key for this group. - /// - /// Note: the service_pointer (where the blob lives) is intentionally - /// NOT stored in the group. It is per-QR application-defined opaque - /// bytes; different invites for the same group may point at different - /// services. Joiners use the service_pointer from the QR they scanned. - #[prost(bytes = "vec", tag = "4")] - pub symmetric_key: ::prost::alloc::vec::Vec, - /// Identifier for the service slot holding the active invite's - /// encrypted blob. Application-defined opaque bytes (UUID, snowflake, - /// short slot key, etc.); decoupled from the MLS group_id. Admins - /// MAY rotate the symmetric_key while keeping this stable (overwrite - /// the same slot on the service) or change both together (new slot, - /// leaves the old slot orphaned for application-side GC). - /// - /// The QR carries the same value. The joiner verifies that the QR's - /// `external_group_id` equals this field after joining, as - /// defense-in-depth against a stale or swapped QR. Mismatch indicates - /// the admin rotated to a new slot after the QR was minted; the - /// joining client SHOULD treat the just-published commit as orphaned - /// (it validates fine, but the refreshed blob the joiner would upload - /// to the old slot will not be reachable by holders of the new QR). - /// - /// MUST be at least 4 bytes when populated (collision-avoidance floor - /// for tiny services). RECOMMENDED: 16 random bytes when no - /// application-specific scheme is in use. Empty (zero-length) means - /// no active invite — and MUST coincide with - /// `allow_external_commit == false` (see the field-coupling - /// invariants at the top of this message). Revoking the invite MUST - /// clear this field; re-enabling SHOULD use a freshly-generated value - /// (reusing a prior `external_group_id` is permitted only when the - /// admin intends to overwrite the old service slot — typically the - /// admin generates a new value to leave the prior slot orphaned). - #[prost(bytes = "vec", tag = "5")] - pub external_group_id: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for ExternalCommitPolicyV1 { - const NAME: &'static str = "ExternalCommitPolicyV1"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ExternalCommitPolicyV1".into() + /// Combine multiple policies. Any must evaluate to true + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct AnyCondition { + #[prost(message, repeated, tag = "1")] + pub policies: ::prost::alloc::vec::Vec, } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ExternalCommitPolicyV1".into() + impl ::prost::Name for AnyCondition { + const NAME: &'static str = "AnyCondition"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.PermissionsUpdatePolicy.AnyCondition".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.PermissionsUpdatePolicy.AnyCondition".into() + } } -} -/// Versioned envelope. New variants are added as new oneof variants; -/// readers that don't recognize a variant treat the policy as default -/// (all fields zero) per the standard unknown-variant tolerance rules. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalCommitPolicyEntry { - #[prost(oneof = "external_commit_policy_entry::Version", tags = "1")] - pub version: ::core::option::Option, -} -/// Nested message and enum types in `ExternalCommitPolicyEntry`. -pub mod external_commit_policy_entry { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Version { - #[prost(message, tag = "1")] - V1(super::ExternalCommitPolicyV1), + /// Base policy + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum PermissionsBasePolicy { + Unspecified = 0, + Deny = 1, + AllowIfAdmin = 2, + AllowIfSuperAdmin = 3, } -} -impl ::prost::Name for ExternalCommitPolicyEntry { - const NAME: &'static str = "ExternalCommitPolicyEntry"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ExternalCommitPolicyEntry".into() + impl PermissionsBasePolicy { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "PERMISSIONS_BASE_POLICY_UNSPECIFIED", + Self::Deny => "PERMISSIONS_BASE_POLICY_DENY", + Self::AllowIfAdmin => "PERMISSIONS_BASE_POLICY_ALLOW_IF_ADMIN", + Self::AllowIfSuperAdmin => "PERMISSIONS_BASE_POLICY_ALLOW_IF_SUPER_ADMIN", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PERMISSIONS_BASE_POLICY_UNSPECIFIED" => Some(Self::Unspecified), + "PERMISSIONS_BASE_POLICY_DENY" => Some(Self::Deny), + "PERMISSIONS_BASE_POLICY_ALLOW_IF_ADMIN" => Some(Self::AllowIfAdmin), + "PERMISSIONS_BASE_POLICY_ALLOW_IF_SUPER_ADMIN" => { + Some(Self::AllowIfSuperAdmin) + } + _ => None, + } + } } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ExternalCommitPolicyEntry".into() + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Kind { + #[prost(enumeration = "PermissionsBasePolicy", tag = "1")] + Base(i32), + #[prost(message, tag = "2")] + AndCondition(AndCondition), + #[prost(message, tag = "3")] + AnyCondition(AnyCondition), } } -/// v1 shape of the shareable invite blob for QR-code or link-based joining -/// of an XMTP group via an MLS external commit. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalInvitePayloadV1 { - /// Application-defined opaque bytes identifying the service location. - #[prost(bytes = "vec", tag = "1")] - pub service_pointer: ::prost::alloc::vec::Vec, - /// Identifier for the service slot holding the encrypted blob. Format - /// is application-defined (UUID, snowflake, short slot key, etc.) and - /// opaque to libxmtp; the only constraint is that the value is unique - /// within the chosen service. Decoupled from the MLS group_id — - /// rotation may keep this stable (overwrite the same slot) or change - /// it (new slot on the service); the admin chooses per invite. - /// - /// MUST be at least 4 bytes (collision-avoidance floor for tiny - /// services). RECOMMENDED: 16 random bytes when no application- - /// specific scheme is in use. Maximum length is not capped by the - /// protocol; applications should bound it to fit their QR / link - /// transport. - /// - /// After joining, the joiner verifies this matches - /// `EXTERNAL_COMMIT_POLICY.external_group_id` in the group state as - /// defense-in-depth against a stale or swapped QR. - #[prost(bytes = "vec", tag = "2")] - pub external_group_id: ::prost::alloc::vec::Vec, - /// 32 bytes; ChaCha20Poly1305 key used to wrap the GroupInfo. Matches - /// `EXTERNAL_COMMIT_POLICY.symmetric_key` in the group state. - #[prost(bytes = "vec", tag = "3")] - pub symmetric_key: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for ExternalInvitePayloadV1 { - const NAME: &'static str = "ExternalInvitePayloadV1"; +impl ::prost::Name for PermissionsUpdatePolicy { + const NAME: &'static str = "PermissionsUpdatePolicy"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ExternalInvitePayloadV1".into() + "xmtp.mls.message_contents.PermissionsUpdatePolicy".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ExternalInvitePayloadV1".into() + "/xmtp.mls.message_contents.PermissionsUpdatePolicy".into() } } -/// Versioned envelope for the shareable invite blob. The application embeds -/// the serialized bytes in whatever transport it prefers (hex, base64, raw -/// QR, NFC, etc.) and stores the corresponding EncryptedGroupInfoBlob on an -/// external service keyed by the v1 payload's `external_group_id`. +/// Per-component permission policy with separate rules for insert, update, +/// and delete operations. /// -/// New wire-format variants are added as new oneof entries; readers that -/// don't recognize a variant treat the invite as unparseable and fail -/// closed (no implicit downgrade). -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ExternalInvitePayload { - #[prost(oneof = "external_invite_payload::Version", tags = "1")] - pub version: ::core::option::Option, -} -/// Nested message and enum types in `ExternalInvitePayload`. -pub mod external_invite_payload { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Version { - #[prost(message, tag = "1")] - V1(super::ExternalInvitePayloadV1), - } +/// Insert and update are separate because some components need different +/// permission levels for creating vs modifying entries. For example, group +/// membership allows any member to update (installations/sequence ID) but +/// only admins to insert (add a new member). +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ComponentPermissions { + /// Policy for inserting a new value (component does not yet exist) + #[prost(message, optional, tag = "1")] + pub insert_policy: ::core::option::Option, + /// Policy for updating an existing value + #[prost(message, optional, tag = "2")] + pub update_policy: ::core::option::Option, + /// Policy for deleting a value + #[prost(message, optional, tag = "3")] + pub delete_policy: ::core::option::Option, } -impl ::prost::Name for ExternalInvitePayload { - const NAME: &'static str = "ExternalInvitePayload"; +impl ::prost::Name for ComponentPermissions { + const NAME: &'static str = "ComponentPermissions"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ExternalInvitePayload".into() + "xmtp.mls.message_contents.ComponentPermissions".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ExternalInvitePayload".into() + "/xmtp.mls.message_contents.ComponentPermissions".into() } } -/// v1 shape of the encrypted-GroupInfo envelope. -/// -/// `epoch` and `group_state_hash` are plaintext metadata serving two -/// distinct purposes: -/// -/// * `epoch` provides a total ordering on uploads. The service accepts -/// an upload iff `upload.epoch > current.epoch` (strictly newer); -/// lower-epoch uploads are stale and rejected outright. -/// -/// * `group_state_hash` is a consistency check at a single epoch. MLS -/// is deterministic — every member that applies the same commit -/// derives identical group state — so two correct uploads at the -/// same epoch MUST carry the same hash. When `upload.epoch == current.epoch`: equal hashes mean an idempotent re-upload (no-op -/// or duplicate-reject); different hashes mean the uploaders are on -/// forked views of the group and the service must refuse to pick a -/// winner. +/// Metadata describing a component: its data type and permission policies. /// -/// The joiner additionally verifies on download that the blob's `epoch` -/// and `group_state_hash` match the decrypted GroupInfo before -/// attempting to join — closes the "malicious service swapped -/// ciphertext" gap. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EncryptedGroupInfoBlobV1 { - /// 12 bytes; ChaCha20Poly1305 nonce specific to this ciphertext. - #[prost(bytes = "vec", tag = "1")] - pub nonce: ::prost::alloc::vec::Vec, - /// wrap_payload_symmetric output: AEAD ciphertext over the serialized - /// MlsMessageOut(GroupInfo). - #[prost(bytes = "vec", tag = "2")] - pub ciphertext: ::prost::alloc::vec::Vec, - /// MLS group epoch of the wrapped GroupInfo. Plaintext; the service - /// totally orders uploads by this value — strictly-newer wins, stale - /// is rejected. Joiner verifies against the decrypted GroupInfo - /// before joining. - #[prost(uint64, tag = "3")] - pub epoch: u64, - /// Tree-hash (or equivalent group-state digest) of the wrapped - /// GroupInfo. Plaintext; the service uses this only at equal epochs - /// to detect forks (same epoch + differing hash = forked uploaders). - /// Not used for ordering. Joiner verifies against the decrypted - /// GroupInfo before joining. - #[prost(bytes = "vec", tag = "4")] - pub group_state_hash: ::prost::alloc::vec::Vec, - /// Wall-clock expiry of this blob, in nanoseconds since UNIX epoch. - /// 0 means no expiry. The service uses this as a TTL hint and MAY - /// garbage-collect blobs past their `expires_at_ns` autonomously. - /// The joining client also enforces this — refuses to join from an - /// expired blob even if the service is still serving it. Admin - /// bounds the campaign by setting this at upload time; extending an - /// invite is a re-upload with a later value. - #[prost(uint64, tag = "5")] - pub expires_at_ns: u64, +/// Stored as the value in the component registry (ComponentId 0x8000). +/// Each registered component has one of these describing what kind of data +/// it holds and who can insert, update, or delete it. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ComponentMetadata { + /// The data structure type of the component's value + #[prost(enumeration = "ComponentType", tag = "1")] + pub component_type: i32, + /// Permission policies for this component, evaluated against regular + /// (member-issued) commits. + #[prost(message, optional, tag = "2")] + pub permissions: ::core::option::Option, + /// Permission policies for this component, evaluated against MLS External + /// Commits (RFC 9420 §12.4.3.2). Absent / unset is equivalent to all-Deny: + /// external committers cannot touch this component. Each component opts in + /// explicitly by setting this field. Combined with the EXTERNAL_COMMIT_POLICY + /// master switch (`allow_external_commit`), this is the per-component declarative + /// authorization for external-commit-driven joins. + #[prost(message, optional, tag = "3")] + pub external_committer_permissions: ::core::option::Option, } -impl ::prost::Name for EncryptedGroupInfoBlobV1 { - const NAME: &'static str = "EncryptedGroupInfoBlobV1"; +impl ::prost::Name for ComponentMetadata { + const NAME: &'static str = "ComponentMetadata"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.EncryptedGroupInfoBlobV1".into() + "xmtp.mls.message_contents.ComponentMetadata".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.EncryptedGroupInfoBlobV1".into() + "/xmtp.mls.message_contents.ComponentMetadata".into() } } -/// Versioned envelope wrapping a single GroupInfo TLS-serialized bytes -/// under an AEAD scheme (ChaCha20Poly1305 in v1) with a fresh nonce per -/// re-encryption. Stored on the external service and replaced by joiners -/// (with a fresh nonce) after each successful join. -/// -/// New variants represent breaking wire-format changes (different AEAD, -/// different metadata layout). Readers that don't recognize a variant -/// fail closed — the joiner cannot attempt MLS state transitions against -/// a blob it can't validate. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct EncryptedGroupInfoBlob { - #[prost(oneof = "encrypted_group_info_blob::Version", tags = "1")] - pub version: ::core::option::Option, +/// The data structure type of a component's value +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ComponentType { + Unspecified = 0, + /// Opaque bytes, replaced atomically + Bytes = 1, + /// A utf-8 encoded string, replaced atomically + String = 2, + /// A TlsMap\ supporting key-level insert/update/delete via deltas + TlsMapBytesBytes = 3, + /// A TlsMap\ supporting key-level insert/update/delete via deltas + TlsMapInboxIdBytes = 4, + /// A TlsSet supporting insert/remove/remove-by-hash via deltas + TlsSetBytes = 5, + /// A TlsSet supporting insert/remove/remove-by-hash via deltas + TlsSetInboxId = 6, } -/// Nested message and enum types in `EncryptedGroupInfoBlob`. -pub mod encrypted_group_info_blob { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Version { - #[prost(message, tag = "1")] - V1(super::EncryptedGroupInfoBlobV1), +impl ComponentType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "COMPONENT_TYPE_UNSPECIFIED", + Self::Bytes => "COMPONENT_TYPE_BYTES", + Self::String => "COMPONENT_TYPE_STRING", + Self::TlsMapBytesBytes => "COMPONENT_TYPE_TLS_MAP_BYTES_BYTES", + Self::TlsMapInboxIdBytes => "COMPONENT_TYPE_TLS_MAP_INBOX_ID_BYTES", + Self::TlsSetBytes => "COMPONENT_TYPE_TLS_SET_BYTES", + Self::TlsSetInboxId => "COMPONENT_TYPE_TLS_SET_INBOX_ID", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "COMPONENT_TYPE_UNSPECIFIED" => Some(Self::Unspecified), + "COMPONENT_TYPE_BYTES" => Some(Self::Bytes), + "COMPONENT_TYPE_STRING" => Some(Self::String), + "COMPONENT_TYPE_TLS_MAP_BYTES_BYTES" => Some(Self::TlsMapBytesBytes), + "COMPONENT_TYPE_TLS_MAP_INBOX_ID_BYTES" => Some(Self::TlsMapInboxIdBytes), + "COMPONENT_TYPE_TLS_SET_BYTES" => Some(Self::TlsSetBytes), + "COMPONENT_TYPE_TLS_SET_INBOX_ID" => Some(Self::TlsSetInboxId), + _ => None, + } } } -impl ::prost::Name for EncryptedGroupInfoBlob { - const NAME: &'static str = "EncryptedGroupInfoBlob"; +/// A group member and affected installation IDs +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MembershipChange { + #[prost(bytes = "vec", repeated, tag = "1")] + pub installation_ids: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + #[prost(string, tag = "2")] + pub account_address: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub initiated_by_account_address: ::prost::alloc::string::String, +} +impl ::prost::Name for MembershipChange { + const NAME: &'static str = "MembershipChange"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.EncryptedGroupInfoBlob".into() + "xmtp.mls.message_contents.MembershipChange".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.EncryptedGroupInfoBlob".into() + "/xmtp.mls.message_contents.MembershipChange".into() } } -/// Contains a mapping of `inbox_id` -> `sequence_id` for all members of a group. -/// Designed to be stored in the group context extension of the MLS group +/// The group membership change proto +/// +/// protolint:disable REPEATED_FIELD_NAMES_PLURALIZED #[derive(Clone, PartialEq, ::prost::Message)] -pub struct GroupMembership { - #[prost(map = "string, uint64", tag = "1")] - pub members: ::std::collections::HashMap<::prost::alloc::string::String, u64>, - /// List of installations that failed to be added due to errors encountered during the evaluation process. - #[prost(bytes = "vec", repeated, tag = "2")] - pub failed_installations: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, +pub struct GroupMembershipChanges { + /// Members that have been added in the commit + #[prost(message, repeated, tag = "1")] + pub members_added: ::prost::alloc::vec::Vec, + /// Members that have been removed in the commit + #[prost(message, repeated, tag = "2")] + pub members_removed: ::prost::alloc::vec::Vec, + /// Installations that have been added in the commit, grouped by member + #[prost(message, repeated, tag = "3")] + pub installations_added: ::prost::alloc::vec::Vec, + /// Installations removed in the commit, grouped by member + #[prost(message, repeated, tag = "4")] + pub installations_removed: ::prost::alloc::vec::Vec, } -impl ::prost::Name for GroupMembership { - const NAME: &'static str = "GroupMembership"; +impl ::prost::Name for GroupMembershipChanges { + const NAME: &'static str = "GroupMembershipChanges"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMembership".into() + "xmtp.mls.message_contents.GroupMembershipChanges".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMembership".into() + "/xmtp.mls.message_contents.GroupMembershipChanges".into() } } -/// Per-member membership state stored inside the GROUP_MEMBERSHIP component -/// as a TlsMap\. Keys are 32-byte inbox ids, values are the -/// encoded bytes of this message. -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct GroupMembershipEntry { - #[prost(oneof = "group_membership_entry::Version", tags = "1")] - pub version: ::core::option::Option, +/// A summary of the changes in a commit. +/// Includes added/removed inboxes and changes to metadata +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GroupUpdated { + #[prost(string, tag = "1")] + pub initiated_by_inbox_id: ::prost::alloc::string::String, + /// The inboxes added in the commit + #[prost(message, repeated, tag = "2")] + pub added_inboxes: ::prost::alloc::vec::Vec, + /// The inboxes removed in the commit + #[prost(message, repeated, tag = "3")] + pub removed_inboxes: ::prost::alloc::vec::Vec, + /// The metadata changes in the commit + #[prost(message, repeated, tag = "4")] + pub metadata_field_changes: ::prost::alloc::vec::Vec< + group_updated::MetadataFieldChange, + >, + /// / The inboxes that were removed from the group in response to pending-remove/self-remove requests + #[prost(message, repeated, tag = "5")] + pub left_inboxes: ::prost::alloc::vec::Vec, + /// The inboxes that were added to admin list in the commit + #[prost(message, repeated, tag = "6")] + pub added_admin_inboxes: ::prost::alloc::vec::Vec, + /// The inboxes that were removed from admin list in the commit + #[prost(message, repeated, tag = "7")] + pub removed_admin_inboxes: ::prost::alloc::vec::Vec, + /// The inboxes that were added to super admin list in the commit + #[prost(message, repeated, tag = "8")] + pub added_super_admin_inboxes: ::prost::alloc::vec::Vec, + /// The inboxes that were removed from super admin list in the commit + #[prost(message, repeated, tag = "9")] + pub removed_super_admin_inboxes: ::prost::alloc::vec::Vec, } -/// Nested message and enum types in `GroupMembershipEntry`. -pub mod group_membership_entry { - /// V1 of the per-member membership state. +/// Nested message and enum types in `GroupUpdated`. +pub mod group_updated { + /// An inbox that was added or removed in this commit #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct V1 { - /// Latest identity-update sequence id this client has applied for this - /// member. Validator-checked at bootstrap against the pre-flip - /// `GroupMembership.members\[inbox_id\]` value. - #[prost(uint64, tag = "1")] - pub sequence_id: u64, - /// Installation ids belonging to this member that we previously failed - /// to add (expired key package, validation failure, etc.). Used to - /// suppress retries on later membership updates. - /// - /// Sender-authoritative at migration: the migrator partitions the - /// global `failed_installations` per inbox by walking identity-update - /// history. Receivers accept these bytes as-is — the validator only - /// checks `sequence_id`, so the blast radius of a bad partition is - /// bounded to extra or silenced retries. Installations whose owning - /// inbox can't be determined are dropped. - #[prost(bytes = "vec", repeated, tag = "2")] - pub failed_installations: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + pub struct Inbox { + #[prost(string, tag = "1")] + pub inbox_id: ::prost::alloc::string::String, + } + impl ::prost::Name for Inbox { + const NAME: &'static str = "Inbox"; + const PACKAGE: &'static str = "xmtp.mls.message_contents"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.mls.message_contents.GroupUpdated.Inbox".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.mls.message_contents.GroupUpdated.Inbox".into() + } } - impl ::prost::Name for V1 { - const NAME: &'static str = "V1"; + /// A summary of a change to the mutable metadata + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct MetadataFieldChange { + /// The field that was changed + #[prost(string, tag = "1")] + pub field_name: ::prost::alloc::string::String, + /// The previous value + #[prost(string, optional, tag = "2")] + pub old_value: ::core::option::Option<::prost::alloc::string::String>, + /// The updated value + #[prost(string, optional, tag = "3")] + pub new_value: ::core::option::Option<::prost::alloc::string::String>, + } + impl ::prost::Name for MetadataFieldChange { + const NAME: &'static str = "MetadataFieldChange"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMembershipEntry.V1".into() + "xmtp.mls.message_contents.GroupUpdated.MetadataFieldChange".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMembershipEntry.V1".into() + "/xmtp.mls.message_contents.GroupUpdated.MetadataFieldChange".into() } } - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] - pub enum Version { - #[prost(message, tag = "1")] - V1(V1), - } } -impl ::prost::Name for GroupMembershipEntry { - const NAME: &'static str = "GroupMembershipEntry"; +impl ::prost::Name for GroupUpdated { + const NAME: &'static str = "GroupUpdated"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMembershipEntry".into() + "xmtp.mls.message_contents.GroupUpdated".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMembershipEntry".into() + "/xmtp.mls.message_contents.GroupUpdated".into() } } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -1461,196 +1825,99 @@ impl ConversationType { } } } -/// Message for group mutable metadata +/// Contains a mapping of `inbox_id` -> `sequence_id` for all members of a group. +/// Designed to be stored in the group context extension of the MLS group #[derive(Clone, PartialEq, ::prost::Message)] -pub struct GroupMutableMetadataV1 { - /// Map to store various metadata attributes (Group name, etc.) - #[prost(map = "string, string", tag = "1")] - pub attributes: ::std::collections::HashMap< - ::prost::alloc::string::String, - ::prost::alloc::string::String, - >, - #[prost(message, optional, tag = "2")] - pub admin_list: ::core::option::Option, - /// Creator starts as only super_admin - /// Only super_admin can add/remove other super_admin - #[prost(message, optional, tag = "3")] - pub super_admin_list: ::core::option::Option, -} -impl ::prost::Name for GroupMutableMetadataV1 { - const NAME: &'static str = "GroupMutableMetadataV1"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMutableMetadataV1".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMutableMetadataV1".into() - } -} -/// Wrapper around a list of repeated Inbox Ids -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct Inboxes { - #[prost(string, repeated, tag = "1")] - pub inbox_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, -} -impl ::prost::Name for Inboxes { - const NAME: &'static str = "Inboxes"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.Inboxes".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.Inboxes".into() - } -} -/// Extension data for proposal support in group context. -/// When present in the group context extensions, indicates the group -/// uses proposal-by-reference flow. -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct ProposalSupport { - #[prost(uint32, tag = "1")] - pub version: u32, +pub struct GroupMembership { + #[prost(map = "string, uint64", tag = "1")] + pub members: ::std::collections::HashMap<::prost::alloc::string::String, u64>, + /// List of installations that failed to be added due to errors encountered during the evaluation process. + #[prost(bytes = "vec", repeated, tag = "2")] + pub failed_installations: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, } -impl ::prost::Name for ProposalSupport { - const NAME: &'static str = "ProposalSupport"; +impl ::prost::Name for GroupMembership { + const NAME: &'static str = "GroupMembership"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.ProposalSupport".into() + "xmtp.mls.message_contents.GroupMembership".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.ProposalSupport".into() + "/xmtp.mls.message_contents.GroupMembership".into() } } -/// A group member and affected installation IDs +/// Per-member membership state stored inside the GROUP_MEMBERSHIP component +/// as a TlsMap\. Keys are 32-byte inbox ids, values are the +/// encoded bytes of this message. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct MembershipChange { - #[prost(bytes = "vec", repeated, tag = "1")] - pub installation_ids: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, - #[prost(string, tag = "2")] - pub account_address: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub initiated_by_account_address: ::prost::alloc::string::String, -} -impl ::prost::Name for MembershipChange { - const NAME: &'static str = "MembershipChange"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.MembershipChange".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.MembershipChange".into() - } -} -/// The group membership change proto -/// -/// protolint:disable REPEATED_FIELD_NAMES_PLURALIZED -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GroupMembershipChanges { - /// Members that have been added in the commit - #[prost(message, repeated, tag = "1")] - pub members_added: ::prost::alloc::vec::Vec, - /// Members that have been removed in the commit - #[prost(message, repeated, tag = "2")] - pub members_removed: ::prost::alloc::vec::Vec, - /// Installations that have been added in the commit, grouped by member - #[prost(message, repeated, tag = "3")] - pub installations_added: ::prost::alloc::vec::Vec, - /// Installations removed in the commit, grouped by member - #[prost(message, repeated, tag = "4")] - pub installations_removed: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for GroupMembershipChanges { - const NAME: &'static str = "GroupMembershipChanges"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupMembershipChanges".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupMembershipChanges".into() - } -} -/// A summary of the changes in a commit. -/// Includes added/removed inboxes and changes to metadata -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GroupUpdated { - #[prost(string, tag = "1")] - pub initiated_by_inbox_id: ::prost::alloc::string::String, - /// The inboxes added in the commit - #[prost(message, repeated, tag = "2")] - pub added_inboxes: ::prost::alloc::vec::Vec, - /// The inboxes removed in the commit - #[prost(message, repeated, tag = "3")] - pub removed_inboxes: ::prost::alloc::vec::Vec, - /// The metadata changes in the commit - #[prost(message, repeated, tag = "4")] - pub metadata_field_changes: ::prost::alloc::vec::Vec< - group_updated::MetadataFieldChange, - >, - /// / The inboxes that were removed from the group in response to pending-remove/self-remove requests - #[prost(message, repeated, tag = "5")] - pub left_inboxes: ::prost::alloc::vec::Vec, - /// The inboxes that were added to admin list in the commit - #[prost(message, repeated, tag = "6")] - pub added_admin_inboxes: ::prost::alloc::vec::Vec, - /// The inboxes that were removed from admin list in the commit - #[prost(message, repeated, tag = "7")] - pub removed_admin_inboxes: ::prost::alloc::vec::Vec, - /// The inboxes that were added to super admin list in the commit - #[prost(message, repeated, tag = "8")] - pub added_super_admin_inboxes: ::prost::alloc::vec::Vec, - /// The inboxes that were removed from super admin list in the commit - #[prost(message, repeated, tag = "9")] - pub removed_super_admin_inboxes: ::prost::alloc::vec::Vec, +pub struct GroupMembershipEntry { + #[prost(oneof = "group_membership_entry::Version", tags = "1")] + pub version: ::core::option::Option, } -/// Nested message and enum types in `GroupUpdated`. -pub mod group_updated { - /// An inbox that was added or removed in this commit +/// Nested message and enum types in `GroupMembershipEntry`. +pub mod group_membership_entry { + /// V1 of the per-member membership state. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct Inbox { - #[prost(string, tag = "1")] - pub inbox_id: ::prost::alloc::string::String, + pub struct V1 { + /// Latest identity-update sequence id this client has applied for this + /// member. Validator-checked at bootstrap against the pre-flip + /// `GroupMembership.members\[inbox_id\]` value. + #[prost(uint64, tag = "1")] + pub sequence_id: u64, + /// Installation ids belonging to this member that we previously failed + /// to add (expired key package, validation failure, etc.). Used to + /// suppress retries on later membership updates. + /// + /// Sender-authoritative at migration: the migrator partitions the + /// global `failed_installations` per inbox by walking identity-update + /// history. Receivers accept these bytes as-is — the validator only + /// checks `sequence_id`, so the blast radius of a bad partition is + /// bounded to extra or silenced retries. Installations whose owning + /// inbox can't be determined are dropped. + #[prost(bytes = "vec", repeated, tag = "2")] + pub failed_installations: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec>, + /// The `external_group_id` of the invite this member was admitted + /// under (XIP-82). Set if and only if the member joined via an MLS + /// external commit; absent for members added by `Welcome`. This is + /// what `EXTERNAL_COMMIT_POLICY.max_uses` accounting counts: the + /// live use-count for an invite is the number of entries whose value + /// here equals the policy's active `external_group_id`. + /// + /// Recorded on EVERY external commit (even when `max_uses` is 0, so + /// a later policy change to a finite cap starts from accurate data) + /// and WRITE-ONCE: set exactly once by the admitting external commit + /// and immutable for the life of the entry. Validators reject a + /// member-sender commit that sets, clears, or alters it — its own + /// entry included; otherwise an invited member could untag itself + /// and free a `max_uses` slot at will — and any rewrite of a + /// member's entry for unrelated reasons (an installation change, + /// say) MUST carry the field through unchanged. The field disappears + /// only when the entry itself does (the member is removed). + #[prost(bytes = "vec", tag = "3")] + pub admitted_via_external_group_id: ::prost::alloc::vec::Vec, } - impl ::prost::Name for Inbox { - const NAME: &'static str = "Inbox"; + impl ::prost::Name for V1 { + const NAME: &'static str = "V1"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupUpdated.Inbox".into() + "xmtp.mls.message_contents.GroupMembershipEntry.V1".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupUpdated.Inbox".into() + "/xmtp.mls.message_contents.GroupMembershipEntry.V1".into() } } - /// A summary of a change to the mutable metadata - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] - pub struct MetadataFieldChange { - /// The field that was changed - #[prost(string, tag = "1")] - pub field_name: ::prost::alloc::string::String, - /// The previous value - #[prost(string, optional, tag = "2")] - pub old_value: ::core::option::Option<::prost::alloc::string::String>, - /// The updated value - #[prost(string, optional, tag = "3")] - pub new_value: ::core::option::Option<::prost::alloc::string::String>, - } - impl ::prost::Name for MetadataFieldChange { - const NAME: &'static str = "MetadataFieldChange"; - const PACKAGE: &'static str = "xmtp.mls.message_contents"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupUpdated.MetadataFieldChange".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupUpdated.MetadataFieldChange".into() - } + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Version { + #[prost(message, tag = "1")] + V1(V1), } } -impl ::prost::Name for GroupUpdated { - const NAME: &'static str = "GroupUpdated"; +impl ::prost::Name for GroupMembershipEntry { + const NAME: &'static str = "GroupMembershipEntry"; const PACKAGE: &'static str = "xmtp.mls.message_contents"; fn full_name() -> ::prost::alloc::string::String { - "xmtp.mls.message_contents.GroupUpdated".into() + "xmtp.mls.message_contents.GroupMembershipEntry".into() } fn type_url() -> ::prost::alloc::string::String { - "/xmtp.mls.message_contents.GroupUpdated".into() + "/xmtp.mls.message_contents.GroupMembershipEntry".into() } } diff --git a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.serde.rs b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.serde.rs index edac2dc005..dc86104170 100644 --- a/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.serde.rs +++ b/crates/xmtp_proto/src/gen/xmtp.mls.message_contents.serde.rs @@ -1275,7 +1275,7 @@ impl serde::Serialize for EncryptedGroupInfoBlobV1 { if self.epoch != 0 { len += 1; } - if !self.group_state_hash.is_empty() { + if self.group_state_hash.is_some() { len += 1; } if self.expires_at_ns != 0 { @@ -1297,10 +1297,8 @@ impl serde::Serialize for EncryptedGroupInfoBlobV1 { #[allow(clippy::needless_borrows_for_generic_args)] struct_ser.serialize_field("epoch", ToString::to_string(&self.epoch).as_str())?; } - if !self.group_state_hash.is_empty() { - #[allow(clippy::needless_borrow)] - #[allow(clippy::needless_borrows_for_generic_args)] - struct_ser.serialize_field("group_state_hash", pbjson::private::base64::encode(&self.group_state_hash).as_str())?; + if let Some(v) = self.group_state_hash.as_ref() { + struct_ser.serialize_field("group_state_hash", v)?; } if self.expires_at_ns != 0 { #[allow(clippy::needless_borrow)] @@ -1414,9 +1412,7 @@ impl<'de> serde::Deserialize<'de> for EncryptedGroupInfoBlobV1 { if group_state_hash__.is_some() { return Err(serde::de::Error::duplicate_field("groupStateHash")); } - group_state_hash__ = - Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) - ; + group_state_hash__ = map_.next_value()?; } GeneratedField::ExpiresAtNs => { if expires_at_ns__.is_some() { @@ -1435,7 +1431,7 @@ impl<'de> serde::Deserialize<'de> for EncryptedGroupInfoBlobV1 { nonce: nonce__.unwrap_or_default(), ciphertext: ciphertext__.unwrap_or_default(), epoch: epoch__.unwrap_or_default(), - group_state_hash: group_state_hash__.unwrap_or_default(), + group_state_hash: group_state_hash__, expires_at_ns: expires_at_ns__.unwrap_or_default(), }) } @@ -1560,12 +1556,18 @@ impl serde::Serialize for ExternalCommitPolicyV1 { if self.expire_in_ns != 0 { len += 1; } - if !self.symmetric_key.is_empty() { + if self.symmetric_key.is_some() { len += 1; } if !self.external_group_id.is_empty() { len += 1; } + if self.max_uses != 0 { + len += 1; + } + if !self.refresh_pointers.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.ExternalCommitPolicyV1", len)?; if self.allow_external_commit { struct_ser.serialize_field("allow_external_commit", &self.allow_external_commit)?; @@ -1580,16 +1582,20 @@ impl serde::Serialize for ExternalCommitPolicyV1 { #[allow(clippy::needless_borrows_for_generic_args)] struct_ser.serialize_field("expire_in_ns", ToString::to_string(&self.expire_in_ns).as_str())?; } - if !self.symmetric_key.is_empty() { - #[allow(clippy::needless_borrow)] - #[allow(clippy::needless_borrows_for_generic_args)] - struct_ser.serialize_field("symmetric_key", pbjson::private::base64::encode(&self.symmetric_key).as_str())?; + if let Some(v) = self.symmetric_key.as_ref() { + struct_ser.serialize_field("symmetric_key", v)?; } if !self.external_group_id.is_empty() { #[allow(clippy::needless_borrow)] #[allow(clippy::needless_borrows_for_generic_args)] struct_ser.serialize_field("external_group_id", pbjson::private::base64::encode(&self.external_group_id).as_str())?; } + if self.max_uses != 0 { + struct_ser.serialize_field("max_uses", &self.max_uses)?; + } + if !self.refresh_pointers.is_empty() { + struct_ser.serialize_field("refresh_pointers", &self.refresh_pointers)?; + } struct_ser.end() } } @@ -1610,6 +1616,10 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { "symmetricKey", "external_group_id", "externalGroupId", + "max_uses", + "maxUses", + "refresh_pointers", + "refreshPointers", ]; #[allow(clippy::enum_variant_names)] @@ -1619,6 +1629,8 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { ExpireInNs, SymmetricKey, ExternalGroupId, + MaxUses, + RefreshPointers, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -1646,6 +1658,8 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { "expireInNs" | "expire_in_ns" => Ok(GeneratedField::ExpireInNs), "symmetricKey" | "symmetric_key" => Ok(GeneratedField::SymmetricKey), "externalGroupId" | "external_group_id" => Ok(GeneratedField::ExternalGroupId), + "maxUses" | "max_uses" => Ok(GeneratedField::MaxUses), + "refreshPointers" | "refresh_pointers" => Ok(GeneratedField::RefreshPointers), _ => Ok(GeneratedField::__SkipField__), } } @@ -1670,6 +1684,8 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { let mut expire_in_ns__ = None; let mut symmetric_key__ = None; let mut external_group_id__ = None; + let mut max_uses__ = None; + let mut refresh_pointers__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::AllowExternalCommit => { @@ -1698,9 +1714,7 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { if symmetric_key__.is_some() { return Err(serde::de::Error::duplicate_field("symmetricKey")); } - symmetric_key__ = - Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) - ; + symmetric_key__ = map_.next_value()?; } GeneratedField::ExternalGroupId => { if external_group_id__.is_some() { @@ -1710,6 +1724,20 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) ; } + GeneratedField::MaxUses => { + if max_uses__.is_some() { + return Err(serde::de::Error::duplicate_field("maxUses")); + } + max_uses__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::RefreshPointers => { + if refresh_pointers__.is_some() { + return Err(serde::de::Error::duplicate_field("refreshPointers")); + } + refresh_pointers__ = Some(map_.next_value()?); + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -1719,8 +1747,10 @@ impl<'de> serde::Deserialize<'de> for ExternalCommitPolicyV1 { allow_external_commit: allow_external_commit__.unwrap_or_default(), expires_at_ns: expires_at_ns__.unwrap_or_default(), expire_in_ns: expire_in_ns__.unwrap_or_default(), - symmetric_key: symmetric_key__.unwrap_or_default(), + symmetric_key: symmetric_key__, external_group_id: external_group_id__.unwrap_or_default(), + max_uses: max_uses__.unwrap_or_default(), + refresh_pointers: refresh_pointers__.unwrap_or_default(), }) } } @@ -1835,30 +1865,26 @@ impl serde::Serialize for ExternalInvitePayloadV1 { { use serde::ser::SerializeStruct; let mut len = 0; - if !self.service_pointer.is_empty() { + if self.service_pointer.is_some() { len += 1; } if !self.external_group_id.is_empty() { len += 1; } - if !self.symmetric_key.is_empty() { + if self.symmetric_key.is_some() { len += 1; } let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.ExternalInvitePayloadV1", len)?; - if !self.service_pointer.is_empty() { - #[allow(clippy::needless_borrow)] - #[allow(clippy::needless_borrows_for_generic_args)] - struct_ser.serialize_field("service_pointer", pbjson::private::base64::encode(&self.service_pointer).as_str())?; + if let Some(v) = self.service_pointer.as_ref() { + struct_ser.serialize_field("service_pointer", v)?; } if !self.external_group_id.is_empty() { #[allow(clippy::needless_borrow)] #[allow(clippy::needless_borrows_for_generic_args)] struct_ser.serialize_field("external_group_id", pbjson::private::base64::encode(&self.external_group_id).as_str())?; } - if !self.symmetric_key.is_empty() { - #[allow(clippy::needless_borrow)] - #[allow(clippy::needless_borrows_for_generic_args)] - struct_ser.serialize_field("symmetric_key", pbjson::private::base64::encode(&self.symmetric_key).as_str())?; + if let Some(v) = self.symmetric_key.as_ref() { + struct_ser.serialize_field("symmetric_key", v)?; } struct_ser.end() } @@ -1936,9 +1962,7 @@ impl<'de> serde::Deserialize<'de> for ExternalInvitePayloadV1 { if service_pointer__.is_some() { return Err(serde::de::Error::duplicate_field("servicePointer")); } - service_pointer__ = - Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) - ; + service_pointer__ = map_.next_value()?; } GeneratedField::ExternalGroupId => { if external_group_id__.is_some() { @@ -1952,9 +1976,7 @@ impl<'de> serde::Deserialize<'de> for ExternalInvitePayloadV1 { if symmetric_key__.is_some() { return Err(serde::de::Error::duplicate_field("symmetricKey")); } - symmetric_key__ = - Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) - ; + symmetric_key__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; @@ -1962,9 +1984,9 @@ impl<'de> serde::Deserialize<'de> for ExternalInvitePayloadV1 { } } Ok(ExternalInvitePayloadV1 { - service_pointer: service_pointer__.unwrap_or_default(), + service_pointer: service_pointer__, external_group_id: external_group_id__.unwrap_or_default(), - symmetric_key: symmetric_key__.unwrap_or_default(), + symmetric_key: symmetric_key__, }) } } @@ -2356,6 +2378,9 @@ impl serde::Serialize for group_membership_entry::V1 { if !self.failed_installations.is_empty() { len += 1; } + if !self.admitted_via_external_group_id.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.GroupMembershipEntry.V1", len)?; if self.sequence_id != 0 { #[allow(clippy::needless_borrow)] @@ -2365,6 +2390,11 @@ impl serde::Serialize for group_membership_entry::V1 { if !self.failed_installations.is_empty() { struct_ser.serialize_field("failed_installations", &self.failed_installations.iter().map(pbjson::private::base64::encode).collect::>())?; } + if !self.admitted_via_external_group_id.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("admitted_via_external_group_id", pbjson::private::base64::encode(&self.admitted_via_external_group_id).as_str())?; + } struct_ser.end() } } @@ -2379,12 +2409,15 @@ impl<'de> serde::Deserialize<'de> for group_membership_entry::V1 { "sequenceId", "failed_installations", "failedInstallations", + "admitted_via_external_group_id", + "admittedViaExternalGroupId", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { SequenceId, FailedInstallations, + AdmittedViaExternalGroupId, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -2409,6 +2442,7 @@ impl<'de> serde::Deserialize<'de> for group_membership_entry::V1 { match value { "sequenceId" | "sequence_id" => Ok(GeneratedField::SequenceId), "failedInstallations" | "failed_installations" => Ok(GeneratedField::FailedInstallations), + "admittedViaExternalGroupId" | "admitted_via_external_group_id" => Ok(GeneratedField::AdmittedViaExternalGroupId), _ => Ok(GeneratedField::__SkipField__), } } @@ -2430,6 +2464,7 @@ impl<'de> serde::Deserialize<'de> for group_membership_entry::V1 { { let mut sequence_id__ = None; let mut failed_installations__ = None; + let mut admitted_via_external_group_id__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::SequenceId => { @@ -2449,6 +2484,14 @@ impl<'de> serde::Deserialize<'de> for group_membership_entry::V1 { .into_iter().map(|x| x.0).collect()) ; } + GeneratedField::AdmittedViaExternalGroupId => { + if admitted_via_external_group_id__.is_some() { + return Err(serde::de::Error::duplicate_field("admittedViaExternalGroupId")); + } + admitted_via_external_group_id__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -2457,6 +2500,7 @@ impl<'de> serde::Deserialize<'de> for group_membership_entry::V1 { Ok(group_membership_entry::V1 { sequence_id: sequence_id__.unwrap_or_default(), failed_installations: failed_installations__.unwrap_or_default(), + admitted_via_external_group_id: admitted_via_external_group_id__.unwrap_or_default(), }) } } @@ -2861,6 +2905,105 @@ impl<'de> serde::Deserialize<'de> for GroupMutablePermissionsV1 { deserializer.deserialize_struct("xmtp.mls.message_contents.GroupMutablePermissionsV1", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for GroupStateHash { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.digest.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.GroupStateHash", len)?; + if !self.digest.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("digest", pbjson::private::base64::encode(&self.digest).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GroupStateHash { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "digest", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Digest, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl serde::de::Visitor<'_> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "digest" => Ok(GeneratedField::Digest), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GroupStateHash; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.mls.message_contents.GroupStateHash") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut digest__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Digest => { + if digest__.is_some() { + return Err(serde::de::Error::duplicate_field("digest")); + } + digest__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GroupStateHash { + digest: digest__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("xmtp.mls.message_contents.GroupStateHash", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for GroupUpdated { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -5957,6 +6100,219 @@ impl<'de> serde::Deserialize<'de> for ReaddRequest { deserializer.deserialize_struct("xmtp.mls.message_contents.ReaddRequest", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for ServicePointer { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.location.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.ServicePointer", len)?; + if let Some(v) = self.location.as_ref() { + match v { + service_pointer::Location::HttpsUrl(v) => { + struct_ser.serialize_field("https_url", v)?; + } + service_pointer::Location::Opaque(v) => { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("opaque", pbjson::private::base64::encode(&v).as_str())?; + } + } + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for ServicePointer { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "https_url", + "httpsUrl", + "opaque", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + HttpsUrl, + Opaque, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl serde::de::Visitor<'_> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "httpsUrl" | "https_url" => Ok(GeneratedField::HttpsUrl), + "opaque" => Ok(GeneratedField::Opaque), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = ServicePointer; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.mls.message_contents.ServicePointer") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut location__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::HttpsUrl => { + if location__.is_some() { + return Err(serde::de::Error::duplicate_field("httpsUrl")); + } + location__ = map_.next_value::<::std::option::Option<_>>()?.map(service_pointer::Location::HttpsUrl); + } + GeneratedField::Opaque => { + if location__.is_some() { + return Err(serde::de::Error::duplicate_field("opaque")); + } + location__ = map_.next_value::<::std::option::Option<::pbjson::private::BytesDeserialize<_>>>()?.map(|x| service_pointer::Location::Opaque(x.0)); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(ServicePointer { + location: location__, + }) + } + } + deserializer.deserialize_struct("xmtp.mls.message_contents.ServicePointer", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for SymmetricKey { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.material.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("xmtp.mls.message_contents.SymmetricKey", len)?; + if !self.material.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("material", pbjson::private::base64::encode(&self.material).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for SymmetricKey { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "material", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Material, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl serde::de::Visitor<'_> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "material" => Ok(GeneratedField::Material), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = SymmetricKey; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct xmtp.mls.message_contents.SymmetricKey") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut material__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Material => { + if material__.is_some() { + return Err(serde::de::Error::duplicate_field("material")); + } + material__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(SymmetricKey { + material: material__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("xmtp.mls.message_contents.SymmetricKey", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for WelcomePointeeEncryptionAeadType { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/crates/xmtp_proto/src/gen/xmtp.xmtpv4.message_api.rs b/crates/xmtp_proto/src/gen/xmtp.xmtpv4.message_api.rs index e443d13bcc..0740fb53c3 100644 --- a/crates/xmtp_proto/src/gen/xmtp.xmtpv4.message_api.rs +++ b/crates/xmtp_proto/src/gen/xmtp.xmtpv4.message_api.rs @@ -1027,215 +1027,9 @@ pub mod replication_api_server { const NAME: &'static str = SERVICE_NAME; } } -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct LivenessFailure { - #[prost(uint32, tag = "1")] - pub response_time_ns: u32, - #[prost(oneof = "liveness_failure::Request", tags = "2, 3, 4")] - pub request: ::core::option::Option, -} -/// Nested message and enum types in `LivenessFailure`. -pub mod liveness_failure { - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Request { - #[prost(message, tag = "2")] - Subscribe(super::SubscribeEnvelopesRequest), - #[prost(message, tag = "3")] - Query(super::QueryEnvelopesRequest), - #[prost(message, tag = "4")] - Publish(super::PublishPayerEnvelopesRequest), - } -} -impl ::prost::Name for LivenessFailure { - const NAME: &'static str = "LivenessFailure"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.LivenessFailure".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.LivenessFailure".into() - } -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SafetyFailure { - #[prost(message, repeated, tag = "1")] - pub envelopes: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for SafetyFailure { - const NAME: &'static str = "SafetyFailure"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.SafetyFailure".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.SafetyFailure".into() - } -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct UnsignedMisbehaviorReport { - #[prost(uint64, tag = "1")] - pub reporter_time_ns: u64, - #[prost(uint32, tag = "2")] - pub misbehaving_node_id: u32, - #[prost(enumeration = "Misbehavior", tag = "3")] - pub r#type: i32, - /// Nodes must verify this field is false for client-submitted reports - #[prost(bool, tag = "6")] - pub submitted_by_node: bool, - #[prost(oneof = "unsigned_misbehavior_report::Failure", tags = "4, 5")] - pub failure: ::core::option::Option, -} -/// Nested message and enum types in `UnsignedMisbehaviorReport`. -pub mod unsigned_misbehavior_report { - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Failure { - #[prost(message, tag = "4")] - Liveness(super::LivenessFailure), - #[prost(message, tag = "5")] - Safety(super::SafetyFailure), - } -} -impl ::prost::Name for UnsignedMisbehaviorReport { - const NAME: &'static str = "UnsignedMisbehaviorReport"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.UnsignedMisbehaviorReport".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.UnsignedMisbehaviorReport".into() - } -} -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct MisbehaviorReport { - /// Server time when the report was stored. Used only for querying reports. - /// This field is not signed. - #[prost(uint64, tag = "1")] - pub server_time_ns: u64, - #[prost(bytes = "vec", tag = "2")] - pub unsigned_misbehavior_report: ::prost::alloc::vec::Vec, - /// Signed by the node hosting the report - #[prost(message, optional, tag = "3")] - pub signature: ::core::option::Option< - super::super::identity::associations::RecoverableEcdsaSignature, - >, -} -impl ::prost::Name for MisbehaviorReport { - const NAME: &'static str = "MisbehaviorReport"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.MisbehaviorReport".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.MisbehaviorReport".into() - } -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SubmitMisbehaviorReportRequest { - #[prost(message, optional, tag = "1")] - pub report: ::core::option::Option, -} -impl ::prost::Name for SubmitMisbehaviorReportRequest { - const NAME: &'static str = "SubmitMisbehaviorReportRequest"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.SubmitMisbehaviorReportRequest".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.SubmitMisbehaviorReportRequest".into() - } -} -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct SubmitMisbehaviorReportResponse {} -impl ::prost::Name for SubmitMisbehaviorReportResponse { - const NAME: &'static str = "SubmitMisbehaviorReportResponse"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.SubmitMisbehaviorReportResponse".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.SubmitMisbehaviorReportResponse".into() - } -} -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct QueryMisbehaviorReportsRequest { - #[prost(uint64, tag = "1")] - pub after_ns: u64, -} -impl ::prost::Name for QueryMisbehaviorReportsRequest { - const NAME: &'static str = "QueryMisbehaviorReportsRequest"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.QueryMisbehaviorReportsRequest".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.QueryMisbehaviorReportsRequest".into() - } -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct QueryMisbehaviorReportsResponse { - #[prost(message, repeated, tag = "1")] - pub reports: ::prost::alloc::vec::Vec, -} -impl ::prost::Name for QueryMisbehaviorReportsResponse { - const NAME: &'static str = "QueryMisbehaviorReportsResponse"; - const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; - fn full_name() -> ::prost::alloc::string::String { - "xmtp.xmtpv4.message_api.QueryMisbehaviorReportsResponse".into() - } - fn type_url() -> ::prost::alloc::string::String { - "/xmtp.xmtpv4.message_api.QueryMisbehaviorReportsResponse".into() - } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum Misbehavior { - Unspecified = 0, - UnresponsiveNode = 1, - SlowNode = 2, - FailedRequest = 3, - OutOfOrder = 4, - DuplicateSequenceId = 5, - CausalOrdering = 6, - InvalidPayload = 7, - BlockchainInconsistency = 8, -} -impl Misbehavior { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "MISBEHAVIOR_UNSPECIFIED", - Self::UnresponsiveNode => "MISBEHAVIOR_UNRESPONSIVE_NODE", - Self::SlowNode => "MISBEHAVIOR_SLOW_NODE", - Self::FailedRequest => "MISBEHAVIOR_FAILED_REQUEST", - Self::OutOfOrder => "MISBEHAVIOR_OUT_OF_ORDER", - Self::DuplicateSequenceId => "MISBEHAVIOR_DUPLICATE_SEQUENCE_ID", - Self::CausalOrdering => "MISBEHAVIOR_CAUSAL_ORDERING", - Self::InvalidPayload => "MISBEHAVIOR_INVALID_PAYLOAD", - Self::BlockchainInconsistency => "MISBEHAVIOR_BLOCKCHAIN_INCONSISTENCY", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "MISBEHAVIOR_UNSPECIFIED" => Some(Self::Unspecified), - "MISBEHAVIOR_UNRESPONSIVE_NODE" => Some(Self::UnresponsiveNode), - "MISBEHAVIOR_SLOW_NODE" => Some(Self::SlowNode), - "MISBEHAVIOR_FAILED_REQUEST" => Some(Self::FailedRequest), - "MISBEHAVIOR_OUT_OF_ORDER" => Some(Self::OutOfOrder), - "MISBEHAVIOR_DUPLICATE_SEQUENCE_ID" => Some(Self::DuplicateSequenceId), - "MISBEHAVIOR_CAUSAL_ORDERING" => Some(Self::CausalOrdering), - "MISBEHAVIOR_INVALID_PAYLOAD" => Some(Self::InvalidPayload), - "MISBEHAVIOR_BLOCKCHAIN_INCONSISTENCY" => Some(Self::BlockchainInconsistency), - _ => None, - } - } -} /// Generated server implementations. #[cfg(any(not(target_arch = "wasm32"), feature = "grpc_server_impls"))] -pub mod misbehavior_api_server { +pub mod notification_api_server { #![allow( unused_variables, dead_code, @@ -1244,33 +1038,36 @@ pub mod misbehavior_api_server { clippy::let_unit_value, )] use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with MisbehaviorApiServer. + /// Generated trait containing gRPC methods that should be implemented for use with NotificationApiServer. #[async_trait] - pub trait MisbehaviorApi: std::marker::Send + std::marker::Sync + 'static { - async fn submit_misbehavior_report( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - async fn query_misbehavior_reports( + pub trait NotificationApi: std::marker::Send + std::marker::Sync + 'static { + /// Server streaming response type for the SubscribeAllEnvelopes method. + type SubscribeAllEnvelopesStream: tonic::codegen::tokio_stream::Stream< + Item = std::result::Result< + super::SubscribeEnvelopesResponse, + tonic::Status, + >, + > + + std::marker::Send + + 'static; + async fn subscribe_all_envelopes( &self, - request: tonic::Request, + request: tonic::Request, ) -> std::result::Result< - tonic::Response, + tonic::Response, tonic::Status, >; } + /// Full envelope stream for notification services. #[derive(Debug)] - pub struct MisbehaviorApiServer { + pub struct NotificationApiServer { inner: Arc, accept_compression_encodings: EnabledCompressionEncodings, send_compression_encodings: EnabledCompressionEncodings, max_decoding_message_size: Option, max_encoding_message_size: Option, } - impl MisbehaviorApiServer { + impl NotificationApiServer { pub fn new(inner: T) -> Self { Self::from_arc(Arc::new(inner)) } @@ -1321,9 +1118,9 @@ pub mod misbehavior_api_server { self } } - impl tonic::codegen::Service> for MisbehaviorApiServer + impl tonic::codegen::Service> for NotificationApiServer where - T: MisbehaviorApi, + T: NotificationApi, B: Body + std::marker::Send + 'static, B::Error: Into + std::marker::Send + 'static, { @@ -1338,27 +1135,27 @@ pub mod misbehavior_api_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { - "/xmtp.xmtpv4.message_api.MisbehaviorApi/SubmitMisbehaviorReport" => { + "/xmtp.xmtpv4.message_api.NotificationApi/SubscribeAllEnvelopes" => { #[allow(non_camel_case_types)] - struct SubmitMisbehaviorReportSvc(pub Arc); + struct SubscribeAllEnvelopesSvc(pub Arc); impl< - T: MisbehaviorApi, - > tonic::server::UnaryService - for SubmitMisbehaviorReportSvc { - type Response = super::SubmitMisbehaviorReportResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( + T: NotificationApi, + > tonic::server::ServerStreamingService< + super::SubscribeAllEnvelopesRequest, + > for SubscribeAllEnvelopesSvc { + type Response = super::SubscribeEnvelopesResponse; + type ResponseStream = T::SubscribeAllEnvelopesStream; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( &mut self, - request: tonic::Request< - super::SubmitMisbehaviorReportRequest, - >, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::submit_misbehavior_report( + ::subscribe_all_envelopes( &inner, request, ) @@ -1373,7 +1170,7 @@ pub mod misbehavior_api_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = SubmitMisbehaviorReportSvc(inner); + let method = SubscribeAllEnvelopesSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -1384,35 +1181,169 @@ pub mod misbehavior_api_server { max_decoding_message_size, max_encoding_message_size, ); - let res = grpc.unary(method, req).await; + let res = grpc.server_streaming(method, req).await; Ok(res) }; Box::pin(fut) } - "/xmtp.xmtpv4.message_api.MisbehaviorApi/QueryMisbehaviorReports" => { + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for NotificationApiServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.NotificationApi"; + impl tonic::server::NamedService for NotificationApiServer { + const NAME: &'static str = SERVICE_NAME; + } +} +/// Generated server implementations. +#[cfg(any(not(target_arch = "wasm32"), feature = "grpc_server_impls"))] +pub mod publish_api_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with PublishApiServer. + #[async_trait] + pub trait PublishApi: std::marker::Send + std::marker::Sync + 'static { + async fn publish_payer_envelopes( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + /// Gateway -> Node. + #[derive(Debug)] + pub struct PublishApiServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl PublishApiServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for PublishApiServer + where + T: PublishApi, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/xmtp.xmtpv4.message_api.PublishApi/PublishPayerEnvelopes" => { #[allow(non_camel_case_types)] - struct QueryMisbehaviorReportsSvc(pub Arc); + struct PublishPayerEnvelopesSvc(pub Arc); impl< - T: MisbehaviorApi, - > tonic::server::UnaryService - for QueryMisbehaviorReportsSvc { - type Response = super::QueryMisbehaviorReportsResponse; + T: PublishApi, + > tonic::server::UnaryService + for PublishPayerEnvelopesSvc { + type Response = super::PublishPayerEnvelopesResponse; type Future = BoxFuture< tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request< - super::QueryMisbehaviorReportsRequest, - >, + request: tonic::Request, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::query_misbehavior_reports( - &inner, - request, - ) + ::publish_payer_envelopes(&inner, request) .await }; Box::pin(fut) @@ -1424,7 +1355,7 @@ pub mod misbehavior_api_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = QueryMisbehaviorReportsSvc(inner); + let method = PublishPayerEnvelopesSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -1462,7 +1393,7 @@ pub mod misbehavior_api_server { } } } - impl Clone for MisbehaviorApiServer { + impl Clone for PublishApiServer { fn clone(&self) -> Self { let inner = self.inner.clone(); Self { @@ -1474,15 +1405,221 @@ pub mod misbehavior_api_server { } } } - /// Generated gRPC service name - pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.MisbehaviorApi"; - impl tonic::server::NamedService for MisbehaviorApiServer { - const NAME: &'static str = SERVICE_NAME; + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.PublishApi"; + impl tonic::server::NamedService for PublishApiServer { + const NAME: &'static str = SERVICE_NAME; + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LivenessFailure { + #[prost(uint32, tag = "1")] + pub response_time_ns: u32, + #[prost(oneof = "liveness_failure::Request", tags = "2, 3, 4")] + pub request: ::core::option::Option, +} +/// Nested message and enum types in `LivenessFailure`. +pub mod liveness_failure { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Request { + #[prost(message, tag = "2")] + Subscribe(super::SubscribeEnvelopesRequest), + #[prost(message, tag = "3")] + Query(super::QueryEnvelopesRequest), + #[prost(message, tag = "4")] + Publish(super::PublishPayerEnvelopesRequest), + } +} +impl ::prost::Name for LivenessFailure { + const NAME: &'static str = "LivenessFailure"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.LivenessFailure".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.LivenessFailure".into() + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SafetyFailure { + #[prost(message, repeated, tag = "1")] + pub envelopes: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for SafetyFailure { + const NAME: &'static str = "SafetyFailure"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.SafetyFailure".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.SafetyFailure".into() + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UnsignedMisbehaviorReport { + #[prost(uint64, tag = "1")] + pub reporter_time_ns: u64, + #[prost(uint32, tag = "2")] + pub misbehaving_node_id: u32, + #[prost(enumeration = "Misbehavior", tag = "3")] + pub r#type: i32, + /// Nodes must verify this field is false for client-submitted reports + #[prost(bool, tag = "6")] + pub submitted_by_node: bool, + #[prost(oneof = "unsigned_misbehavior_report::Failure", tags = "4, 5")] + pub failure: ::core::option::Option, +} +/// Nested message and enum types in `UnsignedMisbehaviorReport`. +pub mod unsigned_misbehavior_report { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Failure { + #[prost(message, tag = "4")] + Liveness(super::LivenessFailure), + #[prost(message, tag = "5")] + Safety(super::SafetyFailure), + } +} +impl ::prost::Name for UnsignedMisbehaviorReport { + const NAME: &'static str = "UnsignedMisbehaviorReport"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.UnsignedMisbehaviorReport".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.UnsignedMisbehaviorReport".into() + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MisbehaviorReport { + /// Server time when the report was stored. Used only for querying reports. + /// This field is not signed. + #[prost(uint64, tag = "1")] + pub server_time_ns: u64, + #[prost(bytes = "vec", tag = "2")] + pub unsigned_misbehavior_report: ::prost::alloc::vec::Vec, + /// Signed by the node hosting the report + #[prost(message, optional, tag = "3")] + pub signature: ::core::option::Option< + super::super::identity::associations::RecoverableEcdsaSignature, + >, +} +impl ::prost::Name for MisbehaviorReport { + const NAME: &'static str = "MisbehaviorReport"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.MisbehaviorReport".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.MisbehaviorReport".into() + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SubmitMisbehaviorReportRequest { + #[prost(message, optional, tag = "1")] + pub report: ::core::option::Option, +} +impl ::prost::Name for SubmitMisbehaviorReportRequest { + const NAME: &'static str = "SubmitMisbehaviorReportRequest"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.SubmitMisbehaviorReportRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.SubmitMisbehaviorReportRequest".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SubmitMisbehaviorReportResponse {} +impl ::prost::Name for SubmitMisbehaviorReportResponse { + const NAME: &'static str = "SubmitMisbehaviorReportResponse"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.SubmitMisbehaviorReportResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.SubmitMisbehaviorReportResponse".into() + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct QueryMisbehaviorReportsRequest { + #[prost(uint64, tag = "1")] + pub after_ns: u64, +} +impl ::prost::Name for QueryMisbehaviorReportsRequest { + const NAME: &'static str = "QueryMisbehaviorReportsRequest"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.QueryMisbehaviorReportsRequest".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.QueryMisbehaviorReportsRequest".into() + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct QueryMisbehaviorReportsResponse { + #[prost(message, repeated, tag = "1")] + pub reports: ::prost::alloc::vec::Vec, +} +impl ::prost::Name for QueryMisbehaviorReportsResponse { + const NAME: &'static str = "QueryMisbehaviorReportsResponse"; + const PACKAGE: &'static str = "xmtp.xmtpv4.message_api"; + fn full_name() -> ::prost::alloc::string::String { + "xmtp.xmtpv4.message_api.QueryMisbehaviorReportsResponse".into() + } + fn type_url() -> ::prost::alloc::string::String { + "/xmtp.xmtpv4.message_api.QueryMisbehaviorReportsResponse".into() + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Misbehavior { + Unspecified = 0, + UnresponsiveNode = 1, + SlowNode = 2, + FailedRequest = 3, + OutOfOrder = 4, + DuplicateSequenceId = 5, + CausalOrdering = 6, + InvalidPayload = 7, + BlockchainInconsistency = 8, +} +impl Misbehavior { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "MISBEHAVIOR_UNSPECIFIED", + Self::UnresponsiveNode => "MISBEHAVIOR_UNRESPONSIVE_NODE", + Self::SlowNode => "MISBEHAVIOR_SLOW_NODE", + Self::FailedRequest => "MISBEHAVIOR_FAILED_REQUEST", + Self::OutOfOrder => "MISBEHAVIOR_OUT_OF_ORDER", + Self::DuplicateSequenceId => "MISBEHAVIOR_DUPLICATE_SEQUENCE_ID", + Self::CausalOrdering => "MISBEHAVIOR_CAUSAL_ORDERING", + Self::InvalidPayload => "MISBEHAVIOR_INVALID_PAYLOAD", + Self::BlockchainInconsistency => "MISBEHAVIOR_BLOCKCHAIN_INCONSISTENCY", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "MISBEHAVIOR_UNSPECIFIED" => Some(Self::Unspecified), + "MISBEHAVIOR_UNRESPONSIVE_NODE" => Some(Self::UnresponsiveNode), + "MISBEHAVIOR_SLOW_NODE" => Some(Self::SlowNode), + "MISBEHAVIOR_FAILED_REQUEST" => Some(Self::FailedRequest), + "MISBEHAVIOR_OUT_OF_ORDER" => Some(Self::OutOfOrder), + "MISBEHAVIOR_DUPLICATE_SEQUENCE_ID" => Some(Self::DuplicateSequenceId), + "MISBEHAVIOR_CAUSAL_ORDERING" => Some(Self::CausalOrdering), + "MISBEHAVIOR_INVALID_PAYLOAD" => Some(Self::InvalidPayload), + "MISBEHAVIOR_BLOCKCHAIN_INCONSISTENCY" => Some(Self::BlockchainInconsistency), + _ => None, + } } } /// Generated server implementations. #[cfg(any(not(target_arch = "wasm32"), feature = "grpc_server_impls"))] -pub mod notification_api_server { +pub mod misbehavior_api_server { #![allow( unused_variables, dead_code, @@ -1491,36 +1628,33 @@ pub mod notification_api_server { clippy::let_unit_value, )] use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with NotificationApiServer. + /// Generated trait containing gRPC methods that should be implemented for use with MisbehaviorApiServer. #[async_trait] - pub trait NotificationApi: std::marker::Send + std::marker::Sync + 'static { - /// Server streaming response type for the SubscribeAllEnvelopes method. - type SubscribeAllEnvelopesStream: tonic::codegen::tokio_stream::Stream< - Item = std::result::Result< - super::SubscribeEnvelopesResponse, - tonic::Status, - >, - > - + std::marker::Send - + 'static; - async fn subscribe_all_envelopes( + pub trait MisbehaviorApi: std::marker::Send + std::marker::Sync + 'static { + async fn submit_misbehavior_report( &self, - request: tonic::Request, + request: tonic::Request, ) -> std::result::Result< - tonic::Response, + tonic::Response, + tonic::Status, + >; + async fn query_misbehavior_reports( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, tonic::Status, >; } - /// Full envelope stream for notification services. #[derive(Debug)] - pub struct NotificationApiServer { + pub struct MisbehaviorApiServer { inner: Arc, accept_compression_encodings: EnabledCompressionEncodings, send_compression_encodings: EnabledCompressionEncodings, max_decoding_message_size: Option, max_encoding_message_size: Option, } - impl NotificationApiServer { + impl MisbehaviorApiServer { pub fn new(inner: T) -> Self { Self::from_arc(Arc::new(inner)) } @@ -1571,9 +1705,9 @@ pub mod notification_api_server { self } } - impl tonic::codegen::Service> for NotificationApiServer + impl tonic::codegen::Service> for MisbehaviorApiServer where - T: NotificationApi, + T: MisbehaviorApi, B: Body + std::marker::Send + 'static, B::Error: Into + std::marker::Send + 'static, { @@ -1588,27 +1722,27 @@ pub mod notification_api_server { } fn call(&mut self, req: http::Request) -> Self::Future { match req.uri().path() { - "/xmtp.xmtpv4.message_api.NotificationApi/SubscribeAllEnvelopes" => { + "/xmtp.xmtpv4.message_api.MisbehaviorApi/SubmitMisbehaviorReport" => { #[allow(non_camel_case_types)] - struct SubscribeAllEnvelopesSvc(pub Arc); + struct SubmitMisbehaviorReportSvc(pub Arc); impl< - T: NotificationApi, - > tonic::server::ServerStreamingService< - super::SubscribeAllEnvelopesRequest, - > for SubscribeAllEnvelopesSvc { - type Response = super::SubscribeEnvelopesResponse; - type ResponseStream = T::SubscribeAllEnvelopesStream; + T: MisbehaviorApi, + > tonic::server::UnaryService + for SubmitMisbehaviorReportSvc { + type Response = super::SubmitMisbehaviorReportResponse; type Future = BoxFuture< - tonic::Response, + tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request< + super::SubmitMisbehaviorReportRequest, + >, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::subscribe_all_envelopes( + ::submit_misbehavior_report( &inner, request, ) @@ -1623,7 +1757,7 @@ pub mod notification_api_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = SubscribeAllEnvelopesSvc(inner); + let method = SubmitMisbehaviorReportSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -1634,169 +1768,35 @@ pub mod notification_api_server { max_decoding_message_size, max_encoding_message_size, ); - let res = grpc.server_streaming(method, req).await; + let res = grpc.unary(method, req).await; Ok(res) }; Box::pin(fut) } - _ => { - Box::pin(async move { - let mut response = http::Response::new( - tonic::body::Body::default(), - ); - let headers = response.headers_mut(); - headers - .insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers - .insert( - http::header::CONTENT_TYPE, - tonic::metadata::GRPC_CONTENT_TYPE, - ); - Ok(response) - }) - } - } - } - } - impl Clone for NotificationApiServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - /// Generated gRPC service name - pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.NotificationApi"; - impl tonic::server::NamedService for NotificationApiServer { - const NAME: &'static str = SERVICE_NAME; - } -} -/// Generated server implementations. -#[cfg(any(not(target_arch = "wasm32"), feature = "grpc_server_impls"))] -pub mod publish_api_server { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with PublishApiServer. - #[async_trait] - pub trait PublishApi: std::marker::Send + std::marker::Sync + 'static { - async fn publish_payer_envelopes( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - } - /// Gateway -> Node. - #[derive(Debug)] - pub struct PublishApiServer { - inner: Arc, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - impl PublishApiServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for PublishApiServer - where - T: PublishApi, - B: Body + std::marker::Send + 'static, - B::Error: Into + std::marker::Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - match req.uri().path() { - "/xmtp.xmtpv4.message_api.PublishApi/PublishPayerEnvelopes" => { + "/xmtp.xmtpv4.message_api.MisbehaviorApi/QueryMisbehaviorReports" => { #[allow(non_camel_case_types)] - struct PublishPayerEnvelopesSvc(pub Arc); + struct QueryMisbehaviorReportsSvc(pub Arc); impl< - T: PublishApi, - > tonic::server::UnaryService - for PublishPayerEnvelopesSvc { - type Response = super::PublishPayerEnvelopesResponse; + T: MisbehaviorApi, + > tonic::server::UnaryService + for QueryMisbehaviorReportsSvc { + type Response = super::QueryMisbehaviorReportsResponse; type Future = BoxFuture< tonic::Response, tonic::Status, >; fn call( &mut self, - request: tonic::Request, + request: tonic::Request< + super::QueryMisbehaviorReportsRequest, + >, ) -> Self::Future { let inner = Arc::clone(&self.0); let fut = async move { - ::publish_payer_envelopes(&inner, request) + ::query_misbehavior_reports( + &inner, + request, + ) .await }; Box::pin(fut) @@ -1808,7 +1808,7 @@ pub mod publish_api_server { let max_encoding_message_size = self.max_encoding_message_size; let inner = self.inner.clone(); let fut = async move { - let method = PublishPayerEnvelopesSvc(inner); + let method = QueryMisbehaviorReportsSvc(inner); let codec = tonic_prost::ProstCodec::default(); let mut grpc = tonic::server::Grpc::new(codec) .apply_compression_config( @@ -1846,7 +1846,7 @@ pub mod publish_api_server { } } } - impl Clone for PublishApiServer { + impl Clone for MisbehaviorApiServer { fn clone(&self) -> Self { let inner = self.inner.clone(); Self { @@ -1859,8 +1859,8 @@ pub mod publish_api_server { } } /// Generated gRPC service name - pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.PublishApi"; - impl tonic::server::NamedService for PublishApiServer { + pub const SERVICE_NAME: &str = "xmtp.xmtpv4.message_api.MisbehaviorApi"; + impl tonic::server::NamedService for MisbehaviorApiServer { const NAME: &'static str = SERVICE_NAME; } }