Skip to content

XIP-82: External commit invites - #138

Merged
tylerhawkes merged 2 commits into
mainfrom
tyler/xip-82-external-commit-invites
Jun 26, 2026
Merged

XIP-82: External commit invites#138
tylerhawkes merged 2 commits into
mainfrom
tyler/xip-82-external-commit-invites

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces External Commit Invites: a shareable invite token (QR / link / NFC) that lets a non-member join an XMTP group on their own via an atomic MLS External Commit (RFC 9420 §12.4.3.2), without any existing member being online at scan time.

The mechanism is a two-piece design:

  • ExternalInvitePayload — small shareable capability (key + service slot id + opaque service pointer). What's encoded in the QR.
  • EncryptedGroupInfoBlob — large encrypted MLS state held by an out-of-band service. Carries plaintext epoch + group_state_hash so the service can totally-order uploads by epoch and reject equal-epoch forks without ever seeing decrypted GroupInfo.

Admin control lives in a new well-known AppData component:

  • EXTERNAL_COMMIT_POLICY — runtime-toggleable master switch plus the active invite's symmetric_key and external_group_id. Storing the key in group state is what lets a printed QR survive epochs — any just-joined member can re-export and re-upload under the same key without the issuing admin being online.

Per-component declarative authorization for external committers lives in a new field on ComponentMetadata:

  • ComponentMetadata.external_committer_permissions — symmetric twin of the existing permissions field, evaluated against external commits. Absent = all-Deny.

Lifecycle invariants spelled out in the spec: enable must atomically populate the invite coordinates, revoke must atomically clear them, re-enable must use fresh material (no key revival).

Discussion

I've raised the design with the team in #review-napkin over the past week. Key decisions captured in the Rationale and "Alternatives considered" sections:

  • Atomic external commit shape required by libxmtp's tree-vs-AppData cross-layer invariants — no fast-follow workaround.
  • Two-layer authorization (master switch + per-component permissions) — defense in depth against a misconfigured component block.
  • Service slot identified by an application-defined external_group_id (≥4 bytes, 16 random RECOMMENDED) — not a hash of the group_id. AEAD already prevents the swap-ciphertext attack.
  • Application owns service transport; protocol does not constrain to a single service (apps MAY mirror across multiple).

Reference implementation

libxmtp PR stack (open):

  • #3664, #3665 — generic AppDataUpdate intent + proto bump
  • #3666, #3667, #3668 — EXTERNAL_COMMIT_POLICY component + validator + ingestion routing
  • #3669–#3671 — payload_encryption + invite::payload + invite::encrypted_group_info helpers
  • #3672 — openmls fork bump for by-value external-commit proposals
  • #3673, #3674 — MlsGroup::create_external_invite + Client::join_group_by_external_invite
  • #3675, #3676 — NAPI bindings
  • #3677 — end-to-end integration test

Proto: xmtp/proto#333 (generic intent), xmtp/proto#334 (this XIP's wire format).

openmls: upstream PR in flight; XMTP fork carries the patch.

Test plan

  • Spell-check clean (cspell against the repo dictionary).
  • Review of wire format + threat model by @xmtp/engineering.
  • Discussion link added once community thread is up.

🤖 Generated with Claude Code

Note

Add XIP-82 specification for external commit invites in XMTP groups

Adds xip-82-external-commit-invites.md, a new XMTP Improvement Proposal defining how external clients can join MLS groups without prior group membership.

  • Defines wire formats ExternalInvitePayload and EncryptedGroupInfoBlob for carrying group secrets to external joiners
  • Introduces an admin-controlled ExternalCommitPolicy permission governing whether external commits are allowed
  • Specifies the atomic external commit shape, service contract, validation rules, and flows for rotation and revocation
  • Covers backward compatibility, security considerations, and links to a reference implementation

Changes since #138 opened

  • Introduced typed primitives SymmetricKey, GroupStateHash, and ServicePointer to replace raw bytes fields in the wire format section, updated ExternalInvitePayloadV1.service_pointer to use optional typed pointer and symmetric_key to use SymmetricKey, and updated EncryptedGroupInfoBlobV1.group_state_hash to use GroupStateHash [b702cb0]
  • Expanded ExternalCommitPolicyV1 semantics with max_uses field, refresh_pointers field, enforcement and lifecycle rules, clarified allow_external_commit on/off invariants requiring absence of per-invite fields on revoke, changed symmetric_key to SymmetricKey type, clarified expire_in_ns origin as epoch start, and updated that allow_external_commit authorizes the joiner's own GROUP_MEMBERSHIP write [b702cb0]
  • Refined atomic external commit shape to require tagging the joiner's own GROUP_MEMBERSHIP entry with admitted_via_external_group_id, disallow PSK proposals, and added immutability rules for the admitted-via tag [b702cb0]
  • Expanded receive-side validation to ensure the joiner is not already a member, apply time bounds against delivery-service envelope timestamps, enforce admitted_via tag equality to the active external_group_id, and enforce max_uses by counting tagged membership entries [b702cb0]
  • Rewrote join-side flow to resolve typed service pointers, fetch bounded candidate sets, perform AAD-check decryption, execute metadata and GroupInfo consistency checks, select freshest valid candidate with fork fallthrough, execute pre-commit policy checks, publish the commit, and conditionally refresh with rotation race handling, including resource-bound and fetch-hygiene guidance [b702cb0]
  • Added normative guidance to EncryptedGroupInfoBlobV1 specifying that metadata is AAD-bound, nonce generation requirements, and defining expires_at_ns as the effective expiry derived from policy bounds [b702cb0]
  • Added new section describing blob freshness maintenance with committer uploads, member existence checks, and infrequent full validations, including rationale for why non-members cannot forge the epoch and group_state_hash witness [b702cb0]
  • Updated rotation and revocation section to note immediate blob staling by epoch advance, limitations against member-grade adversaries, and that revoke clears all per-invite fields [b702cb0]
  • Expanded lifecycle invariants with post-state presence and absence rules, fresh key generation on enable requirement, and use-count scoping to external_group_id [b702cb0]
  • Replaced ASCII overview with Mermaid sequence diagram and expanded the end-to-end flow to cover candidate fetch, joiner-side selection, pre-commit checks, and member-driven refresh [b702cb0]
  • Added rationale sections explaining why joiners select candidates rather than the service, why max_uses is based on membership entries, and introduced a design sketch for confidential components as future work [b702cb0]
  • Expanded security considerations to detail malicious service behavior, concurrent upload handling, slot poisoning model and mitigations, stolen-blob replay scenarios, removed-member implications, DoS attacks, joiner-side resource bounds and fetch hygiene, service-visible metadata concerns, known limitation on invite redirection by key-holders, and expanded threat model table [b702cb0]
  • Updated alternatives considered section to address redirection limits [b702cb0]
  • Expanded test cases list to cover versioned-slot selection, max_uses enforcement, member refresh flows, and revoke-clears checks [b702cb0]
  • Updated reference implementation notes to indicate follow-up PRs for typed primitives and policy fields [b702cb0]
  • Added the word unforgeable to the spell-check dictionary [b702cb0]
📊 Macroscope summarized b702cb0. 2 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@tylerhawkes
tylerhawkes requested a review from a team as a code owner May 22, 2026 00:15
Comment thread XIPs/xip-82-external-commit-invites.md Outdated
@macroscopeapp

macroscopeapp Bot commented May 22, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

2 blocking correctness issues found. New XIP specification document in a directory owned by another user (jhaaaa). Three unresolved review comments identify issues with step ordering, timestamp convergence, and clock-skew handling in the specification that warrant resolution by the designated owner.

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

Shareable group invites enabling QR-code or link-based joins via atomic
MLS External Commits with admin-controlled policy.

Also adds HPKE, NAPI, and openmls to the cspell dictionary.
@tylerhawkes
tylerhawkes force-pushed the tyler/xip-82-external-commit-invites branch from 1ac135e to edf4369 Compare June 15, 2026 20:02
…g, max_uses, confidential-components direction (#140)

* XIP-82: review revisions — typed newtypes, versioned-slot anti-poisoning, max_uses cap, confidential-components direction

* XIP-82: implied-grant + blob-refresh model + review-round fixes

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.
@tylerhawkes
tylerhawkes merged commit cc4ad7f into main Jun 26, 2026
3 checks passed
@tylerhawkes
tylerhawkes deleted the tyler/xip-82-external-commit-invites branch June 26, 2026 20:04
9. When `EXTERNAL_COMMIT_POLICY.expire_in_ns != 0`: the commit's envelope timestamp minus the current epoch's start timestamp does not exceed `expire_in_ns`. The epoch-start timestamp is the envelope timestamp of the message by which the validator entered or observed the current epoch — the epoch's commit, for members that processed it; the corresponding `Welcome` (published together with that commit), for members added at that epoch; the group-creation timestamp at the initial epoch. These differ across members only by publish latency (seconds), and not at all by *when* a validator processes the commit — so `expire_in_ns` is a coarse bound and SHOULD be set to values (minutes or more) for which that skew is immaterial.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium XIPs/xip-82-external-commit-invites.md:586

Check 9 defines the epoch-start timestamp as the envelope timestamp of the message by which each validator entered the epoch — the commit for existing members, but the Welcome for members added at that epoch. The document itself admits these timestamps "differ across members only by publish latency (seconds)." For a small but legal expire_in_ns (e.g., a few seconds), an existing member computes commit_ts − commit_epoch_start and rejects the external commit as stale, while a member added via Welcome at that same epoch computes commit_ts − welcome_epoch_start (a smaller delta) and accepts it. This violates the stated guarantee that "the accept/reject decision is therefore deterministic and convergent across the membership," splitting the group. Consider defining the epoch-start timestamp as a single canonical value all members agree on — the commit's envelope timestamp — rather than the per-validator entry message.

+9. When `EXTERNAL_COMMIT_POLICY.expire_in_ns != 0`: the commit's envelope timestamp minus the current epoch's start timestamp does not exceed `expire_in_ns`. The epoch-start timestamp is the envelope timestamp of the **commit that began the current epoch** — the same value for every member, regardless of how they entered the epoch. Members added via `Welcome` at that epoch obtain the commit's timestamp from the delivery topic (the commit is published alongside the `Welcome`); at the initial epoch it is the group-creation timestamp. Because every validator uses the same epoch-start value — not the `Welcome` timestamp or any per-validator entry time — the staleness verdict is identical across the membership regardless of publish latency or when a member syncs.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 586:

Check 9 defines the epoch-start timestamp as the envelope timestamp of the message by which each validator *entered* the epoch — the commit for existing members, but the `Welcome` for members added at that epoch. The document itself admits these timestamps "differ across members only by publish latency (seconds)." For a small but legal `expire_in_ns` (e.g., a few seconds), an existing member computes `commit_ts − commit_epoch_start` and rejects the external commit as stale, while a member added via `Welcome` at that same epoch computes `commit_ts − welcome_epoch_start` (a smaller delta) and accepts it. This violates the stated guarantee that "the accept/reject decision is therefore deterministic and convergent across the membership," splitting the group. Consider defining the epoch-start timestamp as a single canonical value all members agree on — the commit's envelope timestamp — rather than the per-validator entry message.

One clock caveat: check 3.2 compares against the joiner's local `now_ns`, whereas validators evaluate the policy time bounds against delivery-service envelope timestamps (receive-side checks 8–9). A joiner whose clock lags the delivery service can therefore accept a blob already past its bound in the envelope-timestamp domain and still hit the zombie join. The window is bounded by the joiner's clock skew: clients SHOULD keep their clock synchronized (e.g. NTP) and MAY subtract a small margin from `now_ns`, and admins SHOULD set `expire_in_ns` well above plausible client skew (it is a coarse, minutes-or-more bound anyway — see check 9).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium XIPs/xip-82-external-commit-invites.md:609

Step 3.2's clock-skew caveat says clients MAY subtract a margin from now_ns, but the expiry check is now_ns < blob.expires_at_ns. Subtracting from now_ns makes the check less strict (the blob appears less expired), which increases the zombie-join risk the paragraph is trying to prevent. The margin should go in the opposite direction — add it to now_ns so a lagging client rejects blobs that are closer to expiry.

Suggested change
One clock caveat: check 3.2 compares against the joiner's local `now_ns`, whereas validators evaluate the policy time bounds against delivery-service envelope timestamps (receive-side checks 8–9). A joiner whose clock lags the delivery service can therefore accept a blob already past its bound in the envelope-timestamp domain and still hit the zombie join. The window is bounded by the joiner's clock skew: clients SHOULD keep their clock synchronized (e.g. NTP) and MAY subtract a small margin from `now_ns`, and admins SHOULD set `expire_in_ns` well above plausible client skew (it is a coarse, minutes-or-more bound anyway — see check 9).
+ One clock caveat: check 3.2 compares against the joiner's local `now_ns`, whereas validators evaluate the policy time bounds against delivery-service envelope timestamps (receive-side checks 8–9). A joiner whose clock lags the delivery service can therefore accept a blob already past its bound in the envelope-timestamp domain and still hit the zombie join. The window is bounded by the joiner's clock skew: clients SHOULD keep their clock synchronized (e.g. NTP) and MAY add a small margin to `now_ns`, and admins SHOULD set `expire_in_ns` well above plausible client skew (it is a coarse, minutes-or-more bound anyway — see check 9).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @XIPs/xip-82-external-commit-invites.md around line 609:

Step 3.2's clock-skew caveat says clients MAY subtract a margin from `now_ns`, but the expiry check is `now_ns < blob.expires_at_ns`. Subtracting from `now_ns` makes the check *less* strict (the blob appears less expired), which increases the zombie-join risk the paragraph is trying to prevent. The margin should go in the opposite direction — add it to `now_ns` so a lagging client rejects blobs that are closer to expiry.

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.

2 participants