Metadata encryption keys (#1781)

* Metadata encryption keys

* Decryption hotfix

* Transaction detail bottom bar design hotfix

* Auto backup enabled for metadata
This commit is contained in:
Milan 2025-02-28 15:20:21 +01:00 committed by GitHub
parent 13695bbd97
commit 2c37d086f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 479 additions and 329 deletions

View File

@ -3,4 +3,7 @@
<include
domain="file"
path="address_book/." />
<include
domain="file"
path="metadata/." />
</full-backup-content>

View File

@ -4,5 +4,8 @@
<include
domain="file"
path="address_book/." />
<include
domain="file"
path="metadata/." />
</cloud-backup>
</data-extraction-rules>

View File

@ -11,6 +11,11 @@ interface PreferenceProvider {
value: String?
)
suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
)
suspend fun putLong(
key: PreferenceKey,
value: Long?
@ -20,6 +25,8 @@ interface PreferenceProvider {
suspend fun getString(key: PreferenceKey): String?
suspend fun getStringSet(key: PreferenceKey): Set<String>?
fun observe(key: PreferenceKey): Flow<String?>
suspend fun clearPreferences(): Boolean

View File

@ -15,6 +15,10 @@ class MockPreferenceProvider(
override suspend fun getString(key: PreferenceKey) = map[key.key]
override suspend fun getStringSet(key: PreferenceKey): Set<String>? {
TODO("Not yet implemented")
}
// For the mock implementation, does not support observability of changes
override fun observe(key: PreferenceKey): Flow<String?> = flow { emit(getString(key)) }
@ -32,6 +36,13 @@ class MockPreferenceProvider(
map[key.key] = value
}
override suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
) {
TODO("Not yet implemented")
}
override suspend fun putLong(
key: PreferenceKey,
value: Long?

View File

@ -59,6 +59,22 @@ class AndroidPreferenceProvider private constructor(
}
}
@SuppressLint("ApplySharedPref")
override suspend fun putStringSet(
key: PreferenceKey,
value: Set<String>?
) = withContext(dispatcher) {
mutex.withLock {
val editor = sharedPreferences.edit()
editor.putStringSet(key.key, value)
editor.commit()
Unit
}
}
@SuppressLint("ApplySharedPref")
override suspend fun putLong(
key: PreferenceKey,
@ -91,6 +107,11 @@ class AndroidPreferenceProvider private constructor(
sharedPreferences.getString(key.key, null)
}
override suspend fun getStringSet(key: PreferenceKey): Set<String>? =
withContext(dispatcher) {
sharedPreferences.getStringSet(key.key, null)
}
@SuppressLint("ApplySharedPref")
override suspend fun clearPreferences() =
withContext(dispatcher) {

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@ -22,6 +23,7 @@ import co.electriccoin.zcash.ui.design.util.stringRes
fun ZashiBottomBar(
isElevated: Boolean,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
@ -29,7 +31,9 @@ fun ZashiBottomBar(
color = ZashiColors.Surfaces.bgPrimary,
modifier = modifier,
) {
Column {
Column(
modifier = Modifier.padding(contentPadding),
) {
Spacer(modifier = Modifier.height(16.dp))
content()
Spacer(modifier = Modifier.height(24.dp))

View File

@ -36,13 +36,17 @@ fun Modifier.scaffoldScrollPadding(
)
@Stable
fun PaddingValues.asScaffoldPaddingValues() =
PaddingValues(
top = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg,
bottom = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl,
start = ZashiDimensions.Spacing.spacing3xl,
end = ZashiDimensions.Spacing.spacing3xl
)
fun PaddingValues.asScaffoldPaddingValues(
top: Dp = calculateTopPadding() + ZashiDimensions.Spacing.spacingLg,
bottom: Dp = calculateBottomPadding() + ZashiDimensions.Spacing.spacing3xl,
start: Dp = ZashiDimensions.Spacing.spacing3xl,
end: Dp = ZashiDimensions.Spacing.spacing3xl
) = PaddingValues(
top = top,
bottom = bottom,
start = start,
end = end,
)
@Stable
fun PaddingValues.asScaffoldScrollPaddingValues(

View File

@ -1,6 +1,5 @@
package co.electriccoin.zcash.ui.common.datasource
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.AccountMetadata
import co.electriccoin.zcash.ui.common.model.AnnotationMetadata
@ -8,6 +7,7 @@ import co.electriccoin.zcash.ui.common.model.BookmarkMetadata
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.provider.MetadataProvider
import co.electriccoin.zcash.ui.common.provider.MetadataStorageProvider
import co.electriccoin.zcash.ui.common.serialization.METADATA_SERIALIZATION_V1
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
@ -20,26 +20,22 @@ interface MetadataDataSource {
suspend fun flipTxAsBookmarked(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun createOrUpdateTxNote(
txId: String,
note: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun deleteTxNote(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
suspend fun markTxMemoAsRead(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata
@ -47,8 +43,6 @@ interface MetadataDataSource {
metadata: Metadata,
key: MetadataKey
)
suspend fun resetMetadata()
}
@Suppress("TooManyFunctions")
@ -56,8 +50,6 @@ class MetadataDataSourceImpl(
private val metadataStorageProvider: MetadataStorageProvider,
private val metadataProvider: MetadataProvider,
) : MetadataDataSource {
private var metadata: Metadata? = null
private val mutex = Mutex()
override suspend fun getMetadata(key: MetadataKey): Metadata =
@ -67,11 +59,10 @@ class MetadataDataSourceImpl(
override suspend fun flipTxAsBookmarked(
txId: String,
account: AccountUuid,
key: MetadataKey,
): Metadata =
mutex.withLock {
updateMetadataBookmark(txId = txId, account = account, key = key) {
updateMetadataBookmark(txId = txId, key = key) {
it.copy(
isBookmarked = !it.isBookmarked,
lastUpdated = Instant.now(),
@ -82,13 +73,11 @@ class MetadataDataSourceImpl(
override suspend fun createOrUpdateTxNote(
txId: String,
note: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadataAnnotation(
txId = txId,
account = account,
key = key
) {
it.copy(
@ -100,13 +89,11 @@ class MetadataDataSourceImpl(
override suspend fun deleteTxNote(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadataAnnotation(
txId = txId,
account = account,
key = key
) {
it.copy(
@ -118,12 +105,10 @@ class MetadataDataSourceImpl(
override suspend fun markTxMemoAsRead(
txId: String,
account: AccountUuid,
key: MetadataKey
): Metadata =
mutex.withLock {
updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
@ -138,14 +123,8 @@ class MetadataDataSourceImpl(
key: MetadataKey
) = mutex.withLock {
writeToLocalStorage(metadata, key)
this.metadata = metadata
}
override suspend fun resetMetadata() =
mutex.withLock {
metadata = null
}
private suspend fun getMetadataInternal(key: MetadataKey): Metadata {
fun readLocalFileToMetadata(key: MetadataKey): Metadata? {
val encryptedFile =
@ -158,46 +137,38 @@ class MetadataDataSourceImpl(
}
return withContext(Dispatchers.IO) {
val inMemory = metadata
if (inMemory == null) {
var new: Metadata? = readLocalFileToMetadata(key)
if (new == null) {
new =
Metadata(
version = 1,
lastUpdated = Instant.now(),
accountMetadata = emptyMap(),
).also {
this@MetadataDataSourceImpl.metadata = it
}
writeToLocalStorage(new, key)
}
new
} else {
inMemory
var new: Metadata? = readLocalFileToMetadata(key)
if (new == null) {
new =
Metadata(
version = METADATA_SERIALIZATION_V1,
lastUpdated = Instant.now(),
accountMetadata = defaultAccountMetadata(),
)
writeToLocalStorage(new, key)
}
new
}
}
private suspend fun writeToLocalStorage(
metadata: Metadata,
key: MetadataKey
) = withContext(Dispatchers.IO) {
runCatching {
val file = metadataStorageProvider.getOrCreateStorageFile(key)
metadataProvider.writeMetadataToFile(file, metadata, key)
}.onFailure { e -> Twig.warn(e) { "Failed to write address book" } }
) {
withContext(Dispatchers.IO) {
runCatching {
val file = metadataStorageProvider.getOrCreateStorageFile(key)
metadataProvider.writeMetadataToFile(file, metadata, key)
}.onFailure { e -> Twig.warn(e) { "Failed to write address book" } }
}
}
private suspend fun updateMetadataAnnotation(
txId: String,
account: AccountUuid,
key: MetadataKey,
transform: (AnnotationMetadata) -> AnnotationMetadata
): Metadata {
return updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
@ -217,12 +188,10 @@ class MetadataDataSourceImpl(
private suspend fun updateMetadataBookmark(
txId: String,
account: AccountUuid,
key: MetadataKey,
transform: (BookmarkMetadata) -> BookmarkMetadata
): Metadata {
return updateMetadata(
account = account,
key = key,
transform = { metadata ->
metadata.copy(
@ -240,30 +209,21 @@ class MetadataDataSourceImpl(
)
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun updateMetadata(
account: AccountUuid,
key: MetadataKey,
transform: (AccountMetadata) -> AccountMetadata
): Metadata {
return withContext(Dispatchers.IO) {
val metadata = getMetadataInternal(key)
val accountMetadata = metadata.accountMetadata[account.value.toHexString()] ?: defaultAccountMetadata()
val accountMetadata = metadata.accountMetadata
val updatedMetadata =
metadata.copy(
lastUpdated = Instant.now(),
accountMetadata =
metadata.accountMetadata
.toMutableMap()
.apply {
put(account.value.toHexString(), transform(accountMetadata))
}
.toMap()
accountMetadata = transform(accountMetadata)
)
this@MetadataDataSourceImpl.metadata = updatedMetadata
writeToLocalStorage(updatedMetadata, key)
updatedMetadata

View File

@ -13,7 +13,7 @@ data class Metadata(
@Serializable(InstantSerializer::class)
val lastUpdated: Instant,
@SerialName("accountMetadata")
val accountMetadata: Map<String, AccountMetadata>
val accountMetadata: AccountMetadata
)
@Serializable

View File

@ -2,8 +2,8 @@ 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.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import com.google.crypto.tink.InsecureSecretKeyAccess
import com.google.crypto.tink.SecretKeyAccess
@ -12,9 +12,12 @@ import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
interface MetadataKeyStorageProvider {
suspend fun get(): MetadataKey?
suspend fun get(account: WalletAccount): MetadataKey?
suspend fun store(key: MetadataKey)
suspend fun store(
key: MetadataKey,
account: WalletAccount
)
}
class MetadataKeyStorageProviderImpl(
@ -22,40 +25,74 @@ class MetadataKeyStorageProviderImpl(
) : MetadataKeyStorageProvider {
private val default = MetadataKeyPreferenceDefault()
override suspend fun get(): MetadataKey? {
return default.getValue(encryptedPreferenceProvider())
override suspend fun get(account: WalletAccount): MetadataKey? {
return default.getValue(
walletAccount = account,
preferenceProvider = encryptedPreferenceProvider(),
)
}
override suspend fun store(key: MetadataKey) {
default.putValue(encryptedPreferenceProvider(), key)
override suspend fun store(
key: MetadataKey,
account: WalletAccount
) {
default.putValue(
newValue = key,
walletAccount = account,
preferenceProvider = encryptedPreferenceProvider(),
)
}
}
private class MetadataKeyPreferenceDefault : PreferenceDefault<MetadataKey?> {
private class MetadataKeyPreferenceDefault {
private val secretKeyAccess: SecretKeyAccess?
get() = InsecureSecretKeyAccess.get()
override val key: PreferenceKey = PreferenceKey("metadata_key")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.decode()
override suspend fun putValue(
suspend fun getValue(
walletAccount: WalletAccount,
preferenceProvider: PreferenceProvider,
newValue: MetadataKey?
) = preferenceProvider.putString(key, newValue?.encode())
): MetadataKey? {
return preferenceProvider.getStringSet(
key = getKey(walletAccount)
)?.decode()
}
suspend fun putValue(
newValue: MetadataKey?,
walletAccount: WalletAccount,
preferenceProvider: PreferenceProvider,
) {
preferenceProvider.putStringSet(
key = getKey(walletAccount),
value = newValue?.encode()
)
}
@OptIn(ExperimentalStdlibApi::class)
private fun getKey(walletAccount: WalletAccount): PreferenceKey =
PreferenceKey("metadata_key_${walletAccount.sdkAccount.accountUuid.value.toHexString()}")
@OptIn(ExperimentalEncodingApi::class)
private fun MetadataKey?.encode() =
if (this != null) {
Base64.encode(this.key.toByteArray(secretKeyAccess))
private fun MetadataKey?.encode(): Set<String>? {
return if (this != null) {
setOfNotNull(
Base64.encode(this.encryptionBytes.toByteArray(secretKeyAccess)),
this.decryptionBytes?.let { Base64.encode(it.toByteArray(secretKeyAccess)) }
)
} else {
null
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun String?.decode() =
private fun Set<String>?.decode() =
if (this != null) {
MetadataKey(SecretBytes.copyFrom(Base64.decode(this), secretKeyAccess))
MetadataKey(
encryptionBytes = SecretBytes.copyFrom(Base64.decode(this.toList()[0]), secretKeyAccess),
decryptionBytes =
this.toList().getOrNull(1)
?.let { SecretBytes.copyFrom(Base64.decode(it), secretKeyAccess) },
)
} else {
null
}

View File

@ -1,26 +1,29 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.AccountUuid
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.MetadataDataSource
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.MetadataKeyStorageProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.serialization.metada.MetadataKey
import co.electriccoin.zcash.ui.util.CloseableScopeHolder
import co.electriccoin.zcash.ui.util.CloseableScopeHolderImpl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
interface MetadataRepository {
val metadata: Flow<Metadata?>
@ -36,125 +39,158 @@ interface MetadataRepository {
suspend fun markTxMemoAsRead(txId: String)
suspend fun resetMetadata()
fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?>
}
class MetadataRepositoryImpl(
private val accountDataSource: AccountDataSource,
private val metadataDataSource: MetadataDataSource,
private val metadataKeyStorageProvider: MetadataKeyStorageProvider,
private val accountDataSource: AccountDataSource,
private val persistableWalletProvider: PersistableWalletProvider,
) : MetadataRepository {
private val semaphore = Mutex()
private val cache = MutableStateFlow<Metadata?>(null)
) : MetadataRepository, CloseableScopeHolder by CloseableScopeHolderImpl(Dispatchers.IO) {
private val command = Channel<Command>()
@OptIn(ExperimentalCoroutinesApi::class)
override val metadata: Flow<Metadata?> =
cache
.onSubscription {
withNonCancellableSemaphore {
ensureSynchronization()
accountDataSource
.selectedAccount
.flatMapLatest { account ->
channelFlow {
send(null)
if (account != null) {
val metadataKey = getMetadataKey(account)
send(metadataDataSource.getMetadata(metadataKey))
launch {
command
.receiveAsFlow()
.filter {
it.account.sdkAccount.accountUuid == account.sdkAccount.accountUuid
}
.collect { command ->
val new =
when (command) {
is Command.CreateOrUpdateTxNote ->
metadataDataSource.createOrUpdateTxNote(
txId = command.txId,
key = metadataKey,
note = command.note
)
is Command.DeleteTxNote ->
metadataDataSource.deleteTxNote(
txId = command.txId,
key = metadataKey,
)
is Command.FlipTxBookmark ->
metadataDataSource.flipTxAsBookmarked(
txId = command.txId,
key = metadataKey,
)
is Command.MarkTxMemoAsRead ->
metadataDataSource.markTxMemoAsRead(
txId = command.txId,
key = metadataKey,
)
}
send(new)
}
}
}
awaitClose {
// do nothing
}
}
}
.stateIn(
scope = scope,
started = SharingStarted.Lazily,
initialValue = null
)
override suspend fun flipTxBookmark(txId: String) =
mutateMetadata {
metadataDataSource.flipTxAsBookmarked(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
override suspend fun flipTxBookmark(txId: String) {
scope.launch {
command.send(
Command.FlipTxBookmark(
txId = txId,
account = accountDataSource.getSelectedAccount()
)
)
}
}
override suspend fun createOrUpdateTxNote(
txId: String,
note: String
) = mutateMetadata {
metadataDataSource.createOrUpdateTxNote(
txId = txId,
note = note,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun deleteTxNote(txId: String) =
mutateMetadata {
metadataDataSource.deleteTxNote(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
) {
scope.launch {
command.send(
Command.CreateOrUpdateTxNote(
txId = txId,
note = note,
account = accountDataSource.getSelectedAccount()
)
)
}
}
override suspend fun deleteTxNote(txId: String) {
scope.launch {
command.send(
Command.DeleteTxNote(
txId = txId,
account = accountDataSource.getSelectedAccount()
)
)
}
}
override suspend fun markTxMemoAsRead(txId: String) {
scope.launch {
command.send(
Command.MarkTxMemoAsRead(
txId = txId,
account = accountDataSource.getSelectedAccount()
)
)
}
override suspend fun markTxMemoAsRead(txId: String) =
mutateMetadata {
metadataDataSource.markTxMemoAsRead(
txId = txId,
key = getMetadataKey(),
account = accountDataSource.getSelectedAccount().sdkAccount.accountUuid
)
}
override suspend fun resetMetadata() {
withNonCancellableSemaphore {
metadataDataSource.resetMetadata()
cache.update { null }
}
}
@OptIn(ExperimentalStdlibApi::class)
override fun observeTransactionMetadataByTxId(txId: String): Flow<TransactionMetadata?> =
combine<Metadata?, AccountUuid, TransactionMetadata?>(
metadata,
accountDataSource.selectedAccount.filterNotNull().map { it.sdkAccount.accountUuid }.distinctUntilChanged()
) { metadata, account ->
val accountMetadata = metadata?.accountMetadata?.get(account.value.toHexString())
metadata
.map<Metadata?, TransactionMetadata?> { metadata ->
val accountMetadata = metadata?.accountMetadata
TransactionMetadata(
isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true,
isRead = accountMetadata?.read?.any { it == txId } == true,
note = accountMetadata?.annotations?.find { it.txId == txId }?.content,
)
}.distinctUntilChanged().onStart { emit(null) }
TransactionMetadata(
isBookmarked = accountMetadata?.bookmarked?.find { it.txId == txId }?.isBookmarked == true,
isRead = accountMetadata?.read?.any { it == txId } == true,
note = accountMetadata?.annotations?.find { it.txId == txId }?.content,
)
}
.distinctUntilChanged()
.onStart { emit(null) }
private suspend fun ensureSynchronization() {
if (cache.value == null) {
val metadata = metadataDataSource.getMetadata(key = getMetadataKey())
metadataDataSource.save(metadata = metadata, key = getMetadataKey())
cache.update { metadata }
}
}
private suspend fun mutateMetadata(block: suspend () -> Metadata) =
withNonCancellableSemaphore {
ensureSynchronization()
val new = block()
cache.update { new }
}
private suspend fun withNonCancellableSemaphore(block: suspend () -> Unit) =
withContext(NonCancellable + Dispatchers.Default) {
semaphore.withLock { block() }
}
private suspend fun getMetadataKey(): MetadataKey {
val key = metadataKeyStorageProvider.get()
private suspend fun getMetadataKey(selectedAccount: WalletAccount): MetadataKey {
val key = metadataKeyStorageProvider.get(selectedAccount)
return if (key != null) {
key
} else {
val account = accountDataSource.getZashiAccount()
val persistableWallet = persistableWalletProvider.getPersistableWallet()
val zashiAccount = accountDataSource.getZashiAccount()
val newKey =
MetadataKey.derive(
seedPhrase = persistableWallet.seedPhrase,
network = persistableWallet.network,
account = account
zashiAccount = zashiAccount,
selectedAccount = selectedAccount
)
metadataKeyStorageProvider.store(newKey)
metadataKeyStorageProvider.store(newKey, selectedAccount)
newKey
}
}
@ -165,3 +201,28 @@ data class TransactionMetadata(
val isRead: Boolean,
val note: String?
)
private sealed interface Command {
val account: WalletAccount
data class FlipTxBookmark(
val txId: String,
override val account: WalletAccount
) : Command
data class CreateOrUpdateTxNote(
val txId: String,
val note: String,
override val account: WalletAccount
) : Command
data class DeleteTxNote(
val txId: String,
override val account: WalletAccount
) : Command
data class MarkTxMemoAsRead(
val txId: String,
override val account: WalletAccount
) : Command
}

View File

@ -1,82 +0,0 @@
package co.electriccoin.zcash.ui.common.serialization
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface Encryptor<KEY : Key, T> {
fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
)
fun decrypt(
key: KEY,
inputStream: InputStream
): T
}
abstract class BaseEncryptor<KEY : Key, T> : BaseSerializer(), Encryptor<KEY, T> {
abstract val version: Int
abstract val saltSize: Int
protected abstract fun serialize(
outputStream: ByteArrayOutputStream,
data: T
)
protected abstract fun deserialize(inputStream: ByteArrayInputStream): T
override fun encrypt(
key: KEY,
outputStream: OutputStream,
data: T
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
}
override fun decrypt(
key: KEY,
inputStream: InputStream
): T {
val version = inputStream.readInt()
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
}
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")

View File

@ -0,0 +1,3 @@
package co.electriccoin.zcash.ui.common.serialization
class UnknownEncryptionVersionException : RuntimeException("Unknown encryption version")

View File

@ -3,27 +3,89 @@ package co.electriccoin.zcash.ui.common.serialization.addressbook
import co.electriccoin.zcash.ui.common.model.AddressBook
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.ADDRESS_BOOK_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor
import co.electriccoin.zcash.ui.common.serialization.Encryptor
import co.electriccoin.zcash.ui.common.serialization.BaseSerializer
import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
interface AddressBookEncryptor : Encryptor<AddressBookKey, AddressBook>
interface AddressBookEncryptor {
fun encrypt(
key: AddressBookKey,
outputStream: OutputStream,
data: AddressBook
)
fun decrypt(
key: AddressBookKey,
inputStream: InputStream
): AddressBook
}
class AddressBookEncryptorImpl(
private val addressBookSerializer: AddressBookSerializer,
) : AddressBookEncryptor, BaseEncryptor<AddressBookKey, AddressBook>() {
override val version: Int = ADDRESS_BOOK_ENCRYPTION_V1
override val saltSize: Int = ADDRESS_BOOK_SALT_SIZE
) : AddressBookEncryptor, BaseSerializer() {
private val version: Int = ADDRESS_BOOK_ENCRYPTION_V1
private val saltSize: Int = ADDRESS_BOOK_SALT_SIZE
override fun serialize(
override fun encrypt(
key: AddressBookKey,
outputStream: OutputStream,
data: AddressBook
) {
// Generate a fresh one-time key for this ciphertext.
val salt = Random.randBytes(saltSize)
val cipherText =
ByteArrayOutputStream()
.use { stream ->
serialize(stream, data)
stream.toByteArray()
}.let {
val derivedKey = key.deriveEncryptionKey(salt)
// Tink encodes the ciphertext as `nonce || ciphertext || tag`.
val cipher = ChaCha20Poly1305.create(derivedKey)
cipher.encrypt(it, null)
}
outputStream.write(version.createByteArray())
outputStream.write(salt)
outputStream.write(cipherText)
}
override fun decrypt(
key: AddressBookKey,
inputStream: InputStream
): AddressBook {
val version = inputStream.readInt()
if (version != this.version) {
throw UnknownEncryptionVersionException()
}
val salt = ByteArray(saltSize)
require(inputStream.read(salt) == salt.size) { "Input is too short" }
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
}
private fun serialize(
outputStream: ByteArrayOutputStream,
data: AddressBook
) {
addressBookSerializer.serializeAddressBook(outputStream, data)
}
override fun deserialize(inputStream: ByteArrayInputStream): AddressBook {
private fun deserialize(inputStream: ByteArrayInputStream): AddressBook {
return addressBookSerializer.deserializeAddressBook(inputStream)
}
}

View File

@ -1,36 +1,38 @@
package co.electriccoin.zcash.ui.common.serialization.metada
import co.electriccoin.zcash.ui.common.model.Metadata
import co.electriccoin.zcash.ui.common.serialization.BaseEncryptor
import co.electriccoin.zcash.ui.common.serialization.Encryptor
import co.electriccoin.zcash.ui.common.serialization.BaseSerializer
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_V1
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
import co.electriccoin.zcash.ui.common.serialization.UnknownEncryptionVersionException
import com.google.crypto.tink.aead.ChaCha20Poly1305Key
import com.google.crypto.tink.subtle.ChaCha20Poly1305
import com.google.crypto.tink.subtle.Random
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import kotlin.jvm.Throws
interface MetadataEncryptor : Encryptor<MetadataKey, Metadata>
interface MetadataEncryptor {
fun encrypt(
key: MetadataKey,
outputStream: OutputStream,
data: Metadata
)
@Throws(DecryptionException::class)
fun decrypt(
key: MetadataKey,
inputStream: InputStream
): Metadata
}
class MetadataEncryptorImpl(
private val metadataSerializer: MetadataSerializer,
) : MetadataEncryptor, BaseEncryptor<MetadataKey, Metadata>() {
override val version: Int = METADATA_ENCRYPTION_V1
override val saltSize: Int = METADATA_SALT_SIZE
override fun serialize(
outputStream: ByteArrayOutputStream,
data: Metadata
) {
metadataSerializer.serialize(outputStream, data)
}
override fun deserialize(inputStream: ByteArrayInputStream): Metadata {
return metadataSerializer.deserialize(inputStream)
}
) : MetadataEncryptor, BaseSerializer() {
private val version: Int = METADATA_ENCRYPTION_V1
private val saltSize: Int = METADATA_SALT_SIZE
override fun encrypt(
key: MetadataKey,
@ -72,12 +74,34 @@ class MetadataEncryptorImpl(
val ciphertext = inputStream.readBytes()
val derivedKey = key.deriveEncryptionKey(salt)
val cipher = ChaCha20Poly1305.create(derivedKey)
val plaintext = cipher.decrypt(ciphertext, null)
return decrypt(key.deriveFirstDecryptionKey(salt), ciphertext)
?: decrypt(key.deriveSecondDecryptionKey(salt), ciphertext)
?: throw DecryptionException()
}
return plaintext.inputStream().use { stream ->
deserialize(stream)
}
private fun decrypt(
key: ChaCha20Poly1305Key?,
ciphertext: ByteArray
): Metadata? {
if (key == null) return null
return runCatching {
val cipher = ChaCha20Poly1305.create(key)
val plaintext = cipher.decrypt(ciphertext, null)
plaintext.inputStream().use { stream -> deserialize(stream) }
}.getOrNull()
}
private fun serialize(
outputStream: ByteArrayOutputStream,
data: Metadata
) {
metadataSerializer.serialize(outputStream, data)
}
private fun deserialize(inputStream: ByteArrayInputStream): Metadata {
return metadataSerializer.deserialize(inputStream)
}
}
class DecryptionException : Exception()

View File

@ -3,8 +3,9 @@ package co.electriccoin.zcash.ui.common.serialization.metada
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 co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.serialization.Key
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.serialization.METADATA_ENCRYPTION_KEY_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_FILE_IDENTIFIER_SIZE
import co.electriccoin.zcash.ui.common.serialization.METADATA_SALT_SIZE
@ -16,17 +17,20 @@ import com.google.crypto.tink.util.SecretBytes
/**
* The long-term key that can decrypt an account's encrypted address book.
*/
class MetadataKey(val key: SecretBytes) : Key {
class MetadataKey(
val encryptionBytes: SecretBytes,
val decryptionBytes: SecretBytes?
) {
/**
* Derives the filename that this key is able to decrypt.
*/
@OptIn(ExperimentalStdlibApi::class)
override fun fileIdentifier(): String {
fun fileIdentifier(): String {
val access = InsecureSecretKeyAccess.get()
val fileIdentifier =
Hkdf.computeHkdf(
"HMACSHA256",
key.toByteArray(access),
encryptionBytes.toByteArray(access),
null,
"file_identifier".toByteArray(),
METADATA_FILE_IDENTIFIER_SIZE
@ -34,19 +38,13 @@ class MetadataKey(val key: SecretBytes) : Key {
return "zashi-metadata-" + 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.
*/
override fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
fun deriveEncryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
assert(salt.size == METADATA_SALT_SIZE)
val access = InsecureSecretKeyAccess.get()
val subKey =
Hkdf.computeHkdf(
"HMACSHA256",
key.toByteArray(access),
encryptionBytes.toByteArray(access),
null,
salt + "encryption_key".toByteArray(),
METADATA_ENCRYPTION_KEY_SIZE
@ -54,28 +52,64 @@ class MetadataKey(val key: SecretBytes) : Key {
return ChaCha20Poly1305Key.create(SecretBytes.copyFrom(subKey, access))
}
fun deriveFirstDecryptionKey(salt: ByteArray): ChaCha20Poly1305Key {
return deriveDecryptionkey(salt, encryptionBytes, "encryption_key")
}
fun deriveSecondDecryptionKey(salt: ByteArray): ChaCha20Poly1305Key? {
if (decryptionBytes == null) return null
return deriveDecryptionkey(salt, decryptionBytes, "decryption_key")
}
private fun deriveDecryptionkey(
salt: ByteArray,
decryptionBytes: SecretBytes,
infoKey: String
): ChaCha20Poly1305Key {
assert(salt.size == METADATA_SALT_SIZE)
val access = InsecureSecretKeyAccess.get()
val subKey =
Hkdf.computeHkdf(
"HMACSHA256",
decryptionBytes.toByteArray(access),
null,
salt + infoKey.toByteArray(),
METADATA_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: WalletAccount
zashiAccount: ZashiAccount,
selectedAccount: WalletAccount
): MetadataKey {
val key =
DerivationTool.getInstance().deriveArbitraryAccountKey(
contextString = "ZashiMetadataEncryptionV1".toByteArray(),
seed = seedPhrase.toByteArray(),
network = network,
accountIndex = account.hdAccountIndex,
)
return MetadataKey(SecretBytes.copyFrom(key, InsecureSecretKeyAccess.get()))
DerivationTool.getInstance()
.deriveAccountMetadataKey(
seed = seedPhrase.toByteArray(),
network = network,
accountIndex = zashiAccount.hdAccountIndex,
)
.derivePrivateUseMetadataKey(
ufvk =
when (selectedAccount) {
is KeystoneAccount -> selectedAccount.sdkAccount.ufvk
is ZashiAccount -> null
},
network = network,
privateUseSubject = "metadata".toByteArray()
)
return MetadataKey(
encryptionBytes = SecretBytes.copyFrom(key[0], InsecureSecretKeyAccess.get()),
decryptionBytes =
key.getOrNull(1)
?.let {
SecretBytes.copyFrom(it, InsecureSecretKeyAccess.get())
}
)
}
}
}

View File

@ -1,14 +1,11 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.MetadataRepository
class ResetInMemoryDataUseCase(
private val addressBookRepository: AddressBookRepository,
private val metadataRepository: MetadataRepository
) {
suspend operator fun invoke() {
addressBookRepository.resetAddressBook()
metadataRepository.resetMetadata()
}
}

View File

@ -32,6 +32,7 @@ import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.asScaffoldPaddingValues
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
@ -143,7 +144,7 @@ private fun BottomBar(
) {
ZashiBottomBar(
isElevated = scrollState.value > 0,
modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp)
contentPadding = paddingValues.asScaffoldPaddingValues(top = 0.dp, bottom = 0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth()