mirror of
https://github.com/AsamK/signal-cli
synced 2025-08-29 10:30:38 +00:00
Extract lib module
This commit is contained in:
parent
4adb11dada
commit
c72aeed8bb
79 changed files with 38 additions and 6 deletions
30
lib/build.gradle.kts
Normal file
30
lib/build.gradle.kts
Normal file
|
@ -0,0 +1,30 @@
|
|||
plugins {
|
||||
`java-library`
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api("com.github.turasa:signal-service-java:2.15.3_unofficial_18")
|
||||
implementation("com.google.protobuf:protobuf-javalite:3.10.0")
|
||||
implementation("org.bouncycastle:bcprov-jdk15on:1.68")
|
||||
implementation("org.slf4j:slf4j-api:1.7.30")
|
||||
}
|
||||
|
||||
configurations {
|
||||
implementation {
|
||||
resolutionStrategy.failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class AttachmentInvalidException extends Exception {
|
||||
|
||||
public AttachmentInvalidException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AttachmentInvalidException(String attachment, Exception e) {
|
||||
super(attachment + ": " + e.getMessage());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class AttachmentStore {
|
||||
|
||||
private final File attachmentsPath;
|
||||
|
||||
public AttachmentStore(final File attachmentsPath) {
|
||||
this.attachmentsPath = attachmentsPath;
|
||||
}
|
||||
|
||||
public void storeAttachmentPreview(
|
||||
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
|
||||
) throws IOException {
|
||||
storeAttachment(getAttachmentPreviewFile(attachmentId), storer);
|
||||
}
|
||||
|
||||
public void storeAttachment(
|
||||
final SignalServiceAttachmentRemoteId attachmentId, final AttachmentStorer storer
|
||||
) throws IOException {
|
||||
storeAttachment(getAttachmentFile(attachmentId), storer);
|
||||
}
|
||||
|
||||
private void storeAttachment(final File attachmentFile, final AttachmentStorer storer) throws IOException {
|
||||
createAttachmentsDir();
|
||||
try (OutputStream output = new FileOutputStream(attachmentFile)) {
|
||||
storer.store(output);
|
||||
}
|
||||
}
|
||||
|
||||
private File getAttachmentPreviewFile(SignalServiceAttachmentRemoteId attachmentId) {
|
||||
return new File(attachmentsPath, attachmentId.toString() + ".preview");
|
||||
}
|
||||
|
||||
public File getAttachmentFile(SignalServiceAttachmentRemoteId attachmentId) {
|
||||
return new File(attachmentsPath, attachmentId.toString());
|
||||
}
|
||||
|
||||
private void createAttachmentsDir() throws IOException {
|
||||
IOUtils.createPrivateDirectories(attachmentsPath);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AttachmentStorer {
|
||||
|
||||
void store(OutputStream outputStream) throws IOException;
|
||||
}
|
||||
}
|
91
lib/src/main/java/org/asamk/signal/manager/AvatarStore.java
Normal file
91
lib/src/main/java/org/asamk/signal/manager/AvatarStore.java
Normal file
|
@ -0,0 +1,91 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class AvatarStore {
|
||||
|
||||
private final File avatarsPath;
|
||||
|
||||
public AvatarStore(final File avatarsPath) {
|
||||
this.avatarsPath = avatarsPath;
|
||||
}
|
||||
|
||||
public StreamDetails retrieveContactAvatar(SignalServiceAddress address) throws IOException {
|
||||
return retrieveAvatar(getContactAvatarFile(address));
|
||||
}
|
||||
|
||||
public StreamDetails retrieveProfileAvatar(SignalServiceAddress address) throws IOException {
|
||||
return retrieveAvatar(getProfileAvatarFile(address));
|
||||
}
|
||||
|
||||
public StreamDetails retrieveGroupAvatar(GroupId groupId) throws IOException {
|
||||
final File groupAvatarFile = getGroupAvatarFile(groupId);
|
||||
return retrieveAvatar(groupAvatarFile);
|
||||
}
|
||||
|
||||
public void storeContactAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getContactAvatarFile(address), storer);
|
||||
}
|
||||
|
||||
public void storeProfileAvatar(SignalServiceAddress address, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getProfileAvatarFile(address), storer);
|
||||
}
|
||||
|
||||
public void storeGroupAvatar(GroupId groupId, AvatarStorer storer) throws IOException {
|
||||
storeAvatar(getGroupAvatarFile(groupId), storer);
|
||||
}
|
||||
|
||||
public void deleteProfileAvatar(SignalServiceAddress address) throws IOException {
|
||||
deleteAvatar(getProfileAvatarFile(address));
|
||||
}
|
||||
|
||||
private StreamDetails retrieveAvatar(final File avatarFile) throws IOException {
|
||||
if (!avatarFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
return Utils.createStreamDetailsFromFile(avatarFile);
|
||||
}
|
||||
|
||||
private void storeAvatar(final File avatarFile, final AvatarStorer storer) throws IOException {
|
||||
createAvatarsDir();
|
||||
try (OutputStream output = new FileOutputStream(avatarFile)) {
|
||||
storer.store(output);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteAvatar(final File avatarFile) throws IOException {
|
||||
Files.delete(avatarFile.toPath());
|
||||
}
|
||||
|
||||
private File getGroupAvatarFile(GroupId groupId) {
|
||||
return new File(avatarsPath, "group-" + groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
private File getContactAvatarFile(SignalServiceAddress address) {
|
||||
return new File(avatarsPath, "contact-" + address.getLegacyIdentifier());
|
||||
}
|
||||
|
||||
private File getProfileAvatarFile(SignalServiceAddress address) {
|
||||
return new File(avatarsPath, "profile-" + address.getLegacyIdentifier());
|
||||
}
|
||||
|
||||
private void createAvatarsDir() throws IOException {
|
||||
IOUtils.createPrivateDirectories(avatarsPath);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AvatarStorer {
|
||||
|
||||
void store(OutputStream outputStream) throws IOException;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.util.Util.isEmpty;
|
||||
|
||||
public class DeviceLinkInfo {
|
||||
|
||||
final String deviceIdentifier;
|
||||
final ECPublicKey deviceKey;
|
||||
|
||||
public static DeviceLinkInfo parseDeviceLinkUri(URI linkUri) throws InvalidKeyException {
|
||||
final String rawQuery = linkUri.getRawQuery();
|
||||
if (isEmpty(rawQuery)) {
|
||||
throw new RuntimeException("Invalid device link uri");
|
||||
}
|
||||
|
||||
Map<String, String> query = getQueryMap(rawQuery);
|
||||
String deviceIdentifier = query.get("uuid");
|
||||
String publicKeyEncoded = query.get("pub_key");
|
||||
|
||||
if (isEmpty(deviceIdentifier) || isEmpty(publicKeyEncoded)) {
|
||||
throw new RuntimeException("Invalid device link uri");
|
||||
}
|
||||
|
||||
final byte[] publicKeyBytes;
|
||||
try {
|
||||
publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new RuntimeException("Invalid device link uri", e);
|
||||
}
|
||||
ECPublicKey deviceKey = Curve.decodePoint(publicKeyBytes, 0);
|
||||
|
||||
return new DeviceLinkInfo(deviceIdentifier, deviceKey);
|
||||
}
|
||||
|
||||
private static Map<String, String> getQueryMap(String query) {
|
||||
String[] params = query.split("&");
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (String param : params) {
|
||||
final String[] paramParts = param.split("=");
|
||||
String name = URLDecoder.decode(paramParts[0], StandardCharsets.UTF_8);
|
||||
String value = URLDecoder.decode(paramParts[1], StandardCharsets.UTF_8);
|
||||
map.put(name, value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public DeviceLinkInfo(final String deviceIdentifier, final ECPublicKey deviceKey) {
|
||||
this.deviceIdentifier = deviceIdentifier;
|
||||
this.deviceKey = deviceKey;
|
||||
}
|
||||
|
||||
public String createDeviceLinkUri() {
|
||||
final String deviceKeyString = Base64.getEncoder().encodeToString(deviceKey.serialize()).replace("=", "");
|
||||
return "tsdevice:/?uuid="
|
||||
+ URLEncoder.encode(deviceIdentifier, StandardCharsets.UTF_8)
|
||||
+ "&pub_key="
|
||||
+ URLEncoder.encode(deviceKeyString, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
159
lib/src/main/java/org/asamk/signal/manager/HandleAction.java
Normal file
159
lib/src/main/java/org/asamk/signal/manager/HandleAction.java
Normal file
|
@ -0,0 +1,159 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
interface HandleAction {
|
||||
|
||||
void execute(Manager m) throws Throwable;
|
||||
}
|
||||
|
||||
class SendReceiptAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final long timestamp;
|
||||
|
||||
public SendReceiptAction(final SignalServiceAddress address, final long timestamp) {
|
||||
this.address = address;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendReceipt(address, timestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final SendReceiptAction that = (SendReceiptAction) o;
|
||||
return timestamp == that.timestamp && address.equals(that.address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(address, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncContactsAction implements HandleAction {
|
||||
|
||||
private static final SendSyncContactsAction INSTANCE = new SendSyncContactsAction();
|
||||
|
||||
private SendSyncContactsAction() {
|
||||
}
|
||||
|
||||
public static SendSyncContactsAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendContacts();
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncGroupsAction implements HandleAction {
|
||||
|
||||
private static final SendSyncGroupsAction INSTANCE = new SendSyncGroupsAction();
|
||||
|
||||
private SendSyncGroupsAction() {
|
||||
}
|
||||
|
||||
public static SendSyncGroupsAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroups();
|
||||
}
|
||||
}
|
||||
|
||||
class SendSyncBlockedListAction implements HandleAction {
|
||||
|
||||
private static final SendSyncBlockedListAction INSTANCE = new SendSyncBlockedListAction();
|
||||
|
||||
private SendSyncBlockedListAction() {
|
||||
}
|
||||
|
||||
public static SendSyncBlockedListAction create() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendBlockedList();
|
||||
}
|
||||
}
|
||||
|
||||
class SendGroupInfoRequestAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
public SendGroupInfoRequestAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
|
||||
this.address = address;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroupInfoRequest(groupId, address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final SendGroupInfoRequestAction that = (SendGroupInfoRequestAction) o;
|
||||
|
||||
if (!address.equals(that.address)) return false;
|
||||
return groupId.equals(that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = address.hashCode();
|
||||
result = 31 * result + groupId.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class SendGroupInfoAction implements HandleAction {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
public SendGroupInfoAction(final SignalServiceAddress address, final GroupIdV1 groupId) {
|
||||
this.address = address;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Manager m) throws Throwable {
|
||||
m.sendGroupInfoMessage(groupId, address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final SendGroupInfoAction that = (SendGroupInfoAction) o;
|
||||
|
||||
if (!address.equals(that.address)) return false;
|
||||
return groupId.equals(that.groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = address.hashCode();
|
||||
result = 31 * result + groupId.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class IasTrustStore implements TrustStore {
|
||||
|
||||
@Override
|
||||
public InputStream getKeyStoreInputStream() {
|
||||
return IasTrustStore.class.getResourceAsStream("ias.store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyStorePassword() {
|
||||
return "whisper";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JsonStickerPack {
|
||||
|
||||
@JsonProperty
|
||||
public String title;
|
||||
|
||||
@JsonProperty
|
||||
public String author;
|
||||
|
||||
@JsonProperty
|
||||
public JsonSticker cover;
|
||||
|
||||
@JsonProperty
|
||||
public List<JsonSticker> stickers;
|
||||
|
||||
public static class JsonSticker {
|
||||
|
||||
@JsonProperty
|
||||
public String emoji;
|
||||
|
||||
@JsonProperty
|
||||
public String file;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLogger;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
|
||||
public class LibSignalLogger implements SignalProtocolLogger {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger("LibSignal");
|
||||
|
||||
public static void initLogger() {
|
||||
SignalProtocolLoggerProvider.setProvider(new LibSignalLogger());
|
||||
}
|
||||
|
||||
private LibSignalLogger() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void log(final int priority, final String tag, final String message) {
|
||||
final String logMessage = String.format("[%s]: %s", tag, message);
|
||||
switch (priority) {
|
||||
case SignalProtocolLogger.VERBOSE:
|
||||
logger.trace(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.DEBUG:
|
||||
logger.debug(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.INFO:
|
||||
logger.info(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.WARN:
|
||||
logger.warn(logMessage);
|
||||
break;
|
||||
case SignalProtocolLogger.ERROR:
|
||||
case SignalProtocolLogger.ASSERT:
|
||||
logger.error(logMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
2525
lib/src/main/java/org/asamk/signal/manager/Manager.java
Normal file
2525
lib/src/main/java/org/asamk/signal/manager/Manager.java
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class NotRegisteredException extends Exception {
|
||||
|
||||
public NotRegisteredException() {
|
||||
super("User is not registered.");
|
||||
}
|
||||
}
|
34
lib/src/main/java/org/asamk/signal/manager/PathConfig.java
Normal file
34
lib/src/main/java/org/asamk/signal/manager/PathConfig.java
Normal file
|
@ -0,0 +1,34 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class PathConfig {
|
||||
|
||||
private final File dataPath;
|
||||
private final File attachmentsPath;
|
||||
private final File avatarsPath;
|
||||
|
||||
public static PathConfig createDefault(final File settingsPath) {
|
||||
return new PathConfig(new File(settingsPath, "data"),
|
||||
new File(settingsPath, "attachments"),
|
||||
new File(settingsPath, "avatars"));
|
||||
}
|
||||
|
||||
private PathConfig(final File dataPath, final File attachmentsPath, final File avatarsPath) {
|
||||
this.dataPath = dataPath;
|
||||
this.attachmentsPath = attachmentsPath;
|
||||
this.avatarsPath = avatarsPath;
|
||||
}
|
||||
|
||||
public File getDataPath() {
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public File getAttachmentsPath() {
|
||||
return attachmentsPath;
|
||||
}
|
||||
|
||||
public File getAvatarsPath() {
|
||||
return avatarsPath;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
Copyright (C) 2015-2021 AsamK and contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.util.KeyHelper;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class ProvisioningManager {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(ProvisioningManager.class);
|
||||
|
||||
private final PathConfig pathConfig;
|
||||
private final SignalServiceConfiguration serviceConfiguration;
|
||||
private final String userAgent;
|
||||
|
||||
private final SignalServiceAccountManager accountManager;
|
||||
private final IdentityKeyPair identityKey;
|
||||
private final int registrationId;
|
||||
private final String password;
|
||||
|
||||
public ProvisioningManager(File settingsPath, SignalServiceConfiguration serviceConfiguration, String userAgent) {
|
||||
this.pathConfig = PathConfig.createDefault(settingsPath);
|
||||
this.serviceConfiguration = serviceConfiguration;
|
||||
this.userAgent = userAgent;
|
||||
|
||||
identityKey = KeyUtils.generateIdentityKeyPair();
|
||||
registrationId = KeyHelper.generateRegistrationId(false);
|
||||
password = KeyUtils.createPassword();
|
||||
final SleepTimer timer = new UptimeSleepTimer();
|
||||
GroupsV2Operations groupsV2Operations;
|
||||
try {
|
||||
groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceConfiguration));
|
||||
} catch (Throwable ignored) {
|
||||
groupsV2Operations = null;
|
||||
}
|
||||
accountManager = new SignalServiceAccountManager(serviceConfiguration,
|
||||
new DynamicCredentialsProvider(null, null, password, null, SignalServiceAddress.DEFAULT_DEVICE_ID),
|
||||
userAgent,
|
||||
groupsV2Operations,
|
||||
ServiceConfig.AUTOMATIC_NETWORK_RETRY,
|
||||
timer);
|
||||
}
|
||||
|
||||
public String getDeviceLinkUri() throws TimeoutException, IOException {
|
||||
String deviceUuid = accountManager.getNewDeviceUuid();
|
||||
|
||||
return new DeviceLinkInfo(deviceUuid, identityKey.getPublicKey().getPublicKey()).createDeviceLinkUri();
|
||||
}
|
||||
|
||||
public String finishDeviceLink(String deviceName) throws IOException, InvalidKeyException, TimeoutException, UserAlreadyExists {
|
||||
String signalingKey = KeyUtils.createSignalingKey();
|
||||
SignalServiceAccountManager.NewDeviceRegistrationReturn ret = accountManager.finishNewDeviceRegistration(
|
||||
identityKey,
|
||||
signalingKey,
|
||||
false,
|
||||
true,
|
||||
registrationId,
|
||||
deviceName);
|
||||
|
||||
String username = ret.getNumber();
|
||||
// TODO do this check before actually registering
|
||||
if (SignalAccount.userExists(pathConfig.getDataPath(), username)) {
|
||||
throw new UserAlreadyExists(username, SignalAccount.getFileName(pathConfig.getDataPath(), username));
|
||||
}
|
||||
|
||||
// Create new account with the synced identity
|
||||
byte[] profileKeyBytes = ret.getProfileKey();
|
||||
ProfileKey profileKey;
|
||||
if (profileKeyBytes == null) {
|
||||
profileKey = KeyUtils.createProfileKey();
|
||||
} else {
|
||||
try {
|
||||
profileKey = new ProfileKey(profileKeyBytes);
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException("Received invalid profileKey", e);
|
||||
}
|
||||
}
|
||||
|
||||
try (SignalAccount account = SignalAccount.createLinkedAccount(pathConfig.getDataPath(),
|
||||
username,
|
||||
ret.getUuid(),
|
||||
password,
|
||||
ret.getDeviceId(),
|
||||
ret.getIdentity(),
|
||||
registrationId,
|
||||
signalingKey,
|
||||
profileKey)) {
|
||||
account.save();
|
||||
|
||||
try (Manager m = new Manager(account, pathConfig, serviceConfiguration, userAgent)) {
|
||||
|
||||
try {
|
||||
m.refreshPreKeys();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to refresh prekeys.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
m.requestSyncGroups();
|
||||
m.requestSyncContacts();
|
||||
m.requestSyncBlocked();
|
||||
m.requestSyncConfiguration();
|
||||
m.requestSyncKeys();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to request sync messages from linked device.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
m.close(false);
|
||||
}
|
||||
|
||||
account.save();
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
Copyright (C) 2015-2021 AsamK and contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.asamk.signal.manager;
|
||||
|
||||
import org.asamk.signal.manager.helper.PinHelper;
|
||||
import org.asamk.signal.manager.storage.SignalAccount;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.util.KeyHelper;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.KbsPinData;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public class RegistrationManager implements Closeable {
|
||||
|
||||
private SignalAccount account;
|
||||
private final PathConfig pathConfig;
|
||||
private final SignalServiceConfiguration serviceConfiguration;
|
||||
private final String userAgent;
|
||||
|
||||
private final SignalServiceAccountManager accountManager;
|
||||
private final PinHelper pinHelper;
|
||||
|
||||
public RegistrationManager(
|
||||
SignalAccount account,
|
||||
PathConfig pathConfig,
|
||||
SignalServiceConfiguration serviceConfiguration,
|
||||
String userAgent
|
||||
) {
|
||||
this.account = account;
|
||||
this.pathConfig = pathConfig;
|
||||
this.serviceConfiguration = serviceConfiguration;
|
||||
this.userAgent = userAgent;
|
||||
|
||||
final SleepTimer timer = new UptimeSleepTimer();
|
||||
this.accountManager = new SignalServiceAccountManager(serviceConfiguration, new DynamicCredentialsProvider(
|
||||
// Using empty UUID, because registering doesn't work otherwise
|
||||
null,
|
||||
account.getUsername(),
|
||||
account.getPassword(),
|
||||
account.getSignalingKey(),
|
||||
SignalServiceAddress.DEFAULT_DEVICE_ID), userAgent, null, ServiceConfig.AUTOMATIC_NETWORK_RETRY, timer);
|
||||
final KeyBackupService keyBackupService = ServiceConfig.createKeyBackupService(accountManager);
|
||||
this.pinHelper = new PinHelper(keyBackupService);
|
||||
}
|
||||
|
||||
public static RegistrationManager init(
|
||||
String username, File settingsPath, SignalServiceConfiguration serviceConfiguration, String userAgent
|
||||
) throws IOException {
|
||||
PathConfig pathConfig = PathConfig.createDefault(settingsPath);
|
||||
|
||||
if (!SignalAccount.userExists(pathConfig.getDataPath(), username)) {
|
||||
IdentityKeyPair identityKey = KeyUtils.generateIdentityKeyPair();
|
||||
int registrationId = KeyHelper.generateRegistrationId(false);
|
||||
|
||||
ProfileKey profileKey = KeyUtils.createProfileKey();
|
||||
SignalAccount account = SignalAccount.create(pathConfig.getDataPath(),
|
||||
username,
|
||||
identityKey,
|
||||
registrationId,
|
||||
profileKey);
|
||||
account.save();
|
||||
|
||||
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
|
||||
}
|
||||
|
||||
SignalAccount account = SignalAccount.load(pathConfig.getDataPath(), username);
|
||||
|
||||
return new RegistrationManager(account, pathConfig, serviceConfiguration, userAgent);
|
||||
}
|
||||
|
||||
public void register(boolean voiceVerification, String captcha) throws IOException {
|
||||
if (account.getPassword() == null) {
|
||||
account.setPassword(KeyUtils.createPassword());
|
||||
}
|
||||
|
||||
if (voiceVerification) {
|
||||
accountManager.requestVoiceVerificationCode(Locale.getDefault(),
|
||||
Optional.fromNullable(captcha),
|
||||
Optional.absent());
|
||||
} else {
|
||||
accountManager.requestSmsVerificationCode(false, Optional.fromNullable(captcha), Optional.absent());
|
||||
}
|
||||
|
||||
account.save();
|
||||
}
|
||||
|
||||
public void verifyAccount(
|
||||
String verificationCode, String pin
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
verificationCode = verificationCode.replace("-", "");
|
||||
if (account.getSignalingKey() == null) {
|
||||
account.setSignalingKey(KeyUtils.createSignalingKey());
|
||||
}
|
||||
VerifyAccountResponse response;
|
||||
try {
|
||||
response = verifyAccountWithCode(verificationCode, pin, null);
|
||||
account.setPinMasterKey(null);
|
||||
} catch (LockedException e) {
|
||||
if (pin == null) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
KbsPinData registrationLockData = pinHelper.getRegistrationLockData(pin, e);
|
||||
if (registrationLockData == null) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
String registrationLock = registrationLockData.getMasterKey().deriveRegistrationLock();
|
||||
try {
|
||||
response = verifyAccountWithCode(verificationCode, null, registrationLock);
|
||||
} catch (LockedException _e) {
|
||||
throw new AssertionError("KBS Pin appeared to matched but reg lock still failed!");
|
||||
}
|
||||
account.setPinMasterKey(registrationLockData.getMasterKey());
|
||||
}
|
||||
|
||||
// TODO response.isStorageCapable()
|
||||
//accountManager.setGcmId(Optional.of(GoogleCloudMessaging.getInstance(this).register(REGISTRATION_ID)));
|
||||
|
||||
account.setDeviceId(SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
account.setMultiDevice(false);
|
||||
account.setRegistered(true);
|
||||
account.setUuid(UuidUtil.parseOrNull(response.getUuid()));
|
||||
account.setRegistrationLockPin(pin);
|
||||
account.getSignalProtocolStore()
|
||||
.saveIdentity(account.getSelfAddress(),
|
||||
account.getSignalProtocolStore().getIdentityKeyPair().getPublicKey(),
|
||||
TrustLevel.TRUSTED_VERIFIED);
|
||||
|
||||
try (Manager m = new Manager(account, pathConfig, serviceConfiguration, userAgent)) {
|
||||
|
||||
m.refreshPreKeys();
|
||||
|
||||
m.close(false);
|
||||
}
|
||||
|
||||
account.save();
|
||||
}
|
||||
|
||||
private VerifyAccountResponse verifyAccountWithCode(
|
||||
final String verificationCode, final String legacyPin, final String registrationLock
|
||||
) throws IOException {
|
||||
return accountManager.verifyAccountWithCode(verificationCode,
|
||||
account.getSignalingKey(),
|
||||
account.getSignalProtocolStore().getLocalRegistrationId(),
|
||||
true,
|
||||
legacyPin,
|
||||
registrationLock,
|
||||
account.getSelfUnidentifiedAccessKey(),
|
||||
account.isUnrestrictedUnidentifiedAccess(),
|
||||
ServiceConfig.capabilities,
|
||||
account.isDiscoverableByPhoneNumber());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (account != null) {
|
||||
account.close();
|
||||
account = null;
|
||||
}
|
||||
}
|
||||
}
|
141
lib/src/main/java/org/asamk/signal/manager/ServiceConfig.java
Normal file
141
lib/src/main/java/org/asamk/signal/manager/ServiceConfig.java
Normal file
|
@ -0,0 +1,141 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.signal.zkgroup.ServerPublicParams;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.Interceptor;
|
||||
|
||||
public class ServiceConfig {
|
||||
|
||||
final static byte[] UNIDENTIFIED_SENDER_TRUST_ROOT = Base64.getDecoder()
|
||||
.decode("BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF");
|
||||
final static int PREKEY_MINIMUM_COUNT = 20;
|
||||
final static int PREKEY_BATCH_SIZE = 100;
|
||||
final static int MAX_ATTACHMENT_SIZE = 150 * 1024 * 1024;
|
||||
final static long MAX_ENVELOPE_SIZE = 0;
|
||||
final static long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = 10 * 1024 * 1024;
|
||||
final static boolean AUTOMATIC_NETWORK_RETRY = true;
|
||||
|
||||
final static String CDS_MRENCLAVE = "c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15";
|
||||
|
||||
final static String KEY_BACKUP_ENCLAVE_NAME = "fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe";
|
||||
final static byte[] KEY_BACKUP_SERVICE_ID = Hex.decode(
|
||||
"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe");
|
||||
final static String KEY_BACKUP_MRENCLAVE = "a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87";
|
||||
|
||||
private final static String URL = "https://textsecure-service.whispersystems.org";
|
||||
private final static String CDN_URL = "https://cdn.signal.org";
|
||||
private final static String CDN2_URL = "https://cdn2.signal.org";
|
||||
private final static String SIGNAL_CONTACT_DISCOVERY_URL = "https://api.directory.signal.org";
|
||||
private final static String SIGNAL_KEY_BACKUP_URL = "https://api.backup.signal.org";
|
||||
private final static String STORAGE_URL = "https://storage.signal.org";
|
||||
private final static TrustStore TRUST_STORE = new WhisperTrustStore();
|
||||
private final static TrustStore IAS_TRUST_STORE = new IasTrustStore();
|
||||
|
||||
private final static Optional<Dns> dns = Optional.absent();
|
||||
|
||||
private final static byte[] zkGroupServerPublicParams = Base64.getDecoder()
|
||||
.decode("AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=");
|
||||
|
||||
static final AccountAttributes.Capabilities capabilities;
|
||||
|
||||
static {
|
||||
boolean zkGroupAvailable;
|
||||
try {
|
||||
new ServerPublicParams(zkGroupServerPublicParams);
|
||||
zkGroupAvailable = true;
|
||||
} catch (Throwable ignored) {
|
||||
zkGroupAvailable = false;
|
||||
}
|
||||
capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, zkGroupAvailable);
|
||||
}
|
||||
|
||||
public static SignalServiceConfiguration createDefaultServiceConfiguration(String userAgent) {
|
||||
final Interceptor userAgentInterceptor = chain -> chain.proceed(chain.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.build());
|
||||
|
||||
final List<Interceptor> interceptors = List.of(userAgentInterceptor);
|
||||
|
||||
return new SignalServiceConfiguration(new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
|
||||
makeSignalCdnUrlMapFor(new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)},
|
||||
new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
|
||||
new SignalContactDiscoveryUrl[]{new SignalContactDiscoveryUrl(SIGNAL_CONTACT_DISCOVERY_URL,
|
||||
TRUST_STORE)},
|
||||
new SignalKeyBackupServiceUrl[]{new SignalKeyBackupServiceUrl(SIGNAL_KEY_BACKUP_URL, TRUST_STORE)},
|
||||
new SignalStorageUrl[]{new SignalStorageUrl(STORAGE_URL, TRUST_STORE)},
|
||||
interceptors,
|
||||
dns,
|
||||
zkGroupServerPublicParams);
|
||||
}
|
||||
|
||||
public static AccountAttributes.Capabilities getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
static KeyStore getIasKeyStore() {
|
||||
try {
|
||||
TrustStore contactTrustStore = IAS_TRUST_STORE;
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("BKS");
|
||||
keyStore.load(contactTrustStore.getKeyStoreInputStream(),
|
||||
contactTrustStore.getKeyStorePassword().toCharArray());
|
||||
|
||||
return keyStore;
|
||||
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
static KeyBackupService createKeyBackupService(SignalServiceAccountManager accountManager) {
|
||||
KeyStore keyStore = ServiceConfig.getIasKeyStore();
|
||||
|
||||
return accountManager.getKeyBackupService(keyStore,
|
||||
ServiceConfig.KEY_BACKUP_ENCLAVE_NAME,
|
||||
ServiceConfig.KEY_BACKUP_SERVICE_ID,
|
||||
ServiceConfig.KEY_BACKUP_MRENCLAVE,
|
||||
10);
|
||||
}
|
||||
|
||||
static ECPublicKey getUnidentifiedSenderTrustRoot() {
|
||||
try {
|
||||
return Curve.decodePoint(UNIDENTIFIED_SENDER_TRUST_ROOT, 0);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<Integer, SignalCdnUrl[]> makeSignalCdnUrlMapFor(
|
||||
SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls
|
||||
) {
|
||||
return Map.of(0, cdn0Urls, 2, cdn2Urls);
|
||||
}
|
||||
|
||||
private ServiceConfig() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
public class StickerPackInvalidException extends Exception {
|
||||
|
||||
public StickerPackInvalidException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
42
lib/src/main/java/org/asamk/signal/manager/TrustLevel.java
Normal file
42
lib/src/main/java/org/asamk/signal/manager/TrustLevel.java
Normal file
|
@ -0,0 +1,42 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
|
||||
|
||||
public enum TrustLevel {
|
||||
UNTRUSTED,
|
||||
TRUSTED_UNVERIFIED,
|
||||
TRUSTED_VERIFIED;
|
||||
|
||||
private static TrustLevel[] cachedValues = null;
|
||||
|
||||
public static TrustLevel fromInt(int i) {
|
||||
if (TrustLevel.cachedValues == null) {
|
||||
TrustLevel.cachedValues = TrustLevel.values();
|
||||
}
|
||||
return TrustLevel.cachedValues[i];
|
||||
}
|
||||
|
||||
public static TrustLevel fromVerifiedState(VerifiedMessage.VerifiedState verifiedState) {
|
||||
switch (verifiedState) {
|
||||
case DEFAULT:
|
||||
return TRUSTED_UNVERIFIED;
|
||||
case UNVERIFIED:
|
||||
return UNTRUSTED;
|
||||
case VERIFIED:
|
||||
return TRUSTED_VERIFIED;
|
||||
}
|
||||
throw new RuntimeException("Unknown verified state: " + verifiedState);
|
||||
}
|
||||
|
||||
public VerifiedMessage.VerifiedState toVerifiedState() {
|
||||
switch (this) {
|
||||
case TRUSTED_UNVERIFIED:
|
||||
return VerifiedMessage.VerifiedState.DEFAULT;
|
||||
case UNTRUSTED:
|
||||
return VerifiedMessage.VerifiedState.UNVERIFIED;
|
||||
case TRUSTED_VERIFIED:
|
||||
return VerifiedMessage.VerifiedState.VERIFIED;
|
||||
}
|
||||
throw new RuntimeException("Unknown verified state: " + this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class UserAlreadyExists extends Exception {
|
||||
|
||||
private final String username;
|
||||
private final File fileName;
|
||||
|
||||
public UserAlreadyExists(String username, File fileName) {
|
||||
this.username = username;
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public File getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class WhisperTrustStore implements TrustStore {
|
||||
|
||||
@Override
|
||||
public InputStream getKeyStoreInputStream() {
|
||||
return WhisperTrustStore.class.getResourceAsStream("whisper.store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeyStorePassword() {
|
||||
return "whisper";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
public abstract class GroupId {
|
||||
|
||||
private final byte[] id;
|
||||
|
||||
public static GroupIdV1 v1(byte[] id) {
|
||||
return new GroupIdV1(id);
|
||||
}
|
||||
|
||||
public static GroupIdV2 v2(byte[] id) {
|
||||
return new GroupIdV2(id);
|
||||
}
|
||||
|
||||
public static GroupId unknownVersion(byte[] id) {
|
||||
if (id.length == 16) {
|
||||
return new GroupIdV1(id);
|
||||
} else if (id.length == 32) {
|
||||
return new GroupIdV2(id);
|
||||
}
|
||||
|
||||
throw new AssertionError("Invalid group id of size " + id.length);
|
||||
}
|
||||
|
||||
public static GroupId fromBase64(String id) throws GroupIdFormatException {
|
||||
try {
|
||||
return unknownVersion(java.util.Base64.getDecoder().decode(id));
|
||||
} catch (Throwable e) {
|
||||
throw new GroupIdFormatException(id, e);
|
||||
}
|
||||
}
|
||||
|
||||
public GroupId(final byte[] id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String toBase64() {
|
||||
return Base64.getEncoder().encodeToString(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
final GroupId groupId = (GroupId) o;
|
||||
|
||||
return Arrays.equals(id, groupId.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class GroupIdFormatException extends Exception {
|
||||
|
||||
public GroupIdFormatException(String groupId, Throwable e) {
|
||||
super("Failed to decode groupId (must be base64) \"" + groupId + "\": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import static org.asamk.signal.manager.util.KeyUtils.getSecretBytes;
|
||||
|
||||
public class GroupIdV1 extends GroupId {
|
||||
|
||||
public static GroupIdV1 createRandom() {
|
||||
return new GroupIdV1(getSecretBytes(16));
|
||||
}
|
||||
|
||||
public GroupIdV1(final byte[] id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class GroupIdV2 extends GroupId {
|
||||
|
||||
public static GroupIdV2 fromBase64(String groupId) {
|
||||
return new GroupIdV2(Base64.getDecoder().decode(groupId));
|
||||
}
|
||||
|
||||
public GroupIdV2(final byte[] id) {
|
||||
super(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class GroupNotFoundException extends Exception {
|
||||
|
||||
public GroupNotFoundException(GroupId groupId) {
|
||||
super("Group not found: " + groupId.toBase64());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfo;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
|
||||
|
||||
public class GroupUtils {
|
||||
|
||||
public static void setGroupContext(
|
||||
final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo groupInfo
|
||||
) {
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
|
||||
.withId(groupInfo.getGroupId().serialize())
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
} else {
|
||||
final GroupInfoV2 groupInfoV2 = (GroupInfoV2) groupInfo;
|
||||
SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
|
||||
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().getRevision())
|
||||
.build();
|
||||
messageBuilder.asGroupMessage(group);
|
||||
}
|
||||
}
|
||||
|
||||
public static GroupId getGroupId(SignalServiceGroupContext context) {
|
||||
if (context.getGroupV1().isPresent()) {
|
||||
return GroupId.v1(context.getGroupV1().get().getGroupId());
|
||||
} else if (context.getGroupV2().isPresent()) {
|
||||
return getGroupIdV2(context.getGroupV2().get().getMasterKey());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupSecretParams groupSecretParams) {
|
||||
return GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier().serialize());
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupMasterKey groupMasterKey) {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
return getGroupIdV2(groupSecretParams);
|
||||
}
|
||||
|
||||
public static GroupIdV2 getGroupIdV2(GroupIdV1 groupIdV1) {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(deriveV2MigrationMasterKey(
|
||||
groupIdV1));
|
||||
return getGroupIdV2(groupSecretParams);
|
||||
}
|
||||
|
||||
private static GroupMasterKey deriveV2MigrationMasterKey(GroupIdV1 groupIdV1) {
|
||||
try {
|
||||
return new GroupMasterKey(new HKDFv3().deriveSecrets(groupIdV1.serialize(),
|
||||
"GV2 Migration".getBytes(),
|
||||
GroupMasterKey.SIZE));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.groups;
|
||||
|
||||
public class NotAGroupMemberException extends Exception {
|
||||
|
||||
public NotAGroupMemberException(GroupId groupId, String groupName) {
|
||||
super("User is not a member in group: " + groupName + " (" + groupId.toBase64() + ")");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface GroupAuthorizationProvider {
|
||||
|
||||
GroupsV2AuthorizationString getAuthorizationForToday(GroupSecretParams groupSecretParams) throws IOException;
|
||||
}
|
|
@ -0,0 +1,399 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupLinkPassword;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GroupHelper {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
|
||||
|
||||
private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final SelfAddressProvider selfAddressProvider;
|
||||
|
||||
private final GroupsV2Operations groupsV2Operations;
|
||||
|
||||
private final GroupsV2Api groupsV2Api;
|
||||
|
||||
private final GroupAuthorizationProvider groupAuthorizationProvider;
|
||||
|
||||
public GroupHelper(
|
||||
final ProfileKeyCredentialProvider profileKeyCredentialProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final SelfAddressProvider selfAddressProvider,
|
||||
final GroupsV2Operations groupsV2Operations,
|
||||
final GroupsV2Api groupsV2Api,
|
||||
final GroupAuthorizationProvider groupAuthorizationProvider
|
||||
) {
|
||||
this.profileKeyCredentialProvider = profileKeyCredentialProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.selfAddressProvider = selfAddressProvider;
|
||||
this.groupsV2Operations = groupsV2Operations;
|
||||
this.groupsV2Api = groupsV2Api;
|
||||
this.groupAuthorizationProvider = groupAuthorizationProvider;
|
||||
}
|
||||
|
||||
public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
|
||||
try {
|
||||
final GroupsV2AuthorizationString groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
|
||||
groupSecretParams);
|
||||
return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
|
||||
GroupMasterKey groupMasterKey, GroupLinkPassword password
|
||||
) throws IOException, GroupLinkNotActiveException {
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
|
||||
}
|
||||
|
||||
public GroupInfoV2 createGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, File avatarFile
|
||||
) throws IOException {
|
||||
final byte[] avatarBytes = readAvatarBytes(avatarFile);
|
||||
final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes);
|
||||
if (newGroup == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams();
|
||||
|
||||
final GroupsV2AuthorizationString groupAuthForToday;
|
||||
final DecryptedGroup decryptedGroup;
|
||||
try {
|
||||
groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
|
||||
groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
|
||||
decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
logger.warn("Failed to create V2 group: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
if (decryptedGroup == null) {
|
||||
logger.warn("Failed to create V2 group, unknown error!");
|
||||
return null;
|
||||
}
|
||||
|
||||
final GroupIdV2 groupId = GroupUtils.getGroupIdV2(groupSecretParams);
|
||||
final GroupMasterKey masterKey = groupSecretParams.getMasterKey();
|
||||
GroupInfoV2 g = new GroupInfoV2(groupId, masterKey);
|
||||
g.setGroup(decryptedGroup);
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private byte[] readAvatarBytes(final File avatarFile) throws IOException {
|
||||
final byte[] avatarBytes;
|
||||
try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
|
||||
avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
|
||||
}
|
||||
return avatarBytes;
|
||||
}
|
||||
|
||||
private GroupsV2Operations.NewGroup buildNewGroupV2(
|
||||
String name, Collection<SignalServiceAddress> members, byte[] avatar
|
||||
) {
|
||||
final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
|
||||
selfAddressProvider.getSelfAddress());
|
||||
if (profileKeyCredential == null) {
|
||||
logger.warn("Cannot create a V2 group as self does not have a versioned profile");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!areMembersValid(members)) return null;
|
||||
|
||||
GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
|
||||
Optional.fromNullable(profileKeyCredential));
|
||||
Set<GroupCandidate> candidates = members.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.generate();
|
||||
return groupsV2Operations.createNewGroup(groupSecretParams,
|
||||
name,
|
||||
Optional.fromNullable(avatar),
|
||||
self,
|
||||
candidates,
|
||||
Member.Role.DEFAULT,
|
||||
0);
|
||||
}
|
||||
|
||||
private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
|
||||
final Set<String> noUuidCapability = members.stream()
|
||||
.filter(address -> !address.getUuid().isPresent())
|
||||
.map(SignalServiceAddress::getLegacyIdentifier)
|
||||
.collect(Collectors.toSet());
|
||||
if (noUuidCapability.size() > 0) {
|
||||
logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
|
||||
String.join(", ", noUuidCapability));
|
||||
return false;
|
||||
}
|
||||
|
||||
final Set<SignalProfile> noGv2Capability = members.stream()
|
||||
.map(profileProvider::getProfile)
|
||||
.filter(profile -> profile != null && !profile.getCapabilities().gv2)
|
||||
.collect(Collectors.toSet());
|
||||
if (noGv2Capability.size() > 0) {
|
||||
logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
|
||||
noGv2Capability.stream().map(SignalProfile::getName).collect(Collectors.joining(", ")));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, String name, File avatarFile
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
GroupChange.Actions.Builder change = name != null
|
||||
? groupOperations.createModifyGroupTitle(name)
|
||||
: GroupChange.Actions.newBuilder();
|
||||
|
||||
if (avatarFile != null) {
|
||||
final byte[] avatarBytes = readAvatarBytes(avatarFile);
|
||||
String avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
|
||||
groupSecretParams,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
|
||||
change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
|
||||
}
|
||||
|
||||
final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
|
||||
GroupInfoV2 groupInfoV2, Set<SignalServiceAddress> newMembers
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
if (!areMembersValid(newMembers)) {
|
||||
throw new IOException("Failed to update group");
|
||||
}
|
||||
|
||||
Set<GroupCandidate> candidates = newMembers.stream()
|
||||
.map(member -> new GroupCandidate(member.getUuid().get(),
|
||||
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
final GroupChange.Actions.Builder change = groupOperations.createModifyGroupMembershipChange(candidates,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
|
||||
final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
List<DecryptedPendingMember> pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
|
||||
final UUID selfUuid = selfAddressProvider.getSelfAddress().getUuid().get();
|
||||
Optional<DecryptedPendingMember> selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList,
|
||||
selfUuid);
|
||||
|
||||
if (selfPendingMember.isPresent()) {
|
||||
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
|
||||
} else {
|
||||
return ejectMembers(groupInfoV2, Set.of(selfUuid));
|
||||
}
|
||||
}
|
||||
|
||||
public GroupChange joinGroup(
|
||||
GroupMasterKey groupMasterKey,
|
||||
GroupLinkPassword groupLinkPassword,
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
|
||||
final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
|
||||
selfAddress);
|
||||
if (profileKeyCredential == null) {
|
||||
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
|
||||
}
|
||||
|
||||
boolean requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink()
|
||||
== AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
GroupChange.Actions.Builder change = requestToJoin
|
||||
? groupOperations.createGroupJoinRequest(profileKeyCredential)
|
||||
: groupOperations.createGroupJoinDirect(profileKeyCredential);
|
||||
|
||||
change.setSourceUuid(UuidUtil.toByteString(selfAddress.getUuid().get()));
|
||||
|
||||
return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
|
||||
final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
|
||||
final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
|
||||
selfAddress);
|
||||
if (profileKeyCredential == null) {
|
||||
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
|
||||
}
|
||||
|
||||
final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential);
|
||||
|
||||
final Optional<UUID> uuid = selfAddress.getUuid();
|
||||
if (uuid.isPresent()) {
|
||||
change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
|
||||
}
|
||||
|
||||
return commitChange(groupInfoV2, change);
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> revokeInvites(
|
||||
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final Set<UuidCiphertext> uuidCipherTexts = pendingMembers.stream().map(member -> {
|
||||
try {
|
||||
return new UuidCiphertext(member.getUuidCipherText().toByteArray());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
|
||||
}
|
||||
|
||||
public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
|
||||
}
|
||||
|
||||
private Pair<DecryptedGroup, GroupChange> commitChange(
|
||||
GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
|
||||
) throws IOException {
|
||||
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
|
||||
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
final DecryptedGroup previousGroupState = groupInfoV2.getGroup();
|
||||
final int nextRevision = previousGroupState.getRevision() + 1;
|
||||
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
|
||||
final DecryptedGroupChange decryptedChange;
|
||||
final DecryptedGroup decryptedGroupState;
|
||||
|
||||
try {
|
||||
decryptedChange = groupOperations.decryptChange(changeActions,
|
||||
selfAddressProvider.getSelfAddress().getUuid().get());
|
||||
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
GroupChange signedGroupChange = groupsV2Api.patchGroup(changeActions,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
|
||||
Optional.absent());
|
||||
|
||||
return new Pair<>(decryptedGroupState, signedGroupChange);
|
||||
}
|
||||
|
||||
private GroupChange commitChange(
|
||||
GroupSecretParams groupSecretParams,
|
||||
int currentRevision,
|
||||
GroupChange.Actions.Builder change,
|
||||
GroupLinkPassword password
|
||||
) throws IOException {
|
||||
final int nextRevision = currentRevision + 1;
|
||||
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
|
||||
|
||||
return groupsV2Api.patchGroup(changeActions,
|
||||
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
|
||||
}
|
||||
|
||||
public DecryptedGroup getUpdatedDecryptedGroup(
|
||||
DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
|
||||
) {
|
||||
try {
|
||||
final DecryptedGroupChange decryptedGroupChange = getDecryptedGroupChange(signedGroupChange,
|
||||
groupMasterKey);
|
||||
if (decryptedGroupChange == null) {
|
||||
return null;
|
||||
}
|
||||
return DecryptedGroupUtil.apply(group, decryptedGroupChange);
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
|
||||
if (signedGroupChange != null) {
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(
|
||||
groupMasterKey));
|
||||
|
||||
try {
|
||||
return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
|
||||
public interface MessagePipeProvider {
|
||||
|
||||
SignalServiceMessagePipe getMessagePipe(boolean unidentified);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
|
||||
public interface MessageReceiverProvider {
|
||||
|
||||
SignalServiceMessageReceiver getMessageReceiver();
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.util.PinHashing;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.signalservice.api.KbsPinData;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.KeyBackupServicePinException;
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.push.LockedException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PinHelper {
|
||||
|
||||
private final KeyBackupService keyBackupService;
|
||||
|
||||
public PinHelper(final KeyBackupService keyBackupService) {
|
||||
this.keyBackupService = keyBackupService;
|
||||
}
|
||||
|
||||
public void setRegistrationLockPin(
|
||||
String pin, MasterKey masterKey
|
||||
) throws IOException, UnauthenticatedResponseException {
|
||||
final KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
|
||||
final HashedPin hashedPin = PinHashing.hashPin(pin, pinChangeSession);
|
||||
|
||||
pinChangeSession.setPin(hashedPin, masterKey);
|
||||
pinChangeSession.enableRegistrationLock(masterKey);
|
||||
}
|
||||
|
||||
public void removeRegistrationLockPin() throws IOException, UnauthenticatedResponseException {
|
||||
final KeyBackupService.PinChangeSession pinChangeSession = keyBackupService.newPinChangeSession();
|
||||
pinChangeSession.disableRegistrationLock();
|
||||
pinChangeSession.removePin();
|
||||
}
|
||||
|
||||
public KbsPinData getRegistrationLockData(
|
||||
String pin, LockedException e
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
String basicStorageCredentials = e.getBasicStorageCredentials();
|
||||
if (basicStorageCredentials == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getRegistrationLockData(pin, basicStorageCredentials);
|
||||
}
|
||||
|
||||
private KbsPinData getRegistrationLockData(
|
||||
String pin, String basicStorageCredentials
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
TokenResponse tokenResponse = keyBackupService.getToken(basicStorageCredentials);
|
||||
if (tokenResponse == null || tokenResponse.getTries() == 0) {
|
||||
throw new IOException("KBS Account locked");
|
||||
}
|
||||
|
||||
KbsPinData registrationLockData = restoreMasterKey(pin, basicStorageCredentials, tokenResponse);
|
||||
if (registrationLockData == null) {
|
||||
throw new AssertionError("Failed to restore master key");
|
||||
}
|
||||
return registrationLockData;
|
||||
}
|
||||
|
||||
private KbsPinData restoreMasterKey(
|
||||
String pin, String basicStorageCredentials, TokenResponse tokenResponse
|
||||
) throws IOException, KeyBackupSystemNoDataException, KeyBackupServicePinException {
|
||||
if (pin == null) return null;
|
||||
|
||||
if (basicStorageCredentials == null) {
|
||||
throw new AssertionError("Cannot restore KBS key, no storage credentials supplied");
|
||||
}
|
||||
|
||||
KeyBackupService.RestoreSession session = keyBackupService.newRegistrationSession(basicStorageCredentials,
|
||||
tokenResponse);
|
||||
|
||||
try {
|
||||
HashedPin hashedPin = PinHashing.hashPin(pin, session);
|
||||
KbsPinData kbsData = session.restorePin(hashedPin);
|
||||
if (kbsData == null) {
|
||||
throw new AssertionError("Null not expected");
|
||||
}
|
||||
return kbsData;
|
||||
} catch (UnauthenticatedResponseException | InvalidKeyException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class ProfileHelper {
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final UnidentifiedAccessProvider unidentifiedAccessProvider;
|
||||
|
||||
private final MessagePipeProvider messagePipeProvider;
|
||||
|
||||
private final MessageReceiverProvider messageReceiverProvider;
|
||||
|
||||
public ProfileHelper(
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final UnidentifiedAccessProvider unidentifiedAccessProvider,
|
||||
final MessagePipeProvider messagePipeProvider,
|
||||
final MessageReceiverProvider messageReceiverProvider
|
||||
) {
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.unidentifiedAccessProvider = unidentifiedAccessProvider;
|
||||
this.messagePipeProvider = messagePipeProvider;
|
||||
this.messageReceiverProvider = messageReceiverProvider;
|
||||
}
|
||||
|
||||
public ProfileAndCredential retrieveProfileSync(
|
||||
SignalServiceAddress recipient, SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
try {
|
||||
return retrieveProfile(recipient, requestType).get(10, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof PushNetworkException) {
|
||||
throw (PushNetworkException) e.getCause();
|
||||
} else if (e.getCause() instanceof NotFoundException) {
|
||||
throw (NotFoundException) e.getCause();
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ListenableFuture<ProfileAndCredential> retrieveProfile(
|
||||
SignalServiceAddress address, SignalServiceProfile.RequestType requestType
|
||||
) {
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess = getUnidentifiedAccess(address);
|
||||
Optional<ProfileKey> profileKey = Optional.fromNullable(profileKeyProvider.getProfileKey(address));
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
unidentifiedAccess,
|
||||
requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, unidentifiedAccess, requestType),
|
||||
() -> getPipeRetrievalFuture(address, profileKey, Optional.absent(), requestType),
|
||||
() -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
} else {
|
||||
return new CascadingFuture<>(Arrays.asList(() -> getPipeRetrievalFuture(address,
|
||||
profileKey,
|
||||
Optional.absent(),
|
||||
requestType), () -> getSocketRetrievalFuture(address, profileKey, Optional.absent(), requestType)),
|
||||
e -> !(e instanceof NotFoundException));
|
||||
}
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) throws IOException {
|
||||
SignalServiceMessagePipe unidentifiedPipe = messagePipeProvider.getMessagePipe(true);
|
||||
SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent()
|
||||
? unidentifiedPipe
|
||||
: messagePipeProvider.getMessagePipe(false);
|
||||
if (pipe != null) {
|
||||
try {
|
||||
return pipe.getProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
// Native zkgroup lib not available for ProfileKey
|
||||
if (!address.getNumber().isPresent()) {
|
||||
throw new NotFoundException("Can't request profile without number");
|
||||
}
|
||||
SignalServiceAddress addressWithoutUuid = new SignalServiceAddress(Optional.absent(),
|
||||
address.getNumber());
|
||||
return pipe.getProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("No pipe available!");
|
||||
}
|
||||
|
||||
private ListenableFuture<ProfileAndCredential> getSocketRetrievalFuture(
|
||||
SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType
|
||||
) throws NotFoundException {
|
||||
SignalServiceMessageReceiver receiver = messageReceiverProvider.getMessageReceiver();
|
||||
try {
|
||||
return receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
// Native zkgroup lib not available for ProfileKey
|
||||
if (!address.getNumber().isPresent()) {
|
||||
throw new NotFoundException("Can't request profile without number");
|
||||
}
|
||||
SignalServiceAddress addressWithoutUuid = new SignalServiceAddress(Optional.absent(), address.getNumber());
|
||||
return receiver.retrieveProfile(addressWithoutUuid, profileKey, unidentifiedAccess, requestType);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<UnidentifiedAccess> getUnidentifiedAccess(SignalServiceAddress recipient) {
|
||||
Optional<UnidentifiedAccessPair> unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipient);
|
||||
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
return unidentifiedAccess.get().getTargetUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyCredentialProvider {
|
||||
|
||||
ProfileKeyCredential getProfileKeyCredential(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface ProfileProvider {
|
||||
|
||||
SignalProfile getProfile(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface SelfAddressProvider {
|
||||
|
||||
SignalServiceAddress getSelfAddress();
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
public interface SelfProfileKeyProvider {
|
||||
|
||||
ProfileKey getProfileKey();
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.whispersystems.signalservice.internal.util.Util.getSecretBytes;
|
||||
|
||||
public class UnidentifiedAccessHelper {
|
||||
|
||||
private final SelfProfileKeyProvider selfProfileKeyProvider;
|
||||
|
||||
private final ProfileKeyProvider profileKeyProvider;
|
||||
|
||||
private final ProfileProvider profileProvider;
|
||||
|
||||
private final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider;
|
||||
|
||||
public UnidentifiedAccessHelper(
|
||||
final SelfProfileKeyProvider selfProfileKeyProvider,
|
||||
final ProfileKeyProvider profileKeyProvider,
|
||||
final ProfileProvider profileProvider,
|
||||
final UnidentifiedAccessSenderCertificateProvider senderCertificateProvider
|
||||
) {
|
||||
this.selfProfileKeyProvider = selfProfileKeyProvider;
|
||||
this.profileKeyProvider = profileKeyProvider;
|
||||
this.profileProvider = profileProvider;
|
||||
this.senderCertificateProvider = senderCertificateProvider;
|
||||
}
|
||||
|
||||
private byte[] getSelfUnidentifiedAccessKey() {
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(selfProfileKeyProvider.getProfileKey());
|
||||
}
|
||||
|
||||
public byte[] getTargetUnidentifiedAccessKey(SignalServiceAddress recipient) {
|
||||
ProfileKey theirProfileKey = profileKeyProvider.getProfileKey(recipient);
|
||||
if (theirProfileKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SignalProfile targetProfile = profileProvider.getProfile(recipient);
|
||||
if (targetProfile == null || targetProfile.getUnidentifiedAccess() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetProfile.isUnrestrictedUnidentifiedAccess()) {
|
||||
return createUnrestrictedUnidentifiedAccess();
|
||||
}
|
||||
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey);
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessForSync() {
|
||||
byte[] selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
byte[] selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (selfUnidentifiedAccessKey == null || selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(selfUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Optional<UnidentifiedAccessPair>> getAccessFor(Collection<SignalServiceAddress> recipients) {
|
||||
return recipients.stream().map(this::getAccessFor).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress recipient) {
|
||||
byte[] recipientUnidentifiedAccessKey = getTargetUnidentifiedAccessKey(recipient);
|
||||
byte[] selfUnidentifiedAccessKey = getSelfUnidentifiedAccessKey();
|
||||
byte[] selfUnidentifiedAccessCertificate = senderCertificateProvider.getSenderCertificate();
|
||||
|
||||
if (recipientUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessKey == null
|
||||
|| selfUnidentifiedAccessCertificate == null) {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(new UnidentifiedAccessPair(new UnidentifiedAccess(recipientUnidentifiedAccessKey,
|
||||
selfUnidentifiedAccessCertificate),
|
||||
new UnidentifiedAccess(selfUnidentifiedAccessKey, selfUnidentifiedAccessCertificate)));
|
||||
} catch (InvalidCertificateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createUnrestrictedUnidentifiedAccess() {
|
||||
return getSecretBytes(16);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface UnidentifiedAccessProvider {
|
||||
|
||||
Optional<UnidentifiedAccessPair> getAccessFor(SignalServiceAddress address);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package org.asamk.signal.manager.helper;
|
||||
|
||||
public interface UnidentifiedAccessSenderCertificateProvider {
|
||||
|
||||
byte[] getSenderCertificate();
|
||||
}
|
|
@ -0,0 +1,622 @@
|
|||
package org.asamk.signal.manager.storage;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.storage.contacts.ContactInfo;
|
||||
import org.asamk.signal.manager.storage.contacts.JsonContactsStore;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfo;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.JsonGroupStore;
|
||||
import org.asamk.signal.manager.storage.messageCache.MessageCache;
|
||||
import org.asamk.signal.manager.storage.profiles.ProfileStore;
|
||||
import org.asamk.signal.manager.storage.protocol.IdentityInfo;
|
||||
import org.asamk.signal.manager.storage.protocol.JsonSignalProtocolStore;
|
||||
import org.asamk.signal.manager.storage.protocol.RecipientStore;
|
||||
import org.asamk.signal.manager.storage.protocol.SessionInfo;
|
||||
import org.asamk.signal.manager.storage.protocol.SignalServiceAddressResolver;
|
||||
import org.asamk.signal.manager.storage.stickers.StickerStore;
|
||||
import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
|
||||
import org.asamk.signal.manager.storage.threads.ThreadInfo;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.KeyUtils;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.util.Medium;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SignalAccount implements Closeable {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(SignalAccount.class);
|
||||
|
||||
private final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
private final FileChannel fileChannel;
|
||||
private final FileLock lock;
|
||||
private String username;
|
||||
private UUID uuid;
|
||||
private int deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
private boolean isMultiDevice = false;
|
||||
private String password;
|
||||
private String registrationLockPin;
|
||||
private MasterKey pinMasterKey;
|
||||
private StorageKey storageKey;
|
||||
private String signalingKey;
|
||||
private ProfileKey profileKey;
|
||||
private int preKeyIdOffset;
|
||||
private int nextSignedPreKeyId;
|
||||
|
||||
private boolean registered = false;
|
||||
|
||||
private JsonSignalProtocolStore signalProtocolStore;
|
||||
private JsonGroupStore groupStore;
|
||||
private JsonContactsStore contactStore;
|
||||
private RecipientStore recipientStore;
|
||||
private ProfileStore profileStore;
|
||||
private StickerStore stickerStore;
|
||||
|
||||
private MessageCache messageCache;
|
||||
|
||||
private SignalAccount(final FileChannel fileChannel, final FileLock lock) {
|
||||
this.fileChannel = fileChannel;
|
||||
this.lock = lock;
|
||||
jsonProcessor.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); // disable autodetect
|
||||
jsonProcessor.enable(SerializationFeature.INDENT_OUTPUT); // for pretty print
|
||||
jsonProcessor.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
jsonProcessor.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE);
|
||||
jsonProcessor.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
|
||||
}
|
||||
|
||||
public static SignalAccount load(File dataPath, String username) throws IOException {
|
||||
final File fileName = getFileName(dataPath, username);
|
||||
final Pair<FileChannel, FileLock> pair = openFileChannel(fileName);
|
||||
try {
|
||||
SignalAccount account = new SignalAccount(pair.first(), pair.second());
|
||||
account.load(dataPath);
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
} catch (Throwable e) {
|
||||
pair.second().close();
|
||||
pair.first().close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static SignalAccount create(
|
||||
File dataPath, String username, IdentityKeyPair identityKey, int registrationId, ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
File fileName = getFileName(dataPath, username);
|
||||
if (!fileName.exists()) {
|
||||
IOUtils.createPrivateFile(fileName);
|
||||
}
|
||||
|
||||
final Pair<FileChannel, FileLock> pair = openFileChannel(fileName);
|
||||
SignalAccount account = new SignalAccount(pair.first(), pair.second());
|
||||
|
||||
account.username = username;
|
||||
account.profileKey = profileKey;
|
||||
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
|
||||
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
account.contactStore = new JsonContactsStore();
|
||||
account.recipientStore = new RecipientStore();
|
||||
account.profileStore = new ProfileStore();
|
||||
account.stickerStore = new StickerStore();
|
||||
|
||||
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
account.registered = false;
|
||||
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static SignalAccount createLinkedAccount(
|
||||
File dataPath,
|
||||
String username,
|
||||
UUID uuid,
|
||||
String password,
|
||||
int deviceId,
|
||||
IdentityKeyPair identityKey,
|
||||
int registrationId,
|
||||
String signalingKey,
|
||||
ProfileKey profileKey
|
||||
) throws IOException {
|
||||
IOUtils.createPrivateDirectories(dataPath);
|
||||
File fileName = getFileName(dataPath, username);
|
||||
if (!fileName.exists()) {
|
||||
IOUtils.createPrivateFile(fileName);
|
||||
}
|
||||
|
||||
final Pair<FileChannel, FileLock> pair = openFileChannel(fileName);
|
||||
SignalAccount account = new SignalAccount(pair.first(), pair.second());
|
||||
|
||||
account.username = username;
|
||||
account.uuid = uuid;
|
||||
account.password = password;
|
||||
account.profileKey = profileKey;
|
||||
account.deviceId = deviceId;
|
||||
account.signalingKey = signalingKey;
|
||||
account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
|
||||
account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
account.contactStore = new JsonContactsStore();
|
||||
account.recipientStore = new RecipientStore();
|
||||
account.profileStore = new ProfileStore();
|
||||
account.stickerStore = new StickerStore();
|
||||
|
||||
account.messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
account.registered = true;
|
||||
account.isMultiDevice = true;
|
||||
|
||||
account.migrateLegacyConfigs();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public void migrateLegacyConfigs() {
|
||||
if (getProfileKey() == null && isRegistered()) {
|
||||
// Old config file, creating new profile key
|
||||
setProfileKey(KeyUtils.createProfileKey());
|
||||
save();
|
||||
}
|
||||
// Store profile keys only in profile store
|
||||
for (ContactInfo contact : getContactStore().getContacts()) {
|
||||
String profileKeyString = contact.profileKey;
|
||||
if (profileKeyString == null) {
|
||||
continue;
|
||||
}
|
||||
final ProfileKey profileKey;
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(profileKeyString));
|
||||
} catch (InvalidInputException ignored) {
|
||||
continue;
|
||||
}
|
||||
contact.profileKey = null;
|
||||
getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
|
||||
}
|
||||
// Ensure our profile key is stored in profile store
|
||||
getProfileStore().storeProfileKey(getSelfAddress(), getProfileKey());
|
||||
}
|
||||
|
||||
public static File getFileName(File dataPath, String username) {
|
||||
return new File(dataPath, username);
|
||||
}
|
||||
|
||||
private static File getUserPath(final File dataPath, final String username) {
|
||||
return new File(dataPath, username + ".d");
|
||||
}
|
||||
|
||||
public static File getMessageCachePath(File dataPath, String username) {
|
||||
return new File(getUserPath(dataPath, username), "msg-cache");
|
||||
}
|
||||
|
||||
private static File getGroupCachePath(File dataPath, String username) {
|
||||
return new File(getUserPath(dataPath, username), "group-cache");
|
||||
}
|
||||
|
||||
public static boolean userExists(File dataPath, String username) {
|
||||
if (username == null) {
|
||||
return false;
|
||||
}
|
||||
File f = getFileName(dataPath, username);
|
||||
return !(!f.exists() || f.isDirectory());
|
||||
}
|
||||
|
||||
private void load(File dataPath) throws IOException {
|
||||
JsonNode rootNode;
|
||||
synchronized (fileChannel) {
|
||||
fileChannel.position(0);
|
||||
rootNode = jsonProcessor.readTree(Channels.newInputStream(fileChannel));
|
||||
}
|
||||
|
||||
if (rootNode.hasNonNull("uuid")) {
|
||||
try {
|
||||
uuid = UUID.fromString(rootNode.get("uuid").asText());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Config file contains an invalid uuid, needs to be a valid UUID", e);
|
||||
}
|
||||
}
|
||||
if (rootNode.hasNonNull("deviceId")) {
|
||||
deviceId = rootNode.get("deviceId").asInt();
|
||||
}
|
||||
if (rootNode.hasNonNull("isMultiDevice")) {
|
||||
isMultiDevice = rootNode.get("isMultiDevice").asBoolean();
|
||||
}
|
||||
username = Utils.getNotNullNode(rootNode, "username").asText();
|
||||
password = Utils.getNotNullNode(rootNode, "password").asText();
|
||||
if (rootNode.hasNonNull("registrationLockPin")) {
|
||||
registrationLockPin = rootNode.get("registrationLockPin").asText();
|
||||
}
|
||||
if (rootNode.hasNonNull("pinMasterKey")) {
|
||||
pinMasterKey = new MasterKey(Base64.getDecoder().decode(rootNode.get("pinMasterKey").asText()));
|
||||
}
|
||||
if (rootNode.hasNonNull("storageKey")) {
|
||||
storageKey = new StorageKey(Base64.getDecoder().decode(rootNode.get("storageKey").asText()));
|
||||
}
|
||||
if (rootNode.hasNonNull("signalingKey")) {
|
||||
signalingKey = rootNode.get("signalingKey").asText();
|
||||
if (signalingKey.equals("null")) {
|
||||
// Workaround for load bug in older versions
|
||||
signalingKey = null;
|
||||
}
|
||||
}
|
||||
if (rootNode.hasNonNull("preKeyIdOffset")) {
|
||||
preKeyIdOffset = rootNode.get("preKeyIdOffset").asInt(0);
|
||||
} else {
|
||||
preKeyIdOffset = 0;
|
||||
}
|
||||
if (rootNode.hasNonNull("nextSignedPreKeyId")) {
|
||||
nextSignedPreKeyId = rootNode.get("nextSignedPreKeyId").asInt();
|
||||
} else {
|
||||
nextSignedPreKeyId = 0;
|
||||
}
|
||||
if (rootNode.hasNonNull("profileKey")) {
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(rootNode.get("profileKey").asText()));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException(
|
||||
"Config file contains an invalid profileKey, needs to be base64 encoded array of 32 bytes",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
signalProtocolStore = jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"),
|
||||
JsonSignalProtocolStore.class);
|
||||
registered = Utils.getNotNullNode(rootNode, "registered").asBoolean();
|
||||
JsonNode groupStoreNode = rootNode.get("groupStore");
|
||||
if (groupStoreNode != null) {
|
||||
groupStore = jsonProcessor.convertValue(groupStoreNode, JsonGroupStore.class);
|
||||
groupStore.groupCachePath = getGroupCachePath(dataPath, username);
|
||||
}
|
||||
if (groupStore == null) {
|
||||
groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
|
||||
}
|
||||
|
||||
JsonNode contactStoreNode = rootNode.get("contactStore");
|
||||
if (contactStoreNode != null) {
|
||||
contactStore = jsonProcessor.convertValue(contactStoreNode, JsonContactsStore.class);
|
||||
}
|
||||
if (contactStore == null) {
|
||||
contactStore = new JsonContactsStore();
|
||||
}
|
||||
|
||||
JsonNode recipientStoreNode = rootNode.get("recipientStore");
|
||||
if (recipientStoreNode != null) {
|
||||
recipientStore = jsonProcessor.convertValue(recipientStoreNode, RecipientStore.class);
|
||||
}
|
||||
if (recipientStore == null) {
|
||||
recipientStore = new RecipientStore();
|
||||
|
||||
recipientStore.resolveServiceAddress(getSelfAddress());
|
||||
|
||||
for (ContactInfo contact : contactStore.getContacts()) {
|
||||
recipientStore.resolveServiceAddress(contact.getAddress());
|
||||
}
|
||||
|
||||
for (GroupInfo group : groupStore.getGroups()) {
|
||||
if (group instanceof GroupInfoV1) {
|
||||
GroupInfoV1 groupInfoV1 = (GroupInfoV1) group;
|
||||
groupInfoV1.members = groupInfoV1.members.stream()
|
||||
.map(m -> recipientStore.resolveServiceAddress(m))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
||||
for (SessionInfo session : signalProtocolStore.getSessions()) {
|
||||
session.address = recipientStore.resolveServiceAddress(session.address);
|
||||
}
|
||||
|
||||
for (IdentityInfo identity : signalProtocolStore.getIdentities()) {
|
||||
identity.setAddress(recipientStore.resolveServiceAddress(identity.getAddress()));
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode profileStoreNode = rootNode.get("profileStore");
|
||||
if (profileStoreNode != null) {
|
||||
profileStore = jsonProcessor.convertValue(profileStoreNode, ProfileStore.class);
|
||||
}
|
||||
if (profileStore == null) {
|
||||
profileStore = new ProfileStore();
|
||||
}
|
||||
|
||||
JsonNode stickerStoreNode = rootNode.get("stickerStore");
|
||||
if (stickerStoreNode != null) {
|
||||
stickerStore = jsonProcessor.convertValue(stickerStoreNode, StickerStore.class);
|
||||
}
|
||||
if (stickerStore == null) {
|
||||
stickerStore = new StickerStore();
|
||||
}
|
||||
|
||||
messageCache = new MessageCache(getMessageCachePath(dataPath, username));
|
||||
|
||||
JsonNode threadStoreNode = rootNode.get("threadStore");
|
||||
if (threadStoreNode != null && !threadStoreNode.isNull()) {
|
||||
LegacyJsonThreadStore threadStore = jsonProcessor.convertValue(threadStoreNode,
|
||||
LegacyJsonThreadStore.class);
|
||||
// Migrate thread info to group and contact store
|
||||
for (ThreadInfo thread : threadStore.getThreads()) {
|
||||
if (thread.id == null || thread.id.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ContactInfo contactInfo = contactStore.getContact(new SignalServiceAddress(null, thread.id));
|
||||
if (contactInfo != null) {
|
||||
contactInfo.messageExpirationTime = thread.messageExpirationTime;
|
||||
contactStore.updateContact(contactInfo);
|
||||
} else {
|
||||
GroupInfo groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id));
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
|
||||
groupStore.updateGroup(groupInfo);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void save() {
|
||||
if (fileChannel == null) {
|
||||
return;
|
||||
}
|
||||
ObjectNode rootNode = jsonProcessor.createObjectNode();
|
||||
rootNode.put("username", username)
|
||||
.put("uuid", uuid == null ? null : uuid.toString())
|
||||
.put("deviceId", deviceId)
|
||||
.put("isMultiDevice", isMultiDevice)
|
||||
.put("password", password)
|
||||
.put("registrationLockPin", registrationLockPin)
|
||||
.put("pinMasterKey",
|
||||
pinMasterKey == null ? null : Base64.getEncoder().encodeToString(pinMasterKey.serialize()))
|
||||
.put("storageKey",
|
||||
storageKey == null ? null : Base64.getEncoder().encodeToString(storageKey.serialize()))
|
||||
.put("signalingKey", signalingKey)
|
||||
.put("preKeyIdOffset", preKeyIdOffset)
|
||||
.put("nextSignedPreKeyId", nextSignedPreKeyId)
|
||||
.put("profileKey", Base64.getEncoder().encodeToString(profileKey.serialize()))
|
||||
.put("registered", registered)
|
||||
.putPOJO("axolotlStore", signalProtocolStore)
|
||||
.putPOJO("groupStore", groupStore)
|
||||
.putPOJO("contactStore", contactStore)
|
||||
.putPOJO("recipientStore", recipientStore)
|
||||
.putPOJO("profileStore", profileStore)
|
||||
.putPOJO("stickerStore", stickerStore);
|
||||
try {
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
// Write to memory first to prevent corrupting the file in case of serialization errors
|
||||
jsonProcessor.writeValue(output, rootNode);
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(output.toByteArray());
|
||||
synchronized (fileChannel) {
|
||||
fileChannel.position(0);
|
||||
input.transferTo(Channels.newOutputStream(fileChannel));
|
||||
fileChannel.truncate(fileChannel.position());
|
||||
fileChannel.force(false);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error saving file: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
|
||||
FileChannel fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
|
||||
FileLock lock = fileChannel.tryLock();
|
||||
if (lock == null) {
|
||||
logger.info("Config file is in use by another instance, waiting…");
|
||||
lock = fileChannel.lock();
|
||||
logger.info("Config file lock acquired.");
|
||||
}
|
||||
return new Pair<>(fileChannel, lock);
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
signalProtocolStore.setResolver(resolver);
|
||||
}
|
||||
|
||||
public void addPreKeys(Collection<PreKeyRecord> records) {
|
||||
for (PreKeyRecord record : records) {
|
||||
signalProtocolStore.storePreKey(record.getId(), record);
|
||||
}
|
||||
preKeyIdOffset = (preKeyIdOffset + records.size()) % Medium.MAX_VALUE;
|
||||
}
|
||||
|
||||
public void addSignedPreKey(SignedPreKeyRecord record) {
|
||||
signalProtocolStore.storeSignedPreKey(record.getId(), record);
|
||||
nextSignedPreKeyId = (nextSignedPreKeyId + 1) % Medium.MAX_VALUE;
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore getSignalProtocolStore() {
|
||||
return signalProtocolStore;
|
||||
}
|
||||
|
||||
public JsonGroupStore getGroupStore() {
|
||||
return groupStore;
|
||||
}
|
||||
|
||||
public JsonContactsStore getContactStore() {
|
||||
return contactStore;
|
||||
}
|
||||
|
||||
public RecipientStore getRecipientStore() {
|
||||
return recipientStore;
|
||||
}
|
||||
|
||||
public ProfileStore getProfileStore() {
|
||||
return profileStore;
|
||||
}
|
||||
|
||||
public StickerStore getStickerStore() {
|
||||
return stickerStore;
|
||||
}
|
||||
|
||||
public MessageCache getMessageCache() {
|
||||
return messageCache;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public void setUuid(final UUID uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getSelfAddress() {
|
||||
return new SignalServiceAddress(uuid, username);
|
||||
}
|
||||
|
||||
public int getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public void setDeviceId(final int deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public boolean isMasterDevice() {
|
||||
return deviceId == SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getRegistrationLockPin() {
|
||||
return registrationLockPin;
|
||||
}
|
||||
|
||||
public void setRegistrationLockPin(final String registrationLockPin) {
|
||||
this.registrationLockPin = registrationLockPin;
|
||||
}
|
||||
|
||||
public MasterKey getPinMasterKey() {
|
||||
return pinMasterKey;
|
||||
}
|
||||
|
||||
public void setPinMasterKey(final MasterKey pinMasterKey) {
|
||||
this.pinMasterKey = pinMasterKey;
|
||||
}
|
||||
|
||||
public StorageKey getStorageKey() {
|
||||
if (pinMasterKey != null) {
|
||||
return pinMasterKey.deriveStorageServiceKey();
|
||||
}
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
public void setStorageKey(final StorageKey storageKey) {
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
public String getSignalingKey() {
|
||||
return signalingKey;
|
||||
}
|
||||
|
||||
public void setSignalingKey(final String signalingKey) {
|
||||
this.signalingKey = signalingKey;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public void setProfileKey(final ProfileKey profileKey) {
|
||||
this.profileKey = profileKey;
|
||||
}
|
||||
|
||||
public byte[] getSelfUnidentifiedAccessKey() {
|
||||
return UnidentifiedAccess.deriveAccessKeyFrom(getProfileKey());
|
||||
}
|
||||
|
||||
public int getPreKeyIdOffset() {
|
||||
return preKeyIdOffset;
|
||||
}
|
||||
|
||||
public int getNextSignedPreKeyId() {
|
||||
return nextSignedPreKeyId;
|
||||
}
|
||||
|
||||
public boolean isRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public void setRegistered(final boolean registered) {
|
||||
this.registered = registered;
|
||||
}
|
||||
|
||||
public boolean isMultiDevice() {
|
||||
return isMultiDevice;
|
||||
}
|
||||
|
||||
public void setMultiDevice(final boolean multiDevice) {
|
||||
isMultiDevice = multiDevice;
|
||||
}
|
||||
|
||||
public boolean isUnrestrictedUnidentifiedAccess() {
|
||||
// TODO make configurable
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isDiscoverableByPhoneNumber() {
|
||||
// TODO make configurable
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (fileChannel.isOpen()) {
|
||||
save();
|
||||
}
|
||||
synchronized (fileChannel) {
|
||||
try {
|
||||
lock.close();
|
||||
} catch (ClosedChannelException ignored) {
|
||||
}
|
||||
fileChannel.close();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package org.asamk.signal.manager.storage.contacts;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonProperty.Access.WRITE_ONLY;
|
||||
|
||||
public class ContactInfo {
|
||||
|
||||
@JsonProperty
|
||||
public String name;
|
||||
|
||||
@JsonProperty
|
||||
public String number;
|
||||
|
||||
@JsonProperty
|
||||
public UUID uuid;
|
||||
|
||||
@JsonProperty
|
||||
public String color;
|
||||
|
||||
@JsonProperty(defaultValue = "0")
|
||||
public int messageExpirationTime;
|
||||
|
||||
@JsonProperty(access = WRITE_ONLY)
|
||||
public String profileKey;
|
||||
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean blocked;
|
||||
|
||||
@JsonProperty
|
||||
public Integer inboxPosition;
|
||||
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean archived;
|
||||
|
||||
public ContactInfo() {
|
||||
}
|
||||
|
||||
public ContactInfo(SignalServiceAddress address) {
|
||||
this.number = address.getNumber().orNull();
|
||||
this.uuid = address.getUuid().orNull();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public SignalServiceAddress getAddress() {
|
||||
return new SignalServiceAddress(uuid, number);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package org.asamk.signal.manager.storage.contacts;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class JsonContactsStore {
|
||||
|
||||
@JsonProperty("contacts")
|
||||
private List<ContactInfo> contacts = new ArrayList<>();
|
||||
|
||||
public void updateContact(ContactInfo contact) {
|
||||
final SignalServiceAddress contactAddress = contact.getAddress();
|
||||
for (int i = 0; i < contacts.size(); i++) {
|
||||
if (contacts.get(i).getAddress().matches(contactAddress)) {
|
||||
contacts.set(i, contact);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
contacts.add(contact);
|
||||
}
|
||||
|
||||
public ContactInfo getContact(SignalServiceAddress address) {
|
||||
for (ContactInfo contact : contacts) {
|
||||
if (contact.getAddress().matches(address)) {
|
||||
if (contact.uuid == null) {
|
||||
contact.uuid = address.getUuid().orNull();
|
||||
} else if (contact.number == null) {
|
||||
contact.number = address.getNumber().orNull();
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<ContactInfo> getContacts() {
|
||||
return new ArrayList<>(contacts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all contacts from the store
|
||||
*/
|
||||
public void clear() {
|
||||
contacts.clear();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public abstract class GroupInfo {
|
||||
|
||||
@JsonIgnore
|
||||
public abstract GroupId getGroupId();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract String getTitle();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract GroupInviteLinkUrl getGroupInviteLink();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract Set<SignalServiceAddress> getMembers();
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public abstract boolean isBlocked();
|
||||
|
||||
@JsonIgnore
|
||||
public abstract void setBlocked(boolean blocked);
|
||||
|
||||
@JsonIgnore
|
||||
public abstract int getMessageExpirationTime();
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
|
||||
return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
|
||||
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
|
||||
.filter(member -> !member.matches(address))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isMember(SignalServiceAddress address) {
|
||||
for (SignalServiceAddress member : getMembers()) {
|
||||
if (member.matches(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isPendingMember(SignalServiceAddress address) {
|
||||
for (SignalServiceAddress member : getPendingMembers()) {
|
||||
if (member.matches(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class GroupInfoV1 extends GroupInfo {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
private final GroupIdV1 groupId;
|
||||
|
||||
private GroupIdV2 expectedV2Id;
|
||||
|
||||
@JsonProperty
|
||||
public String name;
|
||||
|
||||
@JsonProperty
|
||||
@JsonDeserialize(using = MembersDeserializer.class)
|
||||
@JsonSerialize(using = MembersSerializer.class)
|
||||
public Set<SignalServiceAddress> members = new HashSet<>();
|
||||
@JsonProperty
|
||||
public String color;
|
||||
@JsonProperty(defaultValue = "0")
|
||||
public int messageExpirationTime;
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean blocked;
|
||||
@JsonProperty
|
||||
public Integer inboxPosition;
|
||||
@JsonProperty(defaultValue = "false")
|
||||
public boolean archived;
|
||||
|
||||
public GroupInfoV1(GroupIdV1 groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public GroupInfoV1(
|
||||
@JsonProperty("groupId") byte[] groupId,
|
||||
@JsonProperty("expectedV2Id") byte[] expectedV2Id,
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("members") Collection<SignalServiceAddress> members,
|
||||
@JsonProperty("avatarId") long _ignored_avatarId,
|
||||
@JsonProperty("color") String color,
|
||||
@JsonProperty("blocked") boolean blocked,
|
||||
@JsonProperty("inboxPosition") Integer inboxPosition,
|
||||
@JsonProperty("archived") boolean archived,
|
||||
@JsonProperty("messageExpirationTime") int messageExpirationTime,
|
||||
@JsonProperty("active") boolean _ignored_active
|
||||
) {
|
||||
this.groupId = GroupId.v1(groupId);
|
||||
this.expectedV2Id = GroupId.v2(expectedV2Id);
|
||||
this.name = name;
|
||||
this.members.addAll(members);
|
||||
this.color = color;
|
||||
this.blocked = blocked;
|
||||
this.inboxPosition = inboxPosition;
|
||||
this.archived = archived;
|
||||
this.messageExpirationTime = messageExpirationTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public GroupIdV1 getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
@JsonProperty("groupId")
|
||||
private byte[] getGroupIdJackson() {
|
||||
return groupId.serialize();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public GroupIdV2 getExpectedV2Id() {
|
||||
if (expectedV2Id == null) {
|
||||
expectedV2Id = GroupUtils.getGroupIdV2(groupId);
|
||||
}
|
||||
return expectedV2Id;
|
||||
}
|
||||
|
||||
@JsonProperty("expectedV2Id")
|
||||
private byte[] getExpectedV2IdJackson() {
|
||||
return getExpectedV2Id().serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupInviteLinkUrl getGroupInviteLink() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Set<SignalServiceAddress> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageExpirationTime() {
|
||||
return messageExpirationTime;
|
||||
}
|
||||
|
||||
public void addMembers(Collection<SignalServiceAddress> addresses) {
|
||||
for (SignalServiceAddress address : addresses) {
|
||||
if (this.members.contains(address)) {
|
||||
continue;
|
||||
}
|
||||
removeMember(address);
|
||||
this.members.add(address);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMember(SignalServiceAddress address) {
|
||||
this.members.removeIf(member -> member.matches(address));
|
||||
}
|
||||
|
||||
private static final class JsonSignalServiceAddress {
|
||||
|
||||
@JsonProperty
|
||||
private UUID uuid;
|
||||
|
||||
@JsonProperty
|
||||
private String number;
|
||||
|
||||
JsonSignalServiceAddress(@JsonProperty("uuid") final UUID uuid, @JsonProperty("number") final String number) {
|
||||
this.uuid = uuid;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
JsonSignalServiceAddress(SignalServiceAddress address) {
|
||||
this.uuid = address.getUuid().orNull();
|
||||
this.number = address.getNumber().orNull();
|
||||
}
|
||||
|
||||
SignalServiceAddress toSignalServiceAddress() {
|
||||
return new SignalServiceAddress(uuid, number);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeStartArray(value.size());
|
||||
for (SignalServiceAddress address : value) {
|
||||
if (address.getUuid().isPresent()) {
|
||||
jgen.writeObject(new JsonSignalServiceAddress(address));
|
||||
} else {
|
||||
jgen.writeString(address.getNumber().get());
|
||||
}
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
if (n.isTextual()) {
|
||||
addresses.add(new SignalServiceAddress(null, n.textValue()));
|
||||
} else {
|
||||
JsonSignalServiceAddress address = jsonProcessor.treeToValue(n, JsonSignalServiceAddress.class);
|
||||
addresses.add(address.toSignalServiceAddress());
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.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;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class GroupInfoV2 extends GroupInfo {
|
||||
|
||||
private final GroupIdV2 groupId;
|
||||
private final GroupMasterKey masterKey;
|
||||
|
||||
private boolean blocked;
|
||||
private DecryptedGroup group; // stored as a file with hexadecimal groupId as name
|
||||
|
||||
public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) {
|
||||
this.groupId = groupId;
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupIdV2 getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public GroupMasterKey getMasterKey() {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
public void setGroup(final DecryptedGroup group) {
|
||||
this.group = group;
|
||||
}
|
||||
|
||||
public DecryptedGroup getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
if (this.group == null) {
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getPendingMembers() {
|
||||
if (this.group == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getPendingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> getRequestingMembers() {
|
||||
if (this.group == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return group.getRequestingMembersList()
|
||||
.stream()
|
||||
.map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBlocked(final boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMessageExpirationTime() {
|
||||
return this.group != null && this.group.hasDisappearingMessagesTimer()
|
||||
? this.group.getDisappearingMessagesTimer().getDuration()
|
||||
: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.util.Hex;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class JsonGroupStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonGroupStore.class);
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
public File groupCachePath;
|
||||
|
||||
@JsonProperty("groups")
|
||||
@JsonSerialize(using = GroupsSerializer.class)
|
||||
@JsonDeserialize(using = GroupsDeserializer.class)
|
||||
private final Map<GroupId, GroupInfo> groups = new HashMap<>();
|
||||
|
||||
private JsonGroupStore() {
|
||||
}
|
||||
|
||||
public JsonGroupStore(final File groupCachePath) {
|
||||
this.groupCachePath = groupCachePath;
|
||||
}
|
||||
|
||||
public void updateGroup(GroupInfo group) {
|
||||
groups.put(group.getGroupId(), group);
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
|
||||
try {
|
||||
IOUtils.createPrivateDirectories(groupCachePath);
|
||||
try (FileOutputStream stream = new FileOutputStream(getGroupFile(group.getGroupId()))) {
|
||||
((GroupInfoV2) group).getGroup().writeTo(stream);
|
||||
}
|
||||
final File groupFileLegacy = getGroupFileLegacy(group.getGroupId());
|
||||
if (groupFileLegacy.exists()) {
|
||||
groupFileLegacy.delete();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(GroupId groupId) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
|
||||
public GroupInfo getGroup(GroupId groupId) {
|
||||
GroupInfo group = groups.get(groupId);
|
||||
if (group == null) {
|
||||
if (groupId instanceof GroupIdV1) {
|
||||
group = groups.get(GroupUtils.getGroupIdV2((GroupIdV1) groupId));
|
||||
} else if (groupId instanceof GroupIdV2) {
|
||||
group = getGroupV1ByV2Id((GroupIdV2) groupId);
|
||||
}
|
||||
}
|
||||
loadDecryptedGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
private GroupInfoV1 getGroupV1ByV2Id(GroupIdV2 groupIdV2) {
|
||||
for (GroupInfo g : groups.values()) {
|
||||
if (g instanceof GroupInfoV1) {
|
||||
final GroupInfoV1 gv1 = (GroupInfoV1) g;
|
||||
if (groupIdV2.equals(gv1.getExpectedV2Id())) {
|
||||
return gv1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadDecryptedGroup(final GroupInfo group) {
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
|
||||
File groupFile = getGroupFile(group.getGroupId());
|
||||
if (!groupFile.exists()) {
|
||||
groupFile = getGroupFileLegacy(group.getGroupId());
|
||||
}
|
||||
if (!groupFile.exists()) {
|
||||
return;
|
||||
}
|
||||
try (FileInputStream stream = new FileInputStream(groupFile)) {
|
||||
((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private File getGroupFileLegacy(final GroupId groupId) {
|
||||
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
|
||||
}
|
||||
|
||||
private File getGroupFile(final GroupId groupId) {
|
||||
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
|
||||
GroupInfo group = getGroup(groupId);
|
||||
if (group instanceof GroupInfoV1) {
|
||||
return (GroupInfoV1) group;
|
||||
}
|
||||
|
||||
if (group == null) {
|
||||
return new GroupInfoV1(groupId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<GroupInfo> getGroups() {
|
||||
final Collection<GroupInfo> groups = this.groups.values();
|
||||
for (GroupInfo group : groups) {
|
||||
loadDecryptedGroup(group);
|
||||
}
|
||||
return new ArrayList<>(groups);
|
||||
}
|
||||
|
||||
private static class GroupsSerializer extends JsonSerializer<Map<String, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final Collection<GroupInfo> groups = value.values();
|
||||
jgen.writeStartArray(groups.size());
|
||||
for (GroupInfo group : groups) {
|
||||
if (group instanceof GroupInfoV1) {
|
||||
jgen.writeObject(group);
|
||||
} else if (group instanceof GroupInfoV2) {
|
||||
final GroupInfoV2 groupV2 = (GroupInfoV2) group;
|
||||
jgen.writeStartObject();
|
||||
jgen.writeStringField("groupId", groupV2.getGroupId().toBase64());
|
||||
jgen.writeStringField("masterKey",
|
||||
Base64.getEncoder().encodeToString(groupV2.getMasterKey().serialize()));
|
||||
jgen.writeBooleanField("blocked", groupV2.isBlocked());
|
||||
jgen.writeEndObject();
|
||||
} else {
|
||||
throw new AssertionError("Unknown group version");
|
||||
}
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class GroupsDeserializer extends JsonDeserializer<Map<GroupId, GroupInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<GroupId, GroupInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<GroupId, GroupInfo> groups = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
GroupInfo g;
|
||||
if (n.hasNonNull("masterKey")) {
|
||||
// a v2 group
|
||||
GroupIdV2 groupId = GroupIdV2.fromBase64(n.get("groupId").asText());
|
||||
try {
|
||||
GroupMasterKey masterKey = new GroupMasterKey(Base64.getDecoder()
|
||||
.decode(n.get("masterKey").asText()));
|
||||
g = new GroupInfoV2(groupId, masterKey);
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
|
||||
}
|
||||
g.setBlocked(n.get("blocked").asBoolean(false));
|
||||
} else {
|
||||
g = jsonProcessor.treeToValue(n, GroupInfoV1.class);
|
||||
}
|
||||
groups.put(g.getGroupId(), g);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.asamk.signal.manager.storage.messageCache;
|
||||
|
||||
import org.asamk.signal.manager.util.MessageCacheUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public final class CachedMessage {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(CachedMessage.class);
|
||||
|
||||
private final File file;
|
||||
|
||||
CachedMessage(final File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public SignalServiceEnvelope loadEnvelope() {
|
||||
try {
|
||||
return MessageCacheUtils.loadEnvelope(file);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to load cached message envelope “{}”: {}", file, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void delete() {
|
||||
try {
|
||||
Files.delete(file.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to delete cached message file “{}”, ignoring: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package org.asamk.signal.manager.storage.messageCache;
|
||||
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.asamk.signal.manager.util.MessageCacheUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class MessageCache {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(MessageCache.class);
|
||||
|
||||
private final File messageCachePath;
|
||||
|
||||
public MessageCache(final File messageCachePath) {
|
||||
this.messageCachePath = messageCachePath;
|
||||
}
|
||||
|
||||
public Iterable<CachedMessage> getCachedMessages() {
|
||||
if (!messageCachePath.exists()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Arrays.stream(Objects.requireNonNull(messageCachePath.listFiles())).flatMap(dir -> {
|
||||
if (dir.isFile()) {
|
||||
return Stream.of(dir);
|
||||
}
|
||||
|
||||
final File[] files = Objects.requireNonNull(dir.listFiles());
|
||||
if (files.length == 0) {
|
||||
try {
|
||||
Files.delete(dir.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to delete cache dir “{}”, ignoring: {}", dir, e.getMessage());
|
||||
}
|
||||
return Stream.empty();
|
||||
}
|
||||
return Arrays.stream(files).filter(File::isFile);
|
||||
}).map(CachedMessage::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public CachedMessage cacheMessage(SignalServiceEnvelope envelope) {
|
||||
final long now = new Date().getTime();
|
||||
final String source = envelope.hasSource() ? envelope.getSourceAddress().getLegacyIdentifier() : "";
|
||||
|
||||
try {
|
||||
File cacheFile = getMessageCacheFile(source, now, envelope.getTimestamp());
|
||||
MessageCacheUtils.storeEnvelope(envelope, cacheFile);
|
||||
return new CachedMessage(cacheFile);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to store encrypted message in disk cache, ignoring: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private File getMessageCachePath(String sender) {
|
||||
if (sender == null || sender.isEmpty()) {
|
||||
return messageCachePath;
|
||||
}
|
||||
|
||||
return new File(messageCachePath, sender.replace("/", "_"));
|
||||
}
|
||||
|
||||
private File getMessageCacheFile(String sender, long now, long timestamp) throws IOException {
|
||||
File cachePath = getMessageCachePath(sender);
|
||||
IOUtils.createPrivateDirectories(cachePath);
|
||||
return new File(cachePath, now + "_" + timestamp);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ProfileStore {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
@JsonProperty("profiles")
|
||||
@JsonDeserialize(using = ProfileStoreDeserializer.class)
|
||||
@JsonSerialize(using = ProfileStoreSerializer.class)
|
||||
private final List<SignalProfileEntry> profiles = new ArrayList<>();
|
||||
|
||||
public SignalProfileEntry getProfileEntry(SignalServiceAddress serviceAddress) {
|
||||
for (SignalProfileEntry entry : profiles) {
|
||||
if (entry.getServiceAddress().matches(serviceAddress)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey(SignalServiceAddress serviceAddress) {
|
||||
for (SignalProfileEntry entry : profiles) {
|
||||
if (entry.getServiceAddress().matches(serviceAddress)) {
|
||||
return entry.getProfileKey();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void updateProfile(
|
||||
SignalServiceAddress serviceAddress,
|
||||
ProfileKey profileKey,
|
||||
long now,
|
||||
SignalProfile profile,
|
||||
ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress,
|
||||
profileKey,
|
||||
now,
|
||||
profile,
|
||||
profileKeyCredential);
|
||||
for (int i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
profiles.set(i, newEntry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.add(newEntry);
|
||||
}
|
||||
|
||||
public void storeProfileKey(SignalServiceAddress serviceAddress, ProfileKey profileKey) {
|
||||
SignalProfileEntry newEntry = new SignalProfileEntry(serviceAddress, profileKey, 0, null, null);
|
||||
for (int i = 0; i < profiles.size(); i++) {
|
||||
if (profiles.get(i).getServiceAddress().matches(serviceAddress)) {
|
||||
if (!profiles.get(i).getProfileKey().equals(profileKey)) {
|
||||
profiles.set(i, newEntry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.add(newEntry);
|
||||
}
|
||||
|
||||
public static class ProfileStoreDeserializer extends JsonDeserializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public List<SignalProfileEntry> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
List<SignalProfileEntry> addresses = new ArrayList<>();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (JsonNode entry : node) {
|
||||
String name = entry.hasNonNull("name") ? entry.get("name").asText() : null;
|
||||
UUID uuid = entry.hasNonNull("uuid") ? UuidUtil.parseOrNull(entry.get("uuid").asText()) : null;
|
||||
final SignalServiceAddress serviceAddress = new SignalServiceAddress(uuid, name);
|
||||
ProfileKey profileKey = null;
|
||||
try {
|
||||
profileKey = new ProfileKey(Base64.getDecoder().decode(entry.get("profileKey").asText()));
|
||||
} catch (InvalidInputException ignored) {
|
||||
}
|
||||
ProfileKeyCredential profileKeyCredential = null;
|
||||
if (entry.hasNonNull("profileKeyCredential")) {
|
||||
try {
|
||||
profileKeyCredential = new ProfileKeyCredential(Base64.getDecoder()
|
||||
.decode(entry.get("profileKeyCredential").asText()));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
long lastUpdateTimestamp = entry.get("lastUpdateTimestamp").asLong();
|
||||
SignalProfile profile = jsonProcessor.treeToValue(entry.get("profile"), SignalProfile.class);
|
||||
addresses.add(new SignalProfileEntry(serviceAddress,
|
||||
profileKey,
|
||||
lastUpdateTimestamp,
|
||||
profile,
|
||||
profileKeyCredential));
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProfileStoreSerializer extends JsonSerializer<List<SignalProfileEntry>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
List<SignalProfileEntry> profiles, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SignalProfileEntry profileEntry : profiles) {
|
||||
final SignalServiceAddress address = profileEntry.getServiceAddress();
|
||||
json.writeStartObject();
|
||||
if (address.getNumber().isPresent()) {
|
||||
json.writeStringField("name", address.getNumber().get());
|
||||
}
|
||||
if (address.getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", address.getUuid().get().toString());
|
||||
}
|
||||
json.writeStringField("profileKey",
|
||||
Base64.getEncoder().encodeToString(profileEntry.getProfileKey().serialize()));
|
||||
json.writeNumberField("lastUpdateTimestamp", profileEntry.getLastUpdateTimestamp());
|
||||
json.writeObjectField("profile", profileEntry.getProfile());
|
||||
if (profileEntry.getProfileKeyCredential() != null) {
|
||||
json.writeStringField("profileKeyCredential",
|
||||
Base64.getEncoder().encodeToString(profileEntry.getProfileKeyCredential().serialize()));
|
||||
}
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
public class SignalProfile {
|
||||
|
||||
@JsonProperty
|
||||
private final String identityKey;
|
||||
|
||||
@JsonProperty
|
||||
private final String name;
|
||||
|
||||
@JsonProperty
|
||||
private final String unidentifiedAccess;
|
||||
|
||||
@JsonProperty
|
||||
private final boolean unrestrictedUnidentifiedAccess;
|
||||
|
||||
@JsonProperty
|
||||
private final Capabilities capabilities;
|
||||
|
||||
public SignalProfile(
|
||||
final String identityKey,
|
||||
final String name,
|
||||
final String unidentifiedAccess,
|
||||
final boolean unrestrictedUnidentifiedAccess,
|
||||
final SignalServiceProfile.Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.unidentifiedAccess = unidentifiedAccess;
|
||||
this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;
|
||||
this.capabilities = new Capabilities();
|
||||
this.capabilities.storage = capabilities.isStorage();
|
||||
this.capabilities.gv1Migration = capabilities.isGv1Migration();
|
||||
this.capabilities.gv2 = capabilities.isGv2();
|
||||
}
|
||||
|
||||
public SignalProfile(
|
||||
@JsonProperty("identityKey") final String identityKey,
|
||||
@JsonProperty("name") final String name,
|
||||
@JsonProperty("unidentifiedAccess") final String unidentifiedAccess,
|
||||
@JsonProperty("unrestrictedUnidentifiedAccess") final boolean unrestrictedUnidentifiedAccess,
|
||||
@JsonProperty("capabilities") final Capabilities capabilities
|
||||
) {
|
||||
this.identityKey = identityKey;
|
||||
this.name = name;
|
||||
this.unidentifiedAccess = unidentifiedAccess;
|
||||
this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
public String getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getUnidentifiedAccess() {
|
||||
return unidentifiedAccess;
|
||||
}
|
||||
|
||||
public boolean isUnrestrictedUnidentifiedAccess() {
|
||||
return unrestrictedUnidentifiedAccess;
|
||||
}
|
||||
|
||||
public Capabilities getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SignalProfile{"
|
||||
+ "identityKey='"
|
||||
+ identityKey
|
||||
+ '\''
|
||||
+ ", name='"
|
||||
+ name
|
||||
+ '\''
|
||||
+ ", avatarFile="
|
||||
+ ", unidentifiedAccess='"
|
||||
+ unidentifiedAccess
|
||||
+ '\''
|
||||
+ ", unrestrictedUnidentifiedAccess="
|
||||
+ unrestrictedUnidentifiedAccess
|
||||
+ ", capabilities="
|
||||
+ capabilities
|
||||
+ '}';
|
||||
}
|
||||
|
||||
public static class Capabilities {
|
||||
|
||||
@JsonIgnore
|
||||
public boolean uuid;
|
||||
|
||||
@JsonProperty
|
||||
public boolean gv2;
|
||||
|
||||
@JsonProperty
|
||||
public boolean storage;
|
||||
|
||||
@JsonProperty
|
||||
public boolean gv1Migration;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.storage.profiles;
|
||||
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public class SignalProfileEntry {
|
||||
|
||||
private final SignalServiceAddress serviceAddress;
|
||||
|
||||
private final ProfileKey profileKey;
|
||||
|
||||
private final long lastUpdateTimestamp;
|
||||
|
||||
private final SignalProfile profile;
|
||||
|
||||
private final ProfileKeyCredential profileKeyCredential;
|
||||
|
||||
private boolean requestPending;
|
||||
|
||||
public SignalProfileEntry(
|
||||
final SignalServiceAddress serviceAddress,
|
||||
final ProfileKey profileKey,
|
||||
final long lastUpdateTimestamp,
|
||||
final SignalProfile profile,
|
||||
final ProfileKeyCredential profileKeyCredential
|
||||
) {
|
||||
this.serviceAddress = serviceAddress;
|
||||
this.profileKey = profileKey;
|
||||
this.lastUpdateTimestamp = lastUpdateTimestamp;
|
||||
this.profile = profile;
|
||||
this.profileKeyCredential = profileKeyCredential;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getServiceAddress() {
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public long getLastUpdateTimestamp() {
|
||||
return lastUpdateTimestamp;
|
||||
}
|
||||
|
||||
public SignalProfile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public ProfileKeyCredential getProfileKeyCredential() {
|
||||
return profileKeyCredential;
|
||||
}
|
||||
|
||||
public boolean isRequestPending() {
|
||||
return requestPending;
|
||||
}
|
||||
|
||||
public void setRequestPending(final boolean requestPending) {
|
||||
this.requestPending = requestPending;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class IdentityInfo {
|
||||
|
||||
SignalServiceAddress address;
|
||||
IdentityKey identityKey;
|
||||
TrustLevel trustLevel;
|
||||
Date added;
|
||||
|
||||
public IdentityInfo(SignalServiceAddress address, IdentityKey identityKey, TrustLevel trustLevel) {
|
||||
this.address = address;
|
||||
this.identityKey = identityKey;
|
||||
this.trustLevel = trustLevel;
|
||||
this.added = new Date();
|
||||
}
|
||||
|
||||
IdentityInfo(SignalServiceAddress address, IdentityKey identityKey, TrustLevel trustLevel, Date added) {
|
||||
this.address = address;
|
||||
this.identityKey = identityKey;
|
||||
this.trustLevel = trustLevel;
|
||||
this.added = added;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(final SignalServiceAddress address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
boolean isTrusted() {
|
||||
return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED;
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return this.identityKey;
|
||||
}
|
||||
|
||||
public TrustLevel getTrustLevel() {
|
||||
return this.trustLevel;
|
||||
}
|
||||
|
||||
public Date getDateAdded() {
|
||||
return this.added;
|
||||
}
|
||||
|
||||
public byte[] getFingerprint() {
|
||||
return identityKey.getPublicKey().serialize();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,272 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.IdentityKeyStore;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class JsonIdentityKeyStore implements IdentityKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonIdentityKeyStore.class);
|
||||
|
||||
private final List<IdentityInfo> identities = new ArrayList<>();
|
||||
|
||||
private final IdentityKeyPair identityKeyPair;
|
||||
private final int localRegistrationId;
|
||||
|
||||
private SignalServiceAddressResolver resolver;
|
||||
|
||||
public JsonIdentityKeyStore(IdentityKeyPair identityKeyPair, int localRegistrationId) {
|
||||
this.identityKeyPair = identityKeyPair;
|
||||
this.localRegistrationId = localRegistrationId;
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
|
||||
if (resolver != null) {
|
||||
return resolver.resolveSignalServiceAddress(identifier);
|
||||
} else {
|
||||
return Utils.getSignalServiceAddressFromIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKeyPair getIdentityKeyPair() {
|
||||
return identityKeyPair;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLocalRegistrationId() {
|
||||
return localRegistrationId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
|
||||
return saveIdentity(resolveSignalServiceAddress(address.getName()),
|
||||
identityKey,
|
||||
TrustLevel.TRUSTED_UNVERIFIED,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given identityKey for the user name and sets the trustLevel and added timestamp.
|
||||
* If the identityKey already exists, the trustLevel and added timestamp are NOT updated.
|
||||
*
|
||||
* @param serviceAddress User address, i.e. phone number and/or uuid
|
||||
* @param identityKey The user's public key
|
||||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
* @param added Added timestamp, if null and the key is newly added, the current time is used.
|
||||
*/
|
||||
public boolean saveIdentity(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel, Date added
|
||||
) {
|
||||
for (IdentityInfo id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
|
||||
id.address = serviceAddress;
|
||||
}
|
||||
// Identity already exists, not updating the trust level
|
||||
return true;
|
||||
}
|
||||
|
||||
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, added != null ? added : new Date()));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trustLevel for the given identityKey for the user name.
|
||||
*
|
||||
* @param serviceAddress User address, i.e. phone number and/or uuid
|
||||
* @param identityKey The user's public key
|
||||
* @param trustLevel Level of trust: untrusted, trusted, trusted and verified
|
||||
*/
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
for (IdentityInfo id : identities) {
|
||||
if (!id.address.matches(serviceAddress) || !id.identityKey.equals(identityKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!id.address.getUuid().isPresent() || !id.address.getNumber().isPresent()) {
|
||||
id.address = serviceAddress;
|
||||
}
|
||||
id.trustLevel = trustLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
identities.add(new IdentityInfo(serviceAddress, identityKey, trustLevel, new Date()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
|
||||
// TODO implement possibility for different handling of incoming/outgoing trust decisions
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
boolean trustOnFirstUse = true;
|
||||
|
||||
for (IdentityInfo id : identities) {
|
||||
if (!id.address.matches(serviceAddress)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id.identityKey.equals(identityKey)) {
|
||||
return id.isTrusted();
|
||||
} else {
|
||||
trustOnFirstUse = false;
|
||||
}
|
||||
}
|
||||
|
||||
return trustOnFirstUse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKey getIdentity(SignalProtocolAddress address) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
IdentityInfo identity = getIdentity(serviceAddress);
|
||||
return identity == null ? null : identity.getIdentityKey();
|
||||
}
|
||||
|
||||
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
|
||||
long maxDate = 0;
|
||||
IdentityInfo maxIdentity = null;
|
||||
for (IdentityInfo id : this.identities) {
|
||||
if (!id.address.matches(serviceAddress)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final long time = id.getDateAdded().getTime();
|
||||
if (maxIdentity == null || maxDate <= time) {
|
||||
maxDate = time;
|
||||
maxIdentity = id;
|
||||
}
|
||||
}
|
||||
return maxIdentity;
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities() {
|
||||
// TODO deep copy
|
||||
return identities;
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
|
||||
List<IdentityInfo> identities = new ArrayList<>();
|
||||
for (IdentityInfo identity : this.identities) {
|
||||
if (identity.address.matches(serviceAddress)) {
|
||||
identities.add(identity);
|
||||
}
|
||||
}
|
||||
return identities;
|
||||
}
|
||||
|
||||
public static class JsonIdentityKeyStoreDeserializer extends JsonDeserializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonIdentityKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
int localRegistrationId = node.get("registrationId").asInt();
|
||||
IdentityKeyPair identityKeyPair = new IdentityKeyPair(Base64.getDecoder()
|
||||
.decode(node.get("identityKey").asText()));
|
||||
|
||||
JsonIdentityKeyStore keyStore = new JsonIdentityKeyStore(identityKeyPair, localRegistrationId);
|
||||
|
||||
JsonNode trustedKeysNode = node.get("trustedKeys");
|
||||
if (trustedKeysNode.isArray()) {
|
||||
for (JsonNode trustedKey : trustedKeysNode) {
|
||||
String trustedKeyName = trustedKey.hasNonNull("name") ? trustedKey.get("name").asText() : null;
|
||||
|
||||
if (UuidUtil.isUuid(trustedKeyName)) {
|
||||
// Ignore identities that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
UUID uuid = trustedKey.hasNonNull("uuid")
|
||||
? UuidUtil.parseOrNull(trustedKey.get("uuid").asText())
|
||||
: null;
|
||||
final SignalServiceAddress serviceAddress = uuid == null
|
||||
? Utils.getSignalServiceAddressFromIdentifier(trustedKeyName)
|
||||
: new SignalServiceAddress(uuid, trustedKeyName);
|
||||
try {
|
||||
IdentityKey id = new IdentityKey(Base64.getDecoder()
|
||||
.decode(trustedKey.get("identityKey").asText()), 0);
|
||||
TrustLevel trustLevel = trustedKey.hasNonNull("trustLevel") ? TrustLevel.fromInt(trustedKey.get(
|
||||
"trustLevel").asInt()) : TrustLevel.TRUSTED_UNVERIFIED;
|
||||
Date added = trustedKey.hasNonNull("addedTimestamp") ? new Date(trustedKey.get("addedTimestamp")
|
||||
.asLong()) : new Date();
|
||||
keyStore.saveIdentity(serviceAddress, id, trustLevel, added);
|
||||
} catch (InvalidKeyException e) {
|
||||
logger.warn("Error while decoding key for {}: {}", trustedKeyName, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonIdentityKeyStoreSerializer extends JsonSerializer<JsonIdentityKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonIdentityKeyStore jsonIdentityKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("registrationId", jsonIdentityKeyStore.getLocalRegistrationId());
|
||||
json.writeStringField("identityKey",
|
||||
Base64.getEncoder().encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().serialize()));
|
||||
json.writeStringField("identityPrivateKey",
|
||||
Base64.getEncoder()
|
||||
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPrivateKey().serialize()));
|
||||
json.writeStringField("identityPublicKey",
|
||||
Base64.getEncoder()
|
||||
.encodeToString(jsonIdentityKeyStore.getIdentityKeyPair().getPublicKey().serialize()));
|
||||
json.writeArrayFieldStart("trustedKeys");
|
||||
for (IdentityInfo trustedKey : jsonIdentityKeyStore.identities) {
|
||||
json.writeStartObject();
|
||||
if (trustedKey.getAddress().getNumber().isPresent()) {
|
||||
json.writeStringField("name", trustedKey.getAddress().getNumber().get());
|
||||
}
|
||||
if (trustedKey.getAddress().getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", trustedKey.getAddress().getUuid().get().toString());
|
||||
}
|
||||
json.writeStringField("identityKey",
|
||||
Base64.getEncoder().encodeToString(trustedKey.identityKey.serialize()));
|
||||
json.writeNumberField("trustLevel", trustedKey.trustLevel.ordinal());
|
||||
json.writeNumberField("addedTimestamp", trustedKey.added.getTime());
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
json.writeEndObject();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.PreKeyStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonPreKeyStore implements PreKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonPreKeyStore.class);
|
||||
|
||||
private final Map<Integer, byte[]> store = new HashMap<>();
|
||||
|
||||
public JsonPreKeyStore() {
|
||||
|
||||
}
|
||||
|
||||
private void addPreKeys(Map<Integer, byte[]> preKeys) {
|
||||
store.putAll(preKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
|
||||
try {
|
||||
if (!store.containsKey(preKeyId)) {
|
||||
throw new InvalidKeyIdException("No such prekeyrecord!");
|
||||
}
|
||||
|
||||
return new PreKeyRecord(store.get(preKeyId));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storePreKey(int preKeyId, PreKeyRecord record) {
|
||||
store.put(preKeyId, record.serialize());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsPreKey(int preKeyId) {
|
||||
return store.containsKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePreKey(int preKeyId) {
|
||||
store.remove(preKeyId);
|
||||
}
|
||||
|
||||
public static class JsonPreKeyStoreDeserializer extends JsonDeserializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Map<Integer, byte[]> preKeyMap = new HashMap<>();
|
||||
if (node.isArray()) {
|
||||
for (JsonNode preKey : node) {
|
||||
final int preKeyId = preKey.get("id").asInt();
|
||||
final byte[] preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
|
||||
preKeyMap.put(preKeyId, preKeyRecord);
|
||||
}
|
||||
}
|
||||
|
||||
JsonPreKeyStore keyStore = new JsonPreKeyStore();
|
||||
keyStore.addPreKeys(preKeyMap);
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonPreKeyStoreSerializer extends JsonSerializer<JsonPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (Map.Entry<Integer, byte[]> preKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("id", preKey.getKey());
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(preKey.getValue()));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.asamk.signal.manager.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.libsignal.state.SessionStore;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
class JsonSessionStore implements SessionStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonSessionStore.class);
|
||||
|
||||
private final List<SessionInfo> sessions = new ArrayList<>();
|
||||
|
||||
private SignalServiceAddressResolver resolver;
|
||||
|
||||
public JsonSessionStore() {
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
private SignalServiceAddress resolveSignalServiceAddress(String identifier) {
|
||||
if (resolver != null) {
|
||||
return resolver.resolveSignalServiceAddress(identifier);
|
||||
} else {
|
||||
return Utils.getSignalServiceAddressFromIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized SessionRecord loadSession(SignalProtocolAddress address) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (SessionInfo info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
try {
|
||||
return new SessionRecord(info.sessionRecord);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to load session, resetting session: {}", e.getMessage());
|
||||
final SessionRecord sessionRecord = new SessionRecord();
|
||||
info.sessionRecord = sessionRecord.serialize();
|
||||
return sessionRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SessionRecord();
|
||||
}
|
||||
|
||||
public synchronized List<SessionInfo> getSessions() {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<Integer> getSubDeviceSessions(String name) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(name);
|
||||
|
||||
List<Integer> deviceIds = new LinkedList<>();
|
||||
for (SessionInfo info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId != 1) {
|
||||
deviceIds.add(info.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return deviceIds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void storeSession(SignalProtocolAddress address, SessionRecord record) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (SessionInfo info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
if (!info.address.getUuid().isPresent() || !info.address.getNumber().isPresent()) {
|
||||
info.address = serviceAddress;
|
||||
}
|
||||
info.sessionRecord = record.serialize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.add(new SessionInfo(serviceAddress, address.getDeviceId(), record.serialize()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean containsSession(SignalProtocolAddress address) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
for (SessionInfo info : sessions) {
|
||||
if (info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteSession(SignalProtocolAddress address) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(address.getName());
|
||||
sessions.removeIf(info -> info.address.matches(serviceAddress) && info.deviceId == address.getDeviceId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void deleteAllSessions(String name) {
|
||||
SignalServiceAddress serviceAddress = resolveSignalServiceAddress(name);
|
||||
deleteAllSessions(serviceAddress);
|
||||
}
|
||||
|
||||
public synchronized void deleteAllSessions(SignalServiceAddress serviceAddress) {
|
||||
sessions.removeIf(info -> info.address.matches(serviceAddress));
|
||||
}
|
||||
|
||||
public static class JsonSessionStoreDeserializer extends JsonDeserializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public JsonSessionStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
JsonSessionStore sessionStore = new JsonSessionStore();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (JsonNode session : node) {
|
||||
String sessionName = session.hasNonNull("name") ? session.get("name").asText() : null;
|
||||
if (UuidUtil.isUuid(sessionName)) {
|
||||
// Ignore sessions that were incorrectly created with UUIDs as name
|
||||
continue;
|
||||
}
|
||||
|
||||
UUID uuid = session.hasNonNull("uuid") ? UuidUtil.parseOrNull(session.get("uuid").asText()) : null;
|
||||
final SignalServiceAddress serviceAddress = uuid == null
|
||||
? Utils.getSignalServiceAddressFromIdentifier(sessionName)
|
||||
: new SignalServiceAddress(uuid, sessionName);
|
||||
final int deviceId = session.get("deviceId").asInt();
|
||||
final byte[] record = Base64.getDecoder().decode(session.get("record").asText());
|
||||
SessionInfo sessionInfo = new SessionInfo(serviceAddress, deviceId, record);
|
||||
sessionStore.sessions.add(sessionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonSessionStoreSerializer extends JsonSerializer<JsonSessionStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonSessionStore jsonSessionStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SessionInfo sessionInfo : jsonSessionStore.sessions) {
|
||||
json.writeStartObject();
|
||||
if (sessionInfo.address.getNumber().isPresent()) {
|
||||
json.writeStringField("name", sessionInfo.address.getNumber().get());
|
||||
}
|
||||
if (sessionInfo.address.getUuid().isPresent()) {
|
||||
json.writeStringField("uuid", sessionInfo.address.getUuid().get().toString());
|
||||
}
|
||||
json.writeNumberField("deviceId", sessionInfo.deviceId);
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(sessionInfo.sessionRecord));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.asamk.signal.manager.TrustLevel;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.libsignal.state.SignalProtocolStore;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JsonSignalProtocolStore implements SignalProtocolStore {
|
||||
|
||||
@JsonProperty("preKeys")
|
||||
@JsonDeserialize(using = JsonPreKeyStore.JsonPreKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonPreKeyStore.JsonPreKeyStoreSerializer.class)
|
||||
private JsonPreKeyStore preKeyStore;
|
||||
|
||||
@JsonProperty("sessionStore")
|
||||
@JsonDeserialize(using = JsonSessionStore.JsonSessionStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonSessionStore.JsonSessionStoreSerializer.class)
|
||||
private JsonSessionStore sessionStore;
|
||||
|
||||
@JsonProperty("signedPreKeyStore")
|
||||
@JsonDeserialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonSignedPreKeyStore.JsonSignedPreKeyStoreSerializer.class)
|
||||
private JsonSignedPreKeyStore signedPreKeyStore;
|
||||
|
||||
@JsonProperty("identityKeyStore")
|
||||
@JsonDeserialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreDeserializer.class)
|
||||
@JsonSerialize(using = JsonIdentityKeyStore.JsonIdentityKeyStoreSerializer.class)
|
||||
private JsonIdentityKeyStore identityKeyStore;
|
||||
|
||||
public JsonSignalProtocolStore() {
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore(
|
||||
JsonPreKeyStore preKeyStore,
|
||||
JsonSessionStore sessionStore,
|
||||
JsonSignedPreKeyStore signedPreKeyStore,
|
||||
JsonIdentityKeyStore identityKeyStore
|
||||
) {
|
||||
this.preKeyStore = preKeyStore;
|
||||
this.sessionStore = sessionStore;
|
||||
this.signedPreKeyStore = signedPreKeyStore;
|
||||
this.identityKeyStore = identityKeyStore;
|
||||
}
|
||||
|
||||
public JsonSignalProtocolStore(IdentityKeyPair identityKeyPair, int registrationId) {
|
||||
preKeyStore = new JsonPreKeyStore();
|
||||
sessionStore = new JsonSessionStore();
|
||||
signedPreKeyStore = new JsonSignedPreKeyStore();
|
||||
this.identityKeyStore = new JsonIdentityKeyStore(identityKeyPair, registrationId);
|
||||
}
|
||||
|
||||
public void setResolver(final SignalServiceAddressResolver resolver) {
|
||||
sessionStore.setResolver(resolver);
|
||||
identityKeyStore.setResolver(resolver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKeyPair getIdentityKeyPair() {
|
||||
return identityKeyStore.getIdentityKeyPair();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLocalRegistrationId() {
|
||||
return identityKeyStore.getLocalRegistrationId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
|
||||
return identityKeyStore.saveIdentity(address, identityKey);
|
||||
}
|
||||
|
||||
public void saveIdentity(SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel) {
|
||||
identityKeyStore.saveIdentity(serviceAddress, identityKey, trustLevel, null);
|
||||
}
|
||||
|
||||
public void setIdentityTrustLevel(
|
||||
SignalServiceAddress serviceAddress, IdentityKey identityKey, TrustLevel trustLevel
|
||||
) {
|
||||
identityKeyStore.setIdentityTrustLevel(serviceAddress, identityKey, trustLevel);
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities() {
|
||||
return identityKeyStore.getIdentities();
|
||||
}
|
||||
|
||||
public List<IdentityInfo> getIdentities(SignalServiceAddress serviceAddress) {
|
||||
return identityKeyStore.getIdentities(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
|
||||
return identityKeyStore.isTrustedIdentity(address, identityKey, direction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityKey getIdentity(SignalProtocolAddress address) {
|
||||
return identityKeyStore.getIdentity(address);
|
||||
}
|
||||
|
||||
public IdentityInfo getIdentity(SignalServiceAddress serviceAddress) {
|
||||
return identityKeyStore.getIdentity(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
|
||||
return preKeyStore.loadPreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storePreKey(int preKeyId, PreKeyRecord record) {
|
||||
preKeyStore.storePreKey(preKeyId, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsPreKey(int preKeyId) {
|
||||
return preKeyStore.containsPreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePreKey(int preKeyId) {
|
||||
preKeyStore.removePreKey(preKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SessionRecord loadSession(SignalProtocolAddress address) {
|
||||
return sessionStore.loadSession(address);
|
||||
}
|
||||
|
||||
public List<SessionInfo> getSessions() {
|
||||
return sessionStore.getSessions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Integer> getSubDeviceSessions(String name) {
|
||||
return sessionStore.getSubDeviceSessions(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSession(SignalProtocolAddress address, SessionRecord record) {
|
||||
sessionStore.storeSession(address, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSession(SignalProtocolAddress address) {
|
||||
return sessionStore.containsSession(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSession(SignalProtocolAddress address) {
|
||||
sessionStore.deleteSession(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAllSessions(String name) {
|
||||
sessionStore.deleteAllSessions(name);
|
||||
}
|
||||
|
||||
public void deleteAllSessions(SignalServiceAddress serviceAddress) {
|
||||
sessionStore.deleteAllSessions(serviceAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
|
||||
return signedPreKeyStore.loadSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignedPreKeyRecord> loadSignedPreKeys() {
|
||||
return signedPreKeyStore.loadSignedPreKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
|
||||
signedPreKeyStore.storeSignedPreKey(signedPreKeyId, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSignedPreKey(int signedPreKeyId) {
|
||||
return signedPreKeyStore.containsSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSignedPreKey(int signedPreKeyId) {
|
||||
signedPreKeyStore.removeSignedPreKey(signedPreKeyId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.libsignal.InvalidKeyIdException;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
class JsonSignedPreKeyStore implements SignedPreKeyStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(JsonSignedPreKeyStore.class);
|
||||
|
||||
private final Map<Integer, byte[]> store = new HashMap<>();
|
||||
|
||||
public JsonSignedPreKeyStore() {
|
||||
|
||||
}
|
||||
|
||||
private void addSignedPreKeys(Map<Integer, byte[]> preKeys) {
|
||||
store.putAll(preKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
|
||||
try {
|
||||
if (!store.containsKey(signedPreKeyId)) {
|
||||
throw new InvalidKeyIdException("No such signedprekeyrecord! " + signedPreKeyId);
|
||||
}
|
||||
|
||||
return new SignedPreKeyRecord(store.get(signedPreKeyId));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SignedPreKeyRecord> loadSignedPreKeys() {
|
||||
try {
|
||||
List<SignedPreKeyRecord> results = new LinkedList<>();
|
||||
|
||||
for (byte[] serialized : store.values()) {
|
||||
results.add(new SignedPreKeyRecord(serialized));
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
|
||||
store.put(signedPreKeyId, record.serialize());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsSignedPreKey(int signedPreKeyId) {
|
||||
return store.containsKey(signedPreKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSignedPreKey(int signedPreKeyId) {
|
||||
store.remove(signedPreKeyId);
|
||||
}
|
||||
|
||||
public static class JsonSignedPreKeyStoreDeserializer extends JsonDeserializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public JsonSignedPreKeyStore deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Map<Integer, byte[]> preKeyMap = new HashMap<>();
|
||||
if (node.isArray()) {
|
||||
for (JsonNode preKey : node) {
|
||||
final int preKeyId = preKey.get("id").asInt();
|
||||
final byte[] preKeyRecord = Base64.getDecoder().decode(preKey.get("record").asText());
|
||||
preKeyMap.put(preKeyId, preKeyRecord);
|
||||
}
|
||||
}
|
||||
|
||||
JsonSignedPreKeyStore keyStore = new JsonSignedPreKeyStore();
|
||||
keyStore.addSignedPreKeys(preKeyMap);
|
||||
|
||||
return keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
public static class JsonSignedPreKeyStoreSerializer extends JsonSerializer<JsonSignedPreKeyStore> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
JsonSignedPreKeyStore jsonPreKeyStore, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (Map.Entry<Integer, byte[]> signedPreKey : jsonPreKeyStore.store.entrySet()) {
|
||||
json.writeStartObject();
|
||||
json.writeNumberField("id", signedPreKey.getKey());
|
||||
json.writeStringField("record", Base64.getEncoder().encodeToString(signedPreKey.getValue()));
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class RecipientStore {
|
||||
|
||||
@JsonProperty("recipientStore")
|
||||
@JsonDeserialize(using = RecipientStoreDeserializer.class)
|
||||
@JsonSerialize(using = RecipientStoreSerializer.class)
|
||||
private final Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
|
||||
public SignalServiceAddress resolveServiceAddress(SignalServiceAddress serviceAddress) {
|
||||
if (addresses.contains(serviceAddress)) {
|
||||
// If the Set already contains the exact address with UUID and Number,
|
||||
// we can just return it here.
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
for (SignalServiceAddress address : addresses) {
|
||||
if (address.matches(serviceAddress)) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceAddress.getNumber().isPresent() && serviceAddress.getUuid().isPresent()) {
|
||||
addresses.add(serviceAddress);
|
||||
}
|
||||
|
||||
return serviceAddress;
|
||||
}
|
||||
|
||||
public static class RecipientStoreDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public Set<SignalServiceAddress> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
|
||||
Set<SignalServiceAddress> addresses = new HashSet<>();
|
||||
|
||||
if (node.isArray()) {
|
||||
for (JsonNode recipient : node) {
|
||||
String recipientName = recipient.get("name").asText();
|
||||
UUID uuid = UuidUtil.parseOrThrow(recipient.get("uuid").asText());
|
||||
final SignalServiceAddress serviceAddress = new SignalServiceAddress(uuid, recipientName);
|
||||
addresses.add(serviceAddress);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecipientStoreSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
Set<SignalServiceAddress> addresses, JsonGenerator json, SerializerProvider serializerProvider
|
||||
) throws IOException {
|
||||
json.writeStartArray();
|
||||
for (SignalServiceAddress address : addresses) {
|
||||
json.writeStartObject();
|
||||
json.writeStringField("name", address.getNumber().get());
|
||||
json.writeStringField("uuid", address.getUuid().get().toString());
|
||||
json.writeEndObject();
|
||||
}
|
||||
json.writeEndArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public class SessionInfo {
|
||||
|
||||
public SignalServiceAddress address;
|
||||
|
||||
public int deviceId;
|
||||
|
||||
public byte[] sessionRecord;
|
||||
|
||||
public SessionInfo(final SignalServiceAddress address, final int deviceId, final byte[] sessionRecord) {
|
||||
this.address = address;
|
||||
this.deviceId = deviceId;
|
||||
this.sessionRecord = sessionRecord;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.asamk.signal.manager.storage.protocol;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
public interface SignalServiceAddressResolver {
|
||||
|
||||
/**
|
||||
* Get a SignalServiceAddress with number and/or uuid from an identifier name.
|
||||
*
|
||||
* @param identifier can be either a serialized uuid or a e164 phone number
|
||||
*/
|
||||
SignalServiceAddress resolveSignalServiceAddress(String identifier);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package org.asamk.signal.manager.storage.stickers;
|
||||
|
||||
public class Sticker {
|
||||
|
||||
private final byte[] packId;
|
||||
private final byte[] packKey;
|
||||
private boolean installed;
|
||||
|
||||
public Sticker(final byte[] packId, final byte[] packKey) {
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
}
|
||||
|
||||
public Sticker(final byte[] packId, final byte[] packKey, final boolean installed) {
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.installed = installed;
|
||||
}
|
||||
|
||||
public byte[] getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public byte[] getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return installed;
|
||||
}
|
||||
|
||||
public void setInstalled(final boolean installed) {
|
||||
this.installed = installed;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package org.asamk.signal.manager.storage.stickers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class StickerStore {
|
||||
|
||||
@JsonSerialize(using = StickersSerializer.class)
|
||||
@JsonDeserialize(using = StickersDeserializer.class)
|
||||
private final Map<byte[], Sticker> stickers = new HashMap<>();
|
||||
|
||||
public Sticker getSticker(byte[] packId) {
|
||||
return stickers.get(packId);
|
||||
}
|
||||
|
||||
public void updateSticker(Sticker sticker) {
|
||||
stickers.put(sticker.getPackId(), sticker);
|
||||
}
|
||||
|
||||
private static class StickersSerializer extends JsonSerializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<byte[], Sticker> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
final Collection<Sticker> stickers = value.values();
|
||||
jgen.writeStartArray(stickers.size());
|
||||
for (Sticker sticker : stickers) {
|
||||
jgen.writeStartObject();
|
||||
jgen.writeStringField("packId", Base64.getEncoder().encodeToString(sticker.getPackId()));
|
||||
jgen.writeStringField("packKey", Base64.getEncoder().encodeToString(sticker.getPackKey()));
|
||||
jgen.writeBooleanField("installed", sticker.isInstalled());
|
||||
jgen.writeEndObject();
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class StickersDeserializer extends JsonDeserializer<Map<byte[], Sticker>> {
|
||||
|
||||
@Override
|
||||
public Map<byte[], Sticker> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<byte[], Sticker> stickers = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
byte[] packId = Base64.getDecoder().decode(n.get("packId").asText());
|
||||
byte[] packKey = Base64.getDecoder().decode(n.get("packKey").asText());
|
||||
boolean installed = n.get("installed").asBoolean(false);
|
||||
stickers.put(packId, new Sticker(packId, packKey, installed));
|
||||
}
|
||||
|
||||
return stickers;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package org.asamk.signal.manager.storage.threads;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LegacyJsonThreadStore {
|
||||
|
||||
private static final ObjectMapper jsonProcessor = new ObjectMapper();
|
||||
|
||||
@JsonProperty("threads")
|
||||
@JsonSerialize(using = MapToListSerializer.class)
|
||||
@JsonDeserialize(using = ThreadsDeserializer.class)
|
||||
private Map<String, ThreadInfo> threads = new HashMap<>();
|
||||
|
||||
public List<ThreadInfo> getThreads() {
|
||||
return new ArrayList<>(threads.values());
|
||||
}
|
||||
|
||||
private static class MapToListSerializer extends JsonSerializer<Map<?, ?>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeObject(value.values());
|
||||
}
|
||||
}
|
||||
|
||||
private static class ThreadsDeserializer extends JsonDeserializer<Map<String, ThreadInfo>> {
|
||||
|
||||
@Override
|
||||
public Map<String, ThreadInfo> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
Map<String, ThreadInfo> threads = new HashMap<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (JsonNode n : node) {
|
||||
ThreadInfo t = jsonProcessor.treeToValue(n, ThreadInfo.class);
|
||||
threads.put(t.id, t);
|
||||
}
|
||||
|
||||
return threads;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.asamk.signal.manager.storage.threads;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class ThreadInfo {
|
||||
|
||||
@JsonProperty
|
||||
public String id;
|
||||
|
||||
@JsonProperty
|
||||
public int messageExpirationTime;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.AttachmentInvalidException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class AttachmentUtils {
|
||||
|
||||
public static List<SignalServiceAttachment> getSignalServiceAttachments(List<String> attachments) throws AttachmentInvalidException {
|
||||
List<SignalServiceAttachment> signalServiceAttachments = null;
|
||||
if (attachments != null) {
|
||||
signalServiceAttachments = new ArrayList<>(attachments.size());
|
||||
for (String attachment : attachments) {
|
||||
try {
|
||||
signalServiceAttachments.add(createAttachment(new File(attachment)));
|
||||
} catch (IOException e) {
|
||||
throw new AttachmentInvalidException(attachment, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return signalServiceAttachments;
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentStream createAttachment(File attachmentFile) throws IOException {
|
||||
final StreamDetails streamDetails = Utils.createStreamDetailsFromFile(attachmentFile);
|
||||
return createAttachment(streamDetails, Optional.of(attachmentFile.getName()));
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentStream createAttachment(
|
||||
StreamDetails streamDetails, Optional<String> name
|
||||
) {
|
||||
// TODO mabybe add a parameter to set the voiceNote, borderless, preview, width, height and caption option
|
||||
final long uploadTimestamp = System.currentTimeMillis();
|
||||
Optional<byte[]> preview = Optional.absent();
|
||||
Optional<String> caption = Optional.absent();
|
||||
Optional<String> blurHash = Optional.absent();
|
||||
final Optional<ResumableUploadSpec> resumableUploadSpec = Optional.absent();
|
||||
return new SignalServiceAttachmentStream(streamDetails.getStream(),
|
||||
streamDetails.getContentType(),
|
||||
streamDetails.getLength(),
|
||||
name,
|
||||
false,
|
||||
false,
|
||||
preview,
|
||||
0,
|
||||
0,
|
||||
uploadTimestamp,
|
||||
caption,
|
||||
blurHash,
|
||||
null,
|
||||
null,
|
||||
resumableUploadSpec);
|
||||
}
|
||||
}
|
76
lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
Normal file
76
lib/src/main/java/org/asamk/signal/manager/util/IOUtils.java
Normal file
|
@ -0,0 +1,76 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE;
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
|
||||
import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
|
||||
|
||||
public class IOUtils {
|
||||
|
||||
public static File createTempFile() throws IOException {
|
||||
final File tempFile = File.createTempFile("signal-cli_tmp_", ".tmp");
|
||||
tempFile.deleteOnExit();
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public static byte[] readFully(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
IOUtils.copyStream(in, baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
public static void createPrivateDirectories(File file) throws IOException {
|
||||
if (file.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Path path = file.toPath();
|
||||
try {
|
||||
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE, OWNER_EXECUTE);
|
||||
Files.createDirectories(path, PosixFilePermissions.asFileAttribute(perms));
|
||||
} catch (UnsupportedOperationException e) {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
public static void createPrivateFile(File path) throws IOException {
|
||||
final Path file = path.toPath();
|
||||
try {
|
||||
Set<PosixFilePermission> perms = EnumSet.of(OWNER_READ, OWNER_WRITE);
|
||||
Files.createFile(file, PosixFilePermissions.asFileAttribute(perms));
|
||||
} catch (UnsupportedOperationException e) {
|
||||
Files.createFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyFileToStream(File inputFile, OutputStream output) throws IOException {
|
||||
try (InputStream inputStream = new FileInputStream(inputFile)) {
|
||||
copyStream(inputStream, output);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyStream(InputStream input, OutputStream output) throws IOException {
|
||||
copyStream(input, output, 4096);
|
||||
}
|
||||
|
||||
public static void copyStream(InputStream input, OutputStream output, int bufferSize) throws IOException {
|
||||
byte[] buffer = new byte[bufferSize];
|
||||
int read;
|
||||
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECKeyPair;
|
||||
import org.whispersystems.libsignal.ecc.ECPrivateKey;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
import org.whispersystems.libsignal.util.Medium;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class KeyUtils {
|
||||
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
private KeyUtils() {
|
||||
}
|
||||
|
||||
public static IdentityKeyPair generateIdentityKeyPair() {
|
||||
ECKeyPair djbKeyPair = Curve.generateKeyPair();
|
||||
IdentityKey djbIdentityKey = new IdentityKey(djbKeyPair.getPublicKey());
|
||||
ECPrivateKey djbPrivateKey = djbKeyPair.getPrivateKey();
|
||||
|
||||
return new IdentityKeyPair(djbIdentityKey, djbPrivateKey);
|
||||
}
|
||||
|
||||
public static List<PreKeyRecord> generatePreKeyRecords(final int offset, final int batchSize) {
|
||||
List<PreKeyRecord> records = new ArrayList<>(batchSize);
|
||||
for (int i = 0; i < batchSize; i++) {
|
||||
int preKeyId = (offset + i) % Medium.MAX_VALUE;
|
||||
ECKeyPair keyPair = Curve.generateKeyPair();
|
||||
PreKeyRecord record = new PreKeyRecord(preKeyId, keyPair);
|
||||
|
||||
records.add(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public static SignedPreKeyRecord generateSignedPreKeyRecord(
|
||||
final IdentityKeyPair identityKeyPair, final int signedPreKeyId
|
||||
) {
|
||||
ECKeyPair keyPair = Curve.generateKeyPair();
|
||||
byte[] signature;
|
||||
try {
|
||||
signature = Curve.calculateSignature(identityKeyPair.getPrivateKey(), keyPair.getPublicKey().serialize());
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
return new SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature);
|
||||
}
|
||||
|
||||
public static String createSignalingKey() {
|
||||
return getSecret(52);
|
||||
}
|
||||
|
||||
public static ProfileKey createProfileKey() {
|
||||
try {
|
||||
return new ProfileKey(getSecretBytes(32));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError("Profile key is guaranteed to be 32 bytes here");
|
||||
}
|
||||
}
|
||||
|
||||
public static String createPassword() {
|
||||
return getSecret(18);
|
||||
}
|
||||
|
||||
public static byte[] createStickerUploadKey() {
|
||||
return getSecretBytes(32);
|
||||
}
|
||||
|
||||
public static MasterKey createMasterKey() {
|
||||
return MasterKey.createNew(secureRandom);
|
||||
}
|
||||
|
||||
private static String getSecret(int size) {
|
||||
byte[] secret = getSecretBytes(size);
|
||||
return Base64.getEncoder().encodeToString(secret);
|
||||
}
|
||||
|
||||
public static byte[] getSecretBytes(int size) {
|
||||
byte[] secret = new byte[size];
|
||||
secureRandom.nextBytes(secret);
|
||||
return secret;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
public class MessageCacheUtils {
|
||||
|
||||
public static SignalServiceEnvelope loadEnvelope(File file) throws IOException {
|
||||
try (FileInputStream f = new FileInputStream(file)) {
|
||||
DataInputStream in = new DataInputStream(f);
|
||||
int version = in.readInt();
|
||||
if (version > 4) {
|
||||
return null;
|
||||
}
|
||||
int type = in.readInt();
|
||||
String source = in.readUTF();
|
||||
UUID sourceUuid = null;
|
||||
if (version >= 3) {
|
||||
sourceUuid = UuidUtil.parseOrNull(in.readUTF());
|
||||
}
|
||||
int sourceDevice = in.readInt();
|
||||
if (version == 1) {
|
||||
// read legacy relay field
|
||||
in.readUTF();
|
||||
}
|
||||
long timestamp = in.readLong();
|
||||
byte[] content = null;
|
||||
int contentLen = in.readInt();
|
||||
if (contentLen > 0) {
|
||||
content = new byte[contentLen];
|
||||
in.readFully(content);
|
||||
}
|
||||
byte[] legacyMessage = null;
|
||||
int legacyMessageLen = in.readInt();
|
||||
if (legacyMessageLen > 0) {
|
||||
legacyMessage = new byte[legacyMessageLen];
|
||||
in.readFully(legacyMessage);
|
||||
}
|
||||
long serverReceivedTimestamp = 0;
|
||||
String uuid = null;
|
||||
if (version >= 2) {
|
||||
serverReceivedTimestamp = in.readLong();
|
||||
uuid = in.readUTF();
|
||||
if ("".equals(uuid)) {
|
||||
uuid = null;
|
||||
}
|
||||
}
|
||||
long serverDeliveredTimestamp = 0;
|
||||
if (version >= 4) {
|
||||
serverDeliveredTimestamp = in.readLong();
|
||||
}
|
||||
Optional<SignalServiceAddress> addressOptional = sourceUuid == null && source.isEmpty()
|
||||
? Optional.absent()
|
||||
: Optional.of(new SignalServiceAddress(sourceUuid, source));
|
||||
return new SignalServiceEnvelope(type,
|
||||
addressOptional,
|
||||
sourceDevice,
|
||||
timestamp,
|
||||
legacyMessage,
|
||||
content,
|
||||
serverReceivedTimestamp,
|
||||
serverDeliveredTimestamp,
|
||||
uuid);
|
||||
}
|
||||
}
|
||||
|
||||
public static void storeEnvelope(SignalServiceEnvelope envelope, File file) throws IOException {
|
||||
try (FileOutputStream f = new FileOutputStream(file)) {
|
||||
try (DataOutputStream out = new DataOutputStream(f)) {
|
||||
out.writeInt(4); // version
|
||||
out.writeInt(envelope.getType());
|
||||
out.writeUTF(envelope.getSourceE164().isPresent() ? envelope.getSourceE164().get() : "");
|
||||
out.writeUTF(envelope.getSourceUuid().isPresent() ? envelope.getSourceUuid().get() : "");
|
||||
out.writeInt(envelope.getSourceDevice());
|
||||
out.writeLong(envelope.getTimestamp());
|
||||
if (envelope.hasContent()) {
|
||||
out.writeInt(envelope.getContent().length);
|
||||
out.write(envelope.getContent());
|
||||
} else {
|
||||
out.writeInt(0);
|
||||
}
|
||||
if (envelope.hasLegacyMessage()) {
|
||||
out.writeInt(envelope.getLegacyMessage().length);
|
||||
out.write(envelope.getLegacyMessage());
|
||||
} else {
|
||||
out.writeInt(0);
|
||||
}
|
||||
out.writeLong(envelope.getServerReceivedTimestamp());
|
||||
String uuid = envelope.getUuid();
|
||||
out.writeUTF(uuid == null ? "" : uuid);
|
||||
out.writeLong(envelope.getServerDeliveredTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters;
|
||||
import org.whispersystems.signalservice.api.KeyBackupService;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.whispersystems.signalservice.internal.registrationpin.PinHasher;
|
||||
|
||||
public final class PinHashing {
|
||||
|
||||
private PinHashing() {
|
||||
}
|
||||
|
||||
public static HashedPin hashPin(String pin, KeyBackupService.HashSession hashSession) {
|
||||
final Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id).withParallelism(1)
|
||||
.withIterations(32)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withMemoryAsKB(16 * 1024)
|
||||
.withSalt(hashSession.hashSalt())
|
||||
.build();
|
||||
|
||||
final Argon2BytesGenerator generator = new Argon2BytesGenerator();
|
||||
generator.init(params);
|
||||
|
||||
return PinHasher.hashPin(PinHasher.normalize(pin), password -> {
|
||||
byte[] output = new byte[64];
|
||||
generator.generateBytes(password, output);
|
||||
return output;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import org.asamk.signal.manager.storage.profiles.SignalProfile;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public class ProfileUtils {
|
||||
|
||||
public static SignalProfile decryptProfile(
|
||||
final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
|
||||
) {
|
||||
ProfileCipher profileCipher = new ProfileCipher(profileKey);
|
||||
try {
|
||||
String name;
|
||||
try {
|
||||
name = encryptedProfile.getName() == null
|
||||
? null
|
||||
: new String(profileCipher.decryptName(Base64.getDecoder().decode(encryptedProfile.getName())));
|
||||
} catch (IllegalArgumentException e) {
|
||||
name = null;
|
||||
}
|
||||
String unidentifiedAccess;
|
||||
try {
|
||||
unidentifiedAccess = encryptedProfile.getUnidentifiedAccess() == null
|
||||
|| !profileCipher.verifyUnidentifiedAccess(Base64.getDecoder()
|
||||
.decode(encryptedProfile.getUnidentifiedAccess()))
|
||||
? null
|
||||
: encryptedProfile.getUnidentifiedAccess();
|
||||
} catch (IllegalArgumentException e) {
|
||||
unidentifiedAccess = null;
|
||||
}
|
||||
return new SignalProfile(encryptedProfile.getIdentityKey(),
|
||||
name,
|
||||
unidentifiedAccess,
|
||||
encryptedProfile.isUnrestrictedUnidentifiedAccess(),
|
||||
encryptedProfile.getCapabilities());
|
||||
} catch (InvalidCiphertextException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.asamk.signal.manager.JsonStickerPack;
|
||||
import org.asamk.signal.manager.StickerPackInvalidException;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public class StickerUtils {
|
||||
|
||||
public static SignalServiceStickerManifestUpload getSignalServiceStickerManifestUpload(
|
||||
final File file
|
||||
) throws IOException, StickerPackInvalidException {
|
||||
ZipFile zip = null;
|
||||
String rootPath = null;
|
||||
|
||||
if (file.getName().endsWith(".zip")) {
|
||||
zip = new ZipFile(file);
|
||||
} else if (file.getName().equals("manifest.json")) {
|
||||
rootPath = file.getParent();
|
||||
} else {
|
||||
throw new StickerPackInvalidException("Could not find manifest.json");
|
||||
}
|
||||
|
||||
JsonStickerPack pack = parseStickerPack(rootPath, zip);
|
||||
|
||||
if (pack.stickers == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'stickers' field.");
|
||||
}
|
||||
|
||||
if (pack.stickers.isEmpty()) {
|
||||
throw new StickerPackInvalidException("Must include stickers.");
|
||||
}
|
||||
|
||||
List<SignalServiceStickerManifestUpload.StickerInfo> stickers = new ArrayList<>(pack.stickers.size());
|
||||
for (JsonStickerPack.JsonSticker sticker : pack.stickers) {
|
||||
if (sticker.file == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'file' field on each sticker.");
|
||||
}
|
||||
|
||||
Pair<InputStream, Long> data;
|
||||
try {
|
||||
data = getInputStreamAndLength(rootPath, zip, sticker.file);
|
||||
} catch (IOException ignored) {
|
||||
throw new StickerPackInvalidException("Could not find find " + sticker.file);
|
||||
}
|
||||
|
||||
String contentType = Utils.getFileMimeType(new File(sticker.file), null);
|
||||
SignalServiceStickerManifestUpload.StickerInfo stickerInfo = new SignalServiceStickerManifestUpload.StickerInfo(
|
||||
data.first(),
|
||||
data.second(),
|
||||
Optional.fromNullable(sticker.emoji).or(""),
|
||||
contentType);
|
||||
stickers.add(stickerInfo);
|
||||
}
|
||||
|
||||
SignalServiceStickerManifestUpload.StickerInfo cover = null;
|
||||
if (pack.cover != null) {
|
||||
if (pack.cover.file == null) {
|
||||
throw new StickerPackInvalidException("Must set a 'file' field on the cover.");
|
||||
}
|
||||
|
||||
Pair<InputStream, Long> data;
|
||||
try {
|
||||
data = getInputStreamAndLength(rootPath, zip, pack.cover.file);
|
||||
} catch (IOException ignored) {
|
||||
throw new StickerPackInvalidException("Could not find find " + pack.cover.file);
|
||||
}
|
||||
|
||||
String contentType = Utils.getFileMimeType(new File(pack.cover.file), null);
|
||||
cover = new SignalServiceStickerManifestUpload.StickerInfo(data.first(),
|
||||
data.second(),
|
||||
Optional.fromNullable(pack.cover.emoji).or(""),
|
||||
contentType);
|
||||
}
|
||||
|
||||
return new SignalServiceStickerManifestUpload(pack.title, pack.author, cover, stickers);
|
||||
}
|
||||
|
||||
private static JsonStickerPack parseStickerPack(String rootPath, ZipFile zip) throws IOException {
|
||||
InputStream inputStream;
|
||||
if (zip != null) {
|
||||
inputStream = zip.getInputStream(zip.getEntry("manifest.json"));
|
||||
} else {
|
||||
inputStream = new FileInputStream((new File(rootPath, "manifest.json")));
|
||||
}
|
||||
return new ObjectMapper().readValue(inputStream, JsonStickerPack.class);
|
||||
}
|
||||
|
||||
private static Pair<InputStream, Long> getInputStreamAndLength(
|
||||
final String rootPath, final ZipFile zip, final String subfile
|
||||
) throws IOException {
|
||||
if (zip != null) {
|
||||
final ZipEntry entry = zip.getEntry(subfile);
|
||||
return new Pair<>(zip.getInputStream(entry), entry.getSize());
|
||||
} else {
|
||||
final File file = new File(rootPath, subfile);
|
||||
return new Pair<>(new FileInputStream(file), file.length());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
94
lib/src/main/java/org/asamk/signal/manager/util/Utils.java
Normal file
94
lib/src/main/java/org/asamk/signal/manager/util/Utils.java
Normal file
|
@ -0,0 +1,94 @@
|
|||
package org.asamk.signal.manager.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.fingerprint.Fingerprint;
|
||||
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InvalidObjectException;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class Utils {
|
||||
|
||||
public static String getFileMimeType(File file, String defaultMimeType) throws IOException {
|
||||
String mime = Files.probeContentType(file.toPath());
|
||||
if (mime == null) {
|
||||
try (InputStream bufferedStream = new BufferedInputStream(new FileInputStream(file))) {
|
||||
mime = URLConnection.guessContentTypeFromStream(bufferedStream);
|
||||
}
|
||||
}
|
||||
if (mime == null) {
|
||||
return defaultMimeType;
|
||||
}
|
||||
return mime;
|
||||
}
|
||||
|
||||
public static StreamDetails createStreamDetailsFromFile(File file) throws IOException {
|
||||
InputStream stream = new FileInputStream(file);
|
||||
final long size = file.length();
|
||||
final String mime = getFileMimeType(file, "application/octet-stream");
|
||||
return new StreamDetails(stream, mime, size);
|
||||
}
|
||||
|
||||
public static String computeSafetyNumber(
|
||||
boolean isUuidCapable,
|
||||
SignalServiceAddress ownAddress,
|
||||
IdentityKey ownIdentityKey,
|
||||
SignalServiceAddress theirAddress,
|
||||
IdentityKey theirIdentityKey
|
||||
) {
|
||||
int version;
|
||||
byte[] ownId;
|
||||
byte[] theirId;
|
||||
|
||||
if (isUuidCapable && ownAddress.getUuid().isPresent() && theirAddress.getUuid().isPresent()) {
|
||||
// Version 2: UUID user
|
||||
version = 2;
|
||||
ownId = UuidUtil.toByteArray(ownAddress.getUuid().get());
|
||||
theirId = UuidUtil.toByteArray(theirAddress.getUuid().get());
|
||||
} else {
|
||||
// Version 1: E164 user
|
||||
version = 1;
|
||||
if (!ownAddress.getNumber().isPresent() || !theirAddress.getNumber().isPresent()) {
|
||||
return "INVALID ID";
|
||||
}
|
||||
ownId = ownAddress.getNumber().get().getBytes();
|
||||
theirId = theirAddress.getNumber().get().getBytes();
|
||||
}
|
||||
|
||||
Fingerprint fingerprint = new NumericFingerprintGenerator(5200).createFor(version,
|
||||
ownId,
|
||||
ownIdentityKey,
|
||||
theirId,
|
||||
theirIdentityKey);
|
||||
return fingerprint.getDisplayableFingerprint().getDisplayText();
|
||||
}
|
||||
|
||||
public static SignalServiceAddress getSignalServiceAddressFromIdentifier(final String identifier) {
|
||||
if (UuidUtil.isUuid(identifier)) {
|
||||
return new SignalServiceAddress(UuidUtil.parseOrNull(identifier), null);
|
||||
} else {
|
||||
return new SignalServiceAddress(null, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonNode getNotNullNode(JsonNode parent, String name) throws InvalidObjectException {
|
||||
JsonNode node = parent.get(name);
|
||||
if (node == null || node.isNull()) {
|
||||
throw new InvalidObjectException(String.format("Incorrect file format: expected parameter %s not found ",
|
||||
name));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
BIN
lib/src/main/resources/org/asamk/signal/manager/ias.store
Normal file
BIN
lib/src/main/resources/org/asamk/signal/manager/ias.store
Normal file
Binary file not shown.
BIN
lib/src/main/resources/org/asamk/signal/manager/whisper.store
Normal file
BIN
lib/src/main/resources/org/asamk/signal/manager/whisper.store
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue