diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/local/config/ConfigModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/local/config/ConfigModule.kt index c8052af564..ce8df4da0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/local/config/ConfigModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/local/config/ConfigModule.kt @@ -14,6 +14,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Named import javax.inject.Singleton @Module @@ -26,6 +27,11 @@ internal object ConfigModule { return GeneratedEnvironmentConfigConverter.convert() } + @Provides + @Singleton + @Named("authServiceKey") + fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey + @Provides @Singleton fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 024f363f10..2647b32dd3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -33,4 +33,5 @@ data class EnvironmentConfig( val customerIoCdpApiKey: String? = null, val surveySparrowToken: String? = null, val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null, + val authServiceKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index fa4a318031..9dfc45cd9a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter { customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey, surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey, surveySparrowSwapRating = createSurveySparrowSwapRating(), + authServiceKey = null, // TODO: provide service key [REDACTED_JIRA] ) } diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index a837781837..9cdc3747e2 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -16,8 +16,8 @@ tasks.withType().configureEach { dependencies { /** Core */ - implementation(projects.core.utils) implementation(projects.core.configToggles) + implementation(projects.core.utils) /** Tangem libraries */ implementation(tangemDeps.card.core) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt index 79520eb33a..a08c21c2e6 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt @@ -22,9 +22,9 @@ internal class DefaultDeviceKeyManager( ) : DeviceKeyManager { override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) { - if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false - try { + if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false + generateKey() TangemLogger.i("Device key generated") true diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt index 6ba337519c..135469b023 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt @@ -11,6 +11,9 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager { override suspend fun getPublicKey(): Option = None - override suspend fun sign(data: ByteArray): ByteArray = - throw DeviceKeySigningException("DeviceKeyManager is disabled: backend authentication feature toggle is off") + override suspend fun sign(data: ByteArray): ByteArray { + throw DeviceKeySigningException( + "DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable", + ) + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt similarity index 56% rename from libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt rename to libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index 24b93f68da..716b126163 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -1,10 +1,13 @@ -package com.tangem.lib.auth.devicekey.di +package com.tangem.lib.auth.di import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager +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.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import dagger.Module @@ -12,11 +15,12 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import java.security.KeyStore +import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object DeviceKeyModule { +internal object AuthModule { @Provides @Singleton @@ -35,4 +39,23 @@ internal object DeviceKeyModule { DisabledDeviceKeyManager } } + + @Provides + @Singleton + fun provideAuthNonceDecryptor( + authFeatureToggles: AuthFeatureToggles, + @Named("authServiceKey") authServiceKey: String?, + dispatchers: CoroutineDispatcherProvider, + ): AuthNonceDecryptor { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledAuthNonceDecryptor + + if (authServiceKey.isNullOrEmpty()) return DisabledAuthNonceDecryptor + + return runCatching { DefaultAuthNonceDecryptor(authServiceKey, dispatchers) } + .getOrElse { e -> + TangemLogger.e("Failed to create AuthNonceDecryptor", e) + FirebaseCrashlytics.getInstance().recordException(e) + DisabledAuthNonceDecryptor + } + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/nonce/AuthNonceDecryptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/AuthNonceDecryptor.kt new file mode 100644 index 0000000000..9bc040fc60 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/AuthNonceDecryptor.kt @@ -0,0 +1,16 @@ +package com.tangem.lib.auth.nonce + +/** + * Decrypts server-issued nonces + */ +interface AuthNonceDecryptor { + + /** + * Decrypts [encryptedNonce] — a Base64url-encoded (no padding) RSA-encrypted nonce from the backend. + * + * @param encryptedNonce Base64url-encoded encrypted nonce + * @return decrypted nonce as a string + * @throws Exception if decryption fails (invalid key, corrupted ciphertext, etc.) + */ + suspend fun decryptNonce(encryptedNonce: String): String +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptor.kt new file mode 100644 index 0000000000..049334b19f --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptor.kt @@ -0,0 +1,52 @@ +package com.tangem.lib.auth.nonce.internal + +import android.util.Base64 +import com.tangem.lib.auth.nonce.AuthNonceDecryptor +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import java.security.KeyFactory +import java.security.spec.MGF1ParameterSpec +import java.security.spec.PKCS8EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource + +internal class DefaultAuthNonceDecryptor( + authServiceKeyBase64: String, + private val dispatchers: CoroutineDispatcherProvider, +) : AuthNonceDecryptor { + + private val privateKey = run { + val keyBytes = Base64.decode(authServiceKeyBase64, Base64.NO_WRAP) + val keySpec = PKCS8EncodedKeySpec(keyBytes) + KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(keySpec) + } + + override suspend fun decryptNonce(encryptedNonce: String): String = withContext(dispatchers.default) { + try { + val encryptedBytes = Base64.decode(encryptedNonce, Base64.URL_SAFE or Base64.NO_PADDING) + + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_PARAM_SPEC) + val decryptedBytes = cipher.doFinal(encryptedBytes) + + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + TangemLogger.e("Nonce decryption failed", e) + throw e + } + } + + private companion object { + const val KEY_ALGORITHM = "RSA" + const val CIPHER_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + + val OAEP_PARAM_SPEC = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT, + ) + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DisabledAuthNonceDecryptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DisabledAuthNonceDecryptor.kt new file mode 100644 index 0000000000..7bcd3b6270 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/nonce/internal/DisabledAuthNonceDecryptor.kt @@ -0,0 +1,9 @@ +package com.tangem.lib.auth.nonce.internal + +import com.tangem.lib.auth.nonce.AuthNonceDecryptor + +internal object DisabledAuthNonceDecryptor : AuthNonceDecryptor { + + override suspend fun decryptNonce(encryptedNonce: String): String = + error("AuthNonceDecryptor is disabled: feature toggle is off or auth service key is missing") +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptorTest.kt new file mode 100644 index 0000000000..c3aa79fbc1 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/nonce/internal/DefaultAuthNonceDecryptorTest.kt @@ -0,0 +1,101 @@ +package com.tangem.lib.auth.nonce.internal + +import com.google.common.truth.Truth.assertThat +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +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 +import org.junit.jupiter.api.assertThrows +import java.security.KeyPairGenerator +import java.security.spec.MGF1ParameterSpec +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultAuthNonceDecryptorTest { + + private val dispatchers = TestingCoroutineDispatcherProvider() + private val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + + private val privateKeyBase64: String = + Base64.getEncoder().encodeToString(keyPair.private.encoded) + + private lateinit var decryptor: DefaultAuthNonceDecryptor + + @BeforeEach + fun setup() { + mockkStatic(android.util.Base64::class) + every { android.util.Base64.decode(any(), any()) } answers { + val input = firstArg() + val flags = secondArg() + if (flags and android.util.Base64.URL_SAFE != 0) { + Base64.getUrlDecoder().decode(input) + } else { + Base64.getDecoder().decode(input) + } + } + decryptor = DefaultAuthNonceDecryptor(privateKeyBase64, dispatchers) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun `decryptNonce returns original nonce string`() = runTest { + val nonce = "dGVzdC1ub25jZS0xMjM0NQ" + val encrypted = encryptAndEncodeBase64Url(nonce) + + val result = decryptor.decryptNonce(encrypted) + + assertThat(result).isEqualTo(nonce) + } + + @Test + fun `decryptNonce handles base64url nonce from backend`() = runTest { + val randomBytes = ByteArray(32) { it.toByte() } + val nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes) + val encrypted = encryptAndEncodeBase64Url(nonce) + + val result = decryptor.decryptNonce(encrypted) + + assertThat(result).isEqualTo(nonce) + } + + @Test + fun `constructor throws on invalid key`() { + assertThrows { + DefaultAuthNonceDecryptor("not-a-valid-base64-key!!", dispatchers) + } + } + + @Test + fun `decryptNonce throws on corrupted ciphertext`() = runTest { + val corrupted = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(256) { 0x42 }) + + assertThrows { + decryptor.decryptNonce(corrupted) + } + } + + private fun encryptAndEncodeBase64Url(plainNonce: String): String { + val oaepSpec = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT, + ) + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.ENCRYPT_MODE, keyPair.public, oaepSpec) + val encrypted = cipher.doFinal(plainNonce.toByteArray(Charsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted) + } +} \ No newline at end of file