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 == member — crates/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)
- Admin issues an open invitation; Alice joins via
MemberJoined { member: Alice, signed_invitation }.
- Eve syncs the namespace DAG and reads Alice's op, extracting
signed_invitation (admin sig + secret_salt).
- Eve constructs
MemberJoined { member: Eve, signed_invitation }, signs the outer op with Eve's key (so signer == member == Eve), and applies.
- 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.
- Repeatable by any distinct identity until
expiration_timestamp.
Proposed fixes (either one)
- 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.
- 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
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'sRootOp::MemberJoinedop lands on the namespace DAG, the embedded admin-signed invitation — including itssecret_salt— is public. Any other identity that syncs the DAG can lift thatsigned_invitation, wrap it in its ownMemberJoinedop (signed by itself), and self-join the group. This repeats for any number of distinct identities until the invitation'sexpiration_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:
crates/governance-store/src/namespace/governance.rs:187(op.verify_signature()).apply_member_joinedrequiressigner == member—crates/governance-store/src/namespace/membership.rs:136. So aMemberJoinedop can only add the identity that signed it (self-join only; no third-party injection).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.RootOp::MemberJoinedAt { … joined_at };MemberJoinedwith a non-zeroexpirationnow requires a signedjoined_atand rejectsjoined_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:There is no invitee/joiner pubkey covered by the admin signature, and no persisted redeemed-invitation nonce.
secret_saltonly 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)
MemberJoined { member: Alice, signed_invitation }.signed_invitation(admin sig +secret_salt).MemberJoined { member: Eve, signed_invitation }, signs the outer op with Eve's key (sosigner == member == Eve), and applies.CAN_INVITE_MEMBERS, not-yet-a-member, not expired → Eve joins.expiration_timestamp.Proposed fixes (either one)
invited_identity: PublicKeytoGroupInvitationFromAdmin(covered by the admin signature). Apply then also requiresmember == invitation.invited_identity, so only the named identity can redeem it.sha256(borsh(invitation))and reject aMemberJoinedwhose 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:
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
crates/context/config/src/types.rs:831(GroupInvitationFromAdmin),:878(SignedGroupOpenInvitation)crates/governance-types/src/lib.rs:607(RootOp::MemberJoined),:657(MemberJoinedAt)crates/governance-store/src/namespace/membership.rs:28(apply_member_joined),:130(verify_member_join_signature),:163(verify_open_invitation_signature)crates/governance-store/src/namespace/governance.rs:187