Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 13:24:29 +04:00
parent 9e986a6cb3
commit 1cfaf5a283
19 changed files with 830 additions and 56 deletions

View file

@ -13,6 +13,8 @@ import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallenge
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -36,6 +38,7 @@ internal class TangemSdkManagerModule {
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
): TangemSdkManager {
return if (BuildConfig.MOCK_DATA_SOURCE) {
MockTangemSdkManager(resources = context.resources)
@ -50,6 +53,7 @@ internal class TangemSdkManagerModule {
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,
walletRegistrationLauncher = walletRegistrationLauncher,
)
}
}

View file

@ -58,8 +58,10 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
@ -77,6 +79,9 @@ internal class DefaultTangemSdkManager(
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository,
// Lazy breaks a DI cycle: the launcher -> hot wallet accessor -> LegacySettingsRepository ->
// TangemSdkManager. It's only needed when a scan actually runs.
private val walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
) : TangemSdkManager {
private val tangemSdk: TangemSdk
@ -147,10 +152,11 @@ internal class DefaultTangemSdkManager(
card = null,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,
sessionCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
cardRepository = cardRepository,
walletRegistrationLauncher = walletRegistrationLauncher.get(),
),
cardId = cardId,
initialMessage = message,

View file

@ -34,7 +34,10 @@ import com.tangem.operations.files.ReadFilesTask
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
import com.tangem.tap.domain.TapSdkError
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
import com.tangem.tap.mainScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -42,10 +45,11 @@ import kotlinx.coroutines.launch
internal class ScanProductTask(
private val card: Card?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val sessionCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean,
private val cardRepository: CardRepository,
private val walletRegistrationLauncher: WalletRegistrationLauncher? = null,
override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> {
@ -97,7 +101,7 @@ internal class ScanProductTask(
val processorScanResponseWithNewCard = processorResult.data.copy(
card = CardDTO(scanTaskResult.data),
)
callback(CompletionResult.Success(processorScanResponseWithNewCard))
registerColdWalletThenComplete(session, processorScanResponseWithNewCard, callback)
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(scanTaskResult.error))
}
@ -107,6 +111,38 @@ internal class ScanProductTask(
}
}
/**
* Best-effort COLD wallet registration with the Auth Service while the session is still open
* (the card is tapped for `AttestWalletKeyTask`); the network POST is deferred by the launcher.
* The in-session part (nonce request + attestation) runs before the scan completes, so it can
* extend the scan slightly but it never *fails* the scan: on any error the scan still
* completes successfully.
*/
private fun registerColdWalletThenComplete(
session: CardSession,
scanResponse: ScanResponse,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val scope = sessionCoroutineScope
val launcher = walletRegistrationLauncher
if (scope == null || launcher == null) {
TangemLogger.i("Skipping cold wallet registration: coroutine scope or launcher unavailable")
callback(CompletionResult.Success(scanResponse))
return
}
scope.launch {
try {
runSuspendCatching { launcher.registerColdInSession(session, scanResponse) }
.onFailure { TangemLogger.e("Cold wallet registration failed", it) }
} finally {
// Always complete the scan, even if the registration coroutine is cancelled
// (runSuspendCatching rethrows CancellationException) — the scan never depends on
// the registration outcome.
callback(CompletionResult.Success(scanResponse))
}
}
}
override fun preflightReadMode(): PreflightReadMode {
return if (shouldCheckIsAlreadyActivated) {
PreflightReadMode.FullCardReadWithAccessCodeCheck
@ -138,12 +174,12 @@ internal class ScanProductTask(
return
}
visaCoroutineScope ?: run {
sessionCoroutineScope ?: run {
callback(CompletionResult.Failure(TangemSdkError.InsNotSupported()))
return
}
visaCoroutineScope.launch {
sessionCoroutineScope.launch {
when (val result = visaCardScanHandler.handleVisaCardScan(session = session)) {
is CompletionResult.Success -> {
scanWalletProcessor.proceed(

View file

@ -31,7 +31,7 @@ class FinalizeTwinTask(
ScanProductTask(
card = readResult.data,
visaCardScanHandler = null,
visaCoroutineScope = null,
sessionCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
onboardingV2FeatureToggles = null,
cardRepository = cardRepository,

View file

@ -0,0 +1,109 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.CompletionResult
import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.lib.auth.session.WalletSignatureBundle
import com.tangem.lib.auth.session.WalletSigner
import com.tangem.operations.attestation.AttestWalletKeyResponse
import com.tangem.operations.attestation.AttestWalletKeyTask
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume
/**
* Builds a [WalletSigner] for a COLD (card-backed) wallet. Runs `AttestWalletKeyTask` in Dynamic
* mode **inside an already-open [CardSession]** (no extra tap): the card produces the wallet
* signature and, on COS 2.01+, the card signature over the wallet nonce. On older cards without a
* card signature the wallet is registered with the wallet signature only (backend treats it as hot).
*/
internal class ColdWalletRegistrationSigner @Inject constructor() {
fun signerFor(session: CardSession, scanResponse: ScanResponse): WalletSigner = WalletSigner { nonceBytes ->
val card = scanResponse.card
val walletPublicKey = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }?.publicKey
?: error("No secp256k1 wallet on card ${card.cardId}")
buildBundle(
response = attest(session, walletPublicKey, nonceBytes),
walletPublicKey = walletPublicKey,
cardPublicKey = card.cardPublicKey,
nonceBytes = nonceBytes,
)
}
/**
* Pure mapping of an [AttestWalletKeyResponse] to a [WalletSignatureBundle] (no card session)
* unit-testable in isolation.
*/
fun buildBundle(
response: AttestWalletKeyResponse,
walletPublicKey: ByteArray,
cardPublicKey: ByteArray,
nonceBytes: ByteArray,
): WalletSignatureBundle {
val salt = response.salt
val walletSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
signature = response.walletSignature,
hash = (nonceBytes + salt).calculateSha256(),
publicKey = walletPublicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
val cardSignature = response.cardSignature
val publicKeySalt = response.publicKeySalt
// The dynamic card signature (proving the card owns the wallet) requires COS 2.01+. On older
// cards it is absent — we then register with the wallet signature only, and the backend
// treats such a wallet as hot (no card-ownership proof).
if (cardSignature == null || publicKeySalt == null) {
return WalletSignatureBundle(
walletSignature = walletSignatureRsv,
walletSignatureSalt = salt,
cardSignature = null,
cardSignatureSalt = null,
walletStatusByte = null,
)
}
val walletStatusByte = response.walletStatus?.code?.toByte()
// Card-signature preimage: walletPublicKey | challenge | publicKeySalt [| walletStatus].
// The walletStatus byte is appended only when the card reports it (COS 6+).
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt +
(walletStatusByte?.let { byteArrayOf(it) } ?: ByteArray(size = 0))
val cardSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
signature = cardSignature,
hash = cardMessage.calculateSha256(),
publicKey = cardPublicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
return WalletSignatureBundle(
walletSignature = walletSignatureRsv,
walletSignatureSalt = salt,
cardSignature = cardSignatureRsv,
cardSignatureSalt = publicKeySalt,
walletStatusByte = walletStatusByte,
)
}
private suspend fun attest(
session: CardSession,
walletPublicKey: ByteArray,
nonceBytes: ByteArray,
): AttestWalletKeyResponse {
val result = suspendCancellableCoroutine { continuation ->
AttestWalletKeyTask(publicKey = walletPublicKey, challenge = nonceBytes)
.run(session) { if (continuation.isActive) continuation.resume(it) }
}
return when (result) {
is CompletionResult.Success -> result.data
is CompletionResult.Failure -> throw ColdWalletAttestationException(result.error.customMessage)
}
}
}
/** `AttestWalletKeyTask` failed (NFC error, verification failure, user cancelled). */
internal class ColdWalletAttestationException(message: String) :
Exception("Cold wallet attestation failed: $message")

View file

@ -0,0 +1,14 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
import javax.inject.Inject
internal class DefaultWalletRegistrationTrigger @Inject constructor(
private val launcher: WalletRegistrationLauncher,
) : WalletRegistrationTrigger {
override suspend fun onMobileWalletCreated(userWallet: UserWallet.Hot) {
launcher.registerMobile(userWallet)
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.blockchain.common.UnmarshalHelper
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toDecompressedPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.lib.auth.session.WalletSignatureBundle
import com.tangem.lib.auth.session.WalletSigner
import java.security.SecureRandom
import javax.inject.Inject
/**
* Builds a [WalletSigner] for a MOBILE (hot/software) wallet. The wallet's secp256k1 key signs
* `sha256(nonceBytes || walletSignatureSalt)` via the hot wallet SDK; there is no card, so the
* card-signature fields stay null.
*/
internal class MobileWalletRegistrationSigner @Inject constructor(
private val hotWalletAccessor: HotWalletAccessor,
) {
fun signerFor(userWallet: UserWallet.Hot): WalletSigner = WalletSigner { nonceBytes ->
val wallet = userWallet.wallets
?.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
?: error("No secp256k1 wallet available for hot wallet ${userWallet.walletId}")
val salt = ByteArray(SALT_SIZE).also(secureRandom::nextBytes)
val hash = (nonceBytes + salt).calculateSha256()
val signature = hotWalletAccessor.signHashes(
hotWalletId = userWallet.hotWalletId,
dataToSign = listOf(DataToSign(curve = EllipticCurve.Secp256k1, hashes = listOf(hash))),
).first().signatures.first()
val rsvSignature = UnmarshalHelper.unmarshalSignatureExtended(
signature = signature,
hash = hash,
publicKey = wallet.publicKey.toDecompressedPublicKey(),
).asRSVLegacyEVM()
WalletSignatureBundle(
walletSignature = rsvSignature,
walletSignatureSalt = salt,
cardSignature = null,
cardSignatureSalt = null,
walletStatusByte = null,
)
}
private companion object {
const val SALT_SIZE = 16
val secureRandom = SecureRandom()
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.tap.domain.walletregistration
import android.util.Base64
import arrow.core.getOrElse
import com.tangem.common.core.CardSession
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.WalletRegistrar
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single entry point that registers wallets with the Tangem Auth Service. Gated by the backend-auth
* feature toggle; all failures are log-only (registration is retried on the next launch / card scan,
* never blocks the user). MOBILE wallets register without UI; COLD wallets attest inside a live
* card session (no extra tap) and POST after the session closes.
*/
internal class WalletRegistrationLauncher @Inject constructor(
private val walletRegistrar: WalletRegistrar,
private val mobileSigner: MobileWalletRegistrationSigner,
private val coldSigner: ColdWalletRegistrationSigner,
private val authFeatureToggles: AuthFeatureToggles,
private val appCoroutineScope: AppCoroutineScope,
) {
/**
* Never throws (beyond cooperative cancellation) any unexpected failure is caught and logged,
* so callers relying on the fire-and-forget contract stay safe.
*/
suspend fun registerMobile(userWallet: UserWallet.Hot) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
runSuspendCatching {
walletRegistrar.register(
walletId = userWallet.walletId.toBase64(),
signer = mobileSigner.signerFor(userWallet),
).onLeft { TangemLogger.e("Mobile wallet registration deferred: $it") }
}.onFailure { TangemLogger.e("Mobile wallet registration failed", it) }
}
/**
* COLD registration. Phase 1 ([WalletRegistrar.prepare]) runs inside the still-open [session]
* (the card is tapped here); phase 2 (the network POST) is dispatched on [appCoroutineScope]
* after this returns, so the user doesn't hold the card during the request. Call this BEFORE
* the scan completes its session callback.
*/
suspend fun registerColdInSession(session: CardSession, scanResponse: ScanResponse) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build()?.toBase64() ?: return
val prepared = walletRegistrar.prepare(walletId, coldSigner.signerFor(session, scanResponse))
.getOrElse { error ->
TangemLogger.e("Cold wallet registration prepare deferred: $error")
return
}
if (prepared == null) return // already registered
appCoroutineScope.launch {
runSuspendCatching {
walletRegistrar.submit(prepared)
.onLeft { TangemLogger.e("Cold wallet registration submit deferred: $it") }
}.onFailure { TangemLogger.e("Cold wallet registration submit failed", it) }
}
}
/** Launch-time safety net: registers any not-yet-registered MOBILE wallets (no UI). */
suspend fun retryMobileRegistrations(userWallets: List<UserWallet>) {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
userWallets.filterIsInstance<UserWallet.Hot>().forEach { registerMobile(it) }
}
private fun UserWalletId.toBase64(): String = Base64.encodeToString(value, Base64.NO_WRAP)
}

View file

@ -0,0 +1,15 @@
package com.tangem.tap.domain.walletregistration
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface WalletRegistrationModule {
@Binds
fun bindWalletRegistrationTrigger(impl: DefaultWalletRegistrationTrigger): WalletRegistrationTrigger
}

View file

@ -0,0 +1,142 @@
package com.tangem.tap.domain.walletregistration
import com.google.common.truth.Truth.assertThat
import com.tangem.common.card.CardWallet
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.CryptoUtils.generatePublicKey
import com.tangem.crypto.sign
import com.tangem.operations.attestation.AttestWalletKeyResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ColdWalletRegistrationSignerTest {
init {
CryptoUtils.initCrypto()
}
private val curve = EllipticCurve.Secp256k1
private val signer = ColdWalletRegistrationSigner()
private val nonceBytes = "nonce-value".toByteArray()
private val salt = ByteArray(SALT) { 7 }
private val publicKeySalt = ByteArray(SALT) { 9 }
private val walletPrivateKey = ByteArray(KEY) { 1 }
private val walletPublicKey = generatePublicKey(walletPrivateKey, curve)
private val cardPrivateKey = ByteArray(KEY) { 2 }
private val cardPublicKey = generatePublicKey(cardPrivateKey, curve)
@Test
fun `buildBundle maps card and wallet signatures to RSV and walletStatus to a single byte`() {
// Real signatures over the exact backend preimages — proves the hash layout + RSV conversion.
val status = CardWallet.Status.BackedUp // 0x82
val walletSignature = (nonceBytes + salt).sign(walletPrivateKey, curve)
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt + byteArrayOf(status.code.toByte())
val cardSignature = cardMessage.sign(cardPrivateKey, curve)
val response = response(
walletSignature = walletSignature,
cardSignature = cardSignature,
walletStatus = status,
)
val bundle = signer.buildBundle(response, walletPublicKey, cardPublicKey, nonceBytes)
assertThat(bundle.walletSignature.size).isEqualTo(RSV)
assertThat(bundle.cardSignature!!.size).isEqualTo(RSV)
// v = recId + 27 (EVM-legacy), recId in 0..3.
assertThat(bundle.walletSignature.last().toInt()).isAtLeast(EVM_V_OFFSET)
assertThat(bundle.walletSignature.last().toInt()).isAtMost(EVM_V_OFFSET + 3)
assertThat(bundle.cardSignature!!.last().toInt()).isAtLeast(EVM_V_OFFSET)
assertThat(bundle.cardSignature!!.last().toInt()).isAtMost(EVM_V_OFFSET + 3)
assertThat(bundle.walletSignatureSalt).isEqualTo(salt)
assertThat(bundle.cardSignatureSalt).isEqualTo(publicKeySalt)
assertThat(bundle.walletStatusByte).isEqualTo(0x82.toByte())
}
@Test
fun `buildBundle maps SEED-imported walletStatus to 0xC2`() {
val status = CardWallet.Status.BackedUpImported // 0xC2
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt + byteArrayOf(status.code.toByte())
val bundle = signer.buildBundle(
response = response(
walletSignature = (nonceBytes + salt).sign(walletPrivateKey, curve),
cardSignature = cardMessage.sign(cardPrivateKey, curve),
walletStatus = status,
),
walletPublicKey = walletPublicKey,
cardPublicKey = cardPublicKey,
nonceBytes = nonceBytes,
)
assertThat(bundle.walletStatusByte).isEqualTo(0xC2.toByte())
}
@Test
fun `buildBundle without walletStatus excludes it from the card preimage and leaves the byte null`() {
// COS < 6 (e.g. Wallet 1): walletStatus is null, so the card signs
// walletPublicKey | challenge | publicKeySalt WITHOUT the status byte.
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt
val bundle = signer.buildBundle(
response = response(
walletSignature = (nonceBytes + salt).sign(walletPrivateKey, curve),
cardSignature = cardMessage.sign(cardPrivateKey, curve),
walletStatus = null,
),
walletPublicKey = walletPublicKey,
cardPublicKey = cardPublicKey,
nonceBytes = nonceBytes,
)
assertThat(bundle.cardSignature!!.size).isEqualTo(RSV)
assertThat(bundle.cardSignature!!.last().toInt()).isAtLeast(EVM_V_OFFSET)
assertThat(bundle.cardSignature!!.last().toInt()).isAtMost(EVM_V_OFFSET + 3)
assertThat(bundle.cardSignatureSalt).isEqualTo(publicKeySalt)
assertThat(bundle.walletStatusByte).isNull()
}
@Test
fun `buildBundle without a card signature returns a wallet-signature-only bundle (treated as hot)`() {
// COS < 2.01: no card signature — register with the wallet signature only, no card fields.
val bundle = signer.buildBundle(
response = response(
walletSignature = (nonceBytes + salt).sign(walletPrivateKey, curve),
cardSignature = null,
walletStatus = null,
),
walletPublicKey = walletPublicKey,
cardPublicKey = cardPublicKey,
nonceBytes = nonceBytes,
)
assertThat(bundle.walletSignature.size).isEqualTo(RSV)
assertThat(bundle.walletSignatureSalt).isEqualTo(salt)
assertThat(bundle.cardSignature).isNull()
assertThat(bundle.cardSignatureSalt).isNull()
assertThat(bundle.walletStatusByte).isNull()
}
private fun response(
walletSignature: ByteArray,
cardSignature: ByteArray?,
walletStatus: CardWallet.Status?,
) = AttestWalletKeyResponse(
cardId = "CARD",
salt = salt,
walletSignature = walletSignature,
challenge = nonceBytes,
cardSignature = cardSignature,
publicKeySalt = publicKeySalt,
walletStatus = walletStatus,
counter = null,
)
private companion object {
const val KEY = 32
const val SALT = 16
const val RSV = 65
const val EVM_V_OFFSET = 27
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.tap.domain.walletregistration
import com.google.common.truth.Truth.assertThat
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.CryptoUtils.generatePublicKey
import com.tangem.crypto.Secp256k1
import com.tangem.domain.models.MobileWallet
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.hot.sdk.model.SignedData
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MobileWalletRegistrationSignerTest {
init {
CryptoUtils.initCrypto()
}
private val curve = EllipticCurve.Secp256k1
private val hotWalletAccessor: HotWalletAccessor = mockk()
private val signer = MobileWalletRegistrationSigner(hotWalletAccessor)
private val walletPrivateKey = ByteArray(KEY) { 1 }
private val walletPublicKey = generatePublicKey(walletPrivateKey, curve)
private val nonceBytes = "nonce-value".toByteArray()
@Test
fun `signer produces 65-byte RSV over the wallet nonce with null card fields`() = runTest {
val userWallet = hotWallet()
// The hot SDK signs the provided hash directly (no re-hash), like the real signHashes.
coEvery { hotWalletAccessor.signHashes(any(), any()) } answers {
val hash = secondArg<List<DataToSign>>().first().hashes.first()
listOf(SignedData(curve = curve, signatures = listOf(Secp256k1.ecdsaSignDigest(hash, walletPrivateKey))))
}
val bundle = signer.signerFor(userWallet).sign(nonceBytes)
assertThat(bundle.walletSignature.size).isEqualTo(RSV)
assertThat(bundle.walletSignature.last().toInt()).isAtLeast(EVM_V_OFFSET)
assertThat(bundle.walletSignature.last().toInt()).isAtMost(EVM_V_OFFSET + 3)
assertThat(bundle.walletSignatureSalt.size).isEqualTo(SALT)
assertThat(bundle.cardSignature).isNull()
assertThat(bundle.cardSignatureSalt).isNull()
assertThat(bundle.walletStatusByte).isNull()
}
private fun hotWallet(): UserWallet.Hot {
val mobileWallet = mockk<MobileWallet>()
every { mobileWallet.curve } returns EllipticCurve.Secp256k1
every { mobileWallet.publicKey } returns walletPublicKey
val userWallet = mockk<UserWallet.Hot>()
every { userWallet.wallets } returns listOf(mobileWallet)
every { userWallet.hotWalletId } returns mockk<HotWalletId>()
every { userWallet.walletId } returns UserWalletId(value = ByteArray(KEY) { 5 })
return userWallet
}
private companion object {
const val KEY = 32
const val SALT = 16
const val RSV = 65
const val EVM_V_OFFSET = 27
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.tap.domain.walletregistration
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.WalletRegistrar
import com.tangem.lib.auth.session.WalletSigner
import com.tangem.utils.coroutines.AppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class WalletRegistrationLauncherTest {
private val walletRegistrar: WalletRegistrar = mockk()
private val mobileSigner: MobileWalletRegistrationSigner = mockk()
private val coldSigner: ColdWalletRegistrationSigner = mockk()
private val authFeatureToggles: AuthFeatureToggles = mockk()
private val appCoroutineScope: AppCoroutineScope = mockk(relaxed = true)
private val launcher = WalletRegistrationLauncher(
walletRegistrar = walletRegistrar,
mobileSigner = mobileSigner,
coldSigner = coldSigner,
authFeatureToggles = authFeatureToggles,
appCoroutineScope = appCoroutineScope,
)
@BeforeEach
fun setup() {
clearMocks(walletRegistrar, mobileSigner, authFeatureToggles)
mockkStatic(android.util.Base64::class)
every { android.util.Base64.encodeToString(any(), any()) } answers {
java.util.Base64.getEncoder().encodeToString(firstArg())
}
every { mobileSigner.signerFor(any()) } returns mockk<WalletSigner>()
coEvery { walletRegistrar.register(any(), any()) } returns Unit.right()
}
@AfterEach
fun teardown() = unmockkAll()
@Test
fun `registerMobile is a no-op when backend auth is disabled`() = runTest {
every { authFeatureToggles.isBackendAuthenticationEnabled } returns false
launcher.registerMobile(hotWallet())
coVerify(exactly = 0) { walletRegistrar.register(any(), any()) }
}
@Test
fun `registerMobile registers with the Base64 walletId`() = runTest {
every { authFeatureToggles.isBackendAuthenticationEnabled } returns true
val walletIdBytes = ByteArray(32) { 5 }
val slot = slot<String>()
coEvery { walletRegistrar.register(capture(slot), any()) } returns Unit.right()
launcher.registerMobile(hotWallet(walletIdBytes))
assertThat(slot.captured).isEqualTo(java.util.Base64.getEncoder().encodeToString(walletIdBytes))
}
@Test
fun `retryMobileRegistrations registers only hot wallets`() = runTest {
every { authFeatureToggles.isBackendAuthenticationEnabled } returns true
launcher.retryMobileRegistrations(listOf(hotWallet(), mockk<UserWallet.Cold>()))
coVerify(exactly = 1) { walletRegistrar.register(any(), any()) }
}
private fun hotWallet(walletIdValue: ByteArray = ByteArray(32) { 1 }): UserWallet.Hot {
val wallet = mockk<UserWallet.Hot>()
every { wallet.walletId } returns UserWalletId(value = walletIdValue)
return wallet
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.wallets.registration
import com.tangem.domain.models.wallet.UserWallet
/**
* Domain port for triggering Tangem Auth Service wallet registration.
*
* Fire-and-forget from the caller's perspective: implementations must not throw registration is
* best-effort and retried later.
*/
interface WalletRegistrationTrigger {
/** Registers a freshly created/imported MOBILE (hot) wallet while its unlock context is fresh. */
suspend fun onMobileWalletCreated(userWallet: UserWallet.Hot)
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.wallets.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
@ -15,6 +16,7 @@ class CreateHotWalletUseCase @Inject constructor(
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase,
private val walletRegistrationTrigger: WalletRegistrationTrigger,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either<Throwable, UserWallet.Hot> {
@ -28,6 +30,11 @@ class CreateHotWalletUseCase @Inject constructor(
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
}
// Register the wallet with the Auth Service while its unlock context is still fresh.
appCoroutineScope.launch {
walletRegistrationTrigger.onMobileWalletCreated(userWallet)
}
userWallet
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth.session
import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest
/**
* Opaque result of [WalletRegistrar.prepare] the fully assembled, signed wallet-registration
* request, ready to be sent by [WalletRegistrar.submit]. Callers hold it between the in-session
* signing phase and the after-session network phase without inspecting its contents.
*/
class PreparedWalletRegistration internal constructor(
internal val walletId: String,
internal val request: WalletRegistrationRequest,
)

View file

@ -22,8 +22,25 @@ import arrow.core.Either
interface WalletRegistrar {
/**
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to
* produce the signature material over the deciphered wallet nonce.
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`) in one shot: [prepare]
* followed by [submit]. Use this when there is no session/UX constraint (MOBILE wallets).
*/
suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit>
/**
* Phase 1: idempotency check, nonce request/decryption, and signing via [signer]. For COLD
* wallets this must run while the card session is open (the signer taps the card). Returns the
* assembled registration to hand to [submit] later, or `null` if the wallet is already
* registered (no-op). Performs NO network write pair it with [submit] after the session closes.
*/
suspend fun prepare(
walletId: String,
signer: WalletSigner,
): Either<WalletRegistrationError, PreparedWalletRegistration?>
/**
* Phase 2: sends the [prepared] registration, persists the reissued tokens, and marks the wallet
* registered. No card needed safe to run after the session has closed.
*/
suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit>
}

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.session.AuthError
import com.tangem.lib.auth.session.PreparedWalletRegistration
import com.tangem.lib.auth.session.WalletRegistrar
import com.tangem.lib.auth.session.WalletRegistrationError
import com.tangem.lib.auth.session.WalletSigner
@ -45,13 +46,38 @@ internal class DefaultWalletRegistrar(
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
withContext(dispatchers.io) {
getMutex(walletId).withLock { runRegister(walletId, signer) }
// Hold the per-wallet lock across the WHOLE one-shot flow (prepare + submit), so two
// concurrent register() calls for the same wallet can't both pass the idempotency check
// and consume/sign separate nonces. Calls the internal steps directly instead of
// re-entering the public prepare()/submit() (which would take the same lock again).
getMutex(walletId).withLock {
either {
val prepared = runPrepare(walletId, signer).bind() ?: return@either
handleRegisterResponse(prepared.walletId, authApi.registerWallet(prepared.request))
}
}
}
override suspend fun prepare(
walletId: String,
signer: WalletSigner,
): Either<WalletRegistrationError, PreparedWalletRegistration?> = withContext(dispatchers.io) {
getMutex(walletId).withLock { runPrepare(walletId, signer) }
}
override suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit> =
withContext(dispatchers.io) {
getMutex(prepared.walletId).withLock {
either { handleRegisterResponse(prepared.walletId, authApi.registerWallet(prepared.request)) }
}
}
private fun getMutex(walletId: String): Mutex = mutexes.computeIfAbsent(walletId) { Mutex() }
private suspend fun runRegister(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
either {
private suspend fun runPrepare(
walletId: String,
signer: WalletSigner,
): Either<WalletRegistrationError, PreparedWalletRegistration?> = either {
val isAlreadyRegistered = try {
walletId in registeredWalletIds()
} catch (e: Exception) {
@ -60,10 +86,10 @@ internal class DefaultWalletRegistrar(
}
if (isAlreadyRegistered) {
TangemLogger.i("Wallet already registered — skipping /wallet")
return@either
return@either null
}
TangemLogger.i("Starting wallet registration")
TangemLogger.i("Preparing wallet registration")
val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull()
?: raise(WalletRegistrationError.DeviceKeyUnavailable)
@ -93,7 +119,9 @@ internal class DefaultWalletRegistrar(
raise(WalletRegistrationError.SigningFailed(e))
}
val request = WalletRegistrationRequest(
PreparedWalletRegistration(
walletId = walletId,
request = WalletRegistrationRequest(
nonce = nonce,
walletId = walletId,
walletSignature = bundle.walletSignature.toBase64NoWrap(),
@ -103,9 +131,8 @@ internal class DefaultWalletRegistrar(
walletStatus = bundle.walletStatusByte?.let { byteArrayOf(it).toBase64NoWrap() },
attestationToken = null,
metadata = signedRequestPayload.deviceMetadata,
),
)
handleRegisterResponse(walletId = walletId, response = authApi.registerWallet(request))
}
private suspend fun Raise<WalletRegistrationError>.handleRegisterResponse(

View file

@ -2,6 +2,7 @@ package com.tangem.lib.auth.session.internal
import arrow.core.Either
import arrow.core.left
import com.tangem.lib.auth.session.PreparedWalletRegistration
import com.tangem.lib.auth.session.WalletRegistrar
import com.tangem.lib.auth.session.WalletRegistrationError
import com.tangem.lib.auth.session.WalletSigner
@ -10,7 +11,14 @@ import com.tangem.utils.annotations.RemoveWithToggle
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
internal object DisabledWalletRegistrar : WalletRegistrar {
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> {
return WalletRegistrationError.Disabled.left()
}
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
WalletRegistrationError.Disabled.left()
override suspend fun prepare(
walletId: String,
signer: WalletSigner,
): Either<WalletRegistrationError, PreparedWalletRegistration?> = WalletRegistrationError.Disabled.left()
override suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit> =
WalletRegistrationError.Disabled.left()
}

View file

@ -271,6 +271,63 @@ class DefaultWalletRegistrarTest {
assertThat(registeredIds()).doesNotContain(WALLET_ID)
}
@Test
fun `prepare returns null when walletId already registered`() = runTest {
preferencesDataStore.edit { it[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = setOf(WALLET_ID) }
val result = registrar.prepare(WALLET_ID, mobileSigner)
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isNull()
coVerify(exactly = 0) { authApi.requestWalletNonce(any()) }
}
@Test
fun `prepare builds the request without posting or persisting anything`() = runTest {
stubHappyPath()
val result = registrar.prepare(WALLET_ID, mobileSigner)
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isNotNull()
coVerify(exactly = 0) { authApi.registerWallet(any()) }
coVerify(exactly = 0) { store.save(any()) }
assertThat(registeredIds()).doesNotContain(WALLET_ID)
}
@Test
fun `submit posts the prepared request, persists tokens and marks registered`() = runTest {
stubHappyPath()
coEvery { authApi.registerWallet(any()) } returns tokenSuccess()
val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!!
val result = registrar.submit(prepared)
assertThat(result.isRight()).isTrue()
coVerify { store.save(any()) }
assertThat(registeredIds()).contains(WALLET_ID)
}
@Test
fun `submit treats 409 Conflict as success and marks registered without persisting tokens`() = runTest {
stubHappyPath()
val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!!
@Suppress("UNCHECKED_CAST")
coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.CONFLICT,
message = "wallet already registered",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
val result = registrar.submit(prepared)
assertThat(result.isRight()).isTrue()
assertThat(registeredIds()).contains(WALLET_ID)
coVerify(exactly = 0) { store.save(any()) }
}
private fun stubHappyPath() {
coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()