Skip to content

xmtp_mls_common: add invite::encrypted_group_info + XMTP_EXTERNAL_INVITE_LABEL#3671

Open
tylerhawkes wants to merge 1 commit into
tyler/qr-invite-l4b-proto-rebumpfrom
tyler/qr-invite-l5-invite-encryption
Open

xmtp_mls_common: add invite::encrypted_group_info + XMTP_EXTERNAL_INVITE_LABEL#3671
tylerhawkes wants to merge 1 commit into
tyler/qr-invite-l4b-proto-rebumpfrom
tyler/qr-invite-l5-invite-encryption

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the encryption envelope for the QR-invite flow (XIP-82), layered on the payload_encryption primitives:

  • xmtp_mls_common::invite::encrypted_group_infowrap_group_info / unwrap_group_info around EncryptedGroupInfoBlobV1 using ChaCha20Poly1305, with 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. reset its expiry to "never"); tampering any envelope field fails the unwrap. Dedicated tests tamper each field individually.
  • Typed GroupStateHash with the digest length enforced to the ciphersuite hash length (32 bytes) at both wrap and unwrap (GROUP_STATE_HASH_LEN).
  • 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). The joiner's one expiry check then also skips candidates validators would reject as stale (no "zombie joins" — a joiner believing it joined a group whose every member rejected the commit), and service TTL-GC naturally collects staleness-dead blobs.
  • wrap_group_info generates a fresh CSPRNG nonce per call by default (many independent writers encrypt under the same long-lived key, so deterministic nonce schemes would collide across writers); .nonce(...) override for tests.
  • unwrap_group_info verifies envelope version, nonce length, digest presence + length, and (when now_ns is supplied) expiry — all before decryption.
  • Adds XMTP_EXTERNAL_INVITE_LABEL to xmtp_configuration::common::mls (used by the create-invite path).

Candidate-set selection across the versioned slot (validate-and-select, highest-epoch, deterministic fork order) lands with the join flow (L-11), where GroupInfo parsing lives.

Stack

Test plan

  • 17 tests: round-trip (default + explicit nonce), version reject, nonce-length reject (short + long), wrong-key fails AEAD, tampered ciphertext / epoch / digest / expiry each fail AEAD, fresh nonces differ, expiry semantics (boundary, zero-means-never, None bypass), digest-length enforcement at wrap and unwrap, effective-expiry math (bounds dropout, min in both orders, saturation).
  • cargo check --locked, just lint-rust clean; cargo nextest -p xmtp_mls_common 824 passed.

🤖 Generated with Claude Code

Note

Add invite::encrypted_group_info and AAD support to wrap_payload_symmetric

  • Adds a new encrypted_group_info module to xmtp_mls_common with wrap_group_info and unwrap_group_info functions that produce/consume an EncryptedGroupInfoBlob V1 envelope using ChaCha20Poly1305 AEAD, binding epoch, expiry, and group state hash as AAD.
  • Adds XMTP_EXTERNAL_INVITE_LABEL constant to xmtp_configuration as an HPKE domain-separation label for external-invite GroupInfo payloads.
  • Converts wrap_payload_symmetric and unwrap_payload_symmetric in payload_encryption.rs to a #[bon::builder] API with an optional aad parameter; existing callers in mls_sync.rs and decrypted_welcome.rs are updated to use the new builder style.
  • Behavioral Change: wrap_payload_symmetric and unwrap_payload_symmetric now accept AAD; omitting aad preserves prior behavior (empty slice).

Macroscope summarized ea93854.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @tylerhawkes's task —— View job


Code Review Complete

Review Date: June 11, 2026, 05:10 UTC

Summary

This PR adds well-structured encryption/decryption helpers for the QR-invite flow with strong cryptographic foundations. The implementation is thorough with comprehensive test coverage. However, there is one unresolved security issue that should be addressed before merge (already flagged by Macroscope).


⚠️ Critical Issue (Already Flagged)

Pre-authentication expiry check in encrypted_group_info.rs:247-255

The expiry validation occurs before AEAD authentication, allowing an attacker to manipulate the expires_at_ns field to cause premature rejection of valid invites. While the AAD binding prevents silent acceptance of tampered expiry values, the early check creates an unauthenticated denial-of-service vector.

Impact: An attacker can intercept a valid encrypted blob and modify expires_at_ns to any past timestamp, causing the joiner to reject it with Expired rather than Unwrap. This defeats the integrity protection of the AAD binding for the expiry field specifically.

Recommendation: Move the expiry check to after successful AEAD decryption (after line 264). This ensures all envelope fields are integrity-verified before any semantic validation.


🟢 Strengths

  1. Strong cryptographic design: AAD binding of all cleartext metadata (epoch, expires_at_ns, group_state_hash) prevents tampering attacks. The test suite validates this thoroughly with dedicated tamper tests for each field.

  2. Excellent test coverage: 17 tests covering round-trips, nonce freshness, version/length validation, wrong-key failures, tampering detection, expiry semantics, and edge cases. Test quality is high.

  3. Good separation of concerns: The builder pattern for wrap_group_info/unwrap_group_info makes parameter misuse difficult (all byte slices are named at call sites). The effective_expires_at_ns helper cleanly encapsulates policy merging logic.

  4. Clear documentation: Inline comments explain the cryptographic requirements (nonce uniqueness, AAD purpose) and design decisions (why staleness deadline is folded into expires_at_ns).

  5. Type safety: GROUP_STATE_HASH_LEN enforcement at both wrap and unwrap sides prevents length confusion. The EncryptedGroupInfoError variants are specific and actionable.


💡 Minor Observations

  1. payload_encryption.rs changes look good: The conversion to builder-style API with optional AAD is a clean refactoring. All existing callers are correctly updated to the new builder syntax. The tests demonstrate both with-AAD and without-AAD paths work correctly.

  2. XMTP_EXTERNAL_INVITE_LABEL is well-documented: The comment explains why it's distinct from WELCOME_HPKE_LABEL (cross-protocol oracle prevention) and notes it's reserved for future HPKE use even though v1 uses symmetric AEAD only.

  3. No performance concerns: ChaCha20Poly1305 is appropriate for this use case. Nonce generation via CSPRNG is acceptable given the security model (many writers sharing a long-lived key).


✅ Test Coverage Assessment

The test suite is comprehensive and covers all critical paths:

  • Nonce handling (default random, explicit override, freshness verification)
  • Error conditions (version, lengths, expiry boundary, wrong key, tampering)
  • AAD binding (all three fields individually)
  • Policy logic (effective_expires_at_ns math including saturation)

No test gaps identified.


Recommendation

Request changes to address the pre-authentication expiry check timing issue. Once that's resolved, this is ready to merge.


macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 21, 2026
@macroscopeapp

macroscopeapp Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

New cryptographic encryption/decryption helpers for the QR-invite flow with ~580 lines of new code. Unresolved review comments identify a potential security issue where expiry checks occur before AEAD authentication. New security-relevant features warrant human review.

No code changes detected at ea93854. Prior analysis still applies.

You can customize Macroscope's approvability policy. Learn more.

@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.04077% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.54%. Comparing base (bd245bf) to head (d58d4c3).

Files with missing lines Patch % Lines
...xmtp_mls_common/src/invite/encrypted_group_info.rs 98.70% 4 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                          @@
##           tyler/qr-invite-l4b-proto-rebump    #3671      +/-   ##
====================================================================
+ Coverage                             84.45%   84.54%   +0.09%     
====================================================================
  Files                                   408      409       +1     
  Lines                                 59869    60248     +379     
====================================================================
+ Hits                                  50561    50938     +377     
- Misses                                 9308     9310       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from 2c61537 to d8d71d5 Compare May 26, 2026 22:59
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l4-invite-payload branch from ce61b41 to 32a32fb Compare May 26, 2026 22:59
@macroscopeapp macroscopeapp Bot dismissed their stale review May 26, 2026 22:59

Dismissing prior approval to re-evaluate d8d71d5

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 26, 2026
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from d8d71d5 to e369a4e Compare May 27, 2026 18:17
@macroscopeapp macroscopeapp Bot dismissed their stale review May 27, 2026 18:18

Dismissing prior approval to re-evaluate e369a4e

@tylerhawkes tylerhawkes changed the base branch from tyler/qr-invite-l4-invite-payload to tyler/qr-invite-l2-payload-encryption May 27, 2026 18:18
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 27, 2026
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l2-payload-encryption branch from be11fef to e8a88aa Compare June 4, 2026 20:08
Base automatically changed from tyler/qr-invite-l2-payload-encryption to main June 4, 2026 22:50
@macroscopeapp macroscopeapp Bot dismissed their stale review June 4, 2026 22:50

Dismissing prior approval to re-evaluate e369a4e

Comment thread crates/xmtp_mls_common/src/invite/payload.rs
Comment thread crates/xmtp_mls_common/src/invite/encrypted_group_info.rs Outdated
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch 2 times, most recently from 7c45f8a to 03ad9fe Compare June 5, 2026 09:00
/// * [`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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low invite/encrypted_group_info.rs:155

unwrap_group_info checks v1.expires_at_ns against now_ns at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify expires_at_ns to any nonzero past timestamp and cause the function to return EncryptedGroupInfoError::Expired instead of EncryptedGroupInfoError::Unwrap. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the expires_at_ns value is integrity-protected by the AAD.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls_common/src/invite/encrypted_group_info.rs around line 155:

`unwrap_group_info` checks `v1.expires_at_ns` against `now_ns` at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify `expires_at_ns` to any nonzero past timestamp and cause the function to return `EncryptedGroupInfoError::Expired` instead of `EncryptedGroupInfoError::Unwrap`. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the `expires_at_ns` value is integrity-protected by the AAD.

/// * [`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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low invite/encrypted_group_info.rs:155

unwrap_group_info checks v1.expires_at_ns against now_ns at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify expires_at_ns to any nonzero past timestamp and cause the function to return EncryptedGroupInfoError::Expired instead of EncryptedGroupInfoError::Unwrap. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the expires_at_ns value is integrity-protected by the AAD.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls_common/src/invite/encrypted_group_info.rs around line 155:

`unwrap_group_info` checks `v1.expires_at_ns` against `now_ns` at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify `expires_at_ns` to any nonzero past timestamp and cause the function to return `EncryptedGroupInfoError::Expired` instead of `EncryptedGroupInfoError::Unwrap`. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the `expires_at_ns` value is integrity-protected by the AAD.

/// * [`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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low invite/encrypted_group_info.rs:155

unwrap_group_info checks v1.expires_at_ns against now_ns at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify expires_at_ns to any nonzero past timestamp and cause the function to return EncryptedGroupInfoError::Expired instead of EncryptedGroupInfoError::Unwrap. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the expires_at_ns value is integrity-protected by the AAD.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls_common/src/invite/encrypted_group_info.rs around line 155:

`unwrap_group_info` checks `v1.expires_at_ns` against `now_ns` at line 167–174 before the AEAD unwrap at line 178–184. Because the envelope fields are attacker-controlled until verified, an attacker can modify `expires_at_ns` to any nonzero past timestamp and cause the function to return `EncryptedGroupInfoError::Expired` instead of `EncryptedGroupInfoError::Unwrap`. This allows unauthenticated expiry invalidation of valid invites. The expiry check should occur after successful AEAD authentication so the `expires_at_ns` value is integrity-protected by the AAD.

@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from 03ad9fe to 54721d0 Compare June 6, 2026 05:07
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from 54721d0 to c4355ad Compare June 9, 2026 23:03
@tylerhawkes tylerhawkes changed the base branch from main to tyler/qr-invite-l4b-proto-rebump June 9, 2026 23:03
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from c4355ad to 004ddc2 Compare June 9, 2026 23:15
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l4b-proto-rebump branch from 51ada44 to a96dde2 Compare June 9, 2026 23:15
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from 004ddc2 to d58d4c3 Compare June 9, 2026 23:25
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l4b-proto-rebump branch from a96dde2 to bd245bf Compare June 9, 2026 23:25
tylerhawkes added a commit that referenced this pull request Jun 11, 2026
…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.
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l4b-proto-rebump branch from bd245bf to bb4f688 Compare June 11, 2026 00:28
…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.
tylerhawkes added a commit that referenced this pull request Jun 11, 2026
…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.
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l4b-proto-rebump branch from bb4f688 to 650ba97 Compare June 11, 2026 04:54
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from d58d4c3 to ea93854 Compare June 11, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant