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.TangemPayGenerateVirtualAccountAddressTask
|
||||||
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
|
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
|
||||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||||
|
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||||
|
import dagger.Lazy
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -36,6 +38,7 @@ internal class TangemSdkManagerModule {
|
||||||
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||||
analyticsErrorHandler: AnalyticsErrorHandler,
|
analyticsErrorHandler: AnalyticsErrorHandler,
|
||||||
cardRepository: CardRepository,
|
cardRepository: CardRepository,
|
||||||
|
walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
|
||||||
): TangemSdkManager {
|
): TangemSdkManager {
|
||||||
return if (BuildConfig.MOCK_DATA_SOURCE) {
|
return if (BuildConfig.MOCK_DATA_SOURCE) {
|
||||||
MockTangemSdkManager(resources = context.resources)
|
MockTangemSdkManager(resources = context.resources)
|
||||||
|
|
@ -50,6 +53,7 @@ internal class TangemSdkManagerModule {
|
||||||
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
||||||
analyticsErrorHandler = analyticsErrorHandler,
|
analyticsErrorHandler = analyticsErrorHandler,
|
||||||
cardRepository = cardRepository,
|
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.CreateSecondTwinWalletTask
|
||||||
import com.tangem.tap.domain.twins.FinalizeTwinTask
|
import com.tangem.tap.domain.twins.FinalizeTwinTask
|
||||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||||
|
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import com.tangem.wallet.R
|
import com.tangem.wallet.R
|
||||||
|
import dagger.Lazy
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
@ -77,6 +79,9 @@ internal class DefaultTangemSdkManager(
|
||||||
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||||
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
||||||
private val cardRepository: CardRepository,
|
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 {
|
) : TangemSdkManager {
|
||||||
|
|
||||||
private val tangemSdk: TangemSdk
|
private val tangemSdk: TangemSdk
|
||||||
|
|
@ -147,10 +152,11 @@ internal class DefaultTangemSdkManager(
|
||||||
card = null,
|
card = null,
|
||||||
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
|
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
|
||||||
visaCardScanHandler = visaCardScanHandler,
|
visaCardScanHandler = visaCardScanHandler,
|
||||||
visaCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
|
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
|
||||||
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
||||||
cardRepository = cardRepository,
|
cardRepository = cardRepository,
|
||||||
|
walletRegistrationLauncher = walletRegistrationLauncher.get(),
|
||||||
),
|
),
|
||||||
cardId = cardId,
|
cardId = cardId,
|
||||||
initialMessage = message,
|
initialMessage = message,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ import com.tangem.operations.files.ReadFilesTask
|
||||||
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
|
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
|
||||||
import com.tangem.tap.domain.TapSdkError
|
import com.tangem.tap.domain.TapSdkError
|
||||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||||
|
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||||
import com.tangem.tap.mainScope
|
import com.tangem.tap.mainScope
|
||||||
|
import com.tangem.utils.coroutines.runSuspendCatching
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -42,10 +45,11 @@ import kotlinx.coroutines.launch
|
||||||
internal class ScanProductTask(
|
internal class ScanProductTask(
|
||||||
private val card: Card?,
|
private val card: Card?,
|
||||||
private val visaCardScanHandler: VisaCardScanHandler?,
|
private val visaCardScanHandler: VisaCardScanHandler?,
|
||||||
private val visaCoroutineScope: CoroutineScope?,
|
private val sessionCoroutineScope: CoroutineScope?,
|
||||||
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
|
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
|
||||||
private val shouldCheckIsAlreadyActivated: Boolean,
|
private val shouldCheckIsAlreadyActivated: Boolean,
|
||||||
private val cardRepository: CardRepository,
|
private val cardRepository: CardRepository,
|
||||||
|
private val walletRegistrationLauncher: WalletRegistrationLauncher? = null,
|
||||||
override val allowsRequestAccessCodeFromRepository: Boolean = false,
|
override val allowsRequestAccessCodeFromRepository: Boolean = false,
|
||||||
) : CardSessionRunnable<ScanResponse> {
|
) : CardSessionRunnable<ScanResponse> {
|
||||||
|
|
||||||
|
|
@ -97,7 +101,7 @@ internal class ScanProductTask(
|
||||||
val processorScanResponseWithNewCard = processorResult.data.copy(
|
val processorScanResponseWithNewCard = processorResult.data.copy(
|
||||||
card = CardDTO(scanTaskResult.data),
|
card = CardDTO(scanTaskResult.data),
|
||||||
)
|
)
|
||||||
callback(CompletionResult.Success(processorScanResponseWithNewCard))
|
registerColdWalletThenComplete(session, processorScanResponseWithNewCard, callback)
|
||||||
}
|
}
|
||||||
is CompletionResult.Failure -> callback(CompletionResult.Failure(scanTaskResult.error))
|
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 {
|
override fun preflightReadMode(): PreflightReadMode {
|
||||||
return if (shouldCheckIsAlreadyActivated) {
|
return if (shouldCheckIsAlreadyActivated) {
|
||||||
PreflightReadMode.FullCardReadWithAccessCodeCheck
|
PreflightReadMode.FullCardReadWithAccessCodeCheck
|
||||||
|
|
@ -138,12 +174,12 @@ internal class ScanProductTask(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
visaCoroutineScope ?: run {
|
sessionCoroutineScope ?: run {
|
||||||
callback(CompletionResult.Failure(TangemSdkError.InsNotSupported()))
|
callback(CompletionResult.Failure(TangemSdkError.InsNotSupported()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
visaCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
when (val result = visaCardScanHandler.handleVisaCardScan(session = session)) {
|
when (val result = visaCardScanHandler.handleVisaCardScan(session = session)) {
|
||||||
is CompletionResult.Success -> {
|
is CompletionResult.Success -> {
|
||||||
scanWalletProcessor.proceed(
|
scanWalletProcessor.proceed(
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class FinalizeTwinTask(
|
||||||
ScanProductTask(
|
ScanProductTask(
|
||||||
card = readResult.data,
|
card = readResult.data,
|
||||||
visaCardScanHandler = null,
|
visaCardScanHandler = null,
|
||||||
visaCoroutineScope = null,
|
sessionCoroutineScope = null,
|
||||||
shouldCheckIsAlreadyActivated = false,
|
shouldCheckIsAlreadyActivated = false,
|
||||||
onboardingV2FeatureToggles = null,
|
onboardingV2FeatureToggles = null,
|
||||||
cardRepository = cardRepository,
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.tangem.domain.wallets.usecase
|
||||||
import arrow.core.Either
|
import arrow.core.Either
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
|
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.TangemHotSdk
|
||||||
import com.tangem.hot.sdk.model.HotAuth
|
import com.tangem.hot.sdk.model.HotAuth
|
||||||
import com.tangem.hot.sdk.model.MnemonicType
|
import com.tangem.hot.sdk.model.MnemonicType
|
||||||
|
|
@ -15,6 +16,7 @@ class CreateHotWalletUseCase @Inject constructor(
|
||||||
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
|
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
|
||||||
private val saveWalletUseCase: SaveWalletUseCase,
|
private val saveWalletUseCase: SaveWalletUseCase,
|
||||||
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase,
|
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase,
|
||||||
|
private val walletRegistrationTrigger: WalletRegistrationTrigger,
|
||||||
private val appCoroutineScope: AppCoroutineScope,
|
private val appCoroutineScope: AppCoroutineScope,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either<Throwable, UserWallet.Hot> {
|
suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either<Throwable, UserWallet.Hot> {
|
||||||
|
|
@ -28,6 +30,11 @@ class CreateHotWalletUseCase @Inject constructor(
|
||||||
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
|
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register the wallet with the Auth Service while its unlock context is still fresh.
|
||||||
|
appCoroutineScope.launch {
|
||||||
|
walletRegistrationTrigger.onMobileWalletCreated(userWallet)
|
||||||
|
}
|
||||||
|
|
||||||
userWallet
|
userWallet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -22,8 +22,25 @@ import arrow.core.Either
|
||||||
interface WalletRegistrar {
|
interface WalletRegistrar {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to
|
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`) in one shot: [prepare]
|
||||||
* produce the signature material over the deciphered wallet nonce.
|
* followed by [submit]. Use this when there is no session/UX constraint (MOBILE wallets).
|
||||||
*/
|
*/
|
||||||
suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit>
|
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>
|
||||||
}
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
|
||||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||||
import com.tangem.lib.auth.session.AuthError
|
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.WalletRegistrar
|
||||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||||
import com.tangem.lib.auth.session.WalletSigner
|
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> =
|
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||||
withContext(dispatchers.io) {
|
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 fun getMutex(walletId: String): Mutex = mutexes.computeIfAbsent(walletId) { Mutex() }
|
||||||
|
|
||||||
private suspend fun runRegister(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
private suspend fun runPrepare(
|
||||||
either {
|
walletId: String,
|
||||||
|
signer: WalletSigner,
|
||||||
|
): Either<WalletRegistrationError, PreparedWalletRegistration?> = either {
|
||||||
val isAlreadyRegistered = try {
|
val isAlreadyRegistered = try {
|
||||||
walletId in registeredWalletIds()
|
walletId in registeredWalletIds()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -60,10 +86,10 @@ internal class DefaultWalletRegistrar(
|
||||||
}
|
}
|
||||||
if (isAlreadyRegistered) {
|
if (isAlreadyRegistered) {
|
||||||
TangemLogger.i("Wallet already registered — skipping /wallet")
|
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()
|
val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull()
|
||||||
?: raise(WalletRegistrationError.DeviceKeyUnavailable)
|
?: raise(WalletRegistrationError.DeviceKeyUnavailable)
|
||||||
|
|
@ -93,7 +119,9 @@ internal class DefaultWalletRegistrar(
|
||||||
raise(WalletRegistrationError.SigningFailed(e))
|
raise(WalletRegistrationError.SigningFailed(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
val request = WalletRegistrationRequest(
|
PreparedWalletRegistration(
|
||||||
|
walletId = walletId,
|
||||||
|
request = WalletRegistrationRequest(
|
||||||
nonce = nonce,
|
nonce = nonce,
|
||||||
walletId = walletId,
|
walletId = walletId,
|
||||||
walletSignature = bundle.walletSignature.toBase64NoWrap(),
|
walletSignature = bundle.walletSignature.toBase64NoWrap(),
|
||||||
|
|
@ -103,9 +131,8 @@ internal class DefaultWalletRegistrar(
|
||||||
walletStatus = bundle.walletStatusByte?.let { byteArrayOf(it).toBase64NoWrap() },
|
walletStatus = bundle.walletStatusByte?.let { byteArrayOf(it).toBase64NoWrap() },
|
||||||
attestationToken = null,
|
attestationToken = null,
|
||||||
metadata = signedRequestPayload.deviceMetadata,
|
metadata = signedRequestPayload.deviceMetadata,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
handleRegisterResponse(walletId = walletId, response = authApi.registerWallet(request))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun Raise<WalletRegistrationError>.handleRegisterResponse(
|
private suspend fun Raise<WalletRegistrationError>.handleRegisterResponse(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
import arrow.core.Either
|
import arrow.core.Either
|
||||||
import arrow.core.left
|
import arrow.core.left
|
||||||
|
import com.tangem.lib.auth.session.PreparedWalletRegistration
|
||||||
import com.tangem.lib.auth.session.WalletRegistrar
|
import com.tangem.lib.auth.session.WalletRegistrar
|
||||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||||
import com.tangem.lib.auth.session.WalletSigner
|
import com.tangem.lib.auth.session.WalletSigner
|
||||||
|
|
@ -10,7 +11,14 @@ import com.tangem.utils.annotations.RemoveWithToggle
|
||||||
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||||
internal object DisabledWalletRegistrar : WalletRegistrar {
|
internal object DisabledWalletRegistrar : WalletRegistrar {
|
||||||
|
|
||||||
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> {
|
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||||
return WalletRegistrationError.Disabled.left()
|
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()
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +271,63 @@ class DefaultWalletRegistrarTest {
|
||||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
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() {
|
private fun stubHappyPath() {
|
||||||
coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
|
coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
|
||||||
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue