Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 17:01:57 +01:00
parent e8cba7d699
commit 75cefb2fba
11 changed files with 247 additions and 42 deletions

View file

@ -26,7 +26,7 @@ import javax.crypto.spec.SecretKeySpec
* 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
* The produced [AddressBookBlob] keeps the GCM authentication tag in a separate `authTag` 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].
@ -41,8 +41,6 @@ class AddressBookCipher {
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)
@ -55,7 +53,7 @@ class AddressBookCipher {
val authTag = cipherWithTag.copyOfRange(fromIndex = tagOffset, toIndex = cipherWithTag.size)
AddressBookBlob(
walletId = addressBook.walletId.stringValue,
walletId = userWallet.walletId.stringValue,
updatedAt = updatedAt.withZone(DateTimeZone.UTC).toString(),
nonce = nonce.toHexString().lowercase(),
ciphertext = ciphertext.toHexString().lowercase(),

View file

@ -1,15 +1,10 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.SerialName
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,
@SerialName("contacts")
val contacts: List<Contact>,
)

View file

@ -19,18 +19,24 @@ import kotlinx.serialization.Serializable
* "updatedAt": "2026-05-22T09:00:00.000Z",
* "nonce": "",
* "ciphertext": "",
* "auth_tag": ""
* "authTag": ""
* }
* ```
*/
@Serializable
data class AddressBookBlob(
@SerialName("version")
val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY]
@SerialName("walletId")
val walletId: String,
@SerialName("updatedAt")
val updatedAt: String,
@SerialName("nonce")
val nonce: String,
@SerialName("ciphertext")
val ciphertext: String,
@SerialName("auth_tag") val authTag: String,
@SerialName("authTag")
val authTag: String,
) {
companion object {

View file

@ -1,15 +1,24 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.serialization.NetworkRawIdAsStringSerializer
import com.tangem.domain.models.network.Network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A single saved address belonging to a [Contact]. */
@Serializable
data class AddressEntry(
@SerialName("id")
val id: AddressEntryId,
@SerialName("address")
val address: String,
@SerialName("networkId")
@Serializable(with = NetworkRawIdAsStringSerializer::class)
val networkId: Network.RawID,
@SerialName("networkName")
val networkName: String,
@SerialName("memo")
val memo: String?,
@SerialName("signature")
val signature: String,
)

View file

@ -1,6 +1,9 @@
package com.tangem.domain.addressbook.model
import com.tangem.domain.addressbook.model.serialization.ContactNameAsStringSerializer
import com.tangem.domain.addressbook.model.serialization.UserWalletIdAsStringSerializer
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
@ -12,12 +15,22 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class Contact(
@SerialName("id")
val id: ContactId,
@SerialName("walletId")
@Serializable(with = UserWalletIdAsStringSerializer::class)
val walletId: UserWalletId,
@SerialName("name")
@Serializable(with = ContactNameAsStringSerializer::class)
val name: ContactName,
@SerialName("icon")
val icon: String,
@SerialName("iconColor")
val iconColor: String,
@SerialName("createdAt")
val createdAt: String,
@SerialName("updatedAt")
val updatedAt: String,
@SerialName("addresses")
val addresses: List<AddressEntry>,
)

View file

@ -0,0 +1,56 @@
package com.tangem.domain.addressbook.model.serialization
import arrow.core.getOrElse
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/*
* The encrypted address-book payload is a cross-platform (iOS) contract. It carries wallet id, contact
* name and network id as bare strings, so the wrapper domain types must serialize to their underlying
* string rather than the default `{"field": }` object. These serializers are applied per-property via
* `@Serializable(with = )`, leaving the global serialization of the shared types untouched.
*/
/** Serializes [UserWalletId] as its bare [UserWalletId.stringValue]. */
internal object UserWalletIdAsStringSerializer : KSerializer<UserWalletId> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UserWalletId", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UserWalletId) = encoder.encodeString(value.stringValue)
override fun deserialize(decoder: Decoder): UserWalletId = UserWalletId(decoder.decodeString())
}
/** Serializes [Network.RawID] as its bare [Network.RawID.value]. */
internal object NetworkRawIdAsStringSerializer : KSerializer<Network.RawID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Network.RawID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Network.RawID) = encoder.encodeString(value.value)
override fun deserialize(decoder: Decoder): Network.RawID = Network.RawID(decoder.decodeString())
}
/**
* Serializes [ContactName] as its bare [ContactName.value]. On read the string goes back through the
* validating [ContactName.invoke] gateway; an invalid name surfaces as a [SerializationException] (the
* cipher maps it to `MalformedBlob`).
*/
internal object ContactNameAsStringSerializer : KSerializer<ContactName> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ContactName", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ContactName) = encoder.encodeString(value.value)
override fun deserialize(decoder: Decoder): ContactName {
val raw = decoder.decodeString()
return ContactName(raw).getOrElse { error ->
throw SerializationException("Invalid contact name in address-book payload: $error")
}
}
}

View file

@ -127,18 +127,6 @@ internal class AddressBookCipherTest {
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
@ -212,7 +200,7 @@ internal class AddressBookCipherTest {
every { card } returns mockk { every { wallets } returns emptyList() }
}
}
val book = addressBook(walletId = wallet.walletId)
val book = addressBook()
// Act
val result = cipher.encrypt(book, lockedWallet, updatedAt)
@ -259,10 +247,7 @@ internal class AddressBookCipherTest {
// 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())
AddressBook(contacts = contacts.toList())
private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact(
id = ContactId("contact-$name"),

View file

@ -0,0 +1,105 @@
package com.tangem.domain.addressbook.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import org.junit.jupiter.api.Test
/**
* Locks the JSON shape of the encrypted address-book payload a cross-platform (iOS) contract. Wallet id,
* contact name and network id must be bare strings, not `{"field": }` objects, so a Kotlin type change
* can't silently break interop.
*/
internal class AddressBookSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `GIVEN a contact WHEN serialized THEN wrapper types are plain strings`() {
// Arrange
val contact = Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("0a0a0a"),
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-05-22T09:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = null,
signature = "",
),
),
)
// Act
val obj = json.parseToJsonElement(json.encodeToString(Contact.serializer(), contact)).jsonObject
// Assert
assertThat(obj["id"]).isEqualTo(JsonPrimitive("contact-1"))
assertThat(obj["walletId"]).isEqualTo(JsonPrimitive("0a0a0a"))
assertThat(obj["name"]).isEqualTo(JsonPrimitive("Alice"))
val entry = obj["addresses"]!!.jsonArray.single().jsonObject
assertThat(entry["id"]).isEqualTo(JsonPrimitive("addr-1"))
assertThat(entry["networkId"]).isEqualTo(JsonPrimitive("ethereum"))
}
@Test
fun `GIVEN serialized contact WHEN deserialized THEN original is restored`() {
// Arrange
val book = AddressBook(
contacts = listOf(
Contact(
id = ContactId("contact-1"),
walletId = UserWalletId("0a0a0a"),
name = requireNotNull(ContactName("Alice").getOrNull()),
icon = "",
iconColor = "TestColor",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-05-22T09:00:00.000Z",
addresses = listOf(
AddressEntry(
id = AddressEntryId("addr-1"),
address = "0xabc",
networkId = Network.RawID("ethereum"),
networkName = "Ethereum",
memo = "memo",
signature = "sig",
),
),
),
),
)
// Act
val restored = json.decodeFromString(
AddressBook.serializer(),
json.encodeToString(AddressBook.serializer(), book),
)
// Assert
assertThat(restored).isEqualTo(book)
}
@Test
fun `GIVEN payload with an invalid contact name WHEN deserialized THEN fails`() {
// Arrange — empty name violates the ContactName rules
val payload = """{"contacts":[{"id":"c1","walletId":"0a0a0a","name":"",""" +
""""icon":"","iconColor":"c","createdAt":"t","updatedAt":"t","addresses":[]}]}"""
// Act
val error = runCatching { json.decodeFromString(AddressBook.serializer(), payload) }.exceptionOrNull()
// Assert
assertThat(error).isNotNull()
}
}