Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-28 16:18:25 +04:00
parent 1ce3399a02
commit bb4b8d4c6b
11 changed files with 219 additions and 7 deletions

View file

@ -14,6 +14,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -26,6 +27,11 @@ internal object ConfigModule {
return GeneratedEnvironmentConfigConverter.convert() return GeneratedEnvironmentConfigConverter.convert()
} }
@Provides
@Singleton
@Named("authServiceKey")
fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey
@Provides @Provides
@Singleton @Singleton
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage { fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {

View file

@ -33,4 +33,5 @@ data class EnvironmentConfig(
val customerIoCdpApiKey: String? = null, val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null, val surveySparrowToken: String? = null,
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null, val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
) )

View file

@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey, customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey, surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
surveySparrowSwapRating = createSurveySparrowSwapRating(), surveySparrowSwapRating = createSurveySparrowSwapRating(),
authServiceKey = null, // TODO: provide service key [REDACTED_JIRA]
) )
} }

View file

@ -16,8 +16,8 @@ tasks.withType<Test>().configureEach {
dependencies { dependencies {
/** Core */ /** Core */
implementation(projects.core.utils)
implementation(projects.core.configToggles) implementation(projects.core.configToggles)
implementation(projects.core.utils)
/** Tangem libraries */ /** Tangem libraries */
implementation(tangemDeps.card.core) implementation(tangemDeps.card.core)

View file

@ -22,9 +22,9 @@ internal class DefaultDeviceKeyManager(
) : DeviceKeyManager { ) : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) { override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) {
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
try { try {
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
generateKey() generateKey()
TangemLogger.i("Device key generated") TangemLogger.i("Device key generated")
true true

View file

@ -11,6 +11,9 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager {
override suspend fun getPublicKey(): Option<ByteArray> = None override suspend fun getPublicKey(): Option<ByteArray> = None
override suspend fun sign(data: ByteArray): ByteArray = override suspend fun sign(data: ByteArray): ByteArray {
throw DeviceKeySigningException("DeviceKeyManager is disabled: backend authentication feature toggle is off") throw DeviceKeySigningException(
"DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable",
)
}
} }

View file

@ -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.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager 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.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import dagger.Module import dagger.Module
@ -12,11 +15,12 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import java.security.KeyStore import java.security.KeyStore
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal object DeviceKeyModule { internal object AuthModule {
@Provides @Provides
@Singleton @Singleton
@ -35,4 +39,23 @@ internal object DeviceKeyModule {
DisabledDeviceKeyManager 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
}
}
} }

View file

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

View file

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

View file

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

View file

@ -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<String>(), any()) } answers {
val input = firstArg<String>()
val flags = secondArg<Int>()
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<Exception> {
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<Exception> {
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)
}
}