]> nmode's Git Repositories - signal-cli/commitdiff
Show group invite link in group list
authorAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 15:59:54 +0000 (16:59 +0100)
committerAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 15:59:54 +0000 (16:59 +0100)
src/main/java/org/asamk/signal/commands/ListGroupsCommand.java
src/main/java/org/asamk/signal/manager/GroupInviteLinkUrl.java [new file with mode: 0644]
src/main/java/org/asamk/signal/manager/GroupLinkPassword.java [new file with mode: 0644]
src/main/java/org/asamk/signal/manager/KeyUtils.java
src/main/java/org/asamk/signal/storage/groups/GroupInfo.java
src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java
src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java

index b9f54a6ba208d64e6551323916736cafa5d14748..e7844fdad119853b5aefe81f8203bca727d7c179 100644 (file)
@@ -4,6 +4,7 @@ import net.sourceforge.argparse4j.impl.Arguments;
 import net.sourceforge.argparse4j.inf.Namespace;
 import net.sourceforge.argparse4j.inf.Subparser;
 
+import org.asamk.signal.manager.GroupInviteLinkUrl;
 import org.asamk.signal.manager.Manager;
 import org.asamk.signal.storage.groups.GroupInfo;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
@@ -35,15 +36,18 @@ public class ListGroupsCommand implements LocalCommand {
                     .map(SignalServiceAddress::getLegacyIdentifier)
                     .collect(Collectors.toSet());
 
+            final GroupInviteLinkUrl groupInviteLink = group.getGroupInviteLink();
+
             System.out.println(String.format(
-                    "Id: %s Name: %s  Active: %s Blocked: %b Members: %s Pending members: %s Requesting members: %s",
+                    "Id: %s Name: %s  Active: %s Blocked: %b Members: %s Pending members: %s Requesting members: %s Link: %s",
                     Base64.encodeBytes(group.groupId),
                     group.getTitle(),
                     group.isMember(m.getSelfAddress()),
                     group.isBlocked(),
                     members,
                     pendingMembers,
-                    requestingMembers));
+                    requestingMembers,
+                    groupInviteLink == null ? '-' : groupInviteLink.getUrl()));
         } else {
             System.out.println(String.format("Id: %s Name: %s  Active: %s Blocked: %b",
                     Base64.encodeBytes(group.groupId),
diff --git a/src/main/java/org/asamk/signal/manager/GroupInviteLinkUrl.java b/src/main/java/org/asamk/signal/manager/GroupInviteLinkUrl.java
new file mode 100644 (file)
index 0000000..67ce789
--- /dev/null
@@ -0,0 +1,140 @@
+package org.asamk.signal.manager;
+
+import com.google.protobuf.ByteString;
+
+import org.signal.storageservice.protos.groups.GroupInviteLink;
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.groups.GroupMasterKey;
+import org.whispersystems.util.Base64UrlSafe;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+public final class GroupInviteLinkUrl {
+
+    private static final String GROUP_URL_HOST = "signal.group";
+    private static final String GROUP_URL_PREFIX = "https://" + GROUP_URL_HOST + "/#";
+
+    private final GroupMasterKey groupMasterKey;
+    private final GroupLinkPassword password;
+    private final String url;
+
+    public static GroupInviteLinkUrl forGroup(GroupMasterKey groupMasterKey, DecryptedGroup group) {
+        return new GroupInviteLinkUrl(groupMasterKey,
+                GroupLinkPassword.fromBytes(group.getInviteLinkPassword().toByteArray()));
+    }
+
+    public static boolean isGroupLink(String urlString) {
+        return getGroupUrl(urlString) != null;
+    }
+
+    /**
+     * @return null iff not a group url.
+     * @throws InvalidGroupLinkException If group url, but cannot be parsed.
+     */
+    public static GroupInviteLinkUrl fromUri(String urlString) throws InvalidGroupLinkException, UnknownGroupLinkVersionException {
+        URI uri = getGroupUrl(urlString);
+
+        if (uri == null) {
+            return null;
+        }
+
+        try {
+            if (!"/".equals(uri.getPath()) && uri.getPath().length() > 0) {
+                throw new InvalidGroupLinkException("No path was expected in uri");
+            }
+
+            String encoding = uri.getFragment();
+
+            if (encoding == null || encoding.length() == 0) {
+                throw new InvalidGroupLinkException("No reference was in the uri");
+            }
+
+            byte[] bytes = Base64UrlSafe.decodePaddingAgnostic(encoding);
+            GroupInviteLink groupInviteLink = GroupInviteLink.parseFrom(bytes);
+
+            switch (groupInviteLink.getContentsCase()) {
+                case V1CONTENTS: {
+                    GroupInviteLink.GroupInviteLinkContentsV1 groupInviteLinkContentsV1 = groupInviteLink.getV1Contents();
+                    GroupMasterKey groupMasterKey = new GroupMasterKey(groupInviteLinkContentsV1.getGroupMasterKey()
+                            .toByteArray());
+                    GroupLinkPassword password = GroupLinkPassword.fromBytes(groupInviteLinkContentsV1.getInviteLinkPassword()
+                            .toByteArray());
+
+                    return new GroupInviteLinkUrl(groupMasterKey, password);
+                }
+                default:
+                    throw new UnknownGroupLinkVersionException("Url contains no known group link content");
+            }
+        } catch (InvalidInputException | IOException e) {
+            throw new InvalidGroupLinkException(e);
+        }
+    }
+
+    /**
+     * @return {@link URI} if the host name matches.
+     */
+    private static URI getGroupUrl(String urlString) {
+        try {
+            URI url = new URI(urlString);
+
+            if (!"https".equalsIgnoreCase(url.getScheme()) && !"sgnl".equalsIgnoreCase(url.getScheme())) {
+                return null;
+            }
+
+            return GROUP_URL_HOST.equalsIgnoreCase(url.getHost()) ? url : null;
+        } catch (URISyntaxException e) {
+            return null;
+        }
+    }
+
+    private GroupInviteLinkUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) {
+        this.groupMasterKey = groupMasterKey;
+        this.password = password;
+        this.url = createUrl(groupMasterKey, password);
+    }
+
+    protected static String createUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) {
+        GroupInviteLink groupInviteLink = GroupInviteLink.newBuilder()
+                .setV1Contents(GroupInviteLink.GroupInviteLinkContentsV1.newBuilder()
+                        .setGroupMasterKey(ByteString.copyFrom(groupMasterKey.serialize()))
+                        .setInviteLinkPassword(ByteString.copyFrom(password.serialize())))
+                .build();
+
+        String encoding = Base64UrlSafe.encodeBytesWithoutPadding(groupInviteLink.toByteArray());
+
+        return GROUP_URL_PREFIX + encoding;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public GroupMasterKey getGroupMasterKey() {
+        return groupMasterKey;
+    }
+
+    public GroupLinkPassword getPassword() {
+        return password;
+    }
+
+    public final static class InvalidGroupLinkException extends Exception {
+
+        public InvalidGroupLinkException(String message) {
+            super(message);
+        }
+
+        public InvalidGroupLinkException(Throwable cause) {
+            super(cause);
+        }
+    }
+
+    public final static class UnknownGroupLinkVersionException extends Exception {
+
+        public UnknownGroupLinkVersionException(String message) {
+            super(message);
+        }
+    }
+}
diff --git a/src/main/java/org/asamk/signal/manager/GroupLinkPassword.java b/src/main/java/org/asamk/signal/manager/GroupLinkPassword.java
new file mode 100644 (file)
index 0000000..38e2aaf
--- /dev/null
@@ -0,0 +1,40 @@
+package org.asamk.signal.manager;
+
+import java.util.Arrays;
+
+public final class GroupLinkPassword {
+
+    private static final int SIZE = 16;
+
+    private final byte[] bytes;
+
+    public static GroupLinkPassword createNew() {
+        return new GroupLinkPassword(KeyUtils.getSecretBytes(SIZE));
+    }
+
+    public static GroupLinkPassword fromBytes(byte[] bytes) {
+        return new GroupLinkPassword(bytes);
+    }
+
+    private GroupLinkPassword(byte[] bytes) {
+        this.bytes = bytes;
+    }
+
+    public byte[] serialize() {
+        return bytes.clone();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (!(other instanceof GroupLinkPassword)) {
+            return false;
+        }
+
+        return Arrays.equals(bytes, ((GroupLinkPassword) other).bytes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Arrays.hashCode(bytes);
+    }
+}
index 1f12193c74ff1a1aceaada9b23d70d2f938bf124..d6f332c0f7745d75599f72f1dfa7bdcec537f4fc 100644 (file)
@@ -39,7 +39,7 @@ class KeyUtils {
         return Base64.encodeBytes(secret);
     }
 
-    private static byte[] getSecretBytes(int size) {
+    static byte[] getSecretBytes(int size) {
         byte[] secret = new byte[size];
         RandomUtils.getSecureRandom().nextBytes(secret);
         return secret;
index 4cd410b88a4d84e5d89bfcc3f81eed3a87908a78..3571985b70bcf64c65476ddaa9683d00363baf79 100644 (file)
@@ -3,6 +3,7 @@ package org.asamk.signal.storage.groups;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
+import org.asamk.signal.manager.GroupInviteLinkUrl;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
 import java.util.Set;
@@ -21,6 +22,9 @@ public abstract class GroupInfo {
     @JsonIgnore
     public abstract String getTitle();
 
+    @JsonIgnore
+    public abstract GroupInviteLinkUrl getGroupInviteLink();
+
     @JsonIgnore
     public abstract Set<SignalServiceAddress> getMembers();
 
index 42c40e949226d1e4376cd4816b34ec3872469046..b06e2436b7e4a79f313b80b449be1a74aaa6c165 100644 (file)
@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 
+import org.asamk.signal.manager.GroupInviteLinkUrl;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
 import java.io.IOException;
@@ -55,6 +56,11 @@ public class GroupInfoV1 extends GroupInfo {
         return name;
     }
 
+    @Override
+    public GroupInviteLinkUrl getGroupInviteLink() {
+        return null;
+    }
+
     public GroupInfoV1(
             @JsonProperty("groupId") byte[] groupId,
             @JsonProperty("expectedV2Id") byte[] expectedV2Id,
index a205d14085cb9868b88dc387ba8c24ae20d98716..65f8c9a49ae0f14cfbf6b9fd036dca39bc41843c 100644 (file)
@@ -1,5 +1,7 @@
 package org.asamk.signal.storage.groups;
 
+import org.asamk.signal.manager.GroupInviteLinkUrl;
+import org.signal.storageservice.protos.groups.AccessControl;
 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
 import org.signal.zkgroup.groups.GroupMasterKey;
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
@@ -41,6 +43,19 @@ public class GroupInfoV2 extends GroupInfo {
         return this.group.getTitle();
     }
 
+    @Override
+    public GroupInviteLinkUrl getGroupInviteLink() {
+        if (this.group == null || this.group.getInviteLinkPassword() == null || (
+                this.group.getAccessControl().getAddFromInviteLink() != AccessControl.AccessRequired.ANY
+                        && this.group.getAccessControl().getAddFromInviteLink()
+                        != AccessControl.AccessRequired.ADMINISTRATOR
+        )) {
+            return null;
+        }
+
+        return GroupInviteLinkUrl.forGroup(masterKey, group);
+    }
+
     @Override
     public Set<SignalServiceAddress> getMembers() {
         if (this.group == null) {