XIP-82 review revisions: typed newtypes, versioned-slot anti-poisoning, max_uses, confidential-components direction#140
Conversation
ApprovabilityVerdict: Needs human review Unable to check for correctness in 6ff3312. The author does not own the main changed file (owned by @jhaaaa), and there are multiple unresolved medium-severity review comments identifying potential design inconsistencies in the specification. The designated code owner should review these substantial protocol specification changes. You can customize Macroscope's approvability policy. Learn more. |
3021bf3 to
09756c8
Compare
|
Thanks for the review. Addressed all five findings in the latest push, and converted the overview to a mermaid 🟠 High — 🟡 Medium — "unchanged on the wire" is false. Correct. Wrapping 🟡 Medium — duplicate coalescing. Changed rule 5 to 🟡 Medium — CCK over the app-stream leaks to all members. Correct. The v2 explicit-grant path now delivers the CCK encrypted specifically to the approved member (HPKE to their installation key(s) / a 1:1 channel), never as a group application message (which is encrypted to all current members and would leak to every other unapproved self-joiner). Still requires no commit. 🟢 Low — fork tie-breaker. Agree there's a loop risk, but a single deterministic pick would guarantee the loop (it re-selects the same losing fork on every re-fetch). Fixed with deterministic ordering (ascending Flagging @jhaaaa per the ownership note — these are substantive protocol-spec revisions and should have an owner's eyes before merge. |
09756c8 to
988edca
Compare
|
Second round — fixed the three new findings in 🟠 High — coalescing identity (line 421). Right, and subtle: keying coalescing on the plaintext 🟡 Medium — 🟡 Medium — |
| @@ -412,9 +479,10 @@ The `EXTERNAL_COMMIT_POLICY.allow_external_commit` master switch provides a sepa | |||
|
|
|||
There was a problem hiding this comment.
🟢 Low XIPs/xip-82-external-commit-invites.md:479
The max_uses count tracks only live GROUP_MEMBERSHIP entries with the active external_group_id, and rotating to a new external_group_id resets the budget. This means max_uses does not actually bound total group size from external invites—an admin can rotate the slot while previously invited members remain, then admit another max_uses members under the new slot. With max_uses = 1, one externally invited member can stay, the slot rotates, and a second external invite is accepted. The PR's goal of capping total externally invited members is not achieved; the mechanism only enforces a per-slot cap.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 479:
The `max_uses` count tracks only live `GROUP_MEMBERSHIP` entries with the **active** `external_group_id`, and rotating to a new `external_group_id` resets the budget. This means `max_uses` does not actually bound total group size from external invites—an admin can rotate the slot while previously invited members remain, then admit another `max_uses` members under the new slot. With `max_uses = 1`, one externally invited member can stay, the slot rotates, and a second external invite is accepted. The PR's goal of capping total externally invited members is not achieved; the mechanism only enforces a per-slot cap.
41414bc to
c5d00b7
Compare
|
Added an Invite redirection (known limitation) security subsection documenting a phishing/redirect risk and why it's hard to fix ( The risk: the invite is a bearer capability — any holder of the QR (or any member) can both join and write refreshed blobs. So a holder can drop a blob wrapping a different, attacker-controlled group's GroupInfo into the slot, and a later scanner ends up in the attacker's group believing it's the advertised one. Why the obvious fixes don't work, under the bearer model:
So v1 does not cryptographically prevent redirection; it's documented as a known limitation for future work, with the interim mitigations being (a) treat the payload as a secret and (b) application-layer post-join verification (fingerprint cross-check / admin-identity challenge). Also corrected the now-stale "substitution is prevented" claims in Malicious service and Alternatives considered (AEAD only stops substitution by a party without the key), and added threat-model rows splitting keyless-substitution (prevented) from key-holder redirection (known limitation). |
There was a problem hiding this comment.
🟡 Medium
XIPs/XIPs/xip-82-external-commit-invites.md
Lines 234 to 237 in c5d00b7
After changing symmetric_key from raw bytes to the SymmetricKey submessage, the revoke invariant at lines 234-237 still instructs clearing it by "set to empty bytes". This is no longer well-typed for field 4, so different validator implementations could encode revocation inconsistently (symmetric_key absent vs symmetric_key {} vs empty material), causing divergent policy validation and potential state forks. Consider updating the comment to specify the exact wire encoding (absent field vs empty submessage vs empty material) and ensure libxmtp enforces that encoding uniformly.
- // * 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
+ // * When `allow_external_commit` transitions to false (revoke):
+ // `symmetric_key` and `external_group_id` MUST be cleared in the
+ // same proposal. `symmetric_key` MUST be absent (not merely empty
+ // `material`). Leaving stale coordinates in
// the group state after revoke would let a future re-enable
// accidentally revive a previously-distributed key.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around lines 234-237:
After changing `symmetric_key` from raw `bytes` to the `SymmetricKey` submessage, the revoke invariant at lines 234-237 still instructs clearing it by "set to empty bytes". This is no longer well-typed for field 4, so different validator implementations could encode revocation inconsistently (`symmetric_key` absent vs `symmetric_key {}` vs empty `material`), causing divergent policy validation and potential state forks. Consider updating the comment to specify the exact wire encoding (absent field vs empty submessage vs empty `material`) and ensure `libxmtp` enforces that encoding uniformly.
| 11. **Single active invited member (`max_uses = 1`)**: the first external-commit join is accepted by every member; a second concurrent external commit is rejected; after the first invited member is removed, a new external commit is admitted (the slot is freed — concurrent semantics). | ||
| 12. **Bounded invite (`max_uses = N`)**: the commit that would create the (`N`+1)-th concurrent member tagged with the active `external_group_id` is rejected; rotating to a new `external_group_id` starts a fresh budget. | ||
| 13. **Slot poisoning by a non-key-holder**: an attacker uploads a high-`epoch` garbage blob; the joiner discards it at AEAD-decrypt, selects the genuine blob, and joins successfully (slot not bricked). | ||
| 14. **Versioned-slot selection**: the slot holds several valid blobs at different epochs; the joiner selects the highest-epoch one. | ||
| 15. **Forged-metadata blob by a key-holder**: a blob whose plaintext `epoch` / `group_state_hash` disagree with the wrapped `GroupInfo` is discarded at the consistency check. | ||
| 16. **`max_uses` convergence for a late joiner**: a member that joined *after* the invite was issued computes the same use-count from `GROUP_MEMBERSHIP` (entries tagged with the active `external_group_id`), not from replayed history, and reaches the same accept/reject decision as older members. |
There was a problem hiding this comment.
🟢 Low XIPs/xip-82-external-commit-invites.md:571
Lines 571-576 list test cases 11-16 (max_uses, versioned-slot selection, etc.) as "included" in the reference implementation, but line 580 states these features are only being "layered onto the open PRs as review follow-ups". This creates a contradiction: the document claims the tests exist while simultaneously admitting they do not, so implementers cannot verify the revised spec against the referenced code. Consider either removing the not-yet-implemented tests from the "included" list, or updating line 580 to clarify that the features are now present.
-11. **Single active invited member (`max_uses = 1`)**: the first external-commit join is accepted by every member; a second concurrent external commit is rejected; after the first invited member is removed, a new external commit is admitted (the slot is freed — concurrent semantics).
-12. **Bounded invite (`max_uses = N`)**: the commit that would create the (`N`+1)-th concurrent member tagged with the active `external_group_id` is rejected; rotating to a new `external_group_id` starts a fresh budget.
-13. **Slot poisoning by a non-key-holder**: an attacker uploads a high-`epoch` garbage blob; the joiner discards it at AEAD-decrypt, selects the genuine blob, and joins successfully (slot not bricked).
-14. **Versioned-slot selection**: the slot holds several valid blobs at different epochs; the joiner selects the highest-epoch one.
-15. **Forged-metadata blob by a key-holder**: a blob whose plaintext `epoch` / `group_state_hash` disagree with the wrapped `GroupInfo` is discarded at the consistency check.
-16. **`max_uses` convergence for a late joiner**: a member that joined *after* the invite was issued computes the same use-count from `GROUP_MEMBERSHIP` (entries tagged with the active `external_group_id`), not from replayed history, and reaches the same accept/reject decision as older members.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around lines 571-576:
Lines 571-576 list test cases 11-16 (`max_uses`, versioned-slot selection, etc.) as "included" in the reference implementation, but line 580 states these features are only being "layered onto the open PRs as review follow-ups". This creates a contradiction: the document claims the tests exist while simultaneously admitting they do not, so implementers cannot verify the revised spec against the referenced code. Consider either removing the not-yet-implemented tests from the "included" list, or updating line 580 to clarify that the features are now present.
There was a problem hiding this comment.
🟡 Medium
XIPs/XIPs/xip-82-external-commit-invites.md
Lines 403 to 407 in 41414bc
Requiring the joiner's GROUP_MEMBERSHIP entry to record the specific external_group_id they were admitted under breaks in-flight joins when an admin rotates external_group_id before commit validation. The joiner only knows the old slot id from their QR/blob, so they cannot populate the entry with the new active id, causing validators to reject the commit. This contradicts the stated stale-QR flow where commits should still validate and only blob re-upload is skipped.
-3. Exactly one `AppDataUpdate` proposal scoped to the joiner's own entry in the `GROUP_MEMBERSHIP` component. The entry MUST record the `external_group_id` the joiner was admitted under (this is what `max_uses` accounting counts). (This keeps the tree-level MLS membership and the AppData-level membership coupled in a single atomic commit — required by libxmtp's cross-layer invariants — and still touches only the joiner's own entry, so the atomic shape is unchanged.)
+3. Exactly one `AppDataUpdate` proposal scoped to the joiner's own entry in the `GROUP_MEMBERSHIP` component. (This keeps the tree-level MLS membership and the AppData-level membership coupled in a single atomic commit — required by libxmtp's cross-layer invariants.)🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around lines 403-407:
Requiring the joiner's `GROUP_MEMBERSHIP` entry to record the specific `external_group_id` they were admitted under breaks in-flight joins when an admin rotates `external_group_id` before commit validation. The joiner only knows the old slot id from their QR/blob, so they cannot populate the entry with the new active id, causing validators to reject the commit. This contradicts the stated stale-QR flow where commits should still validate and only blob re-upload is skipped.
c5d00b7 to
b167b7a
Compare
|
Fixed the CI
markdownlint + cspell both green locally. |
| 4. Parse the plaintext as a `VerifiableGroupInfo`. | ||
| 5. Consistency: the GroupInfo's epoch equals `blob.epoch` **and** `digest(GroupContext)` equals `blob.group_state_hash.digest`. A blob whose key-holder lied about its metadata is discarded here. | ||
| 4. If no candidate survives, abort: the slot is empty, stale, or poisoned. This is transient — a subsequent genuine upload repopulates the slot — so the client MAY retry later. | ||
| 5. Among survivors, select the **highest-epoch** candidate. If several share the highest epoch with differing `group_state_hash` (concurrent forks), order them deterministically (e.g. ascending `group_state_hash`) and attempt them in that order; on a commit rejected at validation, exclude that fork and try the next. (A deterministic *single* pick is not sufficient — it would re-select the same losing fork on every re-fetch and loop forever.) If all same-epoch forks are exhausted, abort and retry later — a newer genuine blob may appear. |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:473
Join-side flow step 5 aborts after exhausting same-epoch forks without falling back to lower-epoch survivors. If the highest-epoch candidate(s) are all validator-rejected forks, the joiner abandons the slot even when lower-epoch blobs are present and valid. Consider continuing iteration through sorted-by-descending-epoch survivors, attempting each distinct epoch until a commit succeeds or all candidates are exhausted.
-5. Among survivors, select the **highest-epoch** candidate. If several share the highest epoch with differing `group_state_hash` (concurrent forks), order them deterministically (e.g. ascending `group_state_hash`) and attempt them in that order; on a commit rejected at validation, exclude that fork and try the next. (A deterministic *single* pick is not sufficient — it would re-select the same losing fork on every re-fetch and loop forever.) If all same-epoch forks are exhausted, abort and retry later — a newer genuine blob may appear.
+5. Among survivors, select candidates by **descending epoch**, and within each epoch by deterministic `group_state_hash` order (e.g. ascending). Attempt each candidate in this order; on a commit rejected at validation, exclude that candidate and continue with the next. If all candidates are exhausted, abort and retry later — a newer genuine blob may appear.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 473:
`Join-side flow` step 5 aborts after exhausting same-epoch forks without falling back to lower-epoch survivors. If the highest-epoch candidate(s) are all validator-rejected forks, the joiner abandons the slot even when lower-epoch blobs are present and valid. Consider continuing iteration through sorted-by-descending-epoch survivors, attempting each distinct epoch until a commit succeeds or all candidates are exhausted.
b167b7a to
d2038ea
Compare
|
Two corrections in
markdownlint + cspell green; |
| ### Why the joiner selects the blob, not the service | ||
|
|
||
| The service is untrusted and keyless, so any authority it exercises over which blob "wins" is an authority an attacker can hijack. An earlier design ordered uploads by a plaintext `epoch` ("strictly newer wins"); but `epoch` is uploader-asserted, so a single forged `epoch = u64::MAX` upload — possible **without** the symmetric key — would mark every genuine upload stale and brick the slot permanently. The fix moves selection to the only party that can actually verify a blob: the joiner, who holds the key and runs MLS validation. The service's job shrinks to "retain the last N uploads, FIFO, and don't pretend to know which is real"; the joiner decrypts each candidate, checks it parses to a `GroupInfo` whose epoch and `GroupContext` digest match the blob metadata, and takes the freshest survivor. Poison from a non-key-holder fails AEAD; poison from a key-holder fails the consistency check; a permanent wedge degrades to transient unavailability (recoverable by an admin or member re-uploading — see "Slot poisoning"). |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:517
Line 519 claims "poison from a key-holder fails the consistency check," but this contradicts the spec's own "Invite redirection (known limitation)" section. Any holder of the symmetric_key can forge a self-consistent blob for an attacker-controlled group where the epoch and GroupContext digest match the forged GroupInfo, passing the joiner's validation. The text documents a security property the protocol does not provide, which can mislead implementers into believing key-holder redirection is mitigated when it is not.
- Poison from a non-key-holder fails AEAD; poison from a key-holder fails the consistency check; a permanent wedge degrades to transient unavailability (recoverable by an admin or member re-uploading — see "Slot poisoning").
+ Poison from a non-key-holder fails AEAD; poison from a key-holder that uploads inconsistent metadata fails the consistency check, but a key-holder can still redirect to an attacker-controlled group with a fully self-consistent blob (see "Invite redirection (known limitation)"). A permanent wedge from non-key-holder poison degrades to transient unavailability (recoverable by an admin or member re-uploading — see "Slot poisoning").🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around lines 517-519:
Line 519 claims "poison from a key-holder fails the consistency check," but this contradicts the spec's own "Invite redirection (known limitation)" section. Any holder of the `symmetric_key` can forge a self-consistent blob for an attacker-controlled group where the epoch and `GroupContext` digest match the forged `GroupInfo`, passing the joiner's validation. The text documents a security property the protocol does not provide, which can mislead implementers into believing key-holder redirection is mitigated when it is not.
d2038ea to
124aba0
Compare
124aba0 to
bdf6b54
Compare
|
|
||
| 1. **Jitter.** Wait a uniformly random delay (RECOMMENDED 0–30 s). | ||
| 2. **Re-check.** If the group's epoch advanced again during the wait, stop — the newer commit's refresh cycle supersedes this one. | ||
| 3. **Check-before-write.** For each pointer in `refresh_pointers`: fetch the slot's candidates and validate them exactly as a joiner would (AEAD + consistency). If any candidate is valid at the current epoch, skip that slot — another member won the race. Otherwise wrap a fresh blob (fresh random nonce, effective `expires_at_ns`) and upload it. |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:585
In Keeping the blob fresh step 3, the check-before-write logic stands down whenever it finds any candidate that is "valid at the current epoch" based only on AEAD + metadata consistency. A same-epoch blob from a different fork passes these checks even when its group_state_hash does not match the member's own current group state. Members on the winning fork will incorrectly stand down, leaving a losing-fork blob in the slot. Future joiners will select and attempt commits from that blob, which validators reject due to the hash mismatch, causing join failures until a later epoch coincidentally refreshes the slot.
- 3. **Check-before-write.** For each pointer in `refresh_pointers`: fetch the slot's candidates and validate them exactly as a joiner would (AEAD + consistency). If any candidate is valid at the current epoch, skip that slot — another member won the race. Otherwise wrap a fresh blob (fresh random nonce, effective `expires_at_ns`) and upload it.
+ 3. **Check-before-write.** For each pointer in `refresh_pointers`: fetch the slot's candidates and validate them exactly as a joiner would (AEAD + consistency). If any candidate is valid at the current epoch **and its `group_state_hash` matches the member's current group state**, skip that slot — another member on this fork won the race. Otherwise wrap a fresh blob (fresh random nonce, effective `expires_at_ns`) and upload it.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 585:
In `Keeping the blob fresh` step 3, the check-before-write logic stands down whenever it finds *any* candidate that is "valid at the current epoch" based only on AEAD + metadata consistency. A same-epoch blob from a different fork passes these checks even when its `group_state_hash` does not match the member's own current group state. Members on the winning fork will incorrectly stand down, leaving a losing-fork blob in the slot. Future joiners will select and attempt commits from that blob, which validators reject due to the hash mismatch, causing join failures until a later epoch coincidentally refreshes the slot.
bdf6b54 to
6e6fc7d
Compare
| 2. Fetch the candidate set from the resolved service slot (up to N `EncryptedGroupInfoBlob`s — see "Service contract"). Clients SHOULD bound this step: cap the number of candidates processed (RECOMMENDED: at least the service's retention N, e.g. 8) and cap the per-blob size — a malicious service must not be able to stream unbounded data through the decrypt loop. See "Joiner-side resource bounds and fetch hygiene". | ||
| 3. For each candidate, discard it on the first failing check: | ||
| 1. `blob.version` is recognized. | ||
| 2. Not expired: `blob.expires_at_ns == 0 || now_ns < blob.expires_at_ns`. |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:576
The Join-side flow candidate-expiry check at step 3.2 uses the joiner's local now_ns (line 576: blob.expires_at_ns == 0 || now_ns < blob.expires_at_ns), but validators reject commits based on delivery-service envelope timestamps (lines 560–561). A client with a slow clock accepts blobs already expired in the envelope-timestamp domain, builds a commit from them, and hits the "zombie join" this section claims to avoid: every member rejects the commit while the joiner believes it succeeded. Consider aligning the join-side check to use the same envelope-timestamp domain as validators, or at minimum documenting that local-clock skew creates this window of zombie-join risk.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 576:
The `Join-side flow` candidate-expiry check at step 3.2 uses the joiner's local `now_ns` (line 576: `blob.expires_at_ns == 0 || now_ns < blob.expires_at_ns`), but validators reject commits based on delivery-service envelope timestamps (lines 560–561). A client with a slow clock accepts blobs already expired in the envelope-timestamp domain, builds a commit from them, and hits the "zombie join" this section claims to avoid: every member rejects the commit while the joiner believes it succeeded. Consider aligning the join-side check to use the same envelope-timestamp domain as validators, or at minimum documenting that local-clock skew creates this window of zombie-join risk.
6e6fc7d to
26f6259
Compare
| oneof location { | ||
| // RFC 3986 https URI. libxmtp parses and syntactically validates it. | ||
| string https_url = 1; | ||
| // Application-defined opaque bytes for non-URL transports (NFC tags, |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:106
ServicePointer adds an opaque arm that is explicitly "opaque to libxmtp," but the spec later requires members to fetch and re-upload every refresh_pointer to keep slots fresh. When EXTERNAL_COMMIT_POLICY.refresh_pointers contains an opaque pointer, no interoperable fetch/upload procedure exists, so conforming implementations cannot refresh those invites — breaking the member-driven freshness mechanism.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 106:
`ServicePointer` adds an `opaque` arm that is explicitly "opaque to libxmtp," but the spec later requires members to fetch and re-upload every `refresh_pointer` to keep slots fresh. When `EXTERNAL_COMMIT_POLICY.refresh_pointers` contains an `opaque` pointer, no interoperable fetch/upload procedure exists, so conforming implementations cannot refresh those invites — breaking the member-driven freshness mechanism.
26f6259 to
2af0112
Compare
| // 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) |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:229
The expires_at_ns formula at line 229 uses epoch_start_ns + EXTERNAL_COMMIT_POLICY.expire_in_ns, but expire_in_ns is documented elsewhere in this spec as a duration since GroupInfo export. When an admin delays exporting GroupInfo and uses a short expire_in_ns, the joiner-side TTL becomes stricter than the validator-side bound: the joiner discards the blob early while validators would still accept it. This breaks the claimed 'zombie join' guarantee by causing false-negative joins that should succeed.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 229:
The `expires_at_ns` formula at line 229 uses `epoch_start_ns + EXTERNAL_COMMIT_POLICY.expire_in_ns`, but `expire_in_ns` is documented elsewhere in this spec as a duration **since GroupInfo export**. When an admin delays exporting GroupInfo and uses a short `expire_in_ns`, the joiner-side TTL becomes stricter than the validator-side bound: the joiner discards the blob early while validators would still accept it. This breaks the claimed 'zombie join' guarantee by causing false-negative joins that should succeed.
| - **Fetch hygiene for `https_url`.** Clients MUST require the `https` scheme, MUST NOT follow redirects to other schemes, and MUST ignore credentials embedded in the URL. Scanning a QR and fetching immediately reveals the scanner's IP address (and scan time) to whoever controls the URL — before the user has decided anything. Clients SHOULD obtain user confirmation before the first fetch for an invite, and MAY route fetches through a proxy. |
There was a problem hiding this comment.
🟡 Medium XIPs/xip-82-external-commit-invites.md:838
The https_url fetch hygiene in the Joiner-side resource bounds and fetch hygiene section only forbids redirects to a different scheme, not to a different host. A malicious https:// URL can still 30x redirect the client to an attacker-controlled https:// origin, leaking the scanner's IP address and timing information to a third party and potentially serving attacker-controlled content. Consider prohibiting all cross-origin redirects, not just scheme changes.
| - **Fetch hygiene for `https_url`.** Clients MUST require the `https` scheme, MUST NOT follow redirects to other schemes, and MUST ignore credentials embedded in the URL. Scanning a QR and fetching immediately reveals the scanner's IP address (and scan time) to whoever controls the URL — before the user has decided anything. Clients SHOULD obtain user confirmation before the first fetch for an invite, and MAY route fetches through a proxy. | |
| - **Fetch hygiene for `https_url`.** Clients MUST require the `https` scheme, MUST NOT follow redirects to other schemes, and MUST ignore credentials embedded in the URL. Scanning a QR and fetching immediately reveals the scanner's IP address (and scan time) to whoever controls the URL — before the user has decided anything. Clients SHOULD obtain user confirmation before the first fetch for an invite, and MAY route fetches through a proxy. |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 838:
The `https_url` fetch hygiene in the `Joiner-side resource bounds and fetch hygiene` section only forbids redirects to a different scheme, not to a different host. A malicious `https://` URL can still 30x redirect the client to an attacker-controlled `https://` origin, leaking the scanner's IP address and timing information to a third party and potentially serving attacker-controlled content. Consider prohibiting all cross-origin redirects, not just scheme changes.
…fresh_pointers, AAD + effective expiry, admitted_via tag Follow-up to #334, mirroring the revised XIP-82 (xmtp/XIPs#140). See the PR body for the full breakdown. Also extends the local-only AppDataUpdateData.V1 intent with repeated additional_updates so writes whose invariants couple components atomically (XIP-82 enable atomicity: policy + GROUP_MEMBERSHIP grant) can land in one commit.
…fresh_pointers, AAD + effective expiry, admitted_via tag Follow-up to #334, mirroring the revised XIP-82 (xmtp/XIPs#140). See the PR body for the full breakdown. Also extends the local-only AppDataUpdateData.V1 intent with repeated additional_updates so writes whose invariants couple components atomically (XIP-82 enable atomicity: policy + GROUP_MEMBERSHIP grant) can land in one commit.
…ing, max_uses cap, confidential-components direction
65e305a to
0ba04f9
Compare
1ac135e to
edf4369
Compare
allow_external_commit itself authorizes the joiner-self-entry insert (implied grant); external_committer_permissions reduced to a reserved deny-by-default surface. Rework blob refresh to committer-immediate (primary, no jitter) + member existence-check backstop + rare deep-check self-heal, justified by the group_state_hash unforgeability (witness) argument. Add service-contract rules for (epoch, group_state_hash) lookup and an existence query; align join-side step 10, the overview diagram, and the service-visible-metadata note. Address the open Macroscope review threads: measure expire_in_ns from epoch start (not GroupInfo export); join-side selection iterates descending-epoch through all survivors (lower-epoch fallback); align the key-holder-poison wording with the redirection limitation; document the joiner local-clock vs envelope-timestamp skew; specify opaque refresh_pointers as application-mediated; clarify admitted_via is the GroupInfo's active id (epoch-bound, so rotation can't break in-flight joins); forbid cross-origin redirects; resolve the test-cases-vs-follow-ups contradiction.
0ba04f9 to
6ff3312
Compare
What
Review-round revisions to XIP-82 (External commit invites). Base is the original
tyler/xip-82-external-commit-invitesbranch, so this PR's diff is exactly the delta from the reviewed XIP — not the whole document.Addresses the points raised in review:
1 + 6 — Tampering / "prevent people from joining" → slot poisoning
The old service contract ("strictly-newer epoch overwrites") let anyone who can write the slot — without even holding the symmetric key — upload an
epoch = u64::MAXblob and permanently brick the slot. Fixed:2 / 3 / 4 — Stronger typing
Typed nominal newtypes instead of raw
bytes:SymmetricKey {material},GroupStateHash {digest},ServicePointer {oneof https_url | opaque}. No algorithm enums — the versioned envelopes pin the AEAD and the ciphersuite pins the hash, so an in-message tag could only contradict them.group_state_hash's previously-handwaved preimage ("tree-hash or equivalent", a latent fork-check bug) is pinned todigest(GroupContext).5 — Limit number of joiners
New
uint32 max_usesonExternalCommitPolicyV1(0 = unlimited, 1 = single-use). Enforced by every validator by counting accepted external commits from the ordered commit stream since issuance — deterministic and convergent, and no change to the atomic external-commit shape (the joiner never touches the policy component). Enable/rotate resets the derived count.7 — Confidential components (documented as v2 direction)
Documents a content-key (
CCK) scheme that gates a subset of members from a subset of content, layered over MLS:CCKheld outside group state (not derivable from the blob); confidential values encrypted under it ride in theAppDataDictionary.EXTERNAL_COMMIT_POLICY) trades re-encrypt churn for revoking a removed member's continued read via the public blob.v1 ships no confidential components — this is forward-looking text only.
Notes
max_usessingle-use / bounded, slot poisoning by a non-key-holder, versioned-slot selection, forged-metadata blob.max_usesrequire a follow-up toxmtp/proto#334; the Reference-implementation section notes this and that the open libxmtp PRs get the consuming changes as separate follow-ups.Note
Revise XIP-82 spec with typed primitives, versioned-slot anti-poisoning, max_uses, and confidential-components direction
SymmetricKey,GroupStateHash,ServicePointer) replacing raw bytes fields inExternalInvitePayloadV1andEncryptedGroupInfoBlobV1.max_usesandrefresh_pointersfields toExternalCommitPolicyV1, with enforcement via taggedadmitted_via_external_group_idmembership entries and lifecycle rules for revocation.admitted_viatag equality, andmax_usesenforcement by counting tagged membership entries.Macroscope summarized 6ff3312.