Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 13:24:29 +04:00
parent 9e986a6cb3
commit 1cfaf5a283
19 changed files with 830 additions and 56 deletions

View file

@ -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,
)

View file

@ -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>
}

View file

@ -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,

View file

@ -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()
}

View file

@ -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()