Updated on 2026-08-14
This commit is contained in:
parent
1ce3399a02
commit
bb4b8d4c6b
11 changed files with 219 additions and 7 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -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]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue