OpenE2EE

Groups

The two group APIs in the SDK — sender-key messaging and encrypted GroupsV2 state — what each one solves, and why membership removal must rotate.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A working client from Start → Quickstart
Reading time
12 min

The SDK exposes two group APIs. They are not alternatives or versions of each other. One moves messages. The other moves group state. Most group products need both, so choose the correct API for each task.

Sender-key messagingGroupsV2 encrypted state
Answers"How do I encrypt one message for 40 people?""Who is in this group, what is it called, who may edit it?"
SurfacecreateGroupSenderKey, encryptGroupMessage, decryptGroupMessage, rotateGroupSenderKeycreateGroupV2, addGroupMemberV2, syncGroupV2, GroupsV2Manager
Backend storesenvelopesopaque encrypted group state, sequenced by version
You can skip it ifyour groups are small enough that pairwise fanout is fineyour app already has a membership system it trusts

A. Sender-key messaging

Pairwise fanout encrypts the message body separately for every recipient device. For a group of 40 users at two devices each, one message requires roughly 80 encryptions and 80 distinct ciphertexts. The sender's device repeats that linear work for every message.

Sender keys break the coupling. Create one symmetric chain for the group. Distribute it once to each member over the existing pairwise sessions. The sender then encrypts each message once:

await signal.createGroupSenderKey(groupId);
await signal.distributeGroupSenderKey(groupId, memberUserIds);

const encrypted = await signal.encryptGroupMessage(groupId, 'ship it');

for (const member of groupMembers) {
  await sendToMember(member, encrypted); // same bytes to everyone
}

distributeGroupSenderKey(groupId, memberUserIds) is a convenience over distributeSenderKeyToUser(groupId, recipientUserId). Both use pairwise sessions. The sender cannot encrypt the distribution message with the key that the message delivers. On the receiving side:

await signal.processGroupSenderKeyDistribution(
  groupId,
  senderId,
  senderDeviceId,
  distributionMessage,
);

const plaintext = await signal.decryptGroupMessage(
  groupId,
  senderId,
  senderDeviceId,
  framedMessage,
);

Distribution costs O(n) per membership change. Message encryption cost stays constant as group size changes.

getGroupSenderKeyDistribution(...) returns the distribution payload for custom routing. hasGroupSenderKey(groupId) reports whether a local chain exists. getGroupSenderKeyStats(...) reports its state, and deleteGroupSenderKey(groupId) removes it.

Delivery does not change: the relay still carries one envelope per recipient device. Sender keys reduce encryption work and ciphertext distinctness, not envelope count.

Rotation is the security property

A sender key is a symmetric chain. Every holder can read all messages that use it. After the group removes someone, their chain still grants access to future messages.

const result = await signal.handleGroupMembershipChange(groupId, 'member_removed');

if (result.rotated) {
  for (const member of remainingMembers) {
    await sendToMember(member, JSON.stringify(result.distributionMessage));
  }
}

handleGroupMembershipChange(groupId, change) takes 'member_added', 'member_removed', or 'metadata_changed'. It encodes the rule that removal and metadata changes rotate the key. Addition does not rotate it because the new member only needs the current key. The method returns { rotated, distributionMessage }. rotateGroupSenderKey(groupId) is the direct form.

B. GroupsV2 encrypted group state

Sender keys say nothing about group membership. GroupsV2 provides the encrypted state model. It covers the title, description, membership list, access control, and invite links. The backend stores and sequences this ciphertext without reading it.

The application backend stores opaque encrypted group state and sequences changes, while each member device owns group secrets, decrypted state, authorization credentials, and sender-key rotation.

The server must enforce authenticated access and version sequencing without receiving plaintext group attributes or group master keys.

Both halves of that second sentence define requirements. The server must still authenticate each request because anyone can request state. It must also sequence changes. Two devices that edit a group concurrently need a monotonic version for reconciliation. The server completes both tasks on ciphertext.

From the client:

const { groupId, masterKey } = await signal.createGroupV2(
  creatorAci,
  creatorProfileKey,
  members,
  'Release engineering',
  { description: 'ship coordination' },
);

await signal.addGroupMemberV2(groupId, editorAci, newMemberAci, newMemberProfileKey);
await signal.removeGroupMemberV2(groupId, editorAci, targetAci);

const state = await signal.syncGroupV2(groupId);

Also on the client: getGroupStateV2(groupId), leaveGroupV2(groupId, userAci), updateGroupTitleV2(...), updateGroupDescriptionV2(...), updateGroupAccessControlV2(...), createGroupInviteLinkV2(groupId, editorAci), and joinGroupViaInviteLinkV2(url, userAci, userProfileKey).

If your application manages group state outside the messaging client, such as in a workspace service or existing org chart, use the standalone manager. Give it your own server and store implementations:

import {
  GroupsV2Manager,
  createGroupId,
  type IGroupServer,
  type IGroupStateStore,
} from '@open-e2ee/signal-protocol-sdk/groups';

const groups = new GroupsV2Manager({
  store: appGroupStore as IGroupStateStore,
  server: appGroupServer as IGroupServer,
  issueCredential: () => appGroupCredentials.issue(),
  credentialPublicKey,
  aci: localAccountServiceId,
});

const state = await groups.syncGroup(createGroupId(rawGroupId));

The /zk/groups subpath exports the zero-knowledge-proof primitives: deriveGroupSecretParams, getGroupPublicParams, and computeProfileKeyVersion. These primitives let a server verify group access without learning the requester's member identity. You will not usually call them directly. Use them when your backend must share the same parameters.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Group sender key chainyesnonono
SenderKeyDistributionMessageyesencrypted pairwisenothat a distribution envelope moved
encryptGroupMessage() outputyesyesnosize, recipient device count
masterKey from createGroupV2()yesnonono
groupId / createGroupId() valueyesyesnoyes — the server routes on it
Group title and descriptionyes, decryptedencryptednothat they changed, and the version number
Membership listyes, decryptedencryptednomember count and change frequency
creatorProfileKey / member profile keysyesencrypted in group statenono
Group state versionyesyesnoyes — the server sequences on it
Invite link from createGroupInviteLinkV2()yesyesnothat a link exists

The metadata column shows a relay limit. A relay that cannot read a group's name can still observe roughly forty devices exchange two hundred envelopes on Tuesday afternoon. It can also observe three state-version increments. See Limits and metadata.

0.1.x; public APIs and persisted formats may change before 1.0.

Next

On this page