Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 13:39:53 +04:00
parent 0b3914cf6a
commit 7581e22ee7
19 changed files with 784 additions and 74 deletions

View file

@ -9,6 +9,8 @@ import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.wallets.repository.WalletsRepository
@ -52,4 +54,8 @@ interface ApplicationEntryPoint {
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
fun getDeviceKeyManager(): DeviceKeyManager
fun getDeviceRegistrar(): DeviceRegistrar
fun getAuthFeatureToggles(): AuthFeatureToggles
}

View file

@ -21,7 +21,9 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -96,6 +98,12 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val deviceKeyManager: DeviceKeyManager
get() = entryPoint.getDeviceKeyManager()
private val deviceRegistrar: DeviceRegistrar
get() = entryPoint.getDeviceRegistrar()
private val authFeatureToggles: AuthFeatureToggles
get() = entryPoint.getAuthFeatureToggles()
// endregion
private val appScope = MainScope()
@ -136,8 +144,15 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
}
fun init() {
appScope.launch {
deviceKeyManager.generateIfMissing()
if (authFeatureToggles.isBackendAuthenticationEnabled) {
appScope.launch {
// Order matters: registration reads the device public key, so it must wait for
// generation to complete. Running them concurrently on first launch would race —
// register() would see `DeviceKeyUnavailable` and defer to the next app launch.
deviceKeyManager.generateIfMissing()
deviceRegistrar.register()
.onLeft { error -> TangemLogger.w("Device registration deferred: $error") }
}
}
walletsRepository = entryPoint.getWalletsRepository()

View file

@ -89,19 +89,19 @@ dependencies {
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
/** Logging */
/** Network */
implementation(deps.moshi)
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.okHttp)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
@ -120,7 +120,7 @@ dependencies {
releaseImplementation(deps.chuckerStub)
/** Local storages */
implementation(deps.androidx.datastore)
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.api.auth
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
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
@ -14,6 +15,24 @@ import retrofit2.http.POST
*/
interface AuthApi {
/**
* Request device registration nonce.
*
* Generates a nonce bound to the device public key for the device registration flow.
*/
@POST("api/v1/auth/nonce/device")
suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Register device.
*
* Registers a new device using its hardware-backed public key and issues the initial
* session token pair. Called once per app install.
*/
@POST("api/v1/auth/register")
@RequiresDpopProof
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
/**
* Request authentication nonce.
*

View file

@ -23,24 +23,4 @@ data class AuthenticationPayload(
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
) {
/** Device metadata collection. */
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)
}
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Client-reported device metadata, included in both [AuthenticationPayload] and [RegisterPayload].
* Mirrors the `DeviceMetadata` schema in the backend OpenAPI contract.
*/
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Registration request registers a new device and establishes initial trust.
*
* Posted to `POST /api/v1/auth/register`; on success the server returns
* [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
*/
@JsonClass(generateAdapter = true)
data class RegisterApiRequest(
/** Signed registration payload. */
@Json(name = "payload") val payload: RegisterPayload,
/** EC signature over the registration payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed registration payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class RegisterPayload(
/** Base64-encoded EC public key of the device. */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the `/api/v1/auth/nonce/device` endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -103,6 +103,8 @@ object PreferencesKeys {
val IS_GOOGLE_PAY_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGooglePayAvailable") }
val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") }
val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") }
val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") }

View file

@ -26,11 +26,6 @@ dependencies {
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.moshi)
implementation(deps.okHttp)
implementation(deps.retrofit)
/** DI */
implementation(deps.hilt.android)

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
@ -20,16 +21,19 @@ import com.tangem.lib.auth.http.SessionAuthenticator
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
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.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.DisabledDeviceRegistrar
import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
import com.tangem.lib.auth.session.internal.SignedRequestPayload
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
import dagger.Module
import dagger.Provides
@ -145,7 +149,7 @@ internal object AuthModule {
store: SessionTokensStore,
deviceKeyManager: DeviceKeyManager,
nonceDecryptor: AuthNonceDecryptor,
appInfoProvider: AppInfoProvider,
signedRequestPayload: SignedRequestPayload,
errorConverter: AuthErrorConverter,
dispatchers: CoroutineDispatcherProvider,
): SessionTokenRefresher {
@ -156,13 +160,41 @@ internal object AuthModule {
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
appInfoProvider = appInfoProvider,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
clock = Clock.System,
dispatchers = dispatchers,
)
}
@Suppress("LongParameterList")
@Provides
@Singleton
fun provideDeviceRegistrar(
authFeatureToggles: AuthFeatureToggles,
authApi: AuthApi,
store: SessionTokensStore,
deviceKeyManager: DeviceKeyManager,
nonceDecryptor: AuthNonceDecryptor,
signedRequestPayload: SignedRequestPayload,
errorConverter: AuthErrorConverter,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): DeviceRegistrar {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceRegistrar
return DefaultDeviceRegistrar(
authApi = authApi,
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
@SessionAuthInterceptor

View file

@ -0,0 +1,24 @@
package com.tangem.lib.auth.session
import arrow.core.Either
/**
* Registers the device with the Tangem Auth Service and persists the initial session tokens.
*
* Idempotent and safe to call on every app launch:
* - on first run, fetches a ciphered nonce from `POST /api/v1/auth/nonce/device`, decrypts it
* with the app's RSA private key, signs a `RegisterPayload` with the device key, posts it to
* `POST /api/v1/auth/register`, persists the resulting `SessionTokens` and flips the
* "device registered" flag in `AppPreferencesStore`,
* - on subsequent runs, sees the flag and short-circuits without any network traffic.
*
* Tokens returned by `/register` are not surfaced to callers they're written to
* `SessionTokensStore` and accessed from there. The result type carries only success/failure
* so callers can log/report transient errors.
*
* Implementations serialise concurrent callers so the server-issued nonce isn't consumed twice.
*/
interface DeviceRegistrar {
suspend fun register(): Either<DeviceRegistrationError, Unit>
}

View file

@ -0,0 +1,29 @@
package com.tangem.lib.auth.session
/**
* Typed failure mode of `DeviceRegistrar.register()`. Mirrors [SessionRefreshError] but covers
* the registration-specific paths (`/nonce/device` + `/register`).
*/
sealed class DeviceRegistrationError {
/** API-level error from `/nonce/device` or `/register`. Transient unless [cause] says otherwise. */
data class Api(val cause: AuthError) : DeviceRegistrationError()
/** Device key is not provisioned in Keystore (registration cannot proceed without one). */
data object DeviceKeyUnavailable : DeviceRegistrationError()
/** RSA/OAEP decryption of the server-issued device-registration nonce failed. */
data class NonceDecryptionFailed(val cause: Throwable) : DeviceRegistrationError()
/** Device-key signing of the registration payload failed (Keystore I/O or ECDSA failure). */
data class SigningFailed(val cause: Throwable) : DeviceRegistrationError()
/**
* Persisting the freshly minted tokens or the `IS_DEVICE_REGISTERED_KEY` flag failed
* (DataStore I/O). The flag stays `false`, so the next launch retries cleanly.
*/
data class PersistenceFailed(val cause: Throwable) : DeviceRegistrationError()
/** Registrar is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */
data object Disabled : DeviceRegistrationError()
}

View file

@ -0,0 +1,115 @@
package com.tangem.lib.auth.session.internal
import arrow.core.Either
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.RegisterApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterPayload
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.datasource.local.preferences.utils.store
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
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
@Suppress("LongParameterList")
internal class DefaultDeviceRegistrar(
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,
) : DeviceRegistrar {
private val mutex = Mutex()
override suspend fun register(): Either<DeviceRegistrationError, Unit> = withContext(dispatchers.io) {
// `Mutex` guards against the unlikely case of two concurrent callers passing the
// already-registered check together and consuming the same `/nonce/device` value twice.
mutex.withLock { runRegister() }
}
private suspend fun runRegister(): Either<DeviceRegistrationError, Unit> = either {
val isAlreadyRegistered = appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY,
default = false,
)
if (isAlreadyRegistered) {
TangemLogger.i("Device already registered — skipping /register")
return@either
}
TangemLogger.i("Starting device registration")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
?: raise(DeviceRegistrationError.DeviceKeyUnavailable)
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
val nonceResponse = authApi.requestDeviceNonce(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/device request failed: $authError")
raise(DeviceRegistrationError.Api(authError))
}
}
val nonce = try {
nonceDecryptor.decryptNonce(cipheredNonce)
} catch (e: Exception) {
TangemLogger.e("Failed to decrypt device-registration nonce", e)
raise(DeviceRegistrationError.NonceDecryptionFailed(e))
}
val payload = RegisterPayload(
devicePublicKey = devicePublicKeyBase64,
nonce = nonce,
attestationToken = null,
metadata = signedRequestPayload.deviceMetadata,
)
val signature = try {
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
} catch (e: Exception) {
TangemLogger.e("Failed to sign device-registration payload", e)
raise(DeviceRegistrationError.SigningFailed(e))
}
val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature))
when (registerResponse) {
is ApiResponse.Success -> {
val tokens = SessionTokensConverter.convertBack(registerResponse.data)
try {
// Keep both writes inside one catch — if the second one fails, the flag stays
// `false` and the next launch retries cleanly. Worst case: tokens are persisted
// without the flag, and the retry mints fresh ones that overwrite them.
store.save(tokens)
appPreferencesStore.store(key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = true)
} catch (e: Exception) {
TangemLogger.e("Failed to persist device-registration tokens / flag", e)
raise(DeviceRegistrationError.PersistenceFailed(e))
}
TangemLogger.i("Device registered successfully")
}
is ApiResponse.Error -> {
val authError = errorConverter.convert(registerResponse.cause)
TangemLogger.e("/register request failed: $authError")
raise(DeviceRegistrationError.Api(authError))
}
}
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.lib.auth.session.internal
import android.util.Base64
import arrow.core.Either
import arrow.core.left
import arrow.core.raise.either
@ -8,7 +7,6 @@ import arrow.core.right
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload.DeviceMetadata
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
@ -21,7 +19,6 @@ import com.tangem.lib.auth.session.SessionTokenRefresher
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
@ -36,7 +33,7 @@ internal class DefaultSessionTokenRefresher(
private val store: SessionTokensStore,
private val deviceKeyManager: DeviceKeyManager,
private val nonceDecryptor: AuthNonceDecryptor,
private val appInfoProvider: AppInfoProvider,
private val signedRequestPayload: SignedRequestPayload,
private val errorConverter: AuthErrorConverter,
private val clock: Clock,
private val dispatchers: CoroutineDispatcherProvider,
@ -122,11 +119,10 @@ internal class DefaultSessionTokenRefresher(
devicePublicKey = devicePublicKeyBase64,
nonce = nonce,
attestationToken = null,
metadata = buildDeviceMetadata(),
metadata = signedRequestPayload.deviceMetadata,
)
val signaturePayload = canonicalize(payload)
val signature = try {
deviceKeyManager.sign(signaturePayload).toBase64NoWrap()
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
} catch (e: Exception) {
TangemLogger.e("Failed to sign authentication payload", e)
raise(SessionRefreshError.SigningFailed(e))
@ -165,35 +161,6 @@ internal class DefaultSessionTokenRefresher(
}
}
private fun buildDeviceMetadata(): DeviceMetadata = DeviceMetadata(
deviceModel = appInfoProvider.device,
os = appInfoProvider.platform,
osVersion = appInfoProvider.osVersion,
appVersion = appInfoProvider.appVersion,
userAgent = null,
locale = appInfoProvider.language,
timezone = appInfoProvider.timezone,
)
private fun canonicalize(payload: AuthenticationPayload): ByteArray {
// Stable, line-separated representation; backend treats the signed bytes opaquely. If the
// server pins to a specific canonicalisation (e.g. CBOR / sorted JSON), update both sides
// together.
return buildString {
append(payload.devicePublicKey).append('\n')
append(payload.nonce).append('\n')
append(payload.attestationToken.orEmpty()).append('\n')
append(payload.metadata.deviceModel.orEmpty()).append('\n')
append(payload.metadata.os).append('\n')
append(payload.metadata.osVersion.orEmpty()).append('\n')
append(payload.metadata.appVersion.orEmpty()).append('\n')
append(payload.metadata.locale.orEmpty()).append('\n')
append(payload.metadata.timezone.orEmpty())
}.toByteArray(Charsets.UTF_8)
}
private fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP)
private sealed interface RefreshOutcome {
data class Success(val tokens: SessionTokens) : RefreshOutcome
data object Unauthenticated : RefreshOutcome

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth.session.internal
import arrow.core.Either
import arrow.core.left
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
internal object DisabledDeviceRegistrar : DeviceRegistrar {
override suspend fun register(): Either<DeviceRegistrationError, Unit> {
return DeviceRegistrationError.Disabled.left()
}
}

View file

@ -0,0 +1,75 @@
package com.tangem.lib.auth.session.internal
import android.util.Base64
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.DeviceMetadata
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.utils.info.AppInfoProvider
import javax.inject.Inject
/**
* Shared helpers for the device-signed request payloads used by `/auth/register`
* ([RegisterPayload]) and `/auth/authenticate` ([AuthenticationPayload]). The two DTOs have
* identical field shapes the canonicalisation is parameterised by primitives and exposed via
* type-specific overloads, so the two DTOs don't need to share a common interface.
*/
internal class SignedRequestPayload @Inject constructor(
private val appInfoProvider: AppInfoProvider,
) {
/** Snapshot of [appInfoProvider]'s device facts as the network DTO. `userAgent` is intentionally null. */
val deviceMetadata: DeviceMetadata
get() = DeviceMetadata(
deviceModel = appInfoProvider.device,
// Backend contract is lowercase `android`/`ios`; AppInfoProvider returns `"Android"`.
os = appInfoProvider.platform.lowercase(),
osVersion = appInfoProvider.osVersion,
appVersion = appInfoProvider.appVersion,
userAgent = null,
locale = appInfoProvider.language,
timezone = appInfoProvider.timezone,
)
/** @see canonicalize */
fun canonicalize(payload: AuthenticationPayload): ByteArray = canonicalize(
devicePublicKey = payload.devicePublicKey,
nonce = payload.nonce,
attestationToken = payload.attestationToken,
metadata = payload.metadata,
)
/** @see canonicalize */
fun canonicalize(payload: RegisterPayload): ByteArray = canonicalize(
devicePublicKey = payload.devicePublicKey,
nonce = payload.nonce,
attestationToken = payload.attestationToken,
metadata = payload.metadata,
)
/**
* 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], with one exception:
* [DeviceMetadata.userAgent] is intentionally NOT included in the signed bytes (it's always
* `null` in [deviceMetadata] and the server doesn't sign it either).
*/
private fun canonicalize(
devicePublicKey: String,
nonce: String,
attestationToken: String?,
metadata: DeviceMetadata,
): ByteArray = buildString {
append(devicePublicKey).append('\n')
append(nonce).append('\n')
append(attestationToken.orEmpty()).append('\n')
append(metadata.deviceModel.orEmpty()).append('\n')
append(metadata.os).append('\n')
append(metadata.osVersion.orEmpty()).append('\n')
append(metadata.appVersion.orEmpty()).append('\n')
append(metadata.locale.orEmpty()).append('\n')
append(metadata.timezone.orEmpty())
}.toByteArray(Charsets.UTF_8)
}
/** Base64-encodes [this] without line wraps — required for DPoP proofs and device signatures. */
internal fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP)

View file

@ -0,0 +1,245 @@
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.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
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.DeviceRegistrationError
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.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 DefaultDeviceRegistrarTest {
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: DefaultDeviceRegistrar
@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 = DefaultDeviceRegistrar(
authApi = authApi,
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@AfterEach
fun teardown() = unmockkAll()
@Test
fun `register posts nonce + register and persists tokens and flag on success`() = runTest {
stubHappyPath()
val result = registrar.register()
assertThat(result.isRight()).isTrue()
coVerify { authApi.requestDeviceNonce(any()) }
coVerify { authApi.register(any<RegisterApiRequest>()) }
coVerify { store.save(any()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
}
@Test
fun `register short-circuits without network when the flag is already set`() = runTest {
preferencesDataStore.edit { it[PreferencesKeys.IS_DEVICE_REGISTERED_KEY] = true }
val result = registrar.register()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
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()
assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable)
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register surfaces nonce-endpoint API error`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
@Suppress("UNCHECKED_CAST")
coEvery { authApi.requestDeviceNonce(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()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
}
@Test
fun `register returns SigningFailed when signing throws`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } throws IllegalStateException("Keystore offline")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
}
@Test
fun `register surfaces register-endpoint API error and does not touch tokens or flag`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST")
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.FORBIDDEN,
message = "already registered",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
coVerify(exactly = 0) { store.save(any()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
stubHappyPath()
coEvery { store.save(any()) } throws IllegalStateException("DataStore I/O")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.PersistenceFailed::class.java)
// Flag must stay unset so the next launch retries cleanly.
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
private fun stubHappyPath() {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse(
accessToken = "fresh-access",
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
refreshToken = "fresh-rt",
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
walletIds = listOf("w1"),
),
)
}
/** 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,6 +43,7 @@ class DefaultSessionTokenRefresherTest {
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 fixedClock = object : Clock {
@ -63,7 +64,7 @@ class DefaultSessionTokenRefresherTest {
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
appInfoProvider = appInfoProvider,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
clock = fixedClock,
dispatchers = dispatchers,

View file

@ -0,0 +1,135 @@
package com.tangem.lib.auth.session.internal
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.DeviceMetadata
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.utils.info.AppInfoProvider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignedRequestPayloadTest {
private val appInfoProvider: AppInfoProvider = mockk {
every { device } returns "Pixel 8"
every { platform } returns "Android"
every { osVersion } returns "14"
every { appVersion } returns "5.40.0"
every { language } returns "en-US"
every { timezone } returns "Europe/Moscow"
}
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
@Test
fun `deviceMetadata wires AppInfoProvider fields, forces userAgent to null, lowercases platform`() {
val metadata = signedRequestPayload.deviceMetadata
// Backend contract is lowercase `android`/`ios` — verify normalization at the source.
assertThat(metadata).isEqualTo(
DeviceMetadata(
deviceModel = "Pixel 8",
os = "android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
),
)
}
@Test
fun `canonicalize produces newline-separated representation in the documented field order`() {
val metadata = DeviceMetadata(
deviceModel = "Pixel 8",
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
)
val payload = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val bytes = signedRequestPayload.canonicalize(payload)
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo(
"""
pub
nonce-1
attestation
Pixel 8
Android
14
5.40.0
en-US
Europe/Moscow
""".trimIndent(),
)
}
@Test
fun `canonicalize replaces null fields with empty string`() {
val metadata = DeviceMetadata(
deviceModel = null,
os = "Android",
osVersion = null,
appVersion = null,
userAgent = null,
locale = null,
timezone = null,
)
val payload = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = null,
metadata = metadata,
)
val bytes = signedRequestPayload.canonicalize(payload)
// 8 newlines separate 9 logical slots; all but `devicePublicKey`, `nonce`, and `os` are empty.
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo("pub\nnonce-1\n\n\nAndroid\n\n\n\n")
}
@Test
fun `canonicalize AuthenticationPayload and RegisterPayload with same fields produces same bytes`() {
// Identical canonicalisation across the two DTOs is the whole point of the shared helper —
// verify the overloads can't drift apart silently.
val metadata = DeviceMetadata(
deviceModel = "Pixel 8",
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
)
val auth = AuthenticationPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val register = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val authBytes = signedRequestPayload.canonicalize(auth)
val registerBytes = signedRequestPayload.canonicalize(register)
assertThat(authBytes).isEqualTo(registerBytes)
}
}