Introduce AEP and SSRE2.
This commit is contained in:
parent
1401256ffd
commit
1b2c0db693
60 changed files with 1162 additions and 511 deletions
|
@ -141,7 +141,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
|||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
|
|
@ -8,11 +8,12 @@ object AppCapabilities {
|
|||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
fun getCapabilities(storageCapable: Boolean, storageServiceEncryptionV2: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
storage = storageCapable,
|
||||
deleteSync = true,
|
||||
versionedExpirationTimer = true
|
||||
versionedExpirationTimer = true,
|
||||
storageServiceEncryptionV2 = storageServiceEncryptionV2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -420,6 +420,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
|||
var value: Long = 0
|
||||
value = Bitmask.update(value, Capabilities.DELETE_SYNC, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isDeleteSync).serialize().toLong())
|
||||
value = Bitmask.update(value, Capabilities.VERSIONED_EXPIRATION_TIMER, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isVersionedExpirationTimer).serialize().toLong())
|
||||
value = Bitmask.update(value, Capabilities.STORAGE_SERVICE_ENCRYPTION_V2, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStorageServiceEncryptionV2).serialize().toLong())
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
@ -4713,6 +4714,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
|||
// const val PAYMENT_ACTIVATION = 8
|
||||
const val DELETE_SYNC = 9
|
||||
const val VERSIONED_EXPIRATION_TIMER = 10
|
||||
const val STORAGE_SERVICE_ENCRYPTION_V2 = 11
|
||||
|
||||
// IMPORTANT: We cannot sore more than 32 capabilities in the bitmask.
|
||||
}
|
||||
|
|
|
@ -177,7 +177,8 @@ object RecipientTableCursorUtil {
|
|||
return RecipientRecord.Capabilities(
|
||||
rawBits = capabilities,
|
||||
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.DELETE_SYNC, Capabilities.BIT_LENGTH).toInt()),
|
||||
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.VERSIONED_EXPIRATION_TIMER, Capabilities.BIT_LENGTH).toInt())
|
||||
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.VERSIONED_EXPIRATION_TIMER, Capabilities.BIT_LENGTH).toInt()),
|
||||
storageServiceEncryptionV2 = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORAGE_SERVICE_ENCRYPTION_V2, Capabilities.BIT_LENGTH).toInt())
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -121,14 +121,16 @@ data class RecipientRecord(
|
|||
data class Capabilities(
|
||||
val rawBits: Long,
|
||||
val deleteSync: Recipient.Capability,
|
||||
val versionedExpirationTimer: Recipient.Capability
|
||||
val versionedExpirationTimer: Recipient.Capability,
|
||||
val storageServiceEncryptionV2: Recipient.Capability
|
||||
) {
|
||||
companion object {
|
||||
@JvmField
|
||||
val UNKNOWN = Capabilities(
|
||||
rawBits = 0,
|
||||
deleteSync = Recipient.Capability.UNKNOWN,
|
||||
versionedExpirationTimer = Recipient.Capability.UNKNOWN
|
||||
versionedExpirationTimer = Recipient.Capability.UNKNOWN,
|
||||
storageServiceEncryptionV2 = Recipient.Capability.UNKNOWN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ import org.whispersystems.signalservice.api.registration.RegistrationApi
|
|||
import org.whispersystems.signalservice.api.services.CallLinksService
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.services.ProfileService
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
|
@ -303,6 +304,9 @@ object AppDependencies {
|
|||
val registrationApi: RegistrationApi
|
||||
get() = networkModule.registrationApi
|
||||
|
||||
val storageServiceApi: StorageServiceApi
|
||||
get() = networkModule.storageServiceApi
|
||||
|
||||
@JvmStatic
|
||||
val okHttpClient: OkHttpClient
|
||||
get() = networkModule.okHttpClient
|
||||
|
@ -367,5 +371,6 @@ object AppDependencies {
|
|||
fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi
|
||||
fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi
|
||||
fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi
|
||||
fun provideStorageServiceApi(pushServiceSocket: PushServiceSocket): StorageServiceApi
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,6 +94,7 @@ import org.whispersystems.signalservice.api.registration.RegistrationApi;
|
|||
import org.whispersystems.signalservice.api.services.CallLinksService;
|
||||
import org.whispersystems.signalservice.api.services.DonationsService;
|
||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer;
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
|
||||
|
@ -244,6 +245,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
|||
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
|
||||
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT);
|
||||
LibSignalNetworkExtensions.applyConfiguration(network, config);
|
||||
|
||||
return network;
|
||||
}
|
||||
|
||||
|
@ -480,6 +482,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
|||
return new RegistrationApi(pushServiceSocket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull StorageServiceApi provideStorageServiceApi(@NonNull PushServiceSocket pushServiceSocket) {
|
||||
return new StorageServiceApi(pushServiceSocket);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static class DynamicCredentialsProvider implements CredentialsProvider {
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.registration.RegistrationApi
|
|||
import org.whispersystems.signalservice.api.services.CallLinksService
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.services.ProfileService
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
import org.whispersystems.signalservice.api.util.Tls12SocketFactory
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
|
@ -148,6 +149,10 @@ class NetworkDependenciesModule(
|
|||
provider.provideRegistrationApi(pushServiceSocket)
|
||||
}
|
||||
|
||||
val storageServiceApi: StorageServiceApi by lazy {
|
||||
provider.provideStorageServiceApi(pushServiceSocket)
|
||||
}
|
||||
|
||||
val okHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(StandardUserAgentInterceptor())
|
||||
|
|
|
@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.jobmanager.migrations.SendReadReceiptsJobMigra
|
|||
import org.thoughtcrime.securesms.jobmanager.migrations.SenderKeyDistributionSendJobRecipientMigration;
|
||||
import org.thoughtcrime.securesms.migrations.AccountConsistencyMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AccountRecordMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AepMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.ApplyUnknownFieldsToSelfMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AttachmentCleanupMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.AttachmentHashBackfillMigrationJob;
|
||||
|
@ -136,7 +137,6 @@ public final class JobManagerFactories {
|
|||
put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.Factory());
|
||||
put(CheckServiceReachabilityJob.KEY, new CheckServiceReachabilityJob.Factory());
|
||||
put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory());
|
||||
put(ContactLinkRebuildMigrationJob.KEY, new ContactLinkRebuildMigrationJob.Factory());
|
||||
put(ConversationShortcutRankingUpdateJob.KEY, new ConversationShortcutRankingUpdateJob.Factory());
|
||||
put(ConversationShortcutUpdateJob.KEY, new ConversationShortcutUpdateJob.Factory());
|
||||
put(CopyAttachmentToArchiveJob.KEY, new CopyAttachmentToArchiveJob.Factory());
|
||||
|
@ -238,6 +238,7 @@ public final class JobManagerFactories {
|
|||
put(SendReadReceiptJob.KEY, new SendReadReceiptJob.Factory(application));
|
||||
put(SendRetryReceiptJob.KEY, new SendRetryReceiptJob.Factory());
|
||||
put(SendViewedReceiptJob.KEY, new SendViewedReceiptJob.Factory(application));
|
||||
put(StorageRotateManifestJob.KEY, new StorageRotateManifestJob.Factory());
|
||||
put(SyncSystemContactLinksJob.KEY, new SyncSystemContactLinksJob.Factory());
|
||||
put(MultiDeviceStorySendSyncJob.KEY, new MultiDeviceStorySendSyncJob.Factory());
|
||||
put(ResetSvrGuessCountJob.KEY, new ResetSvrGuessCountJob.Factory());
|
||||
|
@ -247,7 +248,6 @@ public final class JobManagerFactories {
|
|||
put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory());
|
||||
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
|
||||
put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
|
||||
put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
|
||||
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
|
||||
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
|
||||
put(Svr2MirrorJob.KEY, new Svr2MirrorJob.Factory());
|
||||
|
@ -261,6 +261,7 @@ public final class JobManagerFactories {
|
|||
// Migrations
|
||||
put(AccountConsistencyMigrationJob.KEY, new AccountConsistencyMigrationJob.Factory());
|
||||
put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory());
|
||||
put(AepMigrationJob.KEY, new AepMigrationJob.Factory());
|
||||
put(ApplyUnknownFieldsToSelfMigrationJob.KEY, new ApplyUnknownFieldsToSelfMigrationJob.Factory());
|
||||
put(AttachmentCleanupMigrationJob.KEY, new AttachmentCleanupMigrationJob.Factory());
|
||||
put(AttachmentHashBackfillMigrationJob.KEY, new AttachmentHashBackfillMigrationJob.Factory());
|
||||
|
@ -275,6 +276,7 @@ public final class JobManagerFactories {
|
|||
put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory());
|
||||
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
|
||||
put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory());
|
||||
put(ContactLinkRebuildMigrationJob.KEY, new ContactLinkRebuildMigrationJob.Factory());
|
||||
put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory());
|
||||
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
|
||||
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
|
||||
|
@ -306,6 +308,7 @@ public final class JobManagerFactories {
|
|||
put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory());
|
||||
put(StorageServiceSystemNameMigrationJob.KEY, new StorageServiceSystemNameMigrationJob.Factory());
|
||||
put(StoryViewedReceiptsStateMigrationJob.KEY, new StoryViewedReceiptsStateMigrationJob.Factory());
|
||||
put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
|
||||
put(Svr2MirrorMigrationJob.KEY, new Svr2MirrorMigrationJob.Factory());
|
||||
put(SyncCallLinksMigrationJob.KEY, new SyncCallLinksMigrationJob.Factory());
|
||||
put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory());
|
||||
|
|
|
@ -13,7 +13,6 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
|
|||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
class MultiDeviceKeysUpdateJob private constructor(parameters: Parameters) : BaseJob(parameters) {
|
||||
|
||||
|
@ -54,8 +53,10 @@ class MultiDeviceKeysUpdateJob private constructor(parameters: Parameters) : Bas
|
|||
|
||||
val syncMessage = SignalServiceSyncMessage.forKeys(
|
||||
KeysMessage(
|
||||
Optional.of(SignalStore.storageService.storageKey),
|
||||
Optional.of(SignalStore.svr.masterKey)
|
||||
storageService = SignalStore.storageService.storageKey,
|
||||
master = SignalStore.svr.masterKey,
|
||||
accountEntropyPool = SignalStore.account.accountEntropyPool,
|
||||
mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|||
import org.thoughtcrime.securesms.keyvalue.SvrValues;
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository;
|
||||
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
|
@ -103,7 +104,7 @@ public class RefreshAttributesJob extends BaseJob {
|
|||
String deviceName = SignalStore.account().getDeviceName();
|
||||
byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey());
|
||||
|
||||
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut());
|
||||
AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut(), RemoteConfig.getStorageServiceEncryptionV2());
|
||||
Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() +
|
||||
"\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) +
|
||||
"\n Phone number discoverable : " + phoneNumberDiscoverable +
|
||||
|
|
|
@ -216,17 +216,25 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!Recipient.self().getDeleteSyncCapability().isSupported() && capabilities.isDeleteSync()) {
|
||||
Recipient selfSnapshot = Recipient.self();
|
||||
|
||||
SignalDatabase.recipients().setCapabilities(Recipient.self().getId(), capabilities);
|
||||
|
||||
if (!selfSnapshot.getDeleteSyncCapability().isSupported() && capabilities.isDeleteSync()) {
|
||||
Log.d(TAG, "Transitioned to delete sync capable, notify linked devices in case we were the last one");
|
||||
AppDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
|
||||
}
|
||||
|
||||
if (!Recipient.self().getVersionedExpirationTimerCapability().isSupported() && capabilities.isVersionedExpirationTimer()) {
|
||||
if (!selfSnapshot.getVersionedExpirationTimerCapability().isSupported() && capabilities.isVersionedExpirationTimer()) {
|
||||
Log.d(TAG, "Transitioned to versioned expiration timer capable, notify linked devices in case we were the last one");
|
||||
AppDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
|
||||
}
|
||||
|
||||
SignalDatabase.recipients().setCapabilities(Recipient.self().getId(), capabilities);
|
||||
if (selfSnapshot.getStorageServiceEncryptionV2Capability() == Recipient.Capability.NOT_SUPPORTED && capabilities.isStorageServiceEncryptionV2()) {
|
||||
Log.i(TAG, "Transitioned to storageServiceEncryptionV2 capable. Notifying other devices and pushing to storage service with a recordIkm.");
|
||||
AppDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
|
||||
AppDependencies.getJobManager().add(new StorageForcePushJob());
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureUnidentifiedAccessCorrect(@Nullable String unidentifiedAccessVerifier, boolean universalUnidentifiedAccess) {
|
||||
|
|
|
@ -6,12 +6,16 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
|||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.reclaimUsernameIfNecessary
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.Companion.self
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestResult
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
|
@ -43,13 +47,26 @@ class StorageAccountRestoreJob private constructor(parameters: Parameters) : Bas
|
|||
|
||||
@Throws(Exception::class)
|
||||
override fun onRun() {
|
||||
val accountManager = AppDependencies.signalServiceAccountManager
|
||||
val storageServiceKey = SignalStore.storageService.storageKey
|
||||
val storageServiceKey = SignalStore.storageService.storageKeyForInitialDataRestore?.let {
|
||||
Log.i(TAG, "Using temporary storage key.")
|
||||
it
|
||||
} ?: run {
|
||||
Log.i(TAG, "Using normal storage key.")
|
||||
SignalStore.storageService.storageKey
|
||||
}
|
||||
|
||||
val repository = StorageServiceRepository(SignalNetwork.storageService)
|
||||
|
||||
Log.i(TAG, "Retrieving manifest...")
|
||||
val manifest = accountManager.getStorageManifest(storageServiceKey)
|
||||
val manifest: SignalStorageManifest? = when (val result = repository.getStorageManifest(storageServiceKey)) {
|
||||
is ManifestResult.Success -> result.manifest
|
||||
is ManifestResult.DecryptionError -> null
|
||||
is ManifestResult.NotFoundError -> null
|
||||
is ManifestResult.NetworkError -> throw result.exception
|
||||
is ManifestResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
|
||||
if (!manifest.isPresent) {
|
||||
if (manifest == null) {
|
||||
Log.w(TAG, "Manifest did not exist or was undecryptable (bad key). Not restoring. Force-pushing.")
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return
|
||||
|
@ -58,7 +75,7 @@ class StorageAccountRestoreJob private constructor(parameters: Parameters) : Bas
|
|||
Log.i(TAG, "Resetting the local manifest to an empty state so that it will sync later.")
|
||||
SignalStore.storageService.manifest = SignalStorageManifest.EMPTY
|
||||
|
||||
val accountId = manifest.get().accountStorageId
|
||||
val accountId = manifest.accountStorageId
|
||||
|
||||
if (!accountId.isPresent) {
|
||||
Log.w(TAG, "Manifest had no account record! Not restoring.")
|
||||
|
@ -66,8 +83,18 @@ class StorageAccountRestoreJob private constructor(parameters: Parameters) : Bas
|
|||
}
|
||||
|
||||
Log.i(TAG, "Retrieving account record...")
|
||||
val records = accountManager.readStorageRecords(storageServiceKey, listOf(accountId.get()))
|
||||
val record = if (records.size > 0) records[0] else null
|
||||
val records: List<SignalStorageRecord> = when (val result = repository.readStorageRecords(storageServiceKey, manifest.recordIkm, listOf(accountId.get()))) {
|
||||
is StorageServiceRepository.StorageRecordResult.Success -> result.records
|
||||
is StorageServiceRepository.StorageRecordResult.DecryptionError -> {
|
||||
Log.w(TAG, "Account record was undecryptable. Not restoring. Force-pushing.")
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return
|
||||
}
|
||||
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
|
||||
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
|
||||
val record = if (records.isNotEmpty()) records[0] else null
|
||||
|
||||
if (record == null) {
|
||||
Log.w(TAG, "Could not find account record, even though we had an ID! Not restoring.")
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
|
@ -13,10 +12,13 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
|||
import org.thoughtcrime.securesms.storage.StorageSyncModels
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncValidations
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.storage.RecordIkm
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -62,9 +64,14 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
|||
}
|
||||
|
||||
val storageServiceKey = SignalStore.storageService.storageKey
|
||||
val accountManager = AppDependencies.signalServiceAccountManager
|
||||
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
|
||||
|
||||
val currentVersion = accountManager.storageManifestVersion
|
||||
val currentVersion = when (val result = repository.getManifestVersion()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> throw result.exception
|
||||
is NetworkResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
val oldContactStorageIds: Map<RecipientId, StorageId> = SignalDatabase.recipients.getContactStorageSyncIdsMap()
|
||||
|
||||
val newVersion = currentVersion + 1
|
||||
|
@ -80,30 +87,44 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob(
|
|||
inserts.add(accountRecord)
|
||||
allNewStorageIds.add(accountRecord.id)
|
||||
|
||||
val manifest = SignalStorageManifest(newVersion, SignalStore.account.deviceId, allNewStorageIds)
|
||||
val recordIkm: RecordIkm? = if (Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
|
||||
Log.i(TAG, "Generating and including a new recordIkm.")
|
||||
RecordIkm.generate()
|
||||
} else {
|
||||
Log.i(TAG, "SSRE2 not yet supported. Not including recordIkm.")
|
||||
null
|
||||
}
|
||||
|
||||
val manifest = SignalStorageManifest(newVersion, SignalStore.account.deviceId, recordIkm, allNewStorageIds)
|
||||
StorageSyncValidations.validateForcePush(manifest, inserts, Recipient.self().fresh())
|
||||
|
||||
try {
|
||||
if (newVersion > 1) {
|
||||
Log.i(TAG, "Force-pushing data. Inserting ${inserts.size} IDs.")
|
||||
if (accountManager.resetStorageRecords(storageServiceKey, manifest, inserts).isPresent) {
|
||||
Log.w(TAG, "Hit a conflict. Trying again.")
|
||||
throw RetryLaterException()
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "First version, normal push. Inserting ${inserts.size} IDs.")
|
||||
if (accountManager.writeStorageRecords(storageServiceKey, manifest, inserts, emptyList()).isPresent) {
|
||||
if (newVersion > 1) {
|
||||
Log.i(TAG, "Force-pushing data. Inserting ${inserts.size} IDs.")
|
||||
when (val result = repository.resetAndWriteStorageRecords(storageServiceKey, manifest, inserts)) {
|
||||
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
|
||||
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
|
||||
Log.w(TAG, "Hit a conflict. Trying again.")
|
||||
throw RetryLaterException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "First version, normal push. Inserting ${inserts.size} IDs.")
|
||||
when (val result = repository.writeStorageRecords(storageServiceKey, manifest, inserts, emptyList())) {
|
||||
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
|
||||
Log.w(TAG, "Hit a conflict. Trying again.")
|
||||
throw RetryLaterException()
|
||||
}
|
||||
}
|
||||
} catch (e: InvalidKeyException) {
|
||||
Log.w(TAG, "Hit an invalid key exception, which likely indicates a conflict.")
|
||||
throw RetryLaterException(e)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Force push succeeded. Updating local manifest version to: $newVersion")
|
||||
SignalStore.storageService.manifest = manifest
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = null
|
||||
SignalDatabase.recipients.applyStorageIdUpdates(newContactStorageIds)
|
||||
SignalDatabase.recipients.applyStorageIdUpdates(Collections.singletonMap(Recipient.self().id, accountRecord.id))
|
||||
SignalDatabase.unknownStorageIds.deleteAll()
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* After registration, if the user did not restore their AEP, they'll have a new master key and need to write a newly-encrypted manifest.
|
||||
* If the account is SSRE2-capable, that's all we have to upload.
|
||||
* If they're not, this job will recognize it and schedule a [StorageForcePushJob] instead.
|
||||
*/
|
||||
class StorageRotateManifestJob private constructor(parameters: Parameters) : Job(parameters) {
|
||||
companion object {
|
||||
const val KEY: String = "StorageRotateManifestJob"
|
||||
|
||||
private val TAG = Log.tag(StorageRotateManifestJob::class.java)
|
||||
}
|
||||
|
||||
constructor() : this(
|
||||
Parameters.Builder().addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(StorageSyncJob.QUEUE_KEY)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build()
|
||||
)
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
Log.i(TAG, "Only the primary device can rotate the manifest.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (!SignalStore.account.isRegistered || SignalStore.account.e164 == null) {
|
||||
Log.w(TAG, "User not registered. Skipping.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val restoreKey: StorageKey? = SignalStore.storageService.storageKeyForInitialDataRestore
|
||||
if (restoreKey == null) {
|
||||
Log.w(TAG, "There was no restore key present! Someone must have written to storage service in the meantime.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val storageServiceKey = SignalStore.storageService.storageKey
|
||||
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
|
||||
|
||||
val currentManifest: SignalStorageManifest = when (val result = repository.getStorageManifest(restoreKey)) {
|
||||
is StorageServiceRepository.ManifestResult.Success -> {
|
||||
result.manifest
|
||||
}
|
||||
is StorageServiceRepository.ManifestResult.DecryptionError -> {
|
||||
Log.w(TAG, "Failed to decrypt the manifest! Only recourse is to force push.", result.exception)
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return Result.failure()
|
||||
}
|
||||
is StorageServiceRepository.ManifestResult.NetworkError -> {
|
||||
Log.w(TAG, "Encountered a network error during read, retrying.", result.exception)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
StorageServiceRepository.ManifestResult.NotFoundError -> {
|
||||
Log.w(TAG, "No existing manifest was found! Force pushing.")
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return Result.failure()
|
||||
}
|
||||
is StorageServiceRepository.ManifestResult.StatusCodeError -> {
|
||||
Log.w(TAG, "Encountered a status code error during read, retrying.", result.exception)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
|
||||
if (currentManifest.recordIkm == null) {
|
||||
Log.w(TAG, "No recordIkm set! Can't just rotate the manifest -- we need to re-encrypt all fo the records, too. Force pushing.")
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val manifestWithNewVersion = currentManifest.copy(version = currentManifest.version + 1)
|
||||
|
||||
return when (val result = repository.writeUnchangedManifest(storageServiceKey, manifestWithNewVersion)) {
|
||||
StorageServiceRepository.WriteStorageRecordsResult.Success -> {
|
||||
Log.i(TAG, "Successfully rotated the manifest. Clearing restore key.")
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = null
|
||||
Result.success()
|
||||
}
|
||||
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
|
||||
Log.w(TAG, "Hit a conflict! Enqueuing a sync followed by another rotation.")
|
||||
AppDependencies.jobManager.add(StorageSyncJob())
|
||||
AppDependencies.jobManager.add(StorageRotateManifestJob())
|
||||
Result.failure()
|
||||
}
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> {
|
||||
Log.w(TAG, "Encountered a status code error during write, retrying.", result.exception)
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> {
|
||||
Log.w(TAG, "Encountered a network error during write, retrying.", result.exception)
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<StorageRotateManifestJob?> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageRotateManifestJob {
|
||||
return StorageRotateManifestJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
|||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.AccountRecordProcessor
|
||||
import org.thoughtcrime.securesms.storage.CallLinkRecordProcessor
|
||||
|
@ -37,6 +38,9 @@ import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
|||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestIfDifferentVersionResult
|
||||
import org.whispersystems.signalservice.api.storage.toSignalAccountRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord
|
||||
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
|
||||
|
@ -163,8 +167,20 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
return
|
||||
}
|
||||
|
||||
val (storageServiceKey, usingTempKey) = SignalStore.storageService.storageKeyForInitialDataRestore?.let {
|
||||
Log.i(TAG, "Using temporary storage key.")
|
||||
it to true
|
||||
} ?: run {
|
||||
SignalStore.storageService.storageKey to false
|
||||
}
|
||||
|
||||
try {
|
||||
val needsMultiDeviceSync = performSync()
|
||||
val needsMultiDeviceSync = performSync(storageServiceKey)
|
||||
|
||||
if (usingTempKey) {
|
||||
Log.i(TAG, "Used a temp key. Scheduling a job to rotate the manifest.")
|
||||
AppDependencies.jobManager.add(StorageRotateManifestJob())
|
||||
}
|
||||
|
||||
if (SignalStore.account.hasLinkedDevices && needsMultiDeviceSync) {
|
||||
AppDependencies.jobManager.add(MultiDeviceStorageSyncRequestJob())
|
||||
|
@ -196,15 +212,19 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
}
|
||||
|
||||
@Throws(IOException::class, RetryLaterException::class, InvalidKeyException::class)
|
||||
private fun performSync(): Boolean {
|
||||
private fun performSync(storageServiceKey: StorageKey): Boolean {
|
||||
val stopwatch = Stopwatch("StorageSync")
|
||||
val db = SignalDatabase.rawDatabase
|
||||
val accountManager = AppDependencies.signalServiceAccountManager
|
||||
val storageServiceKey = SignalStore.storageService.storageKey
|
||||
val repository = StorageServiceRepository(SignalNetwork.storageService)
|
||||
|
||||
val localManifest = SignalStore.storageService.manifest
|
||||
val remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifest.version).orElse(localManifest)
|
||||
|
||||
val remoteManifest = when (val result = repository.getStorageManifestIfDifferentVersion(storageServiceKey, localManifest.version)) {
|
||||
is ManifestIfDifferentVersionResult.DifferentVersion -> result.manifest
|
||||
ManifestIfDifferentVersionResult.SameVersion -> localManifest
|
||||
is ManifestIfDifferentVersionResult.DecryptionError -> throw result.exception
|
||||
is ManifestIfDifferentVersionResult.NetworkError -> throw result.exception
|
||||
is ManifestIfDifferentVersionResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
stopwatch.split("remote-manifest")
|
||||
|
||||
var self = freshSelf()
|
||||
|
@ -248,7 +268,12 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
if (!idDifference.isEmpty) {
|
||||
Log.i(TAG, "[Remote Sync] Retrieving records for key difference.")
|
||||
|
||||
val remoteOnlyRecords = accountManager.readStorageRecords(storageServiceKey, idDifference.remoteOnlyIds)
|
||||
val remoteOnlyRecords = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, idDifference.remoteOnlyIds)) {
|
||||
is StorageServiceRepository.StorageRecordResult.Success -> result.records
|
||||
is StorageServiceRepository.StorageRecordResult.DecryptionError -> throw result.exception
|
||||
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
|
||||
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
|
||||
stopwatch.split("remote-records")
|
||||
|
||||
|
@ -292,6 +317,12 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
|
||||
Log.i(TAG, "We are up-to-date with the remote storage state.")
|
||||
|
||||
if (remoteManifest.recordIkm == null && Recipient.self().storageServiceEncryptionV2Capability.isSupported) {
|
||||
Log.w(TAG, "The SSRE2 capability is supported, but no recordIkm is set! Force pushing.")
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
return false
|
||||
}
|
||||
|
||||
val remoteWriteOperation: WriteOperationResult = db.withinTransaction {
|
||||
self = freshSelf()
|
||||
|
||||
|
@ -308,9 +339,14 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
Log.i(TAG, "ID Difference :: $idDifference")
|
||||
|
||||
WriteOperationResult(
|
||||
SignalStorageManifest(remoteManifest.version + 1, SignalStore.account.deviceId, localStorageIds),
|
||||
remoteInserts,
|
||||
remoteDeletes
|
||||
manifest = SignalStorageManifest(
|
||||
version = remoteManifest.version + 1,
|
||||
sourceDeviceId = SignalStore.account.deviceId,
|
||||
recordIkm = remoteManifest.recordIkm,
|
||||
storageIds = localStorageIds
|
||||
),
|
||||
inserts = remoteInserts,
|
||||
deletes = remoteDeletes
|
||||
)
|
||||
}
|
||||
stopwatch.split("local-data-transaction")
|
||||
|
@ -321,15 +357,19 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
|
||||
StorageSyncValidations.validate(remoteWriteOperation, remoteManifest, needsForcePush, self)
|
||||
|
||||
val conflict = accountManager.writeStorageRecords(storageServiceKey, remoteWriteOperation.manifest, remoteWriteOperation.inserts, remoteWriteOperation.deletes)
|
||||
|
||||
if (conflict.isPresent) {
|
||||
Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying.")
|
||||
throw RetryLaterException()
|
||||
when (val result = repository.writeStorageRecords(storageServiceKey, remoteWriteOperation.manifest, remoteWriteOperation.inserts, remoteWriteOperation.deletes)) {
|
||||
StorageServiceRepository.WriteStorageRecordsResult.Success -> Unit
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.StatusCodeError -> throw result.exception
|
||||
is StorageServiceRepository.WriteStorageRecordsResult.NetworkError -> throw result.exception
|
||||
StorageServiceRepository.WriteStorageRecordsResult.ConflictError -> {
|
||||
Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying.")
|
||||
throw RetryLaterException()
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Saved new manifest. Now at version: ${remoteWriteOperation.manifest.versionString}")
|
||||
SignalStore.storageService.manifest = remoteWriteOperation.manifest
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = null
|
||||
|
||||
stopwatch.split("remote-write")
|
||||
|
||||
|
@ -344,7 +384,12 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
|
|||
if (knownUnknownIds.isNotEmpty()) {
|
||||
Log.i(TAG, "We have ${knownUnknownIds.size} unknown records that we can now process.")
|
||||
|
||||
val remote = accountManager.readStorageRecords(storageServiceKey, knownUnknownIds)
|
||||
val remote = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, knownUnknownIds)) {
|
||||
is StorageServiceRepository.StorageRecordResult.Success -> result.records
|
||||
is StorageServiceRepository.StorageRecordResult.DecryptionError -> throw result.exception
|
||||
is StorageServiceRepository.StorageRecordResult.NetworkError -> throw result.exception
|
||||
is StorageServiceRepository.StorageRecordResult.StatusCodeError -> throw result.exception
|
||||
}
|
||||
val records = StorageRecordCollection(remote)
|
||||
|
||||
Log.i(TAG, "Found ${remote.size} of the known-unknowns remotely.")
|
||||
|
|
|
@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceIds
|
||||
|
@ -30,6 +31,9 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
|||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.signal.libsignal.messagebackup.AccountEntropyPool as LibSignalAccountEntropyPool
|
||||
|
||||
class AccountValues internal constructor(store: KeyValueStore, context: Context) : SignalStoreValues(store) {
|
||||
|
||||
|
@ -79,6 +83,10 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
|
|||
private const val KEY_IS_REGISTERED = "account.is_registered"
|
||||
|
||||
private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices"
|
||||
|
||||
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
|
||||
|
||||
private val AEP_LOCK = ReentrantLock()
|
||||
}
|
||||
|
||||
init {
|
||||
|
@ -111,10 +119,37 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
|
|||
KEY_PNI_IDENTITY_PRIVATE_KEY,
|
||||
KEY_USERNAME,
|
||||
KEY_USERNAME_LINK_ENTROPY,
|
||||
KEY_USERNAME_LINK_SERVER_ID
|
||||
KEY_USERNAME_LINK_SERVER_ID,
|
||||
KEY_ACCOUNT_ENTROPY_POOL
|
||||
)
|
||||
}
|
||||
|
||||
val accountEntropyPool: AccountEntropyPool
|
||||
get() {
|
||||
AEP_LOCK.withLock {
|
||||
getString(KEY_ACCOUNT_ENTROPY_POOL, null)?.let {
|
||||
return AccountEntropyPool(it)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Generating Account Entropy Pool (AEP)...")
|
||||
val newAep = LibSignalAccountEntropyPool.generate()
|
||||
putString(KEY_ACCOUNT_ENTROPY_POOL, newAep)
|
||||
return AccountEntropyPool(newAep)
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreAccountEntropyPool(aep: AccountEntropyPool) {
|
||||
AEP_LOCK.withLock {
|
||||
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value).commit()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAccountEntropyPool() {
|
||||
AEP_LOCK.withLock {
|
||||
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, null).commit()
|
||||
}
|
||||
}
|
||||
|
||||
/** The local user's [ACI]. */
|
||||
val aci: ACI?
|
||||
get() = ACI.parseOrNull(getString(KEY_ACI, null))
|
||||
|
|
|
@ -101,7 +101,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
|||
* Key used to backup messages.
|
||||
*/
|
||||
val messageBackupKey: MessageBackupKey
|
||||
get() = SignalStore.svr.masterKey.derivateMessageBackupKey()
|
||||
get() = SignalStore.account.accountEntropyPool.deriveMessageBackupKey()
|
||||
|
||||
/**
|
||||
* Key used to backup media. Purely random and separate from the message backup key.
|
||||
|
|
|
@ -1,22 +1,27 @@
|
|||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class StorageServiceValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(StorageServiceValues::class)
|
||||
|
||||
private const val LAST_SYNC_TIME = "storage.last_sync_time"
|
||||
private const val NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore"
|
||||
private const val MANIFEST = "storage.manifest"
|
||||
|
||||
// TODO [linked-device] No need to track this separately -- we'd get the AEP from the primary
|
||||
private const val SYNC_STORAGE_KEY = "storage.syncStorageKey"
|
||||
private const val INITIAL_RESTORE_STORAGE_KEY = "storage.initialRestoreStorageKey"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
public override fun getKeysToIncludeInBackup(): List<String> = emptyList()
|
||||
|
||||
@get:Synchronized
|
||||
val storageKey: StorageKey
|
||||
get() {
|
||||
if (store.containsKey(SYNC_STORAGE_KEY)) {
|
||||
|
@ -54,4 +59,30 @@ class StorageServiceValues internal constructor(store: KeyValueStore) : SignalSt
|
|||
set(manifest) {
|
||||
putBlob(MANIFEST, manifest.serialize())
|
||||
}
|
||||
|
||||
/**
|
||||
* The [StorageKey] that should be used for our initial storage service data restore.
|
||||
* The presence of this value indicates that it hasn't been used yet.
|
||||
* Once there has been *any* write to storage service, this value needs to be cleared.
|
||||
*/
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var storageKeyForInitialDataRestore: StorageKey?
|
||||
get() {
|
||||
return getBlob(INITIAL_RESTORE_STORAGE_KEY, null)?.let { StorageKey(it) }
|
||||
}
|
||||
set(value) {
|
||||
if (value != storageKeyForInitialDataRestore) {
|
||||
if (value == storageKey) {
|
||||
Log.w(TAG, "The key already matches the one derived from the AEP! All good, no need to store it.")
|
||||
store.beginWrite().putBlob(INITIAL_RESTORE_STORAGE_KEY, null).commit()
|
||||
} else if (value != null) {
|
||||
Log.w(TAG, "Setting initial restore key!", Throwable())
|
||||
store.beginWrite().putBlob(INITIAL_RESTORE_STORAGE_KEY, value.serialize()).commit()
|
||||
} else {
|
||||
Log.w(TAG, "Clearing initial restore key!", Throwable())
|
||||
store.beginWrite().putBlob(INITIAL_RESTORE_STORAGE_KEY, null).commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,8 @@ package org.thoughtcrime.securesms.keyvalue
|
|||
|
||||
import org.signal.core.util.StringStringSerializer
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil.localPinHash
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
|
||||
class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
companion object {
|
||||
|
@ -16,8 +12,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
const val REGISTRATION_LOCK_ENABLED: String = "kbs.v2_lock_enabled"
|
||||
const val OPTED_OUT: String = "kbs.opted_out"
|
||||
|
||||
private const val MASTER_KEY = "kbs.registration_lock_master_key"
|
||||
private const val TOKEN_RESPONSE = "kbs.token_response"
|
||||
private const val PIN = "kbs.pin"
|
||||
private const val LOCK_LOCAL_PIN_HASH = "kbs.registration_lock_local_pin_hash"
|
||||
private const val LAST_CREATE_FAILED_TIMESTAMP = "kbs.last_create_failed_timestamp"
|
||||
|
@ -42,7 +36,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
fun clearRegistrationLockAndPin() {
|
||||
store.beginWrite()
|
||||
.remove(REGISTRATION_LOCK_ENABLED)
|
||||
.remove(TOKEN_RESPONSE)
|
||||
.remove(LOCK_LOCAL_PIN_HASH)
|
||||
.remove(PIN)
|
||||
.remove(LAST_CREATE_FAILED_TIMESTAMP)
|
||||
|
@ -52,10 +45,11 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
.commit()
|
||||
}
|
||||
|
||||
@Deprecated("Switch to restoring AEP instead")
|
||||
@Synchronized
|
||||
fun setMasterKey(masterKey: MasterKey, pin: String?) {
|
||||
store.beginWrite().apply {
|
||||
putBlob(MASTER_KEY, masterKey.serialize())
|
||||
// putBlob(MASTER_KEY, masterKey.serialize())
|
||||
putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
|
||||
putBoolean(OPTED_OUT, false)
|
||||
|
||||
|
@ -71,10 +65,21 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
}.commit()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setPin(pin: String) {
|
||||
store.beginWrite()
|
||||
.putString(PIN, pin)
|
||||
.putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setPinIfNotPresent(pin: String) {
|
||||
if (store.getString(PIN, null) == null) {
|
||||
store.beginWrite().putString(PIN, pin).commit()
|
||||
store.beginWrite()
|
||||
.putString(PIN, pin)
|
||||
.putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -94,33 +99,18 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0
|
||||
}
|
||||
|
||||
/** Returns the Master Key, lazily creating one if needed. */
|
||||
@get:Synchronized
|
||||
/** Returns the Master Key */
|
||||
val masterKey: MasterKey
|
||||
get() {
|
||||
val blob = store.getBlob(MASTER_KEY, null)
|
||||
if (blob != null) {
|
||||
return MasterKey(blob)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Generating Master Key...", Throwable())
|
||||
val masterKey = MasterKey.createNew(SecureRandom())
|
||||
store.beginWrite().putBlob(MASTER_KEY, masterKey.serialize()).commit()
|
||||
return masterKey
|
||||
}
|
||||
get() = SignalStore.account.accountEntropyPool.deriveMasterKey()
|
||||
|
||||
@get:Synchronized
|
||||
val pinBackedMasterKey: MasterKey?
|
||||
/** Returns null if master key is not backed up by a pin. */
|
||||
get() {
|
||||
if (!isRegistrationLockEnabled) return null
|
||||
return rawMasterKey
|
||||
return masterKey
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
private val rawMasterKey: MasterKey?
|
||||
get() = getBlob(MASTER_KEY, null)?.let { MasterKey(it) }
|
||||
|
||||
@get:Synchronized
|
||||
val registrationLockToken: String?
|
||||
get() {
|
||||
|
@ -131,8 +121,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
@get:Synchronized
|
||||
val recoveryPassword: String?
|
||||
get() {
|
||||
val masterKey = rawMasterKey
|
||||
return if (masterKey != null && hasOptedInWithAccess()) {
|
||||
return if (hasOptedInWithAccess()) {
|
||||
masterKey.deriveRegistrationRecoveryPassword()
|
||||
} else {
|
||||
null
|
||||
|
@ -242,8 +231,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
fun optOut() {
|
||||
store.beginWrite()
|
||||
.putBoolean(OPTED_OUT, true)
|
||||
.remove(TOKEN_RESPONSE)
|
||||
.putBlob(MASTER_KEY, MasterKey.createNew(SecureRandom()).serialize())
|
||||
.remove(LOCK_LOCAL_PIN_HASH)
|
||||
.remove(PIN)
|
||||
.remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
|
||||
|
@ -256,17 +243,5 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
|
|||
return getBoolean(OPTED_OUT, false)
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
val registrationLockTokenResponse: TokenResponse?
|
||||
get() {
|
||||
val token = store.getString(TOKEN_RESPONSE, null) ?: return null
|
||||
|
||||
try {
|
||||
return JsonUtils.fromJson(token, TokenResponse::class.java)
|
||||
} catch (e: IOException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
var lastRefreshAuthTimestamp: Long by longValue(SVR_LAST_AUTH_REFRESH_TIMESTAMP, 0L)
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
|
|||
import org.thoughtcrime.securesms.database.model.RecipientRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
public final class LogSectionCapabilities implements LogSection {
|
||||
|
@ -30,17 +31,20 @@ public final class LogSectionCapabilities implements LogSection {
|
|||
|
||||
Recipient self = Recipient.self();
|
||||
|
||||
AccountAttributes.Capabilities localCapabilities = AppCapabilities.getCapabilities(false);
|
||||
AccountAttributes.Capabilities localCapabilities = AppCapabilities.getCapabilities(false, RemoteConfig.getStorageServiceEncryptionV2());
|
||||
RecipientRecord.Capabilities globalCapabilities = SignalDatabase.recipients().getCapabilities(self.getId());
|
||||
|
||||
StringBuilder builder = new StringBuilder().append("-- Local").append("\n")
|
||||
.append("DeleteSync: ").append(localCapabilities.getDeleteSync()).append("\n")
|
||||
.append("VersionedExpirationTimer: ").append(localCapabilities.getVersionedExpirationTimer()).append("\n")
|
||||
.append("StorageServiceEncryptionV2: ").append(localCapabilities.getStorageServiceEncryptionV2()).append("\n")
|
||||
.append("\n")
|
||||
.append("-- Global").append("\n");
|
||||
|
||||
if (globalCapabilities != null) {
|
||||
builder.append("DeleteSync: ").append(globalCapabilities.getDeleteSync()).append("\n");
|
||||
builder.append("VersionedExpirationTimer: ").append(globalCapabilities.getVersionedExpirationTimer()).append("\n");
|
||||
builder.append("StorageServiceEncryptionV2: ").append(globalCapabilities.getStorageServiceEncryptionV2()).append("\n");
|
||||
builder.append("\n");
|
||||
} else {
|
||||
builder.append("Self not found!");
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package org.thoughtcrime.securesms.migrations
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
|
||||
import org.thoughtcrime.securesms.jobs.Svr2MirrorJob
|
||||
|
||||
/**
|
||||
* Migration for when we introduce the Account Entropy Pool (AEP).
|
||||
*/
|
||||
internal class AepMigrationJob(
|
||||
parameters: Parameters = Parameters.Builder().build()
|
||||
) : MigrationJob(parameters) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AepMigrationJob::class.java)
|
||||
const val KEY = "AepMigrationJob"
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun isUiBlocking(): Boolean = false
|
||||
|
||||
override fun performMigration() {
|
||||
AppDependencies.jobManager.add(Svr2MirrorJob())
|
||||
AppDependencies.jobManager.add(StorageForcePushJob())
|
||||
}
|
||||
|
||||
override fun shouldRetry(e: Exception): Boolean = false
|
||||
|
||||
class Factory : Job.Factory<AepMigrationJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): AepMigrationJob {
|
||||
return AepMigrationJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -160,9 +160,10 @@ public class ApplicationMigrations {
|
|||
static final int BACKFILL_DIGESTS_V3 = 116;
|
||||
static final int SVR2_ENCLAVE_UPDATE_2 = 117;
|
||||
static final int WALLPAPER_MIGRATION_CLEANUP = 118;
|
||||
static final int AEP_INTRODUCTION = 119;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 118;
|
||||
public static final int CURRENT_VERSION = 119;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
|
@ -733,6 +734,10 @@ public class ApplicationMigrations {
|
|||
jobs.put(Version.WALLPAPER_MIGRATION_CLEANUP, new WallpaperCleanupMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.AEP_INTRODUCTION) {
|
||||
jobs.put(Version.AEP_INTRODUCTION, new AepMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import org.whispersystems.signalservice.api.archive.ArchiveApi
|
|||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
import org.whispersystems.signalservice.api.link.LinkDeviceApi
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
|
||||
/**
|
||||
* A convenient way to access network operations, similar to [org.thoughtcrime.securesms.database.SignalDatabase] and [org.thoughtcrime.securesms.keyvalue.SignalStore].
|
||||
|
@ -26,4 +27,7 @@ object SignalNetwork {
|
|||
|
||||
val linkDevice: LinkDeviceApi
|
||||
get() = AppDependencies.linkDeviceApi
|
||||
|
||||
val storageService: StorageServiceApi
|
||||
get() = AppDependencies.storageServiceApi
|
||||
}
|
||||
|
|
|
@ -168,7 +168,8 @@ object SvrRepository {
|
|||
SignalStore.registration.localRegistrationMetadata = metadata.copy(masterKey = response.masterKey.serialize().toByteString(), pin = userPin)
|
||||
}
|
||||
|
||||
SignalStore.svr.setMasterKey(response.masterKey, userPin)
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = response.masterKey.deriveStorageServiceKey()
|
||||
SignalStore.svr.setPin(userPin)
|
||||
SignalStore.svr.isRegistrationLockEnabled = false
|
||||
SignalStore.pin.resetPinReminders()
|
||||
SignalStore.pin.keyboardType = pinKeyboardType
|
||||
|
@ -267,7 +268,7 @@ object SvrRepository {
|
|||
if (overallResponse is BackupResponse.Success) {
|
||||
Log.i(TAG, "[setPin] Success!", true)
|
||||
|
||||
SignalStore.svr.setMasterKey(masterKey, userPin)
|
||||
SignalStore.svr.setPin(userPin)
|
||||
responses
|
||||
.filterIsInstance<BackupResponse.Success>()
|
||||
.forEach {
|
||||
|
@ -320,13 +321,14 @@ object SvrRepository {
|
|||
Log.i(TAG, "[onRegistrationComplete] ReRegistration Skip SMS", true)
|
||||
}
|
||||
|
||||
SignalStore.svr.setMasterKey(masterKey, userPin)
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = masterKey.deriveStorageServiceKey()
|
||||
SignalStore.svr.setPin(userPin)
|
||||
SignalStore.pin.resetPinReminders()
|
||||
|
||||
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
|
||||
} else if (masterKey != null) {
|
||||
Log.i(TAG, "[onRegistrationComplete] ReRegistered with key without pin")
|
||||
SignalStore.svr.setMasterKey(masterKey, null)
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = masterKey.deriveStorageServiceKey()
|
||||
} else if (hasPinToRestore) {
|
||||
Log.i(TAG, "[onRegistrationComplete] Has a PIN to restore.", true)
|
||||
SignalStore.svr.clearRegistrationLockAndPin()
|
||||
|
|
|
@ -321,6 +321,9 @@ class Recipient(
|
|||
/** The user's capability to handle tracking an expire timer version. */
|
||||
val versionedExpirationTimerCapability: Capability = capabilities.versionedExpirationTimer
|
||||
|
||||
/** The user's capability to handle the new storage record encryption scheme. */
|
||||
val storageServiceEncryptionV2Capability: Capability = capabilities.storageServiceEncryptionV2
|
||||
|
||||
/** The state around whether we can send sealed sender to this user. */
|
||||
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {
|
||||
SealedSenderAccessMode.DISABLED
|
||||
|
|
|
@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
|
|||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
|
@ -274,7 +275,8 @@ object RegistrationRepository {
|
|||
withContext(Dispatchers.IO) {
|
||||
val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials)
|
||||
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
|
||||
SignalStore.svr.setMasterKey(masterKey, pin)
|
||||
SignalStore.storageService.storageKeyForInitialDataRestore = masterKey.deriveStorageServiceKey()
|
||||
SignalStore.svr.setPin(pin)
|
||||
return@withContext masterKey
|
||||
}
|
||||
|
||||
|
@ -420,7 +422,7 @@ object RegistrationRepository {
|
|||
registrationLock = registrationLock,
|
||||
unidentifiedAccessKey = unidentifiedAccessKey,
|
||||
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
|
||||
capabilities = AppCapabilities.getCapabilities(true),
|
||||
capabilities = AppCapabilities.getCapabilities(true, RemoteConfig.storageServiceEncryptionV2),
|
||||
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
|
||||
name = null,
|
||||
pniRegistrationId = registrationData.pniRegistrationId,
|
||||
|
|
|
@ -81,13 +81,13 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu
|
|||
}
|
||||
|
||||
private fun createChallengeRequiredProcessor(errorResult: NetworkResult.StatusCodeError<RegistrationSessionMetadataResponse>): VerificationCodeRequestResult {
|
||||
if (errorResult.body == null) {
|
||||
if (errorResult.stringBody == null) {
|
||||
Log.w(TAG, "Attempted to parse error body with response code ${errorResult.code} for list of requested information, but body was null.")
|
||||
return UnknownError(errorResult.exception)
|
||||
}
|
||||
|
||||
try {
|
||||
val response = JsonUtil.fromJson(errorResult.body, RegistrationSessionMetadataJson::class.java)
|
||||
val response = JsonUtil.fromJson(errorResult.stringBody, RegistrationSessionMetadataJson::class.java)
|
||||
return ChallengeRequired(Challenge.parse(response.requestedInformation))
|
||||
} catch (parseException: IOException) {
|
||||
Log.w(TAG, "Attempted to parse error body for list of requested information, but encountered exception.", parseException)
|
||||
|
|
|
@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
|
|||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.SvrNoDataException
|
||||
|
@ -414,7 +415,7 @@ object RegistrationRepository {
|
|||
registrationLock = registrationLock,
|
||||
unidentifiedAccessKey = unidentifiedAccessKey,
|
||||
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
|
||||
capabilities = AppCapabilities.getCapabilities(true),
|
||||
capabilities = AppCapabilities.getCapabilities(true, RemoteConfig.storageServiceEncryptionV2),
|
||||
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
|
||||
name = null,
|
||||
pniRegistrationId = registrationData.pniRegistrationId,
|
||||
|
|
|
@ -1143,5 +1143,13 @@ object RemoteConfig {
|
|||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Whether or not this device supports the new storage service recordIkm encryption. */
|
||||
@JvmStatic
|
||||
val storageServiceEncryptionV2: Boolean by remoteBoolean(
|
||||
key = "android.ssre2",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@ package org.thoughtcrime.securesms
|
|||
import org.signal.core.util.Base64
|
||||
import org.signal.spinner.Plugin
|
||||
import org.signal.spinner.PluginResult
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
|
||||
class StorageServicePlugin : Plugin {
|
||||
override val name: String = "Storage"
|
||||
|
@ -14,11 +15,18 @@ class StorageServicePlugin : Plugin {
|
|||
val columns = listOf("Type", "Id", "Data")
|
||||
val rows = mutableListOf<List<String>>()
|
||||
|
||||
val manager = AppDependencies.signalServiceAccountManager
|
||||
val repository = StorageServiceRepository(SignalNetwork.storageService)
|
||||
val storageServiceKey = SignalStore.storageService.storageKey
|
||||
val storageManifestVersion = manager.storageManifestVersion
|
||||
val manifest = manager.getStorageManifestIfDifferentVersion(storageServiceKey, storageManifestVersion - 1).get()
|
||||
val signalStorageRecords = manager.readStorageRecords(storageServiceKey, manifest.storageIds)
|
||||
|
||||
val manifest = when (val result = repository.getStorageManifest(storageServiceKey)) {
|
||||
is StorageServiceRepository.ManifestResult.Success -> result.manifest
|
||||
else -> return PluginResult.StringResult("Failed to find manifest!")
|
||||
}
|
||||
|
||||
val signalStorageRecords = when (val result = repository.readStorageRecords(storageServiceKey, manifest.recordIkm, manifest.storageIds)) {
|
||||
is StorageServiceRepository.StorageRecordResult.Success -> result.records
|
||||
else -> return PluginResult.StringResult("Failed to read records!")
|
||||
}
|
||||
|
||||
for (record in signalStorageRecords) {
|
||||
val row = mutableListOf<String>()
|
||||
|
|
|
@ -127,7 +127,8 @@ object RecipientDatabaseTestUtils {
|
|||
capabilities = RecipientRecord.Capabilities(
|
||||
rawBits = capabilities,
|
||||
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.DELETE_SYNC, RecipientTable.Capabilities.BIT_LENGTH).toInt()),
|
||||
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.VERSIONED_EXPIRATION_TIMER, RecipientTable.Capabilities.BIT_LENGTH).toInt())
|
||||
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.VERSIONED_EXPIRATION_TIMER, RecipientTable.Capabilities.BIT_LENGTH).toInt()),
|
||||
storageServiceEncryptionV2 = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.STORAGE_SERVICE_ENCRYPTION_V2, RecipientTable.Capabilities.BIT_LENGTH).toInt())
|
||||
),
|
||||
storageId = storageId,
|
||||
mentionSetting = mentionSetting,
|
||||
|
|
|
@ -47,6 +47,7 @@ import org.whispersystems.signalservice.api.registration.RegistrationApi
|
|||
import org.whispersystems.signalservice.api.services.CallLinksService
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.services.ProfileService
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import java.util.function.Supplier
|
||||
|
@ -227,4 +228,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
|
|||
override fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
override fun provideStorageServiceApi(pushServiceSocket: PushServiceSocket): StorageServiceApi {
|
||||
return mockk()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import org.whispersystems.signalservice.api.backup.MessageBackupKey
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.signal.libsignal.messagebackup.AccountEntropyPool as LibSignalAccountEntropyPool
|
||||
|
||||
/**
|
||||
* The Root of All Entropy. You can use this to derive the [MasterKey] or [MessageBackupKey].
|
||||
*/
|
||||
class AccountEntropyPool(val value: String) {
|
||||
|
||||
companion object {
|
||||
fun generate(): AccountEntropyPool {
|
||||
return AccountEntropyPool(LibSignalAccountEntropyPool.generate())
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveMasterKey(): MasterKey {
|
||||
return MasterKey(LibSignalAccountEntropyPool.deriveSvrKey(value))
|
||||
}
|
||||
|
||||
fun deriveMessageBackupKey(): MessageBackupKey {
|
||||
val libSignalBackupKey = LibSignalAccountEntropyPool.deriveBackupKey(value)
|
||||
return MessageBackupKey(libSignalBackupKey.serialize())
|
||||
}
|
||||
}
|
|
@ -105,8 +105,8 @@ sealed class NetworkResult<T>(
|
|||
data class NetworkError<T>(val exception: IOException) : NetworkResult<T>()
|
||||
|
||||
/** Indicates we got a response, but it was a non-2xx response. */
|
||||
data class StatusCodeError<T>(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>() {
|
||||
constructor(e: NonSuccessfulResponseCodeException) : this(e.code, e.body, e)
|
||||
data class StatusCodeError<T>(val code: Int, val stringBody: String?, val binaryBody: ByteArray?, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>() {
|
||||
constructor(e: NonSuccessfulResponseCodeException) : this(e.code, e.stringBody, e.binaryBody, e)
|
||||
}
|
||||
|
||||
/** Indicates that the application somehow failed in a way unrelated to network activity. Usually a runtime crash. */
|
||||
|
@ -143,6 +143,8 @@ sealed class NetworkResult<T>(
|
|||
* If it's non-successful, [transform] lambda is not run, and instead the original failure will be propagated.
|
||||
* Useful for changing the type of a result.
|
||||
*
|
||||
* If an exception is thrown during [transform], this is mapped to an [ApplicationError].
|
||||
*
|
||||
* ```kotlin
|
||||
* val user: NetworkResult<LocalUserModel> = NetworkResult
|
||||
* .fromFetch { fetchRemoteUserModel() }
|
||||
|
@ -151,10 +153,16 @@ sealed class NetworkResult<T>(
|
|||
*/
|
||||
fun <R> map(transform: (T) -> R): NetworkResult<R> {
|
||||
return when (this) {
|
||||
is Success -> Success(transform(this.result)).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is Success -> {
|
||||
try {
|
||||
Success(transform(this.result)).runOnStatusCodeError(statusCodeErrorActions)
|
||||
} catch (e: Throwable) {
|
||||
ApplicationError<R>(e).runOnStatusCodeError(statusCodeErrorActions)
|
||||
}
|
||||
}
|
||||
is NetworkError -> NetworkError<R>(exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is ApplicationError -> ApplicationError<R>(throwable).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is StatusCodeError -> StatusCodeError<R>(code, body, exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is StatusCodeError -> StatusCodeError<R>(code, stringBody, binaryBody, exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -204,7 +212,7 @@ sealed class NetworkResult<T>(
|
|||
is Success -> result(this.result).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is NetworkError -> NetworkError<R>(exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is ApplicationError -> ApplicationError<R>(throwable).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is StatusCodeError -> StatusCodeError<R>(code, body, exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
is StatusCodeError -> StatusCodeError<R>(code, stringBody, binaryBody, exception).runOnStatusCodeError(statusCodeErrorActions)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,14 +6,11 @@
|
|||
|
||||
package org.whispersystems.signalservice.api;
|
||||
|
||||
import com.squareup.wire.FieldEncoding;
|
||||
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.libsignal.net.Network;
|
||||
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.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.libsignal.usernames.Username.UsernameLink;
|
||||
|
@ -39,19 +36,10 @@ 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.registration.RegistrationApi;
|
||||
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.svr.SecureValueRecoveryV3;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
|
@ -71,19 +59,11 @@ import org.whispersystems.signalservice.internal.push.RemoteConfigResponse;
|
|||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse;
|
||||
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 java.io.IOException;
|
||||
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;
|
||||
|
@ -94,7 +74,6 @@ 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;
|
||||
|
||||
|
@ -111,8 +90,6 @@ 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 GroupsV2Operations groupsV2Operations;
|
||||
|
@ -290,119 +267,6 @@ public class SignalServiceAccountManager {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
*/
|
||||
|
@ -417,83 +281,6 @@ public class SignalServiceAccountManager {
|
|||
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.sourceDeviceId)
|
||||
.version(manifest.version);
|
||||
|
||||
|
||||
manifestRecordBuilder.identifiers(
|
||||
manifest.storageIds.stream()
|
||||
.map(id -> {
|
||||
ManifestRecord.Identifier.Builder builder = new ManifestRecord.Identifier.Builder()
|
||||
.raw(ByteString.of(id.getRaw()));
|
||||
if (!id.isUnknown()) {
|
||||
builder.type(ManifestRecord.Identifier.Type.Companion.fromValue(id.getType()));
|
||||
} else {
|
||||
builder.type(ManifestRecord.Identifier.Type.UNKNOWN);
|
||||
builder.addUnknownField(2, FieldEncoding.VARINT, id.getType());
|
||||
}
|
||||
return builder.build();
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifestKey manifestKey = storageKey.deriveManifestKey(manifest.version);
|
||||
byte[] encryptedRecord = SignalStorageCipher.encrypt(manifestKey, manifestRecordBuilder.build().encode());
|
||||
StorageManifest storageManifest = new StorageManifest.Builder()
|
||||
.version(manifest.version)
|
||||
.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<>();
|
||||
|
|
|
@ -1657,16 +1657,20 @@ public class SignalServiceMessageSender {
|
|||
SyncMessage.Builder syncMessage = createSyncMessageBuilder();
|
||||
SyncMessage.Keys.Builder builder = new SyncMessage.Keys.Builder();
|
||||
|
||||
if (keysMessage.getStorageService().isPresent()) {
|
||||
builder.storageService(ByteString.of(keysMessage.getStorageService().get().serialize()));
|
||||
if (keysMessage.getStorageService() != null) {
|
||||
builder.storageService(ByteString.of(keysMessage.getStorageService().serialize()));
|
||||
}
|
||||
|
||||
if (keysMessage.getMaster().isPresent()) {
|
||||
builder.master(ByteString.of(keysMessage.getMaster().get().serialize()));
|
||||
if (keysMessage.getMaster() != null) {
|
||||
builder.master(ByteString.of(keysMessage.getMaster().serialize()));
|
||||
}
|
||||
|
||||
if (builder.storageService == null && builder.master == null) {
|
||||
Log.w(TAG, "Invalid keys message!");
|
||||
if (keysMessage.getAccountEntropyPool() != null) {
|
||||
builder.accountEntropyPool(keysMessage.getAccountEntropyPool().getValue());
|
||||
}
|
||||
|
||||
if (keysMessage.getMediaRootBackupKey() != null) {
|
||||
builder.mediaRootBackupKey(ByteString.of(keysMessage.getMediaRootBackupKey().getValue()));
|
||||
}
|
||||
|
||||
return container.syncMessage(syncMessage.keys(builder.build()).build()).build();
|
||||
|
@ -2689,7 +2693,7 @@ public class SignalServiceMessageSender {
|
|||
|
||||
return socket.getPreKeys(recipient, sealedSenderAccess, deviceId);
|
||||
} catch (NonSuccessfulResponseCodeException e) {
|
||||
if (e.getCode() == 401 && story) {
|
||||
if (e.code == 401 && story) {
|
||||
Log.d(TAG, "Got 401 when fetching prekey for story. Trying without UD.");
|
||||
return socket.getPreKeys(recipient, null, deviceId);
|
||||
} else {
|
||||
|
|
|
@ -56,6 +56,7 @@ class AccountAttributes @JsonCreator constructor(
|
|||
data class Capabilities @JsonCreator constructor(
|
||||
@JsonProperty val storage: Boolean,
|
||||
@JsonProperty val deleteSync: Boolean,
|
||||
@JsonProperty val versionedExpirationTimer: Boolean
|
||||
@JsonProperty val versionedExpirationTimer: Boolean,
|
||||
@JsonProperty("ssre2") val storageServiceEncryptionV2: Boolean
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import org.signal.libsignal.protocol.kdf.HKDF
|
||||
|
||||
/**
|
||||
* A collection of cryptographic functions in the same namespace for easy access.
|
||||
*/
|
||||
object Crypto {
|
||||
|
||||
fun hkdf(inputKeyMaterial: ByteArray, info: ByteArray, outputLength: Int, salt: ByteArray? = null): ByteArray {
|
||||
return HKDF.deriveSecrets(inputKeyMaterial, salt, info, outputLength)
|
||||
}
|
||||
}
|
|
@ -46,7 +46,7 @@ public final class MasterKey {
|
|||
return derive("Logging Key");
|
||||
}
|
||||
|
||||
public MessageBackupKey derivateMessageBackupKey() {
|
||||
public MessageBackupKey deriveMessageBackupKey() {
|
||||
// TODO [backup] Derive from AEP
|
||||
return new MessageBackupKey(HKDF.deriveSecrets(masterKey, "20231003_Signal_Backups_GenerateBackupKey".getBytes(), 32));
|
||||
}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
package org.whispersystems.signalservice.api.messages.multidevice;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class KeysMessage {
|
||||
|
||||
private final Optional<StorageKey> storageService;
|
||||
private final Optional<MasterKey> master;
|
||||
|
||||
public KeysMessage(Optional<StorageKey> storageService, Optional<MasterKey> master) {
|
||||
this.storageService = storageService;
|
||||
this.master = master;
|
||||
}
|
||||
|
||||
public Optional<StorageKey> getStorageService() {
|
||||
return storageService;
|
||||
}
|
||||
|
||||
public Optional<MasterKey> getMaster() {
|
||||
return master;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.whispersystems.signalservice.api.messages.multidevice
|
||||
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.storage.StorageKey
|
||||
|
||||
data class KeysMessage(
|
||||
val storageService: StorageKey?,
|
||||
val master: MasterKey?,
|
||||
val accountEntropyPool: AccountEntropyPool?,
|
||||
val mediaRootBackupKey: MediaRootBackupKey?
|
||||
)
|
|
@ -198,13 +198,17 @@ public class SignalServiceProfile {
|
|||
@JsonProperty
|
||||
private boolean versionedExpirationTimer;
|
||||
|
||||
@JsonProperty("ssre2")
|
||||
private boolean storageServiceEncryptionV2;
|
||||
|
||||
@JsonCreator
|
||||
public Capabilities() {}
|
||||
|
||||
public Capabilities(boolean storage, boolean deleteSync, boolean versionedExpirationTimer) {
|
||||
this.storage = storage;
|
||||
this.deleteSync = deleteSync;
|
||||
this.versionedExpirationTimer = versionedExpirationTimer;
|
||||
public Capabilities(boolean storage, boolean deleteSync, boolean versionedExpirationTimer, boolean storageServiceEncryptionV2) {
|
||||
this.storage = storage;
|
||||
this.deleteSync = deleteSync;
|
||||
this.versionedExpirationTimer = versionedExpirationTimer;
|
||||
this.storageServiceEncryptionV2 = storageServiceEncryptionV2;
|
||||
}
|
||||
|
||||
public boolean isStorage() {
|
||||
|
@ -218,6 +222,10 @@ public class SignalServiceProfile {
|
|||
public boolean isVersionedExpirationTimer() {
|
||||
return versionedExpirationTimer;
|
||||
}
|
||||
|
||||
public boolean isStorageServiceEncryptionV2() {
|
||||
return storageServiceEncryptionV2;
|
||||
}
|
||||
}
|
||||
|
||||
public ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse() {
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Indicates a server response that is not successful, typically something outside the 2xx range.
|
||||
*/
|
||||
public class NonSuccessfulResponseCodeException extends IOException {
|
||||
|
||||
private final int code;
|
||||
private final String body;
|
||||
|
||||
public NonSuccessfulResponseCodeException(int code) {
|
||||
super("StatusCode: " + code);
|
||||
this.code = code;
|
||||
this.body = null;
|
||||
}
|
||||
|
||||
public NonSuccessfulResponseCodeException(int code, String s) {
|
||||
super("[" + code + "] " + s);
|
||||
this.code = code;
|
||||
this.body = null;
|
||||
}
|
||||
|
||||
public NonSuccessfulResponseCodeException(int code, String s, String body) {
|
||||
super("[" + code + "] " + s);
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public boolean is4xx() {
|
||||
return code >= 400 && code < 500;
|
||||
}
|
||||
|
||||
public boolean is5xx() {
|
||||
return code >= 500 && code < 600;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Copyright (C) 2014-2016 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
package org.whispersystems.signalservice.api.push.exceptions
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Indicates a server response that is not successful, typically something outside the 2xx range.
|
||||
*/
|
||||
open class NonSuccessfulResponseCodeException : IOException {
|
||||
@JvmField
|
||||
val code: Int
|
||||
val stringBody: String?
|
||||
val binaryBody: ByteArray?
|
||||
|
||||
constructor(code: Int) : super("StatusCode: $code") {
|
||||
this.code = code
|
||||
this.stringBody = null
|
||||
this.binaryBody = null
|
||||
}
|
||||
|
||||
constructor(code: Int, message: String) : super("[$code] $message") {
|
||||
this.code = code
|
||||
this.stringBody = null
|
||||
this.binaryBody = null
|
||||
}
|
||||
|
||||
constructor(code: Int, message: String, body: String?) : super("[$code] $message") {
|
||||
this.code = code
|
||||
this.stringBody = body
|
||||
this.binaryBody = null
|
||||
}
|
||||
|
||||
constructor(code: Int, message: String, body: ByteArray?) : super("[$code] $message") {
|
||||
this.code = code
|
||||
this.stringBody = null
|
||||
this.binaryBody = body
|
||||
}
|
||||
|
||||
fun is4xx(): Boolean {
|
||||
return code >= 400 && code < 500
|
||||
}
|
||||
|
||||
fun is5xx(): Boolean {
|
||||
return code >= 500 && code < 600
|
||||
}
|
||||
}
|
|
@ -89,7 +89,7 @@ public final class CdsiV2Service {
|
|||
.map(result -> ServiceResponse.forResult(result, 200, null))
|
||||
.onErrorReturn(error -> {
|
||||
if (error instanceof NonSuccessfulResponseCodeException) {
|
||||
int status = ((NonSuccessfulResponseCodeException) error).getCode();
|
||||
int status = ((NonSuccessfulResponseCodeException) error).code;
|
||||
return ServiceResponse.forApplicationError(error, status, null);
|
||||
} else {
|
||||
return ServiceResponse.forUnknownError(error);
|
||||
|
|
|
@ -388,7 +388,7 @@ public class DonationsService {
|
|||
return ServiceResponse.forResult(responseAndCode.first(), responseAndCode.second(), null);
|
||||
} catch (NonSuccessfulResponseCodeException e) {
|
||||
Log.w(TAG, "Bad response code from server.", e);
|
||||
return ServiceResponse.forApplicationError(e, e.getCode(), e.getMessage());
|
||||
return ServiceResponse.forApplicationError(e, e.code, e.getMessage());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "An unknown error occurred.", e);
|
||||
return ServiceResponse.forUnknownError(e);
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import org.whispersystems.signalservice.api.crypto.Crypto
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItem
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import org.whispersystems.util.StringUtil
|
||||
|
||||
/**
|
||||
* A wrapper around a [ByteArray], just so the recordIkm is strongly typed.
|
||||
* The recordIkm comes from [ManifestRecord.recordIkm], and is used to encrypt [StorageItem.value_].
|
||||
*/
|
||||
@JvmInline
|
||||
value class RecordIkm(val value: ByteArray) {
|
||||
|
||||
companion object {
|
||||
fun generate(): RecordIkm {
|
||||
return RecordIkm(Util.getSecretBytes(32))
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveStorageItemKey(rawId: ByteArray): StorageItemKey {
|
||||
val key = Crypto.hkdf(
|
||||
inputKeyMaterial = this.value,
|
||||
info = StringUtil.utf8("20240801_SIGNAL_STORAGE_SERVICE_ITEM_") + rawId,
|
||||
outputLength = 32
|
||||
)
|
||||
|
||||
return StorageItemKey(key)
|
||||
}
|
||||
}
|
|
@ -1,18 +1,23 @@
|
|||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.EMPTY
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.core.util.toOptional
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest
|
||||
import java.util.Optional
|
||||
|
||||
class SignalStorageManifest(
|
||||
data class SignalStorageManifest(
|
||||
@JvmField val version: Long,
|
||||
@JvmField val sourceDeviceId: Int,
|
||||
val sourceDeviceId: Int,
|
||||
val recordIkm: RecordIkm?,
|
||||
@JvmField val storageIds: List<StorageId>
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY: SignalStorageManifest = SignalStorageManifest(0, 1, emptyList())
|
||||
val EMPTY: SignalStorageManifest = SignalStorageManifest(0, 1, null, emptyList())
|
||||
|
||||
fun deserialize(serialized: ByteArray): SignalStorageManifest {
|
||||
val manifest = StorageManifest.ADAPTER.decode(serialized)
|
||||
|
@ -21,7 +26,12 @@ class SignalStorageManifest(
|
|||
StorageId.forType(id.raw.toByteArray(), id.typeValue)
|
||||
}
|
||||
|
||||
return SignalStorageManifest(manifest.version, manifestRecord.sourceDevice, ids)
|
||||
return SignalStorageManifest(
|
||||
version = manifest.version,
|
||||
sourceDeviceId = manifestRecord.sourceDevice,
|
||||
recordIkm = manifestRecord.recordIkm.takeIf { it.isNotEmpty() }?.toByteArray()?.let { RecordIkm(it) },
|
||||
storageIds = ids
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -40,7 +50,8 @@ class SignalStorageManifest(
|
|||
|
||||
val manifestRecord = ManifestRecord(
|
||||
identifiers = ids,
|
||||
sourceDevice = sourceDeviceId
|
||||
sourceDevice = sourceDeviceId,
|
||||
recordIkm = recordIkm?.value?.toByteString() ?: ByteString.EMPTY
|
||||
)
|
||||
|
||||
return StorageManifest(
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.logging.Log
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItem
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord
|
||||
import java.io.IOException
|
||||
|
||||
object SignalStorageModels {
|
||||
private val TAG: String = SignalStorageModels::class.java.simpleName
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class, InvalidKeyException::class)
|
||||
fun remoteToLocalStorageManifest(manifest: StorageManifest, storageKey: StorageKey): SignalStorageManifest {
|
||||
val rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(manifest.version), manifest.value_.toByteArray())
|
||||
val manifestRecord = ManifestRecord.ADAPTER.decode(rawRecord)
|
||||
val ids: List<StorageId> = manifestRecord.identifiers.map { id ->
|
||||
StorageId.forType(id.raw.toByteArray(), id.typeValue)
|
||||
}
|
||||
|
||||
return SignalStorageManifest(manifestRecord.version, manifestRecord.sourceDevice, ids)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class, InvalidKeyException::class)
|
||||
fun remoteToLocalStorageRecord(item: StorageItem, type: Int, storageKey: StorageKey): SignalStorageRecord {
|
||||
val key = item.key.toByteArray()
|
||||
val rawRecord = SignalStorageCipher.decrypt(storageKey.deriveItemKey(key), item.value_.toByteArray())
|
||||
val record = StorageRecord.ADAPTER.decode(rawRecord)
|
||||
val id = StorageId.forType(key, type)
|
||||
|
||||
if (record.contact != null && type == ManifestRecord.Identifier.Type.CONTACT.value) {
|
||||
return SignalContactRecord(id, record.contact).toSignalStorageRecord()
|
||||
} else if (record.groupV1 != null && type == ManifestRecord.Identifier.Type.GROUPV1.value) {
|
||||
return SignalGroupV1Record(id, record.groupV1).toSignalStorageRecord()
|
||||
} else if (record.groupV2 != null && type == ManifestRecord.Identifier.Type.GROUPV2.value && record.groupV2.masterKey.size == GroupMasterKey.SIZE) {
|
||||
return SignalGroupV2Record(id, record.groupV2).toSignalStorageRecord()
|
||||
} else if (record.account != null && type == ManifestRecord.Identifier.Type.ACCOUNT.value) {
|
||||
return SignalAccountRecord(id, record.account).toSignalStorageRecord()
|
||||
} else if (record.storyDistributionList != null && type == ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST.value) {
|
||||
return SignalStoryDistributionListRecord(id, record.storyDistributionList).toSignalStorageRecord()
|
||||
} else if (record.callLink != null && type == ManifestRecord.Identifier.Type.CALL_LINK.value) {
|
||||
return SignalCallLinkRecord(id, record.callLink).toSignalStorageRecord()
|
||||
} else {
|
||||
if (StorageId.isKnownType(type)) {
|
||||
Log.w(TAG, "StorageId is of known type ($type), but the data is bad! Falling back to unknown.")
|
||||
}
|
||||
return SignalStorageRecord.forUnknown(StorageId.forType(key, type))
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun localToRemoteStorageRecord(record: SignalStorageRecord, storageKey: StorageKey): StorageItem {
|
||||
val itemKey = storageKey.deriveItemKey(record.id.raw)
|
||||
val encryptedRecord = SignalStorageCipher.encrypt(itemKey, record.proto.encode())
|
||||
|
||||
return StorageItem.Builder()
|
||||
.key(record.id.raw.toByteString())
|
||||
.value_(encryptedRecord.toByteString())
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -20,8 +20,8 @@ class StorageKey(val key: ByteArray) {
|
|||
return StorageManifestKey(derive("Manifest_$version"))
|
||||
}
|
||||
|
||||
fun deriveItemKey(key: ByteArray): StorageItemKey {
|
||||
return StorageItemKey(derive("Item_" + encodeWithPadding(key)))
|
||||
fun deriveItemKey(rawId: ByteArray): StorageItemKey {
|
||||
return StorageItemKey(derive("Item_" + encodeWithPadding(rawId)))
|
||||
}
|
||||
|
||||
private fun derive(keyName: String): ByteArray {
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItems
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest
|
||||
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation
|
||||
|
||||
/**
|
||||
* Class to interact with storage service endpoints.
|
||||
*/
|
||||
class StorageServiceApi(private val pushServiceSocket: PushServiceSocket) {
|
||||
|
||||
/**
|
||||
* Retrieves an auth string that's needed to make other storage requests.
|
||||
*
|
||||
* GET /v1/storage/auth
|
||||
*/
|
||||
fun getAuth(): NetworkResult<String> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.getStorageAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest [StorageManifest].
|
||||
*
|
||||
* GET /v1/storage/manifest
|
||||
*
|
||||
* - 200: Success
|
||||
* - 404: No storage manifest was found
|
||||
*/
|
||||
fun getStorageManifest(authToken: String): NetworkResult<StorageManifest> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.getStorageManifest(authToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest [StorageManifest], but only if the version supplied doesn't match the remote.
|
||||
*
|
||||
* GET /v1/storage/manifest/version/{version}
|
||||
*
|
||||
* - 200: Success
|
||||
* - 204: The manifest matched the provided version, and therefore no manifest was returned
|
||||
*/
|
||||
fun getStorageManifestIfDifferentVersion(authToken: String, version: Long): NetworkResult<StorageManifest> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.getStorageManifestIfDifferentVersion(authToken, version)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /v1/storage/read
|
||||
*/
|
||||
fun readStorageItems(authToken: String, operation: ReadOperation): NetworkResult<StorageItems> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.readStorageItems(authToken, operation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the provided [WriteOperation].
|
||||
*
|
||||
* PUT /v1/storage
|
||||
*
|
||||
* - 200: Success
|
||||
* - 409: Your [WriteOperation] version does not equal remoteVersion + 1. That means that there have been writes that you're not aware of.
|
||||
* The body includes the current [StorageManifest] as binary data.
|
||||
*/
|
||||
fun writeStorageItems(authToken: String, writeOperation: WriteOperation): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.writeStorageItems(authToken, writeOperation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you know if storage service is reachable.
|
||||
*
|
||||
* GET /ping
|
||||
*/
|
||||
fun pingStorageService(): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
pushServiceSocket.pingStorageService()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,328 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.storage
|
||||
|
||||
import com.squareup.wire.FieldEncoding
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okio.IOException
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
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.StorageRecord
|
||||
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation
|
||||
import java.lang.Exception
|
||||
|
||||
/**
|
||||
* Collection of higher-level storage service operations. Each method tends to make multiple
|
||||
* calls to [StorageServiceApi], wrapping the responses in easier-to-use result types.
|
||||
*/
|
||||
class StorageServiceRepository(private val storageServiceApi: StorageServiceApi) {
|
||||
|
||||
companion object {
|
||||
private const val STORAGE_READ_MAX_ITEMS: Int = 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the version of the remote manifest.
|
||||
*/
|
||||
fun getManifestVersion(): NetworkResult<Long> {
|
||||
return storageServiceApi
|
||||
.getAuth()
|
||||
.then { storageServiceApi.getStorageManifest(it) }
|
||||
.map { it.version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and returns the fully-decrypted [SignalStorageManifest], if possible.
|
||||
* Note: You should prefer using [getStorageManifestIfDifferentVersion] when possible.
|
||||
*/
|
||||
fun getStorageManifest(storageKey: StorageKey): ManifestResult {
|
||||
val manifest: StorageManifest = storageServiceApi
|
||||
.getAuth()
|
||||
.then { storageServiceApi.getStorageManifest(it) }
|
||||
.let { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> return ManifestResult.NetworkError(result.exception)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
return when (result.code) {
|
||||
404 -> ManifestResult.NotFoundError
|
||||
else -> ManifestResult.StatusCodeError(result.code, result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val decrypted = manifest.toLocal(storageKey)
|
||||
ManifestResult.Success(decrypted)
|
||||
} catch (e: InvalidKeyException) {
|
||||
ManifestResult.DecryptionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and returns the fully-decrypted [SignalStorageManifest] if the remote version is higher than the [manifestVersion] passed in.
|
||||
* The intent is that you only need the manifest if it's newer than what you already have.
|
||||
*/
|
||||
fun getStorageManifestIfDifferentVersion(storageKey: StorageKey, manifestVersion: Long): ManifestIfDifferentVersionResult {
|
||||
val manifest = storageServiceApi
|
||||
.getAuth()
|
||||
.then { storageServiceApi.getStorageManifestIfDifferentVersion(it, manifestVersion) }
|
||||
.let { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> return ManifestIfDifferentVersionResult.NetworkError(result.exception)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
return when (result.code) {
|
||||
204 -> ManifestIfDifferentVersionResult.SameVersion
|
||||
else -> ManifestIfDifferentVersionResult.StatusCodeError(result.code, result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val decrypted = manifest.toLocal(storageKey)
|
||||
ManifestIfDifferentVersionResult.DifferentVersion(decrypted)
|
||||
} catch (e: InvalidKeyException) {
|
||||
ManifestIfDifferentVersionResult.DecryptionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and returns the fully-decrypted [SignalStorageRecord]s matching the list of provided [storageIds]
|
||||
*/
|
||||
fun readStorageRecords(storageKey: StorageKey, recordIkm: RecordIkm?, storageIds: List<StorageId>): StorageRecordResult {
|
||||
val auth: String = when (val result = storageServiceApi.getAuth()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> return StorageRecordResult.NetworkError(result.exception)
|
||||
is NetworkResult.StatusCodeError -> return StorageRecordResult.StatusCodeError(result.code, result.exception)
|
||||
}
|
||||
|
||||
val knownIds = storageIds.filterNot { it.isUnknown }
|
||||
val batches = knownIds.chunked(STORAGE_READ_MAX_ITEMS)
|
||||
val results = batches.map { batch ->
|
||||
readStorageRecordsBatch(auth, storageKey, recordIkm, batch)
|
||||
}
|
||||
|
||||
results
|
||||
.firstOrNull { it !is StorageRecordResult.Success }
|
||||
?.let { firstFailedResult ->
|
||||
return firstFailedResult
|
||||
}
|
||||
|
||||
val unknownRecordPlaceholders = storageIds
|
||||
.filter { it.isUnknown }
|
||||
.map { SignalStorageRecord.forUnknown(it) }
|
||||
|
||||
val allResults = results
|
||||
.map { (it as StorageRecordResult.Success).records }
|
||||
.flatten() + unknownRecordPlaceholders
|
||||
|
||||
return StorageRecordResult.Success(allResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the provided data to storage service.
|
||||
*/
|
||||
fun writeStorageRecords(
|
||||
storageKey: StorageKey,
|
||||
signalManifest: SignalStorageManifest,
|
||||
insertItems: List<SignalStorageRecord>,
|
||||
deleteRawIds: List<ByteArray>
|
||||
): WriteStorageRecordsResult {
|
||||
return writeStorageRecords(storageKey, signalManifest, insertItems, deleteRawIds, clearAllExisting = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the provided data to storage service, overwriting _all other data_ in the process.
|
||||
* Reserved for very specific circumstances! (Like fixing undecryptable data).
|
||||
*/
|
||||
fun resetAndWriteStorageRecords(
|
||||
storageKey: StorageKey,
|
||||
manifest: SignalStorageManifest,
|
||||
insertItems: List<SignalStorageRecord>
|
||||
): WriteStorageRecordsResult {
|
||||
return writeStorageRecords(storageKey, manifest, insertItems, emptyList(), clearAllExisting = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current manifest with no insertions or deletes. Intended to be done after rotating your AEP.
|
||||
*/
|
||||
fun writeUnchangedManifest(storageKey: StorageKey, manifest: SignalStorageManifest): WriteStorageRecordsResult {
|
||||
return writeStorageRecords(storageKey, manifest, emptyList(), emptyList(), clearAllExisting = false)
|
||||
}
|
||||
|
||||
private fun writeStorageRecords(
|
||||
storageKey: StorageKey,
|
||||
signalManifest: SignalStorageManifest,
|
||||
insertItems: List<SignalStorageRecord>,
|
||||
deleteRawIds: List<ByteArray>,
|
||||
clearAllExisting: Boolean
|
||||
): WriteStorageRecordsResult {
|
||||
val manifestIds = signalManifest.storageIds.map { id ->
|
||||
val builder = ManifestRecord.Identifier.Builder()
|
||||
builder.raw = id.raw.toByteString()
|
||||
if (id.isUnknown) {
|
||||
builder.type = ManifestRecord.Identifier.Type.UNKNOWN
|
||||
builder.addUnknownField(2, FieldEncoding.VARINT, id.type)
|
||||
} else {
|
||||
builder.type(ManifestRecord.Identifier.Type.fromValue(id.type)!!)
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
val manifestRecord = ManifestRecord(
|
||||
sourceDevice = signalManifest.sourceDeviceId,
|
||||
version = signalManifest.version,
|
||||
identifiers = manifestIds
|
||||
)
|
||||
|
||||
val manifestKey = storageKey.deriveManifestKey(signalManifest.version)
|
||||
|
||||
val encryptedManifest = StorageManifest(
|
||||
version = manifestRecord.version,
|
||||
value_ = SignalStorageCipher.encrypt(manifestKey, manifestRecord.encode()).toByteString()
|
||||
)
|
||||
|
||||
val writeOperation = WriteOperation.Builder().apply {
|
||||
manifest = encryptedManifest
|
||||
insertItem = insertItems.map { it.toRemote(storageKey, signalManifest.recordIkm) }
|
||||
|
||||
if (clearAllExisting) {
|
||||
clearAll = true
|
||||
} else {
|
||||
deleteKey = deleteRawIds.map { it.toByteString() }
|
||||
}
|
||||
}.build()
|
||||
|
||||
val result = storageServiceApi
|
||||
.getAuth()
|
||||
.then { auth -> storageServiceApi.writeStorageItems(auth, writeOperation) }
|
||||
|
||||
return when (result) {
|
||||
is NetworkResult.Success -> WriteStorageRecordsResult.Success
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> WriteStorageRecordsResult.NetworkError(result.exception)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
409 -> WriteStorageRecordsResult.ConflictError
|
||||
else -> WriteStorageRecordsResult.StatusCodeError(result.code, result.exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStorageRecordsBatch(auth: String, storageKey: StorageKey, recordIkm: RecordIkm?, storageIds: List<StorageId>): StorageRecordResult {
|
||||
check(storageIds.size <= STORAGE_READ_MAX_ITEMS)
|
||||
check(storageIds.none { it.isUnknown })
|
||||
|
||||
val typesByKey: Map<ByteString, Int> = storageIds.associate { it.raw.toByteString() to it.type }
|
||||
|
||||
val readOperation = ReadOperation(
|
||||
readKey = storageIds.map { it.raw.toByteString() }
|
||||
)
|
||||
|
||||
val storageItems: StorageItems = storageServiceApi
|
||||
.readStorageItems(auth, readOperation)
|
||||
.let { itemResult ->
|
||||
when (itemResult) {
|
||||
is NetworkResult.Success -> itemResult.result
|
||||
is NetworkResult.ApplicationError -> throw itemResult.throwable
|
||||
is NetworkResult.NetworkError -> return StorageRecordResult.NetworkError(itemResult.exception)
|
||||
is NetworkResult.StatusCodeError -> return StorageRecordResult.StatusCodeError(itemResult.code, itemResult.exception)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val items = storageItems.items.map { item ->
|
||||
val type = typesByKey[item.key]!!
|
||||
item.toLocal(type, storageKey, recordIkm)
|
||||
}
|
||||
StorageRecordResult.Success(items)
|
||||
} catch (e: InvalidKeyException) {
|
||||
StorageRecordResult.DecryptionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, InvalidKeyException::class)
|
||||
private fun StorageManifest.toLocal(storageKey: StorageKey): SignalStorageManifest {
|
||||
val rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(this.version), this.value_.toByteArray())
|
||||
val manifestRecord = ManifestRecord.ADAPTER.decode(rawRecord)
|
||||
val ids: List<StorageId> = manifestRecord.identifiers.map { id ->
|
||||
StorageId.forType(id.raw.toByteArray(), id.typeValue)
|
||||
}
|
||||
|
||||
return SignalStorageManifest(
|
||||
version = manifestRecord.version,
|
||||
sourceDeviceId = manifestRecord.sourceDevice,
|
||||
recordIkm = manifestRecord.recordIkm.takeIf { it.isNotEmpty() }?.toByteArray()?.let { RecordIkm(it) },
|
||||
storageIds = ids
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(IOException::class, InvalidKeyException::class)
|
||||
private fun StorageItem.toLocal(type: Int, storageKey: StorageKey, recordIkm: RecordIkm?): SignalStorageRecord {
|
||||
val rawId = this.key.toByteArray()
|
||||
val key = recordIkm?.deriveStorageItemKey(rawId) ?: storageKey.deriveItemKey(rawId)
|
||||
val rawRecord = SignalStorageCipher.decrypt(key, this.value_.toByteArray())
|
||||
val record = StorageRecord.ADAPTER.decode(rawRecord)
|
||||
val id = StorageId.forType(rawId, type)
|
||||
|
||||
return SignalStorageRecord(id, record)
|
||||
}
|
||||
|
||||
private fun SignalStorageRecord.toRemote(storageKey: StorageKey, recordIkm: RecordIkm?): StorageItem {
|
||||
val key = recordIkm?.deriveStorageItemKey(this.id.raw) ?: storageKey.deriveItemKey(this.id.raw)
|
||||
val encryptedRecord = SignalStorageCipher.encrypt(key, this.proto.encode())
|
||||
|
||||
return StorageItem.Builder()
|
||||
.key(this.id.raw.toByteString())
|
||||
.value_(encryptedRecord.toByteString())
|
||||
.build()
|
||||
}
|
||||
|
||||
sealed interface WriteStorageRecordsResult {
|
||||
data object Success : WriteStorageRecordsResult
|
||||
data class NetworkError(val exception: IOException) : WriteStorageRecordsResult
|
||||
data object ConflictError : WriteStorageRecordsResult
|
||||
data class StatusCodeError(val code: Int, val exception: NonSuccessfulResponseCodeException) : WriteStorageRecordsResult
|
||||
}
|
||||
|
||||
sealed interface ManifestResult {
|
||||
data class Success(val manifest: SignalStorageManifest) : ManifestResult
|
||||
data class NetworkError(val exception: IOException) : ManifestResult
|
||||
data class DecryptionError(val exception: Exception) : ManifestResult
|
||||
data object NotFoundError : ManifestResult
|
||||
data class StatusCodeError(val code: Int, val exception: NonSuccessfulResponseCodeException) : ManifestResult
|
||||
}
|
||||
|
||||
sealed interface ManifestIfDifferentVersionResult {
|
||||
data class DifferentVersion(val manifest: SignalStorageManifest) : ManifestIfDifferentVersionResult
|
||||
data object SameVersion : ManifestIfDifferentVersionResult
|
||||
data class NetworkError(val exception: IOException) : ManifestIfDifferentVersionResult
|
||||
data class DecryptionError(val exception: Exception) : ManifestIfDifferentVersionResult
|
||||
data class StatusCodeError(val code: Int, val exception: NonSuccessfulResponseCodeException) : ManifestIfDifferentVersionResult
|
||||
}
|
||||
|
||||
sealed interface StorageRecordResult {
|
||||
data class Success(val records: List<SignalStorageRecord>) : StorageRecordResult
|
||||
data class NetworkError(val exception: IOException) : StorageRecordResult
|
||||
data class DecryptionError(val exception: Exception) : StorageRecordResult
|
||||
data class StatusCodeError(val code: Int, val exception: NonSuccessfulResponseCodeException) : StorageRecordResult
|
||||
}
|
||||
}
|
|
@ -120,7 +120,7 @@ public final class ServiceResponse<Result> {
|
|||
if (throwable instanceof ExecutionException) {
|
||||
return forUnknownError(throwable.getCause());
|
||||
} else if (throwable instanceof NonSuccessfulResponseCodeException) {
|
||||
return forApplicationError(throwable, ((NonSuccessfulResponseCodeException) throwable).getCode(), null);
|
||||
return forApplicationError(throwable, ((NonSuccessfulResponseCodeException) throwable).code, null);
|
||||
} else if (throwable instanceof PushNetworkException && throwable.getCause() != null) {
|
||||
return forUnknownError(throwable.getCause());
|
||||
} else {
|
||||
|
|
|
@ -347,10 +347,11 @@ public class PushServiceSocket {
|
|||
private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
|
||||
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
|
||||
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
|
||||
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
|
||||
private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler();
|
||||
private static final ResponseCodeHandler LONG_POLL_HANDLER = new LongPollingResponseCodeHandler();
|
||||
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
|
||||
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
|
||||
private static final ResponseCodeHandler LONG_POLL_HANDLER = new LongPollingResponseCodeHandler();
|
||||
private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler();
|
||||
private static final ResponseCodeHandler UNOPINIONATED_BINARY_ERROR_HANDLER = new UnopinionatedBinaryErrorResponseCodeHandler();
|
||||
|
||||
public static final long CDN2_RESUMABLE_LINK_LIFETIME_MILLIS = TimeUnit.DAYS.toMillis(7);
|
||||
|
||||
|
@ -1608,6 +1609,10 @@ public class PushServiceSocket {
|
|||
}
|
||||
}
|
||||
|
||||
public void writeStorageItems(String authToken, WriteOperation writeOperation) throws IOException {
|
||||
makeStorageRequest(authToken, "/v1/storage", "PUT", protobufRequestBody(writeOperation), UNOPINIONATED_BINARY_ERROR_HANDLER);
|
||||
}
|
||||
|
||||
public void pingStorageService() throws IOException {
|
||||
try (Response response = makeStorageRequest(null, "/ping", "GET", null, NO_HANDLER)) {
|
||||
return;
|
||||
|
@ -2863,6 +2868,24 @@ public class PushServiceSocket {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link ResponseCodeHandler} that only throws {@link NonSuccessfulResponseCodeException} with the response body.
|
||||
* Any further processing is left to the caller.
|
||||
*/
|
||||
private static class UnopinionatedBinaryErrorResponseCodeHandler implements ResponseCodeHandler {
|
||||
@Override
|
||||
public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException {
|
||||
if (responseCode < 200 || responseCode > 299) {
|
||||
byte[] bodyBytes = null;
|
||||
if (body != null) {
|
||||
bodyBytes = readBodyBytes(body);
|
||||
}
|
||||
|
||||
throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClientSet { KeyBackup }
|
||||
|
||||
public CredentialResponse retrieveGroupsV2Credentials(long todaySeconds)
|
||||
|
@ -2881,16 +2904,19 @@ public class PushServiceSocket {
|
|||
|
||||
private static final ResponseCodeHandler GROUPS_V2_PUT_RESPONSE_HANDLER = (responseCode, body) -> {
|
||||
if (responseCode == 409) throw new GroupExistsException();
|
||||
};;
|
||||
};
|
||||
|
||||
private static final ResponseCodeHandler GROUPS_V2_GET_CURRENT_HANDLER = (responseCode, body) -> {
|
||||
switch (responseCode) {
|
||||
case 403: throw new NotInGroupException();
|
||||
case 404: throw new GroupNotFoundException();
|
||||
}
|
||||
};
|
||||
|
||||
private static final ResponseCodeHandler GROUPS_V2_PATCH_RESPONSE_HANDLER = (responseCode, body) -> {
|
||||
if (responseCode == 400) throw new GroupPatchNotAcceptedException();
|
||||
};
|
||||
|
||||
private static final ResponseCodeHandler GROUPS_V2_GET_JOIN_INFO_HANDLER = new ResponseCodeHandler() {
|
||||
@Override
|
||||
public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException {
|
||||
|
|
|
@ -546,7 +546,10 @@ message SyncMessage {
|
|||
message Keys {
|
||||
// @deprecated
|
||||
optional bytes storageService = 1;
|
||||
optional bytes master = 2;
|
||||
// @deprecated
|
||||
optional bytes master = 2;
|
||||
optional string accountEntropyPool = 3;
|
||||
optional bytes mediaRootBackupKey = 4;
|
||||
}
|
||||
|
||||
message MessageRequestResponse {
|
||||
|
|
|
@ -60,7 +60,8 @@ message ManifestRecord {
|
|||
uint64 version = 1;
|
||||
uint32 sourceDevice = 3;
|
||||
repeated Identifier identifiers = 2;
|
||||
// Next ID: 4
|
||||
bytes recordIkm = 4;
|
||||
// Next ID: 5
|
||||
}
|
||||
|
||||
message StorageRecord {
|
||||
|
|
|
@ -31,7 +31,7 @@ class NetworkResultTest {
|
|||
check(result is NetworkResult.StatusCodeError)
|
||||
assertEquals(exception, result.exception)
|
||||
assertEquals(404, result.code)
|
||||
assertEquals("body", result.body)
|
||||
assertEquals("body", result.stringBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -23,6 +23,9 @@
|
|||
{{/each}}
|
||||
</table>
|
||||
{{/if}}
|
||||
{{#if (eq "string" pluginResult.type)}}
|
||||
<p>{{pluginResult.text}}</p>
|
||||
{{/if}}
|
||||
{{> partials/suffix }}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -6,4 +6,8 @@ sealed class PluginResult(val type: String) {
|
|||
val rows: List<List<String>>,
|
||||
val rowCount: Int = rows.size
|
||||
) : PluginResult("table")
|
||||
|
||||
data class StringResult(
|
||||
val text: String
|
||||
) : PluginResult("string")
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue