mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-02 22:53:09 +01:00
Move libsignal-service up a directory.
This commit is contained in:
committed by
Cody Henthorne
parent
6134244244
commit
4968db750b
@@ -0,0 +1,6 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class CancelationException extends IOException {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
public class ContentTooLargeException extends IllegalStateException {
|
||||
public ContentTooLargeException(long size) {
|
||||
super("Too large! Size: " + size + " bytes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* An exception thrown when something about the proto is malformed. e.g. one of the fields has an invalid value.
|
||||
*/
|
||||
public final class InvalidMessageStructureException extends Exception {
|
||||
|
||||
private final Optional<String> sender;
|
||||
private final Optional<Integer> device;
|
||||
|
||||
public InvalidMessageStructureException(String message) {
|
||||
super(message);
|
||||
this.sender = Optional.empty();
|
||||
this.device = Optional.empty();
|
||||
}
|
||||
|
||||
public InvalidMessageStructureException(String message, String sender, int device) {
|
||||
super(message);
|
||||
this.sender = Optional.ofNullable(sender);
|
||||
this.device = Optional.of(device);
|
||||
}
|
||||
|
||||
public InvalidMessageStructureException(Exception e, String sender, int device) {
|
||||
super(e);
|
||||
this.sender = Optional.ofNullable(sender);
|
||||
this.device = Optional.of(device);
|
||||
}
|
||||
|
||||
public InvalidMessageStructureException(Exception e) {
|
||||
super(e);
|
||||
this.sender = Optional.empty();
|
||||
this.device = Optional.empty();
|
||||
}
|
||||
|
||||
public Optional<String> getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public Optional<Integer> getDevice() {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Wraps an [InvalidKeyException] in an [IOException] with a nicer message.
|
||||
*/
|
||||
class InvalidPreKeyException(
|
||||
address: SignalProtocolAddress,
|
||||
invalidKeyException: InvalidKeyException
|
||||
) : IOException("Invalid prekey for $address", invalidKeyException)
|
||||
@@ -0,0 +1,303 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.libsignal.svr2.PinHash;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.kbs.KbsData;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.KeyBackupCipher;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.Quote;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.RemoteAttestation;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.keybackup.protos.BackupResponse;
|
||||
import org.whispersystems.signalservice.internal.keybackup.protos.RestoreResponse;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.SignatureException;
|
||||
import java.util.Locale;
|
||||
|
||||
public class KeyBackupService {
|
||||
|
||||
private static final String TAG = KeyBackupService.class.getSimpleName();
|
||||
|
||||
private final KeyStore iasKeyStore;
|
||||
private final String enclaveName;
|
||||
private final byte[] serviceId;
|
||||
private final String mrenclave;
|
||||
private final PushServiceSocket pushServiceSocket;
|
||||
private final int maxTries;
|
||||
|
||||
KeyBackupService(KeyStore iasKeyStore,
|
||||
String enclaveName,
|
||||
byte[] serviceId,
|
||||
String mrenclave,
|
||||
PushServiceSocket pushServiceSocket,
|
||||
int maxTries)
|
||||
{
|
||||
this.iasKeyStore = iasKeyStore;
|
||||
this.enclaveName = enclaveName;
|
||||
this.serviceId = serviceId;
|
||||
this.mrenclave = mrenclave;
|
||||
this.pushServiceSocket = pushServiceSocket;
|
||||
this.maxTries = maxTries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this if you don't want to validate that the server has not changed since you last set the pin.
|
||||
*/
|
||||
public PinChangeSession newPinChangeSession()
|
||||
throws IOException
|
||||
{
|
||||
return newSession(pushServiceSocket.getKeyBackupServiceAuthorization().asBasic(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this if you want to validate that the server has not changed since you last set the pin.
|
||||
* The supplied token will have to match for the change to be successful.
|
||||
*/
|
||||
public PinChangeSession newPinChangeSession(TokenResponse currentToken)
|
||||
throws IOException
|
||||
{
|
||||
return newSession(pushServiceSocket.getKeyBackupServiceAuthorization().asBasic(), currentToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only call before registration, to see how many tries are left.
|
||||
* <p>
|
||||
* Pass the token to {@link #newRegistrationSession(String, TokenResponse)}.
|
||||
*/
|
||||
public TokenResponse getToken(String authAuthorization) throws IOException {
|
||||
return pushServiceSocket.getKeyBackupServiceToken(authAuthorization, enclaveName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the authorization token to be used with other requests.
|
||||
*/
|
||||
public AuthCredentials getAuthorization() throws IOException {
|
||||
return pushServiceSocket.getKeyBackupServiceAuthorization();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this during registration, good for one try, on subsequent attempts, pass the token from the previous attempt.
|
||||
*
|
||||
* @param tokenResponse Supplying a token response from a failed previous attempt prevents certain attacks.
|
||||
*/
|
||||
public RestoreSession newRegistrationSession(String authAuthorization, TokenResponse tokenResponse)
|
||||
throws IOException
|
||||
{
|
||||
return newSession(authAuthorization, tokenResponse);
|
||||
}
|
||||
|
||||
public String getEnclaveName() {
|
||||
return enclaveName;
|
||||
}
|
||||
|
||||
public String getMrenclave() {
|
||||
return mrenclave;
|
||||
}
|
||||
|
||||
private Session newSession(String authorization, TokenResponse currentToken)
|
||||
throws IOException
|
||||
{
|
||||
TokenResponse token = currentToken != null ? currentToken : pushServiceSocket.getKeyBackupServiceToken(authorization, enclaveName);
|
||||
|
||||
return new Session(authorization, token);
|
||||
}
|
||||
|
||||
private class Session implements RestoreSession, PinChangeSession {
|
||||
|
||||
private final String authorization;
|
||||
private final TokenResponse currentToken;
|
||||
|
||||
Session(String authorization, TokenResponse currentToken) {
|
||||
this.authorization = authorization;
|
||||
this.currentToken = currentToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] hashSalt() {
|
||||
return currentToken.getBackupId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SvrPinData restorePin(PinHash hashedPin)
|
||||
throws UnauthenticatedResponseException, IOException, KeyBackupServicePinException, SvrNoDataException, InvalidKeyException
|
||||
{
|
||||
int attempt = 0;
|
||||
SecureRandom random = new SecureRandom();
|
||||
TokenResponse token = currentToken;
|
||||
|
||||
while (true) {
|
||||
|
||||
attempt++;
|
||||
|
||||
try {
|
||||
return restorePin(hashedPin, token);
|
||||
} catch (TokenException tokenException) {
|
||||
|
||||
token = tokenException.getToken();
|
||||
|
||||
if (tokenException instanceof KeyBackupServicePinException) {
|
||||
throw (KeyBackupServicePinException) tokenException;
|
||||
}
|
||||
|
||||
if (tokenException.isCanAutomaticallyRetry() && attempt < 5) {
|
||||
// back off randomly, between 250 and 8000 ms
|
||||
int backoffMs = 250 * (1 << (attempt - 1));
|
||||
|
||||
Util.sleep(backoffMs + random.nextInt(backoffMs));
|
||||
} else {
|
||||
throw new UnauthenticatedResponseException("Token mismatch, expended all automatic retries");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SvrPinData restorePin(PinHash hashedPin, TokenResponse token)
|
||||
throws UnauthenticatedResponseException, IOException, TokenException, SvrNoDataException, InvalidKeyException
|
||||
{
|
||||
try {
|
||||
final int remainingTries = token.getTries();
|
||||
final RemoteAttestation remoteAttestation = getAndVerifyRemoteAttestation();
|
||||
final KeyBackupRequest request = KeyBackupCipher.createKeyRestoreRequest(hashedPin.accessKey(), token, remoteAttestation, serviceId);
|
||||
final KeyBackupResponse response = pushServiceSocket.putKbsData(authorization, request, remoteAttestation.getCookies(), enclaveName);
|
||||
final RestoreResponse status = KeyBackupCipher.getKeyRestoreResponse(response, remoteAttestation);
|
||||
|
||||
TokenResponse nextToken = status.token != null ? new TokenResponse(token.getBackupId(), status.token.toByteArray(), status.tries)
|
||||
: token;
|
||||
|
||||
Log.i(TAG, "Restore " + status.status);
|
||||
switch (status.status) {
|
||||
case OK:
|
||||
KbsData kbsData = PinHashUtil.decryptSvrDataIVCipherText(hashedPin, status.data_.toByteArray());
|
||||
MasterKey masterKey = kbsData.getMasterKey();
|
||||
return new SvrPinData(masterKey, nextToken);
|
||||
case PIN_MISMATCH:
|
||||
Log.i(TAG, "Restore PIN_MISMATCH");
|
||||
throw new KeyBackupServicePinException(nextToken);
|
||||
case TOKEN_MISMATCH:
|
||||
Log.i(TAG, "Restore TOKEN_MISMATCH");
|
||||
// if the number of tries has not fallen, the pin is correct we're just using an out of date token
|
||||
boolean canRetry = remainingTries == status.tries;
|
||||
Log.i(TAG, String.format(Locale.US, "Token MISMATCH remainingTries: %d, status.getTries(): %d", remainingTries, status.tries));
|
||||
throw new TokenException(nextToken, canRetry);
|
||||
case MISSING:
|
||||
Log.i(TAG, "Restore OK! No data though");
|
||||
throw new SvrNoDataException();
|
||||
case NOT_YET_VALID:
|
||||
throw new UnauthenticatedResponseException("Key is not valid yet, clock mismatch");
|
||||
default:
|
||||
throw new AssertionError("Unexpected case");
|
||||
}
|
||||
} catch (InvalidCiphertextException e) {
|
||||
throw new UnauthenticatedResponseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private RemoteAttestation getAndVerifyRemoteAttestation() throws UnauthenticatedResponseException, IOException, InvalidKeyException {
|
||||
try {
|
||||
return RemoteAttestationUtil.getAndVerifyRemoteAttestation(pushServiceSocket, PushServiceSocket.ClientSet.KeyBackup, iasKeyStore, enclaveName, mrenclave, authorization);
|
||||
} catch (Quote.InvalidQuoteFormatException | UnauthenticatedQuoteException | InvalidCiphertextException | SignatureException e) {
|
||||
throw new UnauthenticatedResponseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SvrPinData setPin(PinHash pinHash, MasterKey masterKey) throws IOException, UnauthenticatedResponseException {
|
||||
KbsData newKbsData = PinHashUtil.createNewKbsData(pinHash, masterKey);
|
||||
TokenResponse tokenResponse = putKbsData(newKbsData.getKbsAccessKey(),
|
||||
newKbsData.getCipherText(),
|
||||
enclaveName,
|
||||
currentToken);
|
||||
|
||||
return new SvrPinData(masterKey, tokenResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePin()
|
||||
throws IOException, UnauthenticatedResponseException
|
||||
{
|
||||
try {
|
||||
RemoteAttestation remoteAttestation = getAndVerifyRemoteAttestation();
|
||||
KeyBackupRequest request = KeyBackupCipher.createKeyDeleteRequest(currentToken, remoteAttestation, serviceId);
|
||||
KeyBackupResponse response = pushServiceSocket.putKbsData(authorization, request, remoteAttestation.getCookies(), enclaveName);
|
||||
|
||||
KeyBackupCipher.getKeyDeleteResponseStatus(response, remoteAttestation);
|
||||
} catch (InvalidCiphertextException | InvalidKeyException e) {
|
||||
throw new UnauthenticatedResponseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableRegistrationLock(MasterKey masterKey) throws IOException {
|
||||
pushServiceSocket.setRegistrationLockV2(masterKey.deriveRegistrationLock());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableRegistrationLock() throws IOException {
|
||||
pushServiceSocket.disableRegistrationLockV2();
|
||||
}
|
||||
|
||||
private TokenResponse putKbsData(byte[] kbsAccessKey, byte[] kbsData, String enclaveName, TokenResponse token)
|
||||
throws IOException, UnauthenticatedResponseException
|
||||
{
|
||||
try {
|
||||
RemoteAttestation remoteAttestation = getAndVerifyRemoteAttestation();
|
||||
KeyBackupRequest request = KeyBackupCipher.createKeyBackupRequest(kbsAccessKey, kbsData, token, remoteAttestation, serviceId, maxTries);
|
||||
KeyBackupResponse response = pushServiceSocket.putKbsData(authorization, request, remoteAttestation.getCookies(), enclaveName);
|
||||
BackupResponse backupResponse = KeyBackupCipher.getKeyBackupResponse(response, remoteAttestation);
|
||||
BackupResponse.Status status = backupResponse.status;
|
||||
|
||||
switch (status) {
|
||||
case OK:
|
||||
return backupResponse.token != null ? new TokenResponse(token.getBackupId(), backupResponse.token.toByteArray(), maxTries) : token;
|
||||
case ALREADY_EXISTS:
|
||||
throw new UnauthenticatedResponseException("Already exists");
|
||||
case NOT_YET_VALID:
|
||||
throw new UnauthenticatedResponseException("Key is not valid yet, clock mismatch");
|
||||
default:
|
||||
throw new AssertionError("Unknown response status " + status);
|
||||
}
|
||||
} catch (InvalidCiphertextException | InvalidKeyException e) {
|
||||
throw new UnauthenticatedResponseException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface HashSession {
|
||||
|
||||
byte[] hashSalt();
|
||||
}
|
||||
|
||||
public interface RestoreSession extends HashSession {
|
||||
|
||||
SvrPinData restorePin(PinHash hashedPin)
|
||||
throws UnauthenticatedResponseException, IOException, KeyBackupServicePinException, SvrNoDataException, InvalidKeyException;
|
||||
}
|
||||
|
||||
public interface PinChangeSession extends HashSession {
|
||||
/** Creates a PIN. Does nothing to registration lock. */
|
||||
SvrPinData setPin(PinHash hashedPin, MasterKey masterKey) throws IOException, UnauthenticatedResponseException;
|
||||
|
||||
/** Removes the PIN data from KBS. */
|
||||
void removePin() throws IOException, UnauthenticatedResponseException;
|
||||
|
||||
/** Enables registration lock. This assumes a PIN is set. */
|
||||
void enableRegistrationLock(MasterKey masterKey) throws IOException;
|
||||
|
||||
/** Disables registration lock. The user keeps their PIN. */
|
||||
void disableRegistrationLock() throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
public final class KeyBackupServicePinException extends TokenException {
|
||||
|
||||
private final int triesRemaining;
|
||||
|
||||
public KeyBackupServicePinException(TokenResponse nextToken) {
|
||||
super(nextToken, false);
|
||||
this.triesRemaining = nextToken.getTries();
|
||||
}
|
||||
|
||||
public int getTriesRemaining() {
|
||||
return triesRemaining;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
data class RemoteConfigResult(
|
||||
val config: Map<String, Any>,
|
||||
val serverEpochTimeSeconds: Long
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.state.SignalProtocolStore;
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol store interface that has additional methods that are needed
|
||||
* in the service layer, but not the protocol layer.
|
||||
*/
|
||||
public interface SignalServiceAccountDataStore extends SignalProtocolStore,
|
||||
SignalServicePreKeyStore,
|
||||
SignalServiceSessionStore,
|
||||
SignalServiceSenderKeyStore,
|
||||
SignalServiceKyberPreKeyStore {
|
||||
/**
|
||||
* @return True if the user has linked devices, otherwise false.
|
||||
*/
|
||||
boolean isMultiDevice();
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.libsignal.usernames.Username.UsernameLink;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest;
|
||||
import org.whispersystems.signalservice.api.account.PreKeyCollection;
|
||||
import org.whispersystems.signalservice.api.account.PreKeyUpload;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
|
||||
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
|
||||
import org.whispersystems.signalservice.api.payments.CurrencyConversions;
|
||||
import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NoContentException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.services.CdsiV2Service;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageCipher;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageModels;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.storage.StorageId;
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey;
|
||||
import org.whispersystems.signalservice.api.storage.StorageManifestKey;
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher;
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials;
|
||||
import org.whispersystems.signalservice.internal.push.BackupAuthCheckRequest;
|
||||
import org.whispersystems.signalservice.internal.push.BackupAuthCheckResponse;
|
||||
import org.whispersystems.signalservice.internal.push.CdsiAuthResponse;
|
||||
import org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts;
|
||||
import org.whispersystems.signalservice.internal.push.PaymentAddress;
|
||||
import org.whispersystems.signalservice.internal.push.ProfileAvatarData;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisioningVersion;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse;
|
||||
import org.whispersystems.signalservice.internal.push.RemoteConfigResponse;
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse;
|
||||
import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItem;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItems;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation;
|
||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
|
||||
import org.whispersystems.util.Base64;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import okio.ByteString;
|
||||
|
||||
/**
|
||||
* The main interface for creating, registering, and
|
||||
* managing a Signal Service account.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class SignalServiceAccountManager {
|
||||
|
||||
private static final String TAG = SignalServiceAccountManager.class.getSimpleName();
|
||||
|
||||
private static final int STORAGE_READ_MAX_ITEMS = 1000;
|
||||
|
||||
private final PushServiceSocket pushServiceSocket;
|
||||
private final CredentialsProvider credentials;
|
||||
private final String userAgent;
|
||||
private final GroupsV2Operations groupsV2Operations;
|
||||
private final SignalServiceConfiguration configuration;
|
||||
|
||||
|
||||
/**
|
||||
* Construct a SignalServiceAccountManager.
|
||||
* @param configuration The URL for the Signal Service.
|
||||
* @param aci The Signal Service ACI.
|
||||
* @param pni The Signal Service PNI.
|
||||
* @param e164 The Signal Service phone number.
|
||||
* @param password A Signal Service password.
|
||||
* @param signalAgent A string which identifies the client software.
|
||||
*/
|
||||
public SignalServiceAccountManager(SignalServiceConfiguration configuration,
|
||||
ACI aci,
|
||||
PNI pni,
|
||||
String e164,
|
||||
int deviceId,
|
||||
String password,
|
||||
String signalAgent,
|
||||
boolean automaticNetworkRetry,
|
||||
int maxGroupSize)
|
||||
{
|
||||
this(configuration,
|
||||
new StaticCredentialsProvider(aci, pni, e164, deviceId, password),
|
||||
signalAgent,
|
||||
new GroupsV2Operations(ClientZkOperations.create(configuration), maxGroupSize),
|
||||
automaticNetworkRetry);
|
||||
}
|
||||
|
||||
public SignalServiceAccountManager(SignalServiceConfiguration configuration,
|
||||
CredentialsProvider credentialsProvider,
|
||||
String signalAgent,
|
||||
GroupsV2Operations groupsV2Operations,
|
||||
boolean automaticNetworkRetry)
|
||||
{
|
||||
this.groupsV2Operations = groupsV2Operations;
|
||||
this.pushServiceSocket = new PushServiceSocket(configuration, credentialsProvider, signalAgent, groupsV2Operations.getProfileOperations(), automaticNetworkRetry);
|
||||
this.credentials = credentialsProvider;
|
||||
this.userAgent = signalAgent;
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public byte[] getSenderCertificate() throws IOException {
|
||||
return this.pushServiceSocket.getSenderCertificate();
|
||||
}
|
||||
|
||||
public byte[] getSenderCertificateForPhoneNumberPrivacy() throws IOException {
|
||||
return this.pushServiceSocket.getUuidOnlySenderCertificate();
|
||||
}
|
||||
|
||||
public SecureValueRecoveryV2 getSecureValueRecoveryV2(String mrEnclave) {
|
||||
return new SecureValueRecoveryV2(configuration, mrEnclave, pushServiceSocket);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 PINs are no longer used in favor of V2 PINs stored on KBS.
|
||||
*
|
||||
* You can remove a V1 PIN, but typically this is unnecessary, as setting a V2 PIN via
|
||||
* {@link KeyBackupService.PinChangeSession#enableRegistrationLock(MasterKey)}} will automatically clear the
|
||||
* V1 PIN on the service.
|
||||
*/
|
||||
public void removeRegistrationLockV1() throws IOException {
|
||||
this.pushServiceSocket.removeRegistrationLockV1();
|
||||
}
|
||||
|
||||
public WhoAmIResponse getWhoAmI() throws IOException {
|
||||
return this.pushServiceSocket.getWhoAmI();
|
||||
}
|
||||
|
||||
public KeyBackupService getKeyBackupService(KeyStore iasKeyStore,
|
||||
String enclaveName,
|
||||
byte[] serviceId,
|
||||
String mrenclave,
|
||||
int tries)
|
||||
{
|
||||
return new KeyBackupService(iasKeyStore, enclaveName, serviceId, mrenclave, pushServiceSocket, tries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register/Unregister a Google Cloud Messaging registration ID.
|
||||
*
|
||||
* @param gcmRegistrationId The GCM id to register. A call with an absent value will unregister.
|
||||
* @throws IOException
|
||||
*/
|
||||
public void setGcmId(Optional<String> gcmRegistrationId) throws IOException {
|
||||
if (gcmRegistrationId.isPresent()) {
|
||||
this.pushServiceSocket.registerGcmId(gcmRegistrationId.get());
|
||||
} else {
|
||||
this.pushServiceSocket.unregisterGcmId();
|
||||
}
|
||||
}
|
||||
|
||||
public Single<ServiceResponse<BackupAuthCheckResponse>> checkBackupAuthCredentials(@Nonnull String e164, @Nonnull List<String> usernamePasswords) {
|
||||
|
||||
return pushServiceSocket.checkBackupAuthCredentials(new BackupAuthCheckRequest(e164, usernamePasswords), DefaultResponseMapper.getDefault(BackupAuthCheckResponse.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a push challenge. A number will be pushed to the GCM (FCM) id. This can then be used
|
||||
* during SMS/call requests to bypass the CAPTCHA.
|
||||
*
|
||||
* @param gcmRegistrationId The GCM (FCM) id to use.
|
||||
* @param sessionId The session to request a push for.
|
||||
* @throws IOException
|
||||
*/
|
||||
public void requestRegistrationPushChallenge(String sessionId, String gcmRegistrationId) throws IOException {
|
||||
pushServiceSocket.requestPushChallenge(sessionId, gcmRegistrationId);
|
||||
}
|
||||
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> createRegistrationSession(@Nullable String fcmToken, @Nullable String mcc, @Nullable String mnc) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.createVerificationSession(fcmToken, mcc, mnc);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> getRegistrationSession(String sessionId) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.getSessionStatus(sessionId);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> submitPushChallengeToken(String sessionId, String pushChallengeToken) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.patchVerificationSession(sessionId, null, null, null, null, pushChallengeToken);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> submitCaptchaToken(String sessionId, @Nullable String captchaToken) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.patchVerificationSession(sessionId, null, null, null, captchaToken, null);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an SMS verification code. On success, the server will send
|
||||
* an SMS verification code to this Signal user.
|
||||
*
|
||||
* @param androidSmsRetrieverSupported
|
||||
*/
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> requestSmsVerificationCode(String sessionId, Locale locale, boolean androidSmsRetrieverSupported) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, PushServiceSocket.VerificationCodeTransport.SMS);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a Voice verification code. On success, the server will
|
||||
* make a voice call to this Signal user.
|
||||
*
|
||||
* @param locale
|
||||
*/
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> requestVoiceVerificationCode(String sessionId, Locale locale, boolean androidSmsRetrieverSupported) {
|
||||
try {
|
||||
final RegistrationSessionMetadataResponse response = pushServiceSocket.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, PushServiceSocket.VerificationCodeTransport.VOICE);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Signal Service account with a received SMS or voice verification code.
|
||||
*
|
||||
* @param verificationCode The verification code received via SMS or Voice
|
||||
* (see {@link #requestSmsVerificationCode} and
|
||||
* {@link #requestVoiceVerificationCode}).
|
||||
* @param sessionId The ID of the current registration session.
|
||||
* @return The UUID of the user that was registered.
|
||||
* @throws IOException for various HTTP and networking errors
|
||||
*/
|
||||
public ServiceResponse<RegistrationSessionMetadataResponse> verifyAccount(@Nonnull String verificationCode, @Nonnull String sessionId) {
|
||||
try {
|
||||
RegistrationSessionMetadataResponse response = pushServiceSocket.submitVerificationCode(sessionId, verificationCode);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nonnull ServiceResponse<VerifyAccountResponse> registerAccount(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, String fcmToken, boolean skipDeviceTransfer) {
|
||||
try {
|
||||
VerifyAccountResponse response = pushServiceSocket.submitRegistrationRequest(sessionId, recoveryPassword, attributes, aciPreKeys, pniPreKeys, fcmToken, skipDeviceTransfer);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nonnull ServiceResponse<VerifyAccountResponse> changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest) {
|
||||
try {
|
||||
VerifyAccountResponse response = this.pushServiceSocket.changeNumber(changePhoneNumberRequest);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh account attributes with server.
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public void setAccountAttributes(@Nonnull AccountAttributes accountAttributes)
|
||||
throws IOException
|
||||
{
|
||||
this.pushServiceSocket.setAccountAttributes(accountAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an identity key, signed prekey, and list of one time prekeys
|
||||
* with the server.
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
public void setPreKeys(PreKeyUpload preKeyUpload)
|
||||
throws IOException
|
||||
{
|
||||
this.pushServiceSocket.registerPreKeys(preKeyUpload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The server's count of currently available (eg. unused) prekeys for this user.
|
||||
* @throws IOException
|
||||
*/
|
||||
public OneTimePreKeyCounts getPreKeyCounts(ServiceIdType serviceIdType) throws IOException {
|
||||
return this.pushServiceSocket.getAvailablePreKeys(serviceIdType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the client's signed prekey.
|
||||
*
|
||||
* @param signedPreKey The client's new signed prekey.
|
||||
* @throws IOException
|
||||
*/
|
||||
public void setSignedPreKey(ServiceIdType serviceIdType, SignedPreKeyRecord signedPreKey) throws IOException {
|
||||
this.pushServiceSocket.setCurrentSignedPreKey(serviceIdType, signedPreKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the identifier corresponds to a registered user, otherwise false.
|
||||
*/
|
||||
public boolean isIdentifierRegistered(ServiceId identifier) throws IOException {
|
||||
return pushServiceSocket.isIdentifierRegistered(identifier);
|
||||
}
|
||||
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
public CdsiV2Service.Response getRegisteredUsersWithCdsi(Set<String> previousE164s,
|
||||
Set<String> newE164s,
|
||||
Map<ServiceId, ProfileKey> serviceIds,
|
||||
boolean requireAcis,
|
||||
Optional<byte[]> token,
|
||||
String mrEnclave,
|
||||
Long timeoutMs,
|
||||
Consumer<byte[]> tokenSaver)
|
||||
throws IOException
|
||||
{
|
||||
CdsiAuthResponse auth = pushServiceSocket.getCdsiAuth();
|
||||
CdsiV2Service service = new CdsiV2Service(configuration, mrEnclave);
|
||||
CdsiV2Service.Request request = new CdsiV2Service.Request(previousE164s, newE164s, serviceIds, requireAcis, token);
|
||||
Single<ServiceResponse<CdsiV2Service.Response>> single = service.getRegisteredUsers(auth.getUsername(), auth.getPassword(), request, tokenSaver);
|
||||
|
||||
ServiceResponse<CdsiV2Service.Response> serviceResponse;
|
||||
try {
|
||||
if (timeoutMs == null) {
|
||||
serviceResponse = single
|
||||
.blockingGet();
|
||||
} else {
|
||||
serviceResponse = single
|
||||
.timeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.blockingGet();
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof InterruptedException) {
|
||||
throw new IOException("Interrupted", cause);
|
||||
} else if (cause instanceof TimeoutException) {
|
||||
throw new IOException("Timed out");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Unexpected exception when retrieving registered users!", e);
|
||||
}
|
||||
|
||||
if (serviceResponse.getResult().isPresent()) {
|
||||
return serviceResponse.getResult().get();
|
||||
} else if (serviceResponse.getApplicationError().isPresent()) {
|
||||
if (serviceResponse.getApplicationError().get() instanceof IOException) {
|
||||
throw (IOException) serviceResponse.getApplicationError().get();
|
||||
} else {
|
||||
throw new IOException(serviceResponse.getApplicationError().get());
|
||||
}
|
||||
} else if (serviceResponse.getExecutionError().isPresent()) {
|
||||
throw new IOException(serviceResponse.getExecutionError().get());
|
||||
} else {
|
||||
throw new IOException("Missing result!");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Optional<SignalStorageManifest> getStorageManifest(StorageKey storageKey) throws IOException {
|
||||
try {
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifest storageManifest = this.pushServiceSocket.getStorageManifest(authToken);
|
||||
|
||||
return Optional.of(SignalStorageModels.remoteToLocalStorageManifest(storageManifest, storageKey));
|
||||
} catch (InvalidKeyException | NotFoundException e) {
|
||||
Log.w(TAG, "Error while fetching manifest.", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public long getStorageManifestVersion() throws IOException {
|
||||
try {
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifest storageManifest = this.pushServiceSocket.getStorageManifest(authToken);
|
||||
|
||||
return storageManifest.version;
|
||||
} catch (NotFoundException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<SignalStorageManifest> getStorageManifestIfDifferentVersion(StorageKey storageKey, long manifestVersion) throws IOException, InvalidKeyException {
|
||||
try {
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifest storageManifest = this.pushServiceSocket.getStorageManifestIfDifferentVersion(authToken, manifestVersion);
|
||||
|
||||
if (storageManifest.value_.size() == 0) {
|
||||
Log.w(TAG, "Got an empty storage manifest!");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(SignalStorageModels.remoteToLocalStorageManifest(storageManifest, storageKey));
|
||||
} catch (NoContentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignalStorageRecord> readStorageRecords(StorageKey storageKey, List<StorageId> storageKeys) throws IOException, InvalidKeyException {
|
||||
if (storageKeys.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<SignalStorageRecord> result = new ArrayList<>();
|
||||
Map<ByteString, Integer> typeMap = new HashMap<>();
|
||||
List<ReadOperation> readOperations = new LinkedList<>();
|
||||
List<ByteString> readKeys = new LinkedList<>();
|
||||
|
||||
for (StorageId key : storageKeys) {
|
||||
typeMap.put(ByteString.of(key.getRaw()), key.getType());
|
||||
|
||||
if (readKeys.size() >= STORAGE_READ_MAX_ITEMS) {
|
||||
Log.i(TAG, "Going over max read items. Starting a new read operation.");
|
||||
readOperations.add(new ReadOperation.Builder().readKey(readKeys).build());
|
||||
readKeys = new LinkedList<>();
|
||||
}
|
||||
|
||||
if (StorageId.isKnownType(key.getType())) {
|
||||
readKeys.add(ByteString.of(key.getRaw()));
|
||||
} else {
|
||||
result.add(SignalStorageRecord.forUnknown(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (readKeys.size() > 0) {
|
||||
readOperations.add(new ReadOperation.Builder().readKey(readKeys).build());
|
||||
}
|
||||
|
||||
Log.i(TAG, "Reading " + storageKeys.size() + " items split over " + readOperations.size() + " page(s).");
|
||||
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
|
||||
for (ReadOperation readOperation : readOperations) {
|
||||
StorageItems items = this.pushServiceSocket.readStorageItems(authToken, readOperation);
|
||||
|
||||
for (StorageItem item : items.items) {
|
||||
Integer type = typeMap.get(item.key);
|
||||
if (type != null) {
|
||||
result.add(SignalStorageModels.remoteToLocalStorageRecord(item, type, storageKey));
|
||||
} else {
|
||||
Log.w(TAG, "No type found! Skipping.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent.
|
||||
*/
|
||||
public Optional<SignalStorageManifest> resetStorageRecords(StorageKey storageKey,
|
||||
SignalStorageManifest manifest,
|
||||
List<SignalStorageRecord> allRecords)
|
||||
throws IOException, InvalidKeyException
|
||||
{
|
||||
return writeStorageRecords(storageKey, manifest, allRecords, Collections.<byte[]>emptyList(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent.
|
||||
*/
|
||||
public Optional<SignalStorageManifest> writeStorageRecords(StorageKey storageKey,
|
||||
SignalStorageManifest manifest,
|
||||
List<SignalStorageRecord> inserts,
|
||||
List<byte[]> deletes)
|
||||
throws IOException, InvalidKeyException
|
||||
{
|
||||
return writeStorageRecords(storageKey, manifest, inserts, deletes, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enables registration lock for this account.
|
||||
*/
|
||||
public void enableRegistrationLock(MasterKey masterKey) throws IOException {
|
||||
pushServiceSocket.setRegistrationLockV2(masterKey.deriveRegistrationLock());
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables registration lock for this account.
|
||||
*/
|
||||
public void disableRegistrationLock() throws IOException {
|
||||
pushServiceSocket.disableRegistrationLockV2();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent.
|
||||
*/
|
||||
private Optional<SignalStorageManifest> writeStorageRecords(StorageKey storageKey,
|
||||
SignalStorageManifest manifest,
|
||||
List<SignalStorageRecord> inserts,
|
||||
List<byte[]> deletes,
|
||||
boolean clearAll)
|
||||
throws IOException, InvalidKeyException
|
||||
{
|
||||
ManifestRecord.Builder manifestRecordBuilder = new ManifestRecord.Builder()
|
||||
.sourceDevice(manifest.getSourceDeviceId())
|
||||
.version(manifest.getVersion());
|
||||
|
||||
|
||||
manifestRecordBuilder.identifiers(
|
||||
manifest.getStorageIds().stream()
|
||||
.map(id -> new ManifestRecord.Identifier.Builder()
|
||||
.raw(ByteString.of(id.getRaw()))
|
||||
.type(ManifestRecord.Identifier.Type.Companion.fromValue(id.getType()))
|
||||
.build())
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifestKey manifestKey = storageKey.deriveManifestKey(manifest.getVersion());
|
||||
byte[] encryptedRecord = SignalStorageCipher.encrypt(manifestKey, manifestRecordBuilder.build().encode());
|
||||
StorageManifest storageManifest = new StorageManifest.Builder()
|
||||
.version(manifest.getVersion())
|
||||
.value_(ByteString.of(encryptedRecord))
|
||||
.build();
|
||||
|
||||
WriteOperation.Builder writeBuilder = new WriteOperation.Builder().manifest(storageManifest);
|
||||
|
||||
writeBuilder.insertItem(
|
||||
inserts.stream()
|
||||
.map(insert -> SignalStorageModels.localToRemoteStorageRecord(insert, storageKey))
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
||||
if (clearAll) {
|
||||
writeBuilder.clearAll(true);
|
||||
} else {
|
||||
writeBuilder.deleteKey(
|
||||
deletes.stream()
|
||||
.map(delete -> ByteString.of(delete))
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
}
|
||||
|
||||
Optional<StorageManifest> conflict = this.pushServiceSocket.writeStorageContacts(authToken, writeBuilder.build());
|
||||
|
||||
if (conflict.isPresent()) {
|
||||
StorageManifestKey conflictKey = storageKey.deriveManifestKey(conflict.get().version);
|
||||
byte[] rawManifestRecord = SignalStorageCipher.decrypt(conflictKey, conflict.get().value_.toByteArray());
|
||||
ManifestRecord record = ManifestRecord.ADAPTER.decode(rawManifestRecord);
|
||||
List<StorageId> ids = new ArrayList<>(record.identifiers.size());
|
||||
|
||||
for (ManifestRecord.Identifier id : record.identifiers) {
|
||||
ids.add(StorageId.forType(id.raw.toByteArray(), id.type.getValue()));
|
||||
}
|
||||
|
||||
SignalStorageManifest conflictManifest = new SignalStorageManifest(record.version, record.sourceDevice, ids);
|
||||
|
||||
return Optional.of(conflictManifest);
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public RemoteConfigResult getRemoteConfig() throws IOException {
|
||||
RemoteConfigResponse response = this.pushServiceSocket.getRemoteConfig();
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
|
||||
for (RemoteConfigResponse.Config config : response.getConfig()) {
|
||||
out.put(config.getName(), config.getValue() != null ? config.getValue() : config.isEnabled());
|
||||
}
|
||||
|
||||
return new RemoteConfigResult(out, response.getServerEpochTime());
|
||||
}
|
||||
|
||||
public String getAccountDataReport() throws IOException {
|
||||
return pushServiceSocket.getAccountDataReport();
|
||||
}
|
||||
|
||||
public String getNewDeviceVerificationCode() throws IOException {
|
||||
return this.pushServiceSocket.getNewDeviceVerificationCode();
|
||||
}
|
||||
|
||||
public void addDevice(String deviceIdentifier,
|
||||
ECPublicKey deviceKey,
|
||||
IdentityKeyPair aciIdentityKeyPair,
|
||||
IdentityKeyPair pniIdentityKeyPair,
|
||||
ProfileKey profileKey,
|
||||
String code)
|
||||
throws InvalidKeyException, IOException
|
||||
{
|
||||
String e164 = credentials.getE164();
|
||||
ACI aci = credentials.getAci();
|
||||
PNI pni = credentials.getPni();
|
||||
|
||||
Preconditions.checkArgument(e164 != null, "Missing e164!");
|
||||
Preconditions.checkArgument(aci != null, "Missing ACI!");
|
||||
Preconditions.checkArgument(pni != null, "Missing PNI!");
|
||||
|
||||
PrimaryProvisioningCipher cipher = new PrimaryProvisioningCipher(deviceKey);
|
||||
ProvisionMessage.Builder message = new ProvisionMessage.Builder()
|
||||
.aciIdentityKeyPublic(ByteString.of(aciIdentityKeyPair.getPublicKey().serialize()))
|
||||
.aciIdentityKeyPrivate(ByteString.of(aciIdentityKeyPair.getPrivateKey().serialize()))
|
||||
.pniIdentityKeyPublic(ByteString.of(pniIdentityKeyPair.getPublicKey().serialize()))
|
||||
.pniIdentityKeyPrivate(ByteString.of(pniIdentityKeyPair.getPrivateKey().serialize()))
|
||||
.aci(aci.toString())
|
||||
.pni(pni.toStringWithoutPrefix())
|
||||
.number(e164)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.provisioningCode(code)
|
||||
.provisioningVersion(ProvisioningVersion.CURRENT.getValue());
|
||||
|
||||
byte[] ciphertext = cipher.encrypt(message.build());
|
||||
this.pushServiceSocket.sendProvisioningMessage(deviceIdentifier, ciphertext);
|
||||
}
|
||||
|
||||
public ServiceResponse<VerifyAccountResponse> distributePniKeys(PniKeyDistributionRequest request) {
|
||||
try {
|
||||
VerifyAccountResponse response = this.pushServiceSocket.distributePniKeys(request);
|
||||
return ServiceResponse.forResult(response, 200, null);
|
||||
} catch (IOException e) {
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DeviceInfo> getDevices() throws IOException {
|
||||
return this.pushServiceSocket.getDevices();
|
||||
}
|
||||
|
||||
public void removeDevice(long deviceId) throws IOException {
|
||||
this.pushServiceSocket.removeDevice(deviceId);
|
||||
}
|
||||
|
||||
public TurnServerInfo getTurnServerInfo() throws IOException {
|
||||
return this.pushServiceSocket.getTurnServerInfo();
|
||||
}
|
||||
|
||||
public void checkNetworkConnection() throws IOException {
|
||||
this.pushServiceSocket.pingStorageService();
|
||||
}
|
||||
|
||||
public CurrencyConversions getCurrencyConversions() throws IOException {
|
||||
return this.pushServiceSocket.getCurrencyConversions();
|
||||
}
|
||||
|
||||
public void reportSpam(ServiceId serviceId, String serverGuid, String reportingToken) throws IOException {
|
||||
this.pushServiceSocket.reportSpam(serviceId, serverGuid, reportingToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The avatar URL path, if one was written.
|
||||
*/
|
||||
public Optional<String> setVersionedProfile(ACI aci,
|
||||
ProfileKey profileKey,
|
||||
String name,
|
||||
String about,
|
||||
String aboutEmoji,
|
||||
Optional<PaymentAddress> paymentsAddress,
|
||||
AvatarUploadParams avatar,
|
||||
List<String> visibleBadgeIds)
|
||||
throws IOException
|
||||
{
|
||||
if (name == null) name = "";
|
||||
|
||||
ProfileCipher profileCipher = new ProfileCipher(profileKey);
|
||||
byte[] ciphertextName = profileCipher.encryptString(name, ProfileCipher.getTargetNameLength(name));
|
||||
byte[] ciphertextAbout = profileCipher.encryptString(about, ProfileCipher.getTargetAboutLength(about));
|
||||
byte[] ciphertextEmoji = profileCipher.encryptString(aboutEmoji, ProfileCipher.EMOJI_PADDED_LENGTH);
|
||||
byte[] ciphertextMobileCoinAddress = paymentsAddress.map(address -> profileCipher.encryptWithLength(address.encode(), ProfileCipher.PAYMENTS_ADDRESS_CONTENT_SIZE)).orElse(null);
|
||||
ProfileAvatarData profileAvatarData = null;
|
||||
|
||||
if (avatar.stream != null && !avatar.keepTheSame) {
|
||||
profileAvatarData = new ProfileAvatarData(avatar.stream.getStream(),
|
||||
ProfileCipherOutputStream.getCiphertextLength(avatar.stream.getLength()),
|
||||
avatar.stream.getContentType(),
|
||||
new ProfileCipherOutputStreamFactory(profileKey));
|
||||
}
|
||||
|
||||
return this.pushServiceSocket.writeProfile(new SignalServiceProfileWrite(profileKey.getProfileKeyVersion(aci.getLibSignalAci()).serialize(),
|
||||
ciphertextName,
|
||||
ciphertextAbout,
|
||||
ciphertextEmoji,
|
||||
ciphertextMobileCoinAddress,
|
||||
avatar.hasAvatar,
|
||||
avatar.keepTheSame,
|
||||
profileKey.getCommitment(aci.getLibSignalAci()).serialize(),
|
||||
visibleBadgeIds),
|
||||
profileAvatarData);
|
||||
}
|
||||
|
||||
public Optional<ExpiringProfileKeyCredential> resolveProfileKeyCredential(ACI serviceId, ProfileKey profileKey, Locale locale)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException
|
||||
{
|
||||
try {
|
||||
ProfileAndCredential credential = this.pushServiceSocket.retrieveVersionedProfileAndCredential(serviceId, profileKey, Optional.empty(), locale).get(10, TimeUnit.SECONDS);
|
||||
return credential.getExpiringProfileKeyCredential();
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new PushNetworkException(e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof NonSuccessfulResponseCodeException) {
|
||||
throw (NonSuccessfulResponseCodeException) e.getCause();
|
||||
} else if (e.getCause() instanceof PushNetworkException) {
|
||||
throw (PushNetworkException) e.getCause();
|
||||
} else {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ACI getAciByUsernameHash(String usernameHash) throws IOException {
|
||||
return this.pushServiceSocket.getAciByUsernameHash(usernameHash);
|
||||
}
|
||||
|
||||
public ReserveUsernameResponse reserveUsername(List<String> usernameHashes) throws IOException {
|
||||
return this.pushServiceSocket.reserveUsername(usernameHashes);
|
||||
}
|
||||
|
||||
public void confirmUsername(String username, ReserveUsernameResponse reserveUsernameResponse) throws IOException {
|
||||
this.pushServiceSocket.confirmUsername(username, reserveUsernameResponse);
|
||||
}
|
||||
|
||||
public void deleteUsername() throws IOException {
|
||||
this.pushServiceSocket.deleteUsername();
|
||||
}
|
||||
|
||||
public UsernameLinkComponents createUsernameLink(Username username) throws IOException {
|
||||
try {
|
||||
UsernameLink link = username.generateLink();
|
||||
UUID serverId = this.pushServiceSocket.createUsernameLink(Base64UrlSafe.encodeBytes(link.getEncryptedUsername()));
|
||||
|
||||
return new UsernameLinkComponents(link.getEntropy(), serverId);
|
||||
} catch (BaseUsernameException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteUsernameLink() throws IOException {
|
||||
this.pushServiceSocket.deleteUsernameLink();
|
||||
}
|
||||
|
||||
public byte[] getEncryptedUsernameFromLinkServerId(UUID serverId) throws IOException {
|
||||
return this.pushServiceSocket.getEncryptedUsernameFromLinkServerId(serverId);
|
||||
}
|
||||
|
||||
public void deleteAccount() throws IOException {
|
||||
this.pushServiceSocket.deleteAccount();
|
||||
}
|
||||
|
||||
public void requestRateLimitPushChallenge() throws IOException {
|
||||
this.pushServiceSocket.requestRateLimitPushChallenge();
|
||||
}
|
||||
|
||||
public void submitRateLimitPushChallenge(String challenge) throws IOException {
|
||||
this.pushServiceSocket.submitRateLimitPushChallenge(challenge);
|
||||
}
|
||||
|
||||
public void submitRateLimitRecaptchaChallenge(String challenge, String recaptchaToken) throws IOException {
|
||||
this.pushServiceSocket.submitRateLimitRecaptchaChallenge(challenge, recaptchaToken);
|
||||
}
|
||||
|
||||
public void cancelInFlightRequests() {
|
||||
this.pushServiceSocket.cancelInFlightRequests();
|
||||
}
|
||||
|
||||
private String createDirectoryServerToken(String e164number, boolean urlSafe) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA1");
|
||||
byte[] token = Util.trim(digest.digest(e164number.getBytes()), 10);
|
||||
String encoded = Base64.encodeBytesWithoutPadding(token);
|
||||
|
||||
if (urlSafe) return encoded.replace('+', '-').replace('/', '_');
|
||||
else return encoded;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public GroupsV2Api getGroupsV2Api() {
|
||||
return new GroupsV2Api(pushServiceSocket, groupsV2Operations);
|
||||
}
|
||||
|
||||
public AuthCredentials getPaymentsAuthorization() throws IOException {
|
||||
return pushServiceSocket.getPaymentsAuthorization();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol store interface that has additional methods that are needed
|
||||
* in the service layer, but not the protocol layer.
|
||||
*/
|
||||
public interface SignalServiceDataStore {
|
||||
|
||||
/**
|
||||
* @return A {@link SignalServiceAccountDataStore} for the specified account.
|
||||
*/
|
||||
SignalServiceAccountDataStore get(ServiceId accountIdentifier);
|
||||
|
||||
/**
|
||||
* @return A {@link SignalServiceAccountDataStore} for the ACI account.
|
||||
*/
|
||||
SignalServiceAccountDataStore aci();
|
||||
|
||||
/**
|
||||
* @return A {@link SignalServiceAccountDataStore} for the PNI account.
|
||||
*/
|
||||
SignalServiceAccountDataStore pni();
|
||||
|
||||
/**
|
||||
* @return True if the user has linked devices, otherwise false.
|
||||
*/
|
||||
boolean isMultiDevice();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyStore
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol sender key store interface that has additional methods that are
|
||||
* needed in the service layer, but not the protocol layer.
|
||||
*/
|
||||
interface SignalServiceKyberPreKeyStore : KyberPreKeyStore {
|
||||
|
||||
/**
|
||||
* Identical to [storeKyberPreKey] but indicates that this is a last-resort key rather than a one-time key.
|
||||
*/
|
||||
fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord)
|
||||
|
||||
/**
|
||||
* Retrieves all last-resort kyber prekeys.
|
||||
*/
|
||||
fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord>
|
||||
|
||||
/**
|
||||
* Unconditionally remove the specified key from the store.
|
||||
*/
|
||||
fun removeKyberPreKey(kyberPreKeyId: Int)
|
||||
|
||||
/**
|
||||
* Marks all prekeys stale if they haven't been marked already. "Stale" means the time that the keys have been replaced.
|
||||
*/
|
||||
fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long)
|
||||
|
||||
/**
|
||||
* Deletes all prekeys that have been stale since before the threshold. "Stale" means the time that the keys have been replaced.
|
||||
*/
|
||||
fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.push.IdentityCheckRequest;
|
||||
import org.whispersystems.signalservice.internal.push.IdentityCheckResponse;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.sticker.Pack;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.FutureTransformers;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
|
||||
import org.whispersystems.signalservice.internal.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
|
||||
|
||||
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.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
/**
|
||||
* The primary interface for receiving Signal Service messages.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class SignalServiceMessageReceiver {
|
||||
|
||||
private final PushServiceSocket socket;
|
||||
|
||||
/**
|
||||
* Construct a SignalServiceMessageReceiver.
|
||||
*
|
||||
* @param urls The URL of the Signal Service.
|
||||
* @param credentials The Signal Service user's credentials.
|
||||
*/
|
||||
public SignalServiceMessageReceiver(SignalServiceConfiguration urls,
|
||||
CredentialsProvider credentials,
|
||||
String signalAgent,
|
||||
ClientZkProfileOperations clientZkProfileOperations,
|
||||
boolean automaticNetworkRetry)
|
||||
{
|
||||
this.socket = new PushServiceSocket(urls, credentials, signalAgent, clientZkProfileOperations, automaticNetworkRetry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a SignalServiceAttachment.
|
||||
*
|
||||
* @param pointer The {@link SignalServiceAttachmentPointer}
|
||||
* received in a {@link SignalServiceDataMessage}.
|
||||
* @param destination The download destination for this attachment.
|
||||
*
|
||||
* @return An InputStream that streams the plaintext attachment contents.
|
||||
* @throws IOException
|
||||
* @throws InvalidMessageException
|
||||
*/
|
||||
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes)
|
||||
throws IOException, InvalidMessageException, MissingConfigurationException {
|
||||
return retrieveAttachment(pointer, destination, maxSizeBytes, null);
|
||||
}
|
||||
|
||||
public ListenableFuture<ProfileAndCredential> retrieveProfile(SignalServiceAddress address,
|
||||
Optional<ProfileKey> profileKey,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
SignalServiceProfile.RequestType requestType,
|
||||
Locale locale)
|
||||
{
|
||||
|
||||
if (profileKey.isPresent()) {
|
||||
ACI aci;
|
||||
if (address.getServiceId() instanceof ACI) {
|
||||
aci = (ACI) address.getServiceId();
|
||||
} else {
|
||||
// We shouldn't ever have a profile key for a non-ACI.
|
||||
SettableFuture<ProfileAndCredential> result = new SettableFuture<>();
|
||||
result.setException(new ClassCastException("retrieving a versioned profile requires an ACI"));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
|
||||
return socket.retrieveVersionedProfileAndCredential(aci, profileKey.get(), unidentifiedAccess, locale);
|
||||
} else {
|
||||
return FutureTransformers.map(socket.retrieveVersionedProfile(aci, profileKey.get(), unidentifiedAccess, locale), profile -> {
|
||||
return new ProfileAndCredential(profile,
|
||||
SignalServiceProfile.RequestType.PROFILE,
|
||||
Optional.empty());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return FutureTransformers.map(socket.retrieveProfile(address, unidentifiedAccess, locale), profile -> {
|
||||
return new ProfileAndCredential(profile,
|
||||
SignalServiceProfile.RequestType.PROFILE,
|
||||
Optional.empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes)
|
||||
throws IOException
|
||||
{
|
||||
socket.retrieveProfileAvatar(path, destination, maxSizeBytes);
|
||||
return new ProfileCipherInputStream(new FileInputStream(destination), profileKey);
|
||||
}
|
||||
|
||||
public FileInputStream retrieveGroupsV2ProfileAvatar(String path, File destination, long maxSizeBytes)
|
||||
throws IOException
|
||||
{
|
||||
socket.retrieveProfileAvatar(path, destination, maxSizeBytes);
|
||||
return new FileInputStream(destination);
|
||||
}
|
||||
|
||||
public Single<ServiceResponse<IdentityCheckResponse>> performIdentityCheck(@Nonnull IdentityCheckRequest request, @Nonnull Optional<UnidentifiedAccess> unidentifiedAccess, @Nonnull ResponseMapper<IdentityCheckResponse> responseMapper) {
|
||||
return socket.performIdentityCheck(request, unidentifiedAccess, responseMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a SignalServiceAttachment.
|
||||
*
|
||||
* @param pointer The {@link SignalServiceAttachmentPointer}
|
||||
* received in a {@link SignalServiceDataMessage}.
|
||||
* @param destination The download destination for this attachment. If this file exists, it is
|
||||
* assumed that this is previously-downloaded content that can be resumed.
|
||||
* @param listener An optional listener (may be null) to receive callbacks on download progress.
|
||||
*
|
||||
* @return An InputStream that streams the plaintext attachment contents.
|
||||
* @throws IOException
|
||||
* @throws InvalidMessageException
|
||||
*/
|
||||
public InputStream retrieveAttachment(SignalServiceAttachmentPointer pointer, File destination, long maxSizeBytes, ProgressListener listener)
|
||||
throws IOException, InvalidMessageException, MissingConfigurationException {
|
||||
if (!pointer.getDigest().isPresent()) throw new InvalidMessageException("No attachment digest!");
|
||||
|
||||
socket.retrieveAttachment(pointer.getCdnNumber(), pointer.getRemoteId(), destination, maxSizeBytes, listener);
|
||||
return AttachmentCipherInputStream.createForAttachment(destination, pointer.getSize().orElse(0), pointer.getKey(), pointer.getDigest().get(), null, 0);
|
||||
}
|
||||
|
||||
public InputStream retrieveSticker(byte[] packId, byte[] packKey, int stickerId)
|
||||
throws IOException, InvalidMessageException
|
||||
{
|
||||
byte[] data = socket.retrieveSticker(packId, stickerId);
|
||||
return AttachmentCipherInputStream.createForStickerData(data, packKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a {@link SignalServiceStickerManifest}.
|
||||
*
|
||||
* @param packId The 16-byte packId that identifies the sticker pack.
|
||||
* @param packKey The 32-byte packKey that decrypts the sticker pack.
|
||||
* @return The {@link SignalServiceStickerManifest} representing the sticker pack.
|
||||
* @throws IOException
|
||||
* @throws InvalidMessageException
|
||||
*/
|
||||
public SignalServiceStickerManifest retrieveStickerManifest(byte[] packId, byte[] packKey)
|
||||
throws IOException, InvalidMessageException
|
||||
{
|
||||
byte[] manifestBytes = socket.retrieveStickerManifest(packId);
|
||||
|
||||
InputStream cipherStream = AttachmentCipherInputStream.createForStickerData(manifestBytes, packKey);
|
||||
|
||||
Pack pack = Pack.ADAPTER.decode(Util.readFullyAsBytes(cipherStream));
|
||||
List<SignalServiceStickerManifest.StickerInfo> stickers = new ArrayList<>(pack.stickers.size());
|
||||
SignalServiceStickerManifest.StickerInfo cover = pack.cover != null ? new SignalServiceStickerManifest.StickerInfo(pack.cover.id, pack.cover.emoji, pack.cover.contentType)
|
||||
: null;
|
||||
|
||||
for (Pack.Sticker sticker : pack.stickers) {
|
||||
stickers.add(new SignalServiceStickerManifest.StickerInfo(sticker.id, sticker.emoji, sticker.contentType));
|
||||
}
|
||||
|
||||
return new SignalServiceStickerManifest(pack.title, pack.author, cover, stickers);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import org.signal.libsignal.protocol.state.PreKeyStore
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol prekey store interface that has additional methods that are
|
||||
* needed in the service layer, but not the protocol layer.
|
||||
*/
|
||||
interface SignalServicePreKeyStore : PreKeyStore {
|
||||
/**
|
||||
* Marks all prekeys stale if they haven't been marked already. "Stale" means the time that the keys have been replaced.
|
||||
*/
|
||||
fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long)
|
||||
|
||||
/**
|
||||
* Deletes all prekeys that have been stale since before the threshold. "Stale" means the time that the keys have been replaced.
|
||||
*/
|
||||
fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyStore;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol sender key store interface that has additional methods that are
|
||||
* needed in the service layer, but not the protocol layer.
|
||||
*/
|
||||
public interface SignalServiceSenderKeyStore extends SenderKeyStore {
|
||||
/**
|
||||
* @return A set of protocol addresses that have previously been sent the sender key data for the provided distributionId.
|
||||
*/
|
||||
Set<SignalProtocolAddress> getSenderKeySharedWith(DistributionId distributionId);
|
||||
|
||||
/**
|
||||
* Marks the provided addresses as having been sent the sender key data for the provided distributionId.
|
||||
*/
|
||||
void markSenderKeySharedWith(DistributionId distributionId, Collection<SignalProtocolAddress> addresses);
|
||||
|
||||
/**
|
||||
* Marks the provided addresses as not knowing about any distributionIds.
|
||||
*/
|
||||
void clearSenderKeySharedWith(Collection<SignalProtocolAddress> addresses);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.state.SessionStore;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* And extension of the normal protocol session store interface that has additional methods that are
|
||||
* needed in the service layer, but not the protocol layer.
|
||||
*/
|
||||
public interface SignalServiceSessionStore extends SessionStore {
|
||||
void archiveSession(SignalProtocolAddress address);
|
||||
Set<SignalProtocolAddress> getAllAddressesWithActiveSessions(List<String> addressNames);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
/**
|
||||
* An interface to allow the injection of a lock that will be used to keep interactions with
|
||||
* ecryptions/decryptions thread-safe.
|
||||
*/
|
||||
public interface SignalSessionLock {
|
||||
|
||||
Lock acquire();
|
||||
|
||||
interface Lock extends Closeable {
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.messages.EnvelopeResponse;
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketFactory;
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
|
||||
import org.whispersystems.signalservice.internal.push.Envelope;
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage;
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketResponseMessage;
|
||||
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
|
||||
/**
|
||||
* Provide a general interface to the WebSocket for making requests and reading messages sent by the server.
|
||||
* Where appropriate, it will handle retrying failed unidentified requests on the regular WebSocket.
|
||||
*/
|
||||
public final class SignalWebSocket {
|
||||
|
||||
private static final String TAG = SignalWebSocket.class.getSimpleName();
|
||||
|
||||
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
|
||||
private final WebSocketFactory webSocketFactory;
|
||||
|
||||
private WebSocketConnection webSocket;
|
||||
private final BehaviorSubject<WebSocketConnectionState> webSocketState;
|
||||
private CompositeDisposable webSocketStateDisposable;
|
||||
|
||||
private WebSocketConnection unidentifiedWebSocket;
|
||||
private final BehaviorSubject<WebSocketConnectionState> unidentifiedWebSocketState;
|
||||
private CompositeDisposable unidentifiedWebSocketStateDisposable;
|
||||
|
||||
private boolean canConnect;
|
||||
|
||||
public SignalWebSocket(WebSocketFactory webSocketFactory) {
|
||||
this.webSocketFactory = webSocketFactory;
|
||||
this.webSocketState = BehaviorSubject.createDefault(WebSocketConnectionState.DISCONNECTED);
|
||||
this.unidentifiedWebSocketState = BehaviorSubject.createDefault(WebSocketConnectionState.DISCONNECTED);
|
||||
this.webSocketStateDisposable = new CompositeDisposable();
|
||||
this.unidentifiedWebSocketStateDisposable = new CompositeDisposable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an observable stream of the identified WebSocket state. This observable is valid for the lifetime of
|
||||
* the instance, and will update as WebSocketConnections are remade.
|
||||
*/
|
||||
public Observable<WebSocketConnectionState> getWebSocketState() {
|
||||
return webSocketState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an observable stream of the unidentified WebSocket state. This observable is valid for the lifetime of
|
||||
* the instance, and will update as WebSocketConnections are remade.
|
||||
*/
|
||||
public Observable<WebSocketConnectionState> getUnidentifiedWebSocketState() {
|
||||
return unidentifiedWebSocketState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that WebSocketConnections can now be made and attempt to connect both of them.
|
||||
*/
|
||||
public synchronized void connect() {
|
||||
canConnect = true;
|
||||
try {
|
||||
getWebSocket();
|
||||
getUnidentifiedWebSocket();
|
||||
} catch (WebSocketUnavailableException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that WebSocketConnections can no longer be made and disconnect both of them.
|
||||
*/
|
||||
public synchronized void disconnect() {
|
||||
canConnect = false;
|
||||
disconnectIdentified();
|
||||
disconnectUnidentified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the current WebSocket instances need to be destroyed and new ones should be created the
|
||||
* next time a connection is required. Intended to be used by the health monitor to cycle a WebSocket.
|
||||
*/
|
||||
public synchronized void forceNewWebSockets() {
|
||||
Log.i(TAG, "Forcing new WebSockets " +
|
||||
" identified: " + (webSocket != null ? webSocket.getName() : "[null]") +
|
||||
" unidentified: " + (unidentifiedWebSocket != null ? unidentifiedWebSocket.getName() : "[null]") +
|
||||
" canConnect: " + canConnect);
|
||||
|
||||
disconnectIdentified();
|
||||
disconnectUnidentified();
|
||||
}
|
||||
|
||||
private void disconnectIdentified() {
|
||||
if (webSocket != null) {
|
||||
webSocketStateDisposable.dispose();
|
||||
|
||||
webSocket.disconnect();
|
||||
webSocket = null;
|
||||
|
||||
//noinspection ConstantConditions
|
||||
if (!webSocketState.getValue().isFailure()) {
|
||||
webSocketState.onNext(WebSocketConnectionState.DISCONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnectUnidentified() {
|
||||
if (unidentifiedWebSocket != null) {
|
||||
unidentifiedWebSocketStateDisposable.dispose();
|
||||
|
||||
unidentifiedWebSocket.disconnect();
|
||||
unidentifiedWebSocket = null;
|
||||
|
||||
//noinspection ConstantConditions
|
||||
if (!unidentifiedWebSocketState.getValue().isFailure()) {
|
||||
unidentifiedWebSocketState.onNext(WebSocketConnectionState.DISCONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized WebSocketConnection getWebSocket() throws WebSocketUnavailableException {
|
||||
if (!canConnect) {
|
||||
throw new WebSocketUnavailableException();
|
||||
}
|
||||
|
||||
if (webSocket == null || webSocket.isDead()) {
|
||||
webSocketStateDisposable.dispose();
|
||||
|
||||
webSocket = webSocketFactory.createWebSocket();
|
||||
webSocketStateDisposable = new CompositeDisposable();
|
||||
|
||||
Disposable state = webSocket.connect()
|
||||
.subscribeOn(Schedulers.computation())
|
||||
.observeOn(Schedulers.computation())
|
||||
.subscribe(webSocketState::onNext);
|
||||
webSocketStateDisposable.add(state);
|
||||
}
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
private synchronized WebSocketConnection getUnidentifiedWebSocket() throws WebSocketUnavailableException {
|
||||
if (!canConnect) {
|
||||
throw new WebSocketUnavailableException();
|
||||
}
|
||||
|
||||
if (unidentifiedWebSocket == null || unidentifiedWebSocket.isDead()) {
|
||||
unidentifiedWebSocketStateDisposable.dispose();
|
||||
|
||||
unidentifiedWebSocket = webSocketFactory.createUnidentifiedWebSocket();
|
||||
unidentifiedWebSocketStateDisposable = new CompositeDisposable();
|
||||
|
||||
Disposable state = unidentifiedWebSocket.connect()
|
||||
.subscribeOn(Schedulers.computation())
|
||||
.observeOn(Schedulers.computation())
|
||||
.subscribe(unidentifiedWebSocketState::onNext);
|
||||
unidentifiedWebSocketStateDisposable.add(state);
|
||||
}
|
||||
return unidentifiedWebSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keep-alive messages over both WebSocketConnections.
|
||||
*/
|
||||
public synchronized void sendKeepAlive() throws IOException {
|
||||
if (canConnect) {
|
||||
try {
|
||||
getWebSocket().sendKeepAlive();
|
||||
getUnidentifiedWebSocket().sendKeepAlive();
|
||||
} catch (WebSocketUnavailableException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Single<WebsocketResponse> request(WebSocketRequestMessage requestMessage) {
|
||||
try {
|
||||
return getWebSocket().sendRequest(requestMessage);
|
||||
} catch (IOException e) {
|
||||
return Single.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public Single<WebsocketResponse> request(WebSocketRequestMessage requestMessage, Optional<UnidentifiedAccess> unidentifiedAccess) {
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
List<String> headers = new ArrayList<>(requestMessage.headers);
|
||||
headers.add("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey()));
|
||||
WebSocketRequestMessage message = requestMessage.newBuilder()
|
||||
.headers(headers)
|
||||
.build();
|
||||
try {
|
||||
return getUnidentifiedWebSocket().sendRequest(message)
|
||||
.flatMap(r -> {
|
||||
if (r.getStatus() == 401) {
|
||||
return request(requestMessage);
|
||||
}
|
||||
return Single.just(r);
|
||||
});
|
||||
} catch (IOException e) {
|
||||
return Single.error(e);
|
||||
}
|
||||
} else {
|
||||
return request(requestMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reads a batch of messages off of the websocket.
|
||||
*
|
||||
* Rather than just provide you the batch as a return value, it will invoke the provided callback with the
|
||||
* batch as an argument. If you are able to successfully process them, this method will then ack all of the
|
||||
* messages so that they won't be re-delivered in the future.
|
||||
*
|
||||
* The return value of this method is a boolean indicating whether or not there are more messages in the
|
||||
* queue to be read (true if there's still more, or false if you've drained everything).
|
||||
*
|
||||
* However, this return value is only really useful the first time you read from the websocket. That's because
|
||||
* the websocket will only ever let you know if it's drained *once* for any given connection. So if this method
|
||||
* returns false, a subsequent call while using the same websocket connection will simply block until we either
|
||||
* get a new message or hit the timeout.
|
||||
*
|
||||
* Concerning the requested batch size, it's worth noting that this is simply an upper bound. This method will
|
||||
* not wait extra time until the batch has "filled up". Instead, it will wait for a single message, and then
|
||||
* take any extra messages that are also available up until you've hit your batch size.
|
||||
*/
|
||||
@SuppressWarnings("DuplicateThrows")
|
||||
public boolean readMessageBatch(long timeout, int batchSize, MessageReceivedCallback callback)
|
||||
throws TimeoutException, WebSocketUnavailableException, IOException
|
||||
{
|
||||
List<EnvelopeResponse> responses = new ArrayList<>();
|
||||
boolean hitEndOfQueue = false;
|
||||
|
||||
Optional<EnvelopeResponse> firstEnvelope = waitForSingleMessage(timeout);
|
||||
|
||||
if (firstEnvelope.isPresent()) {
|
||||
responses.add(firstEnvelope.get());
|
||||
} else {
|
||||
hitEndOfQueue = true;
|
||||
}
|
||||
|
||||
if (!hitEndOfQueue) {
|
||||
for (int i = 1; i < batchSize; i++) {
|
||||
Optional<WebSocketRequestMessage> request = getWebSocket().readRequestIfAvailable();
|
||||
|
||||
if (request.isPresent()) {
|
||||
if (isSignalServiceEnvelope(request.get())) {
|
||||
responses.add(requestToEnvelopeResponse(request.get()));
|
||||
} else if (isSocketEmptyRequest(request.get())) {
|
||||
hitEndOfQueue = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.size() > 0) {
|
||||
callback.onMessageBatch(responses);
|
||||
}
|
||||
|
||||
return !hitEndOfQueue;
|
||||
}
|
||||
|
||||
public void sendAck(EnvelopeResponse response) throws IOException {
|
||||
getWebSocket().sendResponse(createWebSocketResponse(response.getWebsocketRequest()));
|
||||
}
|
||||
|
||||
@SuppressWarnings("DuplicateThrows")
|
||||
private Optional<EnvelopeResponse> waitForSingleMessage(long timeout)
|
||||
throws TimeoutException, WebSocketUnavailableException, IOException
|
||||
{
|
||||
while (true) {
|
||||
WebSocketRequestMessage request = getWebSocket().readRequest(timeout);
|
||||
|
||||
if (isSignalServiceEnvelope(request)) {
|
||||
return Optional.of(requestToEnvelopeResponse(request));
|
||||
} else if (isSocketEmptyRequest(request)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static EnvelopeResponse requestToEnvelopeResponse(WebSocketRequestMessage request)
|
||||
throws IOException
|
||||
{
|
||||
Optional<String> timestampHeader = findHeader(request);
|
||||
long timestamp = 0;
|
||||
|
||||
if (timestampHeader.isPresent()) {
|
||||
try {
|
||||
timestamp = Long.parseLong(timestampHeader.get());
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Failed to parse " + SERVER_DELIVERED_TIMESTAMP_HEADER);
|
||||
}
|
||||
}
|
||||
|
||||
Envelope envelope = Envelope.ADAPTER.decode(request.body.toByteArray());
|
||||
|
||||
return new EnvelopeResponse(envelope, timestamp, request);
|
||||
}
|
||||
|
||||
private static boolean isSignalServiceEnvelope(WebSocketRequestMessage message) {
|
||||
return "PUT".equals(message.verb) && "/api/v1/message".equals(message.path);
|
||||
}
|
||||
|
||||
private static boolean isSocketEmptyRequest(WebSocketRequestMessage message) {
|
||||
return "PUT".equals(message.verb) && "/api/v1/queue/empty".equals(message.path);
|
||||
}
|
||||
|
||||
private static WebSocketResponseMessage createWebSocketResponse(WebSocketRequestMessage request) {
|
||||
if (isSignalServiceEnvelope(request)) {
|
||||
return new WebSocketResponseMessage.Builder()
|
||||
.id(request.id)
|
||||
.status(200)
|
||||
.message("OK")
|
||||
.build();
|
||||
} else {
|
||||
return new WebSocketResponseMessage.Builder()
|
||||
.id(request.id)
|
||||
.status(400)
|
||||
.message("Unknown")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<String> findHeader(WebSocketRequestMessage message) {
|
||||
if (message.headers.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
for (String header : message.headers) {
|
||||
if (header.startsWith(SERVER_DELIVERED_TIMESTAMP_HEADER)) {
|
||||
String[] split = header.split(":");
|
||||
if (split.length == 2 && split[0].trim().toLowerCase().equals(SERVER_DELIVERED_TIMESTAMP_HEADER.toLowerCase())) {
|
||||
return Optional.of(split[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* For receiving a callback when a new message has been
|
||||
* received.
|
||||
*/
|
||||
public interface MessageReceivedCallback {
|
||||
|
||||
/** Called with the batch of envelopes. You are responsible for sending acks. **/
|
||||
void onMessageBatch(List<EnvelopeResponse> envelopeResponses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
public final class SvrNoDataException extends Exception {
|
||||
|
||||
public SvrNoDataException() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
public final class SvrPinData {
|
||||
|
||||
private final MasterKey masterKey;
|
||||
private final TokenResponse tokenResponse;
|
||||
|
||||
// Visible for testing
|
||||
public SvrPinData(MasterKey masterKey, TokenResponse tokenResponse) {
|
||||
this.masterKey = masterKey;
|
||||
this.tokenResponse = tokenResponse;
|
||||
}
|
||||
|
||||
public MasterKey getMasterKey() {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
public TokenResponse getTokenResponse() {
|
||||
return tokenResponse;
|
||||
}
|
||||
|
||||
public int getRemainingTries() {
|
||||
return tokenResponse.getTries();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
|
||||
class TokenException extends Exception {
|
||||
|
||||
private final TokenResponse nextToken;
|
||||
private final boolean canAutomaticallyRetry;
|
||||
|
||||
TokenException(TokenResponse nextToken, boolean canAutomaticallyRetry) {
|
||||
this.nextToken = nextToken;
|
||||
this.canAutomaticallyRetry = canAutomaticallyRetry;
|
||||
}
|
||||
|
||||
public TokenResponse getToken() {
|
||||
return nextToken;
|
||||
}
|
||||
|
||||
public boolean isCanAutomaticallyRetry() {
|
||||
return canAutomaticallyRetry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
package org.whispersystems.signalservice.api.account
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class AccountAttributes @JsonCreator constructor(
|
||||
@JsonProperty val signalingKey: String?,
|
||||
@JsonProperty val registrationId: Int,
|
||||
@JsonProperty val voice: Boolean,
|
||||
@JsonProperty val video: Boolean,
|
||||
@JsonProperty val fetchesMessages: Boolean,
|
||||
@JsonProperty val registrationLock: String?,
|
||||
@JsonProperty val unidentifiedAccessKey: ByteArray?,
|
||||
@JsonProperty val unrestrictedUnidentifiedAccess: Boolean,
|
||||
@JsonProperty val discoverableByPhoneNumber: Boolean,
|
||||
@JsonProperty val capabilities: Capabilities?,
|
||||
@JsonProperty val name: String?,
|
||||
@JsonProperty val pniRegistrationId: Int,
|
||||
@JsonProperty val recoveryPassword: String?
|
||||
) {
|
||||
constructor(
|
||||
signalingKey: String?,
|
||||
registrationId: Int,
|
||||
fetchesMessages: Boolean,
|
||||
registrationLock: String?,
|
||||
unidentifiedAccessKey: ByteArray?,
|
||||
unrestrictedUnidentifiedAccess: Boolean,
|
||||
capabilities: Capabilities?,
|
||||
discoverableByPhoneNumber: Boolean,
|
||||
name: String?,
|
||||
pniRegistrationId: Int,
|
||||
recoveryPassword: String?
|
||||
) : this(
|
||||
signalingKey = signalingKey,
|
||||
registrationId = registrationId,
|
||||
voice = true,
|
||||
video = true,
|
||||
fetchesMessages = fetchesMessages,
|
||||
registrationLock = registrationLock,
|
||||
unidentifiedAccessKey = unidentifiedAccessKey,
|
||||
unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess,
|
||||
discoverableByPhoneNumber = discoverableByPhoneNumber,
|
||||
capabilities = capabilities,
|
||||
name = name,
|
||||
pniRegistrationId = pniRegistrationId,
|
||||
recoveryPassword = recoveryPassword
|
||||
)
|
||||
|
||||
data class Capabilities @JsonCreator constructor(
|
||||
@JsonProperty val storage: Boolean,
|
||||
@JsonProperty val senderKey: Boolean,
|
||||
@JsonProperty val announcementGroup: Boolean,
|
||||
@JsonProperty val changeNumber: Boolean,
|
||||
@JsonProperty val stories: Boolean,
|
||||
@JsonProperty val giftBadges: Boolean,
|
||||
@JsonProperty val pni: Boolean,
|
||||
@JsonProperty val paymentActivation: Boolean
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.whispersystems.signalservice.api.account;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
|
||||
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ChangePhoneNumberRequest {
|
||||
@JsonProperty
|
||||
private String sessionId;
|
||||
|
||||
@JsonProperty
|
||||
private String recoveryPassword;
|
||||
|
||||
@JsonProperty
|
||||
private String number;
|
||||
|
||||
@JsonProperty("reglock")
|
||||
private String registrationLock;
|
||||
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = JsonUtil.IdentityKeySerializer.class)
|
||||
@JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class)
|
||||
private IdentityKey pniIdentityKey;
|
||||
|
||||
@JsonProperty
|
||||
private List<OutgoingPushMessage> deviceMessages;
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, SignedPreKeyEntity> devicePniSignedPrekeys;
|
||||
|
||||
@JsonProperty("devicePniPqLastResortPrekeys")
|
||||
private Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys;
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, Integer> pniRegistrationIds;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public ChangePhoneNumberRequest() {}
|
||||
|
||||
public ChangePhoneNumberRequest(String sessionId,
|
||||
String recoveryPassword,
|
||||
String number,
|
||||
String registrationLock,
|
||||
IdentityKey pniIdentityKey,
|
||||
List<OutgoingPushMessage> deviceMessages,
|
||||
Map<String, SignedPreKeyEntity> devicePniSignedPrekeys,
|
||||
Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys,
|
||||
Map<String, Integer> pniRegistrationIds)
|
||||
{
|
||||
this.sessionId = sessionId;
|
||||
this.recoveryPassword = recoveryPassword;
|
||||
this.number = number;
|
||||
this.registrationLock = registrationLock;
|
||||
this.pniIdentityKey = pniIdentityKey;
|
||||
this.deviceMessages = deviceMessages;
|
||||
this.devicePniSignedPrekeys = devicePniSignedPrekeys;
|
||||
this.devicePniLastResortKyberPrekeys = devicePniLastResortKyberPrekeys;
|
||||
this.pniRegistrationIds = pniRegistrationIds;
|
||||
}
|
||||
|
||||
public String getNumber() {
|
||||
return number;
|
||||
}
|
||||
|
||||
public String getRegistrationLock() {
|
||||
return registrationLock;
|
||||
}
|
||||
|
||||
public IdentityKey getPniIdentityKey() {
|
||||
return pniIdentityKey;
|
||||
}
|
||||
|
||||
public List<OutgoingPushMessage> getDeviceMessages() {
|
||||
return deviceMessages;
|
||||
}
|
||||
|
||||
public Map<String, SignedPreKeyEntity> getDevicePniSignedPrekeys() {
|
||||
return devicePniSignedPrekeys;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getPniRegistrationIds() {
|
||||
return pniRegistrationIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.whispersystems.signalservice.api.account;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
|
||||
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class PniKeyDistributionRequest {
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = JsonUtil.IdentityKeySerializer.class)
|
||||
@JsonDeserialize(using = JsonUtil.IdentityKeyDeserializer.class)
|
||||
private IdentityKey pniIdentityKey;
|
||||
|
||||
@JsonProperty
|
||||
private List<OutgoingPushMessage> deviceMessages;
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, SignedPreKeyEntity> devicePniSignedPrekeys;
|
||||
|
||||
@JsonProperty("devicePniPqLastResortPrekeys")
|
||||
private Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys;
|
||||
|
||||
@JsonProperty
|
||||
private Map<String, Integer> pniRegistrationIds;
|
||||
|
||||
@JsonProperty
|
||||
private boolean signatureValidOnEachSignedPreKey;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public PniKeyDistributionRequest() {}
|
||||
|
||||
public PniKeyDistributionRequest(IdentityKey pniIdentityKey,
|
||||
List<OutgoingPushMessage> deviceMessages,
|
||||
Map<String, SignedPreKeyEntity> devicePniSignedPrekeys,
|
||||
Map<String, KyberPreKeyEntity> devicePniLastResortKyberPrekeys,
|
||||
Map<String, Integer> pniRegistrationIds,
|
||||
boolean signatureValidOnEachSignedPreKey)
|
||||
{
|
||||
this.pniIdentityKey = pniIdentityKey;
|
||||
this.deviceMessages = deviceMessages;
|
||||
this.devicePniSignedPrekeys = devicePniSignedPrekeys;
|
||||
this.devicePniLastResortKyberPrekeys = devicePniLastResortKyberPrekeys;
|
||||
this.pniRegistrationIds = pniRegistrationIds;
|
||||
this.signatureValidOnEachSignedPreKey = signatureValidOnEachSignedPreKey;
|
||||
}
|
||||
|
||||
public IdentityKey getPniIdentityKey() {
|
||||
return pniIdentityKey;
|
||||
}
|
||||
|
||||
public List<OutgoingPushMessage> getDeviceMessages() {
|
||||
return deviceMessages;
|
||||
}
|
||||
|
||||
public Map<String, SignedPreKeyEntity> getDevicePniSignedPrekeys() {
|
||||
return devicePniSignedPrekeys;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getPniRegistrationIds() {
|
||||
return pniRegistrationIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.account
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
|
||||
/**
|
||||
* Holder class to pass around a bunch of prekeys that we send off to the service during registration.
|
||||
* As the service does not return the submitted prekeys, we need to hold them in memory so that when
|
||||
* the service approves the keys we have a local copy to persist.
|
||||
*/
|
||||
data class PreKeyCollection(
|
||||
val identityKey: IdentityKey,
|
||||
val signedPreKey: SignedPreKeyRecord,
|
||||
val lastResortKyberPreKey: KyberPreKeyRecord
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.account
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType
|
||||
|
||||
/**
|
||||
* Represents a bundle of prekeys you want to upload.
|
||||
*
|
||||
* If a field is nullable, not setting it will simply leave that field alone on the service.
|
||||
*/
|
||||
data class PreKeyUpload(
|
||||
val serviceIdType: ServiceIdType,
|
||||
val identityKey: IdentityKey,
|
||||
val signedPreKey: SignedPreKeyRecord?,
|
||||
val oneTimeEcPreKeys: List<PreKeyRecord>?,
|
||||
val lastResortKyberPreKey: KyberPreKeyRecord?,
|
||||
val oneTimeKyberPreKeys: List<KyberPreKeyRecord>?
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2017 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.InvalidMacException;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.incrementalmac.ChunkSizeChoice;
|
||||
import org.signal.libsignal.protocol.incrementalmac.IncrementalMacInputStream;
|
||||
import org.signal.libsignal.protocol.kdf.HKDFv3;
|
||||
import org.whispersystems.signalservice.internal.util.ContentLengthInputStream;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.ShortBufferException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Class for streaming an encrypted push attachment off disk.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
|
||||
public class AttachmentCipherInputStream extends FilterInputStream {
|
||||
|
||||
private static final int BLOCK_SIZE = 16;
|
||||
private static final int CIPHER_KEY_SIZE = 32;
|
||||
private static final int MAC_KEY_SIZE = 32;
|
||||
|
||||
private Cipher cipher;
|
||||
private boolean done;
|
||||
private long totalDataSize;
|
||||
private long totalRead;
|
||||
private byte[] overflowBuffer;
|
||||
|
||||
/**
|
||||
* Passing in a null incrementalDigest and/or 0 for the chunk size at the call site disables incremental mac validation.
|
||||
*/
|
||||
public static InputStream createForAttachment(File file, long plaintextLength, byte[] combinedKeyMaterial, byte[] digest, byte[] incrementalDigest, int incrementalMacChunkSize)
|
||||
throws InvalidMessageException, IOException
|
||||
{
|
||||
try {
|
||||
byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(parts[1], "HmacSHA256"));
|
||||
|
||||
if (file.length() <= BLOCK_SIZE + mac.getMacLength()) {
|
||||
throw new InvalidMessageException("Message shorter than crypto overhead!");
|
||||
}
|
||||
|
||||
if (digest == null) {
|
||||
throw new InvalidMacException("Missing digest!");
|
||||
}
|
||||
|
||||
|
||||
final InputStream wrappedStream;
|
||||
final boolean hasIncrementalMac = incrementalDigest != null && incrementalDigest.length > 0 && incrementalMacChunkSize > 0;
|
||||
|
||||
if (!hasIncrementalMac) {
|
||||
try (FileInputStream macVerificationStream = new FileInputStream(file)) {
|
||||
verifyMac(macVerificationStream, file.length(), mac, digest);
|
||||
}
|
||||
wrappedStream = new FileInputStream(file);
|
||||
} else {
|
||||
wrappedStream = new IncrementalMacInputStream(
|
||||
new FileInputStream(file),
|
||||
parts[1],
|
||||
ChunkSizeChoice.everyNthByte(incrementalMacChunkSize),
|
||||
incrementalDigest);
|
||||
}
|
||||
InputStream inputStream = new AttachmentCipherInputStream(wrappedStream, parts[0], file.length() - BLOCK_SIZE - mac.getMacLength());
|
||||
|
||||
if (plaintextLength != 0) {
|
||||
inputStream = new ContentLengthInputStream(inputStream, plaintextLength);
|
||||
}
|
||||
|
||||
return inputStream;
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (InvalidMacException e) {
|
||||
throw new InvalidMessageException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static InputStream createForStickerData(byte[] data, byte[] packKey)
|
||||
throws InvalidMessageException, IOException
|
||||
{
|
||||
try {
|
||||
byte[] combinedKeyMaterial = new HKDFv3().deriveSecrets(packKey, "Sticker Pack".getBytes(), 64);
|
||||
byte[][] parts = Util.split(combinedKeyMaterial, CIPHER_KEY_SIZE, MAC_KEY_SIZE);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(parts[1], "HmacSHA256"));
|
||||
|
||||
if (data.length <= BLOCK_SIZE + mac.getMacLength()) {
|
||||
throw new InvalidMessageException("Message shorter than crypto overhead!");
|
||||
}
|
||||
|
||||
try (InputStream inputStream = new ByteArrayInputStream(data)) {
|
||||
verifyMac(inputStream, data.length, mac, null);
|
||||
}
|
||||
|
||||
return new AttachmentCipherInputStream(new ByteArrayInputStream(data), parts[0], data.length - BLOCK_SIZE - mac.getMacLength());
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (InvalidMacException e) {
|
||||
throw new InvalidMessageException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private AttachmentCipherInputStream(InputStream inputStream, byte[] cipherKey, long totalDataSize)
|
||||
throws IOException
|
||||
{
|
||||
super(inputStream);
|
||||
|
||||
try {
|
||||
byte[] iv = new byte[BLOCK_SIZE];
|
||||
readFully(iv);
|
||||
|
||||
this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
this.cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
this.done = false;
|
||||
this.totalRead = 0;
|
||||
this.totalDataSize = totalDataSize;
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
byte[] buffer = new byte[1];
|
||||
int read;
|
||||
|
||||
//noinspection StatementWithEmptyBody
|
||||
while ((read = read(buffer)) == 0) ;
|
||||
|
||||
return (read == -1) ? -1 : ((int) buffer[0]) & 0xFF;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer) throws IOException {
|
||||
return read(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
if (totalRead != totalDataSize) {
|
||||
return readIncremental(buffer, offset, length);
|
||||
} else if (!done) {
|
||||
return readFinal(buffer, offset, length);
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markSupported() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(long byteCount) throws IOException {
|
||||
long skipped = 0L;
|
||||
while (skipped < byteCount) {
|
||||
byte[] buf = new byte[Math.min(4096, (int) (byteCount - skipped))];
|
||||
int read = read(buf);
|
||||
|
||||
skipped += read;
|
||||
}
|
||||
|
||||
return skipped;
|
||||
}
|
||||
|
||||
private int readFinal(byte[] buffer, int offset, int length) throws IOException {
|
||||
try {
|
||||
byte[] internal = new byte[buffer.length];
|
||||
int actualLength = Math.min(length, cipher.doFinal(internal, 0));
|
||||
System.arraycopy(internal, 0, buffer, offset, actualLength);
|
||||
|
||||
done = true;
|
||||
return actualLength;
|
||||
} catch (IllegalBlockSizeException | BadPaddingException | ShortBufferException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private int readIncremental(byte[] buffer, int offset, int length) throws IOException {
|
||||
int readLength = 0;
|
||||
if (null != overflowBuffer) {
|
||||
if (overflowBuffer.length > length) {
|
||||
System.arraycopy(overflowBuffer, 0, buffer, offset, length);
|
||||
overflowBuffer = Arrays.copyOfRange(overflowBuffer, length, overflowBuffer.length);
|
||||
return length;
|
||||
} else if (overflowBuffer.length == length) {
|
||||
System.arraycopy(overflowBuffer, 0, buffer, offset, length);
|
||||
overflowBuffer = null;
|
||||
return length;
|
||||
} else {
|
||||
System.arraycopy(overflowBuffer, 0, buffer, offset, overflowBuffer.length);
|
||||
readLength += overflowBuffer.length;
|
||||
offset += readLength;
|
||||
length -= readLength;
|
||||
overflowBuffer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (length + totalRead > totalDataSize)
|
||||
length = (int) (totalDataSize - totalRead);
|
||||
|
||||
byte[] internalBuffer = new byte[length];
|
||||
int read = super.read(internalBuffer, 0, internalBuffer.length <= cipher.getBlockSize() ? internalBuffer.length : internalBuffer.length - cipher.getBlockSize());
|
||||
totalRead += read;
|
||||
|
||||
try {
|
||||
int outputLen = cipher.getOutputSize(read);
|
||||
|
||||
if (outputLen <= length) {
|
||||
readLength += cipher.update(internalBuffer, 0, read, buffer, offset);
|
||||
return readLength;
|
||||
}
|
||||
|
||||
byte[] transientBuffer = new byte[outputLen];
|
||||
outputLen = cipher.update(internalBuffer, 0, read, transientBuffer, 0);
|
||||
if (outputLen <= length) {
|
||||
System.arraycopy(transientBuffer, 0, buffer, offset, outputLen);
|
||||
readLength += outputLen;
|
||||
} else {
|
||||
System.arraycopy(transientBuffer, 0, buffer, offset, length);
|
||||
overflowBuffer = Arrays.copyOfRange(transientBuffer, length, outputLen);
|
||||
readLength += length;
|
||||
}
|
||||
return readLength;
|
||||
} catch (ShortBufferException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyMac(InputStream inputStream, long length, Mac mac, byte[] theirDigest)
|
||||
throws InvalidMacException
|
||||
{
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA256");
|
||||
int remainingData = Util.toIntExact(length) - mac.getMacLength();
|
||||
byte[] buffer = new byte[4096];
|
||||
|
||||
while (remainingData > 0) {
|
||||
int read = inputStream.read(buffer, 0, Math.min(buffer.length, remainingData));
|
||||
mac.update(buffer, 0, read);
|
||||
digest.update(buffer, 0, read);
|
||||
remainingData -= read;
|
||||
}
|
||||
|
||||
byte[] ourMac = mac.doFinal();
|
||||
byte[] theirMac = new byte[mac.getMacLength()];
|
||||
Util.readFully(inputStream, theirMac);
|
||||
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw new InvalidMacException("MAC doesn't match!");
|
||||
}
|
||||
|
||||
byte[] ourDigest = digest.digest(theirMac);
|
||||
|
||||
if (theirDigest != null && !MessageDigest.isEqual(ourDigest, theirDigest)) {
|
||||
throw new InvalidMacException("Digest doesn't match!");
|
||||
}
|
||||
|
||||
} catch (IOException | ArithmeticException e1) {
|
||||
throw new InvalidMacException(e1);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void readFully(byte[] buffer) throws IOException {
|
||||
int offset = 0;
|
||||
|
||||
for (; ; ) {
|
||||
int read = super.read(buffer, offset, buffer.length - offset);
|
||||
|
||||
if (read + offset < buffer.length) {
|
||||
offset += read;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2017 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class AttachmentCipherOutputStream extends DigestingOutputStream {
|
||||
|
||||
private final Cipher cipher;
|
||||
private final Mac mac;
|
||||
|
||||
public AttachmentCipherOutputStream(byte[] combinedKeyMaterial,
|
||||
byte[] iv,
|
||||
OutputStream outputStream)
|
||||
throws IOException
|
||||
{
|
||||
super(outputStream);
|
||||
try {
|
||||
this.cipher = initializeCipher();
|
||||
this.mac = initializeMac();
|
||||
byte[][] keyParts = Util.split(combinedKeyMaterial, 32, 32);
|
||||
|
||||
if (iv == null) {
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES"));
|
||||
} else {
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES"), new IvParameterSpec(iv));
|
||||
}
|
||||
|
||||
this.mac.init(new SecretKeySpec(keyParts[1], "HmacSHA256"));
|
||||
|
||||
mac.update(cipher.getIV());
|
||||
super.write(cipher.getIV());
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer) throws IOException {
|
||||
write(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int length) throws IOException {
|
||||
byte[] ciphertext = cipher.update(buffer, offset, length);
|
||||
|
||||
if (ciphertext != null) {
|
||||
mac.update(ciphertext);
|
||||
super.write(ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) {
|
||||
throw new AssertionError("NYI");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
try {
|
||||
byte[] ciphertext = cipher.doFinal();
|
||||
byte[] auth = mac.doFinal(ciphertext);
|
||||
|
||||
super.write(ciphertext);
|
||||
super.write(auth);
|
||||
|
||||
super.flush();
|
||||
} catch (IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Mac initializeMac() {
|
||||
try {
|
||||
return Mac.getInstance("HmacSHA256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Cipher initializeCipher() {
|
||||
try {
|
||||
return Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
class AttachmentCipherStreamUtil {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getCiphertextLength(plaintextLength: Long): Long {
|
||||
return 16 + (plaintextLength / 16 + 1) * 16 + 32
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPlaintextLength(ciphertextLength: Long): Long {
|
||||
return ((ciphertextLength - 16 - 32) / 16 - 1) * 16
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public enum ContentHint {
|
||||
/** This message has content, but you shouldn't expect it to be re-sent to you. */
|
||||
DEFAULT(UnidentifiedSenderMessageContent.CONTENT_HINT_DEFAULT),
|
||||
|
||||
/** You should expect to be able to have this content be re-sent to you. */
|
||||
RESENDABLE(UnidentifiedSenderMessageContent.CONTENT_HINT_RESENDABLE),
|
||||
|
||||
/** This message has no real content and likely cannot be re-sent to you. */
|
||||
IMPLICIT(UnidentifiedSenderMessageContent.CONTENT_HINT_IMPLICIT);
|
||||
|
||||
private static final Map<Integer, ContentHint> TYPE_MAP = new HashMap<>();
|
||||
static {
|
||||
for (ContentHint value : values()) {
|
||||
TYPE_MAP.put(value.getType(), value);
|
||||
}
|
||||
}
|
||||
|
||||
private final int type;
|
||||
|
||||
ContentHint(int type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public static ContentHint fromType(int type) {
|
||||
return TYPE_MAP.getOrDefault(type, DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public final class CryptoUtil {
|
||||
|
||||
private static final String HMAC_SHA256 = "HmacSHA256";
|
||||
|
||||
private CryptoUtil() {
|
||||
}
|
||||
|
||||
public static byte[] hmacSha256(byte[] key, byte[] data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_SHA256);
|
||||
mac.init(new SecretKeySpec(key, HMAC_SHA256));
|
||||
return mac.doFinal(data);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] sha256(byte[] data) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return digest.digest(data);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public abstract class DigestingOutputStream extends FilterOutputStream {
|
||||
|
||||
private final MessageDigest runningDigest;
|
||||
|
||||
private byte[] digest;
|
||||
|
||||
public DigestingOutputStream(OutputStream outputStream) {
|
||||
super(outputStream);
|
||||
|
||||
try {
|
||||
this.runningDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer) throws IOException {
|
||||
runningDigest.update(buffer, 0, buffer.length);
|
||||
out.write(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
public void write(byte[] buffer, int offset, int length) throws IOException {
|
||||
runningDigest.update(buffer, offset, length);
|
||||
out.write(buffer, offset, length);
|
||||
}
|
||||
|
||||
public void write(int b) throws IOException {
|
||||
runningDigest.update((byte)b);
|
||||
out.write(b);
|
||||
}
|
||||
|
||||
public void flush() throws IOException {
|
||||
digest = runningDigest.digest();
|
||||
out.flush();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
out.close();
|
||||
}
|
||||
|
||||
public byte[] getTransmittedDigest() {
|
||||
return digest;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
||||
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException;
|
||||
import org.signal.libsignal.protocol.message.CiphertextMessage;
|
||||
import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
|
||||
import org.signal.libsignal.protocol.message.PlaintextContent;
|
||||
import org.whispersystems.signalservice.internal.push.Content;
|
||||
import org.whispersystems.signalservice.internal.push.Envelope.Type;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
import org.whispersystems.signalservice.internal.push.PushTransportDetails;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* An abstraction over the different types of message contents we can have.
|
||||
*/
|
||||
public interface EnvelopeContent {
|
||||
|
||||
/**
|
||||
* Processes the content using sealed sender.
|
||||
*/
|
||||
OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
|
||||
SignalSealedSessionCipher sealedSessionCipher,
|
||||
SignalProtocolAddress destination,
|
||||
SenderCertificate senderCertificate)
|
||||
throws UntrustedIdentityException, InvalidKeyException, NoSessionException;
|
||||
|
||||
/**
|
||||
* Processes the content using unsealed sender.
|
||||
*/
|
||||
OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) throws UntrustedIdentityException, NoSessionException;
|
||||
|
||||
/**
|
||||
* An estimated size, in bytes.
|
||||
*/
|
||||
int size();
|
||||
|
||||
/**
|
||||
* A content proto, if applicable.
|
||||
*/
|
||||
Optional<Content> getContent();
|
||||
|
||||
/**
|
||||
* Wrap {@link Content} you plan on sending as an encrypted message.
|
||||
* This is the default. Consider anything else exceptional.
|
||||
*/
|
||||
static EnvelopeContent encrypted(Content content, ContentHint contentHint, Optional<byte[]> groupId) {
|
||||
return new Encrypted(content, contentHint, groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link PlaintextContent}. This is exceptional, currently limited only to {@link DecryptionErrorMessage}.
|
||||
*/
|
||||
static EnvelopeContent plaintext(PlaintextContent content, Optional<byte[]> groupId) {
|
||||
return new Plaintext(content, groupId);
|
||||
}
|
||||
|
||||
class Encrypted implements EnvelopeContent {
|
||||
|
||||
private final Content content;
|
||||
private final ContentHint contentHint;
|
||||
private final Optional<byte[]> groupId;
|
||||
|
||||
public Encrypted(Content content, ContentHint contentHint, Optional<byte[]> groupId) {
|
||||
this.content = content;
|
||||
this.contentHint = contentHint;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
|
||||
SignalSealedSessionCipher sealedSessionCipher,
|
||||
SignalProtocolAddress destination,
|
||||
SenderCertificate senderCertificate)
|
||||
throws UntrustedIdentityException, InvalidKeyException, NoSessionException
|
||||
{
|
||||
PushTransportDetails transportDetails = new PushTransportDetails();
|
||||
CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.encode()));
|
||||
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(message,
|
||||
senderCertificate,
|
||||
contentHint.getType(),
|
||||
groupId);
|
||||
|
||||
byte[] ciphertext = sealedSessionCipher.encrypt(destination, messageContent);
|
||||
String body = Base64.encodeBytes(ciphertext);
|
||||
int remoteRegistrationId = sealedSessionCipher.getRemoteRegistrationId(destination);
|
||||
|
||||
return new OutgoingPushMessage(Type.UNIDENTIFIED_SENDER.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) throws UntrustedIdentityException, NoSessionException {
|
||||
PushTransportDetails transportDetails = new PushTransportDetails();
|
||||
CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.encode()));
|
||||
int remoteRegistrationId = sessionCipher.getRemoteRegistrationId();
|
||||
String body = Base64.encodeBytes(message.serialize());
|
||||
|
||||
int type;
|
||||
|
||||
switch (message.getType()) {
|
||||
case CiphertextMessage.PREKEY_TYPE: type = Type.PREKEY_BUNDLE.getValue(); break;
|
||||
case CiphertextMessage.WHISPER_TYPE: type = Type.CIPHERTEXT.getValue(); break;
|
||||
default: throw new AssertionError("Bad type: " + message.getType());
|
||||
}
|
||||
|
||||
return new OutgoingPushMessage(type, destination.getDeviceId(), remoteRegistrationId, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return Content.ADAPTER.encodedSize(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Content> getContent() {
|
||||
return Optional.of(content);
|
||||
}
|
||||
}
|
||||
|
||||
class Plaintext implements EnvelopeContent {
|
||||
|
||||
private final PlaintextContent plaintextContent;
|
||||
private final Optional<byte[]> groupId;
|
||||
|
||||
public Plaintext(PlaintextContent plaintextContent, Optional<byte[]> groupId) {
|
||||
this.plaintextContent = plaintextContent;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutgoingPushMessage processSealedSender(SignalSessionCipher sessionCipher,
|
||||
SignalSealedSessionCipher sealedSessionCipher,
|
||||
SignalProtocolAddress destination,
|
||||
SenderCertificate senderCertificate)
|
||||
throws UntrustedIdentityException, InvalidKeyException
|
||||
{
|
||||
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(plaintextContent,
|
||||
senderCertificate,
|
||||
ContentHint.IMPLICIT.getType(),
|
||||
groupId);
|
||||
|
||||
byte[] ciphertext = sealedSessionCipher.encrypt(destination, messageContent);
|
||||
String body = Base64.encodeBytes(ciphertext);
|
||||
int remoteRegistrationId = sealedSessionCipher.getRemoteRegistrationId(destination);
|
||||
|
||||
return new OutgoingPushMessage(Type.UNIDENTIFIED_SENDER.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) {
|
||||
String body = Base64.encodeBytes(plaintextContent.serialize());
|
||||
int remoteRegistrationId = sessionCipher.getRemoteRegistrationId();
|
||||
|
||||
return new OutgoingPushMessage(Type.PLAINTEXT_CONTENT.getValue(), destination.getDeviceId(), remoteRegistrationId, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return plaintextContent.getBody().length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Content> getContent() {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
|
||||
class EnvelopeMetadata(
|
||||
val sourceServiceId: ServiceId,
|
||||
val sourceE164: String?,
|
||||
val sourceDeviceId: Int,
|
||||
val sealedSender: Boolean,
|
||||
val groupId: ByteArray?,
|
||||
val destinationServiceId: ServiceId
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.whispersystems.util.StringUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.whispersystems.signalservice.api.crypto.CryptoUtil.hmacSha256;
|
||||
import static org.whispersystems.util.ByteArrayUtil.concat;
|
||||
import static org.whispersystems.util.ByteArrayUtil.xor;
|
||||
import static java.util.Arrays.copyOfRange;
|
||||
|
||||
/**
|
||||
* Encrypts or decrypts with a Synthetic IV.
|
||||
* <p>
|
||||
* Normal Java casing has been ignored to match original specifications.
|
||||
*/
|
||||
public final class HmacSIV {
|
||||
|
||||
private static final byte[] AUTH_BYTES = StringUtil.utf8("auth");
|
||||
private static final byte[] ENC_BYTES = StringUtil.utf8("enc");
|
||||
|
||||
/**
|
||||
* Encrypts M with K.
|
||||
*
|
||||
* @param K Key
|
||||
* @param M 32-byte Key to encrypt
|
||||
* @return (IV, C) 48-bytes: 16-byte Synthetic IV and 32-byte Ciphertext.
|
||||
*/
|
||||
public static byte[] encrypt(byte[] K, byte[] M) {
|
||||
if (K.length != 32) throw new AssertionError("K was wrong length");
|
||||
if (M.length != 32) throw new AssertionError("M was wrong length");
|
||||
|
||||
byte[] Ka = hmacSha256(K, AUTH_BYTES);
|
||||
byte[] Ke = hmacSha256(K, ENC_BYTES);
|
||||
byte[] IV = copyOfRange(hmacSha256(Ka, M), 0, 16);
|
||||
byte[] Kx = hmacSha256(Ke, IV);
|
||||
byte[] C = xor(Kx, M);
|
||||
return concat(IV, C);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts M from (IV, C) with K.
|
||||
*
|
||||
* @param K Key
|
||||
* @param IVC Output from {@link #encrypt(byte[], byte[])}
|
||||
* @return 32-byte M
|
||||
* @throws InvalidCiphertextException if the supplied IVC was not correct.
|
||||
*/
|
||||
public static byte[] decrypt(byte[] K, byte[] IVC) throws InvalidCiphertextException {
|
||||
if (K.length != 32) throw new AssertionError("K was wrong length");
|
||||
if (IVC.length != 48) throw new InvalidCiphertextException("IVC was wrong length");
|
||||
|
||||
byte[] IV = copyOfRange(IVC, 0, 16);
|
||||
byte[] C = copyOfRange(IVC, 16, 48);
|
||||
|
||||
byte[] Ka = hmacSha256(K, AUTH_BYTES);
|
||||
byte[] Ke = hmacSha256(K, ENC_BYTES);
|
||||
byte[] Kx = hmacSha256(Ke, IV);
|
||||
byte[] M = xor(Kx, C);
|
||||
|
||||
byte[] eExpectedIV = copyOfRange(hmacSha256(Ka, M), 0, 16);
|
||||
|
||||
if (Arrays.equals(IV, eExpectedIV)) {
|
||||
return M;
|
||||
} else {
|
||||
throw new InvalidCiphertextException("IV was incorrect");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
public class InvalidCiphertextException extends Exception {
|
||||
public InvalidCiphertextException(Exception nested) {
|
||||
super(nested);
|
||||
}
|
||||
|
||||
public InvalidCiphertextException(String s) {
|
||||
super(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Use when the stream is already encrypted.
|
||||
*/
|
||||
public final class NoCipherOutputStream extends DigestingOutputStream {
|
||||
|
||||
public NoCipherOutputStream(OutputStream outputStream) {
|
||||
super(outputStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class ProfileCipher {
|
||||
|
||||
private static final int NAME_PADDED_LENGTH_1 = 53;
|
||||
private static final int NAME_PADDED_LENGTH_2 = 257;
|
||||
private static final int ABOUT_PADDED_LENGTH_1 = 128;
|
||||
private static final int ABOUT_PADDED_LENGTH_2 = 254;
|
||||
private static final int ABOUT_PADDED_LENGTH_3 = 512;
|
||||
|
||||
public static final int MAX_POSSIBLE_NAME_LENGTH = NAME_PADDED_LENGTH_2;
|
||||
public static final int MAX_POSSIBLE_ABOUT_LENGTH = ABOUT_PADDED_LENGTH_3;
|
||||
public static final int EMOJI_PADDED_LENGTH = 32;
|
||||
public static final int ENCRYPTION_OVERHEAD = 28;
|
||||
|
||||
public static final int PAYMENTS_ADDRESS_BASE64_FIELD_SIZE = 776;
|
||||
public static final int PAYMENTS_ADDRESS_CONTENT_SIZE = PAYMENTS_ADDRESS_BASE64_FIELD_SIZE * 6 / 8 - ProfileCipher.ENCRYPTION_OVERHEAD;
|
||||
|
||||
private final ProfileKey key;
|
||||
|
||||
public ProfileCipher(ProfileKey key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an input and ensures padded length.
|
||||
* <p>
|
||||
* Padded length does not include {@link #ENCRYPTION_OVERHEAD}.
|
||||
*/
|
||||
public byte[] encrypt(byte[] input, int paddedLength) {
|
||||
try {
|
||||
byte[] inputPadded = new byte[paddedLength];
|
||||
|
||||
if (input.length > inputPadded.length) {
|
||||
throw new IllegalArgumentException("Input is too long: " + new String(input));
|
||||
}
|
||||
|
||||
System.arraycopy(input, 0, inputPadded, 0, input.length);
|
||||
|
||||
byte[] nonce = Util.getSecretBytes(12);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
|
||||
|
||||
byte[] encryptedPadded = ByteUtil.combine(nonce, cipher.doFinal(inputPadded));
|
||||
|
||||
if (encryptedPadded.length != (paddedLength + ENCRYPTION_OVERHEAD)) {
|
||||
throw new AssertionError(String.format(Locale.US, "Wrong output length %d != padded length %d + %d", encryptedPadded.length, paddedLength, ENCRYPTION_OVERHEAD));
|
||||
}
|
||||
|
||||
return encryptedPadded;
|
||||
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | BadPaddingException | NoSuchPaddingException | IllegalBlockSizeException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns original data with padding still intact.
|
||||
*/
|
||||
public byte[] decrypt(byte[] input) throws InvalidCiphertextException {
|
||||
try {
|
||||
if (input.length < 12 + 16 + 1) {
|
||||
throw new InvalidCiphertextException("Too short: " + input.length);
|
||||
}
|
||||
|
||||
byte[] nonce = new byte[12];
|
||||
System.arraycopy(input, 0, nonce, 0, nonce.length);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
|
||||
|
||||
return cipher.doFinal(input, nonce.length, input.length - nonce.length);
|
||||
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (InvalidKeyException | BadPaddingException e) {
|
||||
throw new InvalidCiphertextException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string's UTF bytes representation.
|
||||
*/
|
||||
public byte[] encryptString(String input, int paddedLength) {
|
||||
return encrypt(input.getBytes(StandardCharsets.UTF_8), paddedLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips 0 char padding from decrypt result.
|
||||
*/
|
||||
public String decryptString(byte[] input) throws InvalidCiphertextException {
|
||||
byte[] paddedPlaintext = decrypt(input);
|
||||
int plaintextLength = 0;
|
||||
|
||||
for (int i = paddedPlaintext.length - 1; i >= 0; i--) {
|
||||
if (paddedPlaintext[i] != (byte) 0x00) {
|
||||
plaintextLength = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] plaintext = new byte[plaintextLength];
|
||||
System.arraycopy(paddedPlaintext, 0, plaintext, 0, plaintextLength);
|
||||
|
||||
return new String(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the length, and adds padding.
|
||||
* <p>
|
||||
* encrypt(input.length | input | padding)
|
||||
* <p>
|
||||
* Padded length does not include 28 bytes encryption overhead.
|
||||
*/
|
||||
public byte[] encryptWithLength(byte[] input, int paddedLength) {
|
||||
ByteBuffer content = ByteBuffer.wrap(new byte[input.length + 4]);
|
||||
content.order(ByteOrder.LITTLE_ENDIAN);
|
||||
content.putInt(input.length);
|
||||
content.put(input);
|
||||
return encrypt(content.array(), paddedLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts result from:
|
||||
* <p>
|
||||
* decrypt(encrypt(result.length | result | padding))
|
||||
*/
|
||||
public byte[] decryptWithLength(byte[] input) throws InvalidCiphertextException, IOException {
|
||||
byte[] decrypted = decrypt(input);
|
||||
int maxLength = decrypted.length - 4;
|
||||
ByteBuffer content = ByteBuffer.wrap(decrypted);
|
||||
content.order(ByteOrder.LITTLE_ENDIAN);
|
||||
int contentLength = content.getInt();
|
||||
if (contentLength > maxLength) {
|
||||
throw new IOException("Encoded length exceeds content length");
|
||||
}
|
||||
if (contentLength < 0) {
|
||||
throw new IOException("Encoded length is less than 0");
|
||||
}
|
||||
byte[] result = new byte[contentLength];
|
||||
content.get(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean verifyUnidentifiedAccess(byte[] theirUnidentifiedAccessVerifier) {
|
||||
try {
|
||||
if (theirUnidentifiedAccessVerifier == null || theirUnidentifiedAccessVerifier.length == 0) return false;
|
||||
|
||||
byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(key);
|
||||
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256"));
|
||||
|
||||
byte[] ourUnidentifiedAccessVerifier = mac.doFinal(new byte[32]);
|
||||
|
||||
return MessageDigest.isEqual(theirUnidentifiedAccessVerifier, ourUnidentifiedAccessVerifier);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static int getTargetNameLength(String name) {
|
||||
int nameLength = name.getBytes(StandardCharsets.UTF_8).length;
|
||||
|
||||
if (nameLength <= NAME_PADDED_LENGTH_1) {
|
||||
return NAME_PADDED_LENGTH_1;
|
||||
} else {
|
||||
return NAME_PADDED_LENGTH_2;
|
||||
}
|
||||
}
|
||||
|
||||
public static int getTargetAboutLength(String about) {
|
||||
int aboutLength = about.getBytes(StandardCharsets.UTF_8).length;
|
||||
|
||||
if (aboutLength <= ABOUT_PADDED_LENGTH_1) {
|
||||
return ABOUT_PADDED_LENGTH_1;
|
||||
} else if (aboutLength < ABOUT_PADDED_LENGTH_2){
|
||||
return ABOUT_PADDED_LENGTH_2;
|
||||
} else {
|
||||
return ABOUT_PADDED_LENGTH_3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.crypto.Aes256GcmDecryption;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.signal.libsignal.crypto.Aes256GcmDecryption.TAG_SIZE_IN_BYTES;
|
||||
|
||||
public class ProfileCipherInputStream extends FilterInputStream {
|
||||
|
||||
private Aes256GcmDecryption aes;
|
||||
|
||||
// The buffer size must match the length of the authentication tag.
|
||||
private byte[] buffer = new byte[TAG_SIZE_IN_BYTES];
|
||||
private byte[] swapBuffer = new byte[TAG_SIZE_IN_BYTES];
|
||||
|
||||
public ProfileCipherInputStream(InputStream in, ProfileKey key) throws IOException {
|
||||
super(in);
|
||||
|
||||
try {
|
||||
byte[] nonce = new byte[12];
|
||||
Util.readFully(in, nonce);
|
||||
Util.readFully(in, buffer);
|
||||
|
||||
this.aes = new Aes256GcmDecryption(key.serialize(), nonce, new byte[] {});
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
throw new AssertionError("Not supported!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] input) throws IOException {
|
||||
return read(input, 0, input.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] output, int outputOffset, int outputLength) throws IOException {
|
||||
if (aes == null) return -1;
|
||||
|
||||
int read = in.read(output, outputOffset, outputLength);
|
||||
|
||||
if (read == -1) {
|
||||
// We're done. The buffer has the final tag for authentication.
|
||||
Aes256GcmDecryption aes = this.aes;
|
||||
this.aes = null;
|
||||
if (!aes.verifyTag(this.buffer)) {
|
||||
throw new IOException("authentication of decrypted data failed");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (read < TAG_SIZE_IN_BYTES) {
|
||||
// swapBuffer = buffer[read..] + output[offset..][..read]
|
||||
// output[offset..][..read] = buffer[..read]
|
||||
System.arraycopy(this.buffer, read, this.swapBuffer, 0, TAG_SIZE_IN_BYTES - read);
|
||||
System.arraycopy(output, outputOffset, this.swapBuffer, TAG_SIZE_IN_BYTES - read, read);
|
||||
System.arraycopy(this.buffer, 0, output, outputOffset, read);
|
||||
} else if (read == TAG_SIZE_IN_BYTES) {
|
||||
// swapBuffer = output[offset..][..read]
|
||||
// output[offset..][..read] = buffer
|
||||
System.arraycopy(output, outputOffset, this.swapBuffer, 0, read);
|
||||
System.arraycopy(this.buffer, 0, output, outputOffset, read);
|
||||
} else {
|
||||
// swapBuffer = output[offset..][(read - TAG_SIZE)..read]
|
||||
// output[offset..][TAG_SIZE..read] = output[offset..][..(read - TAG_SIZE)]
|
||||
// output[offset..][..TAG_SIZE] = buffer
|
||||
System.arraycopy(output, outputOffset + read - TAG_SIZE_IN_BYTES, this.swapBuffer, 0, TAG_SIZE_IN_BYTES);
|
||||
System.arraycopy(output, outputOffset, output, outputOffset + TAG_SIZE_IN_BYTES, read - TAG_SIZE_IN_BYTES);
|
||||
System.arraycopy(this.buffer, 0, output, outputOffset, TAG_SIZE_IN_BYTES);
|
||||
}
|
||||
|
||||
// Now swapBuffer has the buffer for next time.
|
||||
byte[] temp = this.buffer;
|
||||
this.buffer = this.swapBuffer;
|
||||
this.swapBuffer = temp;
|
||||
|
||||
aes.decrypt(output, outputOffset, read);
|
||||
return read;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class ProfileCipherOutputStream extends DigestingOutputStream {
|
||||
|
||||
private final Cipher cipher;
|
||||
|
||||
public ProfileCipherOutputStream(OutputStream out, ProfileKey key) throws IOException {
|
||||
super(out);
|
||||
try {
|
||||
this.cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
|
||||
byte[] nonce = generateNonce();
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, nonce));
|
||||
|
||||
super.write(nonce, 0, nonce.length);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer) throws IOException {
|
||||
write(buffer, 0, buffer.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int length) throws IOException {
|
||||
byte[] output = cipher.update(buffer, offset, length);
|
||||
super.write(output);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
byte[] input = new byte[1];
|
||||
input[0] = (byte)b;
|
||||
|
||||
byte[] output = cipher.update(input);
|
||||
super.write(output);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
try {
|
||||
byte[] output = cipher.doFinal();
|
||||
|
||||
super.write(output);
|
||||
super.flush();
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] generateNonce() {
|
||||
byte[] nonce = new byte[12];
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public static long getCiphertextLength(long plaintextLength) {
|
||||
return 12 + 16 + plaintextLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.DuplicateMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.LegacyMessageException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.groups.GroupCipher;
|
||||
import org.signal.libsignal.protocol.message.CiphertextMessage;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A thread-safe wrapper around {@link GroupCipher}.
|
||||
*/
|
||||
public class SignalGroupCipher {
|
||||
|
||||
private final SignalSessionLock lock;
|
||||
private final GroupCipher cipher;
|
||||
|
||||
public SignalGroupCipher(SignalSessionLock lock, GroupCipher cipher) {
|
||||
this.lock = lock;
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
public CiphertextMessage encrypt(UUID distributionId, byte[] paddedPlaintext) throws NoSessionException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.encrypt(distributionId, paddedPlaintext);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] decrypt(byte[] senderKeyMessageBytes)
|
||||
throws LegacyMessageException, DuplicateMessageException, InvalidMessageException, NoSessionException
|
||||
{
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.decrypt(senderKeyMessageBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.SessionBuilder;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
|
||||
import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A thread-safe wrapper around {@link SessionBuilder}.
|
||||
*/
|
||||
public class SignalGroupSessionBuilder {
|
||||
|
||||
private final SignalSessionLock lock;
|
||||
private final GroupSessionBuilder builder;
|
||||
|
||||
public SignalGroupSessionBuilder(SignalSessionLock lock, GroupSessionBuilder builder) {
|
||||
this.lock = lock;
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
public void process(SignalProtocolAddress sender, SenderKeyDistributionMessage senderKeyDistributionMessage) {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
builder.process(sender, senderKeyDistributionMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public SenderKeyDistributionMessage create(SignalProtocolAddress sender, UUID distributionId) {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return builder.create(sender, distributionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.metadata.InvalidMetadataMessageException;
|
||||
import org.signal.libsignal.metadata.InvalidMetadataVersionException;
|
||||
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidVersionException;
|
||||
import org.signal.libsignal.metadata.ProtocolLegacyMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolNoSessionException;
|
||||
import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
|
||||
import org.signal.libsignal.metadata.SealedSessionCipher;
|
||||
import org.signal.libsignal.metadata.SelfSendException;
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator;
|
||||
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.InvalidRegistrationIdException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A thread-safe wrapper around {@link SealedSessionCipher}.
|
||||
*/
|
||||
public class SignalSealedSessionCipher {
|
||||
|
||||
private final SignalSessionLock lock;
|
||||
private final SealedSessionCipher cipher;
|
||||
|
||||
public SignalSealedSessionCipher(SignalSessionLock lock, SealedSessionCipher cipher) {
|
||||
this.lock = lock;
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
public byte[] encrypt(SignalProtocolAddress destinationAddress, UnidentifiedSenderMessageContent content)
|
||||
throws InvalidKeyException, UntrustedIdentityException
|
||||
{
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.encrypt(destinationAddress, content);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] multiRecipientEncrypt(List<SignalProtocolAddress> recipients, UnidentifiedSenderMessageContent content)
|
||||
throws InvalidKeyException, UntrustedIdentityException, NoSessionException, InvalidRegistrationIdException
|
||||
{
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.multiRecipientEncrypt(recipients, content);
|
||||
}
|
||||
}
|
||||
|
||||
public SealedSessionCipher.DecryptionResult decrypt(CertificateValidator validator, byte[] ciphertext, long timestamp) throws InvalidMetadataMessageException, InvalidMetadataVersionException, ProtocolInvalidMessageException, ProtocolInvalidKeyException, ProtocolNoSessionException, ProtocolLegacyMessageException, ProtocolInvalidVersionException, ProtocolDuplicateMessageException, ProtocolInvalidKeyIdException, ProtocolUntrustedIdentityException, SelfSendException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.decrypt(validator, ciphertext, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public int getSessionVersion(SignalProtocolAddress remoteAddress) {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.getSessionVersion(remoteAddress);
|
||||
}
|
||||
}
|
||||
|
||||
public int getRemoteRegistrationId(SignalProtocolAddress remoteAddress) {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.getRemoteRegistrationId(remoteAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.metadata.InvalidMetadataMessageException;
|
||||
import org.signal.libsignal.metadata.InvalidMetadataVersionException;
|
||||
import org.signal.libsignal.metadata.ProtocolDuplicateMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolInvalidVersionException;
|
||||
import org.signal.libsignal.metadata.ProtocolLegacyMessageException;
|
||||
import org.signal.libsignal.metadata.ProtocolNoSessionException;
|
||||
import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
|
||||
import org.signal.libsignal.metadata.SealedSessionCipher;
|
||||
import org.signal.libsignal.metadata.SealedSessionCipher.DecryptionResult;
|
||||
import org.signal.libsignal.metadata.SelfSendException;
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator;
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
||||
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
|
||||
import org.signal.libsignal.protocol.DuplicateMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.InvalidKeyIdException;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidRegistrationIdException;
|
||||
import org.signal.libsignal.protocol.InvalidSessionException;
|
||||
import org.signal.libsignal.protocol.InvalidVersionException;
|
||||
import org.signal.libsignal.protocol.LegacyMessageException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.SessionCipher;
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress;
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException;
|
||||
import org.signal.libsignal.protocol.groups.GroupCipher;
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.libsignal.protocol.message.CiphertextMessage;
|
||||
import org.signal.libsignal.protocol.message.PlaintextContent;
|
||||
import org.signal.libsignal.protocol.message.PreKeySignalMessage;
|
||||
import org.signal.libsignal.protocol.message.SignalMessage;
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.internal.push.Content;
|
||||
import org.whispersystems.signalservice.internal.push.Envelope;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
import org.whispersystems.signalservice.internal.push.PushTransportDetails;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* This is used to encrypt + decrypt received envelopes.
|
||||
*/
|
||||
public class SignalServiceCipher {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = SignalServiceCipher.class.getSimpleName();
|
||||
|
||||
private final SignalServiceAccountDataStore signalProtocolStore;
|
||||
private final SignalSessionLock sessionLock;
|
||||
private final SignalServiceAddress localAddress;
|
||||
private final int localDeviceId;
|
||||
private final CertificateValidator certificateValidator;
|
||||
|
||||
public SignalServiceCipher(SignalServiceAddress localAddress,
|
||||
int localDeviceId,
|
||||
SignalServiceAccountDataStore signalProtocolStore,
|
||||
SignalSessionLock sessionLock,
|
||||
CertificateValidator certificateValidator)
|
||||
{
|
||||
this.signalProtocolStore = signalProtocolStore;
|
||||
this.sessionLock = sessionLock;
|
||||
this.localAddress = localAddress;
|
||||
this.localDeviceId = localDeviceId;
|
||||
this.certificateValidator = certificateValidator;
|
||||
}
|
||||
|
||||
public byte[] encryptForGroup(DistributionId distributionId,
|
||||
List<SignalProtocolAddress> destinations,
|
||||
SenderCertificate senderCertificate,
|
||||
byte[] unpaddedMessage,
|
||||
ContentHint contentHint,
|
||||
Optional<byte[]> groupId)
|
||||
throws NoSessionException, UntrustedIdentityException, InvalidKeyException, InvalidRegistrationIdException
|
||||
{
|
||||
PushTransportDetails transport = new PushTransportDetails();
|
||||
SignalProtocolAddress localProtocolAddress = new SignalProtocolAddress(localAddress.getIdentifier(), localDeviceId);
|
||||
SignalGroupCipher groupCipher = new SignalGroupCipher(sessionLock, new GroupCipher(signalProtocolStore, localProtocolAddress));
|
||||
SignalSealedSessionCipher sessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber().orElse(null), localDeviceId));
|
||||
CiphertextMessage message = groupCipher.encrypt(distributionId.asUuid(), transport.getPaddedMessageBody(unpaddedMessage));
|
||||
UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(message,
|
||||
senderCertificate,
|
||||
contentHint.getType(),
|
||||
groupId);
|
||||
|
||||
return sessionCipher.multiRecipientEncrypt(destinations, messageContent);
|
||||
}
|
||||
|
||||
public OutgoingPushMessage encrypt(SignalProtocolAddress destination,
|
||||
Optional<UnidentifiedAccess> unidentifiedAccess,
|
||||
EnvelopeContent content)
|
||||
throws UntrustedIdentityException, InvalidKeyException
|
||||
{
|
||||
try {
|
||||
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, destination));
|
||||
if (unidentifiedAccess.isPresent()) {
|
||||
SignalSealedSessionCipher sealedSessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber()
|
||||
.orElse(null), localDeviceId));
|
||||
|
||||
return content.processSealedSender(sessionCipher, sealedSessionCipher, destination, unidentifiedAccess.get().getUnidentifiedCertificate());
|
||||
} else {
|
||||
return content.processUnsealedSender(sessionCipher, destination);
|
||||
}
|
||||
} catch (NoSessionException e) {
|
||||
throw new InvalidSessionException("Session not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public SignalServiceCipherResult decrypt(Envelope envelope, long serverDeliveredTimestamp)
|
||||
throws InvalidMetadataMessageException, InvalidMetadataVersionException,
|
||||
ProtocolInvalidKeyIdException, ProtocolLegacyMessageException,
|
||||
ProtocolUntrustedIdentityException, ProtocolNoSessionException,
|
||||
ProtocolInvalidVersionException, ProtocolInvalidMessageException,
|
||||
ProtocolInvalidKeyException, ProtocolDuplicateMessageException,
|
||||
SelfSendException, InvalidMessageStructureException
|
||||
{
|
||||
try {
|
||||
if (envelope.content != null) {
|
||||
Plaintext plaintext = decryptInternal(envelope, serverDeliveredTimestamp);
|
||||
Content content = Content.ADAPTER.decode(plaintext.getData());
|
||||
|
||||
return new SignalServiceCipherResult(
|
||||
content,
|
||||
new EnvelopeMetadata(
|
||||
plaintext.metadata.getSender().getServiceId(),
|
||||
plaintext.metadata.getSender().getNumber().orElse(null),
|
||||
plaintext.metadata.getSenderDevice(),
|
||||
plaintext.metadata.isNeedsReceipt(),
|
||||
plaintext.metadata.getGroupId().orElse(null),
|
||||
localAddress.getServiceId()
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new InvalidMetadataMessageException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Plaintext decryptInternal(Envelope envelope, long serverDeliveredTimestamp)
|
||||
throws InvalidMetadataMessageException, InvalidMetadataVersionException,
|
||||
ProtocolDuplicateMessageException, ProtocolUntrustedIdentityException,
|
||||
ProtocolLegacyMessageException, ProtocolInvalidKeyException,
|
||||
ProtocolInvalidVersionException, ProtocolInvalidMessageException,
|
||||
ProtocolInvalidKeyIdException, ProtocolNoSessionException,
|
||||
SelfSendException, InvalidMessageStructureException
|
||||
{
|
||||
try {
|
||||
|
||||
byte[] paddedMessage;
|
||||
SignalServiceMetadata metadata;
|
||||
|
||||
if (envelope.sourceServiceId == null && envelope.type != Envelope.Type.UNIDENTIFIED_SENDER) {
|
||||
throw new InvalidMessageStructureException("Non-UD envelope is missing a UUID!");
|
||||
}
|
||||
|
||||
if (envelope.type == Envelope.Type.PREKEY_BUNDLE) {
|
||||
SignalProtocolAddress sourceAddress = new SignalProtocolAddress(envelope.sourceServiceId, envelope.sourceDevice);
|
||||
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, sourceAddress));
|
||||
|
||||
paddedMessage = sessionCipher.decrypt(new PreKeySignalMessage(envelope.content.toByteArray()));
|
||||
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, envelope.serverGuid, Optional.empty(), envelope.destinationServiceId);
|
||||
|
||||
signalProtocolStore.clearSenderKeySharedWith(Collections.singleton(sourceAddress));
|
||||
} else if (envelope.type == Envelope.Type.CIPHERTEXT) {
|
||||
SignalProtocolAddress sourceAddress = new SignalProtocolAddress(envelope.sourceServiceId, envelope.sourceDevice);
|
||||
SignalSessionCipher sessionCipher = new SignalSessionCipher(sessionLock, new SessionCipher(signalProtocolStore, sourceAddress));
|
||||
|
||||
paddedMessage = sessionCipher.decrypt(new SignalMessage(envelope.content.toByteArray()));
|
||||
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, envelope.serverGuid, Optional.empty(), envelope.destinationServiceId);
|
||||
} else if (envelope.type == Envelope.Type.PLAINTEXT_CONTENT) {
|
||||
paddedMessage = new PlaintextContent(envelope.content.toByteArray()).getBody();
|
||||
metadata = new SignalServiceMetadata(getSourceAddress(envelope), envelope.sourceDevice, envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, false, envelope.serverGuid, Optional.empty(), envelope.destinationServiceId);
|
||||
} else if (envelope.type == Envelope.Type.UNIDENTIFIED_SENDER) {
|
||||
SignalSealedSessionCipher sealedSessionCipher = new SignalSealedSessionCipher(sessionLock, new SealedSessionCipher(signalProtocolStore, localAddress.getServiceId().getRawUuid(), localAddress.getNumber().orElse(null), localDeviceId));
|
||||
DecryptionResult result = sealedSessionCipher.decrypt(certificateValidator, envelope.content.toByteArray(), envelope.serverTimestamp);
|
||||
SignalServiceAddress resultAddress = new SignalServiceAddress(ACI.parseOrThrow(result.getSenderUuid()), result.getSenderE164());
|
||||
Optional<byte[]> groupId = result.getGroupId();
|
||||
boolean needsReceipt = true;
|
||||
|
||||
if (envelope.sourceServiceId != null) {
|
||||
Log.w(TAG, "[" + envelope.timestamp + "] Received a UD-encrypted message sent over an identified channel. Marking as needsReceipt=false");
|
||||
needsReceipt = false;
|
||||
}
|
||||
|
||||
if (result.getCiphertextMessageType() == CiphertextMessage.PREKEY_TYPE) {
|
||||
signalProtocolStore.clearSenderKeySharedWith(Collections.singleton(new SignalProtocolAddress(result.getSenderUuid(), result.getDeviceId())));
|
||||
}
|
||||
|
||||
paddedMessage = result.getPaddedMessage();
|
||||
metadata = new SignalServiceMetadata(resultAddress, result.getDeviceId(), envelope.timestamp, envelope.serverTimestamp, serverDeliveredTimestamp, needsReceipt, envelope.serverGuid, groupId, envelope.destinationServiceId);
|
||||
} else {
|
||||
throw new InvalidMetadataMessageException("Unknown type: " + envelope.type);
|
||||
}
|
||||
|
||||
PushTransportDetails transportDetails = new PushTransportDetails();
|
||||
byte[] data = transportDetails.getStrippedPaddingMessageBody(paddedMessage);
|
||||
|
||||
return new Plaintext(metadata, data);
|
||||
} catch (DuplicateMessageException e) {
|
||||
throw new ProtocolDuplicateMessageException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (LegacyMessageException e) {
|
||||
throw new ProtocolLegacyMessageException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (InvalidMessageException e) {
|
||||
throw new ProtocolInvalidMessageException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (InvalidKeyIdException e) {
|
||||
throw new ProtocolInvalidKeyIdException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new ProtocolInvalidKeyException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (UntrustedIdentityException e) {
|
||||
throw new ProtocolUntrustedIdentityException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (InvalidVersionException e) {
|
||||
throw new ProtocolInvalidVersionException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
} catch (NoSessionException e) {
|
||||
throw new ProtocolNoSessionException(e, envelope.sourceServiceId, envelope.sourceDevice);
|
||||
}
|
||||
}
|
||||
|
||||
private static SignalServiceAddress getSourceAddress(Envelope envelope) {
|
||||
return new SignalServiceAddress(ServiceId.parseOrNull(envelope.sourceServiceId));
|
||||
}
|
||||
|
||||
private static class Plaintext {
|
||||
private final SignalServiceMetadata metadata;
|
||||
private final byte[] data;
|
||||
|
||||
private Plaintext(SignalServiceMetadata metadata, byte[] data) {
|
||||
this.metadata = metadata;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public SignalServiceMetadata getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
|
||||
/**
|
||||
* Represents the output of decrypting a [SignalServiceProtos.Envelope] via [SignalServiceCipher.decrypt]
|
||||
*
|
||||
* @param content The [SignalServiceProtos.Content] that was decrypted from the envelope.
|
||||
* @param metadata The decrypted metadata of the envelope. Represents sender information that may have
|
||||
* been encrypted with sealed sender.
|
||||
*/
|
||||
data class SignalServiceCipherResult(
|
||||
val content: Content,
|
||||
val metadata: EnvelopeMetadata
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.SessionBuilder;
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException;
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
/**
|
||||
* A thread-safe wrapper around {@link SessionBuilder}.
|
||||
*/
|
||||
public class SignalSessionBuilder {
|
||||
|
||||
private final SignalSessionLock lock;
|
||||
private final SessionBuilder builder;
|
||||
|
||||
public SignalSessionBuilder(SignalSessionLock lock, SessionBuilder builder) {
|
||||
this.lock = lock;
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
public void process(PreKeyBundle preKey) throws InvalidKeyException, UntrustedIdentityException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
builder.process(preKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.DuplicateMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.InvalidKeyIdException;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidVersionException;
|
||||
import org.signal.libsignal.protocol.LegacyMessageException;
|
||||
import org.signal.libsignal.protocol.NoSessionException;
|
||||
import org.signal.libsignal.protocol.SessionCipher;
|
||||
import org.signal.libsignal.protocol.UntrustedIdentityException;
|
||||
import org.signal.libsignal.protocol.message.CiphertextMessage;
|
||||
import org.signal.libsignal.protocol.message.PreKeySignalMessage;
|
||||
import org.signal.libsignal.protocol.message.SignalMessage;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
|
||||
/**
|
||||
* A thread-safe wrapper around {@link SessionCipher}.
|
||||
*/
|
||||
public class SignalSessionCipher {
|
||||
|
||||
private final SignalSessionLock lock;
|
||||
private final SessionCipher cipher;
|
||||
|
||||
public SignalSessionCipher(SignalSessionLock lock, SessionCipher cipher) {
|
||||
this.lock = lock;
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
public CiphertextMessage encrypt(byte[] paddedMessage) throws org.signal.libsignal.protocol.UntrustedIdentityException, NoSessionException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.encrypt(paddedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] decrypt(PreKeySignalMessage ciphertext) throws DuplicateMessageException, LegacyMessageException, InvalidMessageException, InvalidKeyIdException, InvalidKeyException, org.signal.libsignal.protocol.UntrustedIdentityException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.decrypt(ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] decrypt(SignalMessage ciphertext) throws InvalidMessageException, InvalidVersionException, DuplicateMessageException, LegacyMessageException, NoSessionException, UntrustedIdentityException {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.decrypt(ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
public int getRemoteRegistrationId() {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.getRemoteRegistrationId();
|
||||
}
|
||||
}
|
||||
|
||||
public int getSessionVersion() {
|
||||
try (SignalSessionLock.Lock unused = lock.acquire()) {
|
||||
return cipher.getSessionVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* SkippingOutputStream will skip a number of bytes being written as specified by toSkip and then
|
||||
* continue writing all remaining bytes to the wrapped output stream.
|
||||
*/
|
||||
public class SkippingOutputStream extends FilterOutputStream {
|
||||
|
||||
private long toSkip;
|
||||
|
||||
public SkippingOutputStream(long toSkip, OutputStream wrapped) {
|
||||
super(wrapped);
|
||||
this.toSkip = toSkip;
|
||||
}
|
||||
|
||||
public void write(int b) throws IOException {
|
||||
if (toSkip > 0) {
|
||||
toSkip--;
|
||||
} else {
|
||||
out.write(b);
|
||||
}
|
||||
}
|
||||
|
||||
public void write(byte[] b) throws IOException {
|
||||
write(b, 0, b.length);
|
||||
}
|
||||
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
if (b == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
|
||||
if (off < 0 || off > b.length || len < 0 || len + off > b.length || len + off < 0) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
|
||||
if (toSkip > 0) {
|
||||
if (len <= toSkip) {
|
||||
toSkip -= len;
|
||||
} else {
|
||||
out.write(b, off + (int) toSkip, len - (int) toSkip);
|
||||
toSkip = 0;
|
||||
}
|
||||
} else {
|
||||
out.write(b, off, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.signal.libsignal.zkgroup.internal.ByteArray;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class UnidentifiedAccess {
|
||||
|
||||
private final byte[] unidentifiedAccessKey;
|
||||
private final SenderCertificate unidentifiedCertificate;
|
||||
private final boolean isUnrestrictedForStory;
|
||||
|
||||
/**
|
||||
* @param isUnrestrictedForStory When sending to a story, we always want to use sealed sender. Receivers will accept it for story messages. However, there are
|
||||
* some situations where we need to know if this access key will be correct for non-story purposes. Set this flag to true if
|
||||
* the access key is a synthetic one that would only be valid for story messages.
|
||||
*/
|
||||
public UnidentifiedAccess(byte[] unidentifiedAccessKey, byte[] unidentifiedCertificate, boolean isUnrestrictedForStory)
|
||||
throws InvalidCertificateException
|
||||
{
|
||||
this.unidentifiedAccessKey = unidentifiedAccessKey;
|
||||
this.unidentifiedCertificate = new SenderCertificate(unidentifiedCertificate);
|
||||
this.isUnrestrictedForStory = isUnrestrictedForStory;
|
||||
}
|
||||
|
||||
public byte[] getUnidentifiedAccessKey() {
|
||||
return unidentifiedAccessKey;
|
||||
}
|
||||
|
||||
public SenderCertificate getUnidentifiedCertificate() {
|
||||
return unidentifiedCertificate;
|
||||
}
|
||||
|
||||
public boolean isUnrestrictedForStory() {
|
||||
return isUnrestrictedForStory;
|
||||
}
|
||||
|
||||
public static byte[] deriveAccessKeyFrom(ProfileKey profileKey) {
|
||||
try {
|
||||
byte[] nonce = createEmptyByteArray(12);
|
||||
byte[] input = createEmptyByteArray(16);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(profileKey.serialize(), "AES"), new GCMParameterSpec(128, nonce));
|
||||
|
||||
byte[] ciphertext = cipher.doFinal(input);
|
||||
|
||||
return ByteUtil.trim(ciphertext, 16);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException | BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static byte[] createEmptyByteArray(int length) {
|
||||
return new byte[length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class UnidentifiedAccessPair {
|
||||
|
||||
private final Optional<UnidentifiedAccess> targetUnidentifiedAccess;
|
||||
private final Optional<UnidentifiedAccess> selfUnidentifiedAccess;
|
||||
|
||||
public UnidentifiedAccessPair(UnidentifiedAccess targetUnidentifiedAccess, UnidentifiedAccess selfUnidentifiedAccess) {
|
||||
this.targetUnidentifiedAccess = Optional.of(targetUnidentifiedAccess);
|
||||
this.selfUnidentifiedAccess = Optional.of(selfUnidentifiedAccess);
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccess> getTargetUnidentifiedAccess() {
|
||||
return targetUnidentifiedAccess;
|
||||
}
|
||||
|
||||
public Optional<UnidentifiedAccess> getSelfUnidentifiedAccess() {
|
||||
return selfUnidentifiedAccess;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
|
||||
public class UntrustedIdentityException extends Exception {
|
||||
|
||||
private final IdentityKey identityKey;
|
||||
private final String identifier;
|
||||
|
||||
public UntrustedIdentityException(String s, String identifier, IdentityKey identityKey) {
|
||||
super(s);
|
||||
this.identifier = identifier;
|
||||
this.identityKey = identityKey;
|
||||
}
|
||||
|
||||
public UntrustedIdentityException(UntrustedIdentityException e) {
|
||||
this(e.getMessage(), e.getIdentifier(), e.getIdentityKey());
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
public interface ChangeSetModifier {
|
||||
void removeAddMembers(int i);
|
||||
|
||||
void moveAddToPromote(int i);
|
||||
|
||||
void removeDeleteMembers(int i);
|
||||
|
||||
void removeModifyMemberRoles(int i);
|
||||
|
||||
void removeModifyMemberProfileKeys(int i);
|
||||
|
||||
void removeAddPendingMembers(int i);
|
||||
|
||||
void removeDeletePendingMembers(int i);
|
||||
|
||||
void removePromotePendingMembers(int i);
|
||||
|
||||
void clearModifyTitle();
|
||||
|
||||
void clearModifyAvatar();
|
||||
|
||||
void clearModifyDisappearingMessagesTimer();
|
||||
|
||||
void clearModifyAttributesAccess();
|
||||
|
||||
void clearModifyMemberAccess();
|
||||
|
||||
void clearModifyAddFromInviteLinkAccess();
|
||||
|
||||
void removeAddRequestingMembers(int i);
|
||||
|
||||
void moveAddRequestingMembersToPromote(int i);
|
||||
|
||||
void removeDeleteRequestingMembers(int i);
|
||||
|
||||
void removePromoteRequestingMembers(int i);
|
||||
|
||||
void clearModifyDescription();
|
||||
|
||||
void clearModifyAnnouncementsOnly();
|
||||
|
||||
void removeAddBannedMembers(int i);
|
||||
|
||||
void removeDeleteBannedMembers(int i);
|
||||
|
||||
void removePromotePendingPniAciMembers(int i);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.ServerPublicParams;
|
||||
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
|
||||
/**
|
||||
* Contains access to all ZK group operations for the client.
|
||||
* <p>
|
||||
* Authorization and profile operations.
|
||||
*/
|
||||
public final class ClientZkOperations {
|
||||
|
||||
private final ClientZkAuthOperations clientZkAuthOperations;
|
||||
private final ClientZkProfileOperations clientZkProfileOperations;
|
||||
private final ClientZkReceiptOperations clientZkReceiptOperations;
|
||||
private final ServerPublicParams serverPublicParams;
|
||||
|
||||
public ClientZkOperations(ServerPublicParams serverPublicParams) {
|
||||
this.serverPublicParams = serverPublicParams;
|
||||
this.clientZkAuthOperations = new ClientZkAuthOperations (serverPublicParams);
|
||||
this.clientZkProfileOperations = new ClientZkProfileOperations(serverPublicParams);
|
||||
this.clientZkReceiptOperations = new ClientZkReceiptOperations(serverPublicParams);
|
||||
}
|
||||
|
||||
public static ClientZkOperations create(SignalServiceConfiguration configuration) {
|
||||
try {
|
||||
return new ClientZkOperations(new ServerPublicParams(configuration.getZkGroupServerPublicParams()));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public ClientZkAuthOperations getAuthOperations() {
|
||||
return clientZkAuthOperations;
|
||||
}
|
||||
|
||||
public ClientZkProfileOperations getProfileOperations() {
|
||||
return clientZkProfileOperations;
|
||||
}
|
||||
|
||||
public ClientZkReceiptOperations getReceiptOperations() {
|
||||
return clientZkReceiptOperations;
|
||||
}
|
||||
|
||||
public ServerPublicParams getServerPublicParams() {
|
||||
return serverPublicParams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class CredentialResponse {
|
||||
|
||||
@JsonProperty
|
||||
private TemporalCredential[] credentials;
|
||||
|
||||
@JsonProperty
|
||||
private TemporalCredential[] callLinkAuthCredentials;
|
||||
|
||||
public TemporalCredential[] getCredentials() {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
public TemporalCredential[] getCallLinkAuthCredentials() {
|
||||
return callLinkAuthCredentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState
|
||||
|
||||
internal class DecryptedGroupChangeActionsBuilderChangeSetModifier(private val result: DecryptedGroupChange.Builder) : ChangeSetModifier {
|
||||
override fun removeAddMembers(i: Int) {
|
||||
result.newMembers = result.newMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun moveAddToPromote(i: Int) {
|
||||
val addMemberAction: DecryptedMember = result.newMembers[i]
|
||||
result.newMembers = result.newMembers.removeIndex(i)
|
||||
result.promotePendingMembers += addMemberAction
|
||||
}
|
||||
|
||||
override fun removeDeleteMembers(i: Int) {
|
||||
result.deleteMembers = result.deleteMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeModifyMemberRoles(i: Int) {
|
||||
result.modifyMemberRoles = result.modifyMemberRoles.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeModifyMemberProfileKeys(i: Int) {
|
||||
result.modifiedProfileKeys = result.modifiedProfileKeys.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeAddPendingMembers(i: Int) {
|
||||
result.newPendingMembers = result.newPendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeDeletePendingMembers(i: Int) {
|
||||
result.deletePendingMembers = result.deletePendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromotePendingMembers(i: Int) {
|
||||
result.promotePendingMembers = result.promotePendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun clearModifyTitle() {
|
||||
result.newTitle = null
|
||||
}
|
||||
|
||||
override fun clearModifyAvatar() {
|
||||
result.newAvatar = null
|
||||
}
|
||||
|
||||
override fun clearModifyDisappearingMessagesTimer() {
|
||||
result.newTimer = null
|
||||
}
|
||||
|
||||
override fun clearModifyAttributesAccess() {
|
||||
result.newAttributeAccess = AccessControl.AccessRequired.UNKNOWN
|
||||
}
|
||||
|
||||
override fun clearModifyMemberAccess() {
|
||||
result.newMemberAccess = AccessControl.AccessRequired.UNKNOWN
|
||||
}
|
||||
|
||||
override fun clearModifyAddFromInviteLinkAccess() {
|
||||
result.newInviteLinkAccess = AccessControl.AccessRequired.UNKNOWN
|
||||
}
|
||||
|
||||
override fun removeAddRequestingMembers(i: Int) {
|
||||
result.newRequestingMembers = result.newRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun moveAddRequestingMembersToPromote(i: Int) {
|
||||
val addMemberAction: DecryptedRequestingMember = result.newRequestingMembers[i]
|
||||
result.newRequestingMembers = result.newRequestingMembers.removeIndex(i)
|
||||
|
||||
val promote = DecryptedMember(
|
||||
aciBytes = addMemberAction.aciBytes,
|
||||
profileKey = addMemberAction.profileKey,
|
||||
role = Member.Role.DEFAULT
|
||||
)
|
||||
result.promotePendingMembers += promote
|
||||
}
|
||||
|
||||
override fun removeDeleteRequestingMembers(i: Int) {
|
||||
result.deleteRequestingMembers = result.deleteRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromoteRequestingMembers(i: Int) {
|
||||
result.promoteRequestingMembers = result.promoteRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun clearModifyDescription() {
|
||||
result.newDescription = null
|
||||
}
|
||||
|
||||
override fun clearModifyAnnouncementsOnly() {
|
||||
result.newIsAnnouncementGroup = EnabledState.UNKNOWN
|
||||
}
|
||||
|
||||
override fun removeAddBannedMembers(i: Int) {
|
||||
result.newBannedMembers = result.newBannedMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeDeleteBannedMembers(i: Int) {
|
||||
result.deleteBannedMembers = result.deleteBannedMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromotePendingPniAciMembers(i: Int) {
|
||||
result.promotePendingPniAciMembers = result.promotePendingPniAciMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
private fun <T> List<T>.removeIndex(i: Int): List<T> {
|
||||
val modifiedList = this.toMutableList()
|
||||
modifiedList.removeAt(i)
|
||||
return modifiedList
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.groupsv2
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.util.Optional
|
||||
|
||||
fun Collection<DecryptedMember>.toAciListWithUnknowns(): List<ACI> {
|
||||
return DecryptedGroupUtil.toAciListWithUnknowns(this)
|
||||
}
|
||||
|
||||
fun Collection<DecryptedMember>.toAciList(): List<ACI> {
|
||||
return DecryptedGroupUtil.toAciList(this)
|
||||
}
|
||||
|
||||
fun Collection<DecryptedMember>.findMemberByAci(aci: ACI): Optional<DecryptedMember> {
|
||||
return DecryptedGroupUtil.findMemberByAci(this, aci)
|
||||
}
|
||||
|
||||
fun Collection<DecryptedRequestingMember>.findRequestingByAci(aci: ACI): Optional<DecryptedRequestingMember> {
|
||||
return DecryptedGroupUtil.findRequestingByAci(this, aci)
|
||||
}
|
||||
|
||||
fun Collection<DecryptedPendingMember>.findPendingByServiceId(serviceId: ServiceId): Optional<DecryptedPendingMember> {
|
||||
return DecryptedGroupUtil.findPendingByServiceId(this, serviceId)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* Pair of a {@link DecryptedGroup} and the {@link DecryptedGroupChange} for that version.
|
||||
*/
|
||||
public final class DecryptedGroupHistoryEntry {
|
||||
|
||||
private final Optional<DecryptedGroup> group;
|
||||
private final Optional<DecryptedGroupChange> change;
|
||||
|
||||
public DecryptedGroupHistoryEntry(Optional<DecryptedGroup> group, Optional<DecryptedGroupChange> change)
|
||||
throws InvalidGroupStateException
|
||||
{
|
||||
if (group.isPresent() && change.isPresent() && group.get().revision != change.get().revision) {
|
||||
throw new InvalidGroupStateException();
|
||||
}
|
||||
|
||||
this.group = group;
|
||||
this.change = change;
|
||||
}
|
||||
|
||||
public Optional<DecryptedGroup> getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
public Optional<DecryptedGroupChange> getChange() {
|
||||
return change;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIds;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public final class DecryptedGroupUtil {
|
||||
|
||||
private static final String TAG = DecryptedGroupUtil.class.getSimpleName();
|
||||
|
||||
public static ArrayList<ACI> toAciListWithUnknowns(Collection<DecryptedMember> membersList) {
|
||||
ArrayList<ACI> serviceIdList = new ArrayList<>(membersList.size());
|
||||
|
||||
for (DecryptedMember member : membersList) {
|
||||
serviceIdList.add(ACI.parseOrUnknown(member.aciBytes));
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
/** Converts the list of members to ACI's, filtering out unknown ACI's. */
|
||||
public static ArrayList<ACI> toAciList(Collection<DecryptedMember> membersList) {
|
||||
ArrayList<ACI> serviceIdList = new ArrayList<>(membersList.size());
|
||||
|
||||
for (DecryptedMember member : membersList) {
|
||||
ACI aci = ACI.parseOrNull(member.aciBytes);
|
||||
|
||||
if (aci != null) {
|
||||
serviceIdList.add(aci);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
public static Set<ByteString> membersToAciByteStringSet(Collection<DecryptedMember> membersList) {
|
||||
Set<ByteString> aciList = new HashSet<>(membersList.size());
|
||||
|
||||
for (DecryptedMember member : membersList) {
|
||||
aciList.add(member.aciBytes);
|
||||
}
|
||||
|
||||
return aciList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can return non-decryptable member ACIs as unknown ACIs.
|
||||
*/
|
||||
public static ArrayList<ServiceId> pendingToServiceIdList(Collection<DecryptedPendingMember> membersList) {
|
||||
ArrayList<ServiceId> serviceIdList = new ArrayList<>(membersList.size());
|
||||
|
||||
for (DecryptedPendingMember member : membersList) {
|
||||
ServiceId serviceId = ServiceId.parseOrNull(member.serviceIdBytes);
|
||||
if (serviceId != null) {
|
||||
serviceIdList.add(serviceId);
|
||||
} else {
|
||||
serviceIdList.add(ACI.UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will not return any non-decryptable member ACIs.
|
||||
*/
|
||||
public static ArrayList<ServiceId> removedMembersServiceIdList(DecryptedGroupChange groupChange) {
|
||||
List<ByteString> deletedMembers = groupChange.deleteMembers;
|
||||
ArrayList<ServiceId> serviceIdList = new ArrayList<>(deletedMembers.size());
|
||||
|
||||
for (ByteString member : deletedMembers) {
|
||||
ServiceId serviceId = ServiceId.parseOrNull(member);
|
||||
|
||||
if (serviceId != null) {
|
||||
serviceIdList.add(serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will not return any non-decryptable member ACIs.
|
||||
*/
|
||||
public static ArrayList<ServiceId> removedPendingMembersServiceIdList(DecryptedGroupChange groupChange) {
|
||||
List<DecryptedPendingMemberRemoval> deletedPendingMembers = groupChange.deletePendingMembers;
|
||||
ArrayList<ServiceId> serviceIdList = new ArrayList<>(deletedPendingMembers.size());
|
||||
|
||||
for (DecryptedPendingMemberRemoval member : deletedPendingMembers) {
|
||||
ServiceId serviceId = ServiceId.parseOrNull(member.serviceIdBytes);
|
||||
|
||||
if(serviceId != null) {
|
||||
serviceIdList.add(serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will not return any non-decryptable member ACIs.
|
||||
*/
|
||||
public static ArrayList<ServiceId> removedRequestingMembersServiceIdList(DecryptedGroupChange groupChange) {
|
||||
List<ByteString> deleteRequestingMembers = groupChange.deleteRequestingMembers;
|
||||
ArrayList<ServiceId> serviceIdList = new ArrayList<>(deleteRequestingMembers.size());
|
||||
|
||||
for (ByteString member : deleteRequestingMembers) {
|
||||
ServiceId serviceId = ServiceId.parseOrNull(member);
|
||||
|
||||
if(serviceId != null) {
|
||||
serviceIdList.add(serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdList;
|
||||
}
|
||||
|
||||
public static Set<ServiceId> bannedMembersToServiceIdSet(Collection<DecryptedBannedMember> membersList) {
|
||||
Set<ServiceId> serviceIdSet = new HashSet<>(membersList.size());
|
||||
|
||||
for (DecryptedBannedMember member : membersList) {
|
||||
ServiceId serviceId = ServiceId.parseOrNull(member.serviceIdBytes);
|
||||
if (serviceId != null) {
|
||||
serviceIdSet.add(serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceIdSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACI of the member that made the change.
|
||||
*/
|
||||
public static Optional<ServiceId> editorServiceId(DecryptedGroupChange change) {
|
||||
return Optional.ofNullable(change != null ? ServiceId.parseOrNull(change.editorServiceIdBytes) : null);
|
||||
}
|
||||
|
||||
public static Optional<DecryptedMember> findMemberByAci(Collection<DecryptedMember> members, ACI aci) {
|
||||
ByteString aciBytes = aci.toByteString();
|
||||
|
||||
for (DecryptedMember member : members) {
|
||||
if (aciBytes.equals(member.aciBytes)) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<DecryptedPendingMember> findPendingByServiceId(Collection<DecryptedPendingMember> members, ServiceId serviceId) {
|
||||
ByteString serviceIdBinary = serviceId.toByteString();
|
||||
|
||||
for (DecryptedPendingMember member : members) {
|
||||
if (serviceIdBinary.equals(member.serviceIdBytes)) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<DecryptedPendingMember> findPendingByServiceIds(Collection<DecryptedPendingMember> members, ServiceIds serviceIds) {
|
||||
for (DecryptedPendingMember member : members) {
|
||||
if (serviceIds.matches(member.serviceIdBytes)) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static int findPendingIndexByServiceIdCipherText(List<DecryptedPendingMember> members, ByteString cipherText) {
|
||||
for (int i = 0; i < members.size(); i++) {
|
||||
DecryptedPendingMember member = members.get(i);
|
||||
if (cipherText.equals(member.serviceIdCipherText)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int findPendingIndexByServiceId(List<DecryptedPendingMember> members, ByteString serviceIdBinary) {
|
||||
for (int i = 0; i < members.size(); i++) {
|
||||
DecryptedPendingMember member = members.get(i);
|
||||
if (serviceIdBinary.equals(member.serviceIdBytes)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static Optional<DecryptedRequestingMember> findRequestingByAci(Collection<DecryptedRequestingMember> members, ACI aci) {
|
||||
ByteString aciBytes = aci.toByteString();
|
||||
|
||||
for (DecryptedRequestingMember member : members) {
|
||||
if (aciBytes.equals(member.aciBytes)) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<DecryptedRequestingMember> findRequestingByServiceIds(Collection<DecryptedRequestingMember> members, ServiceIds serviceIds) {
|
||||
for (DecryptedRequestingMember member : members) {
|
||||
if (serviceIds.matches(member.aciBytes)) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean isPendingOrRequesting(DecryptedGroup group, ServiceIds serviceIds) {
|
||||
return findPendingByServiceIds(group.pendingMembers, serviceIds).isPresent() ||
|
||||
findRequestingByServiceIds(group.requestingMembers, serviceIds).isPresent();
|
||||
}
|
||||
|
||||
public static boolean isRequesting(DecryptedGroup group, ACI aci) {
|
||||
return findRequestingByAci(group.requestingMembers, aci).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the aci from the full members of a group.
|
||||
* <p>
|
||||
* Generally not expected to have to do this, just in the case of leaving a group where you cannot
|
||||
* get the new group state as you are not in the group any longer.
|
||||
*/
|
||||
public static DecryptedGroup removeMember(DecryptedGroup group, ACI aci, int revision) {
|
||||
DecryptedGroup.Builder builder = group.newBuilder();
|
||||
ByteString aciByteString = aci.toByteString();
|
||||
boolean removed = false;
|
||||
ArrayList<DecryptedMember> decryptedMembers = new ArrayList<>(builder.members);
|
||||
Iterator<DecryptedMember> membersList = decryptedMembers.iterator();
|
||||
|
||||
while (membersList.hasNext()) {
|
||||
if (aciByteString.equals(membersList.next().aciBytes)) {
|
||||
membersList.remove();
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
return builder.members(decryptedMembers)
|
||||
.revision(revision)
|
||||
.build();
|
||||
} else {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
public static DecryptedGroup apply(DecryptedGroup group, DecryptedGroupChange change)
|
||||
throws NotAbleToApplyGroupV2ChangeException
|
||||
{
|
||||
if (change.revision != group.revision + 1) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
return applyWithoutRevisionCheck(group, change);
|
||||
}
|
||||
|
||||
public static DecryptedGroup applyWithoutRevisionCheck(DecryptedGroup group, DecryptedGroupChange change)
|
||||
throws NotAbleToApplyGroupV2ChangeException
|
||||
{
|
||||
DecryptedGroup.Builder builder = group.newBuilder()
|
||||
.revision(change.revision);
|
||||
|
||||
applyAddMemberAction(builder, change.newMembers);
|
||||
|
||||
applyDeleteMemberActions(builder, change.deleteMembers);
|
||||
|
||||
applyModifyMemberRoleActions(builder, change.modifyMemberRoles);
|
||||
|
||||
applyModifyMemberProfileKeyActions(builder, change.modifiedProfileKeys);
|
||||
|
||||
applyAddPendingMemberActions(builder, change.newPendingMembers);
|
||||
|
||||
applyDeletePendingMemberActions(builder, change.deletePendingMembers);
|
||||
|
||||
applyPromotePendingMemberActions(builder, change.promotePendingMembers);
|
||||
|
||||
applyModifyTitleAction(builder, change);
|
||||
|
||||
applyModifyDescriptionAction(builder, change);
|
||||
|
||||
applyModifyIsAnnouncementGroupAction(builder, change);
|
||||
|
||||
applyModifyAvatarAction(builder, change);
|
||||
|
||||
applyModifyDisappearingMessagesTimerAction(builder, change);
|
||||
|
||||
applyModifyAttributesAccessControlAction(builder, change);
|
||||
|
||||
applyModifyMembersAccessControlAction(builder, change);
|
||||
|
||||
applyModifyAddFromInviteLinkAccessControlAction(builder, change);
|
||||
|
||||
applyAddRequestingMembers(builder, change.newRequestingMembers);
|
||||
|
||||
applyDeleteRequestingMembers(builder, change.deleteRequestingMembers);
|
||||
|
||||
applyPromoteRequestingMemberActions(builder, change.promoteRequestingMembers);
|
||||
|
||||
applyInviteLinkPassword(builder, change);
|
||||
|
||||
applyAddBannedMembersActions(builder, change.newBannedMembers);
|
||||
|
||||
applyDeleteBannedMembersActions(builder, change.deleteBannedMembers);
|
||||
|
||||
applyPromotePendingPniAciMemberActions(builder, change.promotePendingPniAciMembers);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static void applyAddMemberAction(DecryptedGroup.Builder builder, List<DecryptedMember> newMembersList) {
|
||||
if (newMembersList.isEmpty()) return;
|
||||
|
||||
LinkedHashMap<ByteString, DecryptedMember> members = new LinkedHashMap<>();
|
||||
|
||||
for (DecryptedMember member : builder.members) {
|
||||
members.put(member.aciBytes, member);
|
||||
}
|
||||
|
||||
for (DecryptedMember member : newMembersList) {
|
||||
members.put(member.aciBytes, member);
|
||||
}
|
||||
|
||||
builder.members(new ArrayList<>(members.values()));
|
||||
|
||||
removePendingAndRequestingMembersNowInGroup(builder);
|
||||
}
|
||||
|
||||
private static void applyDeleteMemberActions(DecryptedGroup.Builder builder, List<ByteString> deleteMembersList) {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
|
||||
for (ByteString removedMember : deleteMembersList) {
|
||||
int index = indexOfAci(members, removedMember);
|
||||
|
||||
if (index == -1) {
|
||||
Log.w(TAG, "Deleted member on change not found in group");
|
||||
continue;
|
||||
}
|
||||
|
||||
members.remove(index);
|
||||
}
|
||||
|
||||
builder.members(members);
|
||||
}
|
||||
|
||||
private static void applyModifyMemberRoleActions(DecryptedGroup.Builder builder, List<DecryptedModifyMemberRole> modifyMemberRolesList) throws NotAbleToApplyGroupV2ChangeException {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
|
||||
for (DecryptedModifyMemberRole modifyMemberRole : modifyMemberRolesList) {
|
||||
int index = indexOfAci(members, modifyMemberRole.aciBytes);
|
||||
|
||||
if (index == -1) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
Member.Role role = modifyMemberRole.role;
|
||||
|
||||
ensureKnownRole(role);
|
||||
|
||||
members.set(index, members.get(index).newBuilder().role(role).build());
|
||||
}
|
||||
|
||||
builder.members(members);
|
||||
}
|
||||
|
||||
private static void applyModifyMemberProfileKeyActions(DecryptedGroup.Builder builder, List<DecryptedMember> modifiedProfileKeysList) throws NotAbleToApplyGroupV2ChangeException {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
|
||||
for (DecryptedMember modifyProfileKey : modifiedProfileKeysList) {
|
||||
int index = indexOfAci(members, modifyProfileKey.aciBytes);
|
||||
|
||||
if (index == -1) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
members.set(index, withNewProfileKey(members.get(index), modifyProfileKey.profileKey));
|
||||
}
|
||||
|
||||
builder.members(members);
|
||||
}
|
||||
|
||||
private static void applyAddPendingMemberActions(DecryptedGroup.Builder builder, List<DecryptedPendingMember> newPendingMembersList) throws NotAbleToApplyGroupV2ChangeException {
|
||||
Set<ByteString> fullMemberSet = getMemberAciSet(builder.members);
|
||||
Set<ByteString> pendingMemberCipherTexts = getPendingMemberCipherTextSet(builder.pendingMembers);
|
||||
List<DecryptedPendingMember> pendingMembers = new ArrayList<>(builder.pendingMembers);
|
||||
|
||||
for (DecryptedPendingMember pendingMember : newPendingMembersList) {
|
||||
if (fullMemberSet.contains(pendingMember.serviceIdBytes)) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
if (!pendingMemberCipherTexts.contains(pendingMember.serviceIdCipherText)) {
|
||||
pendingMembers.add(pendingMember);
|
||||
}
|
||||
}
|
||||
|
||||
builder.pendingMembers(pendingMembers);
|
||||
}
|
||||
|
||||
private static void applyDeletePendingMemberActions(DecryptedGroup.Builder builder, List<DecryptedPendingMemberRemoval> deletePendingMembersList) {
|
||||
List<DecryptedPendingMember> pendingMembers = new ArrayList<>(builder.pendingMembers);
|
||||
|
||||
for (DecryptedPendingMemberRemoval removedMember : deletePendingMembersList) {
|
||||
int index = findPendingIndexByServiceIdCipherText(pendingMembers, removedMember.serviceIdCipherText);
|
||||
|
||||
if (index == -1) {
|
||||
Log.w(TAG, "Deleted pending member on change not found in group");
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingMembers.remove(index);
|
||||
}
|
||||
|
||||
builder.pendingMembers(pendingMembers);
|
||||
}
|
||||
|
||||
private static void applyPromotePendingMemberActions(DecryptedGroup.Builder builder, List<DecryptedMember> promotePendingMembersList) throws NotAbleToApplyGroupV2ChangeException {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
List<DecryptedPendingMember> pendingMembers = new ArrayList<>(builder.pendingMembers);
|
||||
|
||||
for (DecryptedMember newMember : promotePendingMembersList) {
|
||||
int index = findPendingIndexByServiceId(pendingMembers, newMember.aciBytes);
|
||||
|
||||
if (index == -1) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
pendingMembers.remove(index);
|
||||
members.add(newMember);
|
||||
}
|
||||
|
||||
builder.pendingMembers(pendingMembers);
|
||||
builder.members(members);
|
||||
}
|
||||
|
||||
private static void applyModifyTitleAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newTitle != null) {
|
||||
builder.title(change.newTitle.value_);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyDescriptionAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newDescription != null) {
|
||||
builder.description(change.newDescription.value_);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyIsAnnouncementGroupAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newIsAnnouncementGroup != EnabledState.UNKNOWN) {
|
||||
builder.isAnnouncementGroup(change.newIsAnnouncementGroup);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyAvatarAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newAvatar != null) {
|
||||
builder.avatar(change.newAvatar.value_);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyDisappearingMessagesTimerAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newTimer != null) {
|
||||
builder.disappearingMessagesTimer(change.newTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyAttributesAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
AccessControl.AccessRequired newAccessLevel = change.newAttributeAccess;
|
||||
|
||||
if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) {
|
||||
AccessControl.Builder accessControlBuilder = builder.accessControl != null ? builder.accessControl.newBuilder() : new AccessControl.Builder();
|
||||
builder.accessControl(accessControlBuilder.attributes(change.newAttributeAccess).build());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyMembersAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
AccessControl.AccessRequired newAccessLevel = change.newMemberAccess;
|
||||
|
||||
if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) {
|
||||
AccessControl.Builder accessControlBuilder = builder.accessControl != null ? builder.accessControl.newBuilder() : new AccessControl.Builder();
|
||||
builder.accessControl(accessControlBuilder.members(change.newMemberAccess).build());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyModifyAddFromInviteLinkAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
AccessControl.AccessRequired newAccessLevel = change.newInviteLinkAccess;
|
||||
|
||||
if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) {
|
||||
AccessControl.Builder accessControlBuilder = builder.accessControl != null ? builder.accessControl.newBuilder() : new AccessControl.Builder();
|
||||
builder.accessControl(accessControlBuilder.addFromInviteLink(newAccessLevel).build());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyAddRequestingMembers(DecryptedGroup.Builder builder, List<DecryptedRequestingMember> newRequestingMembers) {
|
||||
List<DecryptedRequestingMember> requestingMembers = new ArrayList<>(builder.requestingMembers);
|
||||
requestingMembers.addAll(newRequestingMembers);
|
||||
builder.requestingMembers(requestingMembers);
|
||||
}
|
||||
|
||||
private static void applyDeleteRequestingMembers(DecryptedGroup.Builder builder, List<ByteString> deleteRequestingMembersList) {
|
||||
List<DecryptedRequestingMember> requestingMembers = new ArrayList<>(builder.requestingMembers);
|
||||
for (ByteString removedMember : deleteRequestingMembersList) {
|
||||
int index = indexOfAciInRequestingList(requestingMembers, removedMember);
|
||||
|
||||
if (index == -1) {
|
||||
Log.w(TAG, "Deleted member on change not found in group");
|
||||
continue;
|
||||
}
|
||||
|
||||
requestingMembers.remove(index);
|
||||
}
|
||||
builder.requestingMembers(requestingMembers);
|
||||
}
|
||||
|
||||
private static void applyPromoteRequestingMemberActions(DecryptedGroup.Builder builder, List<DecryptedApproveMember> promoteRequestingMembers) throws NotAbleToApplyGroupV2ChangeException {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
List<DecryptedRequestingMember> requestingMembers = new ArrayList<>(builder.requestingMembers);
|
||||
|
||||
for (DecryptedApproveMember approvedMember : promoteRequestingMembers) {
|
||||
int index = indexOfAciInRequestingList(requestingMembers, approvedMember.aciBytes);
|
||||
|
||||
if (index == -1) {
|
||||
Log.w(TAG, "Deleted member on change not found in group");
|
||||
continue;
|
||||
}
|
||||
|
||||
DecryptedRequestingMember requestingMember = requestingMembers.get(index);
|
||||
Member.Role role = approvedMember.role;
|
||||
|
||||
ensureKnownRole(role);
|
||||
|
||||
requestingMembers.remove(index);
|
||||
members.add(new DecryptedMember.Builder()
|
||||
.aciBytes(approvedMember.aciBytes)
|
||||
.profileKey(requestingMember.profileKey)
|
||||
.role(role)
|
||||
.build());
|
||||
}
|
||||
|
||||
builder.members(members);
|
||||
builder.requestingMembers(requestingMembers);
|
||||
}
|
||||
|
||||
private static void applyInviteLinkPassword(DecryptedGroup.Builder builder, DecryptedGroupChange change) {
|
||||
if (change.newInviteLinkPassword.size() > 0) {
|
||||
builder.inviteLinkPassword(change.newInviteLinkPassword);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyAddBannedMembersActions(DecryptedGroup.Builder builder, List<DecryptedBannedMember> newBannedMembersList) {
|
||||
Set<ByteString> bannedMemberServiceIdSet = getBannedMemberServiceIdSet(builder.bannedMembers);
|
||||
List<DecryptedBannedMember> bannedMembers = new ArrayList<>(builder.bannedMembers);
|
||||
|
||||
for (DecryptedBannedMember member : newBannedMembersList) {
|
||||
if (bannedMemberServiceIdSet.contains(member.serviceIdBytes)) {
|
||||
Log.w(TAG, "Banned member already in banned list");
|
||||
} else {
|
||||
bannedMembers.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
builder.bannedMembers(bannedMembers);
|
||||
}
|
||||
|
||||
private static void applyDeleteBannedMembersActions(DecryptedGroup.Builder builder, List<DecryptedBannedMember> deleteMembersList) {
|
||||
List<DecryptedBannedMember> bannedMembers = new ArrayList<>(builder.bannedMembers);
|
||||
|
||||
for (DecryptedBannedMember removedMember : deleteMembersList) {
|
||||
int index = indexOfServiceIdInBannedMemberList(bannedMembers, removedMember.serviceIdBytes);
|
||||
|
||||
if (index == -1) {
|
||||
Log.w(TAG, "Deleted banned member on change not found in banned list");
|
||||
continue;
|
||||
}
|
||||
|
||||
bannedMembers.remove(index);
|
||||
}
|
||||
|
||||
builder.bannedMembers(bannedMembers);
|
||||
}
|
||||
|
||||
private static void applyPromotePendingPniAciMemberActions(DecryptedGroup.Builder builder, List<DecryptedMember> promotePendingPniAciMembersList) throws NotAbleToApplyGroupV2ChangeException {
|
||||
List<DecryptedMember> members = new ArrayList<>(builder.members);
|
||||
List<DecryptedPendingMember> pendingMembers = new ArrayList<>(builder.pendingMembers);
|
||||
|
||||
for (DecryptedMember newMember : promotePendingPniAciMembersList) {
|
||||
int index = findPendingIndexByServiceId(pendingMembers, newMember.pniBytes);
|
||||
|
||||
if (index == -1) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
|
||||
pendingMembers.remove(index);
|
||||
members.add(newMember);
|
||||
}
|
||||
|
||||
builder.members(members);
|
||||
builder.pendingMembers(pendingMembers);
|
||||
}
|
||||
|
||||
private static DecryptedMember withNewProfileKey(DecryptedMember member, ByteString profileKey) {
|
||||
return member.newBuilder()
|
||||
.profileKey(profileKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Set<ByteString> getMemberAciSet(List<DecryptedMember> membersList) {
|
||||
Set<ByteString> memberAcis = new HashSet<>(membersList.size());
|
||||
|
||||
for (DecryptedMember members : membersList) {
|
||||
memberAcis.add(members.aciBytes);
|
||||
}
|
||||
|
||||
return memberAcis;
|
||||
}
|
||||
|
||||
private static Set<ByteString> getPendingMemberCipherTextSet(List<DecryptedPendingMember> pendingMemberList) {
|
||||
Set<ByteString> pendingMemberCipherTexts = new HashSet<>(pendingMemberList.size());
|
||||
|
||||
for (DecryptedPendingMember pendingMember : pendingMemberList) {
|
||||
pendingMemberCipherTexts.add(pendingMember.serviceIdCipherText);
|
||||
}
|
||||
|
||||
return pendingMemberCipherTexts;
|
||||
}
|
||||
|
||||
private static Set<ByteString> getBannedMemberServiceIdSet(List<DecryptedBannedMember> bannedMemberList) {
|
||||
Set<ByteString> memberServiceIds = new HashSet<>(bannedMemberList.size());
|
||||
|
||||
for (DecryptedBannedMember member : bannedMemberList) {
|
||||
memberServiceIds.add(member.serviceIdBytes);
|
||||
}
|
||||
|
||||
return memberServiceIds;
|
||||
}
|
||||
|
||||
private static void removePendingAndRequestingMembersNowInGroup(DecryptedGroup.Builder builder) {
|
||||
Set<ByteString> allMembers = membersToAciByteStringSet(builder.members);
|
||||
|
||||
List<DecryptedPendingMember> pendingMembers = new ArrayList<>(builder.pendingMembers);
|
||||
for (int i = pendingMembers.size() - 1; i >= 0; i--) {
|
||||
DecryptedPendingMember pendingMember = pendingMembers.get(i);
|
||||
if (allMembers.contains(pendingMember.serviceIdBytes)) {
|
||||
pendingMembers.remove(i);
|
||||
}
|
||||
}
|
||||
builder.pendingMembers(pendingMembers);
|
||||
|
||||
List<DecryptedRequestingMember> requestingMembers = new ArrayList<>(builder.requestingMembers);
|
||||
for (int i = requestingMembers.size() - 1; i >= 0; i--) {
|
||||
DecryptedRequestingMember requestingMember = requestingMembers.get(i);
|
||||
if (allMembers.contains(requestingMember.aciBytes)) {
|
||||
requestingMembers.remove(i);
|
||||
}
|
||||
}
|
||||
builder.requestingMembers(requestingMembers);
|
||||
}
|
||||
|
||||
private static void ensureKnownRole(Member.Role role) throws NotAbleToApplyGroupV2ChangeException {
|
||||
if (role != Member.Role.ADMINISTRATOR && role != Member.Role.DEFAULT) {
|
||||
throw new NotAbleToApplyGroupV2ChangeException();
|
||||
}
|
||||
}
|
||||
|
||||
private static int indexOfAci(List<DecryptedMember> memberList, ByteString aci) {
|
||||
for (int i = 0; i < memberList.size(); i++) {
|
||||
if (aci.equals(memberList.get(i).aciBytes)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int indexOfAciInRequestingList(List<DecryptedRequestingMember> memberList, ByteString aci) {
|
||||
for (int i = 0; i < memberList.size(); i++) {
|
||||
if (aci.equals(memberList.get(i).aciBytes)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int indexOfServiceIdInBannedMemberList(List<DecryptedBannedMember> memberList, ByteString serviceIdBinary) {
|
||||
for (int i = 0; i < memberList.size(); i++) {
|
||||
if (serviceIdBinary.equals(memberList.get(i).serviceIdBytes)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static boolean changeIsEmpty(DecryptedGroupChange change) {
|
||||
return change.modifiedProfileKeys.size() == 0 && // field 6
|
||||
changeIsEmptyExceptForProfileKeyChanges(change);
|
||||
}
|
||||
|
||||
/*
|
||||
* When updating this, update {@link #changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(DecryptedGroupChange)}
|
||||
*/
|
||||
public static boolean changeIsEmptyExceptForProfileKeyChanges(DecryptedGroupChange change) {
|
||||
return change.newMembers.size() == 0 && // field 3
|
||||
change.deleteMembers.size() == 0 && // field 4
|
||||
change.modifyMemberRoles.size() == 0 && // field 5
|
||||
change.newPendingMembers.size() == 0 && // field 7
|
||||
change.deletePendingMembers.size() == 0 && // field 8
|
||||
change.promotePendingMembers.size() == 0 && // field 9
|
||||
change.newTitle == null && // field 10
|
||||
change.newAvatar == null && // field 11
|
||||
change.newTimer == null && // field 12
|
||||
isEmpty(change.newAttributeAccess) && // field 13
|
||||
isEmpty(change.newMemberAccess) && // field 14
|
||||
isEmpty(change.newInviteLinkAccess) && // field 15
|
||||
change.newRequestingMembers.size() == 0 && // field 16
|
||||
change.deleteRequestingMembers.size() == 0 && // field 17
|
||||
change.promoteRequestingMembers.size() == 0 && // field 18
|
||||
change.newInviteLinkPassword.size() == 0 && // field 19
|
||||
change.newDescription == null && // field 20
|
||||
isEmpty(change.newIsAnnouncementGroup) && // field 21
|
||||
change.newBannedMembers.size() == 0 && // field 22
|
||||
change.deleteBannedMembers.size() == 0 && // field 23
|
||||
change.promotePendingPniAciMembers.size() == 0; // field 24
|
||||
}
|
||||
|
||||
public static boolean changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(DecryptedGroupChange change) {
|
||||
return (change.newBannedMembers.size() != 0 || change.deleteBannedMembers.size() != 0) &&
|
||||
change.newMembers.size() == 0 && // field 3
|
||||
change.deleteMembers.size() == 0 && // field 4
|
||||
change.modifyMemberRoles.size() == 0 && // field 5
|
||||
change.newPendingMembers.size() == 0 && // field 7
|
||||
change.deletePendingMembers.size() == 0 && // field 8
|
||||
change.promotePendingMembers.size() == 0 && // field 9
|
||||
change.newTitle == null && // field 10
|
||||
change.newAvatar == null && // field 11
|
||||
change.newTimer == null && // field 12
|
||||
isEmpty(change.newAttributeAccess) && // field 13
|
||||
isEmpty(change.newMemberAccess) && // field 14
|
||||
isEmpty(change.newInviteLinkAccess) && // field 15
|
||||
change.newRequestingMembers.size() == 0 && // field 16
|
||||
change.deleteRequestingMembers.size() == 0 && // field 17
|
||||
change.promoteRequestingMembers.size() == 0 && // field 18
|
||||
change.newInviteLinkPassword.size() == 0 && // field 19
|
||||
change.newDescription == null && // field 20
|
||||
isEmpty(change.newIsAnnouncementGroup) && // field 21
|
||||
change.promotePendingPniAciMembers.size() == 0; // field 24
|
||||
}
|
||||
|
||||
static boolean isEmpty(AccessControl.AccessRequired newAttributeAccess) {
|
||||
return newAttributeAccess == AccessControl.AccessRequired.UNKNOWN;
|
||||
}
|
||||
|
||||
static boolean isEmpty(EnabledState enabledState) {
|
||||
return enabledState == EnabledState.UNKNOWN;
|
||||
}
|
||||
|
||||
public static boolean changeIsSilent(DecryptedGroupChange plainGroupChange) {
|
||||
return changeIsEmptyExceptForProfileKeyChanges(plainGroupChange) || changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(plainGroupChange);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Represents a potential new member of a group.
|
||||
* <p>
|
||||
* The entry may or may not have a {@link ExpiringProfileKeyCredential}.
|
||||
* <p>
|
||||
* If it does not, then this user can only be invited.
|
||||
* <p>
|
||||
* Equality by ServiceId only used to makes sure Sets only contain one copy.
|
||||
*/
|
||||
public final class GroupCandidate {
|
||||
|
||||
private final ServiceId serviceId;
|
||||
private final Optional<ExpiringProfileKeyCredential> expiringProfileKeyCredential;
|
||||
|
||||
public GroupCandidate(ServiceId serviceId, Optional<ExpiringProfileKeyCredential> expiringProfileKeyCredential) {
|
||||
this.serviceId = serviceId;
|
||||
this.expiringProfileKeyCredential = expiringProfileKeyCredential;
|
||||
}
|
||||
|
||||
public ServiceId getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public Optional<ExpiringProfileKeyCredential> getExpiringProfileKeyCredential() {
|
||||
return expiringProfileKeyCredential;
|
||||
}
|
||||
|
||||
public ExpiringProfileKeyCredential requireExpiringProfileKeyCredential() {
|
||||
if (expiringProfileKeyCredential.isPresent()) {
|
||||
return expiringProfileKeyCredential.get();
|
||||
}
|
||||
throw new IllegalStateException("no profile key credential");
|
||||
}
|
||||
|
||||
public boolean hasValidProfileKeyCredential() {
|
||||
return expiringProfileKeyCredential.map(ExpiringProfileCredentialUtil::isValid).orElse(false);
|
||||
}
|
||||
|
||||
public static Set<GroupCandidate> withoutExpiringProfileKeyCredentials(Set<GroupCandidate> groupCandidates) {
|
||||
HashSet<GroupCandidate> result = new HashSet<>(groupCandidates.size());
|
||||
|
||||
for (GroupCandidate candidate : groupCandidates) {
|
||||
result.add(candidate.withoutExpiringProfileKeyCredential());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public GroupCandidate withoutExpiringProfileKeyCredential() {
|
||||
return expiringProfileKeyCredential.isPresent() ? new GroupCandidate(serviceId, Optional.empty())
|
||||
: this;
|
||||
}
|
||||
|
||||
public GroupCandidate withExpiringProfileKeyCredential(ExpiringProfileKeyCredential expiringProfileKeyCredential) {
|
||||
return new GroupCandidate(serviceId, Optional.of(expiringProfileKeyCredential));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == null || obj.getClass() != getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
GroupCandidate other = (GroupCandidate) obj;
|
||||
return other.serviceId.equals(serviceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return serviceId.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupChange
|
||||
import org.signal.storageservice.protos.groups.GroupChange.Actions.AddMemberAction
|
||||
import org.signal.storageservice.protos.groups.GroupChange.Actions.AddRequestingMemberAction
|
||||
|
||||
internal class GroupChangeActionsBuilderChangeSetModifier(private val result: GroupChange.Actions.Builder) : ChangeSetModifier {
|
||||
override fun removeAddMembers(i: Int) {
|
||||
result.addMembers = result.addMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun moveAddToPromote(i: Int) {
|
||||
val addMemberAction: AddMemberAction = result.addMembers[i]
|
||||
result.addMembers = result.addMembers.removeIndex(i)
|
||||
result.promotePendingMembers += GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(addMemberAction.added!!.presentation).build()
|
||||
}
|
||||
|
||||
override fun removeDeleteMembers(i: Int) {
|
||||
result.deleteMembers = result.deleteMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeModifyMemberRoles(i: Int) {
|
||||
result.modifyMemberRoles = result.modifyMemberRoles.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeModifyMemberProfileKeys(i: Int) {
|
||||
result.modifyMemberProfileKeys = result.modifyMemberProfileKeys.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeAddPendingMembers(i: Int) {
|
||||
result.addPendingMembers = result.addPendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeDeletePendingMembers(i: Int) {
|
||||
result.deletePendingMembers = result.deletePendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromotePendingMembers(i: Int) {
|
||||
result.promotePendingMembers = result.promotePendingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun clearModifyTitle() {
|
||||
result.modifyTitle = null
|
||||
}
|
||||
|
||||
override fun clearModifyAvatar() {
|
||||
result.modifyAvatar = null
|
||||
}
|
||||
|
||||
override fun clearModifyDisappearingMessagesTimer() {
|
||||
result.modifyDisappearingMessagesTimer = null
|
||||
}
|
||||
|
||||
override fun clearModifyAttributesAccess() {
|
||||
result.modifyAttributesAccess = null
|
||||
}
|
||||
|
||||
override fun clearModifyMemberAccess() {
|
||||
result.modifyMemberAccess = null
|
||||
}
|
||||
|
||||
override fun clearModifyAddFromInviteLinkAccess() {
|
||||
result.modifyAddFromInviteLinkAccess = null
|
||||
}
|
||||
|
||||
override fun removeAddRequestingMembers(i: Int) {
|
||||
result.addRequestingMembers = result.addRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun moveAddRequestingMembersToPromote(i: Int) {
|
||||
val addMemberAction: AddRequestingMemberAction = result.addRequestingMembers[i]
|
||||
result.addRequestingMembers = result.addRequestingMembers.removeIndex(i)
|
||||
result.promotePendingMembers += GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(addMemberAction.added!!.presentation).build()
|
||||
}
|
||||
|
||||
override fun removeDeleteRequestingMembers(i: Int) {
|
||||
result.deleteRequestingMembers = result.deleteRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromoteRequestingMembers(i: Int) {
|
||||
result.promoteRequestingMembers = result.promoteRequestingMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun clearModifyDescription() {
|
||||
result.modifyDescription = null
|
||||
}
|
||||
|
||||
override fun clearModifyAnnouncementsOnly() {
|
||||
result.modifyAnnouncementsOnly = null
|
||||
}
|
||||
|
||||
override fun removeAddBannedMembers(i: Int) {
|
||||
result.addBannedMembers = result.addBannedMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removeDeleteBannedMembers(i: Int) {
|
||||
result.deleteBannedMembers = result.deleteBannedMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
override fun removePromotePendingPniAciMembers(i: Int) {
|
||||
result.promotePendingPniAciMembers = result.promotePendingPniAciMembers.removeIndex(i)
|
||||
}
|
||||
|
||||
private fun <T> List<T>.removeIndex(i: Int): List<T> {
|
||||
val modifiedList = this.toMutableList()
|
||||
modifiedList.removeAt(i)
|
||||
return modifiedList
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public final class GroupChangeReconstruct {
|
||||
|
||||
/**
|
||||
* Given a {@param fromState} and a {@param toState} creates a {@link DecryptedGroupChange} that would take the {@param fromState} to the {@param toState}.
|
||||
*/
|
||||
public static DecryptedGroupChange reconstructGroupChange(DecryptedGroup fromState, DecryptedGroup toState) {
|
||||
DecryptedGroupChange.Builder builder = new DecryptedGroupChange.Builder()
|
||||
.revision(toState.revision);
|
||||
|
||||
if (!fromState.title.equals(toState.title)) {
|
||||
builder.newTitle(new DecryptedString.Builder().value_(toState.title).build());
|
||||
}
|
||||
|
||||
if (!fromState.description.equals(toState.description)) {
|
||||
builder.newDescription(new DecryptedString.Builder().value_(toState.description).build());
|
||||
}
|
||||
|
||||
if (!fromState.isAnnouncementGroup.equals(toState.isAnnouncementGroup)) {
|
||||
builder.newIsAnnouncementGroup(toState.isAnnouncementGroup);
|
||||
}
|
||||
|
||||
if (!fromState.avatar.equals(toState.avatar)) {
|
||||
builder.newAvatar(new DecryptedString.Builder().value_(toState.avatar).build());
|
||||
}
|
||||
|
||||
if (!Objects.equals(fromState.disappearingMessagesTimer, toState.disappearingMessagesTimer)) {
|
||||
builder.newTimer(toState.disappearingMessagesTimer);
|
||||
}
|
||||
|
||||
if (fromState.accessControl == null || (toState.accessControl != null && !fromState.accessControl.attributes.equals(toState.accessControl.attributes))) {
|
||||
if (toState.accessControl != null) {
|
||||
builder.newAttributeAccess(toState.accessControl.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
if (fromState.accessControl == null || (toState.accessControl != null && !fromState.accessControl.members.equals(toState.accessControl.members))) {
|
||||
if (toState.accessControl != null) {
|
||||
builder.newMemberAccess(toState.accessControl.members);
|
||||
}
|
||||
}
|
||||
|
||||
Set<ByteString> fromStateMemberAcis = membersToSetOfAcis(fromState.members);
|
||||
Set<ByteString> toStateMemberAcis = membersToSetOfAcis(toState.members);
|
||||
|
||||
Set<ByteString> pendingMembersListA = pendingMembersToSetOfServiceIds(fromState.pendingMembers);
|
||||
Set<ByteString> pendingMembersListB = pendingMembersToSetOfServiceIds(toState.pendingMembers);
|
||||
|
||||
Set<ByteString> requestingMembersListA = requestingMembersToSetOfAcis(fromState.requestingMembers);
|
||||
Set<ByteString> requestingMembersListB = requestingMembersToSetOfAcis(toState.requestingMembers);
|
||||
|
||||
Set<ByteString> bannedMembersListA = bannedMembersToSetOfServiceIds(fromState.bannedMembers);
|
||||
Set<ByteString> bannedMembersListB = bannedMembersToSetOfServiceIds(toState.bannedMembers);
|
||||
|
||||
Set<ByteString> removedPendingMemberServiceIds = subtract(pendingMembersListA, pendingMembersListB);
|
||||
Set<ByteString> removedRequestingMemberAcis = subtract(requestingMembersListA, requestingMembersListB);
|
||||
Set<ByteString> newPendingMemberServiceIds = subtract(pendingMembersListB, pendingMembersListA);
|
||||
Set<ByteString> newRequestingMemberAcis = subtract(requestingMembersListB, requestingMembersListA);
|
||||
Set<ByteString> removedMemberAcis = subtract(fromStateMemberAcis, toStateMemberAcis);
|
||||
Set<ByteString> newMemberAcis = subtract(toStateMemberAcis, fromStateMemberAcis);
|
||||
Set<ByteString> removedBannedMemberServiceIds = subtract(bannedMembersListA, bannedMembersListB);
|
||||
Set<ByteString> newBannedMemberServiceIds = subtract(bannedMembersListB, bannedMembersListA);
|
||||
|
||||
Set<ByteString> addedByInvitationAcis = intersect(newMemberAcis, removedPendingMemberServiceIds);
|
||||
Set<ByteString> addedByRequestApprovalAcis = intersect(newMemberAcis, removedRequestingMemberAcis);
|
||||
Set<DecryptedMember> addedMembersByInvitation = intersectByAci(toState.members, addedByInvitationAcis);
|
||||
Set<DecryptedMember> addedMembersByRequestApproval = intersectByAci(toState.members, addedByRequestApprovalAcis);
|
||||
Set<DecryptedMember> addedMembers = intersectByAci(toState.members, subtract(newMemberAcis, addedByInvitationAcis, addedByRequestApprovalAcis));
|
||||
Set<DecryptedPendingMember> uninvitedMembers = intersectPendingByServiceId(fromState.pendingMembers, subtract(removedPendingMemberServiceIds, addedByInvitationAcis));
|
||||
Set<DecryptedRequestingMember> rejectedRequestMembers = intersectRequestingByAci(fromState.requestingMembers, subtract(removedRequestingMemberAcis, addedByRequestApprovalAcis));
|
||||
|
||||
|
||||
builder.deleteMembers(intersectByAci(fromState.members, removedMemberAcis).stream()
|
||||
.map(m -> m.aciBytes)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
builder.newMembers(new ArrayList<>(addedMembers));
|
||||
|
||||
builder.promotePendingMembers(new ArrayList<>(addedMembersByInvitation));
|
||||
|
||||
builder.deletePendingMembers(uninvitedMembers.stream()
|
||||
.map(uninvitedMember -> new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(uninvitedMember.serviceIdBytes)
|
||||
.serviceIdCipherText(uninvitedMember.serviceIdCipherText)
|
||||
.build())
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
builder.newPendingMembers(new ArrayList<>(intersectPendingByServiceId(toState.pendingMembers, newPendingMemberServiceIds)));
|
||||
|
||||
Set<ByteString> consistentMemberAcis = intersect(fromStateMemberAcis, toStateMemberAcis);
|
||||
Set<DecryptedMember> changedMembers = intersectByAci(subtract(toState.members, fromState.members), consistentMemberAcis);
|
||||
Map<ByteString, DecryptedMember> membersAciMap = mapByAci(fromState.members);
|
||||
Map<ByteString, DecryptedBannedMember> bannedMembersServiceIdMap = bannedServiceIdMap(toState.bannedMembers);
|
||||
|
||||
List<DecryptedModifyMemberRole> modifiedMemberRoles = new ArrayList<>(changedMembers.size());
|
||||
List<DecryptedMember> modifiedProfileKeys = new ArrayList<>(changedMembers.size());
|
||||
for (DecryptedMember newState : changedMembers) {
|
||||
DecryptedMember oldState = membersAciMap.get(newState.aciBytes);
|
||||
if (oldState.role != newState.role) {
|
||||
modifiedMemberRoles.add(new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(newState.aciBytes)
|
||||
.role(newState.role)
|
||||
.build());
|
||||
}
|
||||
|
||||
if (!oldState.profileKey.equals(newState.profileKey)) {
|
||||
modifiedProfileKeys.add(newState);
|
||||
}
|
||||
}
|
||||
builder.modifyMemberRoles(modifiedMemberRoles);
|
||||
builder.modifiedProfileKeys(modifiedProfileKeys);
|
||||
|
||||
if (fromState.accessControl == null || (toState.accessControl != null && !fromState.accessControl.addFromInviteLink.equals(toState.accessControl.addFromInviteLink))) {
|
||||
if (toState.accessControl != null) {
|
||||
builder.newInviteLinkAccess(toState.accessControl.addFromInviteLink);
|
||||
}
|
||||
}
|
||||
|
||||
builder.newRequestingMembers(new ArrayList<>(intersectRequestingByAci(toState.requestingMembers, newRequestingMemberAcis)));
|
||||
|
||||
builder.deleteRequestingMembers(rejectedRequestMembers.stream().map(requestingMember -> requestingMember.aciBytes).collect(Collectors.toList()));
|
||||
|
||||
builder.promoteRequestingMembers(addedMembersByRequestApproval.stream()
|
||||
.map(member -> new DecryptedApproveMember.Builder()
|
||||
.aciBytes(member.aciBytes)
|
||||
.role(member.role)
|
||||
.build())
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (!fromState.inviteLinkPassword.equals(toState.inviteLinkPassword)) {
|
||||
builder.newInviteLinkPassword(toState.inviteLinkPassword);
|
||||
}
|
||||
|
||||
builder.deleteBannedMembers(removedBannedMemberServiceIds.stream().map(serviceIdBinary -> new DecryptedBannedMember.Builder().serviceIdBytes(serviceIdBinary).build()).collect(Collectors.toList()));
|
||||
|
||||
builder.newBannedMembers(newBannedMemberServiceIds.stream()
|
||||
.map(serviceIdBinary -> {
|
||||
DecryptedBannedMember.Builder newBannedBuilder = new DecryptedBannedMember.Builder().serviceIdBytes(serviceIdBinary);
|
||||
DecryptedBannedMember bannedMember = bannedMembersServiceIdMap.get(serviceIdBinary);
|
||||
if (bannedMember != null) {
|
||||
newBannedBuilder.timestamp(bannedMember.timestamp);
|
||||
}
|
||||
|
||||
return newBannedBuilder.build();
|
||||
})
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static Map<ByteString, DecryptedMember> mapByAci(List<DecryptedMember> membersList) {
|
||||
Map<ByteString, DecryptedMember> map = new LinkedHashMap<>(membersList.size());
|
||||
for (DecryptedMember member : membersList) {
|
||||
map.put(member.aciBytes, member);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<ByteString, DecryptedBannedMember> bannedServiceIdMap(List<DecryptedBannedMember> membersList) {
|
||||
Map<ByteString, DecryptedBannedMember> map = new LinkedHashMap<>(membersList.size());
|
||||
for (DecryptedBannedMember member : membersList) {
|
||||
map.put(member.serviceIdBytes, member);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Set<DecryptedMember> intersectByAci(Collection<DecryptedMember> members, Set<ByteString> acis) {
|
||||
Set<DecryptedMember> result = new LinkedHashSet<>(members.size());
|
||||
for (DecryptedMember member : members) {
|
||||
if (acis.contains(member.aciBytes))
|
||||
result.add(member);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<DecryptedPendingMember> intersectPendingByServiceId(Collection<DecryptedPendingMember> members, Set<ByteString> serviceIds) {
|
||||
Set<DecryptedPendingMember> result = new LinkedHashSet<>(members.size());
|
||||
for (DecryptedPendingMember member : members) {
|
||||
if (serviceIds.contains(member.serviceIdBytes))
|
||||
result.add(member);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<DecryptedRequestingMember> intersectRequestingByAci(Collection<DecryptedRequestingMember> members, Set<ByteString> acis) {
|
||||
Set<DecryptedRequestingMember> result = new LinkedHashSet<>(members.size());
|
||||
for (DecryptedRequestingMember member : members) {
|
||||
if (acis.contains(member.aciBytes))
|
||||
result.add(member);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<ByteString> pendingMembersToSetOfServiceIds(Collection<DecryptedPendingMember> pendingMembers) {
|
||||
Set<ByteString> serviceIds = new LinkedHashSet<>(pendingMembers.size());
|
||||
for (DecryptedPendingMember pendingMember : pendingMembers) {
|
||||
serviceIds.add(pendingMember.serviceIdBytes);
|
||||
}
|
||||
return serviceIds;
|
||||
}
|
||||
|
||||
private static Set<ByteString> requestingMembersToSetOfAcis(Collection<DecryptedRequestingMember> requestingMembers) {
|
||||
Set<ByteString> acis = new LinkedHashSet<>(requestingMembers.size());
|
||||
for (DecryptedRequestingMember requestingMember : requestingMembers) {
|
||||
acis.add(requestingMember.aciBytes);
|
||||
}
|
||||
return acis;
|
||||
}
|
||||
|
||||
private static Set<ByteString> membersToSetOfAcis(Collection<DecryptedMember> members) {
|
||||
Set<ByteString> acis = new LinkedHashSet<>(members.size());
|
||||
for (DecryptedMember member : members) {
|
||||
acis.add(member.aciBytes);
|
||||
}
|
||||
return acis;
|
||||
}
|
||||
|
||||
private static Set<ByteString> bannedMembersToSetOfServiceIds(Collection<DecryptedBannedMember> bannedMembers) {
|
||||
Set<ByteString> serviceIds = new LinkedHashSet<>(bannedMembers.size());
|
||||
for (DecryptedBannedMember bannedMember : bannedMembers) {
|
||||
serviceIds.add(bannedMember.serviceIdBytes);
|
||||
}
|
||||
return serviceIds;
|
||||
}
|
||||
|
||||
private static <T> Set<T> subtract(Collection<T> a, Collection<T> b) {
|
||||
Set<T> result = new LinkedHashSet<>(a);
|
||||
result.removeAll(b);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <T> Set<T> subtract(Collection<T> a, Collection<T> b, Collection<T> c) {
|
||||
Set<T> result = new LinkedHashSet<>(a);
|
||||
result.removeAll(b);
|
||||
result.removeAll(c);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <T> Set<T> intersect(Collection<T> a, Collection<T> b) {
|
||||
Set<T> result = new LinkedHashSet<>(a);
|
||||
result.retainAll(b);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public final class GroupChangeUtil {
|
||||
|
||||
private GroupChangeUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff there are no change actions.
|
||||
*/
|
||||
public static boolean changeIsEmpty(GroupChange.Actions change) {
|
||||
return change.addMembers.size() == 0 && // field 3
|
||||
change.deleteMembers.size() == 0 && // field 4
|
||||
change.modifyMemberRoles.size() == 0 && // field 5
|
||||
change.modifyMemberProfileKeys.size() == 0 && // field 6
|
||||
change.addPendingMembers.size() == 0 && // field 7
|
||||
change.deletePendingMembers.size() == 0 && // field 8
|
||||
change.promotePendingMembers.size() == 0 && // field 9
|
||||
change.modifyTitle == null && // field 10
|
||||
change.modifyAvatar == null && // field 11
|
||||
change.modifyDisappearingMessagesTimer == null && // field 12
|
||||
change.modifyAttributesAccess == null && // field 13
|
||||
change.modifyMemberAccess == null && // field 14
|
||||
change.modifyAddFromInviteLinkAccess == null && // field 15
|
||||
change.addRequestingMembers.size() == 0 && // field 16
|
||||
change.deleteRequestingMembers.size() == 0 && // field 17
|
||||
change.promoteRequestingMembers.size() == 0 && // field 18
|
||||
change.modifyInviteLinkPassword == null && // field 19
|
||||
change.modifyDescription == null && // field 20
|
||||
change.modifyAnnouncementsOnly == null && // field 21
|
||||
change.addBannedMembers.size() == 0 && // field 22
|
||||
change.deleteBannedMembers.size() == 0 && // field 23
|
||||
change.promotePendingPniAciMembers.size() == 0; // field 24
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the latest group state and a conflicting change, decides which changes to carry forward
|
||||
* and returns a new group change which could be empty.
|
||||
* <p>
|
||||
* Titles, avatars, and other settings are carried forward if they are different. Last writer wins.
|
||||
* <p>
|
||||
* Membership additions and removals also respect last writer wins and are removed if they have
|
||||
* already been applied. e.g. you add someone but they are already added.
|
||||
* <p>
|
||||
* Membership additions will be altered to {@link GroupChange.Actions.PromotePendingMemberAction}
|
||||
* if someone has invited them since.
|
||||
*
|
||||
* @param groupState Latest group state in plaintext.
|
||||
* @param conflictingChange The potentially conflicting change in plaintext.
|
||||
* @param encryptedChange Encrypted version of the {@param conflictingChange}.
|
||||
* @return A new change builder.
|
||||
*/
|
||||
public static GroupChange.Actions.Builder resolveConflict(DecryptedGroup groupState,
|
||||
DecryptedGroupChange conflictingChange,
|
||||
GroupChange.Actions encryptedChange)
|
||||
{
|
||||
GroupChange.Actions.Builder result = encryptedChange.newBuilder();
|
||||
|
||||
resolveConflict(groupState, conflictingChange, new GroupChangeActionsBuilderChangeSetModifier(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the latest group state and a conflicting change, decides which changes to carry forward
|
||||
* and returns a new group change which could be empty.
|
||||
* <p>
|
||||
* Titles, avatars, and other settings are carried forward if they are different. Last writer wins.
|
||||
* <p>
|
||||
* Membership additions and removals also respect last writer wins and are removed if they have
|
||||
* already been applied. e.g. you add someone but they are already added.
|
||||
* <p>
|
||||
* Membership additions will be altered to {@link DecryptedGroupChange} promotes if someone has
|
||||
* invited them since.
|
||||
*
|
||||
* @param groupState Latest group state in plaintext.
|
||||
* @param conflictingChange The potentially conflicting change in plaintext.
|
||||
* @return A new change builder.
|
||||
*/
|
||||
public static DecryptedGroupChange.Builder resolveConflict(DecryptedGroup groupState,
|
||||
DecryptedGroupChange conflictingChange)
|
||||
{
|
||||
DecryptedGroupChange.Builder result = conflictingChange.newBuilder();
|
||||
|
||||
resolveConflict(groupState, conflictingChange, new DecryptedGroupChangeActionsBuilderChangeSetModifier(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void resolveConflict(DecryptedGroup groupState,
|
||||
DecryptedGroupChange conflictingChange,
|
||||
ChangeSetModifier changeSetModifier)
|
||||
{
|
||||
HashMap<ByteString, DecryptedMember> fullMembersByUuid = new HashMap<>(groupState.members.size());
|
||||
HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId = new HashMap<>(groupState.pendingMembers.size());
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid = new HashMap<>(groupState.members.size());
|
||||
HashMap<ByteString, DecryptedBannedMember> bannedMembersByServiceId = new HashMap<>(groupState.bannedMembers.size());
|
||||
|
||||
for (DecryptedMember member : groupState.members) {
|
||||
fullMembersByUuid.put(member.aciBytes, member);
|
||||
}
|
||||
|
||||
for (DecryptedPendingMember member : groupState.pendingMembers) {
|
||||
pendingMembersByServiceId.put(member.serviceIdBytes, member);
|
||||
}
|
||||
|
||||
for (DecryptedRequestingMember member : groupState.requestingMembers) {
|
||||
requestingMembersByUuid.put(member.aciBytes, member);
|
||||
}
|
||||
|
||||
for (DecryptedBannedMember member : groupState.bannedMembers) {
|
||||
bannedMembersByServiceId.put(member.serviceIdBytes, member);
|
||||
}
|
||||
|
||||
resolveField3AddMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByServiceId);
|
||||
resolveField4DeleteMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField5ModifyMemberRoles (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField6ModifyProfileKeys (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField7AddPendingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByServiceId);
|
||||
resolveField8DeletePendingMembers (conflictingChange, changeSetModifier, pendingMembersByServiceId);
|
||||
resolveField9PromotePendingMembers (conflictingChange, changeSetModifier, pendingMembersByServiceId);
|
||||
resolveField10ModifyTitle (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField11ModifyAvatar (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, changeSetModifier);
|
||||
resolveField13modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField14modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField16AddRequestingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByServiceId);
|
||||
resolveField17DeleteMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||
resolveField18PromoteRequestingMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||
resolveField20ModifyDescription (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField21ModifyAnnouncementsOnly (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField22AddBannedMembers (conflictingChange, changeSetModifier, bannedMembersByServiceId);
|
||||
resolveField23DeleteBannedMembers (conflictingChange, changeSetModifier, bannedMembersByServiceId);
|
||||
resolveField24PromotePendingPniAciMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
}
|
||||
|
||||
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
|
||||
List<DecryptedMember> newMembersList = conflictingChange.newMembers;
|
||||
|
||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedMember member = newMembersList.get(i);
|
||||
|
||||
if (fullMembersByUuid.containsKey(member.aciBytes)) {
|
||||
result.removeAddMembers(i);
|
||||
} else if (pendingMembersByServiceId.containsKey(member.aciBytes) || pendingMembersByServiceId.containsKey(member.pniBytes)) {
|
||||
result.moveAddToPromote(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<ByteString> deletedMembersList = conflictingChange.deleteMembers;
|
||||
|
||||
for (int i = deletedMembersList.size() - 1; i >= 0; i--) {
|
||||
ByteString member = deletedMembersList.get(i);
|
||||
|
||||
if (!fullMembersByUuid.containsKey(member)) {
|
||||
result.removeDeleteMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<DecryptedModifyMemberRole> modifyRolesList = conflictingChange.modifyMemberRoles;
|
||||
|
||||
for (int i = modifyRolesList.size() - 1; i >= 0; i--) {
|
||||
DecryptedModifyMemberRole modifyRoleAction = modifyRolesList.get(i);
|
||||
DecryptedMember memberInGroup = fullMembersByUuid.get(modifyRoleAction.aciBytes);
|
||||
|
||||
if (memberInGroup == null || memberInGroup.role == modifyRoleAction.role) {
|
||||
result.removeModifyMemberRoles(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<DecryptedMember> modifyProfileKeysList = conflictingChange.modifiedProfileKeys;
|
||||
|
||||
for (int i = modifyProfileKeysList.size() - 1; i >= 0; i--) {
|
||||
DecryptedMember member = modifyProfileKeysList.get(i);
|
||||
DecryptedMember memberInGroup = fullMembersByUuid.get(member.aciBytes);
|
||||
|
||||
if (memberInGroup == null || member.profileKey.equals(memberInGroup.profileKey)) {
|
||||
result.removeModifyMemberProfileKeys(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
|
||||
List<DecryptedPendingMember> newPendingMembersList = conflictingChange.newPendingMembers;
|
||||
|
||||
for (int i = newPendingMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedPendingMember member = newPendingMembersList.get(i);
|
||||
|
||||
if (fullMembersByUuid.containsKey(member.serviceIdBytes) || pendingMembersByServiceId.containsKey(member.serviceIdBytes)) {
|
||||
result.removeAddPendingMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
|
||||
List<DecryptedPendingMemberRemoval> deletePendingMembersList = conflictingChange.deletePendingMembers;
|
||||
|
||||
for (int i = deletePendingMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedPendingMemberRemoval member = deletePendingMembersList.get(i);
|
||||
|
||||
if (!pendingMembersByServiceId.containsKey(member.serviceIdBytes)) {
|
||||
result.removeDeletePendingMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
|
||||
List<DecryptedMember> promotePendingMembersList = conflictingChange.promotePendingMembers;
|
||||
|
||||
for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedMember member = promotePendingMembersList.get(i);
|
||||
|
||||
if (!pendingMembersByServiceId.containsKey(member.aciBytes) && !pendingMembersByServiceId.containsKey(member.pniBytes)) {
|
||||
result.removePromotePendingMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.newTitle != null && conflictingChange.newTitle.value_.equals(groupState.title)) {
|
||||
result.clearModifyTitle();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.newAvatar != null && conflictingChange.newAvatar.value_.equals(groupState.avatar)) {
|
||||
result.clearModifyAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (groupState.disappearingMessagesTimer != null && conflictingChange.newTimer != null && conflictingChange.newTimer.duration == groupState.disappearingMessagesTimer.duration) {
|
||||
result.clearModifyDisappearingMessagesTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (groupState.accessControl != null && conflictingChange.newAttributeAccess == groupState.accessControl.attributes) {
|
||||
result.clearModifyAttributesAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (groupState.accessControl != null && conflictingChange.newMemberAccess == groupState.accessControl.members) {
|
||||
result.clearModifyMemberAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (groupState.accessControl != null && conflictingChange.newInviteLinkAccess == groupState.accessControl.addFromInviteLink) {
|
||||
result.clearModifyAddFromInviteLinkAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByServiceId) {
|
||||
List<DecryptedRequestingMember> newMembersList = conflictingChange.newRequestingMembers;
|
||||
|
||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedRequestingMember member = newMembersList.get(i);
|
||||
|
||||
if (fullMembersByUuid.containsKey(member.aciBytes)) {
|
||||
result.removeAddRequestingMembers(i);
|
||||
} else if (pendingMembersByServiceId.containsKey(member.aciBytes)) {
|
||||
result.moveAddRequestingMembersToPromote(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange,
|
||||
ChangeSetModifier result,
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembers)
|
||||
{
|
||||
List<ByteString> deletedMembersList = conflictingChange.deleteRequestingMembers;
|
||||
|
||||
for (int i = deletedMembersList.size() - 1; i >= 0; i--) {
|
||||
ByteString member = deletedMembersList.get(i);
|
||||
|
||||
if (!requestingMembers.containsKey(member)) {
|
||||
result.removeDeleteRequestingMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange,
|
||||
ChangeSetModifier result,
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid)
|
||||
{
|
||||
List<DecryptedApproveMember> promoteRequestingMembersList = conflictingChange.promoteRequestingMembers;
|
||||
|
||||
for (int i = promoteRequestingMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedApproveMember member = promoteRequestingMembersList.get(i);
|
||||
|
||||
if (!requestingMembersByUuid.containsKey(member.aciBytes)) {
|
||||
result.removePromoteRequestingMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField20ModifyDescription(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.newDescription != null && conflictingChange.newDescription.value_.equals(groupState.description)) {
|
||||
result.clearModifyDescription();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField21ModifyAnnouncementsOnly(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.newIsAnnouncementGroup.equals(groupState.isAnnouncementGroup)) {
|
||||
result.clearModifyAnnouncementsOnly();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField22AddBannedMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedBannedMember> bannedMembersByServiceId) {
|
||||
List<DecryptedBannedMember> newBannedMembersList = conflictingChange.newBannedMembers;
|
||||
|
||||
for (int i = newBannedMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedBannedMember member = newBannedMembersList.get(i);
|
||||
|
||||
if (bannedMembersByServiceId.containsKey(member.serviceIdBytes)) {
|
||||
result.removeAddBannedMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField23DeleteBannedMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedBannedMember> bannedMembersByServiceId) {
|
||||
List<DecryptedBannedMember> deleteBannedMembersList = conflictingChange.deleteBannedMembers;
|
||||
|
||||
for (int i = deleteBannedMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedBannedMember member = deleteBannedMembersList.get(i);
|
||||
|
||||
if (!bannedMembersByServiceId.containsKey(member.serviceIdBytes)) {
|
||||
result.removeDeleteBannedMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField24PromotePendingPniAciMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByAci) {
|
||||
List<DecryptedMember> promotePendingPniAciMembersList = conflictingChange.promotePendingPniAciMembers;
|
||||
|
||||
for (int i = promotePendingPniAciMembersList.size() - 1; i >= 0; i--) {
|
||||
DecryptedMember member = promotePendingPniAciMembersList.get(i);
|
||||
|
||||
if (fullMembersByAci.containsKey(member.aciBytes)) {
|
||||
result.removePromotePendingPniAciMembers(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wraps result of group history fetch with it's associated paging data.
|
||||
*/
|
||||
public final class GroupHistoryPage {
|
||||
|
||||
private final List<DecryptedGroupHistoryEntry> results;
|
||||
private final PagingData pagingData;
|
||||
|
||||
|
||||
public GroupHistoryPage(List<DecryptedGroupHistoryEntry> results, PagingData pagingData) {
|
||||
this.results = results;
|
||||
this.pagingData = pagingData;
|
||||
}
|
||||
|
||||
public List<DecryptedGroupHistoryEntry> getResults() {
|
||||
return results;
|
||||
}
|
||||
|
||||
public PagingData getPagingData() {
|
||||
return pagingData;
|
||||
}
|
||||
|
||||
public static final class PagingData {
|
||||
public static final PagingData NONE = new PagingData(false, -1);
|
||||
|
||||
private final boolean hasMorePages;
|
||||
private final int nextPageRevision;
|
||||
|
||||
public static PagingData fromGroup(PushServiceSocket.GroupHistory groupHistory) {
|
||||
return new PagingData(groupHistory.hasMore(), groupHistory.hasMore() ? groupHistory.getNextPageStartGroupRevision() : -1);
|
||||
}
|
||||
|
||||
private PagingData(boolean hasMorePages, int nextPageRevision) {
|
||||
this.hasMorePages = hasMorePages;
|
||||
this.nextPageRevision = nextPageRevision;
|
||||
}
|
||||
|
||||
public boolean hasMorePages() {
|
||||
return hasMorePages;
|
||||
}
|
||||
|
||||
public int getNextPageRevision() {
|
||||
return nextPageRevision;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Thrown when a group link:
|
||||
* - has an out of date password, or;
|
||||
* - is currently not shared, or;
|
||||
* - has been banned from the group, or;
|
||||
* - the master key does not match a group on the server
|
||||
*/
|
||||
public final class GroupLinkNotActiveException extends Exception {
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public GroupLinkNotActiveException(Throwable t, Optional<String> reason) {
|
||||
super(t);
|
||||
|
||||
if (reason.isPresent() && reason.get().equalsIgnoreCase("banned")) {
|
||||
this.reason = Reason.BANNED;
|
||||
} else {
|
||||
this.reason = Reason.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
public Reason getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public enum Reason {
|
||||
UNKNOWN,
|
||||
BANNED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.auth.AuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPni;
|
||||
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
|
||||
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
|
||||
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.storageservice.protos.groups.AvatarUploadAttributes;
|
||||
import org.signal.storageservice.protos.groups.Group;
|
||||
import org.signal.storageservice.protos.groups.GroupAttributeBlob;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.GroupChanges;
|
||||
import org.signal.storageservice.protos.groups.GroupExternalCredential;
|
||||
import org.signal.storageservice.protos.groups.GroupJoinInfo;
|
||||
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.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public class GroupsV2Api {
|
||||
|
||||
private final PushServiceSocket socket;
|
||||
private final GroupsV2Operations groupsOperations;
|
||||
|
||||
public GroupsV2Api(PushServiceSocket socket, GroupsV2Operations groupsOperations) {
|
||||
this.socket = socket;
|
||||
this.groupsOperations = groupsOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides 7 days of credentials, which you should cache.
|
||||
*/
|
||||
public CredentialResponseMaps getCredentials(long todaySeconds)
|
||||
throws IOException
|
||||
{
|
||||
return parseCredentialResponse(socket.retrieveGroupsV2Credentials(todaySeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an auth token from a credential response.
|
||||
*/
|
||||
public GroupsV2AuthorizationString getGroupsV2AuthorizationString(ACI aci,
|
||||
PNI pni,
|
||||
long redemptionTimeSeconds,
|
||||
GroupSecretParams groupSecretParams,
|
||||
AuthCredentialWithPniResponse authCredentialWithPniResponse)
|
||||
throws VerificationFailedException
|
||||
{
|
||||
ClientZkAuthOperations authOperations = groupsOperations.getAuthOperations();
|
||||
AuthCredentialWithPni authCredentialWithPni = authOperations.receiveAuthCredentialWithPniAsServiceId(aci.getLibSignalAci(), pni.getLibSignalPni(), redemptionTimeSeconds, authCredentialWithPniResponse);
|
||||
AuthCredentialPresentation authCredentialPresentation = authOperations.createAuthCredentialPresentation(new SecureRandom(), groupSecretParams, authCredentialWithPni);
|
||||
|
||||
return new GroupsV2AuthorizationString(groupSecretParams, authCredentialPresentation);
|
||||
}
|
||||
|
||||
public void putNewGroup(GroupsV2Operations.NewGroup newGroup,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException
|
||||
{
|
||||
Group group = newGroup.getNewGroupMessage();
|
||||
|
||||
if (newGroup.getAvatar().isPresent()) {
|
||||
String cdnKey = uploadAvatar(newGroup.getAvatar().get(), newGroup.getGroupSecretParams(), authorization);
|
||||
|
||||
group = group.newBuilder()
|
||||
.avatar(cdnKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
socket.putNewGroupsV2Group(group, authorization);
|
||||
}
|
||||
|
||||
public PartialDecryptedGroup getPartialDecryptedGroup(GroupSecretParams groupSecretParams,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException, InvalidGroupStateException, VerificationFailedException
|
||||
{
|
||||
Group group = socket.getGroupsV2Group(authorization);
|
||||
|
||||
return groupsOperations.forGroup(groupSecretParams)
|
||||
.partialDecryptGroup(group);
|
||||
}
|
||||
|
||||
public DecryptedGroup getGroup(GroupSecretParams groupSecretParams,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException, InvalidGroupStateException, VerificationFailedException
|
||||
{
|
||||
Group group = socket.getGroupsV2Group(authorization);
|
||||
|
||||
return groupsOperations.forGroup(groupSecretParams)
|
||||
.decryptGroup(group);
|
||||
}
|
||||
|
||||
public GroupHistoryPage getGroupHistoryPage(GroupSecretParams groupSecretParams,
|
||||
int fromRevision,
|
||||
GroupsV2AuthorizationString authorization,
|
||||
boolean includeFirstState)
|
||||
throws IOException, InvalidGroupStateException, VerificationFailedException
|
||||
{
|
||||
PushServiceSocket.GroupHistory group = socket.getGroupsV2GroupHistory(fromRevision, authorization, GroupsV2Operations.HIGHEST_KNOWN_EPOCH, includeFirstState);
|
||||
List<DecryptedGroupHistoryEntry> result = new ArrayList<>(group.getGroupChanges().groupChanges.size());
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams);
|
||||
|
||||
for (GroupChanges.GroupChangeState change : group.getGroupChanges().groupChanges) {
|
||||
Optional<DecryptedGroup> decryptedGroup = change.groupState != null ? Optional.of(groupOperations.decryptGroup(change.groupState)) : Optional.empty();
|
||||
Optional<DecryptedGroupChange> decryptedChange = change.groupChange != null ? groupOperations.decryptChange(change.groupChange, false) : Optional.empty();
|
||||
|
||||
result.add(new DecryptedGroupHistoryEntry(decryptedGroup, decryptedChange));
|
||||
}
|
||||
|
||||
return new GroupHistoryPage(result, GroupHistoryPage.PagingData.fromGroup(group));
|
||||
}
|
||||
|
||||
public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams,
|
||||
Optional<byte[]> password,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException, GroupLinkNotActiveException
|
||||
{
|
||||
try {
|
||||
GroupJoinInfo joinInfo = socket.getGroupJoinInfo(password, authorization);
|
||||
GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams);
|
||||
|
||||
return groupOperations.decryptGroupJoinInfo(joinInfo);
|
||||
} catch (ForbiddenException e) {
|
||||
throw new GroupLinkNotActiveException(null, e.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
public String uploadAvatar(byte[] avatar,
|
||||
GroupSecretParams groupSecretParams,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException
|
||||
{
|
||||
AvatarUploadAttributes form = socket.getGroupsV2AvatarUploadForm(authorization.toString());
|
||||
|
||||
byte[] cipherText;
|
||||
try {
|
||||
cipherText = new ClientZkGroupCipher(groupSecretParams).encryptBlob(new GroupAttributeBlob.Builder().avatar(ByteString.of(avatar)).build().encode());
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
socket.uploadGroupV2Avatar(cipherText, form);
|
||||
|
||||
return form.key;
|
||||
}
|
||||
|
||||
public GroupChange patchGroup(GroupChange.Actions groupChange,
|
||||
GroupsV2AuthorizationString authorization,
|
||||
Optional<byte[]> groupLinkPassword)
|
||||
throws IOException
|
||||
{
|
||||
return socket.patchGroupsV2Group(groupChange, authorization.toString(), groupLinkPassword);
|
||||
}
|
||||
|
||||
public GroupExternalCredential getGroupExternalCredential(GroupsV2AuthorizationString authorization)
|
||||
throws IOException
|
||||
{
|
||||
return socket.getGroupExternalCredential(authorization);
|
||||
}
|
||||
|
||||
private static CredentialResponseMaps parseCredentialResponse(CredentialResponse credentialResponse)
|
||||
throws IOException
|
||||
{
|
||||
HashMap<Long, AuthCredentialWithPniResponse> credentials = new HashMap<>();
|
||||
HashMap<Long, CallLinkAuthCredentialResponse> callLinkCredentials = new HashMap<>();
|
||||
|
||||
for (TemporalCredential credential : credentialResponse.getCredentials()) {
|
||||
AuthCredentialWithPniResponse authCredentialWithPniResponse;
|
||||
try {
|
||||
authCredentialWithPniResponse = new AuthCredentialWithPniResponse(credential.getCredential());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
credentials.put(credential.getRedemptionTime(), authCredentialWithPniResponse);
|
||||
}
|
||||
|
||||
for (TemporalCredential credential : credentialResponse.getCallLinkAuthCredentials()) {
|
||||
CallLinkAuthCredentialResponse callLinkAuthCredentialResponse;
|
||||
try {
|
||||
callLinkAuthCredentialResponse = new CallLinkAuthCredentialResponse(credential.getCredential());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
callLinkCredentials.put(credential.getRedemptionTime(), callLinkAuthCredentialResponse);
|
||||
}
|
||||
|
||||
return new CredentialResponseMaps(credentials, callLinkCredentials);
|
||||
}
|
||||
|
||||
public static class CredentialResponseMaps {
|
||||
private final Map<Long, AuthCredentialWithPniResponse> authCredentialWithPniResponseHashMap;
|
||||
private final Map<Long, CallLinkAuthCredentialResponse> callLinkAuthCredentialResponseHashMap;
|
||||
|
||||
public CredentialResponseMaps(Map<Long, AuthCredentialWithPniResponse> authCredentialWithPniResponseHashMap,
|
||||
Map<Long, CallLinkAuthCredentialResponse> callLinkAuthCredentialResponseHashMap)
|
||||
{
|
||||
this.authCredentialWithPniResponseHashMap = authCredentialWithPniResponseHashMap;
|
||||
this.callLinkAuthCredentialResponseHashMap = callLinkAuthCredentialResponseHashMap;
|
||||
}
|
||||
|
||||
public Map<Long, AuthCredentialWithPniResponse> getAuthCredentialWithPniResponseHashMap() {
|
||||
return authCredentialWithPniResponseHashMap;
|
||||
}
|
||||
|
||||
public Map<Long, CallLinkAuthCredentialResponse> getCallLinkAuthCredentialResponseHashMap() {
|
||||
return callLinkAuthCredentialResponseHashMap;
|
||||
}
|
||||
|
||||
public CredentialResponseMaps createUnmodifiableCopy() {
|
||||
return new CredentialResponseMaps(
|
||||
Map.copyOf(authCredentialWithPniResponseHashMap),
|
||||
Map.copyOf(callLinkAuthCredentialResponseHashMap)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.auth.AuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.whispersystems.signalservice.internal.util.Hex;
|
||||
|
||||
import okhttp3.Credentials;
|
||||
|
||||
public final class GroupsV2AuthorizationString {
|
||||
|
||||
private final String authString;
|
||||
|
||||
GroupsV2AuthorizationString(GroupSecretParams groupSecretParams, AuthCredentialPresentation authCredentialPresentation) {
|
||||
String username = Hex.toStringCondensed(groupSecretParams.getPublicParams().serialize());
|
||||
String password = Hex.toStringCondensed(authCredentialPresentation.serialize());
|
||||
|
||||
authString = Credentials.basic(username, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return authString;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
|
||||
/**
|
||||
* Thrown when a group has some data that cannot be decrypted, or is in some other way in an
|
||||
* unexpected state.
|
||||
*/
|
||||
public final class InvalidGroupStateException extends Exception {
|
||||
|
||||
InvalidGroupStateException(InvalidInputException e) {
|
||||
super(e);
|
||||
}
|
||||
|
||||
InvalidGroupStateException() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
/**
|
||||
* Thrown when we do not have a credential locally for a given time.
|
||||
*/
|
||||
public final class NoCredentialForRedemptionTimeException extends Exception {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
public final class NotAbleToApplyGroupV2ChangeException extends Exception {
|
||||
|
||||
NotAbleToApplyGroupV2ChangeException() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.storageservice.protos.groups.Group;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decrypting an entire group can be expensive for large groups. Since not every
|
||||
* operation requires all data to be decrypted, this class can be populated with only
|
||||
* the minimalist about of information need to perform an operation. Currently, only
|
||||
* updating from the server utilizes it.
|
||||
*/
|
||||
public class PartialDecryptedGroup {
|
||||
private final Group group;
|
||||
private final DecryptedGroup decryptedGroup;
|
||||
private final GroupsV2Operations groupsOperations;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
|
||||
public PartialDecryptedGroup(Group group,
|
||||
DecryptedGroup decryptedGroup,
|
||||
GroupsV2Operations groupsOperations,
|
||||
GroupSecretParams groupSecretParams)
|
||||
{
|
||||
this.group = group;
|
||||
this.decryptedGroup = decryptedGroup;
|
||||
this.groupsOperations = groupsOperations;
|
||||
this.groupSecretParams = groupSecretParams;
|
||||
}
|
||||
|
||||
public int getRevision() {
|
||||
return decryptedGroup.revision;
|
||||
}
|
||||
|
||||
public List<DecryptedMember> getMembersList() {
|
||||
return decryptedGroup.members;
|
||||
}
|
||||
|
||||
public List<DecryptedPendingMember> getPendingMembersList() {
|
||||
return decryptedGroup.pendingMembers;
|
||||
}
|
||||
|
||||
public DecryptedGroup getFullyDecryptedGroup()
|
||||
throws IOException
|
||||
{
|
||||
try {
|
||||
return groupsOperations.forGroup(groupSecretParams)
|
||||
.decryptGroup(group);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class TemporalCredential {
|
||||
|
||||
@JsonProperty
|
||||
private byte[] credential;
|
||||
|
||||
@JsonProperty
|
||||
private long redemptionTime;
|
||||
|
||||
public byte[] getCredential() {
|
||||
return credential;
|
||||
}
|
||||
|
||||
public long getRedemptionTime() {
|
||||
return redemptionTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class UuidProfileKey {
|
||||
|
||||
private final UUID uuid;
|
||||
private final ProfileKey profileKey;
|
||||
|
||||
public UuidProfileKey(UUID uuid, ProfileKey profileKey) {
|
||||
this.uuid = uuid;
|
||||
this.profileKey = profileKey;
|
||||
}
|
||||
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public ProfileKey getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.whispersystems.signalservice.api.kbs;
|
||||
|
||||
/**
|
||||
* Construct from a {@link org.signal.libsignal.svr2.PinHash}.
|
||||
*/
|
||||
public final class KbsData {
|
||||
private final MasterKey masterKey;
|
||||
private final byte[] kbsAccessKey;
|
||||
private final byte[] cipherText;
|
||||
|
||||
KbsData(MasterKey masterKey, byte[] kbsAccessKey, byte[] cipherText) {
|
||||
this.masterKey = masterKey;
|
||||
this.kbsAccessKey = kbsAccessKey;
|
||||
this.cipherText = cipherText;
|
||||
}
|
||||
|
||||
public MasterKey getMasterKey() {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
public byte[] getKbsAccessKey() {
|
||||
return kbsAccessKey;
|
||||
}
|
||||
|
||||
public byte[] getCipherText() {
|
||||
return cipherText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.whispersystems.signalservice.api.kbs;
|
||||
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey;
|
||||
import org.whispersystems.signalservice.internal.util.Hex;
|
||||
import org.whispersystems.util.Base64;
|
||||
import org.whispersystems.util.StringUtil;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.whispersystems.signalservice.api.crypto.CryptoUtil.hmacSha256;
|
||||
|
||||
public final class MasterKey {
|
||||
|
||||
private static final int LENGTH = 32;
|
||||
|
||||
private final byte[] masterKey;
|
||||
|
||||
public MasterKey(byte[] masterKey) {
|
||||
if (masterKey.length != LENGTH) throw new AssertionError();
|
||||
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
public static MasterKey createNew(SecureRandom secureRandom) {
|
||||
byte[] key = new byte[LENGTH];
|
||||
secureRandom.nextBytes(key);
|
||||
return new MasterKey(key);
|
||||
}
|
||||
|
||||
public String deriveRegistrationLock() {
|
||||
return Hex.toStringCondensed(derive("Registration Lock"));
|
||||
}
|
||||
|
||||
public String deriveRegistrationRecoveryPassword() {
|
||||
return Base64.encodeBytes(derive("Registration Recovery"));
|
||||
}
|
||||
|
||||
public StorageKey deriveStorageServiceKey() {
|
||||
return new StorageKey(derive("Storage Service Encryption"));
|
||||
}
|
||||
|
||||
private byte[] derive(String keyName) {
|
||||
return hmacSha256(masterKey, StringUtil.utf8(keyName));
|
||||
}
|
||||
|
||||
public byte[] serialize() {
|
||||
return masterKey.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || o.getClass() != getClass()) return false;
|
||||
|
||||
return Arrays.equals(((MasterKey) o).masterKey, masterKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(masterKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MasterKey(xxx)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.signalservice.api.kbs
|
||||
|
||||
import org.signal.libsignal.svr2.Pin
|
||||
import org.signal.libsignal.svr2.PinHash
|
||||
import org.whispersystems.signalservice.api.crypto.HmacSIV
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.Normalizer
|
||||
|
||||
object PinHashUtil {
|
||||
|
||||
/**
|
||||
* Takes a user-provided (i.e. non-normalized) PIN, normalizes it, and generates a [PinHash].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun hashPin(pin: String, salt: ByteArray): PinHash {
|
||||
return PinHash.svr1(normalize(pin), salt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a user-provided (i.e. non-normalized) PIN, normalizes it, and generates a hash that is suitable for storing on-device
|
||||
* for the purpose of checking PIN reminder correctness. Use in combination with [verifyLocalPinHash].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun localPinHash(pin: String): String {
|
||||
return Pin.localHash(normalize(pin))
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a user-provided (i.e. non-normalized) PIN, normalizes it, checks to see if it matches a hash that was generated with [localPinHash].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun verifyLocalPinHash(localPinHash: String, pin: String): Boolean {
|
||||
return Pin.verifyLocalHash(localPinHash, normalize(pin))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new [KbsData] to store on KBS.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createNewKbsData(pinHash: PinHash, masterKey: MasterKey): KbsData {
|
||||
val ivc = HmacSIV.encrypt(pinHash.encryptionKey(), masterKey.serialize())
|
||||
return KbsData(masterKey, pinHash.accessKey(), ivc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes 48 byte IVC from KBS and returns full [KbsData].
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(InvalidCiphertextException::class)
|
||||
fun decryptSvrDataIVCipherText(pinHash: PinHash, ivc: ByteArray?): KbsData {
|
||||
val masterKey = HmacSIV.decrypt(pinHash.encryptionKey(), ivc)
|
||||
return KbsData(MasterKey(masterKey), pinHash.accessKey(), ivc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a user-input PIN string and normalizes it to a standard character set.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun normalize(pin: String): ByteArray {
|
||||
var normalizedPin = pin.trim()
|
||||
|
||||
if (PinString.allNumeric(normalizedPin)) {
|
||||
normalizedPin = PinString.toArabic(normalizedPin)
|
||||
}
|
||||
|
||||
normalizedPin = Normalizer.normalize(normalizedPin, Normalizer.Form.NFKD)
|
||||
|
||||
return normalizedPin.toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.kbs;
|
||||
|
||||
final class PinString {
|
||||
|
||||
static boolean allNumeric(CharSequence pin) {
|
||||
for (int i = 0; i < pin.length(); i++) {
|
||||
if (!Character.isDigit(pin.charAt(i))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string of not necessarily Arabic numerals to Arabic 0..9 characters.
|
||||
*/
|
||||
static String toArabic(CharSequence numerals) {
|
||||
int length = numerals.length();
|
||||
char[] arabic = new char[length];
|
||||
|
||||
for (int i = 0; i < length; i++) {
|
||||
int digit = Character.digit(numerals.charAt(i), 10);
|
||||
|
||||
arabic[i] = (char) ('0' + digit);
|
||||
}
|
||||
|
||||
return new String(arabic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.kbs;
|
||||
|
||||
public final class PinValidityChecker {
|
||||
|
||||
public static boolean valid(String pin) {
|
||||
pin = pin.trim();
|
||||
|
||||
if (pin.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PinString.allNumeric(pin)) {
|
||||
pin = PinString.toArabic(pin);
|
||||
|
||||
return !sequential(pin) &&
|
||||
!sequential(reverse(pin)) &&
|
||||
!allTheSame(pin);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static String reverse(String string) {
|
||||
char[] chars = string.toCharArray();
|
||||
|
||||
for (int i = 0; i < chars.length / 2; i++) {
|
||||
char temp = chars[i];
|
||||
chars[i] = chars[chars.length - i - 1];
|
||||
chars[chars.length - i - 1] = temp;
|
||||
}
|
||||
|
||||
return new String(chars);
|
||||
}
|
||||
|
||||
private static boolean sequential(String pin) {
|
||||
int length = pin.length();
|
||||
|
||||
if (length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char c = pin.charAt(0);
|
||||
|
||||
for (int i = 1; i < length; i++) {
|
||||
char n = pin.charAt(i);
|
||||
if (n != c + 1) {
|
||||
return false;
|
||||
}
|
||||
c = n;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean allTheSame(String pin) {
|
||||
int length = pin.length();
|
||||
|
||||
if (length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char c = pin.charAt(0);
|
||||
|
||||
for (int i = 1; i < length; i++) {
|
||||
char n = pin.charAt(i);
|
||||
if (n != c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
import org.signal.libsignal.protocol.message.DecryptionErrorMessage
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.EditMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.ReceiptMessage
|
||||
import org.whispersystems.signalservice.internal.push.StoryMessage
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import org.whispersystems.signalservice.internal.push.TypingMessage
|
||||
|
||||
/**
|
||||
* Validates an [Envelope] and its decrypted [Content] so that we know the message can be processed safely
|
||||
* down the line.
|
||||
*
|
||||
* Mostly makes sure that UUIDs are valid, required fields are presents, etc.
|
||||
*/
|
||||
object EnvelopeContentValidator {
|
||||
|
||||
fun validate(envelope: Envelope, content: Content): Result {
|
||||
if (envelope.type == Envelope.Type.PLAINTEXT_CONTENT) {
|
||||
val result: Result? = createPlaintextResultIfInvalid(content)
|
||||
|
||||
if (result != null) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.sourceServiceId != null && envelope.sourceServiceId.isInvalidServiceId()) {
|
||||
return Result.Invalid("Envelope had an invalid sourceServiceId!")
|
||||
}
|
||||
|
||||
// Reminder: envelope.destinationServiceId was already validated since we need that for decryption
|
||||
|
||||
return when {
|
||||
envelope.story == true && !content.meetsStoryFlagCriteria() -> Result.Invalid("Envelope was flagged as a story, but it did not have any story-related content!")
|
||||
content.dataMessage != null -> validateDataMessage(envelope, content.dataMessage)
|
||||
content.syncMessage != null -> validateSyncMessage(envelope, content.syncMessage)
|
||||
content.callMessage != null -> Result.Valid
|
||||
content.nullMessage != null -> Result.Valid
|
||||
content.receiptMessage != null -> validateReceiptMessage(content.receiptMessage)
|
||||
content.typingMessage != null -> validateTypingMessage(envelope, content.typingMessage)
|
||||
content.decryptionErrorMessage != null -> validateDecryptionErrorMessage(content.decryptionErrorMessage.toByteArray())
|
||||
content.storyMessage != null -> validateStoryMessage(content.storyMessage)
|
||||
content.pniSignatureMessage != null -> Result.Valid
|
||||
content.senderKeyDistributionMessage != null -> Result.Valid
|
||||
content.editMessage != null -> validateEditMessage(content.editMessage)
|
||||
else -> Result.Invalid("Content is empty!")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDataMessage(envelope: Envelope, dataMessage: DataMessage): Result {
|
||||
if (dataMessage.requiredProtocolVersion != null && dataMessage.requiredProtocolVersion > DataMessage.ProtocolVersion.CURRENT.value) {
|
||||
return Result.UnsupportedDataMessage(
|
||||
ourVersion = DataMessage.ProtocolVersion.CURRENT.value,
|
||||
theirVersion = dataMessage.requiredProtocolVersion
|
||||
)
|
||||
}
|
||||
|
||||
if (dataMessage.timestamp == null) {
|
||||
return Result.Invalid("[DataMessage] Missing timestamp!")
|
||||
}
|
||||
|
||||
if (dataMessage.timestamp != envelope.timestamp) {
|
||||
Result.Invalid("[DataMessage] Timestamps don't match! envelope: ${envelope.timestamp}, content: ${dataMessage.timestamp}")
|
||||
}
|
||||
|
||||
if (dataMessage.quote != null && dataMessage.quote.authorAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[DataMessage] Invalid ACI on quote!")
|
||||
}
|
||||
|
||||
if (dataMessage.contact.any { it.avatar != null && it.avatar.avatar.isPresentAndInvalid() }) {
|
||||
return Result.Invalid("[DataMessage] Invalid AttachmentPointer on DataMessage.contactList.avatar!")
|
||||
}
|
||||
|
||||
if (dataMessage.preview.any { it.image != null && it.image.isPresentAndInvalid() }) {
|
||||
return Result.Invalid("[DataMessage] Invalid AttachmentPointer on DataMessage.previewList.image!")
|
||||
}
|
||||
|
||||
if (dataMessage.bodyRanges.any { it.mentionAci != null && it.mentionAci.isNullOrInvalidAci() }) {
|
||||
return Result.Invalid("[DataMessage] Invalid ACI on body range!")
|
||||
}
|
||||
|
||||
if (dataMessage.sticker != null && dataMessage.sticker.data_.isNullOrInvalid()) {
|
||||
return Result.Invalid("[DataMessage] Invalid AttachmentPointer on DataMessage.sticker!")
|
||||
}
|
||||
|
||||
if (dataMessage.reaction != null) {
|
||||
if (dataMessage.reaction.targetSentTimestamp == null) {
|
||||
return Result.Invalid("[DataMessage] Missing timestamp on DataMessage.reaction!")
|
||||
}
|
||||
|
||||
if (dataMessage.reaction.targetAuthorAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[DataMessage] Invalid ACI on DataMessage.reaction!")
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMessage.delete != null && dataMessage.delete.targetSentTimestamp == null) {
|
||||
return Result.Invalid("[DataMessage] Missing timestamp on DataMessage.delete!")
|
||||
}
|
||||
|
||||
if (dataMessage.storyContext != null && dataMessage.storyContext.authorAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[DataMessage] Invalid ACI on DataMessage.storyContext!")
|
||||
}
|
||||
|
||||
if (dataMessage.giftBadge != null) {
|
||||
if (dataMessage.giftBadge.receiptCredentialPresentation == null) {
|
||||
return Result.Invalid("[DataMessage] Missing DataMessage.giftBadge.receiptCredentialPresentation!")
|
||||
}
|
||||
|
||||
try {
|
||||
ReceiptCredentialPresentation(dataMessage.giftBadge.receiptCredentialPresentation.toByteArray())
|
||||
} catch (e: InvalidInputException) {
|
||||
return Result.Invalid("[DataMessage] Invalid DataMessage.giftBadge.receiptCredentialPresentation!")
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMessage.attachments.any { it.isNullOrInvalid() }) {
|
||||
return Result.Invalid("[DataMessage] Invalid attachments!")
|
||||
}
|
||||
|
||||
if (dataMessage.groupV2 != null) {
|
||||
validateGroupContextV2(dataMessage.groupV2, "[DataMessage]")?.let { return it }
|
||||
}
|
||||
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun validateSyncMessage(envelope: Envelope, syncMessage: SyncMessage): Result {
|
||||
if (syncMessage.sent != null) {
|
||||
val validAddress = syncMessage.sent.destinationServiceId.isValidServiceId()
|
||||
val hasDataGroup = syncMessage.sent.message?.groupV2 != null
|
||||
val hasStoryGroup = syncMessage.sent.storyMessage?.group != null
|
||||
val hasStoryManifest = syncMessage.sent.storyMessageRecipients.isNotEmpty()
|
||||
val hasEditMessageGroup = syncMessage.sent.editMessage?.dataMessage?.groupV2 != null
|
||||
|
||||
if (hasDataGroup) {
|
||||
validateGroupContextV2(syncMessage.sent.message!!.groupV2!!, "[SyncMessage.Sent.Message]")?.let { return it }
|
||||
}
|
||||
|
||||
if (hasStoryGroup) {
|
||||
validateGroupContextV2(syncMessage.sent.storyMessage!!.group!!, "[SyncMessage.Sent.StoryMessage]")?.let { return it }
|
||||
}
|
||||
|
||||
if (hasEditMessageGroup) {
|
||||
validateGroupContextV2(syncMessage.sent.editMessage!!.dataMessage!!.groupV2!!, "[SyncMessage.Sent.EditMessage]")?.let { return it }
|
||||
}
|
||||
|
||||
if (!validAddress && !hasDataGroup && !hasStoryGroup && !hasStoryManifest && !hasEditMessageGroup) {
|
||||
return Result.Invalid("[SyncMessage] No valid destination! Checked the destination, DataMessage.group, StoryMessage.group, EditMessage.group and storyMessageRecipientList")
|
||||
}
|
||||
|
||||
for (status in syncMessage.sent.unidentifiedStatus) {
|
||||
if (status.destinationServiceId.isNullOrInvalidServiceId()) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ServiceId in SyncMessage.sent.unidentifiedStatusList!")
|
||||
}
|
||||
}
|
||||
|
||||
return if (syncMessage.sent.message != null) {
|
||||
validateDataMessage(envelope, syncMessage.sent.message)
|
||||
} else if (syncMessage.sent.storyMessage != null) {
|
||||
validateStoryMessage(syncMessage.sent.storyMessage)
|
||||
} else if (syncMessage.sent.storyMessageRecipients.isNotEmpty()) {
|
||||
Result.Valid
|
||||
} else if (syncMessage.sent.editMessage != null) {
|
||||
validateEditMessage(syncMessage.sent.editMessage)
|
||||
} else {
|
||||
Result.Invalid("[SyncMessage] Empty SyncMessage.sent!")
|
||||
}
|
||||
}
|
||||
|
||||
if (syncMessage.read.any { it.senderAci.isNullOrInvalidAci() }) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.readList!")
|
||||
}
|
||||
|
||||
if (syncMessage.viewed.any { it.senderAci.isNullOrInvalidAci() }) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.viewList!")
|
||||
}
|
||||
|
||||
if (syncMessage.viewOnceOpen != null && syncMessage.viewOnceOpen.senderAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.viewOnceOpen!")
|
||||
}
|
||||
|
||||
if (syncMessage.verified != null && syncMessage.verified.destinationAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.verified!")
|
||||
}
|
||||
|
||||
if (syncMessage.stickerPackOperation.any { it.packId == null }) {
|
||||
return Result.Invalid("[SyncMessage] Missing packId in stickerPackOperationList!")
|
||||
}
|
||||
|
||||
if (syncMessage.blocked != null && syncMessage.blocked.acis.any { it.isNullOrInvalidAci() }) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.blocked!")
|
||||
}
|
||||
|
||||
if (syncMessage.messageRequestResponse != null && syncMessage.messageRequestResponse.groupId == null && syncMessage.messageRequestResponse.threadAci.isNullOrInvalidAci()) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ACI in SyncMessage.messageRequestResponse!")
|
||||
}
|
||||
|
||||
if (syncMessage.outgoingPayment != null && syncMessage.outgoingPayment.recipientServiceId.isNullOrInvalidServiceId()) {
|
||||
return Result.Invalid("[SyncMessage] Invalid ServiceId in SyncMessage.outgoingPayment!")
|
||||
}
|
||||
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun validateReceiptMessage(receiptMessage: ReceiptMessage): Result {
|
||||
return if (receiptMessage.type == null) {
|
||||
Result.Invalid("[ReceiptMessage] Missing type!")
|
||||
} else {
|
||||
Result.Valid
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateTypingMessage(envelope: Envelope, typingMessage: TypingMessage): Result {
|
||||
return if (typingMessage.timestamp == null) {
|
||||
return Result.Invalid("[TypingMessage] Missing timestamp!")
|
||||
} else if (typingMessage.timestamp != envelope.timestamp) {
|
||||
Result.Invalid("[TypingMessage] Timestamps don't match! envelope: ${envelope.timestamp}, content: ${typingMessage.timestamp}")
|
||||
} else if (typingMessage.action == null) {
|
||||
Result.Invalid("[TypingMessage] Missing action!")
|
||||
} else {
|
||||
Result.Valid
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDecryptionErrorMessage(serializedDecryptionErrorMessage: ByteArray): Result {
|
||||
return try {
|
||||
DecryptionErrorMessage(serializedDecryptionErrorMessage)
|
||||
Result.Valid
|
||||
} catch (e: InvalidMessageStructureException) {
|
||||
Result.Invalid("[DecryptionErrorMessage] Bad decryption error message!", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateStoryMessage(storyMessage: StoryMessage): Result {
|
||||
if (storyMessage.group != null) {
|
||||
validateGroupContextV2(storyMessage.group, "[StoryMessage]")?.let { return it }
|
||||
}
|
||||
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun validateEditMessage(editMessage: EditMessage): Result {
|
||||
if (editMessage.dataMessage == null) {
|
||||
return Result.Invalid("[EditMessage] No data message present")
|
||||
}
|
||||
|
||||
if (editMessage.targetSentTimestamp == null) {
|
||||
return Result.Invalid("[EditMessage] No targetSentTimestamp specified")
|
||||
}
|
||||
|
||||
val dataMessage: DataMessage = editMessage.dataMessage
|
||||
|
||||
if (dataMessage.requiredProtocolVersion != null && dataMessage.requiredProtocolVersion > DataMessage.ProtocolVersion.CURRENT.value) {
|
||||
return Result.UnsupportedDataMessage(
|
||||
ourVersion = DataMessage.ProtocolVersion.CURRENT.value,
|
||||
theirVersion = dataMessage.requiredProtocolVersion
|
||||
)
|
||||
}
|
||||
|
||||
if (dataMessage.preview.any { it.image != null && it.image.isPresentAndInvalid() }) {
|
||||
return Result.Invalid("[EditMessage] Invalid AttachmentPointer on DataMessage.previewList.image!")
|
||||
}
|
||||
|
||||
if (dataMessage.bodyRanges.any { it.mentionAci != null && it.mentionAci.isNullOrInvalidAci() }) {
|
||||
return Result.Invalid("[EditMessage] Invalid UUID on body range!")
|
||||
}
|
||||
|
||||
if (dataMessage.attachments.any { it.isNullOrInvalid() }) {
|
||||
return Result.Invalid("[EditMessage] Invalid attachments!")
|
||||
}
|
||||
|
||||
if (dataMessage.groupV2 != null) {
|
||||
validateGroupContextV2(dataMessage.groupV2, "[EditMessage]")?.let { return it }
|
||||
}
|
||||
|
||||
return Result.Valid
|
||||
}
|
||||
|
||||
private fun AttachmentPointer?.isNullOrInvalid(): Boolean {
|
||||
return this == null || (this.cdnId == null && this.cdnKey == null)
|
||||
}
|
||||
|
||||
private fun AttachmentPointer?.isPresentAndInvalid(): Boolean {
|
||||
return this != null && (this.cdnId == null && this.cdnKey == null)
|
||||
}
|
||||
|
||||
private fun String?.isValidServiceId(): Boolean {
|
||||
val parsed = ServiceId.parseOrNull(this)
|
||||
return parsed != null && parsed.isValid
|
||||
}
|
||||
|
||||
private fun String?.isNullOrInvalidServiceId(): Boolean {
|
||||
val parsed = ServiceId.parseOrNull(this)
|
||||
return parsed == null || parsed.isUnknown
|
||||
}
|
||||
|
||||
private fun String.isInvalidServiceId(): Boolean {
|
||||
val parsed = ServiceId.parseOrNull(this)
|
||||
return parsed == null || parsed.isUnknown
|
||||
}
|
||||
|
||||
private fun String?.isNullOrInvalidAci(): Boolean {
|
||||
val parsed = ACI.parseOrNull(this)
|
||||
return parsed == null || parsed.isUnknown
|
||||
}
|
||||
|
||||
private fun Content?.meetsStoryFlagCriteria(): Boolean {
|
||||
return when {
|
||||
this == null -> false
|
||||
this.senderKeyDistributionMessage != null -> true
|
||||
this.storyMessage != null -> true
|
||||
this.dataMessage != null && this.dataMessage.storyContext != null && this.dataMessage.groupV2 != null -> true
|
||||
this.dataMessage != null && this.dataMessage.delete != null -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlaintextResultIfInvalid(content: Content): Result? {
|
||||
val errors: MutableList<String> = mutableListOf()
|
||||
|
||||
if (content.decryptionErrorMessage == null) {
|
||||
errors += "Missing DecryptionErrorMessage"
|
||||
}
|
||||
if (content.storyMessage != null) {
|
||||
errors += "Unexpected StoryMessage"
|
||||
}
|
||||
if (content.senderKeyDistributionMessage != null) {
|
||||
errors += "Unexpected SenderKeyDistributionMessage"
|
||||
}
|
||||
if (content.callMessage != null) {
|
||||
errors += "Unexpected CallMessage"
|
||||
}
|
||||
if (content.editMessage != null) {
|
||||
errors += "Unexpected EditMessage"
|
||||
}
|
||||
if (content.nullMessage != null) {
|
||||
errors += "Unexpected NullMessage"
|
||||
}
|
||||
if (content.pniSignatureMessage != null) {
|
||||
errors += "Unexpected PniSignatureMessage"
|
||||
}
|
||||
if (content.receiptMessage != null) {
|
||||
errors += "Unexpected ReceiptMessage"
|
||||
}
|
||||
if (content.syncMessage != null) {
|
||||
errors += "Unexpected SyncMessage"
|
||||
}
|
||||
if (content.typingMessage != null) {
|
||||
errors += "Unexpected TypingMessage"
|
||||
}
|
||||
|
||||
return if (errors.isNotEmpty()) {
|
||||
Result.Invalid("Invalid PLAINTEXT_CONTENT! Errors: $errors")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateGroupContextV2(groupContext: GroupContextV2, prefix: String): Result.Invalid? {
|
||||
return if (groupContext.masterKey == null) {
|
||||
Result.Invalid("$prefix Missing GV2 master key!")
|
||||
} else if (groupContext.revision == null) {
|
||||
Result.Invalid("$prefix Missing GV2 revision!")
|
||||
} else {
|
||||
try {
|
||||
GroupMasterKey(groupContext.masterKey.toByteArray())
|
||||
null
|
||||
} catch (e: InvalidInputException) {
|
||||
Result.Invalid("$prefix Bad GV2 master key!", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
/** Content is valid. */
|
||||
object Valid : Result()
|
||||
|
||||
/** The [DataMessage.requiredProtocolVersion] is newer than the one we support. */
|
||||
class UnsupportedDataMessage(val ourVersion: Int, val theirVersion: Int?) : Result()
|
||||
|
||||
/** The contents of the proto do not match our expectations, e.g. invalid UUIDs, missing required fields, etc. */
|
||||
class Invalid(val reason: String, val throwable: Throwable = Throwable()) : Result()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
|
||||
/**
|
||||
* Represents an envelope off the wire, paired with the metadata needed to process it.
|
||||
*/
|
||||
class EnvelopeResponse(
|
||||
val envelope: Envelope,
|
||||
val serverDeliveredTimestamp: Long,
|
||||
val websocketRequest: WebSocketRequestMessage
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.internal.push.Content;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SendMessageResult {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final Success success;
|
||||
private final boolean networkFailure;
|
||||
private final boolean unregisteredFailure;
|
||||
private final IdentityFailure identityFailure;
|
||||
private final ProofRequiredException proofRequiredFailure;
|
||||
private final RateLimitException rateLimitFailure;
|
||||
private final boolean invalidPreKeyFailure;
|
||||
|
||||
public static SendMessageResult success(SignalServiceAddress address, List<Integer> devices, boolean unidentified, boolean needsSync, long duration, Optional<Content> content) {
|
||||
return new SendMessageResult(address, new Success(unidentified, needsSync, duration, content, devices), false, false, null, null, null, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult networkFailure(SignalServiceAddress address) {
|
||||
return new SendMessageResult(address, null, true, false, null, null, null, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult unregisteredFailure(SignalServiceAddress address) {
|
||||
return new SendMessageResult(address, null, false, true, null, null, null, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult identityFailure(SignalServiceAddress address, IdentityKey identityKey) {
|
||||
return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey), null, null, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult proofRequiredFailure(SignalServiceAddress address, ProofRequiredException proofRequiredException) {
|
||||
return new SendMessageResult(address, null, false, false, null, proofRequiredException, null, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult rateLimitFailure(SignalServiceAddress address, RateLimitException rateLimitException) {
|
||||
return new SendMessageResult(address, null, false, false, null, null, rateLimitException, false);
|
||||
}
|
||||
|
||||
public static SendMessageResult invalidPreKeyFailure(SignalServiceAddress address) {
|
||||
return new SendMessageResult(address, null, false, false, null, null, null, true);
|
||||
}
|
||||
|
||||
public SignalServiceAddress getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public Success getSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success != null;
|
||||
}
|
||||
|
||||
public boolean isNetworkFailure() {
|
||||
return networkFailure || proofRequiredFailure != null || rateLimitFailure != null;
|
||||
}
|
||||
|
||||
public boolean isUnregisteredFailure() {
|
||||
return unregisteredFailure;
|
||||
}
|
||||
|
||||
public IdentityFailure getIdentityFailure() {
|
||||
return identityFailure;
|
||||
}
|
||||
|
||||
public ProofRequiredException getProofRequiredFailure() {
|
||||
return proofRequiredFailure;
|
||||
}
|
||||
|
||||
public RateLimitException getRateLimitFailure() {
|
||||
return rateLimitFailure;
|
||||
}
|
||||
|
||||
public boolean isInvalidPreKeyFailure() {
|
||||
return invalidPreKeyFailure;
|
||||
}
|
||||
|
||||
private SendMessageResult(SignalServiceAddress address,
|
||||
Success success,
|
||||
boolean networkFailure,
|
||||
boolean unregisteredFailure,
|
||||
IdentityFailure identityFailure,
|
||||
ProofRequiredException proofRequiredFailure,
|
||||
RateLimitException rateLimitFailure,
|
||||
boolean invalidPreKeyFailure)
|
||||
{
|
||||
this.address = address;
|
||||
this.success = success;
|
||||
this.networkFailure = networkFailure;
|
||||
this.unregisteredFailure = unregisteredFailure;
|
||||
this.identityFailure = identityFailure;
|
||||
this.proofRequiredFailure = proofRequiredFailure;
|
||||
this.rateLimitFailure = rateLimitFailure;
|
||||
this.invalidPreKeyFailure = invalidPreKeyFailure;
|
||||
}
|
||||
|
||||
public static class Success {
|
||||
private final boolean unidentified;
|
||||
private final boolean needsSync;
|
||||
private final long duration;
|
||||
private final Optional<Content> content;
|
||||
private final List<Integer> devices;
|
||||
|
||||
private Success(boolean unidentified, boolean needsSync, long duration, Optional<Content> content, List<Integer> devices) {
|
||||
this.unidentified = unidentified;
|
||||
this.needsSync = needsSync;
|
||||
this.duration = duration;
|
||||
this.content = content;
|
||||
this.devices = devices;
|
||||
}
|
||||
|
||||
public boolean isUnidentified() {
|
||||
return unidentified;
|
||||
}
|
||||
|
||||
public boolean isNeedsSync() {
|
||||
return needsSync;
|
||||
}
|
||||
|
||||
public long getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public Optional<Content> getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public List<Integer> getDevices() {
|
||||
return devices;
|
||||
}
|
||||
}
|
||||
|
||||
public static class IdentityFailure {
|
||||
private final IdentityKey identityKey;
|
||||
|
||||
private IdentityFailure(IdentityKey identityKey) {
|
||||
this.identityKey = identityKey;
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class SignalServiceAttachment {
|
||||
|
||||
private final String contentType;
|
||||
|
||||
protected SignalServiceAttachment(String contentType) {
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public abstract boolean isStream();
|
||||
public abstract boolean isPointer();
|
||||
|
||||
public SignalServiceAttachmentStream asStream() {
|
||||
return (SignalServiceAttachmentStream)this;
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentPointer asPointer() {
|
||||
return (SignalServiceAttachmentPointer)this;
|
||||
}
|
||||
|
||||
public static Builder newStreamBuilder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentStream emptyStream(String contentType) {
|
||||
return new SignalServiceAttachmentStream(new ByteArrayInputStream(new byte[0]), contentType, 0, Optional.empty(), false, false, false, null, null);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private InputStream inputStream;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
private long length;
|
||||
private ProgressListener listener;
|
||||
private CancelationSignal cancelationSignal;
|
||||
private boolean voiceNote;
|
||||
private boolean borderless;
|
||||
private boolean gif;
|
||||
private int width;
|
||||
private int height;
|
||||
private String caption;
|
||||
private String blurHash;
|
||||
private long uploadTimestamp;
|
||||
private ResumableUploadSpec resumableUploadSpec;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
public Builder withStream(InputStream inputStream) {
|
||||
this.inputStream = inputStream;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withContentType(String contentType) {
|
||||
this.contentType = contentType;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withLength(long length) {
|
||||
this.length = length;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withListener(ProgressListener listener) {
|
||||
this.listener = listener;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withCancelationSignal(CancelationSignal cancelationSignal) {
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withVoiceNote(boolean voiceNote) {
|
||||
this.voiceNote = voiceNote;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withBorderless(boolean borderless) {
|
||||
this.borderless = borderless;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withGif(boolean gif) {
|
||||
this.gif = gif;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withWidth(int width) {
|
||||
this.width = width;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withHeight(int height) {
|
||||
this.height = height;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withCaption(String caption) {
|
||||
this.caption = caption;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withBlurHash(String blurHash) {
|
||||
this.blurHash = blurHash;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withUploadTimestamp(long uploadTimestamp) {
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withResumableUploadSpec(ResumableUploadSpec resumableUploadSpec) {
|
||||
this.resumableUploadSpec = resumableUploadSpec;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentStream build() {
|
||||
if (inputStream == null) throw new IllegalArgumentException("Must specify stream!");
|
||||
if (contentType == null) throw new IllegalArgumentException("No content type specified!");
|
||||
if (length == 0) throw new IllegalArgumentException("No length specified!");
|
||||
|
||||
return new SignalServiceAttachmentStream(inputStream,
|
||||
contentType,
|
||||
length,
|
||||
Optional.ofNullable(fileName),
|
||||
voiceNote,
|
||||
borderless,
|
||||
gif,
|
||||
Optional.empty(),
|
||||
width,
|
||||
height,
|
||||
uploadTimestamp,
|
||||
Optional.ofNullable(caption),
|
||||
Optional.ofNullable(blurHash),
|
||||
listener,
|
||||
cancelationSignal,
|
||||
Optional.ofNullable(resumableUploadSpec));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface to receive progress information on upload/download of
|
||||
* an attachment.
|
||||
*/
|
||||
public interface ProgressListener {
|
||||
/**
|
||||
* Called on a progress change event.
|
||||
*
|
||||
* @param total The total amount to transmit/receive in bytes.
|
||||
* @param progress The amount that has been transmitted/received in bytes thus far
|
||||
*/
|
||||
void onAttachmentProgress(long total, long progress);
|
||||
|
||||
boolean shouldCancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2017 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Represents a received SignalServiceAttachment "handle." This
|
||||
* is a pointer to the actual attachment content, which needs to be
|
||||
* retrieved using {@link SignalServiceMessageReceiver#retrieveAttachment(SignalServiceAttachmentPointer, java.io.File, long)}
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class SignalServiceAttachmentPointer extends SignalServiceAttachment {
|
||||
|
||||
private final int cdnNumber;
|
||||
private final SignalServiceAttachmentRemoteId remoteId;
|
||||
private final byte[] key;
|
||||
private final Optional<Integer> size;
|
||||
private final Optional<byte[]> preview;
|
||||
private final Optional<byte[]> digest;
|
||||
private final Optional<byte[]> incrementalDigest;
|
||||
private final int incrementalMacChunkSize;
|
||||
private final Optional<String> fileName;
|
||||
private final boolean voiceNote;
|
||||
private final boolean borderless;
|
||||
private final boolean gif;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final Optional<String> caption;
|
||||
private final Optional<String> blurHash;
|
||||
private final long uploadTimestamp;
|
||||
|
||||
public SignalServiceAttachmentPointer(int cdnNumber,
|
||||
SignalServiceAttachmentRemoteId remoteId,
|
||||
String contentType,
|
||||
byte[] key,
|
||||
Optional<Integer> size,
|
||||
Optional<byte[]> preview,
|
||||
int width,
|
||||
int height,
|
||||
Optional<byte[]> digest,
|
||||
Optional<byte[]> incrementalDigest,
|
||||
int incrementalMacChunkSize,
|
||||
Optional<String> fileName,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean gif,
|
||||
Optional<String> caption,
|
||||
Optional<String> blurHash,
|
||||
long uploadTimestamp)
|
||||
{
|
||||
super(contentType);
|
||||
this.cdnNumber = cdnNumber;
|
||||
this.remoteId = remoteId;
|
||||
this.key = key;
|
||||
this.size = size;
|
||||
this.preview = preview;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.incrementalMacChunkSize = incrementalMacChunkSize;
|
||||
this.digest = digest;
|
||||
this.incrementalDigest = incrementalDigest;
|
||||
this.fileName = fileName;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.gif = gif;
|
||||
}
|
||||
|
||||
public int getCdnNumber() {
|
||||
return cdnNumber;
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentRemoteId getRemoteId() {
|
||||
return remoteId;
|
||||
}
|
||||
|
||||
public byte[] getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isStream() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPointer() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<Integer> getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public Optional<String> getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getDigest() {
|
||||
return digest;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getIncrementalDigest() {
|
||||
return incrementalDigest;
|
||||
}
|
||||
|
||||
public boolean getVoiceNote() {
|
||||
return voiceNote;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return borderless;
|
||||
}
|
||||
|
||||
public boolean isGif() {
|
||||
return gif;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int getIncrementalMacChunkSize() {
|
||||
return incrementalMacChunkSize;
|
||||
}
|
||||
|
||||
public Optional<String> getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public Optional<String> getBlurHash() {
|
||||
return blurHash;
|
||||
}
|
||||
|
||||
public long getUploadTimestamp() {
|
||||
return uploadTimestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Represents a signal service attachment identifier. This can be either a CDN key or a long, but
|
||||
* not both at once. Attachments V2 used a long as an attachment identifier. This lacks sufficient
|
||||
* entropy to reduce the likelihood of any two uploads going to the same location within a 30-day
|
||||
* window. Attachments V3 uses an opaque string as an attachment identifier which provides more
|
||||
* flexibility in the amount of entropy present.
|
||||
*/
|
||||
public final class SignalServiceAttachmentRemoteId {
|
||||
private final Optional<Long> v2;
|
||||
private final Optional<String> v3;
|
||||
|
||||
public SignalServiceAttachmentRemoteId(long v2) {
|
||||
this.v2 = Optional.of(v2);
|
||||
this.v3 = Optional.empty();
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentRemoteId(String v3) {
|
||||
this.v2 = Optional.empty();
|
||||
this.v3 = Optional.of(v3);
|
||||
}
|
||||
|
||||
public Optional<Long> getV2() {
|
||||
return v2;
|
||||
}
|
||||
|
||||
public Optional<String> getV3() {
|
||||
return v3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
if (v2.isPresent()) {
|
||||
return v2.get().toString();
|
||||
} else {
|
||||
return v3.get();
|
||||
}
|
||||
}
|
||||
|
||||
public static SignalServiceAttachmentRemoteId from(AttachmentPointer attachmentPointer) throws InvalidMessageStructureException {
|
||||
if (attachmentPointer.cdnKey != null) {
|
||||
return new SignalServiceAttachmentRemoteId(attachmentPointer.cdnKey);
|
||||
} else if (attachmentPointer.cdnId != null && attachmentPointer.cdnId > 0) {
|
||||
return new SignalServiceAttachmentRemoteId(attachmentPointer.cdnId);
|
||||
} else {
|
||||
throw new InvalidMessageStructureException("AttachmentPointer CDN location not set");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guesses that strings which contain values parseable to {@code long} should use an id-based
|
||||
* CDN path. Otherwise, use key-based CDN path.
|
||||
*/
|
||||
public static SignalServiceAttachmentRemoteId from(String string) {
|
||||
try {
|
||||
return new SignalServiceAttachmentRemoteId(Long.parseLong(string));
|
||||
} catch (NumberFormatException e) {
|
||||
return new SignalServiceAttachmentRemoteId(string);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Represents a local SignalServiceAttachment to be sent.
|
||||
*/
|
||||
public class SignalServiceAttachmentStream extends SignalServiceAttachment implements Closeable {
|
||||
|
||||
private final InputStream inputStream;
|
||||
private final long length;
|
||||
private final Optional<String> fileName;
|
||||
private final ProgressListener listener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final Optional<byte[]> preview;
|
||||
private final boolean voiceNote;
|
||||
private final boolean borderless;
|
||||
private final boolean gif;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final long uploadTimestamp;
|
||||
private final Optional<String> caption;
|
||||
private final Optional<String> blurHash;
|
||||
private final Optional<ResumableUploadSpec> resumableUploadSpec;
|
||||
|
||||
public SignalServiceAttachmentStream(InputStream inputStream,
|
||||
String contentType,
|
||||
long length,
|
||||
Optional<String> fileName,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean gif,
|
||||
ProgressListener listener,
|
||||
CancelationSignal cancelationSignal)
|
||||
{
|
||||
this(inputStream, contentType, length, fileName, voiceNote, borderless, gif, Optional.empty(), 0, 0, System.currentTimeMillis(), Optional.empty(), Optional.empty(), listener, cancelationSignal, Optional.empty());
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentStream(InputStream inputStream,
|
||||
String contentType,
|
||||
long length,
|
||||
Optional<String> fileName,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean gif,
|
||||
Optional<byte[]> preview,
|
||||
int width,
|
||||
int height,
|
||||
long uploadTimestamp,
|
||||
Optional<String> caption,
|
||||
Optional<String> blurHash,
|
||||
ProgressListener listener,
|
||||
CancelationSignal cancelationSignal,
|
||||
Optional<ResumableUploadSpec> resumableUploadSpec)
|
||||
{
|
||||
super(contentType);
|
||||
this.inputStream = inputStream;
|
||||
this.length = length;
|
||||
this.fileName = fileName;
|
||||
this.listener = listener;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.gif = gif;
|
||||
this.preview = preview;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
this.resumableUploadSpec = resumableUploadSpec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isStream() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPointer() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
public long getLength() {
|
||||
return length;
|
||||
}
|
||||
|
||||
public Optional<String> getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public ProgressListener getListener() {
|
||||
return listener;
|
||||
}
|
||||
|
||||
public CancelationSignal getCancelationSignal() {
|
||||
return cancelationSignal;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public boolean getVoiceNote() {
|
||||
return voiceNote;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return borderless;
|
||||
}
|
||||
|
||||
public boolean isGif() {
|
||||
return gif;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public Optional<String> getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public Optional<String> getBlurHash() {
|
||||
return blurHash;
|
||||
}
|
||||
|
||||
public long getUploadTimestamp() {
|
||||
return uploadTimestamp;
|
||||
}
|
||||
|
||||
public Optional<ResumableUploadSpec> getResumableUploadSpec() {
|
||||
return resumableUploadSpec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
inputStream.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.whispersystems.signalservice.api.messages.shared.SharedContact
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil.emptyIfStringEmpty
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage.Payment as PaymentProto
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage.Quote as QuoteProto
|
||||
|
||||
/**
|
||||
* Represents a decrypted Signal Service data message.
|
||||
*
|
||||
* @param timestamp The sent timestamp.
|
||||
* @param groupContext The group information (or null if none).
|
||||
* @param attachments The attachments (or null if none).
|
||||
* @param body The message contents.
|
||||
* @param isEndSession Flag indicating whether this message should close a session.
|
||||
* @param expiresInSeconds Number of seconds in which the message should disappear after being seen.
|
||||
*/
|
||||
class SignalServiceDataMessage private constructor(
|
||||
val timestamp: Long,
|
||||
val groupContext: Optional<SignalServiceGroupV2>,
|
||||
val attachments: Optional<List<SignalServiceAttachment>>,
|
||||
val body: Optional<String>,
|
||||
val isEndSession: Boolean,
|
||||
val expiresInSeconds: Int,
|
||||
val isExpirationUpdate: Boolean,
|
||||
val profileKey: Optional<ByteArray>,
|
||||
val isProfileKeyUpdate: Boolean,
|
||||
val quote: Optional<Quote>,
|
||||
val sharedContacts: Optional<List<SharedContact>>,
|
||||
val previews: Optional<List<SignalServicePreview>>,
|
||||
val mentions: Optional<List<Mention>>,
|
||||
val sticker: Optional<Sticker>,
|
||||
val isViewOnce: Boolean,
|
||||
val reaction: Optional<Reaction>,
|
||||
val remoteDelete: Optional<RemoteDelete>,
|
||||
val groupCallUpdate: Optional<GroupCallUpdate>,
|
||||
val payment: Optional<Payment>,
|
||||
val storyContext: Optional<StoryContext>,
|
||||
val giftBadge: Optional<GiftBadge>,
|
||||
val bodyRanges: Optional<List<BodyRange>>
|
||||
) {
|
||||
val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false)
|
||||
val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false)
|
||||
|
||||
val groupId: Optional<ByteArray> = groupContext.map { GroupSecretParams.deriveFromMasterKey(it.masterKey).publicParams.groupIdentifier.serialize() }
|
||||
val isGroupV2Message: Boolean = groupContext.isPresent
|
||||
|
||||
/** Contains some user data that affects the conversation */
|
||||
private val hasRenderableContent: Boolean =
|
||||
this.attachments.isPresent ||
|
||||
this.body.isPresent ||
|
||||
this.quote.isPresent ||
|
||||
this.sharedContacts.isPresent ||
|
||||
this.previews.isPresent ||
|
||||
this.mentions.isPresent ||
|
||||
this.sticker.isPresent ||
|
||||
this.reaction.isPresent ||
|
||||
this.remoteDelete.isPresent
|
||||
|
||||
val isGroupV2Update: Boolean = groupContext.isPresent && groupContext.get().hasSignedGroupChange() && !hasRenderableContent
|
||||
val isEmptyGroupV2Message: Boolean = isGroupV2Message && !isGroupV2Update && !hasRenderableContent
|
||||
|
||||
class Builder {
|
||||
private var timestamp: Long = 0
|
||||
private var groupV2: SignalServiceGroupV2? = null
|
||||
private val attachments: MutableList<SignalServiceAttachment> = LinkedList<SignalServiceAttachment>()
|
||||
private var body: String? = null
|
||||
private var endSession: Boolean = false
|
||||
private var expiresInSeconds: Int = 0
|
||||
private var expirationUpdate: Boolean = false
|
||||
private var profileKey: ByteArray? = null
|
||||
private var profileKeyUpdate: Boolean = false
|
||||
private var quote: Quote? = null
|
||||
private val sharedContacts: MutableList<SharedContact> = LinkedList<SharedContact>()
|
||||
private val previews: MutableList<SignalServicePreview> = LinkedList<SignalServicePreview>()
|
||||
private val mentions: MutableList<Mention> = LinkedList<Mention>()
|
||||
private var sticker: Sticker? = null
|
||||
private var viewOnce: Boolean = false
|
||||
private var reaction: Reaction? = null
|
||||
private var remoteDelete: RemoteDelete? = null
|
||||
private var groupCallUpdate: GroupCallUpdate? = null
|
||||
private var payment: Payment? = null
|
||||
private var storyContext: StoryContext? = null
|
||||
private var giftBadge: GiftBadge? = null
|
||||
private var bodyRanges: MutableList<BodyRange> = LinkedList<BodyRange>()
|
||||
|
||||
fun withTimestamp(timestamp: Long): Builder {
|
||||
this.timestamp = timestamp
|
||||
return this
|
||||
}
|
||||
|
||||
fun asGroupMessage(group: SignalServiceGroupV2?): Builder {
|
||||
groupV2 = group
|
||||
return this
|
||||
}
|
||||
|
||||
fun withAttachment(attachment: SignalServiceAttachment?): Builder {
|
||||
attachment?.let { attachments.add(attachment) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withAttachments(attachments: List<SignalServiceAttachment>?): Builder {
|
||||
attachments?.let { this.attachments.addAll(attachments) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withBody(body: String?): Builder {
|
||||
this.body = body
|
||||
return this
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun asEndSessionMessage(endSession: Boolean = true): Builder {
|
||||
this.endSession = endSession
|
||||
return this
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun asExpirationUpdate(expirationUpdate: Boolean = true): Builder {
|
||||
this.expirationUpdate = expirationUpdate
|
||||
return this
|
||||
}
|
||||
|
||||
fun withExpiration(expiresInSeconds: Int): Builder {
|
||||
this.expiresInSeconds = expiresInSeconds
|
||||
return this
|
||||
}
|
||||
|
||||
fun withProfileKey(profileKey: ByteArray?): Builder {
|
||||
this.profileKey = profileKey
|
||||
return this
|
||||
}
|
||||
|
||||
fun asProfileKeyUpdate(profileKeyUpdate: Boolean): Builder {
|
||||
this.profileKeyUpdate = profileKeyUpdate
|
||||
return this
|
||||
}
|
||||
|
||||
fun withQuote(quote: Quote?): Builder {
|
||||
this.quote = quote
|
||||
return this
|
||||
}
|
||||
|
||||
fun withSharedContact(contact: SharedContact?): Builder {
|
||||
contact?.let { sharedContacts.add(contact) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withSharedContacts(contacts: List<SharedContact>?): Builder {
|
||||
contacts?.let { sharedContacts.addAll(contacts) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withPreviews(previews: List<SignalServicePreview>?): Builder {
|
||||
previews?.let { this.previews.addAll(previews) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withMentions(mentions: List<Mention>?): Builder {
|
||||
mentions?.let { this.mentions.addAll(mentions) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun withSticker(sticker: Sticker?): Builder {
|
||||
this.sticker = sticker
|
||||
return this
|
||||
}
|
||||
|
||||
fun withViewOnce(viewOnce: Boolean): Builder {
|
||||
this.viewOnce = viewOnce
|
||||
return this
|
||||
}
|
||||
|
||||
fun withReaction(reaction: Reaction?): Builder {
|
||||
this.reaction = reaction
|
||||
return this
|
||||
}
|
||||
|
||||
fun withRemoteDelete(remoteDelete: RemoteDelete?): Builder {
|
||||
this.remoteDelete = remoteDelete
|
||||
return this
|
||||
}
|
||||
|
||||
fun withGroupCallUpdate(groupCallUpdate: GroupCallUpdate?): Builder {
|
||||
this.groupCallUpdate = groupCallUpdate
|
||||
return this
|
||||
}
|
||||
|
||||
fun withPayment(payment: Payment?): Builder {
|
||||
this.payment = payment
|
||||
return this
|
||||
}
|
||||
|
||||
fun withStoryContext(storyContext: StoryContext?): Builder {
|
||||
this.storyContext = storyContext
|
||||
return this
|
||||
}
|
||||
|
||||
fun withGiftBadge(giftBadge: GiftBadge?): Builder {
|
||||
this.giftBadge = giftBadge
|
||||
return this
|
||||
}
|
||||
|
||||
fun withBodyRanges(bodyRanges: List<BodyRange>?): Builder {
|
||||
bodyRanges?.let { this.bodyRanges.addAll(bodyRanges) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): SignalServiceDataMessage {
|
||||
if (timestamp == 0L) {
|
||||
timestamp = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
return SignalServiceDataMessage(
|
||||
timestamp = timestamp,
|
||||
groupContext = groupV2.asOptional(),
|
||||
attachments = attachments.asOptional(),
|
||||
body = body.emptyIfStringEmpty(),
|
||||
isEndSession = endSession,
|
||||
expiresInSeconds = expiresInSeconds,
|
||||
isExpirationUpdate = expirationUpdate,
|
||||
profileKey = profileKey.asOptional(),
|
||||
isProfileKeyUpdate = profileKeyUpdate,
|
||||
quote = quote.asOptional(),
|
||||
sharedContacts = sharedContacts.asOptional(),
|
||||
previews = previews.asOptional(),
|
||||
mentions = mentions.asOptional(),
|
||||
sticker = sticker.asOptional(),
|
||||
isViewOnce = viewOnce,
|
||||
reaction = reaction.asOptional(),
|
||||
remoteDelete = remoteDelete.asOptional(),
|
||||
groupCallUpdate = groupCallUpdate.asOptional(),
|
||||
payment = payment.asOptional(),
|
||||
storyContext = storyContext.asOptional(),
|
||||
giftBadge = giftBadge.asOptional(),
|
||||
bodyRanges = bodyRanges.asOptional()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Quote(
|
||||
val id: Long,
|
||||
val author: ServiceId?,
|
||||
val text: String,
|
||||
val attachments: List<QuotedAttachment>?,
|
||||
val mentions: List<Mention>?,
|
||||
val type: Type,
|
||||
val bodyRanges: List<BodyRange>?
|
||||
) {
|
||||
enum class Type(val protoType: QuoteProto.Type) {
|
||||
NORMAL(QuoteProto.Type.NORMAL),
|
||||
GIFT_BADGE(QuoteProto.Type.GIFT_BADGE);
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromProto(protoType: QuoteProto.Type): Type {
|
||||
return values().firstOrNull { it.protoType == protoType } ?: NORMAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class QuotedAttachment(val contentType: String, val fileName: String?, val thumbnail: SignalServiceAttachment?)
|
||||
}
|
||||
class Sticker(val packId: ByteArray?, val packKey: ByteArray?, val stickerId: Int, val emoji: String?, val attachment: SignalServiceAttachment?)
|
||||
data class Reaction(val emoji: String, val isRemove: Boolean, val targetAuthor: ServiceId, val targetSentTimestamp: Long)
|
||||
data class RemoteDelete(val targetSentTimestamp: Long)
|
||||
data class Mention(val serviceId: ServiceId, val start: Int, val length: Int)
|
||||
data class GroupCallUpdate(val eraId: String?)
|
||||
class PaymentNotification(val receipt: ByteArray, val note: String)
|
||||
data class PaymentActivation(val type: PaymentProto.Activation.Type)
|
||||
class Payment(paymentNotification: PaymentNotification?, paymentActivation: PaymentActivation?) {
|
||||
val paymentNotification: Optional<PaymentNotification> = Optional.ofNullable(paymentNotification)
|
||||
val paymentActivation: Optional<PaymentActivation> = Optional.ofNullable(paymentActivation)
|
||||
val isActivationRequest: Boolean = paymentActivation != null && paymentActivation.type == PaymentProto.Activation.Type.REQUEST
|
||||
val isActivation: Boolean = paymentActivation != null && paymentActivation.type == PaymentProto.Activation.Type.ACTIVATED
|
||||
}
|
||||
data class StoryContext(val authorServiceId: ServiceId, val sentTimestamp: Long)
|
||||
data class GiftBadge(val receiptCredentialPresentation: ReceiptCredentialPresentation)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun newBuilder(): Builder {
|
||||
return Builder()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
data class SignalServiceEditMessage(
|
||||
val targetSentTimestamp: Long,
|
||||
val dataMessage: SignalServiceDataMessage
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Group information to include in SignalServiceMessages destined to groups.
|
||||
*
|
||||
* This class represents a "context" that is included with Signal Service messages
|
||||
* to make them group messages. There are three types of context:
|
||||
*
|
||||
* 1) Update -- Sent when either creating a group, or updating the properties
|
||||
* of a group (such as the avatar icon, membership list, or title).
|
||||
* 2) Deliver -- Sent when a message is to be delivered to an existing group.
|
||||
* 3) Quit -- Sent when the sender wishes to leave an existing group.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class SignalServiceGroup {
|
||||
|
||||
public enum Type {
|
||||
UNKNOWN,
|
||||
UPDATE,
|
||||
DELIVER,
|
||||
QUIT,
|
||||
REQUEST_INFO
|
||||
}
|
||||
|
||||
private final byte[] groupId;
|
||||
private final Type type;
|
||||
private final Optional<String> name;
|
||||
private final Optional<List<SignalServiceAddress>> members;
|
||||
private final Optional<SignalServiceAttachment> avatar;
|
||||
|
||||
|
||||
/**
|
||||
* Construct a DELIVER group context.
|
||||
* @param groupId
|
||||
*/
|
||||
public SignalServiceGroup(byte[] groupId) {
|
||||
this(Type.DELIVER, groupId, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a group context.
|
||||
* @param type The group message type (update, deliver, quit).
|
||||
* @param groupId The group ID.
|
||||
* @param name The group title.
|
||||
* @param members The group membership list.
|
||||
* @param avatar The group avatar icon.
|
||||
*/
|
||||
public SignalServiceGroup(Type type, byte[] groupId, String name,
|
||||
List<SignalServiceAddress> members,
|
||||
SignalServiceAttachment avatar)
|
||||
{
|
||||
this.type = type;
|
||||
this.groupId = groupId;
|
||||
this.name = Optional.ofNullable(name);
|
||||
this.members = Optional.ofNullable(members);
|
||||
this.avatar = Optional.ofNullable(avatar);
|
||||
}
|
||||
|
||||
public byte[] getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public Optional<String> getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Optional<List<SignalServiceAddress>> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
public Optional<SignalServiceAttachment> getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public static Builder newUpdateBuilder() {
|
||||
return new Builder(Type.UPDATE);
|
||||
}
|
||||
|
||||
public static Builder newBuilder(Type type) {
|
||||
return new Builder(type);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private Type type;
|
||||
private byte[] id;
|
||||
private String name;
|
||||
private List<SignalServiceAddress> members;
|
||||
private SignalServiceAttachment avatar;
|
||||
|
||||
private Builder(Type type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Builder withId(byte[] id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withMembers(List<SignalServiceAddress> members) {
|
||||
this.members = members;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withAvatar(SignalServiceAttachment avatar) {
|
||||
this.avatar = avatar;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SignalServiceGroup build() {
|
||||
if (id == null) throw new IllegalArgumentException("No group ID specified!");
|
||||
|
||||
if (type == Type.UPDATE && name == null && members == null && avatar == null) {
|
||||
throw new IllegalArgumentException("Group update with no updates!");
|
||||
}
|
||||
|
||||
return new SignalServiceGroup(type, id, name, members, avatar);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2;
|
||||
|
||||
import io.reactivex.rxjava3.annotations.NonNull;
|
||||
|
||||
/**
|
||||
* Group information to include in SignalServiceMessages destined to v2 groups.
|
||||
* <p>
|
||||
* This class represents a "context" that is included with Signal Service messages
|
||||
* to make them group messages.
|
||||
*/
|
||||
public final class SignalServiceGroupV2 {
|
||||
|
||||
private final GroupMasterKey masterKey;
|
||||
private final int revision;
|
||||
private final byte[] signedGroupChange;
|
||||
|
||||
private SignalServiceGroupV2(Builder builder) {
|
||||
this.masterKey = builder.masterKey;
|
||||
this.revision = builder.revision;
|
||||
this.signedGroupChange = builder.signedGroupChange != null ? builder.signedGroupChange.clone() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a context model populated from a protobuf group V2 context.
|
||||
*/
|
||||
public static SignalServiceGroupV2 fromProtobuf(@NonNull GroupContextV2 groupContextV2) {
|
||||
Preconditions.checkArgument(groupContextV2.masterKey != null && groupContextV2.revision != null);
|
||||
|
||||
GroupMasterKey masterKey;
|
||||
try {
|
||||
masterKey = new GroupMasterKey(groupContextV2.masterKey.toByteArray());
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
Builder builder = newBuilder(masterKey);
|
||||
|
||||
if (groupContextV2.groupChange != null && groupContextV2.groupChange.size() > 0) {
|
||||
builder.withSignedGroupChange(groupContextV2.groupChange.toByteArray());
|
||||
}
|
||||
|
||||
return builder.withRevision(groupContextV2.revision)
|
||||
.build();
|
||||
}
|
||||
|
||||
public GroupMasterKey getMasterKey() {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
public int getRevision() {
|
||||
return revision;
|
||||
}
|
||||
|
||||
public byte[] getSignedGroupChange() {
|
||||
return signedGroupChange;
|
||||
}
|
||||
|
||||
public boolean hasSignedGroupChange() {
|
||||
return signedGroupChange != null && signedGroupChange.length > 0;
|
||||
}
|
||||
|
||||
public static Builder newBuilder(GroupMasterKey masterKey) {
|
||||
return new Builder(masterKey);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private final GroupMasterKey masterKey;
|
||||
private int revision;
|
||||
private byte[] signedGroupChange;
|
||||
|
||||
private Builder(GroupMasterKey masterKey) {
|
||||
if (masterKey == null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
public Builder withRevision(int revision) {
|
||||
this.revision = revision;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withSignedGroupChange(byte[] signedGroupChange) {
|
||||
this.signedGroupChange = signedGroupChange;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SignalServiceGroupV2 build() {
|
||||
return new SignalServiceGroupV2(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class SignalServiceMetadata {
|
||||
private final SignalServiceAddress sender;
|
||||
private final int senderDevice;
|
||||
private final long timestamp;
|
||||
private final long serverReceivedTimestamp;
|
||||
private final long serverDeliveredTimestamp;
|
||||
private final boolean needsReceipt;
|
||||
private final String serverGuid;
|
||||
private final Optional<byte[]> groupId;
|
||||
private final String destinationUuid;
|
||||
|
||||
public SignalServiceMetadata(SignalServiceAddress sender,
|
||||
int senderDevice,
|
||||
long timestamp,
|
||||
long serverReceivedTimestamp,
|
||||
long serverDeliveredTimestamp,
|
||||
boolean needsReceipt,
|
||||
String serverGuid,
|
||||
Optional<byte[]> groupId,
|
||||
String destinationUuid)
|
||||
{
|
||||
this.sender = sender;
|
||||
this.senderDevice = senderDevice;
|
||||
this.timestamp = timestamp;
|
||||
this.serverReceivedTimestamp = serverReceivedTimestamp;
|
||||
this.serverDeliveredTimestamp = serverDeliveredTimestamp;
|
||||
this.needsReceipt = needsReceipt;
|
||||
this.serverGuid = serverGuid;
|
||||
this.groupId = groupId;
|
||||
this.destinationUuid = destinationUuid != null ? destinationUuid : "";
|
||||
}
|
||||
|
||||
public SignalServiceAddress getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public int getSenderDevice() {
|
||||
return senderDevice;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getServerReceivedTimestamp() {
|
||||
return serverReceivedTimestamp;
|
||||
}
|
||||
|
||||
public long getServerDeliveredTimestamp() {
|
||||
return serverDeliveredTimestamp;
|
||||
}
|
||||
|
||||
public boolean isNeedsReceipt() {
|
||||
return needsReceipt;
|
||||
}
|
||||
|
||||
public String getServerGuid() {
|
||||
return serverGuid;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public String getDestinationUuid() {
|
||||
return destinationUuid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
|
||||
|
||||
/**
|
||||
* When someone sends a message to your PNI, you need to attach one of these PNI signature messages,
|
||||
* proving that you own the PNI identity.
|
||||
*
|
||||
* The signature is generated by signing your ACI public key with your PNI identity.
|
||||
*/
|
||||
public class SignalServicePniSignatureMessage {
|
||||
|
||||
private final PNI pni;
|
||||
private final byte[] signature;
|
||||
|
||||
public SignalServicePniSignatureMessage(PNI pni, byte[] signature) {
|
||||
this.pni = pni;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public PNI getPni() {
|
||||
return pni;
|
||||
}
|
||||
|
||||
public byte[] getSignature() {
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class SignalServicePreview {
|
||||
private final String url;
|
||||
private final String title;
|
||||
private final String description;
|
||||
private final long date;
|
||||
private final Optional<SignalServiceAttachment> image;
|
||||
|
||||
public SignalServicePreview(String url, String title, String description, long date, Optional<SignalServiceAttachment> image) {
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.date = date;
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public long getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public Optional<SignalServiceAttachment> getImage() {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SignalServiceReceiptMessage {
|
||||
|
||||
public enum Type {
|
||||
UNKNOWN, DELIVERY, READ, VIEWED
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final List<Long> timestamps;
|
||||
private final long when;
|
||||
|
||||
public SignalServiceReceiptMessage(Type type, List<Long> timestamps, long when) {
|
||||
this.type = type;
|
||||
this.timestamps = timestamps;
|
||||
this.when = when;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public List<Long> getTimestamps() {
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
public long getWhen() {
|
||||
return when;
|
||||
}
|
||||
|
||||
public boolean isDeliveryReceipt() {
|
||||
return type == Type.DELIVERY;
|
||||
}
|
||||
|
||||
public boolean isReadReceipt() {
|
||||
return type == Type.READ;
|
||||
}
|
||||
|
||||
public boolean isViewedReceipt() {
|
||||
return type == Type.VIEWED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SignalServiceStickerManifest {
|
||||
|
||||
private final Optional<String> title;
|
||||
private final Optional<String> author;
|
||||
private final Optional<StickerInfo> cover;
|
||||
private final List<StickerInfo> stickers;
|
||||
|
||||
public SignalServiceStickerManifest(String title, String author, StickerInfo cover, List<StickerInfo> stickers) {
|
||||
this.title = Optional.ofNullable(title);
|
||||
this.author = Optional.ofNullable(author);
|
||||
this.cover = Optional.ofNullable(cover);
|
||||
this.stickers = (stickers == null) ? Collections.<StickerInfo>emptyList() : new ArrayList<>(stickers);
|
||||
}
|
||||
|
||||
public Optional<String> getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public Optional<String> getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public Optional<StickerInfo> getCover() {
|
||||
return cover;
|
||||
}
|
||||
|
||||
public List<StickerInfo> getStickers() {
|
||||
return stickers;
|
||||
}
|
||||
|
||||
public static final class StickerInfo {
|
||||
private final int id;
|
||||
private final String emoji;
|
||||
private final String contentType;
|
||||
|
||||
public StickerInfo(int id, String emoji, String contentType) {
|
||||
this.id = id;
|
||||
this.emoji = emoji;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SignalServiceStoryMessage {
|
||||
private final Optional<byte[]> profileKey;
|
||||
private final Optional<SignalServiceGroupV2> groupContext;
|
||||
private final Optional<SignalServiceAttachment> fileAttachment;
|
||||
private final Optional<SignalServiceTextAttachment> textAttachment;
|
||||
private final Optional<Boolean> allowsReplies;
|
||||
private final Optional<List<BodyRange>> bodyRanges;
|
||||
|
||||
private SignalServiceStoryMessage(byte[] profileKey,
|
||||
SignalServiceGroupV2 groupContext,
|
||||
SignalServiceAttachment fileAttachment,
|
||||
SignalServiceTextAttachment textAttachment,
|
||||
boolean allowsReplies,
|
||||
List<BodyRange> bodyRanges)
|
||||
{
|
||||
this.profileKey = Optional.ofNullable(profileKey);
|
||||
this.groupContext = Optional.ofNullable(groupContext);
|
||||
this.fileAttachment = Optional.ofNullable(fileAttachment);
|
||||
this.textAttachment = Optional.ofNullable(textAttachment);
|
||||
this.allowsReplies = Optional.of(allowsReplies);
|
||||
this.bodyRanges = Optional.ofNullable(bodyRanges);
|
||||
}
|
||||
|
||||
public static SignalServiceStoryMessage forFileAttachment(byte[] profileKey,
|
||||
SignalServiceGroupV2 groupContext,
|
||||
SignalServiceAttachment fileAttachment,
|
||||
boolean allowsReplies,
|
||||
List<BodyRange> bodyRanges)
|
||||
{
|
||||
return new SignalServiceStoryMessage(profileKey, groupContext, fileAttachment, null, allowsReplies, bodyRanges);
|
||||
}
|
||||
|
||||
public static SignalServiceStoryMessage forTextAttachment(byte[] profileKey,
|
||||
SignalServiceGroupV2 groupContext,
|
||||
SignalServiceTextAttachment textAttachment,
|
||||
boolean allowsReplies,
|
||||
List<BodyRange> bodyRanges)
|
||||
{
|
||||
return new SignalServiceStoryMessage(profileKey, groupContext, null, textAttachment, allowsReplies, bodyRanges);
|
||||
}
|
||||
|
||||
public Optional<byte[]> getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public Optional<SignalServiceGroupV2> getGroupContext() {
|
||||
return groupContext;
|
||||
}
|
||||
|
||||
public Optional<SignalServiceAttachment> getFileAttachment() {
|
||||
return fileAttachment;
|
||||
}
|
||||
|
||||
public Optional<SignalServiceTextAttachment> getTextAttachment() {
|
||||
return textAttachment;
|
||||
}
|
||||
|
||||
public Optional<Boolean> getAllowsReplies() {
|
||||
return allowsReplies;
|
||||
}
|
||||
|
||||
public Optional<List<BodyRange>> getBodyRanges() {
|
||||
return bodyRanges;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SignalServiceStoryMessageRecipient {
|
||||
|
||||
private final SignalServiceAddress signalServiceAddress;
|
||||
private final List<String> distributionListIds;
|
||||
private final boolean isAllowedToReply;
|
||||
|
||||
public SignalServiceStoryMessageRecipient(SignalServiceAddress signalServiceAddress,
|
||||
List<String> distributionListIds,
|
||||
boolean isAllowedToReply)
|
||||
{
|
||||
this.signalServiceAddress = signalServiceAddress;
|
||||
this.distributionListIds = distributionListIds;
|
||||
this.isAllowedToReply = isAllowedToReply;
|
||||
}
|
||||
|
||||
public List<String> getDistributionListIds() {
|
||||
return distributionListIds;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getSignalServiceAddress() {
|
||||
return signalServiceAddress;
|
||||
}
|
||||
|
||||
public boolean isAllowedToReply() {
|
||||
return isAllowedToReply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SignalServiceTextAttachment {
|
||||
|
||||
private final Optional<String> text;
|
||||
private final Optional<Style> style;
|
||||
private final Optional<Integer> textForegroundColor;
|
||||
private final Optional<Integer> textBackgroundColor;
|
||||
private final Optional<SignalServicePreview> preview;
|
||||
private final Optional<Gradient> backgroundGradient;
|
||||
private final Optional<Integer> backgroundColor;
|
||||
|
||||
private SignalServiceTextAttachment(Optional<String> text,
|
||||
Optional<Style> style,
|
||||
Optional<Integer> textForegroundColor,
|
||||
Optional<Integer> textBackgroundColor,
|
||||
Optional<SignalServicePreview> preview,
|
||||
Optional<Gradient> backgroundGradient,
|
||||
Optional<Integer> backgroundColor) {
|
||||
this.text = text;
|
||||
this.style = style;
|
||||
this.textForegroundColor = textForegroundColor;
|
||||
this.textBackgroundColor = textBackgroundColor;
|
||||
this.preview = preview;
|
||||
this.backgroundGradient = backgroundGradient;
|
||||
this.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
public static SignalServiceTextAttachment forGradientBackground(Optional<String> text,
|
||||
Optional<Style> style,
|
||||
Optional<Integer> textForegroundColor,
|
||||
Optional<Integer> textBackgroundColor,
|
||||
Optional<SignalServicePreview> preview,
|
||||
Gradient backgroundGradient) {
|
||||
return new SignalServiceTextAttachment(text,
|
||||
style,
|
||||
textForegroundColor,
|
||||
textBackgroundColor,
|
||||
preview,
|
||||
Optional.of(backgroundGradient),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
public static SignalServiceTextAttachment forSolidBackground(Optional<String> text,
|
||||
Optional<Style> style,
|
||||
Optional<Integer> textForegroundColor,
|
||||
Optional<Integer> textBackgroundColor,
|
||||
Optional<SignalServicePreview> preview,
|
||||
int backgroundColor) {
|
||||
return new SignalServiceTextAttachment(text,
|
||||
style,
|
||||
textForegroundColor,
|
||||
textBackgroundColor,
|
||||
preview,
|
||||
Optional.empty(),
|
||||
Optional.of(backgroundColor));
|
||||
}
|
||||
|
||||
public Optional<String> getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public Optional<Style> getStyle() {
|
||||
return style;
|
||||
}
|
||||
|
||||
public Optional<Integer> getTextForegroundColor() {
|
||||
return textForegroundColor;
|
||||
}
|
||||
|
||||
public Optional<Integer> getTextBackgroundColor() {
|
||||
return textBackgroundColor;
|
||||
}
|
||||
|
||||
public Optional<SignalServicePreview> getPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public Optional<Gradient> getBackgroundGradient() {
|
||||
return backgroundGradient;
|
||||
}
|
||||
|
||||
public Optional<Integer> getBackgroundColor() {
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
public static class Gradient {
|
||||
private final Optional<Integer> angle;
|
||||
private final List<Integer> colors;
|
||||
private final List<Float> positions;
|
||||
|
||||
public Gradient(Optional<Integer> angle, List<Integer> colors, List<Float> positions) {
|
||||
this.angle = angle;
|
||||
this.colors = colors;
|
||||
this.positions = positions;
|
||||
}
|
||||
|
||||
public Optional<Integer> getAngle() {
|
||||
return angle;
|
||||
}
|
||||
|
||||
public List<Integer> getColors() {
|
||||
return colors;
|
||||
}
|
||||
|
||||
public List<Float> getPositions() {
|
||||
return positions;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Style {
|
||||
DEFAULT,
|
||||
REGULAR,
|
||||
BOLD,
|
||||
SERIF,
|
||||
SCRIPT,
|
||||
CONDENSED,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.whispersystems.signalservice.api.messages;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class SignalServiceTypingMessage {
|
||||
|
||||
public enum Action {
|
||||
UNKNOWN, STARTED, STOPPED
|
||||
}
|
||||
|
||||
private final Action action;
|
||||
private final long timestamp;
|
||||
private final Optional<byte[]> groupId;
|
||||
|
||||
public SignalServiceTypingMessage(Action action, long timestamp, Optional<byte[]> groupId) {
|
||||
this.action = action;
|
||||
this.timestamp = timestamp;
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public Action getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public boolean isTypingStarted() {
|
||||
return action == Action.STARTED;
|
||||
}
|
||||
|
||||
public boolean isTypingStopped() {
|
||||
return action == Action.STOPPED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.whispersystems.signalservice.api.messages.calls;
|
||||
|
||||
|
||||
public class AnswerMessage {
|
||||
|
||||
private final long id;
|
||||
private final String sdp;
|
||||
private final byte[] opaque;
|
||||
|
||||
public AnswerMessage(long id, String sdp, byte[] opaque) {
|
||||
this.id = id;
|
||||
this.sdp = sdp;
|
||||
this.opaque = opaque;
|
||||
}
|
||||
|
||||
public String getSdp() {
|
||||
return sdp;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public byte[] getOpaque() {
|
||||
return opaque;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.whispersystems.signalservice.api.messages.calls;
|
||||
|
||||
|
||||
public class BusyMessage {
|
||||
|
||||
private final long id;
|
||||
|
||||
public BusyMessage(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user