mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-08-12 03:16:22 +00:00
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:
parent
85580d1ff6
commit
4dfdcf5214
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user