xmtp_mls_common: add invite::encrypted_group_info + XMTP_EXTERNAL_INVITE_LABEL#3671
Conversation
|
Claude finished @tylerhawkes's task —— View job Code Review CompleteReview Date: June 11, 2026, 05:10 UTC SummaryThis 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).
|
ApprovabilityVerdict: 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 You can customize Macroscope's approvability policy. Learn more. |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
2c61537 to
d8d71d5
Compare
ce61b41 to
32a32fb
Compare
Dismissing prior approval to re-evaluate d8d71d5
d8d71d5 to
e369a4e
Compare
Dismissing prior approval to re-evaluate e369a4e
be11fef to
e8a88aa
Compare
Dismissing prior approval to re-evaluate e369a4e
7c45f8a to
03ad9fe
Compare
| /// * [`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>( |
There was a problem hiding this comment.
🟢 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>( |
There was a problem hiding this comment.
🟢 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>( |
There was a problem hiding this comment.
🟢 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.
03ad9fe to
54721d0
Compare
54721d0 to
c4355ad
Compare
c4355ad to
004ddc2
Compare
51ada44 to
a96dde2
Compare
004ddc2 to
d58d4c3
Compare
a96dde2 to
bd245bf
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.
bd245bf to
bb4f688
Compare
…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.
…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.
bb4f688 to
650ba97
Compare
d58d4c3 to
ea93854
Compare
Summary
Adds the encryption envelope for the QR-invite flow (XIP-82), layered on the
payload_encryptionprimitives:xmtp_mls_common::invite::encrypted_group_info—wrap_group_info/unwrap_group_infoaroundEncryptedGroupInfoBlobV1using 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.GroupStateHashwith 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_infogenerates 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_infoverifies envelope version, nonce length, digest presence + length, and (whennow_nsis supplied) expiry — all before decryption.XMTP_EXTERNAL_INVITE_LABELtoxmtp_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
tyler/qr-invite-l4b-proto-rebump(xmtp_proto: bump to xmtp/proto XIP-82 revisions (typed invite primitives) + adapt invite::payload #3751 — xmtp_proto bump to feat: XIP-82 review revisions — typed invite primitives, max_uses, refresh_pointers, AAD + effective expiry, admitted_via tag proto#336 + typedinvite::payload).Test plan
Nonebypass), digest-length enforcement at wrap and unwrap, effective-expiry math (bounds dropout, min in both orders, saturation).cargo check --locked,just lint-rustclean;cargo nextest -p xmtp_mls_common824 passed.🤖 Generated with Claude Code
Note
Add
invite::encrypted_group_infoand AAD support towrap_payload_symmetricencrypted_group_infomodule toxmtp_mls_commonwithwrap_group_infoandunwrap_group_infofunctions that produce/consume anEncryptedGroupInfoBlobV1 envelope using ChaCha20Poly1305 AEAD, binding epoch, expiry, and group state hash as AAD.XMTP_EXTERNAL_INVITE_LABELconstant toxmtp_configurationas an HPKE domain-separation label for external-invite GroupInfo payloads.wrap_payload_symmetricandunwrap_payload_symmetricinpayload_encryption.rsto a#[bon::builder]API with an optionalaadparameter; existing callers inmls_sync.rsanddecrypted_welcome.rsare updated to use the new builder style.wrap_payload_symmetricandunwrap_payload_symmetricnow accept AAD; omittingaadpreserves prior behavior (empty slice).Macroscope summarized ea93854.