Move libsignal-service up a directory.

This commit is contained in:
Greyson Parrelli
2023-10-06 11:34:51 -04:00
committed by Cody Henthorne
parent 6134244244
commit 4968db750b
483 changed files with 1 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
package org.whispersystems.signalservice.api;
import java.io.IOException;
public class CancelationException extends IOException {
}

View File

@@ -0,0 +1,7 @@
package org.whispersystems.signalservice.api;
public class ContentTooLargeException extends IllegalStateException {
public ContentTooLargeException(long size) {
super("Too large! Size: " + size + " bytes");
}
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
)

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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)
}

View File

@@ -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);
}
}

View File

@@ -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)
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,7 @@
package org.whispersystems.signalservice.api;
public final class SvrNoDataException extends Exception {
public SvrNoDataException() {
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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
)
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
)

View File

@@ -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>?
)

View File

@@ -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;
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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
)

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
)

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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() {
}
}

View File

@@ -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 {
}

View File

@@ -0,0 +1,8 @@
package org.whispersystems.signalservice.api.groupsv2;
public final class NotAbleToApplyGroupV2ChangeException extends Exception {
NotAbleToApplyGroupV2ChangeException() {
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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)";
}
}

View File

@@ -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)
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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()
}
}

View File

@@ -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
)

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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()
}
}
}

View File

@@ -0,0 +1,6 @@
package org.whispersystems.signalservice.api.messages
data class SignalServiceEditMessage(
val targetSentTimestamp: Long,
val dataMessage: SignalServiceDataMessage
)

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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