From 1cfaf5a283fba2fe6f1fb21a52ebcf8b531b766b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Jul 2026 13:24:29 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/tap/di/TangemSdkManagerModule.kt | 4 + .../sdk/impl/DefaultTangemSdkManager.kt | 8 +- .../domain/tasks/product/ScanProductTask.kt | 44 +++++- .../tap/domain/twins/FinalizeTwinTask.kt | 2 +- .../ColdWalletRegistrationSigner.kt | 109 ++++++++++++++ .../DefaultWalletRegistrationTrigger.kt | 14 ++ .../MobileWalletRegistrationSigner.kt | 56 +++++++ .../WalletRegistrationLauncher.kt | 82 ++++++++++ .../WalletRegistrationModule.kt | 15 ++ .../ColdWalletRegistrationSignerTest.kt | 142 ++++++++++++++++++ .../MobileWalletRegistrationSignerTest.kt | 75 +++++++++ .../WalletRegistrationLauncherTest.kt | 91 +++++++++++ .../registration/WalletRegistrationTrigger.kt | 15 ++ .../wallets/usecase/CreateHotWalletUseCase.kt | 7 + .../session/PreparedWalletRegistration.kt | 13 ++ .../lib/auth/session/WalletRegistrar.kt | 21 ++- .../internal/DefaultWalletRegistrar.kt | 117 +++++++++------ .../internal/DisabledWalletRegistrar.kt | 14 +- .../internal/DefaultWalletRegistrarTest.kt | 57 +++++++ 19 files changed, 830 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSigner.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/walletregistration/DefaultWalletRegistrationTrigger.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSigner.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncher.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationModule.kt create mode 100644 app/src/test/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSignerTest.kt create mode 100644 app/src/test/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSignerTest.kt create mode 100644 app/src/test/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncherTest.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/registration/WalletRegistrationTrigger.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/PreparedWalletRegistration.kt diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index e24a130c14..6601fa658b 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -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, ): 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, ) } } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 0a6e31fb97..908050c97c 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -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, ) : 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, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 7476e9dde4..3900771f64 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -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 { @@ -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) -> 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( diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index cba0726923..79b8764e30 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -31,7 +31,7 @@ class FinalizeTwinTask( ScanProductTask( card = readResult.data, visaCardScanHandler = null, - visaCoroutineScope = null, + sessionCoroutineScope = null, shouldCheckIsAlreadyActivated = false, onboardingV2FeatureToggles = null, cardRepository = cardRepository, diff --git a/app/src/main/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSigner.kt b/app/src/main/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSigner.kt new file mode 100644 index 0000000000..6d5d835e13 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSigner.kt @@ -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") \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletregistration/DefaultWalletRegistrationTrigger.kt b/app/src/main/java/com/tangem/tap/domain/walletregistration/DefaultWalletRegistrationTrigger.kt new file mode 100644 index 0000000000..c78cbd8506 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/walletregistration/DefaultWalletRegistrationTrigger.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSigner.kt b/app/src/main/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSigner.kt new file mode 100644 index 0000000000..b529915e64 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSigner.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncher.kt b/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncher.kt new file mode 100644 index 0000000000..459f5955d8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncher.kt @@ -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) { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return + + userWallets.filterIsInstance().forEach { registerMobile(it) } + } + + private fun UserWalletId.toBase64(): String = Base64.encodeToString(value, Base64.NO_WRAP) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationModule.kt b/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationModule.kt new file mode 100644 index 0000000000..132083ab49 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/walletregistration/WalletRegistrationModule.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/test/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSignerTest.kt b/app/src/test/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSignerTest.kt new file mode 100644 index 0000000000..07ce1bd070 --- /dev/null +++ b/app/src/test/java/com/tangem/tap/domain/walletregistration/ColdWalletRegistrationSignerTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/test/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSignerTest.kt b/app/src/test/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSignerTest.kt new file mode 100644 index 0000000000..f482b77155 --- /dev/null +++ b/app/src/test/java/com/tangem/tap/domain/walletregistration/MobileWalletRegistrationSignerTest.kt @@ -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>().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() + every { mobileWallet.curve } returns EllipticCurve.Secp256k1 + every { mobileWallet.publicKey } returns walletPublicKey + + val userWallet = mockk() + every { userWallet.wallets } returns listOf(mobileWallet) + every { userWallet.hotWalletId } returns mockk() + 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 + } +} \ No newline at end of file diff --git a/app/src/test/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncherTest.kt b/app/src/test/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncherTest.kt new file mode 100644 index 0000000000..3de81302ff --- /dev/null +++ b/app/src/test/java/com/tangem/tap/domain/walletregistration/WalletRegistrationLauncherTest.kt @@ -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() + 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() + 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())) + + coVerify(exactly = 1) { walletRegistrar.register(any(), any()) } + } + + private fun hotWallet(walletIdValue: ByteArray = ByteArray(32) { 1 }): UserWallet.Hot { + val wallet = mockk() + every { wallet.walletId } returns UserWalletId(value = walletIdValue) + return wallet + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/registration/WalletRegistrationTrigger.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/registration/WalletRegistrationTrigger.kt new file mode 100644 index 0000000000..cb5217a7e9 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/registration/WalletRegistrationTrigger.kt @@ -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) +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/CreateHotWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/CreateHotWalletUseCase.kt index 7f71437686..d113309c28 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/CreateHotWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/CreateHotWalletUseCase.kt @@ -3,6 +3,7 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.builder.HotUserWalletBuilder +import com.tangem.domain.wallets.registration.WalletRegistrationTrigger import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.hot.sdk.model.MnemonicType @@ -15,6 +16,7 @@ class CreateHotWalletUseCase @Inject constructor( private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory, private val saveWalletUseCase: SaveWalletUseCase, private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase, + private val walletRegistrationTrigger: WalletRegistrationTrigger, private val appCoroutineScope: AppCoroutineScope, ) { suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either { @@ -28,6 +30,11 @@ class CreateHotWalletUseCase @Inject constructor( syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId) } + // Register the wallet with the Auth Service while its unlock context is still fresh. + appCoroutineScope.launch { + walletRegistrationTrigger.onMobileWalletCreated(userWallet) + } + userWallet } } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/PreparedWalletRegistration.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/PreparedWalletRegistration.kt new file mode 100644 index 0000000000..6c176f3875 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/PreparedWalletRegistration.kt @@ -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, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt index 29ff1ea49a..62f457ab1b 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt @@ -22,8 +22,25 @@ import arrow.core.Either interface WalletRegistrar { /** - * Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to - * produce the signature material over the deciphered wallet nonce. + * Registers the wallet identified by [walletId] (Base64 `UserWalletId`) in one shot: [prepare] + * followed by [submit]. Use this when there is no session/UX constraint (MOBILE wallets). */ suspend fun register(walletId: String, signer: WalletSigner): Either + + /** + * 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 + + /** + * 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 } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt index 3181a5d29f..6a20d40c80 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt @@ -14,6 +14,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.nonce.AuthNonceDecryptor import com.tangem.lib.auth.session.AuthError +import com.tangem.lib.auth.session.PreparedWalletRegistration import com.tangem.lib.auth.session.WalletRegistrar import com.tangem.lib.auth.session.WalletRegistrationError import com.tangem.lib.auth.session.WalletSigner @@ -45,55 +46,82 @@ internal class DefaultWalletRegistrar( override suspend fun register(walletId: String, signer: WalletSigner): Either = 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 = withContext(dispatchers.io) { + getMutex(walletId).withLock { runPrepare(walletId, signer) } + } + + override suspend fun submit(prepared: PreparedWalletRegistration): Either = + withContext(dispatchers.io) { + getMutex(prepared.walletId).withLock { + either { handleRegisterResponse(prepared.walletId, authApi.registerWallet(prepared.request)) } + } } private fun getMutex(walletId: String): Mutex = mutexes.computeIfAbsent(walletId) { Mutex() } - private suspend fun runRegister(walletId: String, signer: WalletSigner): Either = - either { - val isAlreadyRegistered = try { - walletId in registeredWalletIds() - } catch (e: Exception) { - TangemLogger.e("Failed to read registered wallet ids", e) - raise(WalletRegistrationError.PersistenceFailed(e)) - } - if (isAlreadyRegistered) { - TangemLogger.i("Wallet already registered — skipping /wallet") - return@either + private suspend fun runPrepare( + walletId: String, + signer: WalletSigner, + ): Either = either { + val isAlreadyRegistered = try { + walletId in registeredWalletIds() + } catch (e: Exception) { + TangemLogger.e("Failed to read registered wallet ids", e) + raise(WalletRegistrationError.PersistenceFailed(e)) + } + if (isAlreadyRegistered) { + TangemLogger.i("Wallet already registered — skipping /wallet") + return@either null + } + + TangemLogger.i("Preparing wallet registration") + + val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull() + ?: raise(WalletRegistrationError.DeviceKeyUnavailable) + val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() + + val nonceResponse = authApi.requestWalletNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64)) + val cipheredNonce = when (nonceResponse) { + is ApiResponse.Success -> nonceResponse.data.cipheredNonce + is ApiResponse.Error -> { + val authError = errorConverter.convert(nonceResponse.cause) + TangemLogger.e("/nonce/wallet request failed: $authError") + raise(WalletRegistrationError.Api(authError)) } + } - TangemLogger.i("Starting wallet registration") + val nonce = try { + nonceDecryptor.decryptNonce(cipheredNonce) + } catch (e: Exception) { + TangemLogger.e("Failed to decrypt wallet nonce", e) + raise(WalletRegistrationError.NonceDecryptionFailed(e)) + } - val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull() - ?: raise(WalletRegistrationError.DeviceKeyUnavailable) - val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() + val bundle = try { + signer.sign(nonceBytes = nonce.toByteArray(Charsets.UTF_8)) + } catch (e: Exception) { + TangemLogger.e("Failed to sign wallet-registration payload", e) + raise(WalletRegistrationError.SigningFailed(e)) + } - val nonceResponse = authApi.requestWalletNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64)) - val cipheredNonce = when (nonceResponse) { - is ApiResponse.Success -> nonceResponse.data.cipheredNonce - is ApiResponse.Error -> { - val authError = errorConverter.convert(nonceResponse.cause) - TangemLogger.e("/nonce/wallet request failed: $authError") - raise(WalletRegistrationError.Api(authError)) - } - } - - val nonce = try { - nonceDecryptor.decryptNonce(cipheredNonce) - } catch (e: Exception) { - TangemLogger.e("Failed to decrypt wallet nonce", e) - raise(WalletRegistrationError.NonceDecryptionFailed(e)) - } - - val bundle = try { - signer.sign(nonceBytes = nonce.toByteArray(Charsets.UTF_8)) - } catch (e: Exception) { - TangemLogger.e("Failed to sign wallet-registration payload", e) - raise(WalletRegistrationError.SigningFailed(e)) - } - - val request = WalletRegistrationRequest( + PreparedWalletRegistration( + walletId = walletId, + request = WalletRegistrationRequest( nonce = nonce, walletId = walletId, walletSignature = bundle.walletSignature.toBase64NoWrap(), @@ -103,10 +131,9 @@ internal class DefaultWalletRegistrar( walletStatus = bundle.walletStatusByte?.let { byteArrayOf(it).toBase64NoWrap() }, attestationToken = null, metadata = signedRequestPayload.deviceMetadata, - ) - - handleRegisterResponse(walletId = walletId, response = authApi.registerWallet(request)) - } + ), + ) + } private suspend fun Raise.handleRegisterResponse( walletId: String, diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt index 212802c6ab..621a7831a6 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt @@ -2,6 +2,7 @@ package com.tangem.lib.auth.session.internal import arrow.core.Either import arrow.core.left +import com.tangem.lib.auth.session.PreparedWalletRegistration import com.tangem.lib.auth.session.WalletRegistrar import com.tangem.lib.auth.session.WalletRegistrationError import com.tangem.lib.auth.session.WalletSigner @@ -10,7 +11,14 @@ import com.tangem.utils.annotations.RemoveWithToggle @RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") internal object DisabledWalletRegistrar : WalletRegistrar { - override suspend fun register(walletId: String, signer: WalletSigner): Either { - return WalletRegistrationError.Disabled.left() - } + override suspend fun register(walletId: String, signer: WalletSigner): Either = + WalletRegistrationError.Disabled.left() + + override suspend fun prepare( + walletId: String, + signer: WalletSigner, + ): Either = WalletRegistrationError.Disabled.left() + + override suspend fun submit(prepared: PreparedWalletRegistration): Either = + WalletRegistrationError.Disabled.left() } \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt index 59947c9baf..bc64129bfb 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt @@ -271,6 +271,63 @@ class DefaultWalletRegistrarTest { assertThat(registeredIds()).doesNotContain(WALLET_ID) } + @Test + fun `prepare returns null when walletId already registered`() = runTest { + preferencesDataStore.edit { it[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = setOf(WALLET_ID) } + + val result = registrar.prepare(WALLET_ID, mobileSigner) + + assertThat(result.isRight()).isTrue() + assertThat(result.getOrNull()).isNull() + coVerify(exactly = 0) { authApi.requestWalletNonce(any()) } + } + + @Test + fun `prepare builds the request without posting or persisting anything`() = runTest { + stubHappyPath() + + val result = registrar.prepare(WALLET_ID, mobileSigner) + + assertThat(result.isRight()).isTrue() + assertThat(result.getOrNull()).isNotNull() + coVerify(exactly = 0) { authApi.registerWallet(any()) } + coVerify(exactly = 0) { store.save(any()) } + assertThat(registeredIds()).doesNotContain(WALLET_ID) + } + + @Test + fun `submit posts the prepared request, persists tokens and marks registered`() = runTest { + stubHappyPath() + coEvery { authApi.registerWallet(any()) } returns tokenSuccess() + val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!! + + val result = registrar.submit(prepared) + + assertThat(result.isRight()).isTrue() + coVerify { store.save(any()) } + assertThat(registeredIds()).contains(WALLET_ID) + } + + @Test + fun `submit treats 409 Conflict as success and marks registered without persisting tokens`() = runTest { + stubHappyPath() + val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!! + @Suppress("UNCHECKED_CAST") + coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.CONFLICT, + message = "wallet already registered", + errorBody = null, + ), + ) as ApiResponse + + val result = registrar.submit(prepared) + + assertThat(result.isRight()).isTrue() + assertThat(registeredIds()).contains(WALLET_ID) + coVerify(exactly = 0) { store.save(any()) } + } + private fun stubHappyPath() { coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()