Updated on 2026-08-14
This commit is contained in:
parent
1ce3399a02
commit
bb4b8d4c6b
11 changed files with 219 additions and 7 deletions
|
|
@ -16,8 +16,8 @@ tasks.withType<Test>().configureEach {
|
|||
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager {
|
|||
|
||||
override suspend fun getPublicKey(): Option<ByteArray> = 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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