From 1b1ab97c7614b2821e8c3b002294cf2ad3fc0aa2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Jun 2026 12:48:55 +0100 Subject: [PATCH 1/2] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 11 + domain/address-book/build.gradle.kts | 2 + .../addressbook/crypto/AddressBookCipher.kt | 102 +++++++ .../crypto/AddressBookKeyDerivation.kt | 39 +++ .../error/AddressBookCryptoError.kt | 20 ++ .../domain/addressbook/model/AddressBook.kt | 15 + .../addressbook/model/AddressBookBlob.kt | 39 +++ .../domain/addressbook/model/Contact.kt | 10 +- .../addressbook/time/IsoTimestampProvider.kt | 14 + .../usecase/CreateContactUseCase.kt | 8 +- .../usecase/UpdateContactUseCase.kt | 9 +- .../crypto/AddressBookCipherTest.kt | 276 ++++++++++++++++++ .../usecase/CreateContactUseCaseTest.kt | 10 + .../usecase/SignAddressEntriesUseCaseTest.kt | 2 + .../usecase/UpdateContactUseCaseTest.kt | 12 + .../usecase/ValidateContactNameUseCaseTest.kt | 2 + .../VerifyAddressEntriesUseCaseTest.kt | 2 + 17 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookKeyDerivation.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookCryptoError.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/time/IsoTimestampProvider.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt index ffb237ebe0..3fa8efefb9 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -1,5 +1,8 @@ package com.tangem.tap.di.domain +import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider +import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase @@ -34,4 +37,12 @@ object AddressBookDomainModule { ): VerifyAddressEntriesUseCase { return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) } + + @Provides + @Singleton + fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher() + + @Provides + @Singleton + fun provideIsoTimestampProvider(): IsoTimestampProvider = DefaultIsoTimestampProvider() } \ No newline at end of file diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts index 6121847a3c..02180ffe1e 100644 --- a/domain/address-book/build.gradle.kts +++ b/domain/address-book/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) id("configuration") } @@ -19,6 +20,7 @@ dependencies { implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) implementation(deps.kotlin.serialization) + implementation(deps.jodatime) // region Test libraries testImplementation(projects.test.core) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt new file mode 100644 index 0000000000..832cb02442 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipher.kt @@ -0,0 +1,102 @@ +package com.tangem.domain.addressbook.crypto + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.domain.addressbook.error.AddressBookCryptoError +import com.tangem.domain.addressbook.model.AddressBook +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.extensions.hexToBytesOrNull +import com.tangem.utils.extensions.toHexString +import kotlinx.serialization.json.Json +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Encrypts and decrypts a wallet's [AddressBook] with AES-256-GCM. + * + * The symmetric key is derived deterministically from the wallet's public key (see + * [AddressBookKeyDerivation]), so the same wallet always yields the same key — no key needs to be + * stored. Each encryption uses a fresh random 12-byte nonce, so encrypting the same book twice + * produces different blobs that both decrypt back to the original. + * + * The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `auth_tag` field + * (Java appends it to the ciphertext; this class splits it out and re-joins it on decrypt). A + * tampered ciphertext or tag fails the tag check and surfaces as + * [AddressBookCryptoError.DecryptionFailed]. + */ +class AddressBookCipher { + + private val json = Json { ignoreUnknownKeys = true } + private val secureRandom = SecureRandom() + + fun encrypt( + addressBook: AddressBook, + userWallet: UserWallet, + updatedAt: DateTime, + ): Either = either { + ensure(addressBook.walletId == userWallet.walletId) { AddressBookCryptoError.WalletMismatch } + + val aesKey = deriveKey(userWallet) + val plaintext = json.encodeToString(AddressBook.serializer(), addressBook).toByteArray(Charsets.UTF_8) + + val nonce = ByteArray(NONCE_SIZE_BYTES).also(secureRandom::nextBytes) + val cipherWithTag = cipher(Cipher.ENCRYPT_MODE, aesKey, nonce).doFinal(plaintext) + + // Java's GCM doFinal returns ciphertext || authTag — split the trailing tag out for the blob. + val tagOffset = cipherWithTag.size - TAG_SIZE_BYTES + val ciphertext = cipherWithTag.copyOfRange(fromIndex = 0, toIndex = tagOffset) + val authTag = cipherWithTag.copyOfRange(fromIndex = tagOffset, toIndex = cipherWithTag.size) + + AddressBookBlob( + walletId = addressBook.walletId.stringValue, + updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(), + nonce = nonce.toHexString().lowercase(), + ciphertext = ciphertext.toHexString().lowercase(), + authTag = authTag.toHexString().lowercase(), + ) + } + + fun decrypt(blob: AddressBookBlob, userWallet: UserWallet): Either = either { + ensure(blob.walletId == userWallet.walletId.stringValue) { AddressBookCryptoError.WalletMismatch } + + val aesKey = deriveKey(userWallet) + val nonce = blob.nonce.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed) + val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed) + val authTag = blob.authTag.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed) + + val plaintext = runCatching { + cipher(Cipher.DECRYPT_MODE, aesKey, nonce).doFinal(ciphertext + authTag) + }.getOrElse { raise(AddressBookCryptoError.DecryptionFailed) } + + runCatching { + json.decodeFromString(AddressBook.serializer(), plaintext.toString(Charsets.UTF_8)) + }.getOrElse { raise(AddressBookCryptoError.MalformedBlob) } + } + + private fun Raise.deriveKey(userWallet: UserWallet): ByteArray { + val publicKey = AddressBookKeyDerivation.walletPublicKey(userWallet) + ?: raise(AddressBookCryptoError.NoWalletPublicKey) + return AddressBookKeyDerivation.deriveAesKey(publicKey) + } + + private fun cipher(mode: Int, key: ByteArray, nonce: ByteArray): Cipher { + return Cipher.getInstance(AES_GCM_TRANSFORMATION).apply { + init(mode, SecretKeySpec(key, AES_ALGORITHM), GCMParameterSpec(TAG_SIZE_BITS, nonce)) + } + } + + private companion object { + const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding" + const val AES_ALGORITHM = "AES" + const val NONCE_SIZE_BYTES = 12 + const val TAG_SIZE_BYTES = 16 + const val TAG_SIZE_BITS = TAG_SIZE_BYTES * 8 + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookKeyDerivation.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookKeyDerivation.kt new file mode 100644 index 0000000000..2f545550a6 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/crypto/AddressBookKeyDerivation.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.addressbook.crypto + +import com.tangem.domain.models.wallet.UserWallet +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Derives the AES-256 symmetric key used to encrypt a wallet's address book. + * + * The scheme mirrors `UserWalletEncyptionKeyCalculator`, differing only in the domain-separation + * message: `HMAC-SHA256(SHA-256(walletPublicKey), "TokensSymmetricKey")`. HMAC-SHA256 yields 32 + * bytes, exactly an AES-256 key. + */ +internal object AddressBookKeyDerivation { + + private const val SYMMETRIC_KEY_MESSAGE = "TokensSymmetricKey" + private const val SHA_256 = "SHA-256" + private const val HMAC_SHA_256 = "HmacSHA256" + + /** Primary wallet public key, or `null` if the wallet is locked / has no wallets. */ + fun walletPublicKey(userWallet: UserWallet): ByteArray? = when (userWallet) { + is UserWallet.Cold -> { + val card = userWallet.scanResponse.card + card.wallets + .firstOrNull() + ?.publicKey + } + is UserWallet.Hot -> userWallet.wallets?.firstOrNull()?.publicKey + } + + /** `HMAC-SHA256(key = SHA-256(publicKey), msg = "TokensSymmetricKey")` — a 32-byte AES-256 key. */ + fun deriveAesKey(publicKey: ByteArray): ByteArray { + val keyHash = MessageDigest.getInstance(SHA_256).digest(publicKey) + val mac = Mac.getInstance(HMAC_SHA_256) + mac.init(SecretKeySpec(keyHash, HMAC_SHA_256)) + return mac.doFinal(SYMMETRIC_KEY_MESSAGE.toByteArray(Charsets.UTF_8)) + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookCryptoError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookCryptoError.kt new file mode 100644 index 0000000000..bc22078650 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookCryptoError.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.addressbook.error + +import com.tangem.domain.addressbook.model.AddressBook +import com.tangem.domain.addressbook.model.AddressBookBlob + +/** Failure modes of [com.tangem.domain.addressbook.crypto.AddressBookCipher]. */ +sealed interface AddressBookCryptoError { + + /** The wallet exposes no public key, so the symmetric key cannot be derived. */ + data object NoWalletPublicKey : AddressBookCryptoError + + /** [AddressBookBlob.walletId] does not belong to the wallet passed to decrypt. */ + data object WalletMismatch : AddressBookCryptoError + + /** Authentication tag check failed, or the nonce/ciphertext/tag were not valid hex. */ + data object DecryptionFailed : AddressBookCryptoError + + /** Decryption succeeded but the plaintext could not be parsed into an [AddressBook]. */ + data object MalformedBlob : AddressBookCryptoError +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt new file mode 100644 index 0000000000..0d8aacbe08 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBook.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.addressbook.model + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +/** + * All [Contact]s of a single wallet. This is the plaintext payload that + * [com.tangem.domain.addressbook.crypto.AddressBookCipher] encrypts into an + * [AddressBookBlob] and reconstructs on decryption. + */ +@Serializable +data class AddressBook( + val walletId: UserWalletId, + val contacts: List, +) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt new file mode 100644 index 0000000000..1e3374a2c1 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.addressbook.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Self-describing envelope around an AES-256-GCM encrypted [AddressBook]. Produced and consumed by + * [com.tangem.domain.addressbook.crypto.AddressBookCipher]; safe to persist or sync off-device. + * + * The [ciphertext] holds the serialized [AddressBook]; the metadata ([walletId], [updatedAt]) stays + * in clear text so the blob can be routed/sorted without decrypting it. [nonce], [ciphertext] and + * [authTag] are lowercase hex strings. + * + * Being `@Serializable`, the blob serializes into exactly: + * ```json + * { + * "version": "1.0", + * "walletId": "…", + * "updatedAt": "2026-05-22T09:00:00.000Z", + * "nonce": "…", + * "ciphertext": "…", + * "auth_tag": "…" + * } + * ``` + */ +@Serializable +data class AddressBookBlob( + val version: String = CURRENT_VERSION, + val walletId: String, + val updatedAt: String, + val nonce: String, + val ciphertext: String, + @SerialName("auth_tag") val authTag: String, +) { + + companion object { + const val CURRENT_VERSION = "1.0" + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt index 658de7ed5b..2cf5cae408 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt @@ -3,11 +3,19 @@ package com.tangem.domain.addressbook.model import com.tangem.domain.models.wallet.UserWalletId import kotlinx.serialization.Serializable -/** A named address stored in the user's address book for fast access when sending. */ +/** + * A named address stored in the user's address book for fast access when sending. + * + + * `2026-06-10T14:30:00.000Z`. The whole contact (including these fields) is encrypted by + * [com.tangem.domain.addressbook.crypto.AddressBookCipher] and never leaves the device in clear text. + */ @Serializable data class Contact( val id: ContactId, val walletId: UserWalletId, val name: ContactName, + val createdAt: String, + val updatedAt: String, val addressEntries: List, ) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/time/IsoTimestampProvider.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/time/IsoTimestampProvider.kt new file mode 100644 index 0000000000..edfebfc4fa --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/time/IsoTimestampProvider.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.addressbook.time + +import org.joda.time.DateTime +import org.joda.time.DateTimeZone + +interface IsoTimestampProvider { + + fun now(): String +} + +class DefaultIsoTimestampProvider : IsoTimestampProvider { + + override fun now(): String = DateTime.now(DateTimeZone.UTC).toString() +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt index 9fca6a9f79..f7e861cd6f 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt @@ -7,17 +7,20 @@ import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import java.util.UUID /** * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique - * (case-insensitive) within the wallet. + + * the current time. */ class CreateContactUseCase( private val repository: AddressBookRepository, private val validateContactName: ValidateContactNameUseCase, + private val timestampProvider: IsoTimestampProvider, ) { @Suppress("LongParameterList") @@ -31,10 +34,13 @@ class CreateContactUseCase( .mapLeft(SaveContactError::Name) .bind() + val now = timestampProvider.now() val contact = Contact( id = ContactId(UUID.randomUUID().toString()), walletId = userWalletId, name = validName, + createdAt = now, + updatedAt = now, addressEntries = addressEntries, ) repository.saveContact(contact) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt index 28782da0f1..3f6580f0f4 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt @@ -8,14 +8,16 @@ import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider /** - * Updates an existing [Contact], preserving its contact id. The name is only format-checked — - * uniqueness is not re-validated on update. Address entries must be prepared and validated before - * calling this use case. + + * format-checked — uniqueness is not re-validated on update. Address entries must be prepared and + * validated before calling this use case. [Contact.updatedAt] is restamped with the current time. */ class UpdateContactUseCase( private val repository: AddressBookRepository, + private val timestampProvider: IsoTimestampProvider, ) { suspend operator fun invoke( @@ -30,6 +32,7 @@ class UpdateContactUseCase( val updated = contact.copy( name = validName, addressEntries = addressEntries, + updatedAt = timestampProvider.now(), ) repository.saveContact(updated) updated diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt new file mode 100644 index 0000000000..3f6371f5a5 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt @@ -0,0 +1,276 @@ +package com.tangem.domain.addressbook.crypto + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.error.AddressBookCryptoError +import com.tangem.domain.addressbook.model.AddressBook +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.extensions.toHexString +import arrow.core.Either +import io.mockk.every +import io.mockk.mockk +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.junit.jupiter.api.Test +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +internal class AddressBookCipherTest { + + private val cipher = AddressBookCipher() + + private val wallet: UserWallet.Cold = MockUserWalletFactory.create() + private val updatedAt: DateTime = DateTime.parse("2026-05-22T09:00:00.000Z") + + @Test + fun `GIVEN multi-contact book WHEN encrypt then decrypt THEN original book is restored`() { + // Arrange + val book = addressBook( + contact("Alice", entry("addr-1", "0xabc", memo = "memo")), + contact("Bob", entry("addr-2", "0xdef", memo = null)), + ) + + // Act + val blob = cipher.encrypt(book, wallet, updatedAt).rightValue() + val decrypted = cipher.decrypt(blob, wallet).rightValue() + + // Assert + assertThat(decrypted).isEqualTo(book) + } + + @Test + fun `GIVEN empty book WHEN encrypt then decrypt THEN empty book is restored`() { + // Arrange + val book = addressBook() + + // Act + val blob = cipher.encrypt(book, wallet, updatedAt).rightValue() + val decrypted = cipher.decrypt(blob, wallet).rightValue() + + // Assert + assertThat(decrypted).isEqualTo(book) + } + + @Test + fun `GIVEN a book WHEN encrypt THEN blob metadata and field sizes match the spec`() { + // Arrange + val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null))) + + // Act + val blob = cipher.encrypt(book, wallet, updatedAt).rightValue() + + // Assert + assertThat(blob.version).isEqualTo(AddressBookBlob.CURRENT_VERSION) + assertThat(blob.walletId).isEqualTo(wallet.walletId.stringValue) + assertThat(blob.updatedAt).isEqualTo("2026-05-22T09:00:00.000Z") + assertThat(blob.nonce).hasLength(NONCE_HEX_LENGTH) // 12 bytes + assertThat(blob.authTag).hasLength(TAG_HEX_LENGTH) // 16 bytes + assertThat(blob.ciphertext).isNotEmpty() + } + + @Test + fun `GIVEN updatedAt in a non-UTC zone WHEN encrypt THEN it is normalized to UTC ISO-8601`() { + // Arrange + val book = addressBook() + val nonUtc = DateTime.parse("2026-05-22T09:00:00.000Z").withZone(DateTimeZone.forOffsetHours(3)) + + // Act + val blob = cipher.encrypt(book, wallet, nonUtc).rightValue() + + // Assert + assertThat(blob.updatedAt).isEqualTo("2026-05-22T09:00:00.000Z") + } + + @Test + fun `GIVEN same book encrypted twice WHEN compared THEN nonce differs but both decrypt to original`() { + // Arrange + val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null))) + + // Act + val first = cipher.encrypt(book, wallet, updatedAt).rightValue() + val second = cipher.encrypt(book, wallet, updatedAt).rightValue() + + // Assert + assertThat(first.nonce).isNotEqualTo(second.nonce) + assertThat(first.ciphertext).isNotEqualTo(second.ciphertext) + assertThat(cipher.decrypt(first, wallet).rightValue()).isEqualTo(book) + assertThat(cipher.decrypt(second, wallet).rightValue()).isEqualTo(book) + } + + @Test + fun `GIVEN book whose walletId differs from the wallet WHEN encrypt THEN WalletMismatch`() { + // Arrange + val book = addressBook().copy(walletId = UserWalletId("deadbeef")) + + // Act + val result = cipher.encrypt(book, wallet, updatedAt) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.WalletMismatch) + } + + @Test + fun `GIVEN blob WHEN decrypt with a wallet of different id THEN WalletMismatch`() { + // Arrange + val blob = cipher.encrypt(addressBook(), wallet, updatedAt).rightValue() + val otherWallet = wallet.copy(walletId = UserWalletId("deadbeef")) + + // Act + val result = cipher.decrypt(blob, otherWallet) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.WalletMismatch) + } + + @Test + fun `GIVEN blob with tampered ciphertext WHEN decrypt THEN DecryptionFailed`() { + // Arrange + val blob = cipher.encrypt(addressBook(), wallet, updatedAt).rightValue() + val tampered = blob.copy(ciphertext = blob.ciphertext.flipFirstHexNibble()) + + // Act + val result = cipher.decrypt(tampered, wallet) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.DecryptionFailed) + } + + @Test + fun `GIVEN blob with tampered auth tag WHEN decrypt THEN DecryptionFailed`() { + // Arrange + val blob = cipher.encrypt(addressBook(), wallet, updatedAt).rightValue() + val tampered = blob.copy(authTag = blob.authTag.flipFirstHexNibble()) + + // Act + val result = cipher.decrypt(tampered, wallet) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.DecryptionFailed) + } + + @Test + fun `GIVEN blob with non-hex nonce WHEN decrypt THEN DecryptionFailed`() { + // Arrange + val blob = cipher.encrypt(addressBook(), wallet, updatedAt).rightValue() + val tampered = blob.copy(nonce = "zzzz") + + // Act + val result = cipher.decrypt(tampered, wallet) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.DecryptionFailed) + } + + @Test + fun `GIVEN valid blob holding non-AddressBook plaintext WHEN decrypt THEN MalformedBlob`() { + // Arrange — encrypt arbitrary bytes with the real derived key so only the payload is wrong + val blob = encryptRawPayload(plaintext = "{}".toByteArray(Charsets.UTF_8), userWallet = wallet) + + // Act + val result = cipher.decrypt(blob, wallet) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.MalformedBlob) + } + + @Test + fun `GIVEN locked wallet without a public key WHEN encrypt THEN NoWalletPublicKey`() { + // Arrange — a Cold wallet whose card exposes no wallets (locked), so no key can be derived + val lockedWallet = mockk { + every { walletId } returns wallet.walletId + every { scanResponse } returns mockk { + every { card } returns mockk { every { wallets } returns emptyList() } + } + } + val book = addressBook(walletId = wallet.walletId) + + // Act + val result = cipher.encrypt(book, lockedWallet, updatedAt) + + // Assert + assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.NoWalletPublicKey) + } + + @Test + fun `GIVEN fixed public key WHEN deriveAesKey THEN matches the locked HMAC-SHA256 vector`() { + // Arrange — independently computed: HMAC-SHA256(SHA-256([01,02,03,04]), "TokensSymmetricKey") + val publicKey = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val expected = "da48094b89902e137ae73ae90acbd809af9ad4f648044c17e7ee6de73e96b0c2" + + // Act + val aesKey = AddressBookKeyDerivation.deriveAesKey(publicKey) + + // Assert + assertThat(aesKey).hasLength(AES_256_KEY_BYTES) + assertThat(aesKey.toHexString().lowercase()).isEqualTo(expected) + } + + // region helpers + private fun addressBook(vararg contacts: Contact): AddressBook = + AddressBook(walletId = wallet.walletId, contacts = contacts.toList()) + + private fun addressBook(walletId: UserWalletId): AddressBook = + AddressBook(walletId = walletId, contacts = emptyList()) + + private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact( + id = ContactId("contact-$name"), + walletId = wallet.walletId, + name = requireNotNull(ContactName(name).getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-05-22T09:00:00.000Z", + addressEntries = entries.toList(), + ) + + private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + memo = memo, + signature = "", + ) + + private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1) + + private fun Either.rightValue(): T = + getOrNull() ?: error("Expected Either.Right but was $this") + + private fun Either.leftValue(): AddressBookCryptoError = + leftOrNull() ?: error("Expected Either.Left but was $this") + + /** Mirrors [AddressBookCipher.encrypt] but accepts arbitrary plaintext, to forge a decryptable-but-malformed blob. */ + private fun encryptRawPayload(plaintext: ByteArray, userWallet: UserWallet): AddressBookBlob { + val aesKey = AddressBookKeyDerivation.deriveAesKey(AddressBookKeyDerivation.walletPublicKey(userWallet)!!) + val nonce = ByteArray(NONCE_BYTES).also(SecureRandom()::nextBytes) + val cipherWithTag = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), GCMParameterSpec(TAG_BITS, nonce)) + }.doFinal(plaintext) + val tagOffset = cipherWithTag.size - TAG_BYTES + return AddressBookBlob( + walletId = userWallet.walletId.stringValue, + updatedAt = updatedAt.toString(), + nonce = nonce.toHexString().lowercase(), + ciphertext = cipherWithTag.copyOfRange(0, tagOffset).toHexString().lowercase(), + authTag = cipherWithTag.copyOfRange(tagOffset, cipherWithTag.size).toHexString().lowercase(), + ) + } + // endregion + + private companion object { + const val NONCE_BYTES = 12 + const val TAG_BYTES = 16 + const val TAG_BITS = TAG_BYTES * 8 + const val NONCE_HEX_LENGTH = NONCE_BYTES * 2 + const val TAG_HEX_LENGTH = TAG_BYTES * 2 + const val AES_256_KEY_BYTES = 32 + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt index c33c19f8fa..c37c8c7d07 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -9,6 +9,7 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks @@ -27,9 +28,14 @@ import org.junit.jupiter.api.TestInstance class CreateContactUseCaseTest { private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val expectedTimestamp = "2026-06-10T14:30:00.000Z" + private val timestampProvider: IsoTimestampProvider = mockk { + every { now() } returns expectedTimestamp + } private val useCase = CreateContactUseCase( repository = repository, validateContactName = ValidateContactNameUseCase(repository), + timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") @@ -71,6 +77,8 @@ class CreateContactUseCaseTest { assertThat(contact.name.value).isEqualTo("Alice") assertThat(contact.id.value).isNotEmpty() assertThat(contact.addressEntries).isEqualTo(addressEntries) + assertThat(contact.createdAt).isEqualTo(expectedTimestamp) + assertThat(contact.updatedAt).isEqualTo(expectedTimestamp) } @Test @@ -109,6 +117,8 @@ class CreateContactUseCaseTest { id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + createdAt = expectedTimestamp, + updatedAt = expectedTimestamp, addressEntries = listOf( AddressEntry( id = AddressEntryId("addr-$name"), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt index 9a4405a9f4..da7c887d62 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -124,6 +124,8 @@ class SignAddressEntriesUseCaseTest { id = ContactId("contact-1"), walletId = UserWalletId("011"), name = requireNotNull(ContactName("Alice").getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = entries.toList(), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt index e015e72b88..043697d440 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -9,11 +9,13 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest @@ -25,8 +27,14 @@ import org.junit.jupiter.api.TestInstance class UpdateContactUseCaseTest { private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val newTimestamp = "2026-06-10T14:30:00.000Z" + private val originalTimestamp = "2026-01-01T00:00:00.000Z" + private val timestampProvider: IsoTimestampProvider = mockk { + every { now() } returns newTimestamp + } private val useCase = UpdateContactUseCase( repository = repository, + timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") @@ -64,6 +72,8 @@ class UpdateContactUseCaseTest { assertThat(contact!!.id).isEqualTo(existing.id) assertThat(contact.name.value).isEqualTo("Bob") assertThat(contact.addressEntries).isEqualTo(updatedEntries) + assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved + assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped coVerify(exactly = 0) { repository.getContacts(any()) } } @@ -84,6 +94,8 @@ class UpdateContactUseCaseTest { id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + createdAt = originalTimestamp, + updatedAt = originalTimestamp, addressEntries = listOf( AddressEntry( id = AddressEntryId("addr-$name"), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt index f255050b3f..aa81fcd4c1 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt @@ -64,6 +64,8 @@ class ValidateContactNameUseCaseTest { id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = listOf( AddressEntry( id = AddressEntryId("addr-$name"), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt index 88501ea467..858206d127 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt @@ -152,6 +152,8 @@ class VerifyAddressEntriesUseCaseTest { id = ContactId("contact-1"), walletId = UserWalletId("011"), name = requireNotNull(ContactName("Alice").getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = entries.toList(), ) From 47e51d93fb89d841d026823beafbde161923516a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Jun 2026 13:27:34 +0100 Subject: [PATCH 2/2] Updated on 2026-08-14 --- .../com/tangem/domain/addressbook/model/AddressBookBlob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt index 1e3374a2c1..58d75415e5 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt @@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable */ @Serializable data class AddressBookBlob( - val version: String = CURRENT_VERSION, + val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY] val walletId: String, val updatedAt: String, val nonce: String,