Address book encryption (#1643)

* Add Tink as a dependency

* Serialize AddressBook to any OutputStream

* Extract address book format parser

* Address book key storage provider

* Address book encryption finalisation

* Implement AddressBook encryption

* Address book encryption code cleanup

* Address book reset hotfix

* SDK snapshot

* Documentation update

* Code cleanup

* Test hotfix

* Error handling

* Code cleanup

* Unencrypted address book removed after successful encrypted file read

* Code cleanup

* Code cleanup

* Test hotfix

---------

Co-authored-by: Milan Cerovsky <milan@z.cash>
Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Jack Grigg 2024-11-15 08:14:12 +00:00 committed by GitHub
parent f59add8e3b
commit 6aee0e2469
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 500 additions and 152 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <T> executeRemoteAddressBookSafe(block: suspend () -> T): T? {
// if (hasGoogleDrivePermission().not()) {

View File

@ -99,6 +99,8 @@ interface WalletRepository {
suspend fun getAllServers(): List<LightWalletEndpoint>
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<SynchronizerError?> =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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