diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 88b77270c2..61f52257be 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index cca253b4cc..1a1a4046bf 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -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() diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 31884225fe..0d1c7f9689 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -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) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt index e332009dff..1161483d0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -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 + + /** + * 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 + /** * Request authentication nonce. * diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt index 4e9775755d..8792a60a01 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/AuthApiRequest.kt @@ -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?, - ) -} \ No newline at end of file +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt new file mode 100644 index 0000000000..960957a6a4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt @@ -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?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt new file mode 100644 index 0000000000..78cbb232dd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt @@ -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, +) \ No newline at end of file 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 544f799474..96ea2c114d 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 @@ -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") } diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 0c43be369f..685d2b32a2 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -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) 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 e5b0572cd5..7a987f509e 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 @@ -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 diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt new file mode 100644 index 0000000000..02aa26a01f --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt @@ -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 +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrationError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrationError.kt new file mode 100644 index 0000000000..5d4a4d574a --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrationError.kt @@ -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() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt new file mode 100644 index 0000000000..44abb0afb4 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt @@ -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 = 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 = 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)) + } + } + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt index a9d9bc4dd7..3357b50e06 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt @@ -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 diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt new file mode 100644 index 0000000000..6b6dee0459 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt @@ -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 { + return DeviceRegistrationError.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 new file mode 100644 index 0000000000..beecbe578f --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt @@ -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) \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt new file mode 100644 index 0000000000..ebf0d14a60 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt @@ -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()) } + 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()) } + 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()) } + 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 + + val result = registrar.register() + + assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java) + coVerify(exactly = 0) { authApi.register(any()) } + 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()) } + } + + @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()) } + } + + @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()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.FORBIDDEN, + message = "already registered", + errorBody = null, + ), + ) as ApiResponse + + 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()) } 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 { + + 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/DefaultSessionTokenRefresherTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt index a0cd03e070..f5263ea5b4 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt @@ -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, 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 new file mode 100644 index 0000000000..6c83b8845d --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt @@ -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) + } +} \ No newline at end of file