Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-22 22:31:07 +04:00
parent f0f5fe96fc
commit e3d2e7f2e1
15 changed files with 476 additions and 1 deletions

View file

@ -1,9 +1,42 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.lib.auth"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.utils)
implementation(projects.core.configToggles)
/** Tangem libraries */
implementation(tangemDeps.card.core)
/** Firebase */
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.crashlytics)
/** Other */
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,5 @@
package com.tangem.lib.auth
interface AuthFeatureToggles {
val isBackendAuthenticationEnabled: Boolean
}

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
internal class DefaultAuthFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : AuthFeatureToggles {
override val isBackendAuthenticationEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15438_BACKEND_AUTHENTICATION_ENABLED)
}

View file

@ -0,0 +1,27 @@
package com.tangem.lib.auth.devicekey
import arrow.core.Option
/**
* Manages a device-bound secp256r1 keypair in Android Keystore (TEE/StrongBox).
* The private key never leaves the secure hardware.
*/
interface DeviceKeyManager {
/**
* Ensures the device keypair exists. Generates one if missing.
* Never throws generation failures are logged and reported via the return value.
* @return `true` if a new keypair was generated, `false` if it already existed or generation failed
*/
suspend fun generateIfMissing(): Boolean
/** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */
suspend fun getPublicKey(): Option<ByteArray>
/**
* Signs [data] with SHA256withECDSA using the device private key.
* @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes
* @throws DeviceKeySigningException if signing fails
*/
suspend fun sign(data: ByteArray): ByteArray
}

View file

@ -0,0 +1,3 @@
package com.tangem.lib.auth.devicekey
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)

View file

@ -0,0 +1,38 @@
package com.tangem.lib.auth.devicekey.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.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.security.KeyStore
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DeviceKeyModule {
@Provides
@Singleton
fun provideDeviceKeyManager(
authFeatureToggles: AuthFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): DeviceKeyManager {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceKeyManager
return runCatching {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
DefaultDeviceKeyManager(keyStore, dispatchers)
}.getOrElse { e ->
TangemLogger.e("Failed to init AndroidKeyStore, falling back to disabled DeviceKeyManager", e)
FirebaseCrashlytics.getInstance().recordException(e)
DisabledDeviceKeyManager
}
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.lib.auth.devicekey.internal
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import arrow.core.None
import arrow.core.Option
import com.tangem.crypto.Secp256r1
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
internal class DefaultDeviceKeyManager(
private val keyStore: KeyStore,
private val dispatchers: CoroutineDispatcherProvider,
) : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) {
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
try {
generateKey()
TangemLogger.i("Device key generated")
true
} catch (e: Exception) {
TangemLogger.e("Failed to generate device key", e)
false
}
}
override suspend fun getPublicKey(): Option<ByteArray> = withContext(dispatchers.io) {
Option.catch(
recover = { e ->
TangemLogger.e("Failed to get device public key", e)
None
},
f = ::getPublicKeyBytes,
)
}
override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) {
try {
val privateKey = keyStore.getKey(KEY_ALIAS, null)
?: throw DeviceKeySigningException("Device key not found")
val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey as java.security.PrivateKey)
update(data)
}
val derSignature = signature.sign()
Secp256r1.toByte64(derSignature)
} catch (e: DeviceKeySigningException) {
TangemLogger.e("Device key signing failed", e)
throw e
} catch (e: Exception) {
TangemLogger.e("Device key signing failed", e)
throw DeviceKeySigningException("Signing failed", e)
}
}
private fun generateKey() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
initAndGenerateKeyPair(strongBox = true)
} catch (e: Exception) {
TangemLogger.i("StrongBox unavailable, falling back to TEE", e)
initAndGenerateKeyPair(strongBox = false)
}
} else {
initAndGenerateKeyPair(strongBox = false)
}
}
private fun initAndGenerateKeyPair(strongBox: Boolean) {
val spec = buildKeyGenSpec(strongBox)
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER)
generator.initialize(spec)
generator.generateKeyPair()
}
private fun buildKeyGenSpec(strongBox: Boolean): KeyGenParameterSpec {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false)
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
return builder.build()
}
private fun getPublicKeyBytes(): ByteArray {
val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" }
val encoded = cert.publicKey.encoded
check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) {
"Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}"
}
val point = encoded.copyOfRange(encoded.size - EC_UNCOMPRESSED_POINT_SIZE, encoded.size)
check(point[0] == UNCOMPRESSED_POINT_PREFIX) {
"Invalid EC public key: expected uncompressed point prefix 0x04, got 0x${"%02x".format(point[0])}"
}
return point
}
private companion object {
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
const val KEY_ALIAS = "tangem_device_key"
const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
const val EC_UNCOMPRESSED_POINT_SIZE = 65
const val UNCOMPRESSED_POINT_PREFIX = 0x04.toByte()
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
internal object DisabledDeviceKeyManager : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = false
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")
}

View file

@ -0,0 +1,21 @@
package com.tangem.lib.auth.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.DefaultAuthFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AuthFeatureTogglesModule {
@Provides
@Singleton
fun provideAuthFeatureToggles(featureTogglesManager: FeatureTogglesManager): AuthFeatureToggles {
return DefaultAuthFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import com.google.common.truth.Truth.assertThat
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
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.KeyStore
import java.security.PrivateKey
import java.security.cert.Certificate
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultDeviceKeyManagerTest {
private val keyStore: KeyStore = mockk(relaxed = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val manager: DefaultDeviceKeyManager = DefaultDeviceKeyManager(keyStore, dispatchers)
@BeforeEach
fun setup() {
clearMocks(keyStore)
}
@AfterEach
fun teardown() {
unmockkAll()
}
@Test
fun `generateIfMissing returns false when key already exists`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns true
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `generateIfMissing returns false when generation fails`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns false
val keyPairGenerator = mockk<KeyPairGenerator>(relaxed = true)
every { keyPairGenerator.generateKeyPair() } throws RuntimeException("keystore unavailable")
mockkStatic(KeyPairGenerator::class)
every { KeyPairGenerator.getInstance("EC", "AndroidKeyStore") } returns keyPairGenerator
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `getPublicKey returns last 65 bytes from encoded key`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result.getOrNull()).isEqualTo(rawPoint)
}
@Test
fun `getPublicKey returns None when certificate not found`() = runTest {
every { keyStore.getCertificate(KEY_ALIAS) } returns null
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `sign returns raw 64-byte signature`() = runTest {
val data = "test data".toByteArray()
val r = ByteArray(32) { 0x01 }
val s = ByteArray(32) { 0x02 }
val derSignature = buildDer(r, s)
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } returns Unit
every { javaSig.update(data) } returns Unit
every { javaSig.sign() } returns derSignature
mockkSignatureGetInstance(javaSig)
val result = manager.sign(data)
assertThat(result).hasLength(64)
assertThat(result.copyOfRange(0, 32)).isEqualTo(r)
assertThat(result.copyOfRange(32, 64)).isEqualTo(s)
}
@Test
fun `sign throws DeviceKeySigningException when key not found`() = runTest {
every { keyStore.getKey(KEY_ALIAS, null) } returns null
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).contains("Device key not found")
}
@Test
fun `sign wraps unexpected exception in DeviceKeySigningException`() = runTest {
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } throws RuntimeException("hardware error")
mockkSignatureGetInstance(javaSig)
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).isEqualTo("Signing failed")
assertThat(exception.cause).isInstanceOf(RuntimeException::class.java)
}
private fun mockkSignatureGetInstance(mock: java.security.Signature) {
io.mockk.mockkStatic(java.security.Signature::class)
every { java.security.Signature.getInstance("SHA256withECDSA") } returns mock
}
private fun buildDer(r: ByteArray, s: ByteArray): ByteArray {
val rTlv = byteArrayOf(0x02, r.size.toByte()) + r
val sTlv = byteArrayOf(0x02, s.size.toByte()) + s
val body = rTlv + sTlv
return byteArrayOf(0x30, body.size.toByte()) + body
}
private companion object {
const val KEY_ALIAS = "tangem_device_key"
}
}