Updated on 2026-08-14
This commit is contained in:
parent
9e986a6cb3
commit
1cfaf5a283
19 changed files with 830 additions and 56 deletions
|
|
@ -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