Updated on 2026-08-14
This commit is contained in:
parent
9e986a6cb3
commit
1cfaf5a283
19 changed files with 830 additions and 56 deletions
|
|
@ -13,6 +13,8 @@ import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallenge
|
|||
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
|
||||
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
|
||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||
import dagger.Lazy
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -36,6 +38,7 @@ internal class TangemSdkManagerModule {
|
|||
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||
analyticsErrorHandler: AnalyticsErrorHandler,
|
||||
cardRepository: CardRepository,
|
||||
walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
|
||||
): TangemSdkManager {
|
||||
return if (BuildConfig.MOCK_DATA_SOURCE) {
|
||||
MockTangemSdkManager(resources = context.resources)
|
||||
|
|
@ -50,6 +53,7 @@ internal class TangemSdkManagerModule {
|
|||
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
||||
analyticsErrorHandler = analyticsErrorHandler,
|
||||
cardRepository = cardRepository,
|
||||
walletRegistrationLauncher = walletRegistrationLauncher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,10 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
|
|||
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
|
||||
import com.tangem.tap.domain.twins.FinalizeTwinTask
|
||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import com.tangem.wallet.R
|
||||
import dagger.Lazy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
|
@ -77,6 +79,9 @@ internal class DefaultTangemSdkManager(
|
|||
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
|
||||
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
||||
private val cardRepository: CardRepository,
|
||||
// Lazy breaks a DI cycle: the launcher -> hot wallet accessor -> LegacySettingsRepository ->
|
||||
// TangemSdkManager. It's only needed when a scan actually runs.
|
||||
private val walletRegistrationLauncher: Lazy<WalletRegistrationLauncher>,
|
||||
) : TangemSdkManager {
|
||||
|
||||
private val tangemSdk: TangemSdk
|
||||
|
|
@ -147,10 +152,11 @@ internal class DefaultTangemSdkManager(
|
|||
card = null,
|
||||
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
|
||||
visaCardScanHandler = visaCardScanHandler,
|
||||
visaCoroutineScope = this,
|
||||
sessionCoroutineScope = this,
|
||||
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
|
||||
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
|
||||
cardRepository = cardRepository,
|
||||
walletRegistrationLauncher = walletRegistrationLauncher.get(),
|
||||
),
|
||||
cardId = cardId,
|
||||
initialMessage = message,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ import com.tangem.operations.files.ReadFilesTask
|
|||
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
|
||||
import com.tangem.tap.domain.TapSdkError
|
||||
import com.tangem.tap.domain.visa.VisaCardScanHandler
|
||||
import com.tangem.tap.domain.walletregistration.WalletRegistrationLauncher
|
||||
import com.tangem.tap.mainScope
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -42,10 +45,11 @@ import kotlinx.coroutines.launch
|
|||
internal class ScanProductTask(
|
||||
private val card: Card?,
|
||||
private val visaCardScanHandler: VisaCardScanHandler?,
|
||||
private val visaCoroutineScope: CoroutineScope?,
|
||||
private val sessionCoroutineScope: CoroutineScope?,
|
||||
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
|
||||
private val shouldCheckIsAlreadyActivated: Boolean,
|
||||
private val cardRepository: CardRepository,
|
||||
private val walletRegistrationLauncher: WalletRegistrationLauncher? = null,
|
||||
override val allowsRequestAccessCodeFromRepository: Boolean = false,
|
||||
) : CardSessionRunnable<ScanResponse> {
|
||||
|
||||
|
|
@ -97,7 +101,7 @@ internal class ScanProductTask(
|
|||
val processorScanResponseWithNewCard = processorResult.data.copy(
|
||||
card = CardDTO(scanTaskResult.data),
|
||||
)
|
||||
callback(CompletionResult.Success(processorScanResponseWithNewCard))
|
||||
registerColdWalletThenComplete(session, processorScanResponseWithNewCard, callback)
|
||||
}
|
||||
is CompletionResult.Failure -> callback(CompletionResult.Failure(scanTaskResult.error))
|
||||
}
|
||||
|
|
@ -107,6 +111,38 @@ internal class ScanProductTask(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort COLD wallet registration with the Auth Service while the session is still open
|
||||
* (the card is tapped for `AttestWalletKeyTask`); the network POST is deferred by the launcher.
|
||||
* The in-session part (nonce request + attestation) runs before the scan completes, so it can
|
||||
* extend the scan slightly — but it never *fails* the scan: on any error the scan still
|
||||
* completes successfully.
|
||||
*/
|
||||
private fun registerColdWalletThenComplete(
|
||||
session: CardSession,
|
||||
scanResponse: ScanResponse,
|
||||
callback: (result: CompletionResult<ScanResponse>) -> Unit,
|
||||
) {
|
||||
val scope = sessionCoroutineScope
|
||||
val launcher = walletRegistrationLauncher
|
||||
if (scope == null || launcher == null) {
|
||||
TangemLogger.i("Skipping cold wallet registration: coroutine scope or launcher unavailable")
|
||||
callback(CompletionResult.Success(scanResponse))
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
runSuspendCatching { launcher.registerColdInSession(session, scanResponse) }
|
||||
.onFailure { TangemLogger.e("Cold wallet registration failed", it) }
|
||||
} finally {
|
||||
// Always complete the scan, even if the registration coroutine is cancelled
|
||||
// (runSuspendCatching rethrows CancellationException) — the scan never depends on
|
||||
// the registration outcome.
|
||||
callback(CompletionResult.Success(scanResponse))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun preflightReadMode(): PreflightReadMode {
|
||||
return if (shouldCheckIsAlreadyActivated) {
|
||||
PreflightReadMode.FullCardReadWithAccessCodeCheck
|
||||
|
|
@ -138,12 +174,12 @@ internal class ScanProductTask(
|
|||
return
|
||||
}
|
||||
|
||||
visaCoroutineScope ?: run {
|
||||
sessionCoroutineScope ?: run {
|
||||
callback(CompletionResult.Failure(TangemSdkError.InsNotSupported()))
|
||||
return
|
||||
}
|
||||
|
||||
visaCoroutineScope.launch {
|
||||
sessionCoroutineScope.launch {
|
||||
when (val result = visaCardScanHandler.handleVisaCardScan(session = session)) {
|
||||
is CompletionResult.Success -> {
|
||||
scanWalletProcessor.proceed(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class FinalizeTwinTask(
|
|||
ScanProductTask(
|
||||
card = readResult.data,
|
||||
visaCardScanHandler = null,
|
||||
visaCoroutineScope = null,
|
||||
sessionCoroutineScope = null,
|
||||
shouldCheckIsAlreadyActivated = false,
|
||||
onboardingV2FeatureToggles = null,
|
||||
cardRepository = cardRepository,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
package com.tangem.tap.domain.walletregistration
|
||||
|
||||
import com.tangem.blockchain.common.UnmarshalHelper
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.card.EllipticCurve
|
||||
import com.tangem.common.core.CardSession
|
||||
import com.tangem.common.extensions.calculateSha256
|
||||
import com.tangem.common.extensions.toDecompressedPublicKey
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.lib.auth.session.WalletSignatureBundle
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
import com.tangem.operations.attestation.AttestWalletKeyResponse
|
||||
import com.tangem.operations.attestation.AttestWalletKeyTask
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Builds a [WalletSigner] for a COLD (card-backed) wallet. Runs `AttestWalletKeyTask` in Dynamic
|
||||
* mode **inside an already-open [CardSession]** (no extra tap): the card produces the wallet
|
||||
* signature and, on COS 2.01+, the card signature over the wallet nonce. On older cards without a
|
||||
* card signature the wallet is registered with the wallet signature only (backend treats it as hot).
|
||||
*/
|
||||
internal class ColdWalletRegistrationSigner @Inject constructor() {
|
||||
|
||||
fun signerFor(session: CardSession, scanResponse: ScanResponse): WalletSigner = WalletSigner { nonceBytes ->
|
||||
val card = scanResponse.card
|
||||
val walletPublicKey = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }?.publicKey
|
||||
?: error("No secp256k1 wallet on card ${card.cardId}")
|
||||
|
||||
buildBundle(
|
||||
response = attest(session, walletPublicKey, nonceBytes),
|
||||
walletPublicKey = walletPublicKey,
|
||||
cardPublicKey = card.cardPublicKey,
|
||||
nonceBytes = nonceBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mapping of an [AttestWalletKeyResponse] to a [WalletSignatureBundle] (no card session) —
|
||||
* unit-testable in isolation.
|
||||
*/
|
||||
fun buildBundle(
|
||||
response: AttestWalletKeyResponse,
|
||||
walletPublicKey: ByteArray,
|
||||
cardPublicKey: ByteArray,
|
||||
nonceBytes: ByteArray,
|
||||
): WalletSignatureBundle {
|
||||
val salt = response.salt
|
||||
val walletSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
|
||||
signature = response.walletSignature,
|
||||
hash = (nonceBytes + salt).calculateSha256(),
|
||||
publicKey = walletPublicKey.toDecompressedPublicKey(),
|
||||
).asRSVLegacyEVM()
|
||||
|
||||
val cardSignature = response.cardSignature
|
||||
val publicKeySalt = response.publicKeySalt
|
||||
// The dynamic card signature (proving the card owns the wallet) requires COS 2.01+. On older
|
||||
// cards it is absent — we then register with the wallet signature only, and the backend
|
||||
// treats such a wallet as hot (no card-ownership proof).
|
||||
if (cardSignature == null || publicKeySalt == null) {
|
||||
return WalletSignatureBundle(
|
||||
walletSignature = walletSignatureRsv,
|
||||
walletSignatureSalt = salt,
|
||||
cardSignature = null,
|
||||
cardSignatureSalt = null,
|
||||
walletStatusByte = null,
|
||||
)
|
||||
}
|
||||
|
||||
val walletStatusByte = response.walletStatus?.code?.toByte()
|
||||
// Card-signature preimage: walletPublicKey | challenge | publicKeySalt [| walletStatus].
|
||||
// The walletStatus byte is appended only when the card reports it (COS 6+).
|
||||
val cardMessage = walletPublicKey + nonceBytes + publicKeySalt +
|
||||
(walletStatusByte?.let { byteArrayOf(it) } ?: ByteArray(size = 0))
|
||||
val cardSignatureRsv = UnmarshalHelper.unmarshalSignatureExtended(
|
||||
signature = cardSignature,
|
||||
hash = cardMessage.calculateSha256(),
|
||||
publicKey = cardPublicKey.toDecompressedPublicKey(),
|
||||
).asRSVLegacyEVM()
|
||||
|
||||
return WalletSignatureBundle(
|
||||
walletSignature = walletSignatureRsv,
|
||||
walletSignatureSalt = salt,
|
||||
cardSignature = cardSignatureRsv,
|
||||
cardSignatureSalt = publicKeySalt,
|
||||
walletStatusByte = walletStatusByte,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun attest(
|
||||
session: CardSession,
|
||||
walletPublicKey: ByteArray,
|
||||
nonceBytes: ByteArray,
|
||||
): AttestWalletKeyResponse {
|
||||
val result = suspendCancellableCoroutine { continuation ->
|
||||
AttestWalletKeyTask(publicKey = walletPublicKey, challenge = nonceBytes)
|
||||
.run(session) { if (continuation.isActive) continuation.resume(it) }
|
||||
}
|
||||
return when (result) {
|
||||
is CompletionResult.Success -> result.data
|
||||
is CompletionResult.Failure -> throw ColdWalletAttestationException(result.error.customMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `AttestWalletKeyTask` failed (NFC error, verification failure, user cancelled). */
|
||||
internal class ColdWalletAttestationException(message: String) :
|
||||
Exception("Cold wallet attestation failed: $message")
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.tap.domain.walletregistration
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultWalletRegistrationTrigger @Inject constructor(
|
||||
private val launcher: WalletRegistrationLauncher,
|
||||
) : WalletRegistrationTrigger {
|
||||
|
||||
override suspend fun onMobileWalletCreated(userWallet: UserWallet.Hot) {
|
||||
launcher.registerMobile(userWallet)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.tangem.tap.domain.walletregistration
|
||||
|
||||
import com.tangem.blockchain.common.UnmarshalHelper
|
||||
import com.tangem.common.card.EllipticCurve
|
||||
import com.tangem.common.extensions.calculateSha256
|
||||
import com.tangem.common.extensions.toDecompressedPublicKey
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.hot.HotWalletAccessor
|
||||
import com.tangem.hot.sdk.model.DataToSign
|
||||
import com.tangem.lib.auth.session.WalletSignatureBundle
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
import java.security.SecureRandom
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Builds a [WalletSigner] for a MOBILE (hot/software) wallet. The wallet's secp256k1 key signs
|
||||
* `sha256(nonceBytes || walletSignatureSalt)` via the hot wallet SDK; there is no card, so the
|
||||
* card-signature fields stay null.
|
||||
*/
|
||||
internal class MobileWalletRegistrationSigner @Inject constructor(
|
||||
private val hotWalletAccessor: HotWalletAccessor,
|
||||
) {
|
||||
|
||||
fun signerFor(userWallet: UserWallet.Hot): WalletSigner = WalletSigner { nonceBytes ->
|
||||
val wallet = userWallet.wallets
|
||||
?.firstOrNull { it.curve == EllipticCurve.Secp256k1 }
|
||||
?: error("No secp256k1 wallet available for hot wallet ${userWallet.walletId}")
|
||||
|
||||
val salt = ByteArray(SALT_SIZE).also(secureRandom::nextBytes)
|
||||
val hash = (nonceBytes + salt).calculateSha256()
|
||||
|
||||
val signature = hotWalletAccessor.signHashes(
|
||||
hotWalletId = userWallet.hotWalletId,
|
||||
dataToSign = listOf(DataToSign(curve = EllipticCurve.Secp256k1, hashes = listOf(hash))),
|
||||
).first().signatures.first()
|
||||
|
||||
val rsvSignature = UnmarshalHelper.unmarshalSignatureExtended(
|
||||
signature = signature,
|
||||
hash = hash,
|
||||
publicKey = wallet.publicKey.toDecompressedPublicKey(),
|
||||
).asRSVLegacyEVM()
|
||||
|
||||
WalletSignatureBundle(
|
||||
walletSignature = rsvSignature,
|
||||
walletSignatureSalt = salt,
|
||||
cardSignature = null,
|
||||
cardSignatureSalt = null,
|
||||
walletStatusByte = null,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SALT_SIZE = 16
|
||||
val secureRandom = SecureRandom()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.tangem.tap.domain.walletregistration
|
||||
|
||||
import android.util.Base64
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.common.core.CardSession
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
|
||||
import com.tangem.lib.auth.AuthFeatureToggles
|
||||
import com.tangem.lib.auth.session.WalletRegistrar
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Single entry point that registers wallets with the Tangem Auth Service. Gated by the backend-auth
|
||||
* feature toggle; all failures are log-only (registration is retried on the next launch / card scan,
|
||||
* never blocks the user). MOBILE wallets register without UI; COLD wallets attest inside a live
|
||||
* card session (no extra tap) and POST after the session closes.
|
||||
*/
|
||||
internal class WalletRegistrationLauncher @Inject constructor(
|
||||
private val walletRegistrar: WalletRegistrar,
|
||||
private val mobileSigner: MobileWalletRegistrationSigner,
|
||||
private val coldSigner: ColdWalletRegistrationSigner,
|
||||
private val authFeatureToggles: AuthFeatureToggles,
|
||||
private val appCoroutineScope: AppCoroutineScope,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
||||
* Never throws (beyond cooperative cancellation) — any unexpected failure is caught and logged,
|
||||
* so callers relying on the fire-and-forget contract stay safe.
|
||||
*/
|
||||
suspend fun registerMobile(userWallet: UserWallet.Hot) {
|
||||
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
|
||||
|
||||
runSuspendCatching {
|
||||
walletRegistrar.register(
|
||||
walletId = userWallet.walletId.toBase64(),
|
||||
signer = mobileSigner.signerFor(userWallet),
|
||||
).onLeft { TangemLogger.e("Mobile wallet registration deferred: $it") }
|
||||
}.onFailure { TangemLogger.e("Mobile wallet registration failed", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* COLD registration. Phase 1 ([WalletRegistrar.prepare]) runs inside the still-open [session]
|
||||
* (the card is tapped here); phase 2 (the network POST) is dispatched on [appCoroutineScope]
|
||||
* after this returns, so the user doesn't hold the card during the request. Call this BEFORE
|
||||
* the scan completes its session callback.
|
||||
*/
|
||||
suspend fun registerColdInSession(session: CardSession, scanResponse: ScanResponse) {
|
||||
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
|
||||
|
||||
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build()?.toBase64() ?: return
|
||||
|
||||
val prepared = walletRegistrar.prepare(walletId, coldSigner.signerFor(session, scanResponse))
|
||||
.getOrElse { error ->
|
||||
TangemLogger.e("Cold wallet registration prepare deferred: $error")
|
||||
return
|
||||
}
|
||||
if (prepared == null) return // already registered
|
||||
|
||||
appCoroutineScope.launch {
|
||||
runSuspendCatching {
|
||||
walletRegistrar.submit(prepared)
|
||||
.onLeft { TangemLogger.e("Cold wallet registration submit deferred: $it") }
|
||||
}.onFailure { TangemLogger.e("Cold wallet registration submit failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch-time safety net: registers any not-yet-registered MOBILE wallets (no UI). */
|
||||
suspend fun retryMobileRegistrations(userWallets: List<UserWallet>) {
|
||||
if (!authFeatureToggles.isBackendAuthenticationEnabled) return
|
||||
|
||||
userWallets.filterIsInstance<UserWallet.Hot>().forEach { registerMobile(it) }
|
||||
}
|
||||
|
||||
private fun UserWalletId.toBase64(): String = Base64.encodeToString(value, Base64.NO_WRAP)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.tap.domain.walletregistration
|
||||
|
||||
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface WalletRegistrationModule {
|
||||
|
||||
@Binds
|
||||
fun bindWalletRegistrationTrigger(impl: DefaultWalletRegistrationTrigger): WalletRegistrationTrigger
|
||||
}
|
||||
|
|
@ -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 com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.builder.HotUserWalletBuilder
|
||||
import com.tangem.domain.wallets.registration.WalletRegistrationTrigger
|
||||
import com.tangem.hot.sdk.TangemHotSdk
|
||||
import com.tangem.hot.sdk.model.HotAuth
|
||||
import com.tangem.hot.sdk.model.MnemonicType
|
||||
|
|
@ -15,6 +16,7 @@ class CreateHotWalletUseCase @Inject constructor(
|
|||
private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory,
|
||||
private val saveWalletUseCase: SaveWalletUseCase,
|
||||
private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase,
|
||||
private val walletRegistrationTrigger: WalletRegistrationTrigger,
|
||||
private val appCoroutineScope: AppCoroutineScope,
|
||||
) {
|
||||
suspend operator fun invoke(auth: HotAuth, mnemonicType: MnemonicType): Either<Throwable, UserWallet.Hot> {
|
||||
|
|
@ -28,6 +30,11 @@ class CreateHotWalletUseCase @Inject constructor(
|
|||
syncWalletWithRemoteUseCase(userWalletId = userWallet.walletId)
|
||||
}
|
||||
|
||||
// Register the wallet with the Auth Service while its unlock context is still fresh.
|
||||
appCoroutineScope.launch {
|
||||
walletRegistrationTrigger.onMobileWalletCreated(userWallet)
|
||||
}
|
||||
|
||||
userWallet
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to
|
||||
* produce the signature material over the deciphered wallet nonce.
|
||||
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`) in one shot: [prepare]
|
||||
* followed by [submit]. Use this when there is no session/UX constraint (MOBILE wallets).
|
||||
*/
|
||||
suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit>
|
||||
|
||||
/**
|
||||
* Phase 1: idempotency check, nonce request/decryption, and signing via [signer]. For COLD
|
||||
* wallets this must run while the card session is open (the signer taps the card). Returns the
|
||||
* assembled registration to hand to [submit] later, or `null` if the wallet is already
|
||||
* registered (no-op). Performs NO network write — pair it with [submit] after the session closes.
|
||||
*/
|
||||
suspend fun prepare(
|
||||
walletId: String,
|
||||
signer: WalletSigner,
|
||||
): Either<WalletRegistrationError, PreparedWalletRegistration?>
|
||||
|
||||
/**
|
||||
* Phase 2: sends the [prepared] registration, persists the reissued tokens, and marks the wallet
|
||||
* registered. No card needed — safe to run after the session has closed.
|
||||
*/
|
||||
suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit>
|
||||
}
|
||||
|
|
@ -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<WalletRegistrationError, Unit> =
|
||||
withContext(dispatchers.io) {
|
||||
getMutex(walletId).withLock { runRegister(walletId, signer) }
|
||||
// Hold the per-wallet lock across the WHOLE one-shot flow (prepare + submit), so two
|
||||
// concurrent register() calls for the same wallet can't both pass the idempotency check
|
||||
// and consume/sign separate nonces. Calls the internal steps directly instead of
|
||||
// re-entering the public prepare()/submit() (which would take the same lock again).
|
||||
getMutex(walletId).withLock {
|
||||
either {
|
||||
val prepared = runPrepare(walletId, signer).bind() ?: return@either
|
||||
handleRegisterResponse(prepared.walletId, authApi.registerWallet(prepared.request))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prepare(
|
||||
walletId: String,
|
||||
signer: WalletSigner,
|
||||
): Either<WalletRegistrationError, PreparedWalletRegistration?> = withContext(dispatchers.io) {
|
||||
getMutex(walletId).withLock { runPrepare(walletId, signer) }
|
||||
}
|
||||
|
||||
override suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit> =
|
||||
withContext(dispatchers.io) {
|
||||
getMutex(prepared.walletId).withLock {
|
||||
either { handleRegisterResponse(prepared.walletId, authApi.registerWallet(prepared.request)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMutex(walletId: String): Mutex = mutexes.computeIfAbsent(walletId) { Mutex() }
|
||||
|
||||
private suspend fun runRegister(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||
either {
|
||||
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<WalletRegistrationError, PreparedWalletRegistration?> = 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<WalletRegistrationError>.handleRegisterResponse(
|
||||
walletId: String,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.lib.auth.session.internal
|
|||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.lib.auth.session.PreparedWalletRegistration
|
||||
import com.tangem.lib.auth.session.WalletRegistrar
|
||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
|
|
@ -10,7 +11,14 @@ import com.tangem.utils.annotations.RemoveWithToggle
|
|||
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||
internal object DisabledWalletRegistrar : WalletRegistrar {
|
||||
|
||||
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> {
|
||||
return WalletRegistrationError.Disabled.left()
|
||||
}
|
||||
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||
WalletRegistrationError.Disabled.left()
|
||||
|
||||
override suspend fun prepare(
|
||||
walletId: String,
|
||||
signer: WalletSigner,
|
||||
): Either<WalletRegistrationError, PreparedWalletRegistration?> = WalletRegistrationError.Disabled.left()
|
||||
|
||||
override suspend fun submit(prepared: PreparedWalletRegistration): Either<WalletRegistrationError, Unit> =
|
||||
WalletRegistrationError.Disabled.left()
|
||||
}
|
||||
|
|
@ -271,6 +271,63 @@ class DefaultWalletRegistrarTest {
|
|||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare returns null when walletId already registered`() = runTest {
|
||||
preferencesDataStore.edit { it[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = setOf(WALLET_ID) }
|
||||
|
||||
val result = registrar.prepare(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()).isNull()
|
||||
coVerify(exactly = 0) { authApi.requestWalletNonce(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare builds the request without posting or persisting anything`() = runTest {
|
||||
stubHappyPath()
|
||||
|
||||
val result = registrar.prepare(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()).isNotNull()
|
||||
coVerify(exactly = 0) { authApi.registerWallet(any()) }
|
||||
coVerify(exactly = 0) { store.save(any()) }
|
||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit posts the prepared request, persists tokens and marks registered`() = runTest {
|
||||
stubHappyPath()
|
||||
coEvery { authApi.registerWallet(any()) } returns tokenSuccess()
|
||||
val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!!
|
||||
|
||||
val result = registrar.submit(prepared)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify { store.save(any()) }
|
||||
assertThat(registeredIds()).contains(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit treats 409 Conflict as success and marks registered without persisting tokens`() = runTest {
|
||||
stubHappyPath()
|
||||
val prepared = registrar.prepare(WALLET_ID, mobileSigner).getOrNull()!!
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.CONFLICT,
|
||||
message = "wallet already registered",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
|
||||
val result = registrar.submit(prepared)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(registeredIds()).contains(WALLET_ID)
|
||||
coVerify(exactly = 0) { store.save(any()) }
|
||||
}
|
||||
|
||||
private fun stubHappyPath() {
|
||||
coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue