Skip to content

xmtp_mls_common: add invite::encrypted_group_info + XMTP_EXTERNAL_INVITE_LABEL#3671

Open
tylerhawkes wants to merge 1 commit into
mainfrom
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
mainfrom
tyler/qr-invite-l5-invite-encryption

Conversation

@tylerhawkes
Copy link
Copy Markdown
Contributor

@tylerhawkes tylerhawkes commented May 21, 2026

Summary

Adds the encryption envelope for the QR-invite flow.

  • xmtp_mls_common::invite::encrypted_group_info — wraps/unwraps a TLS-serialized GroupInfo into an EncryptedGroupInfoBlob proto using ChaCha20Poly1305 (via the payload_encryption helpers from PR-L2).
  • wrap_group_info generates a fresh nonce per call; wrap_group_info_with_nonce for tests / explicit nonce management.
  • unwrap_group_info verifies envelope version + nonce length before decryption.
  • Adds XMTP_EXTERNAL_INVITE_LABEL constant to xmtp_configuration::common::mls (passed to wrap_payload_hpke by the create-invite path, not used here directly).

The blob's epoch and group_state_hash fields are populated by the L-10/L-11 callers where the MlsGroup is available — the pure-codec helpers in this PR leave them zero.

Stack

Test plan

  • 7 tests: round-trip (default + explicit nonce), version-mismatch reject, nonce-length reject, wrong-key fails AEAD, tampered ciphertext fails AEAD, fresh nonces differ across calls.

Note

Add invite::encrypted_group_info module with wrap_group_info/unwrap_group_info and XMTP_EXTERNAL_INVITE_LABEL

  • Adds encrypted_group_info.rs implementing ChaCha20Poly1305 symmetric encryption/decryption for EncryptedGroupInfoBlob::V1, with AAD binding epoch, expiry, and group state hash to authenticate envelope metadata.
  • Adds XMTP_EXTERNAL_INVITE_LABEL in mls.rs as a distinct HPKE domain-separation label reserved for external-invite flows.
  • Converts wrap_payload_symmetric and unwrap_payload_symmetric in payload_encryption.rs from positional-argument functions to #[bon::builder]-style, adding an optional aad parameter that defaults to empty bytes.
  • Behavioral Change: existing callers of wrap_payload_symmetric/unwrap_payload_symmetric must be updated to use builder syntax; AAD defaults to empty preserving prior decryption behavior.

Macroscope summarized 54721d0.

@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Claude finished @tylerhawkes's task —— View job


Code Review — June 6, 2026 05:09 UTC

I've reviewed the encryption helpers for the QR-invite flow. The implementation is solid overall, with one outstanding security concern that should be addressed:

Security: Expiry Check Ordering (High Priority)

The existing review comment about expiry-check ordering at encrypted_group_info.rs:165 is valid and should be fixed:

Issue: unwrap_group_info checks expires_at_ns against now_ns (lines 177-184) before AEAD unwrap (lines 187-194). Since expires_at_ns is attacker-controlled until the AEAD verifies it, an attacker can modify this field to cause spurious Expired errors on valid invites.

Impact: Denial-of-service vector — attackers can make valid, non-expired invites appear expired by tampering with expires_at_ns in transit.

Fix: Move the expiry check after line 195 (after successful AEAD unwrap), when v1.expires_at_ns is integrity-protected by the AAD. The check will still prevent using genuinely expired invites, but tampering will correctly surface as Unwrap errors instead of Expired.

Example:

// After line 194, before Ok((plaintext, v1)):
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,
    });
}

Test gap: The existing expired_blob_rejected_when_now_supplied test doesn't catch this because it only tests legitimate expiry (matching AAD). Add a test that tampers with expires_at_ns on a valid blob and verifies it returns Unwrap (not Expired).


Other Observations

Code Quality: ✅ Excellent

  • Builder pattern for wrap_group_info is clean and type-safe
  • AAD construction (blob_aad) is unambiguous with length-prefixing
  • Error types are well-structured and informative
  • Comprehensive doc comments with examples

Test Coverage: ✅ Strong (98.76%)

  • 7 tests cover: round-trips, version mismatch, nonce validation, wrong keys, ciphertext tampering, AAD tampering (epoch, state hash, expiry)
  • Nonce-uniqueness property verified (fresh_nonces_differ)
  • Missing: test for the expiry-ordering DoS described above

Performance: ✅ No concerns

  • ChaCha20Poly1305 is appropriate for this use case
  • Single-pass AAD construction is efficient

Best Practices: ✅ Followed

  • AEAD nonce management is correct (fresh per call, overridable for tests)
  • Domain separation via AAD (not just ciphertext)
  • Zero-copy where practical (&[u8] arguments)

Summary

This is high-quality cryptographic code. The only blocker is the expiry-check ordering issue, which is a real security concern (DoS vector) but straightforward to fix. Once that's addressed, this is ready to merge.

Tasks

  • Analyze code structure and implementation
  • Review security concerns
  • Check test coverage and edge cases
  • Provide final feedback

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 21, 2026
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 21, 2026

Approvability

Verdict: Needs human review

Introduces new cryptographic functionality for encrypted group info with AEAD encryption. Unresolved review comments identify a potential security issue where expiry is checked before AEAD authentication, which warrants human attention.

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

@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

❌ Patch coverage is 98.81657% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.36%. Comparing base (360a2e8) to head (54721d0).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
...xmtp_mls_common/src/invite/encrypted_group_info.rs 98.26% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3671      +/-   ##
==========================================
+ Coverage   84.23%   84.36%   +0.12%     
==========================================
  Files         407      409       +2     
  Lines       59350    59519     +169     
==========================================
+ Hits        49994    50213     +219     
+ Misses       9356     9306      -50     

☔ 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
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