Updated on 2026-08-14
This commit is contained in:
commit
a236e11593
17 changed files with 568 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue