From: AsamK Date: Mon, 21 Dec 2020 15:59:54 +0000 (+0100) Subject: Show group invite link in group list X-Git-Tag: v0.7.1~3 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/9912da9546718fc2fe5139b277e0d5263a85ef67 Show group invite link in group list --- diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index b9f54a6b..e7844fda 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -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 index 00000000..67ce7892 --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/GroupInviteLinkUrl.java @@ -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 index 00000000..38e2aaf4 --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/GroupLinkPassword.java @@ -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); + } +} diff --git a/src/main/java/org/asamk/signal/manager/KeyUtils.java b/src/main/java/org/asamk/signal/manager/KeyUtils.java index 1f12193c..d6f332c0 100644 --- a/src/main/java/org/asamk/signal/manager/KeyUtils.java +++ b/src/main/java/org/asamk/signal/manager/KeyUtils.java @@ -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; diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java index 4cd410b8..3571985b 100644 --- a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java @@ -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 getMembers(); diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java index 42c40e94..b06e2436 100644 --- a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java @@ -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, diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java index a205d140..65f8c9a4 100644 --- a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java @@ -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 getMembers() { if (this.group == null) {