Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions proto/mls/message_contents/component_permissions.proto
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ message ComponentMetadata {
// Permission policies for this component, evaluated against MLS External
// Commits (RFC 9420 §12.4.3.2). Absent / unset is equivalent to all-Deny:
// external committers cannot touch this component. Each component opts in
// explicitly by setting this field. Combined with the EXTERNAL_COMMIT_POLICY
// master switch (`allow_external_commit`), this is the per-component declarative
// authorization for external-commit-driven joins.
// explicitly by setting this field.
//
// Reserved surface in XIP-82 v1: the atomic external-commit shape permits
// no AppDataUpdate beyond the joiner's own GROUP_MEMBERSHIP entry, and that
// one mandatory write is authorized by `allow_external_commit` itself — not
// by this field. Future flavors that let an external committer write other
// components consult the touched component's block here (deny-by-default).
ComponentPermissions external_committer_permissions = 3;
}
214 changes: 162 additions & 52 deletions proto/mls/message_contents/external_commit_policy.proto
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@
//
// 1. RFC 9420 / libxmtp invariants enforced in the validator
// (exactly one ExternalInit, joiner credential binding on all Adds,
// no by-reference proposals, no SelfRemove, etc.).
// no by-reference proposals, no Remove / PSK /
// GroupContextExtensions proposals, etc.).
// 2. Per-component `external_committer_permissions` blocks declared on
// each touched component's `ComponentMetadata`.
// each touched component's `ComponentMetadata` — a reserved,
// deny-by-default surface in v1: the atomic shape permits no write
// beyond the joiner's own GROUP_MEMBERSHIP entry, and THAT write is
// authorized by `allow_external_commit` itself (see the invariant
// below), not by a permissions block.
//
// In other words: EXTERNAL_COMMIT_POLICY is the runtime-toggleable
// master switch + time-window controls. `external_committer_permissions`
// is the declarative, per-component surface that says *what* an external
// committer may do once the master switch is on. Both must admit the
// commit for it to be accepted.
// is the declarative, per-component surface for anything a future flavor
// lets an external committer touch beyond the atomic shape.
//
// Toggling fields at runtime is an AppDataUpdate(EXTERNAL_COMMIT_POLICY)
// proposal; the component's update policy (in its
Expand All @@ -30,31 +34,60 @@ syntax = "proto3";

package xmtp.mls.message_contents;

import "mls/message_contents/external_invite.proto";

option go_package = "github.com/xmtp/proto/v3/go/mls/message_contents";
option java_package = "org.xmtp.proto.mls.message.contents";

// v1 external-commit-policy payload.
//
// The fields split into two classes. DURABLE SETTINGS (`expire_in_ns`,
// `max_uses`) describe the group's posture toward ANY invite and survive
// a revoke. PER-INVITE fields (`symmetric_key`, `external_group_id`,
// `expires_at_ns`, `refresh_pointers`) describe the currently-active
// invite and are cleared by a revoke.
//
// Field-coupling invariants enforced by libxmtp when applying an
// AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal:
//
// * When `allow_external_commit` transitions to true: `symmetric_key`
// and `external_group_id` MUST be populated (non-empty, meeting
// their length requirements) in the same proposal. The two
// transitions are atomic — there is no window where the bit is on
// but the invite coordinates are unset.
// and `external_group_id` MUST be populated (meeting their length
// requirements) in the same proposal. The transitions are atomic —
// there is no window where the bit is on but the invite coordinates
// are unset. `allow_external_commit = true` itself authorizes the
// one write the atomic external-commit shape requires — the joiner
// inserting its OWN GROUP_MEMBERSHIP entry. No permissions grant
// exists for it: in every state validators accept, a grant's value
// would be forced by this switch (enabled => must admit, disabled
// => never consulted), so the enable stays a single-component
// write with no cross-component coupling.
//
// * 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
// the group state after revoke would let a future re-enable
// accidentally revive a previously-distributed key.
// * When `allow_external_commit` transitions to false (revoke): every
// per-invite field MUST be ABSENT from the resulting policy — not
// serialized at all. For the proto3 scalar and repeated fields this
// is the only cleared state there is (defaults — empty bytes, 0,
// empty list — are never serialized; "empty" and "absent" are the
// same wire state). The message-typed `symmetric_key` is the one
// field with explicit presence and therefore a second representable
// state, which is forbidden: an empty SymmetricKey submessage (or
// empty `material`) is invalid — absence is the only cleared
// encoding. Net effect: a revoked policy serializes to nothing but
// the durable settings, byte-identical to a policy that never had
// an invite. Validators enforce this as a state invariant —
// `allow_external_commit == false` implies all four absent — so
// stale state cannot linger (a lingering key could be revived by a
// careless re-enable; lingering pointers re-adopted; a lingering
// absolute `expires_at_ns` would silently mis-bound the next
// campaign) and a re-enable mints everything fresh.
//
// * On re-enable (false → true after a prior revoke): the new
// `symmetric_key` MUST differ from every previously-used value for
// this group, and the new `external_group_id` SHOULD differ as
// well. Reusing a revoked key would re-validate every QR ever
// printed under that key, defeating the revocation. Admin clients
// are responsible for generating fresh material on each enable.
// * On every enable (first or re-enable): the new `symmetric_key`
// MUST be freshly generated from a cryptographically secure random
// source. Uniform 256-bit randomness guarantees — up to negligible
// probability — that it differs from every previously-used key,
// with no key-history tracking; validators do not (and cannot)
// audit this rule, so it binds the setter. Reusing a revoked key
// would re-validate every QR ever printed under that key, defeating
// the revocation. The new `external_group_id` SHOULD also differ.
message ExternalCommitPolicyV1 {
// Master switch for MLS External Commits adding new members.
// Required for the QR-invite flow. Defaults to false; admins
Expand All @@ -63,44 +96,66 @@ message ExternalCommitPolicyV1 {
//
// See the field-coupling invariants in the message-level comment
// above: enabling MUST populate symmetric_key + external_group_id;
// revoking (true → false) MUST clear them.
// revoking (true → false) MUST leave every per-invite field absent.
bool allow_external_commit = 1;
// Wall-clock auto-disable timestamp (ns since UNIX epoch).
// 0 = no automatic expiry. After this timestamp the validator
// rejects all external commits regardless of `allow_external_commit`.
// Lets admins issue time-bounded invite campaigns without having to
// come back and flip the bit manually.
// come back and flip the bit manually. Validators evaluate this
// against the external commit's delivery-service envelope timestamp —
// never the validator's wall clock at processing time — so members
// that sync at different times reach the same verdict.
//
// Per-invite, not a durable setting: revoking clears it to 0 — i.e.
// absent on the wire; proto3 never serializes defaults (see the
// field-coupling invariants — an absolute campaign end left behind
// would silently mis-bound the next campaign). While enabled, 0
// remains a legal value (no automatic expiry).
uint64 expires_at_ns = 2;
// Maximum staleness of the GroupInfo referenced by an external
// commit, in nanoseconds since GroupInfo export. 0 = no staleness
// limit. External commits whose referenced GroupInfo was exported
// more than `expire_in_ns` ago are rejected. Narrows the replay
// window for stolen-blob attacks and forces re-export frequency.
// commit, in nanoseconds. 0 = no staleness limit. Validators reject
// an external commit when its envelope timestamp minus the current
// epoch's start timestamp exceeds this value (the epoch-start is the
// envelope timestamp of the message by which the validator entered or
// observed the current epoch — the epoch's commit, or the Welcome
// published together with it). Narrows the replay window for
// stolen-blob attacks and forces re-export frequency. A coarse bound:
// SHOULD be set to values (minutes or more) for which publish-latency
// skew across members is immaterial.
//
// Durable setting: survives a revoke (it describes the group's
// staleness posture for any invite, not the current campaign).
uint64 expire_in_ns = 3;
// 32-byte ChaCha20Poly1305 key used to wrap the EncryptedGroupInfoBlob
// for the currently-active invite. Carried in the group state so any
// member (especially a just-joined external committer) can re-export
// GroupInfo and re-upload a refreshed blob under the same key after a
// join — without this, a printed QR / link would die the moment the
// issuing admin went offline.
// ChaCha20Poly1305 key (32 bytes in v1) used to wrap the
// EncryptedGroupInfoBlob for the currently-active invite. Carried in
// the group state so any member (especially a just-joined external
// committer) can re-export GroupInfo and re-upload a refreshed blob
// under the same key after a join — without this, a printed QR / link
// would die the moment the issuing admin went offline.
//
// The QR carries the same key bytes. Rotation = admin sets a new value
// here in a single AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal AND
// The QR carries the same key. Rotation = admin sets a new value here
// in a single AppDataUpdate(EXTERNAL_COMMIT_POLICY) proposal AND
// issues a new QR carrying the matching key; old QR holders' keys no
// longer decrypt blobs the service serves under the rotated slot.
// longer decrypt blobs wrapped after the rotation.
//
// Length MUST be exactly 32 bytes when populated. Empty (zero-length)
// means no active invite — and MUST coincide with
// `allow_external_commit == false` (see the field-coupling invariants
// at the top of this message). Revoking the invite MUST clear this
// field; re-enabling MUST populate it with a freshly-generated value
// distinct from any previously-used key for this group.
// `material` MUST be exactly 32 bytes when populated (v1); the
// SymmetricKey submessage does not enforce this, so validators and
// setters MUST check it. The field being ABSENT is the canonical "no
// active invite" encoding (an empty submessage or empty `material` is
// invalid) — and MUST coincide with `allow_external_commit == false`
// (see the field-coupling invariants at the top of this message).
// Revoking the invite MUST clear this field; re-enabling MUST
// populate it with a value freshly generated from a cryptographically
// secure random source — uniform randomness guarantees distinctness
// from every prior key without any key-history tracking.
//
// Note: the service_pointer (where the blob lives) is intentionally
// NOT stored in the group. It is per-QR application-defined opaque
// bytes; different invites for the same group may point at different
// services. Joiners use the service_pointer from the QR they scanned.
bytes symmetric_key = 4;
// Note: the per-QR service_pointer is application-defined and travels
// in the QR — joiners use the pointer from the QR they scanned (a
// scanner cannot read group state before decrypting the blob).
// Members keep slots fresh via the optional `refresh_pointers` list
// below.
SymmetricKey symmetric_key = 4;
// Identifier for the service slot holding the active invite's
// encrypted blob. Application-defined opaque bytes (UUID, snowflake,
// short slot key, etc.); decoupled from the MLS group_id. Admins
Expand All @@ -112,21 +167,76 @@ message ExternalCommitPolicyV1 {
// `external_group_id` equals this field after joining, as
// defense-in-depth against a stale or swapped QR. Mismatch indicates
// the admin rotated to a new slot after the QR was minted; the
// joining client SHOULD treat the just-published commit as orphaned
// (it validates fine, but the refreshed blob the joiner would upload
// to the old slot will not be reachable by holders of the new QR).
// joining client MUST NOT upload a refreshed blob on mismatch (it
// would land on an orphaned slot, or be encrypted under a rotated
// key).
//
// MUST be at least 4 bytes when populated (collision-avoidance floor
// for tiny services). RECOMMENDED: 16 random bytes when no
// application-specific scheme is in use. Empty (zero-length) means
// no active invite — and MUST coincide with
// `allow_external_commit == false` (see the field-coupling
// application-specific scheme is in use. Absent means no active
// invite (proto3 bytes: an empty value is never serialized, so
// "empty" and "absent" are the same wire state) — and MUST coincide
// with `allow_external_commit == false` (see the field-coupling
// invariants at the top of this message). Revoking the invite MUST
// clear this field; re-enabling SHOULD use a freshly-generated value
// (reusing a prior `external_group_id` is permitted only when the
// admin intends to overwrite the old service slot — typically the
// admin generates a new value to leave the prior slot orphaned).
bytes external_group_id = 5;
// Maximum number of members concurrently admitted to the group via
// the currently-active invite. 0 = unlimited; 1 = a single active
// invited member at a time.
//
// Enforced by every validating member, NOT by the service. Each
// external committer tags its own GROUP_MEMBERSHIP entry with the
// `external_group_id` it joined under (see
// GroupMembershipEntry.V1.admitted_via_external_group_id); the live
// use-count is the number of current GROUP_MEMBERSHIP entries
// carrying the active `external_group_id`. A member rejects an
// external commit when that count is already >= max_uses. Because the
// count is read from GROUP_MEMBERSHIP in the shared group state — not
// replayed from commit history — every member, INCLUDING one that
// joined after the invite was issued, computes the same value and
// converges. No change to the atomic external-commit shape: the
// joiner writes only its own GROUP_MEMBERSHIP entry.
//
// Semantics are CONCURRENT, not total-ever: removing an invited
// member drops its entry and frees a slot. The count is scoped to
// `external_group_id`, so rotating to a new slot id starts a fresh
// budget (entries under the old id no longer count); a same-slot
// `symmetric_key`-only rotation does NOT reset it. To make an invite
// truly one-shot, revoke it (allow_external_commit = false) or rotate
// the `external_group_id` after the join.
//
// max_uses is intentionally scoped per-invite (per-
// `external_group_id`): it throttles a single invite/slot, by design.
// It is NOT a cap on the total inboxes in the group; a cap on how
// many inboxes may join a group is a SEPARATE setting, not max_uses.
// Durable setting: survives a revoke.
uint32 max_uses = 6;
// Service locations members use to keep the active invite's blob
// fresh. Optional. When populated, every member re-wraps and
// re-uploads the blob after epoch advances (jittered, check-before-
// write — see XIP-82 "Keeping the blob fresh") — the poster on the
// wall stays joinable while any one member is online. When empty,
// only the issuing admin and past scanners (who know a pointer from
// their own QR) can refresh, or the application drives refresh itself
// against application-resolved pointers; that mode preserves pointer
// secrecy from group state at a liveness cost.
//
// Listing pointers here places fetch locations in group state: every
// member — including every future removed member — learns them. The
// per-QR `service_pointer` is unaffected: scanners cannot read group
// state before decrypting the blob, so the QR (or the app's own
// knowledge) always carries the fetch target for joining.
//
// Per-invite, not a durable setting: revoking clears the list — i.e.
// absent on the wire; repeated fields have no separate presence, an
// empty list is simply not serialized (see the field-coupling
// invariants). Re-enabling SHOULD populate fresh locations. While
// enabled, an empty list remains the legal opt-out of member-driven
// refresh.
repeated ServicePointer refresh_pointers = 7;
}

// Versioned envelope. New variants are added as new oneof variants;
Expand Down
Loading
Loading