diff --git a/CHANGELOG.md b/CHANGELOG.md index e90d3082..eaab25ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] ### Added +- Address book encryption - The device authentication feature on the Zashi app launch has been added - Zashi app now supports Spanish language - The Flexa SDK has been adopted to enable payments using the embedded Flexa UI diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index 663fd1b8..39b3ff55 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -10,6 +10,7 @@ directly impact users rather than highlighting other key architectural updates.* ## [Unreleased] ### Added +- Address book encryption - The device authentication feature on the Zashi app launch has been added - Zashi app now supports Spanish language - The Flexa SDK has been adopted to enable payments using the embedded Flexa UI diff --git a/docs/whatsNew/WHATS_NEW_ES.md b/docs/whatsNew/WHATS_NEW_ES.md index 104f7e25..5474ed77 100644 --- a/docs/whatsNew/WHATS_NEW_ES.md +++ b/docs/whatsNew/WHATS_NEW_ES.md @@ -10,6 +10,7 @@ directly impact users rather than highlighting other key architectural updates.* ## [Unreleased] ### Added +- Address book encryption - The device authentication feature on the Zashi app launch has been added - Zashi app now supports Spanish language - The Flexa SDK has been adopted to enable payments using the embedded Flexa UI diff --git a/gradle.properties b/gradle.properties index f2827ade..18f998e0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -211,6 +211,7 @@ PLAY_APP_UPDATE_VERSION=2.1.0 PLAY_APP_UPDATE_KTX_VERSION=2.1.0 PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0 PLAY_SERVICES_AUTH_VERSION=21.2.0 +TINK_VERSION=1.15.0 ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0 ZXING_VERSION=3.5.3 ZIP_321_VERSION = 0.0.6 diff --git a/settings.gradle.kts b/settings.gradle.kts index 24e3250b..93f0ff50 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -182,6 +182,7 @@ dependencyResolutionManagement { val markdownVersion = extra["MARKDOWN_VERSION"].toString() val playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString() val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString() + val tinkVersion = extra["TINK_VERSION"].toString() val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString() val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString() val zip321Version = extra["ZIP_321_VERSION"].toString() @@ -254,6 +255,7 @@ dependencyResolutionManagement { library("markdown", "org.jetbrains:markdown:$markdownVersion") library("play-update", "com.google.android.play:app-update:$playAppUpdateVersion") library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion") + library("tink", "com.google.crypto.tink:tink-android:$tinkVersion") library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion") library("zcash-sdk-incubator", "cash.z.ecc.android:zcash-android-sdk-incubator:$zcashSdkVersion") library("zcash-bip39", "cash.z.ecc.android:kotlin-bip39:$zcashBip39Version") diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 8380d9d1..804eb949 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -138,6 +138,7 @@ dependencies { implementation(libs.zcash.sdk) implementation(libs.zcash.sdk.incubator) implementation(libs.zcash.bip39) + implementation(libs.tink) implementation(libs.zxing) api(libs.flexa.core) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt index 085e7745..e0ee1f7d 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt @@ -1,5 +1,7 @@ package co.electriccoin.zcash.di +import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProvider +import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProviderImpl import co.electriccoin.zcash.ui.common.provider.AddressBookProvider import co.electriccoin.zcash.ui.common.provider.AddressBookProviderImpl import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider @@ -20,4 +22,5 @@ val providerModule = factoryOf(::GetMonetarySeparatorProvider) factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class + factoryOf(::AddressBookKeyStorageProviderImpl) bind AddressBookKeyStorageProvider::class } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index 4415f52f..118e7003 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -1,7 +1,6 @@ package co.electriccoin.zcash.di import co.electriccoin.zcash.ui.common.usecase.CopyToClipboardUseCase -import co.electriccoin.zcash.ui.common.usecase.DeleteAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.DeleteContactUseCase import co.electriccoin.zcash.ui.common.usecase.GetAddressesUseCase import co.electriccoin.zcash.ui.common.usecase.GetBackupPersistableWalletUseCase @@ -28,6 +27,7 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase +import co.electriccoin.zcash.ui.common.usecase.ResetAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase import co.electriccoin.zcash.ui.common.usecase.SendEmailUseCase import co.electriccoin.zcash.ui.common.usecase.SendSupportEmailUseCase @@ -59,7 +59,7 @@ val useCaseModule = factoryOf(::RescanBlockchainUseCase) factoryOf(::GetTransparentAddressUseCase) factoryOf(::ObserveAddressBookContactsUseCase) - factoryOf(::DeleteAddressBookUseCase) + factoryOf(::ResetAddressBookUseCase) factoryOf(::ValidateContactAddressUseCase) factoryOf(::ValidateContactNameUseCase) factoryOf(::SaveContactUseCase) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt index 8afe1000..3fe1d173 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/datasource/LocalAddressBookDataSource.kt @@ -1,33 +1,46 @@ package co.electriccoin.zcash.ui.common.datasource +import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.io.deleteSuspend import co.electriccoin.zcash.ui.common.model.AddressBook import co.electriccoin.zcash.ui.common.model.AddressBookContact import co.electriccoin.zcash.ui.common.provider.AddressBookProvider import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider +import co.electriccoin.zcash.ui.common.serialization.addressbook.ADDRESS_BOOK_SERIALIZATION_V1 +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock +import java.io.IOException +import java.security.GeneralSecurityException interface LocalAddressBookDataSource { - suspend fun getContacts(): AddressBook + suspend fun getContacts(addressBookKey: AddressBookKey): AddressBook suspend fun saveContact( name: String, - address: String + address: String, + addressBookKey: AddressBookKey ): AddressBook suspend fun updateContact( contact: AddressBookContact, name: String, - address: String + address: String, + addressBookKey: AddressBookKey ): AddressBook - suspend fun deleteContact(addressBookContact: AddressBookContact): AddressBook + suspend fun deleteContact( + addressBookContact: AddressBookContact, + addressBookKey: AddressBookKey + ): AddressBook - suspend fun saveContacts(contacts: AddressBook) + suspend fun saveAddressBook( + addressBook: AddressBook, + addressBookKey: AddressBookKey + ) - suspend fun deleteAddressBook() + suspend fun resetAddressBook() } class LocalAddressBookDataSourceImpl( @@ -36,20 +49,20 @@ class LocalAddressBookDataSourceImpl( ) : LocalAddressBookDataSource { private var addressBook: AddressBook? = null - override suspend fun getContacts(): AddressBook = + override suspend fun getContacts(addressBookKey: AddressBookKey): AddressBook = withContext(Dispatchers.IO) { val addressBook = this@LocalAddressBookDataSourceImpl.addressBook if (addressBook == null) { - var newAddressBook: AddressBook? = readLocalFileToAddressBook() + var newAddressBook: AddressBook? = readLocalFileToAddressBook(addressBookKey) if (newAddressBook == null) { newAddressBook = AddressBook( lastUpdated = Clock.System.now(), - version = 1, + version = ADDRESS_BOOK_SERIALIZATION_V1, contacts = emptyList(), ) - writeAddressBookToLocalStorage(newAddressBook) + writeAddressBookToLocalStorage(newAddressBook, addressBookKey) } newAddressBook } else { @@ -59,14 +72,15 @@ class LocalAddressBookDataSourceImpl( override suspend fun saveContact( name: String, - address: String + address: String, + addressBookKey: AddressBookKey ): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - addressBook = + val newAddressBook = AddressBook( lastUpdated = lastUpdated, - version = 1, + version = ADDRESS_BOOK_SERIALIZATION_V1, contacts = addressBook?.contacts.orEmpty() + AddressBookContact( @@ -75,21 +89,22 @@ class LocalAddressBookDataSourceImpl( lastUpdated = lastUpdated, ), ) - writeAddressBookToLocalStorage(addressBook!!) - addressBook!! + writeAddressBookToLocalStorage(newAddressBook, addressBookKey) + newAddressBook } override suspend fun updateContact( contact: AddressBookContact, name: String, - address: String + address: String, + addressBookKey: AddressBookKey ): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - addressBook = + val newAddressBook = AddressBook( lastUpdated = lastUpdated, - version = 1, + version = ADDRESS_BOOK_SERIALIZATION_V1, contacts = addressBook?.contacts.orEmpty().toMutableList() .apply { @@ -104,17 +119,20 @@ class LocalAddressBookDataSourceImpl( } .toList(), ) - writeAddressBookToLocalStorage(addressBook!!) - addressBook!! + writeAddressBookToLocalStorage(newAddressBook, addressBookKey) + newAddressBook } - override suspend fun deleteContact(addressBookContact: AddressBookContact): AddressBook = + override suspend fun deleteContact( + addressBookContact: AddressBookContact, + addressBookKey: AddressBookKey + ): AddressBook = withContext(Dispatchers.IO) { val lastUpdated = Clock.System.now() - addressBook = + val newAddressBook = AddressBook( lastUpdated = lastUpdated, - version = 1, + version = ADDRESS_BOOK_SERIALIZATION_V1, contacts = addressBook?.contacts.orEmpty().toMutableList() .apply { @@ -122,27 +140,58 @@ class LocalAddressBookDataSourceImpl( } .toList(), ) - writeAddressBookToLocalStorage(addressBook!!) - addressBook!! + writeAddressBookToLocalStorage(newAddressBook, addressBookKey) + newAddressBook } - override suspend fun saveContacts(contacts: AddressBook) { - writeAddressBookToLocalStorage(contacts) - this@LocalAddressBookDataSourceImpl.addressBook = contacts + override suspend fun saveAddressBook( + addressBook: AddressBook, + addressBookKey: AddressBookKey + ) { + writeAddressBookToLocalStorage(addressBook, addressBookKey) + this.addressBook = addressBook } - override suspend fun deleteAddressBook() { - addressBookStorageProvider.getStorageFile()?.deleteSuspend() + override suspend fun resetAddressBook() { addressBook = null } - private fun readLocalFileToAddressBook(): AddressBook? { - val file = addressBookStorageProvider.getStorageFile() ?: return null - return addressBookProvider.readAddressBookFromFile(file) + @Suppress("ReturnCount") + private suspend fun readLocalFileToAddressBook(addressBookKey: AddressBookKey): AddressBook? { + val encryptedFile = addressBookStorageProvider.getStorageFile(addressBookKey) + val unencryptedFile = addressBookStorageProvider.getLegacyUnencryptedStorageFile() + + if (encryptedFile != null) { + return try { + addressBookProvider.readAddressBookFromFile(encryptedFile, addressBookKey) + .also { + unencryptedFile?.deleteSuspend() + } + } catch (e: GeneralSecurityException) { + Twig.warn(e) { "Failed to decrypt address book" } + null + } catch (e: IOException) { + Twig.warn(e) { "Failed to decrypt address book" } + null + } + } + + return if (unencryptedFile != null) { + addressBookProvider.readLegacyUnencryptedAddressBookFromFile(unencryptedFile) + .also { unencryptedAddressBook -> + writeAddressBookToLocalStorage(unencryptedAddressBook, addressBookKey) + unencryptedFile.deleteSuspend() + } + } else { + null + } } - private fun writeAddressBookToLocalStorage(addressBook: AddressBook) { - val file = addressBookStorageProvider.getOrCreateStorageFile() - addressBookProvider.writeAddressBookToFile(file, addressBook) + private fun writeAddressBookToLocalStorage( + addressBook: AddressBook, + addressBookKey: AddressBookKey + ) { + val file = addressBookStorageProvider.getOrCreateStorageFile(addressBookKey) + addressBookProvider.writeAddressBookToFile(file, addressBook, addressBookKey) } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookKeyStorageProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookKeyStorageProvider.kt new file mode 100644 index 00000000..42a38197 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookKeyStorageProvider.kt @@ -0,0 +1,62 @@ +package co.electriccoin.zcash.ui.common.provider + +import co.electriccoin.zcash.preference.EncryptedPreferenceProvider +import co.electriccoin.zcash.preference.api.PreferenceProvider +import co.electriccoin.zcash.preference.model.entry.PreferenceDefault +import co.electriccoin.zcash.preference.model.entry.PreferenceKey +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey +import com.google.crypto.tink.InsecureSecretKeyAccess +import com.google.crypto.tink.SecretKeyAccess +import com.google.crypto.tink.util.SecretBytes +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +interface AddressBookKeyStorageProvider { + suspend fun getAddressBookKey(): AddressBookKey? + + suspend fun storeAddressBookKey(addressBookKey: AddressBookKey) +} + +class AddressBookKeyStorageProviderImpl( + private val encryptedPreferenceProvider: EncryptedPreferenceProvider +) : AddressBookKeyStorageProvider { + private val default = AddressBookKeyPreferenceDefault() + + override suspend fun getAddressBookKey(): AddressBookKey? { + return default.getValue(encryptedPreferenceProvider()) + } + + override suspend fun storeAddressBookKey(addressBookKey: AddressBookKey) { + default.putValue(encryptedPreferenceProvider(), addressBookKey) + } +} + +private class AddressBookKeyPreferenceDefault : PreferenceDefault { + private val secretKeyAccess: SecretKeyAccess? + get() = InsecureSecretKeyAccess.get() + + override val key: PreferenceKey = PreferenceKey("address_book_key") + + override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.decode() + + override suspend fun putValue( + preferenceProvider: PreferenceProvider, + newValue: AddressBookKey? + ) = preferenceProvider.putString(key, newValue?.encode()) + + @OptIn(ExperimentalEncodingApi::class) + private fun AddressBookKey?.encode() = + if (this != null) { + Base64.encode(this.key.toByteArray(secretKeyAccess)) + } else { + null + } + + @OptIn(ExperimentalEncodingApi::class) + private fun String?.decode() = + if (this != null) { + AddressBookKey(SecretBytes.copyFrom(Base64.decode(this), secretKeyAccess)) + } else { + null + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt index 2906bff4..4a5a0375 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookProvider.kt @@ -1,100 +1,63 @@ package co.electriccoin.zcash.ui.common.provider import co.electriccoin.zcash.ui.common.model.AddressBook -import co.electriccoin.zcash.ui.common.model.AddressBookContact -import kotlinx.datetime.Instant +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookEncryptor +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookSerializer import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder +import kotlin.LazyThreadSafetyMode.NONE interface AddressBookProvider { fun writeAddressBookToFile( file: File, - addressBook: AddressBook + addressBook: AddressBook, + addressBookKey: AddressBookKey ) - fun readAddressBookFromFile(file: File): AddressBook + fun readAddressBookFromFile( + file: File, + addressBookKey: AddressBookKey + ): AddressBook + + fun readLegacyUnencryptedAddressBookFromFile(file: File): AddressBook } class AddressBookProviderImpl : AddressBookProvider { + private val addressBookSerializer by lazy(NONE) { AddressBookSerializer() } + private val addressBookEncryptor by lazy(NONE) { AddressBookEncryptor() } + override fun writeAddressBookToFile( file: File, - addressBook: AddressBook + addressBook: AddressBook, + addressBookKey: AddressBookKey ) { - file.outputStream().use { - serializeAddressBookToByteArrayFile(it, addressBook) + file.outputStream().buffered().use { stream -> + addressBookEncryptor.encryptAddressBook( + addressBookKey, + addressBookSerializer, + stream, + addressBook + ) + stream.flush() } } - override fun readAddressBookFromFile(file: File): AddressBook { - return file.inputStream().use { - deserializeByteArrayFileToAddressBook(it) - } - } - - private fun serializeAddressBookToByteArrayFile( - outputStream: FileOutputStream, - addressBook: AddressBook - ) { - outputStream.buffered().use { - it.write(addressBook.version.createByteArray()) - it.write(addressBook.lastUpdated.toEpochMilliseconds().createByteArray()) - it.write(addressBook.contacts.size.createByteArray()) - - addressBook.contacts.forEach { contact -> - it.write(contact.lastUpdated.toEpochMilliseconds().createByteArray()) - it.write(contact.address.createByteArray()) - it.write(contact.name.createByteArray()) - } - } - } - - private fun deserializeByteArrayFileToAddressBook(inputStream: InputStream): AddressBook { - return inputStream.buffered().use { stream -> - AddressBook( - version = stream.readInt(), - lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, - contacts = - stream.readInt().let { contactsSize -> - (0 until contactsSize).map { _ -> - AddressBookContact( - lastUpdated = stream.readLong().let { Instant.fromEpochMilliseconds(it) }, - address = stream.readString(), - name = stream.readString(), - ) - } - } + override fun readAddressBookFromFile( + file: File, + addressBookKey: AddressBookKey + ): AddressBook { + return file.inputStream().use { stream -> + addressBookEncryptor.decryptAddressBook( + addressBookKey, + addressBookSerializer, + stream ) } } - private fun Int.createByteArray(): ByteArray = this.toLong().createByteArray() - - private fun Long.createByteArray(): ByteArray = - ByteBuffer - .allocate(Long.SIZE_BYTES).order(BYTE_ORDER).putLong(this).array() - - private fun String.createByteArray(): ByteArray { - val byteArray = this.toByteArray() - return byteArray.size.createByteArray() + byteArray - } - - private fun InputStream.readInt(): Int = readLong().toInt() - - private fun InputStream.readLong(): Long { - val buffer = ByteArray(Long.SIZE_BYTES) - this.read(buffer) - return ByteBuffer.wrap(buffer).order(BYTE_ORDER).getLong() - } - - private fun InputStream.readString(): String { - val size = this.readInt() - val buffer = ByteArray(size) - this.read(buffer) - return String(buffer) + override fun readLegacyUnencryptedAddressBookFromFile(file: File): AddressBook { + return file.inputStream().use { stream -> + addressBookSerializer.deserializeAddressBook(stream) + } } } - -private val BYTE_ORDER = ByteOrder.BIG_ENDIAN diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt index 52b6a98e..69b8dcd6 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/AddressBookStorageProvider.kt @@ -1,12 +1,15 @@ package co.electriccoin.zcash.ui.common.provider import android.content.Context +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey import java.io.File interface AddressBookStorageProvider { - fun getStorageFile(): File? + fun getStorageFile(addressBookKey: AddressBookKey): File? - fun getOrCreateStorageFile(): File + fun getLegacyUnencryptedStorageFile(): File? + + fun getOrCreateStorageFile(addressBookKey: AddressBookKey): File /** * Create a temporary file into which data from remote is written. This file is removed after usage. @@ -17,12 +20,19 @@ interface AddressBookStorageProvider { class AddressBookStorageProviderImpl( private val context: Context ) : AddressBookStorageProvider { - override fun getStorageFile(): File? { - return File(context.noBackupFilesDir, LOCAL_ADDRESS_BOOK_FILE_NAME) + override fun getStorageFile(addressBookKey: AddressBookKey): File? { + return File(context.noBackupFilesDir, addressBookKey.fileIdentifier()) .takeIf { it.exists() && it.isFile } } - override fun getOrCreateStorageFile(): File = getOrCreateFile(LOCAL_ADDRESS_BOOK_FILE_NAME) + override fun getLegacyUnencryptedStorageFile(): File? { + return File(context.noBackupFilesDir, LEGACY_UNENCRYPTED_ADDRESS_BOOK_FILE_NAME) + .takeIf { it.exists() && it.isFile } + } + + override fun getOrCreateStorageFile(addressBookKey: AddressBookKey): File { + return getOrCreateFile(addressBookKey.fileIdentifier()) + } override fun getOrCreateTempStorageFile(): File = getOrCreateFile(REMOTE_ADDRESS_BOOK_FILE_NAME_LOCAL_COPY) @@ -35,5 +45,5 @@ class AddressBookStorageProviderImpl( } } -private const val LOCAL_ADDRESS_BOOK_FILE_NAME = "address_book" +private const val LEGACY_UNENCRYPTED_ADDRESS_BOOK_FILE_NAME = "address_book" private const val REMOTE_ADDRESS_BOOK_FILE_NAME_LOCAL_COPY = "address_book_temp" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt index 23d397f8..96650b54 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/AddressBookRepository.kt @@ -1,11 +1,12 @@ -@file:Suppress("DEPRECATION") - package co.electriccoin.zcash.ui.common.repository +import cash.z.ecc.android.sdk.model.Account import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.ui.common.datasource.LocalAddressBookDataSource import co.electriccoin.zcash.ui.common.model.AddressBook import co.electriccoin.zcash.ui.common.model.AddressBookContact +import co.electriccoin.zcash.ui.common.provider.AddressBookKeyStorageProvider +import co.electriccoin.zcash.ui.common.serialization.addressbook.AddressBookKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow @@ -38,7 +39,7 @@ interface AddressBookRepository { suspend fun deleteContact(contact: AddressBookContact) - suspend fun deleteAddressBook() + suspend fun resetAddressBook() // fun onGoogleSignInSuccess() // @@ -50,6 +51,8 @@ interface AddressBookRepository { @Suppress("TooManyFunctions") class AddressBookRepositoryImpl( private val localAddressBookDataSource: LocalAddressBookDataSource, + private val addressBookKeyStorageProvider: AddressBookKeyStorageProvider, + private val walletRepository: WalletRepository, // private val remoteAddressBookDataSource: RemoteAddressBookDataSource, // private val context: Context ) : AddressBookRepository { @@ -92,9 +95,9 @@ class AddressBookRepositoryImpl( InternalOperation.Delete(contact = contact) ) - override suspend fun deleteAddressBook() = + override suspend fun resetAddressBook() = withNonCancellableSemaphore { - localAddressBookDataSource.deleteAddressBook() + localAddressBookDataSource.resetAddressBook() addressBookCache.update { null } } @@ -150,12 +153,18 @@ class AddressBookRepositoryImpl( // } val merged = mergeContacts( - local = localAddressBookDataSource.getContacts(), + local = + localAddressBookDataSource.getContacts( + addressBookKey = getAddressBookKey() + ), // remote = remote, remote = null, fromOperation = operation ) - localAddressBookDataSource.saveContacts(merged) + localAddressBookDataSource.saveAddressBook( + addressBook = merged, + addressBookKey = getAddressBookKey() + ) // executeRemoteAddressBookSafe { // remoteAddressBookDataSource.uploadContacts() // Twig.info { "Address Book: ensureSynchronization - remote address book uploaded" } @@ -232,12 +241,19 @@ class AddressBookRepositoryImpl( when (operation) { is InternalOperation.Delete -> { Twig.info { "Address Book: executeInternalOperation - delete" } - localAddressBookDataSource.deleteContact(addressBookContact = operation.contact) + localAddressBookDataSource.deleteContact( + addressBookContact = operation.contact, + addressBookKey = getAddressBookKey() + ) } is InternalOperation.Save -> { Twig.info { "Address Book: executeInternalOperation - save" } - localAddressBookDataSource.saveContact(name = operation.name, address = operation.address) + localAddressBookDataSource.saveContact( + name = operation.name, + address = operation.address, + addressBookKey = getAddressBookKey() + ) } is InternalOperation.Update -> { @@ -245,7 +261,8 @@ class AddressBookRepositoryImpl( localAddressBookDataSource.updateContact( contact = operation.contact, name = operation.name, - address = operation.address + address = operation.address, + addressBookKey = getAddressBookKey() ) } } @@ -263,6 +280,24 @@ class AddressBookRepositoryImpl( } } + private suspend fun getAddressBookKey(account: Account = Account.DEFAULT): AddressBookKey { + val key = addressBookKeyStorageProvider.getAddressBookKey() + + return if (key != null) { + key + } else { + val wallet = walletRepository.getPersistableWallet() + val newKey = + AddressBookKey.derive( + seedPhrase = wallet.seedPhrase, + network = wallet.network, + account = account + ) + addressBookKeyStorageProvider.storeAddressBookKey(newKey) + newKey + } + } + // @Suppress("TooGenericExceptionCaught") // private suspend fun executeRemoteAddressBookSafe(block: suspend () -> T): T? { // if (hasGoogleDrivePermission().not()) { diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt index bd7943a0..10c165cb 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/repository/WalletRepository.kt @@ -99,6 +99,8 @@ interface WalletRepository { suspend fun getAllServers(): List suspend fun getSynchronizer(): Synchronizer + + suspend fun getPersistableWallet(): PersistableWallet } class WalletRepositoryImpl( @@ -355,6 +357,8 @@ class WalletRepositoryImpl( } override suspend fun getSynchronizer(): Synchronizer = synchronizer.filterNotNull().first() + + override suspend fun getPersistableWallet(): PersistableWallet = persistableWallet.filterNotNull().first() } private fun Synchronizer.toCommonError(): Flow = diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt new file mode 100644 index 00000000..19a3545e --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookEncryptor.kt @@ -0,0 +1,61 @@ +package co.electriccoin.zcash.ui.common.serialization.addressbook + +import co.electriccoin.zcash.ui.common.model.AddressBook +import com.google.crypto.tink.subtle.ChaCha20Poly1305 +import com.google.crypto.tink.subtle.Random +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream + +internal class AddressBookEncryptor : BaseAddressBookSerializer() { + fun encryptAddressBook( + addressBookKey: AddressBookKey, + serializer: AddressBookSerializer, + outputStream: OutputStream, + addressBook: AddressBook + ) { + // Generate a fresh one-time key for this ciphertext. + val salt = Random.randBytes(ADDRESS_BOOK_SALT_SIZE) + val cipherText = + ByteArrayOutputStream() + .use { stream -> + serializer.serializeAddressBook(stream, addressBook) + stream.toByteArray() + }.let { + val key = addressBookKey.deriveEncryptionKey(salt) + // Tink encodes the ciphertext as `nonce || ciphertext || tag`. + val cipher = ChaCha20Poly1305.create(key) + cipher.encrypt(it, null) + } + + outputStream.write(ADDRESS_BOOK_ENCRYPTION_V1.createByteArray()) + outputStream.write(salt) + outputStream.write(cipherText) + } + + fun decryptAddressBook( + addressBookKey: AddressBookKey, + serializer: AddressBookSerializer, + inputStream: InputStream + ): AddressBook { + val version = inputStream.readInt() + if (version != ADDRESS_BOOK_ENCRYPTION_V1) { + throw UnknownAddressBookEncryptionVersionException() + } + + val salt = ByteArray(ADDRESS_BOOK_SALT_SIZE) + require(inputStream.read(salt) == salt.size) { "Input is too short" } + + val ciphertext = inputStream.readBytes() + + val key = addressBookKey.deriveEncryptionKey(salt) + val cipher = ChaCha20Poly1305.create(key) + val plaintext = cipher.decrypt(ciphertext, null) + + return plaintext.inputStream().use { stream -> + serializer.deserializeAddressBook(stream) + } + } +} + +class UnknownAddressBookEncryptionVersionException : RuntimeException("Unknown address book encryption version") diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookKey.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookKey.kt new file mode 100644 index 00000000..30d1d7ea --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookKey.kt @@ -0,0 +1,77 @@ +package co.electriccoin.zcash.ui.common.serialization.addressbook + +import cash.z.ecc.android.sdk.model.Account +import cash.z.ecc.android.sdk.model.SeedPhrase +import cash.z.ecc.android.sdk.model.ZcashNetwork +import cash.z.ecc.android.sdk.tool.DerivationTool +import com.google.crypto.tink.InsecureSecretKeyAccess +import com.google.crypto.tink.aead.ChaCha20Poly1305Key +import com.google.crypto.tink.subtle.Hkdf +import com.google.crypto.tink.util.SecretBytes + +/** + * The long-term key that can decrypt an account's encrypted address book. + */ +class AddressBookKey(val key: SecretBytes) { + /** + * Derives the filename that this key is able to decrypt. + */ + @OptIn(ExperimentalStdlibApi::class) + fun fileIdentifier(): String { + val access = InsecureSecretKeyAccess.get() + val fileIdentifier = + Hkdf.computeHkdf( + "HMACSHA256", + key.toByteArray(access), + null, + "file_identifier".toByteArray(), + ADDRESS_BOOK_FILE_IDENTIFIER_SIZE + ) + return "zashi-address-book-" + fileIdentifier.toHexString() + } + + /** + * Derives a one-time address book encryption key. + * + * At encryption time, the one-time property MUST be ensured by generating a + * random 32-byte salt. + */ + fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key { + assert(salt.size == ADDRESS_BOOK_SALT_SIZE) + val access = InsecureSecretKeyAccess.get() + val subKey = + Hkdf.computeHkdf( + "HMACSHA256", + key.toByteArray(access), + null, + salt + "encryption_key".toByteArray(), + ADDRESS_BOOK_ENCRYPTION_KEY_SIZE + ) + return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access)) + } + + companion object { + /** + * Derives the long-term key that can decrypt the given account's encrypted + * address book. + * + * This requires access to the seed phrase. If the app has separate access + * control requirements for the seed phrase and the address book, this key + * should be cached in the app's keystore. + */ + suspend fun derive( + seedPhrase: SeedPhrase, + network: ZcashNetwork, + account: Account + ): AddressBookKey { + val key = + DerivationTool.getInstance().deriveArbitraryAccountKey( + "ZashiAddressBookEncryptionV1".toByteArray(), + seedPhrase.toByteArray(), + network, + account, + ) + return AddressBookKey(SecretBytes.copyFrom(key, InsecureSecretKeyAccess.get())) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookSerializer.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookSerializer.kt new file mode 100644 index 00000000..38814258 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/AddressBookSerializer.kt @@ -0,0 +1,41 @@ +package co.electriccoin.zcash.ui.common.serialization.addressbook + +import co.electriccoin.zcash.ui.common.model.AddressBook +import co.electriccoin.zcash.ui.common.model.AddressBookContact +import kotlinx.datetime.Instant +import java.io.InputStream +import java.io.OutputStream + +internal class AddressBookSerializer : BaseAddressBookSerializer() { + fun serializeAddressBook( + outputStream: OutputStream, + addressBook: AddressBook + ) { + outputStream.write(addressBook.version.createByteArray()) + outputStream.write(addressBook.lastUpdated.toEpochMilliseconds().createByteArray()) + outputStream.write(addressBook.contacts.size.createByteArray()) + + addressBook.contacts.forEach { contact -> + outputStream.write(contact.lastUpdated.toEpochMilliseconds().createByteArray()) + outputStream.write(contact.address.createByteArray()) + outputStream.write(contact.name.createByteArray()) + } + } + + fun deserializeAddressBook(inputStream: InputStream): AddressBook { + return AddressBook( + version = inputStream.readInt(), + lastUpdated = inputStream.readLong().let { Instant.fromEpochMilliseconds(it) }, + contacts = + inputStream.readInt().let { contactsSize -> + (0 until contactsSize).map { _ -> + AddressBookContact( + lastUpdated = inputStream.readLong().let { Instant.fromEpochMilliseconds(it) }, + address = inputStream.readString(), + name = inputStream.readString(), + ) + } + } + ) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/BaseAddressBookSerializer.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/BaseAddressBookSerializer.kt new file mode 100644 index 00000000..997c7e32 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/BaseAddressBookSerializer.kt @@ -0,0 +1,36 @@ +package co.electriccoin.zcash.ui.common.serialization.addressbook + +import java.io.InputStream +import java.nio.ByteBuffer + +internal abstract class BaseAddressBookSerializer { + protected fun Int.createByteArray(): ByteArray { + return this.toLong().createByteArray() + } + + protected fun Long.createByteArray(): ByteArray { + return ByteBuffer.allocate(Long.SIZE_BYTES).order(ADDRESS_BOOK_BYTE_ORDER).putLong(this).array() + } + + protected fun String.createByteArray(): ByteArray { + val byteArray = this.toByteArray() + return byteArray.size.createByteArray() + byteArray + } + + protected fun InputStream.readInt(): Int { + return readLong().toInt() + } + + protected fun InputStream.readLong(): Long { + val buffer = ByteArray(Long.SIZE_BYTES) + require(this.read(buffer) == buffer.size) + return ByteBuffer.wrap(buffer).order(ADDRESS_BOOK_BYTE_ORDER).getLong() + } + + protected fun InputStream.readString(): String { + val size = this.readInt() + val buffer = ByteArray(size) + require(this.read(buffer) == buffer.size) + return String(buffer) + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/Constants.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/Constants.kt new file mode 100644 index 00000000..e7e121fe --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/serialization/addressbook/Constants.kt @@ -0,0 +1,10 @@ +package co.electriccoin.zcash.ui.common.serialization.addressbook + +import java.nio.ByteOrder + +internal const val ADDRESS_BOOK_SERIALIZATION_V1 = 1 +internal const val ADDRESS_BOOK_ENCRYPTION_V1 = 1 +internal const val ADDRESS_BOOK_ENCRYPTION_KEY_SIZE = 32 +internal const val ADDRESS_BOOK_FILE_IDENTIFIER_SIZE = 32 +internal const val ADDRESS_BOOK_SALT_SIZE = 32 +internal val ADDRESS_BOOK_BYTE_ORDER = ByteOrder.BIG_ENDIAN diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteAddressBookUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteAddressBookUseCase.kt deleted file mode 100644 index 82ce8953..00000000 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/DeleteAddressBookUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package co.electriccoin.zcash.ui.common.usecase - -import co.electriccoin.zcash.ui.common.repository.AddressBookRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class DeleteAddressBookUseCase( - private val addressBookRepository: AddressBookRepository -) { - suspend operator fun invoke() = - withContext(Dispatchers.IO) { - addressBookRepository.deleteAddressBook() - } -} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt index 2b90e2a9..7cd43ba0 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/GetPersistableWalletUseCase.kt @@ -1,14 +1,9 @@ package co.electriccoin.zcash.ui.common.usecase import co.electriccoin.zcash.ui.common.repository.WalletRepository -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first class GetPersistableWalletUseCase( private val walletRepository: WalletRepository ) { - suspend operator fun invoke() = - walletRepository.persistableWallet - .filterNotNull() - .first() + suspend operator fun invoke() = walletRepository.getPersistableWallet() } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetAddressBookUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetAddressBookUseCase.kt new file mode 100644 index 00000000..4f58b31d --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ResetAddressBookUseCase.kt @@ -0,0 +1,9 @@ +package co.electriccoin.zcash.ui.common.usecase + +import co.electriccoin.zcash.ui.common.repository.AddressBookRepository + +class ResetAddressBookUseCase( + private val addressBookRepository: AddressBookRepository +) { + suspend operator fun invoke() = addressBookRepository.resetAddressBook() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt index a78405a4..ef6a54ea 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/viewmodel/WalletViewModel.kt @@ -24,9 +24,9 @@ import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider import co.electriccoin.zcash.ui.common.repository.BalanceRepository import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository import co.electriccoin.zcash.ui.common.repository.WalletRepository -import co.electriccoin.zcash.ui.common.usecase.DeleteAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase +import co.electriccoin.zcash.ui.common.usecase.ResetAddressBookUseCase import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt import co.electriccoin.zcash.ui.screen.account.ext.getSortHeight @@ -64,7 +64,7 @@ class WalletViewModel( private val encryptedPreferenceProvider: EncryptedPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider, private val getAvailableServers: GetDefaultServersProvider, - private val deleteAddressBookUseCase: DeleteAddressBookUseCase, + private val resetAddressBook: ResetAddressBookUseCase, private val isFlexaAvailable: IsFlexaAvailableUseCase, private val getSynchronizer: GetSynchronizerUseCase ) : AndroidViewModel(application) { @@ -227,7 +227,7 @@ class WalletViewModel( val encryptedPrefsCleared = encryptedPreferenceProvider() .clearPreferences() - deleteAddressBookUseCase() + resetAddressBook() Twig.info { "Both preferences cleared: ${standardPrefsCleared && encryptedPrefsCleared}" }