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
}