Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-23 19:45:31 +04:00
parent c16e04089d
commit 7fdfe487b1
11 changed files with 683 additions and 29 deletions

View file

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

View file

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

View file

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

View file

@ -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<WalletRegistrationError, Unit>
}

View file

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

View file

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

View file

@ -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<String, Mutex>()
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
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<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
}
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<WalletRegistrationError>.handleRegisterResponse(
walletId: String,
response: ApiResponse<TokenApiResponse>,
) {
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<String> = 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
}
}
}

View file

@ -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<WalletRegistrationError, Unit> {
return WalletRegistrationError.Disabled.left()
}
}

View file

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

View file

@ -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<WalletRegistrationRequest>()
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<WalletRegistrationRequest>()
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<NonceApiResponse>
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<TokenApiResponse>
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<TokenApiResponse>
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<String> =
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<Preferences> {
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()
}
}
}

View file

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