Show and enforce the terminated state of a group

A group admin can permanently terminate a group. Afterwards the group is
read-only for everyone, not even an admin can send messages or start calls;
members keep access to the existing history. Other clients silently drop
messages sent to a terminated group.

Read the DecryptedGroup.terminated flag through GroupInfo.isTerminated() and:

- surface it in listGroups (json and plain text) and as the DBus IsTerminated
  group property
- refuse to send to a terminated group locally (messages, reactions, typing and
  group stories) rather than send one that other clients ignore
- drop incoming messages addressed to a terminated group
This commit is contained in:
FailSpy 2026-08-08 11:00:22 -04:00
parent 85580d1ff6
commit 4dfdcf5214
13 changed files with 152 additions and 9 deletions

View File

@ -22,7 +22,8 @@ public record Group(
GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage,
boolean isMember,
boolean isAdmin
boolean isAdmin,
boolean isTerminated
) {
public static Group from(
@ -59,6 +60,7 @@ public record Group(
groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId));
groupInfo.isAdmin(selfRecipientId),
groupInfo.isTerminated());
}
}

View File

@ -837,6 +837,15 @@ public final class IncomingMessageHandler {
return true;
}
if (group.isTerminated()) {
return message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent();
}
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null
|| message.getBody().isPresent()

View File

@ -567,7 +567,7 @@ public class SendHelper {
return results;
}
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException {
var g = context.getGroupHelper().getGroup(groupId);
if (g == null) {
throw new GroupNotFoundException(groupId);
@ -575,6 +575,10 @@ public class SendHelper {
if (!g.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, g.getTitle());
}
if (g.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new GroupSendingNotAllowedException(groupId, g.getTitle());
}
if (!g.isProfileSharingEnabled()) {
g.setProfileSharingEnabled(true);
account.getGroupStore().updateGroup(g);

View File

@ -912,6 +912,10 @@ public class ManagerImpl implements Manager {
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups");
}
if (groupInfoV2.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new IOException("Cannot send a story to a group that has been terminated");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())

View File

@ -66,6 +66,8 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract boolean isAnnouncementGroup();
public abstract boolean isTerminated();
public abstract GroupPermission getPermissionAddMember();
public abstract GroupPermission getPermissionEditDetails();

View File

@ -113,6 +113,11 @@ public final class GroupInfoV1 extends GroupInfo {
return false;
}
@Override
public boolean isTerminated() {
return false;
}
@Override
public GroupPermission getPermissionAddMember() {
return GroupPermission.EVERY_MEMBER;

View File

@ -211,6 +211,11 @@ public final class GroupInfoV2 extends GroupInfo {
return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED;
}
@Override
public boolean isTerminated() {
return this.group != null && Boolean.TRUE.equals(this.group.terminated);
}
@Override
public GroupPermission getPermissionAddMember() {
final var accessControl = getAccessControl();

View File

@ -0,0 +1,104 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.whispersystems.signalservice.api.push.DistributionId;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GroupInfoTerminatedTest {
private static final RecipientResolver UNUSED_RESOLVER = new RecipientResolver() {
@Override
public RecipientId resolveRecipient(final RecipientAddress address) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final long recipientId) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final String identifier) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final ServiceId serviceId) {
throw new UnsupportedOperationException();
}
};
private static GroupMasterKey masterKey() {
final var bytes = new byte[32];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i + 1);
}
try {
return new GroupMasterKey(bytes);
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
}
private static GroupInfoV2 groupV2(final DecryptedGroup group) {
final var masterKey = masterKey();
return new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey),
masterKey,
group,
DistributionId.create(),
false,
false,
false,
null,
UNUSED_RESOLVER);
}
@Test
void v1GroupsAreNeverTerminated() {
final var group = new GroupInfoV1(GroupId.v1(new byte[16]));
assertFalse(group.isTerminated());
}
@Test
void v2ReadsTerminatedFlagFromDecryptedGroup() {
assertTrue(groupV2(new DecryptedGroup.Builder().terminated(true).build()).isTerminated());
assertFalse(groupV2(new DecryptedGroup.Builder().terminated(false).build()).isTerminated());
assertFalse(groupV2(new DecryptedGroup.Builder().build()).isTerminated());
}
@Test
void v2IsNotTerminatedWhenGroupStateMissing() {
final var masterKey = masterKey();
final var group = new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey), masterKey, UNUSED_RESOLVER);
assertFalse(group.isTerminated());
}
@Test
void groupApiRecordCarriesTerminatedFromModel() {
final RecipientId self = TestRecipientId.createTestId(1);
final var terminated = Group.from(groupV2(new DecryptedGroup.Builder().terminated(true).build()),
recipientId -> null,
self);
assertTrue(terminated.isTerminated());
final var live = Group.from(groupV2(new DecryptedGroup.Builder().terminated(false).build()),
recipientId -> null,
self);
assertFalse(live.isTerminated());
}
}

View File

@ -452,6 +452,7 @@ Groups have the following (case-sensitive) properties:
* IsBlocked<b> : true=member will not receive group messages; false=not blocked
* IsMember<b> (read-only) : always true (object path exists only for group members)
* IsAdmin<b> (read-only) : true=member has admin privileges; false=not admin
* IsTerminated<b> (read-only) : true=group was permanently ended by an admin; nobody (not even an admin) can send messages or start calls, sending is rejected locally
* MessageExpirationTimer<i> : int32 representing message expiration time for group
* Members<as> (read-only) : String array of group members' phone numbers
* PendingMembers<as> (read-only) : String array of pending members' phone numbers

View File

@ -595,6 +595,7 @@ public interface Signal extends DBusInterface {
@DBusProperty(name = "IsBlocked", type = Boolean.class)
@DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "IsTerminated", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "MessageExpirationTimer", type = Integer.class)
@DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ)
@DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ)

View File

@ -79,12 +79,13 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
final var groupInviteLink = group.groupInviteLinkUrl();
writer.println(
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Terminated: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
group.groupId().toBase64(),
group.title(),
group.description(),
group.isMember(),
group.isBlocked(),
group.isTerminated(),
resolveMembers(group.members()),
resolveMemberAddress(group.pendingMembers()),
resolveMemberAddress(group.requestingMembers()),
@ -92,11 +93,12 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
groupInviteLink == null ? '-' : groupInviteLink.getUrl());
} else {
writer.println("Id: {} Name: {} Active: {} Blocked: {}",
writer.println("Id: {} Name: {} Active: {} Blocked: {} Terminated: {}",
group.groupId().toBase64(),
group.title(),
group.isMember(),
group.isBlocked());
group.isBlocked(),
group.isTerminated());
}
}
@ -133,7 +135,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.permissionAddMember().name(),
group.permissionEditDetails().name(),
group.permissionSendMessage().name(),
groupInviteLink == null ? null : groupInviteLink.getUrl());
groupInviteLink == null ? null : groupInviteLink.getUrl(),
group.isTerminated());
}).toList();
jsonWriter.write(jsonGroups);
}
@ -161,7 +164,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
String permissionAddMember,
String permissionEditDetails,
String permissionSendMessage,
String groupInviteLink
String groupInviteLink,
boolean isTerminated
) {}
private record JsonGroupMemberAddress(String number, String uuid) {}

View File

@ -871,7 +871,8 @@ public class DbusManagerImpl implements Manager {
GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()),
GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()),
(boolean) group.get("IsMember").getValue(),
(boolean) group.get("IsAdmin").getValue());
(boolean) group.get("IsAdmin").getValue(),
group.get("IsTerminated") != null && (boolean) group.get("IsTerminated").getValue());
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
throw new AssertionError(e);
}

View File

@ -1308,6 +1308,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked),
new DbusProperty<>("IsMember", () -> getGroup().isMember()),
new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()),
new DbusProperty<>("IsTerminated", () -> getGroup().isTerminated()),
new DbusProperty<>("MessageExpirationTimer",
() -> getGroup().messageExpirationTimer(),
this::setMessageExpirationTime),