Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-17 14:21:56 +01:00
commit a236e11593
17 changed files with 568 additions and 5 deletions

View file

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

View file

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

View file

@ -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<AddressBookCryptoError, AddressBookBlob> = 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<AddressBookCryptoError, AddressBook> = 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<AddressBookCryptoError>.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
}
}

View file

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

View file

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

View file

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

View file

@ -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, // TODO Will come from BE in [REDACTED_TASK_KEY]
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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<UserWallet.Cold> {
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 <T> Either<AddressBookCryptoError, T>.rightValue(): T =
getOrNull() ?: error("Expected Either.Right but was $this")
private fun <T> Either<AddressBookCryptoError, T>.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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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