From 7fdfe487b148ca37cb2c6249a5d914f94dc12a3c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 19:45:31 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../datasource/di/utils/RetrofitApiBuilder.kt | 9 +- .../local/preferences/PreferencesKeys.kt | 3 + .../java/com/tangem/lib/auth/di/AuthModule.kt | 31 ++ .../lib/auth/session/WalletRegistrar.kt | 30 ++ .../auth/session/WalletRegistrationError.kt | 32 ++ .../lib/auth/session/WalletSignatureBundle.kt | 57 +++ .../internal/DefaultWalletRegistrar.kt | 162 +++++++++ .../internal/DisabledWalletRegistrar.kt | 16 + .../session/internal/SignedRequestPayload.kt | 26 +- .../internal/DefaultWalletRegistrarTest.kt | 326 ++++++++++++++++++ .../internal/SignedRequestPayloadTest.kt | 20 +- 11 files changed, 683 insertions(+), 29 deletions(-) create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrationError.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/WalletSignatureBundle.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt index e2fc94b168..91d7fb773e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt @@ -128,8 +128,13 @@ internal class RetrofitApiBuilder @Inject constructor( // feature toggle is OFF we skip installing the hooks entirely (avoids wiring up DPoP // header generation and 401 retry logic on builds where auth isn't live yet). if (condition && isBackendAuthEnabled.get()) { - addInterceptor(sessionAuthInterceptor.get()) - authenticator(sessionAuthenticator.get()) + // Resolve the providers lazily, at request time, instead of eagerly here. The session + // authenticator depends (via SessionTokenRefresher) back on AuthApi, so calling `.get()` + // while AuthApi is still being built would recurse into provideAuthApi → applySessionAuth + // → `.get()` → … and overflow the stack. Deferring `.get()` to the first HTTP call lets + // AuthApi finish constructing (and get cached) first, breaking the cycle. + addInterceptor(Interceptor { chain -> sessionAuthInterceptor.get().intercept(chain) }) + authenticator { route, response -> sessionAuthenticator.get().authenticate(route, response) } } return this diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index c67196aa54..6236ee015b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -117,6 +117,9 @@ object PreferencesKeys { val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") } + /** Base64 `UserWalletId`s already registered with the Tangem Auth Service (`/auth/wallet`). */ + val REGISTERED_WALLET_IDS_KEY by lazy { stringSetPreferencesKey(name = "registeredWalletIds") } + val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") } val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index 7a987f509e..ba1b29776d 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -24,12 +24,15 @@ import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.lib.auth.session.SessionTokenRefresher import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.lib.auth.session.WalletRegistrar import com.tangem.lib.auth.session.internal.AuthErrorConverter import com.tangem.lib.auth.session.internal.DefaultDeviceRegistrar import com.tangem.lib.auth.session.internal.DefaultSessionTokenRefresher import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore +import com.tangem.lib.auth.session.internal.DefaultWalletRegistrar import com.tangem.lib.auth.session.internal.DisabledDeviceRegistrar import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher +import com.tangem.lib.auth.session.internal.DisabledWalletRegistrar import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore import com.tangem.lib.auth.session.internal.SignedRequestPayload import com.tangem.sdk.storage.AndroidSecureStorageV2 @@ -195,6 +198,34 @@ internal object AuthModule { ) } + @Suppress("LongParameterList") + @Provides + @Singleton + fun provideWalletRegistrar( + authFeatureToggles: AuthFeatureToggles, + authApi: AuthApi, + store: SessionTokensStore, + deviceKeyManager: DeviceKeyManager, + nonceDecryptor: AuthNonceDecryptor, + signedRequestPayload: SignedRequestPayload, + errorConverter: AuthErrorConverter, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): WalletRegistrar { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledWalletRegistrar + + return DefaultWalletRegistrar( + authApi = authApi, + store = store, + deviceKeyManager = deviceKeyManager, + nonceDecryptor = nonceDecryptor, + signedRequestPayload = signedRequestPayload, + errorConverter = errorConverter, + appPreferencesStore = appPreferencesStore, + dispatchers = dispatchers, + ) + } + @Provides @Singleton @SessionAuthInterceptor 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 new file mode 100644 index 0000000000..1ffa5ad68c --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt @@ -0,0 +1,30 @@ +package com.tangem.lib.auth.session + +import arrow.core.Either + +/** + * Binds a wallet to the already-registered device with the Tangem Auth Service + * (`POST /api/v1/auth/wallet`), proving wallet (and, for cold cards, card) ownership over a + * server-issued wallet nonce. + * + * Idempotent per wallet: once a `walletId` is registered it is remembered, and subsequent calls + * for it short-circuit without network traffic. Layered on top of device registration — requires a + * valid DPoP-bound session (see `DeviceRegistrar` / `SessionTokenRefresher`). + * + * Signing is delegated to the caller via [WalletSigner]: the registrar owns the nonce request, + * decryption, request assembly, POST and persistence, but the NFC/biometric signature itself is + * produced in the app layer (the registrar has no Card/hot SDK dependency). The nonce must be + * fetched before signing (the signature is over the nonce), so the registrar fetches it and hands + * the deciphered bytes to the signer. + * + * Tokens returned by `/wallet` are written to `SessionTokensStore`, not surfaced to callers — the + * result type carries only success/failure so callers can log transient errors. + */ +interface WalletRegistrar { + + /** + * Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to + * produce the signature material over the deciphered wallet nonce. + */ + suspend fun register(walletId: String, signer: WalletSigner): Either +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrationError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrationError.kt new file mode 100644 index 0000000000..89e99077c0 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrationError.kt @@ -0,0 +1,32 @@ +package com.tangem.lib.auth.session + +/** + * Typed failure mode of `WalletRegistrar.register()`. Mirrors [DeviceRegistrationError] but covers + * the wallet-binding paths (`/nonce/wallet` + `/wallet`). + */ +sealed class WalletRegistrationError { + + /** API-level error from `/nonce/wallet` or `/wallet`. Transient unless [cause] says otherwise. */ + data class Api(val cause: AuthError) : WalletRegistrationError() + + /** Device key is not provisioned in Keystore (registration cannot proceed without one). */ + data object DeviceKeyUnavailable : WalletRegistrationError() + + /** RSA/OAEP decryption of the server-issued wallet nonce failed. */ + data class NonceDecryptionFailed(val cause: Throwable) : WalletRegistrationError() + + /** + * Producing the wallet/card signature failed — Card SDK / hot SDK error, or the user cancelled + * the NFC tap / biometric prompt. + */ + data class SigningFailed(val cause: Throwable) : WalletRegistrationError() + + /** + * Persisting the reissued tokens or the registered-wallet marker failed (DataStore I/O). The + * marker stays unset, so the next attempt retries cleanly. + */ + data class PersistenceFailed(val cause: Throwable) : WalletRegistrationError() + + /** Registrar is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */ + data object Disabled : WalletRegistrationError() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletSignatureBundle.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletSignatureBundle.kt new file mode 100644 index 0000000000..1fa48ef06c --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletSignatureBundle.kt @@ -0,0 +1,57 @@ +package com.tangem.lib.auth.session + +/** + * Raw signature material a caller produces for a single wallet over the deciphered wallet nonce. + * Produced by a [WalletSigner] (NFC card attestation for COLD wallets, hot SDK signing for MOBILE). + * + * `WalletRegistrar` Base64-encodes these bytes before building the network request, so all values + * here are raw bytes — the caller must not pre-encode them. + * + * @property walletSignature 65-byte secp256k1 RSV signature (v = recId + 27) over + * `sha256(nonceBytes || walletSignatureSalt)`. The server recovers the wallet public key from it. + * @property walletSignatureSalt salt mixed into the wallet-signature hash. + * @property cardSignature 65-byte secp256k1 RSV signature over + * `sha256(walletPublicKey || nonceBytes || cardSignatureSalt || walletStatus)`. COLD wallets only; + * `null` for MOBILE (hot) wallets. + * @property cardSignatureSalt salt mixed into the card-signature hash. COLD wallets only. + * @property walletStatusByte single byte describing wallet provenance on the card + * (`0x82` = generated on card, `0xC2` = SEED imported). COLD wallets only. + */ +data class WalletSignatureBundle( + val walletSignature: ByteArray, + val walletSignatureSalt: ByteArray, + val cardSignature: ByteArray?, + val cardSignatureSalt: ByteArray?, + val walletStatusByte: Byte?, +) { + // ByteArray uses referential equality in the data-class-generated equals/hashCode; override with + // content comparison so two bundles with identical bytes compare equal. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WalletSignatureBundle) return false + + return walletSignature.contentEquals(other.walletSignature) && + walletSignatureSalt.contentEquals(other.walletSignatureSalt) && + cardSignature.contentEquals(other.cardSignature) && + cardSignatureSalt.contentEquals(other.cardSignatureSalt) && + walletStatusByte == other.walletStatusByte + } + + override fun hashCode(): Int { + var result = walletSignature.contentHashCode() + result = 31 * result + walletSignatureSalt.contentHashCode() + result = 31 * result + (cardSignature?.contentHashCode() ?: 0) + result = 31 * result + (cardSignatureSalt?.contentHashCode() ?: 0) + result = 31 * result + (walletStatusByte?.hashCode() ?: 0) + return result + } +} + +/** + * Produces a [WalletSignatureBundle] for the wallet being registered, given the deciphered wallet + * nonce bytes. Implemented in the app/data layer where the Card SDK / hot wallet SDK live; the + * `WalletRegistrar` stays free of SDK dependencies. + */ +fun interface WalletSigner { + suspend fun sign(nonceBytes: ByteArray): WalletSignatureBundle +} \ 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 new file mode 100644 index 0000000000..416f6fa95e --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt @@ -0,0 +1,162 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import com.tangem.datasource.api.auth.AuthApi +import com.tangem.datasource.api.auth.models.request.NonceApiRequest +import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +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.WalletRegistrar +import com.tangem.lib.auth.session.WalletRegistrationError +import com.tangem.lib.auth.session.WalletSigner +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +@Suppress("LongParameterList") +internal class DefaultWalletRegistrar( + private val authApi: AuthApi, + private val store: SessionTokensStore, + private val deviceKeyManager: DeviceKeyManager, + private val nonceDecryptor: AuthNonceDecryptor, + private val signedRequestPayload: SignedRequestPayload, + private val errorConverter: AuthErrorConverter, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletRegistrar { + + // Per-wallet mutex: serialises concurrent attempts for the SAME wallet (so its nonce isn't + // consumed twice) while letting different wallets register in parallel. The registered-wallet + // set is mutated atomically inside a single DataStore transaction (see [markRegistered]), so it + // needs no cross-wallet lock. + private val mutexes = ConcurrentHashMap() + + override suspend fun register(walletId: String, signer: WalletSigner): Either = + withContext(dispatchers.io) { + getMutex(walletId).withLock { runRegister(walletId, signer) } + } + + 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 + } + + TangemLogger.i("Starting wallet registration") + + val devicePublicKey = deviceKeyManager.getPublicKey().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)) + } + } + + 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( + nonce = nonce, + walletId = walletId, + walletSignature = bundle.walletSignature.toBase64NoWrap(), + walletSignatureSalt = bundle.walletSignatureSalt.toBase64NoWrap(), + cardSignature = bundle.cardSignature?.toBase64NoWrap(), + cardSignatureSalt = bundle.cardSignatureSalt?.toBase64NoWrap(), + 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, + response: ApiResponse, + ) { + when (response) { + is ApiResponse.Success -> { + val tokens = SessionTokensConverter.convertBack(response.data) + try { + // Keep both writes inside one catch — if the marker write fails, the wallet + // stays unregistered locally and the next attempt retries cleanly. + store.save(tokens) + markRegistered(walletId) + } catch (e: Exception) { + TangemLogger.e("Failed to persist wallet-registration tokens / marker", e) + raise(WalletRegistrationError.PersistenceFailed(e)) + } + TangemLogger.i("Wallet registered successfully") + } + is ApiResponse.Error -> { + val authError = errorConverter.convert(response.cause) + if (authError is AuthError.Conflict) { + // Wallet is already registered server-side (e.g. local marker was lost on + // reinstall). Persist the marker to stop retrying. + TangemLogger.i("Wallet already registered server-side (409) — marking as registered") + try { + markRegistered(walletId) + } catch (e: Exception) { + TangemLogger.e("Failed to persist wallet-registration marker after 409", e) + raise(WalletRegistrationError.PersistenceFailed(e)) + } + return + } + TangemLogger.e("/wallet request failed: $authError") + raise(WalletRegistrationError.Api(authError)) + } + } + } + + private suspend fun registeredWalletIds(): Set = appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.REGISTERED_WALLET_IDS_KEY, + default = emptySet(), + ) + + private suspend fun markRegistered(walletId: String) { + // Atomic read-modify-write inside a single DataStore transaction — DataStore serialises + // these, so concurrent registrations of different wallets can't lose set entries. + appPreferencesStore.editData { preferences -> + val current = preferences.getOrDefault(PreferencesKeys.REGISTERED_WALLET_IDS_KEY, emptySet()) + preferences[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = current + walletId + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..212802c6ab --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledWalletRegistrar.kt @@ -0,0 +1,16 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.Either +import arrow.core.left +import com.tangem.lib.auth.session.WalletRegistrar +import com.tangem.lib.auth.session.WalletRegistrationError +import com.tangem.lib.auth.session.WalletSigner +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() + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt index 51bf498bca..f92a2e23f7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt @@ -47,9 +47,11 @@ internal class SignedRequestPayload @Inject constructor( ) /** - * Stable, newline-separated representation of the signed payload. Backend treats the bytes - * opaquely; must stay aligned with the server-side canonicalisation. Field order matches the - * declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata]. + * Stable, colon-separated representation of the signed payload. Must stay byte-for-byte aligned + * with the server-side canonicalisation (`RegistrationService` / `AuthenticationService`): + * fields joined with `:` in this exact order, with a missing `attestationToken` collapsing to + * an empty segment, and no trailing separator. The server verifies via `SHA256withECDSA` over + * these UTF-8 bytes. */ private fun canonicalize( devicePublicKey: String, @@ -57,15 +59,15 @@ internal class SignedRequestPayload @Inject constructor( attestationToken: String?, metadata: DeviceMetadata, ): ByteArray = buildString { - append(devicePublicKey).append('\n') - append(nonce).append('\n') - append(attestationToken.orEmpty()).append('\n') - append(metadata.deviceModel).append('\n') - append(metadata.os).append('\n') - append(metadata.osVersion).append('\n') - append(metadata.appVersion).append('\n') - append(metadata.userAgent).append('\n') - append(metadata.locale).append('\n') + append(devicePublicKey).append(':') + append(nonce).append(':') + append(attestationToken.orEmpty()).append(':') + append(metadata.deviceModel).append(':') + append(metadata.os).append(':') + append(metadata.osVersion).append(':') + append(metadata.appVersion).append(':') + append(metadata.userAgent).append(':') + append(metadata.locale).append(':') append(metadata.timezone) }.toByteArray(Charsets.UTF_8) } 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 new file mode 100644 index 0000000000..c3507d3179 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt @@ -0,0 +1,326 @@ +package com.tangem.lib.auth.session.internal + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.datasource.api.auth.AuthApi +import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest +import com.tangem.datasource.api.auth.models.response.NonceApiResponse +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.nonce.AuthNonceDecryptor +import com.tangem.lib.auth.session.WalletRegistrationError +import com.tangem.lib.auth.session.WalletSignatureBundle +import com.tangem.lib.auth.session.WalletSigner +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.utils.info.AppInfoProvider +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.flow.flowOf +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) +class DefaultWalletRegistrarTest { + + private val authApi: AuthApi = mockk() + private val store: SessionTokensStore = mockk(relaxUnitFun = true) + private val deviceKeyManager: DeviceKeyManager = mockk() + private val nonceDecryptor: AuthNonceDecryptor = mockk() + private val appInfoProvider: AppInfoProvider = mockk(relaxed = true) + private val signedRequestPayload = SignedRequestPayload(appInfoProvider) + private val errorConverter = AuthErrorConverter() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val preferencesDataStore = InMemoryPreferencesDataStore() + private val appPreferencesStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = dispatchers, + preferencesDataStore = preferencesDataStore, + ) + + private lateinit var registrar: DefaultWalletRegistrar + + private val mobileSigner = WalletSigner { + WalletSignatureBundle( + walletSignature = ByteArray(65) { 1 }, + walletSignatureSalt = ByteArray(16) { 2 }, + cardSignature = null, + cardSignatureSalt = null, + walletStatusByte = null, + ) + } + + private val coldSigner = WalletSigner { + WalletSignatureBundle( + walletSignature = ByteArray(65) { 1 }, + walletSignatureSalt = ByteArray(16) { 2 }, + cardSignature = ByteArray(65) { 3 }, + cardSignatureSalt = ByteArray(16) { 4 }, + walletStatusByte = 0x82.toByte(), + ) + } + + @BeforeEach + fun setup() { + clearMocks(authApi, store, deviceKeyManager, nonceDecryptor) + preferencesDataStore.reset() + mockkStatic(android.util.Base64::class) + every { android.util.Base64.encodeToString(any(), any()) } answers { + java.util.Base64.getEncoder().encodeToString(firstArg()) + } + registrar = DefaultWalletRegistrar( + authApi = authApi, + store = store, + deviceKeyManager = deviceKeyManager, + nonceDecryptor = nonceDecryptor, + signedRequestPayload = signedRequestPayload, + errorConverter = errorConverter, + appPreferencesStore = appPreferencesStore, + dispatchers = dispatchers, + ) + } + + @AfterEach + fun teardown() = unmockkAll() + + @Test + fun `register MOBILE posts wallet and persists tokens and marker with null card fields`() = runTest { + stubHappyPath() + val slot = slot() + coEvery { authApi.registerWallet(capture(slot)) } returns tokenSuccess() + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.isRight()).isTrue() + coVerify { authApi.requestWalletNonce(any()) } + coVerify { store.save(any()) } + assertThat(registeredIds()).contains(WALLET_ID) + val request = slot.captured + assertThat(request.walletId).isEqualTo(WALLET_ID) + assertThat(request.walletSignature).isEqualTo(base64(ByteArray(65) { 1 })) + assertThat(request.walletSignatureSalt).isEqualTo(base64(ByteArray(16) { 2 })) + assertThat(request.cardSignature).isNull() + assertThat(request.cardSignatureSalt).isNull() + assertThat(request.walletStatus).isNull() + assertThat(request.attestationToken).isNull() + } + + @Test + fun `register COLD posts wallet with card fields and single-byte walletStatus`() = runTest { + stubHappyPath() + val slot = slot() + coEvery { authApi.registerWallet(capture(slot)) } returns tokenSuccess() + + val result = registrar.register(WALLET_ID, coldSigner) + + assertThat(result.isRight()).isTrue() + val request = slot.captured + assertThat(request.cardSignature).isEqualTo(base64(ByteArray(65) { 3 })) + assertThat(request.cardSignatureSalt).isEqualTo(base64(ByteArray(16) { 4 })) + // walletStatus must be exactly one Base64-encoded byte (0x82). + assertThat(request.walletStatus).isEqualTo(base64(byteArrayOf(0x82.toByte()))) + } + + @Test + fun `registering two different wallets accumulates both ids in the marker set`() = runTest { + stubHappyPath() + coEvery { authApi.registerWallet(any()) } returns tokenSuccess() + + assertThat(registrar.register(WALLET_ID, mobileSigner).isRight()).isTrue() + assertThat(registrar.register(OTHER_WALLET_ID, mobileSigner).isRight()).isTrue() + + // markRegistered must read-modify-write atomically, not overwrite the existing set. + assertThat(registeredIds()).containsExactly(WALLET_ID, OTHER_WALLET_ID) + } + + @Test + fun `register short-circuits without network when walletId already registered`() = runTest { + preferencesDataStore.edit { it[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = setOf(WALLET_ID) } + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { authApi.requestWalletNonce(any()) } + coVerify(exactly = 0) { authApi.registerWallet(any()) } + coVerify(exactly = 0) { store.save(any()) } + } + + @Test + fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns None + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.leftOrNull()).isEqualTo(WalletRegistrationError.DeviceKeyUnavailable) + coVerify(exactly = 0) { authApi.requestWalletNonce(any()) } + assertThat(registeredIds()).doesNotContain(WALLET_ID) + } + + @Test + fun `register surfaces nonce-endpoint API error`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + @Suppress("UNCHECKED_CAST") + coEvery { authApi.requestWalletNonce(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.TOO_MANY_REQUESTS, + message = "rate-limited", + errorBody = null, + ), + ) as ApiResponse + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.Api::class.java) + coVerify(exactly = 0) { authApi.registerWallet(any()) } + assertThat(registeredIds()).doesNotContain(WALLET_ID) + } + + @Test + fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() + coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed") + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.NonceDecryptionFailed::class.java) + coVerify(exactly = 0) { authApi.registerWallet(any()) } + } + + @Test + fun `register returns SigningFailed when signer throws (e g cancelled NFC or biometric)`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() + coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" + val failingSigner = WalletSigner { throw IllegalStateException("user cancelled") } + + val result = registrar.register(WALLET_ID, failingSigner) + + assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.SigningFailed::class.java) + coVerify(exactly = 0) { authApi.registerWallet(any()) } + } + + @Test + fun `register surfaces wallet-endpoint API error and does not touch tokens or marker`() = runTest { + stubHappyPath() + @Suppress("UNCHECKED_CAST") + coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.FORBIDDEN, + message = "max wallets", + errorBody = null, + ), + ) as ApiResponse + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.Api::class.java) + coVerify(exactly = 0) { store.save(any()) } + assertThat(registeredIds()).doesNotContain(WALLET_ID) + } + + @Test + fun `register treats 409 Conflict as success, marks registered without persisting tokens`() = runTest { + stubHappyPath() + @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.register(WALLET_ID, mobileSigner) + + assertThat(result.isRight()).isTrue() + assertThat(registeredIds()).contains(WALLET_ID) + coVerify(exactly = 0) { store.save(any()) } + } + + @Test + fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest { + stubHappyPath() + coEvery { authApi.registerWallet(any()) } returns tokenSuccess() + coEvery { store.save(any()) } throws IllegalStateException("DataStore I/O") + + val result = registrar.register(WALLET_ID, mobileSigner) + + assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.PersistenceFailed::class.java) + // Marker must stay unset so the next attempt retries cleanly. + assertThat(registeredIds()).doesNotContain(WALLET_ID) + } + + private fun stubHappyPath() { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() + coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" + } + + private fun nonceSuccess() = ApiResponse.Success( + data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), + ) + + private fun tokenSuccess() = ApiResponse.Success( + data = TokenApiResponse( + accessToken = "fresh-access", + accessTokenExpiresAt = "2024-01-01T00:00:00Z", + refreshToken = "fresh-rt", + refreshTokenExpiresAt = "2024-02-01T00:00:00Z", + walletIds = listOf(WALLET_ID), + ), + ) + + private fun registeredIds(): Set = + preferencesDataStore.current()[PreferencesKeys.REGISTERED_WALLET_IDS_KEY].orEmpty() + + private fun base64(bytes: ByteArray): String = java.util.Base64.getEncoder().encodeToString(bytes) + + private companion object { + const val WALLET_ID = "wallet-1" + const val OTHER_WALLET_ID = "wallet-2" + } + + /** Minimal in-memory [DataStore] implementation — only the surface area used by tests. */ + private class InMemoryPreferencesDataStore : DataStore { + + private var preferences: MutablePreferences = mutablePreferencesOf() + + override val data get() = flowOf(preferences) + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + preferences = transform(preferences).toMutablePreferences() + return preferences + } + + fun edit(block: (MutablePreferences) -> Unit) { + preferences = preferences.toMutablePreferences().also(block) + } + + fun current(): Preferences = preferences + + fun reset() { + preferences = mutablePreferencesOf() + } + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt index 5b7fba6f0e..13f4051abe 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt @@ -43,7 +43,7 @@ class SignedRequestPayloadTest { } @Test - fun `canonicalize produces newline-separated representation in the documented field order`() { + fun `canonicalize produces colon-separated representation in the documented field order`() { val metadata = DeviceMetadata( deviceModel = "Pixel 8", os = "Android", @@ -62,19 +62,9 @@ class SignedRequestPayloadTest { val bytes = signedRequestPayload.canonicalize(payload) + // Must match the backend's `:`-joined canonicalisation byte-for-byte. assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo( - """ - pub - nonce-1 - attestation - Pixel 8 - Android - 14 - 5.40.0 - Tangem/5.40.0 (Pixel 8; Android 14) - en-US - Europe/Moscow - """.trimIndent(), + "pub:nonce-1:attestation:Pixel 8:Android:14:5.40.0:Tangem/5.40.0 (Pixel 8; Android 14):en-US:Europe/Moscow", ) } @@ -98,9 +88,9 @@ class SignedRequestPayloadTest { val bytes = signedRequestPayload.canonicalize(payload) - // The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`. + // The null attestationToken collapses to an empty segment between `nonce` and `deviceModel`. assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo( - "pub\nnonce-1\n\nPixel 8\nAndroid\n14\n5.40.0\nTangem/5.40.0 (Pixel 8; Android 14)\nen-US\nEurope/Moscow", + "pub:nonce-1::Pixel 8:Android:14:5.40.0:Tangem/5.40.0 (Pixel 8; Android 14):en-US:Europe/Moscow", ) }