Skip to content

Open group invitation is multi-claimable by any syncing identity until expiry (no joiner-binding / no single-use nonce) #3207

Description

@chefsale

Summary

An admin-signed open group invitation (GroupInvitationFromAdmin) is not bound to a specific joiner and has no single-use / used-nonce marker. Once the first joiner's RootOp::MemberJoined op lands on the namespace DAG, the embedded admin-signed invitation — including its secret_salt — is public. Any other identity that syncs the DAG can lift that signed_invitation, wrap it in its own MemberJoined op (signed by itself), and self-join the group. This repeats for any number of distinct identities until the invitation's expiration_timestamp.

This is invitation reuse by distinct self-signing identities, effectively turning a single open invitation into an unlimited-use invite link within its validity window.

What is already mitigated (not the concern here)

The naive "replay the same op to inject a third party" attack is closed:

  • Outer op signature is verified before apply — crates/governance-store/src/namespace/governance.rs:187 (op.verify_signature()).
  • apply_member_joined requires signer == membercrates/governance-store/src/namespace/membership.rs:136. So a MemberJoined op can only add the identity that signed it (self-join only; no third-party injection).
  • Exact-op idempotency (contains_op(delta_id), governance.rs:213) + per-member direct-row dedup (has_direct_member, membership.rs:71) — the same op / same member can't double-apply.
  • Deterministic expiry exists via RootOp::MemberJoinedAt { … joined_at }; MemberJoined with a non-zero expiration now requires a signed joined_at and rejects joined_at > expiration (membership.rs:47-56).

These bound the blast radius (self-join only, once per identity, before expiry) but do not make the invitation single-invitee or single-use.

The gap

GroupInvitationFromAdmin (crates/context/config/src/types.rs:831) carries:

inviter_identity, group_id, expiration_timestamp, secret_salt, invited_role

There is no invitee/joiner pubkey covered by the admin signature, and no persisted redeemed-invitation nonce. secret_salt only provides pre-reveal MEV/front-run protection; once the invitation is revealed on the public DAG it is spent, and the invitation degrades to "anyone who syncs can self-join until expiry."

Reproduction (conceptual)

  1. Admin issues an open invitation; Alice joins via MemberJoined { member: Alice, signed_invitation }.
  2. Eve syncs the namespace DAG and reads Alice's op, extracting signed_invitation (admin sig + secret_salt).
  3. Eve constructs MemberJoined { member: Eve, signed_invitation }, signs the outer op with Eve's key (so signer == member == Eve), and applies.
  4. All apply checks pass — outer sig valid (Eve signed), inviter sig valid (admin signed the invitation), inviter has CAN_INVITE_MEMBERS, not-yet-a-member, not expired → Eve joins.
  5. Repeatable by any distinct identity until expiration_timestamp.

Proposed fixes (either one)

  1. Bind to a specific joiner. Add a signed invited_identity: PublicKey to GroupInvitationFromAdmin (covered by the admin signature). Apply then also requires member == invitation.invited_identity, so only the named identity can redeem it.
  2. Single-use nonce. Persist a redeemed-invitation marker keyed by sha256(borsh(invitation)) and reject a MemberJoined whose invitation hash is already recorded, so the invitation can be redeemed at most once.

Open question — intended semantics

Whether this is a defect depends on the intended model for open invitations:

  • If open invitations are meant to be single-invitee / single-use, this is a real unfixed gap and one of the two fixes above is needed.
  • If they are meant to be invite-link style (multi-claim by design — the type doc says "an open invitation payload that allows any party to claim it"), then the multi-claim behavior is intentional and the real protections are expiration_timestamp + admins not over-sharing. In that case this issue should capture the intent explicitly (docs) and possibly add an admin-side cap on concurrent redemptions.

Please confirm the intended semantics before implementing a fix.

Code anchors

  • Invitation type: crates/context/config/src/types.rs:831 (GroupInvitationFromAdmin), :878 (SignedGroupOpenInvitation)
  • Join op: crates/governance-types/src/lib.rs:607 (RootOp::MemberJoined), :657 (MemberJoinedAt)
  • Apply/verify: crates/governance-store/src/namespace/membership.rs:28 (apply_member_joined), :130 (verify_member_join_signature), :163 (verify_open_invitation_signature)
  • Outer-op verification: crates/governance-store/src/namespace/governance.rs:187

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions