diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a4e6bf6d6..5d8629fac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -98,9 +98,10 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - tools:replace="android:allowBackup" android:resizeableActivity="true" - android:allowBackup="false" + android:fullBackupOnly="false" + android:allowBackup="true" + android:backupAgent=".absbackup.SignalBackupAgent" android:theme="@style/TextSecure.LightTheme" android:largeHeap="true"> diff --git a/app/src/main/java/org/thoughtcrime/securesms/absbackup/AndroidBackupItem.kt b/app/src/main/java/org/thoughtcrime/securesms/absbackup/AndroidBackupItem.kt new file mode 100644 index 000000000..9a601857a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/absbackup/AndroidBackupItem.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.absbackup + +/** + * Abstracts away the implementation of pieces of data we want to hand off to various backup services. + * Here we can control precisely which data gets backed up and more importantly, what does not. + */ +interface AndroidBackupItem { + fun getKey(): String + fun getDataForBackup(): ByteArray + fun restoreData(data: ByteArray) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/absbackup/SignalBackupAgent.kt b/app/src/main/java/org/thoughtcrime/securesms/absbackup/SignalBackupAgent.kt new file mode 100644 index 000000000..2e68975ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/absbackup/SignalBackupAgent.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.absbackup + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.os.ParcelFileDescriptor +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.absbackup.backupables.KbsAuthTokens +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException + +/** + * Uses the [Android Backup Service](https://developer.android.com/guide/topics/data/keyvaluebackup) and backs up everything in [items] + */ +class SignalBackupAgent : BackupAgent() { + private val items: List = listOf( + KbsAuthTokens, + ) + + override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) { + val contentsHash = cumulativeHashCode() + if (oldState == null) { + performBackup(data) + } else { + val hash = try { + DataInputStream(FileInputStream(oldState.fileDescriptor)).use { it.readInt() } + } catch (e: IOException) { + Log.w(TAG, "No old state, may be first backup request or bug with not writing to newState at end.", e) + } + if (hash != contentsHash) { + performBackup(data) + } + } + + DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(contentsHash) } + } + + private fun performBackup(data: BackupDataOutput) { + items.forEach { + val backupData = it.getDataForBackup() + data.writeEntityHeader(it.getKey(), backupData.size) + data.writeEntityData(backupData, backupData.size) + } + } + + override fun onRestore(dataInput: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) { + while (dataInput.readNextHeader()) { + val buffer = ByteArray(dataInput.dataSize) + dataInput.readEntityData(buffer, 0, dataInput.dataSize) + items.find { dataInput.key == it.getKey() }?.restoreData(buffer) + } + DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) } + } + + private fun cumulativeHashCode(): Int { + return items.fold("") { acc: String, androidBackupItem: AndroidBackupItem -> acc + androidBackupItem.getDataForBackup().decodeToString() }.hashCode() + } + + companion object { + private const val TAG = "SignalBackupAgent" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt b/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt new file mode 100644 index 000000000..c0919cd8e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.absbackup.backupables + +import com.google.protobuf.InvalidProtocolBufferException +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.absbackup.AndroidBackupItem +import org.thoughtcrime.securesms.absbackup.ExternalBackupProtos +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process. + */ +object KbsAuthTokens : AndroidBackupItem { + private const val TAG = "KbsAuthTokens" + + override fun getKey(): String { + return TAG + } + + override fun getDataForBackup(): ByteArray { + val registrationRecoveryTokenList = SignalStore.kbsValues().kbsAuthTokenList + val proto = ExternalBackupProtos.KbsAuthToken.newBuilder() + .addAllToken(registrationRecoveryTokenList) + .build() + return proto.toByteArray() + } + + override fun restoreData(data: ByteArray) { + if (SignalStore.kbsValues().kbsAuthTokenList.isNotEmpty()) { + return + } + + try { + val proto = ExternalBackupProtos.KbsAuthToken.parseFrom(data) + + SignalStore.kbsValues().putAuthTokenList(proto.tokenList) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Cannot restore KbsAuthToken from backup service.") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java index 8c867c3a3..dc54a90ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.signal.core.util.StringStringSerializer; import org.thoughtcrime.securesms.lock.PinHashing; import org.thoughtcrime.securesms.util.JsonUtils; import org.whispersystems.signalservice.api.KbsPinData; @@ -13,6 +14,8 @@ import java.io.IOException; import java.security.SecureRandom; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; public final class KbsValues extends SignalStoreValues { @@ -24,6 +27,7 @@ public final class KbsValues extends SignalStoreValues { private static final String LAST_CREATE_FAILED_TIMESTAMP = "kbs.last_create_failed_timestamp"; public static final String OPTED_OUT = "kbs.opted_out"; private static final String PIN_FORGOTTEN_OR_SKIPPED = "kbs.pin.forgotten.or.skipped"; + private static final String KBS_AUTH_TOKENS = "kbs.kbs_auth_tokens"; KbsValues(KeyValueStore store) { super(store); @@ -165,6 +169,30 @@ public final class KbsValues extends SignalStoreValues { putBoolean(PIN_FORGOTTEN_OR_SKIPPED, value); } + public synchronized void putAuthTokenList(List tokens) { + putList(KBS_AUTH_TOKENS, tokens, StringStringSerializer.INSTANCE); + } + + public synchronized List getKbsAuthTokenList() { + return getList(KBS_AUTH_TOKENS, StringStringSerializer.INSTANCE); + } + + /** + * Keeps the 10 most recent KBS auth tokens. + * @param token + * @return whether the token was added (new) or ignored (already existed) + */ + public synchronized boolean appendAuthTokenToList(String token) { + List tokens = getKbsAuthTokenList(); + if (tokens.contains(token)) { + return false; + } else { + final List result = Stream.concat(Stream.of(token), tokens.stream()).limit(10).collect(Collectors.toList()); + putAuthTokenList(result); + return true; + } + } + /** Should only be called by {@link org.thoughtcrime.securesms.pin.PinState}. */ public synchronized void optOut() { getStore().beginWrite() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/KbsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/pin/KbsRepository.java index 1ac2bcb20..bbcade027 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/KbsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/KbsRepository.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.pin; +import android.app.backup.BackupManager; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -8,6 +10,7 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.InvalidKeyException; import org.thoughtcrime.securesms.KbsEnclave; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.PinHashing; import org.whispersystems.signalservice.api.KbsPinData; import org.whispersystems.signalservice.api.KeyBackupService; @@ -69,6 +72,7 @@ public class KbsRepository { try { authorization = authorization == null ? kbs.getAuthorization() : authorization; + backupAuthToken(authorization); token = kbs.getToken(authorization); } catch (NonSuccessfulResponseCodeException e) { if (e.getCode() == 404) { @@ -95,6 +99,13 @@ public class KbsRepository { return Objects.requireNonNull(firstKnownTokenData); } + private static void backupAuthToken(String token) { + final boolean tokenIsNew = SignalStore.kbsValues().appendAuthTokenToList(token); + if (tokenIsNew) { + new BackupManager(ApplicationDependencies.getApplication()).dataChanged(); + } + } + /** * Invoked during registration to restore the master key based on the server response during * verification. diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java index f4a3ee99a..c056ccff3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinState.java @@ -29,6 +29,7 @@ import org.whispersystems.signalservice.internal.contacts.crypto.Unauthenticated import java.io.IOException; import java.util.Arrays; +import java.util.Collections; import java.util.Locale; import java.util.Optional; import java.util.concurrent.TimeUnit; diff --git a/app/src/main/proto/ExternalBackups.proto b/app/src/main/proto/ExternalBackups.proto new file mode 100644 index 000000000..a59f54974 --- /dev/null +++ b/app/src/main/proto/ExternalBackups.proto @@ -0,0 +1,16 @@ +/** + * Copyright (C) 2023 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.absbackup"; +option java_outer_classname = "ExternalBackupProtos"; + +message KbsAuthToken { + repeated string token = 1; +} \ No newline at end of file diff --git a/core-util/src/main/java/org/signal/core/util/Serializer.kt b/core-util/src/main/java/org/signal/core/util/Serializer.kt index 07d7ee476..5521d1d7f 100644 --- a/core-util/src/main/java/org/signal/core/util/Serializer.kt +++ b/core-util/src/main/java/org/signal/core/util/Serializer.kt @@ -15,3 +15,15 @@ interface IntSerializer : Serializer interface LongSerializer : Serializer interface ByteSerializer : Serializer + +object StringStringSerializer : StringSerializer { + + override fun serialize(data: String?): String { + return data ?: "" + } + + override fun deserialize(data: String): String { + return data + } + +} \ No newline at end of file diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java index 47bd3a604..1b36ffe9a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java @@ -31,6 +31,10 @@ public final class MasterKey { return Hex.toStringCondensed(derive("Registration Lock")); } + public String deriveRegistrationRecoveryToken() { + return Hex.toStringCondensed(derive("Registration Recovery")); + } + public StorageKey deriveStorageServiceKey() { return new StorageKey(derive("Storage Service Encryption")); }