Updated on 2026-08-14
This commit is contained in:
parent
9e986a6cb3
commit
1cfaf5a283
19 changed files with 830 additions and 56 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class FinalizeTwinTask(
|
|||
ScanProductTask(
|
||||
card = readResult.data,
|
||||
visaCardScanHandler = null,
|
||||
visaCoroutineScope = null,
|
||||
sessionCoroutineScope = null,
|
||||
shouldCheckIsAlreadyActivated = false,
|
||||
onboardingV2FeatureToggles = null,
|
||||
cardRepository = cardRepository,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue