From c78e3795bfcad611c1da025b0ad40bd3b625c39c Mon Sep 17 00:00:00 2001 From: Tyler Hawkes Date: Tue, 9 Jun 2026 16:24:42 -0600 Subject: [PATCH] XIP-82 implied grant: drop additional_updates, reframe grant comments allow_external_commit itself authorizes the joiner-self-entry insert, so the enable is a single-component write again and the multi-update intent extension comes out entirely (it never existed on main). external_committer_permissions comments reframed as the reserved deny-by-default surface for writes beyond the v1 atomic shape. --- .../component_permissions.proto | 10 +- .../external_commit_policy.proto | 214 +++++++++++++----- .../message_contents/external_invite.proto | 192 ++++++++++++---- .../message_contents/group_membership.proto | 18 ++ 4 files changed, 333 insertions(+), 101 deletions(-) diff --git a/proto/mls/message_contents/component_permissions.proto b/proto/mls/message_contents/component_permissions.proto index 6b637aeb..b46654ce 100644 --- a/proto/mls/message_contents/component_permissions.proto +++ b/proto/mls/message_contents/component_permissions.proto @@ -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; } diff --git a/proto/mls/message_contents/external_commit_policy.proto b/proto/mls/message_contents/external_commit_policy.proto index c3529cc2..cee22bc3 100644 --- a/proto/mls/message_contents/external_commit_policy.proto +++ b/proto/mls/message_contents/external_commit_policy.proto @@ -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 @@ -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 @@ -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 @@ -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; diff --git a/proto/mls/message_contents/external_invite.proto b/proto/mls/message_contents/external_invite.proto index 6de09bf5..a8cdba61 100644 --- a/proto/mls/message_contents/external_invite.proto +++ b/proto/mls/message_contents/external_invite.proto @@ -1,4 +1,5 @@ -// External invite payloads used for QR-code or link-based joining via MLS external commits +// External invite payloads used for QR-code or link-based joining via MLS +// external commits syntax = "proto3"; package xmtp.mls.message_contents; @@ -6,11 +7,66 @@ package xmtp.mls.message_contents; option go_package = "github.com/xmtp/proto/v3/go/mls/message_contents"; option java_package = "org.xmtp.proto.mls.message.contents"; -// v1 shape of the shareable invite blob for QR-code or link-based joining -// of an XMTP group via an MLS external commit. +// A symmetric AEAD key. In every v1 envelope this is a 32-byte +// ChaCha20Poly1305 key. The protobuf submessage does not itself constrain +// length, so validators and setters MUST check that `material` is exactly +// 32 bytes for v1; empty or wrong-length `material` is invalid. The +// algorithm is fixed by the enclosing envelope version, not carried here — +// a new algorithm is a new envelope version, never an in-message tag that +// could disagree with the version. +message SymmetricKey { + bytes material = 1; +} + +// A digest of MLS group state at a single epoch. The hash function is the +// one bound to the group's MLS ciphersuite (one ciphersuite per group, so +// the algorithm is never ambiguous and is not carried on the wire). v1 +// pins the preimage precisely: the TLS-serialized `GroupContext` of the +// referenced epoch — which itself binds `epoch`, `tree_hash`, and +// `confirmed_transcript_hash`. MLS is deterministic, so every member at an +// epoch derives an identical `GroupContext` and therefore an identical +// digest; equal digests mean identical group state. Like SymmetricKey, the +// submessage does not constrain length: validators and setters MUST check +// that `digest` is exactly the ciphersuite's hash output length (32 bytes +// under XMTP's current ciphersuite); any other length is invalid. +message GroupStateHash { + bytes digest = 1; +} + +// Where the EncryptedGroupInfoBlob is hosted. A typed transport with a +// validated happy path plus an application-defined escape hatch. Lives in +// the invite payload (the QR) and, optionally, in +// EXTERNAL_COMMIT_POLICY.refresh_pointers so members can keep slots fresh. +// Exactly one `location` variant MUST be set; a ServicePointer with no +// variant set (the empty oneof) gives the joiner no fetch target and MUST +// be treated as a parse failure (fail-closed), like an unrecognized +// version. +message ServicePointer { + oneof location { + // RFC 3986 https URI. libxmtp parses and syntactically validates it. + // Clients MUST require the https scheme, MUST NOT follow redirects to + // other schemes, and MUST ignore credentials embedded in the URL. + string https_url = 1; + // Application-defined opaque bytes for non-URL transports (NFC tags, + // custom resolver schemes, etc.). Opaque to libxmtp. + bytes opaque = 2; + } +} + +// v1 shape of the shareable invite payload for QR-code or link-based +// joining of an XMTP group via an MLS external commit. message ExternalInvitePayloadV1 { - // Application-defined opaque bytes identifying the service location. - bytes service_pointer = 1; + // Typed pointer to where the encrypted blob is hosted (HTTPS URL or + // application-defined opaque bytes). Per-QR; not stored in group state. + // + // MAY be ABSENT: an application whose scanner already knows how to + // reach its service (a first-party reader with a baked-in endpoint) + // omits the pointer entirely and resolves the service out-of-band. + // This keeps the fetch target out of the QR — a leaked payload then + // reveals no service location. When the field IS present, exactly one + // `location` variant MUST be set (see ServicePointer): present-but- + // empty is a parse failure; absent means application-resolved. + ServicePointer service_pointer = 1; // Identifier for the service slot holding the encrypted blob. Format // is application-defined (UUID, snowflake, short slot key, etc.) and // opaque to libxmtp; the only constraint is that the value is unique @@ -28,15 +84,16 @@ message ExternalInvitePayloadV1 { // `EXTERNAL_COMMIT_POLICY.external_group_id` in the group state as // defense-in-depth against a stale or swapped QR. bytes external_group_id = 2; - // 32 bytes; ChaCha20Poly1305 key used to wrap the GroupInfo. Matches - // `EXTERNAL_COMMIT_POLICY.symmetric_key` in the group state. - bytes symmetric_key = 3; + // ChaCha20Poly1305 key (32 bytes in v1) used to wrap the GroupInfo. + // Matches `EXTERNAL_COMMIT_POLICY.symmetric_key` in the group state. + SymmetricKey symmetric_key = 3; } -// Versioned envelope for the shareable invite blob. The application embeds -// the serialized bytes in whatever transport it prefers (hex, base64, raw -// QR, NFC, etc.) and stores the corresponding EncryptedGroupInfoBlob on an -// external service keyed by the v1 payload's `external_group_id`. +// Versioned envelope for the shareable invite payload. The application +// embeds the serialized bytes in whatever transport it prefers (hex, +// base64, raw QR, NFC, etc.) and stores the corresponding +// EncryptedGroupInfoBlob on an external service keyed by the v1 payload's +// `external_group_id`. // // New wire-format variants are added as new oneof entries; readers that // don't recognize a variant treat the invite as unparseable and fail @@ -49,57 +106,100 @@ message ExternalInvitePayload { // v1 shape of the encrypted-GroupInfo envelope. // -// `epoch` and `group_state_hash` are plaintext metadata serving two -// distinct purposes: +// `epoch` and `group_state_hash` are plaintext metadata. They are NOT a +// trust anchor for the service — both are uploader-asserted, so a +// conformant service MUST NOT use them to evict entries or "pick a +// winner" (doing so turns a forged high `epoch` into a permanent slot +// wedge). A conformant service instead retains the most recent N uploads +// (RECOMMENDED N >= 4), evicting by arrival order (FIFO), and coalesces +// only byte-identical re-uploads (equal across ALL fields — the plaintext +// metadata alone is attacker-copyable). The roles of the metadata are +// joiner-side: +// +// * `epoch` lets the joiner sort the retained candidates and prefer +// the freshest, and lets the joiner reject a blob whose claimed +// epoch disagrees with the GroupInfo it decrypts. // -// * `epoch` provides a total ordering on uploads. The service accepts -// an upload iff `upload.epoch > current.epoch` (strictly newer); -// lower-epoch uploads are stale and rejected outright. +// * `group_state_hash` lets the joiner confirm, after decrypting, that +// the blob's metadata matches the wrapped GroupInfo, and lets a +// member recognize an idempotent re-upload. // -// * `group_state_hash` is a consistency check at a single epoch. MLS -// is deterministic — every member that applies the same commit -// derives identical group state — so two correct uploads at the -// same epoch MUST carry the same hash. When `upload.epoch == -// current.epoch`: equal hashes mean an idempotent re-upload (no-op -// or duplicate-reject); different hashes mean the uploaders are on -// forked views of the group and the service must refuse to pick a -// winner. +// The trust anchor is the joiner's key + MLS validation, not the +// metadata: the joiner AEAD-decrypts each candidate and parses it to a +// GroupInfo whose internal epoch and `digest(GroupContext)` MUST equal +// the blob's `epoch` and `group_state_hash` before it will join. A blob +// that fails either check is discarded. // -// The joiner additionally verifies on download that the blob's `epoch` -// and `group_state_hash` match the decrypted GroupInfo before -// attempting to join — closes the "malicious service swapped -// ciphertext" gap. +// All plaintext metadata is additionally bound into the AEAD as +// associated data. v1 AAD = `epoch` || `expires_at_ns` (each 8-byte +// big-endian) || `group_state_hash.digest`. A writer without the key +// therefore cannot mutate any metadata field of a genuine ciphertext — +// re-uploading a captured blob with, say, an extended `expires_at_ns` +// fails the unwrap. A key-holder can still re-mint a blob with arbitrary +// metadata; the post-decrypt consistency check and the validator-side +// policy bounds cover that case. message EncryptedGroupInfoBlobV1 { - // 12 bytes; ChaCha20Poly1305 nonce specific to this ciphertext. + // 12 bytes; ChaCha20Poly1305 nonce specific to this ciphertext. MUST + // be generated uniformly at random from a cryptographically secure + // source for every encryption; deterministic (counter-based) schemes + // are forbidden. Many independent writers encrypt under the same + // long-lived key (every joiner refreshes the blob), and two writers' + // counters colliding would reuse a nonce — which in ChaCha20Poly1305 + // reuses the cipher stream and leaks the Poly1305 forgery key for + // that nonce. bytes nonce = 1; // wrap_payload_symmetric output: AEAD ciphertext over the serialized - // MlsMessageOut(GroupInfo). + // MlsMessageOut(GroupInfo), with the v1 AAD (see the message comment). bytes ciphertext = 2; - // MLS group epoch of the wrapped GroupInfo. Plaintext; the service - // totally orders uploads by this value — strictly-newer wins, stale - // is rejected. Joiner verifies against the decrypted GroupInfo + // MLS group epoch of the wrapped GroupInfo. Plaintext, uploader- + // asserted. Used by the joiner to prefer the freshest candidate and to + // consistency-check the decrypted GroupInfo; NOT used by the service + // to order or evict. Joiner verifies against the decrypted GroupInfo // before joining. uint64 epoch = 3; - // Tree-hash (or equivalent group-state digest) of the wrapped - // GroupInfo. Plaintext; the service uses this only at equal epochs - // to detect forks (same epoch + differing hash = forked uploaders). - // Not used for ordering. Joiner verifies against the decrypted - // GroupInfo before joining. - bytes group_state_hash = 4; + // Digest of the wrapped GroupInfo's epoch state (see GroupStateHash: a + // digest over the canonical `GroupContext` under the group's MLS + // ciphersuite). Plaintext. The joiner verifies it equals + // `digest(GroupContext)` of the decrypted GroupInfo; a member uses it + // to recognize an idempotent re-upload. Not used for service ordering. + GroupStateHash group_state_hash = 4; // Wall-clock expiry of this blob, in nanoseconds since UNIX epoch. // 0 means no expiry. The service uses this as a TTL hint and MAY // garbage-collect blobs past their `expires_at_ns` autonomously. // The joining client also enforces this — refuses to join from an - // expired blob even if the service is still serving it. Admin - // bounds the campaign by setting this at upload time; extending an - // invite is a re-upload with a later value. + // expired blob even if the service is still serving it. + // + // This is the blob's EFFECTIVE expiry, computed by the uploader as the + // earlier of the two policy bounds that apply at wrap time (saturating; + // 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) + // + // where epoch_start_ns is the delivery-service envelope timestamp of + // the commit that began the wrapped GroupInfo's epoch (known to every + // uploader). Folding the staleness bound in here means the joiner's + // single expiry check also skips candidates that validators would + // reject as stale — avoiding a "zombie join" where the joiner + // publishes a commit every member rejects and believes it joined a + // group that never accepted it — and the service's TTL-based GC + // naturally collects staleness-dead blobs. 0 only when neither policy + // bound is set. + // + // AAD-bound: a writer without the key cannot alter a genuine blob's + // expiry (see the message comment). A key-holder can re-mint with a + // later value, so this is a TTL / UX bound, not a security bound + // against key-holders — the authoritative bounds are the policy + // fields, enforced by validators against envelope timestamps. uint64 expires_at_ns = 5; } // Versioned envelope wrapping a single GroupInfo TLS-serialized bytes -// under an AEAD scheme (ChaCha20Poly1305 in v1) with a fresh nonce per -// re-encryption. Stored on the external service and replaced by joiners -// (with a fresh nonce) after each successful join. +// under an AEAD scheme (ChaCha20Poly1305 in v1) with a fresh random nonce +// per re-encryption and all plaintext metadata bound as associated data +// (see EncryptedGroupInfoBlobV1). Stored on the external service; joiners +// upload a refreshed blob (fresh nonce) after each successful join, and +// the slot retains the most recent uploads. // // New variants represent breaking wire-format changes (different AEAD, // different metadata layout). Readers that don't recognize a variant diff --git a/proto/mls/message_contents/group_membership.proto b/proto/mls/message_contents/group_membership.proto index 5ad8b80d..adbf39ba 100644 --- a/proto/mls/message_contents/group_membership.proto +++ b/proto/mls/message_contents/group_membership.proto @@ -35,6 +35,24 @@ message GroupMembershipEntry { // bounded to extra or silenced retries. Installations whose owning // inbox can't be determined are dropped. repeated bytes failed_installations = 2; + // The `external_group_id` of the invite this member was admitted + // under (XIP-82). Set if and only if the member joined via an MLS + // external commit; absent for members added by `Welcome`. This is + // what `EXTERNAL_COMMIT_POLICY.max_uses` accounting counts: the + // live use-count for an invite is the number of entries whose value + // here equals the policy's active `external_group_id`. + // + // Recorded on EVERY external commit (even when `max_uses` is 0, so + // a later policy change to a finite cap starts from accurate data) + // and WRITE-ONCE: set exactly once by the admitting external commit + // and immutable for the life of the entry. Validators reject a + // member-sender commit that sets, clears, or alters it — its own + // entry included; otherwise an invited member could untag itself + // and free a `max_uses` slot at will — and any rewrite of a + // member's entry for unrelated reasons (an installation change, + // say) MUST carry the field through unchanged. The field disappears + // only when the entry itself does (the member is removed). + bytes admitted_via_external_group_id = 3; } oneof version {