xmtp_mls: ValidatedCommit::from_external_commit — XIP-82 receive-side check set#3667
xmtp_mls: ValidatedCommit::from_external_commit — XIP-82 receive-side check set#3667tylerhawkes wants to merge 4 commits into
Conversation
|
Claude finished @tylerhawkes's task —— View job Code Review — 2026-06-10 05:45 UTCReviewed the external commit validation implementation. Macroscope flagged two issues; one is a false positive, the other is valid. ❌ False Positive: Macroscope's Delete/Update ConcernMacroscope's claim: Reality: The code at let [TlsMapMutation::Insert { key, value }] = delta.mutations.as_slice() else {
return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope);
};This pattern match rejects any delta containing ✅ Valid Issue: Missing DM CheckLocation: Issue: This method doesn't check if the conversation is a DM before allowing external commit policy changes. Every other metadata update method in the file includes this guard: if self.metadata().await?.conversation_type == ConversationType::Dm {
return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
}Examples of protected methods:
Risk: Without this check, external commit policies could be set on DM conversations, potentially allowing third parties to join a two-person DM via external commits. Recommendation: Add the DM check at the start of Minor Observations
Summary: One valid security issue (missing DM check). The Delete/Update concern flagged by Macroscope is incorrect due to misreading the slice pattern match. |
| /// 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( |
There was a problem hiding this comment.
🟠 High groups/mod.rs:2015
set_external_commit_policy allows external commit policies to be set on DM conversations, bypassing the restriction that other metadata update methods enforce. This could enable third parties to join a two-person DM via external commits. Consider adding the DM check that other methods use: if self.metadata().await?.conversation_type == ConversationType::Dm { return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into()); }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/mod.rs around line 2015:
`set_external_commit_policy` allows external commit policies to be set on DM conversations, bypassing the restriction that other metadata update methods enforce. This could enable third parties to join a two-person DM via external commits. Consider adding the DM check that other methods use: `if self.metadata().await?.conversation_type == ConversationType::Dm { return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into()); }`
Evidence trail:
crates/xmtp_mls/src/groups/mod.rs lines 2015-2046 (set_external_commit_policy missing DM check), lines 1987-2004 (update_commit_log_signer with DM check at 1993-1995), lines 2051-2060 (set_allow_external_commit wrapper). git_grep for DmGroupMetadataForbidden shows the check at lines 1901, 1927, 1994, 2086, 2144, 2184, 2482 — all other metadata update paths.
ApprovabilityVerdict: Needs human review 4 blocking correctness issues found. This PR introduces substantial new external commit validation logic (XIP-82 receive-side checks) - a significant new capability affecting runtime behavior. Additionally, unresolved review comments identify a HIGH severity security concern (DM conversations can have external commit policies set) and MEDIUM severity validation gaps that warrant human attention. You can customize Macroscope's approvability policy. Learn more. |
4bb5976 to
f29a7c1
Compare
2cdc15e to
67cd34e
Compare
f29a7c1 to
6d0ec42
Compare
…ves) + adapt invite::payload Regenerates xmtp_proto from xmtp/proto#336 (typed SymmetricKey / GroupStateHash / ServicePointer newtypes, ExternalCommitPolicyV1 max_uses + refresh_pointers, EncryptedGroupInfoBlobV1 AAD + effective-expiry semantics, GroupMembershipEntry.V1 admitted_via_external_group_id). Consuming changes kept to what main already contains: - invite::payload rewritten for the typed shape: symmetric_key is a SymmetricKey submessage (MissingSymmetricKey + material-length checks), service_pointer is an optional ServicePointer (absent = application-resolved; present-but-empty oneof fails closed; https_url parsed and scheme-checked via url). New https_service_pointer / opaque_service_pointer / validate_service_pointer helpers. - GroupMembershipEntry.V1 construction sites gain the new admitted_via_external_group_id field, empty everywhere: the migrator's synthesized entries are all Welcome/legacy members (absent is permanently correct), and the membership-update rewrite path documents that tag preservation MUST land together with the validator's write-once enforcement in the external-commit stack — nothing on main can set the tag yet, so empty is correct today. The rest of the QR-invite stack (#3671 -> #3666 -> #3667 -> #3668 -> #3673 -> #3674) rebases onto this and picks up the new fields layer by layer.
…ITE_LABEL Encryption/decryption helpers for the GroupInfo blobs stored on an external service in the QR-invite flow (XIP-82), layered on the payload_encryption primitives: - wrap_group_info / unwrap_group_info around EncryptedGroupInfoBlobV1 with ChaCha20Poly1305 and the v1 AAD pinned by the XIP: epoch || expires_at_ns (8-byte BE each) || group_state_hash.digest. All plaintext metadata is authenticated — a keyless writer cannot doctor a captured blob (e.g. extend its expiry); tampering any field fails the unwrap. - Typed GroupStateHash with digest length enforced to the ciphersuite hash length (32 bytes) at both wrap and unwrap. - effective_expires_at_ns: the blob's single expiry field carries the earlier of the policy's campaign expiry and the staleness deadline (epoch_start + expire_in_ns, saturating; 0 = no bound drops out), so the joiner's one expiry check also skips candidates validators would reject as stale (no zombie joins) and service TTL-GC collects staleness-dead blobs. - Fresh CSPRNG nonce per wrap by default (many independent writers share one long-lived key; deterministic schemes would collide). Candidate-set selection (validate-and-select across the versioned slot) lands with the join flow, where GroupInfo parsing lives.
67cd34e to
ba504d7
Compare
…th XIP-82 lifecycle invariants The EXTERNAL_COMMIT_POLICY well-known component (0x800C) and the policy lifecycle, per the revised XIP-82: - Typed wire shape: SymmetricKey submessage, max_uses (concurrent per-invite cap), refresh_pointers (member-driven blob refresh locations). - enable_external_commits(settings) -> ExternalInviteCoordinates: mints a fresh CSPRNG key + slot id (uniform randomness replaces key-history tracking for no-key-revival), applies the caller's durable settings, and establishes the GROUP_MEMBERSHIP external-committer insert grant IN THE SAME COMMIT when absent — via the new multi-update AppDataUpdate intent (additional_updates) and stage_app_data_propose_many_and_commit (N standalone proposals swept into one commit). - revoke_external_commits(): leaves every per-invite field absent (key / slot id / campaign expiry / refresh pointers) while preserving the durable settings (expire_in_ns, max_uses) — a revoked policy serializes byte-identical to one that never had an invite. - validate_policy_v1: the field-coupling invariants as a pure function, enforced setter-side AND receive-side. The receive side (validate_external_commit_policy_post_state) additionally enforces the grant coupling against the POST-state registry — a COMPONENT_REGISTRY write carried by the same commit counts — so a proposal that plants a key while the switch is off, or enables without the grant, is rejected by every member identically. Consumed by the L-7 validator (ValidatedCommit::from_external_commit) for the external-commit-time checks (master switch, time windows via envelope timestamps, max_uses counting).
ba504d7 to
ccb136f
Compare
… check set The full 12-check validation for Sender::NewMemberCommit (external commit) joins: structural shape (exactly one ExternalInit, one AppDataUpdate inserting the joiner's own tagged GROUP_MEMBERSHIP entry, Adds bound to the joiner, nothing by reference, no PSK/GCE/Remove), policy gate from the typed EXTERNAL_COMMIT_POLICY component, both envelope-timestamp time bounds (expires_at_ns, expire_in_ns vs epoch start), the external_committer_permissions grant, already-member rejection, admitted_via_external_group_id tag enforcement, and max_uses counting from shared pre-commit state. Plus the two member-path halves that land together with the validator: write-once enforcement for admitted_via_external_group_id on member commits (set/clear/alter rejected; Delete frees the slot), and tag preservation in build_group_membership_app_data_payload (deferred from the proto re-bump layer).
6d0ec42 to
9bfd0b7
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## tyler/qr-invite-l6-external-commit-policy #3667 +/- ##
=============================================================================
+ Coverage 84.25% 84.29% +0.03%
=============================================================================
Files 410 410
Lines 60747 61577 +830
=============================================================================
+ Hits 51184 51906 +722
- Misses 9563 9671 +108 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| 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)?; | ||
|
|
There was a problem hiding this comment.
🟡 Medium groups/validated_commit.rs:1085
from_external_commit trusts the decoded policy from load_external_commit_policy without running validate_policy_v1, so a malformed enabled policy (e.g., empty external_group_id or missing per-invite fields) is accepted as active. This lets external joins proceed against state that the write-side rejects, and with an empty external_group_id the enforce_max_uses check is skipped entirely, breaking the invite access control.
let policy = super::external_commit_policy::load_external_commit_policy(openmls_group)?;
- let policy = enforce_external_commit_policy(policy.as_ref())?;
+ let policy = policy
+ .filter(|p| super::external_commit_policy::validate_policy_v1(p).is_ok())
+ .ok_or(CommitValidationError::ExternalCommitNotAllowed)?;
+ let policy = enforce_external_commit_policy(Some(&policy))?;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/validated_commit.rs around lines 1085-1090:
`from_external_commit` trusts the decoded policy from `load_external_commit_policy` without running `validate_policy_v1`, so a malformed enabled policy (e.g., empty `external_group_id` or missing per-invite fields) is accepted as active. This lets external joins proceed against state that the write-side rejects, and with an empty `external_group_id` the `enforce_max_uses` check is skipped entirely, breaking the invite access control.
| // party. We surface the same participant in `proposers` | ||
| // for downstream consumers that look there for "who | ||
| // wrote this commit". | ||
| proposers: Vec::new(), |
There was a problem hiding this comment.
🟢 Low groups/validated_commit.rs:1180
from_external_commit documents that the joiner should be surfaced in ValidatedCommit.proposers because "the joiner is the sole authoring party," but line 1180 initializes proposers: Vec::new(). Any consumer that reads or serializes proposers will see an external join as having no proposer at all, breaking attribution and contradicting the struct's contract that it contains the unique proposal authors for the commit.
- proposers: Vec::new(),
+ proposers: vec![joiner_participant.clone()],🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/validated_commit.rs around line 1180:
`from_external_commit` documents that the joiner should be surfaced in `ValidatedCommit.proposers` because "the joiner is the sole authoring party," but line 1180 initializes `proposers: Vec::new()`. Any consumer that reads or serializes `proposers` will see an external join as having no proposer at all, breaking attribution and contradicting the struct's contract that it contains the unique proposal authors for the commit.
| GroupMembershipEntry, group_membership_entry::Version as GroupMembershipEntryVersion, | ||
| }; | ||
|
|
||
| let AppDataUpdateOperation::Update(payload) = operation else { |
There was a problem hiding this comment.
🟡 Medium groups/validated_commit.rs:3258
enforce_admitted_via_write_once returns Ok(()) immediately for AppDataUpdateOperation::Remove, so a member-authored wipe of the GROUP_MEMBERSHIP component bypasses the write-once check entirely. If the registry's delete_policy permits the actor, they can clear all entries and their admitted_via_external_group_id tags in one commit, resetting the max_uses count to zero and freeing invite slots for reuse. Consider rejecting Remove operations on GROUP_MEMBERSHIP when any entry carries a non-empty tag, or documenting if this is an accepted trade-off.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/validated_commit.rs around line 3258:
`enforce_admitted_via_write_once` returns `Ok(())` immediately for `AppDataUpdateOperation::Remove`, so a member-authored wipe of the `GROUP_MEMBERSHIP` component bypasses the write-once check entirely. If the registry's `delete_policy` permits the actor, they can clear all entries and their `admitted_via_external_group_id` tags in one commit, resetting the `max_uses` count to zero and freeing invite slots for reuse. Consider rejecting `Remove` operations on `GROUP_MEMBERSHIP` when any entry carries a non-empty tag, or documenting if this is an accepted trade-off.
| /// 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), |
There was a problem hiding this comment.
groups/validated_commit.rs:2847
first_unsupported captures PreSharedKey as an unsupported proposal type and downstream validation rejects it with ExternalCommitUnsupportedProposalType(PreSharedKey). However, the PR's stated contract lists PreSharedKey as a legal proposal type for external commits, so valid joins including a PSK will be incorrectly rejected. Consider either removing PreSharedKey from the unsupported classification or updating the PR contract to exclude PSKs.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/validated_commit.rs around line 2847:
`first_unsupported` captures `PreSharedKey` as an unsupported proposal type and downstream validation rejects it with `ExternalCommitUnsupportedProposalType(PreSharedKey)`. However, the PR's stated contract lists `PreSharedKey` as a legal proposal type for external commits, so valid joins including a PSK will be incorrectly rejected. Consider either removing `PreSharedKey` from the unsupported classification or updating the PR contract to exclude PSKs.
…ves) + adapt invite::payload Regenerates xmtp_proto from xmtp/proto#336 (typed SymmetricKey / GroupStateHash / ServicePointer newtypes, ExternalCommitPolicyV1 max_uses + refresh_pointers, EncryptedGroupInfoBlobV1 AAD + effective-expiry semantics, GroupMembershipEntry.V1 admitted_via_external_group_id). Consuming changes kept to what main already contains: - invite::payload rewritten for the typed shape: symmetric_key is a SymmetricKey submessage (MissingSymmetricKey + material-length checks), service_pointer is an optional ServicePointer (absent = application-resolved; present-but-empty oneof fails closed; https_url parsed and scheme-checked via url). New https_service_pointer / opaque_service_pointer / validate_service_pointer helpers. - GroupMembershipEntry.V1 construction sites gain the new admitted_via_external_group_id field, empty everywhere: the migrator's synthesized entries are all Welcome/legacy members (absent is permanently correct), and the membership-update rewrite path documents that tag preservation MUST land together with the validator's write-once enforcement in the external-commit stack — nothing on main can set the tag yet, so empty is correct today. The rest of the QR-invite stack (#3671 -> #3666 -> #3667 -> #3668 -> #3673 -> #3674) rebases onto this and picks up the new fields layer by layer.
…ves) + adapt invite::payload Regenerates xmtp_proto from xmtp/proto#336 (typed SymmetricKey / GroupStateHash / ServicePointer newtypes, ExternalCommitPolicyV1 max_uses + refresh_pointers, EncryptedGroupInfoBlobV1 AAD + effective-expiry semantics, GroupMembershipEntry.V1 admitted_via_external_group_id). Consuming changes kept to what main already contains: - invite::payload rewritten for the typed shape: symmetric_key is a SymmetricKey submessage (MissingSymmetricKey + material-length checks), service_pointer is an optional ServicePointer (absent = application-resolved; present-but-empty oneof fails closed; https_url parsed and scheme-checked via url). New https_service_pointer / opaque_service_pointer / validate_service_pointer helpers. - GroupMembershipEntry.V1 construction sites gain the new admitted_via_external_group_id field, empty everywhere: the migrator's synthesized entries are all Welcome/legacy members (absent is permanently correct), and the membership-update rewrite path documents that tag preservation MUST land together with the validator's write-once enforcement in the external-commit stack — nothing on main can set the tag yet, so empty is correct today. The rest of the QR-invite stack (#3671 -> #3666 -> #3667 -> #3668 -> #3673 -> #3674) rebases onto this and picks up the new fields layer by layer.
ccb136f to
5b4bf1f
Compare
Summary
ValidatedCommit::from_external_commit— the full XIP-82 receive-side check set forSender::NewMemberCommit(external commit) joins, replacing the earlier draft's boolean policy gate:NewMemberCommit(defensive re-check under the dispatch).ExternalInit.Addbinds to the joiner's path-leaf inbox id (anti-smuggling).GROUP_MEMBERSHIPentry (re-adding yourself would let you re-tag your own entry; resync is deferred, not v1).AppDataUpdate: a singleInsertof the joiner's ownGROUP_MEMBERSHIPentry. Insert-only is not extra restriction — check 4 guarantees no existing entry andTlsMapDeltaapply failsUpdate/Deleteon a missing key, so any other shape could never merge.Remove/SelfRemove, PSKs (changed from the draft — a non-member has no PSK it can legitimately reference; gratuitous attack surface),GroupContextExtensions, anything else.EXTERNAL_COMMIT_POLICY.allow_external_commit == true; absent component / unknown version / decode failure all fail closed.expires_at_ns(absolute) andexpire_in_ns(vs epoch-start envelope ts), threaded in as the newExternalCommitTimestampsparam so the verdict never depends on a validator's wall clock. Age saturates at zero to absorb publish-latency skew.GROUP_MEMBERSHIP.external_committer_permissionsadmits the joiner-self insert (the grant the enable commit establishes atomically in xmtp_mls: EXTERNAL_COMMIT_POLICY component + enable/revoke API with XIP-82 lifecycle invariants #3666).admitted_via_external_group_idequal to the activeexternal_group_id— on every external commit, whatevermax_usesis.max_uses: strictly fewer than the cap of current entries tagged with the active slot, counted from shared pre-commit state so every member — including post-invite joiners — computes the same count.Plus the two member-path halves the XIP requires to land together with the validator (the deferral documented in #3751):
enforce_admitted_via_write_once, hooked intovalidate_one_app_data_update_with_old_value): a member-sender commit that sets, clears, or altersadmitted_via_external_group_idon any entry — its own included — is rejected, so an invited member can't untag itself and free amax_usesslot.Delete(member removal) remains the one legal way a tag disappears. Fails closed on undecodable/unknown-version entry values: an opaque blob over a tagged V1 entry would hide the tag from every V1 reader's count.build_group_membership_app_data_payload: the Update arm (sequence-id bumps, installation changes) now carries the existing entry's tag through from the AppData dict; fresh Inserts stay untagged. Without this, any member's routine membership rewrite would be convergently rejected by the new write-once check.Supporting:
read_group_membership_entries(decoded per-inbox entries, tag included — the legacy-flattening reader drops it;read_group_membership_from_dictnow layers on it), new structured error variants for every check.Stack
Base:
tyler/qr-invite-l6-external-commit-policy(#3666) ← #3671 ← #3751 ← main.Test plan
external_commit_validator_tests: typed policy gate (absent/disabled/enabled), both time bounds incl. exact-boundary and skew-clamp cases, already-member (tree hit / map hit / clean), max_uses (0 = unlimited, below/at cap, untagged + rotated-slot entries don't count), scope + tag (untagged entry, wrong-slot tag, unknown-version entry, non-Insert mutation, mixed-inbox, empty/malformed delta, Remove op, wrong component), write-once (carry-through, untagged rewrite, clear/set/alter rejected, delete allowed, unknown-version rewrite rejected), PSK-forbidden, plus the pre-existing structure/sender cases.xmtp_mlssuite: 518 passed.cargo check --locked,just lint-rustclean.process_message) rides with L-8 (xmtp_mls: route Sender::NewMemberCommit through from_external_commit + envelope-timestamp plumbing #3668), which owns the dispatch that routesNewMemberCommithere and sourcesExternalCommitTimestampsfrom envelopes.🤖 Generated with Claude Code
Note
Implement XIP-82 receive-side validation in
ValidatedCommit::from_external_commitNewMemberCommit-sender commits invalidated_commit.rs, enforcing policy gate, time bounds (absolute expiry and per-epoch staleness), proposal structure, Add-to-joiner binding, andGROUP_MEMBERSHIPAppData scope.ExternalCommitTimestampsto thread delivery-envelope timestamps into expiry/staleness checks, and adds ~20 newCommitValidationErrorvariants for granular rejection reasons.admitted_via_external_group_idon member-sentGROUP_MEMBERSHIPupdates viaenforce_admitted_via_write_once, rejecting any rewrite that sets, clears, or alters the tag.build_group_membership_app_data_payloadandencode_membership_entryinupdate_group_membership.rsto preserve existingadmitted_via_external_group_idon updates while leaving inserts untagged.read_group_membership_entriesinto a reusable helper incomponent_source.rsand refactorsread_group_membership_from_dictto use it.📊 Macroscope summarized 9bfd0b7. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.