Updated on 2026-08-14
This commit is contained in:
parent
f0f5fe96fc
commit
e3d2e7f2e1
15 changed files with 476 additions and 1 deletions
|
|
@ -9,6 +9,11 @@
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
|
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
|
||||||
|
},
|
||||||
|
"notion": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import com.tangem.core.abtests.manager.ABTestsManager
|
||||||
import com.tangem.core.analytics.filter.OneTimeEventFilter
|
import com.tangem.core.analytics.filter.OneTimeEventFilter
|
||||||
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
|
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
|
||||||
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
|
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
|
||||||
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||||
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
||||||
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||||
|
|
@ -49,4 +50,6 @@ interface ApplicationEntryPoint {
|
||||||
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
|
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
|
||||||
|
|
||||||
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
|
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
|
||||||
|
|
||||||
|
fun getDeviceKeyManager(): DeviceKeyManager
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||||
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
|
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
|
||||||
import com.tangem.domain.common.LogConfig
|
import com.tangem.domain.common.LogConfig
|
||||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||||
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.tap.common.analytics.AnalyticsFactory
|
import com.tangem.tap.common.analytics.AnalyticsFactory
|
||||||
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
|
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
|
||||||
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
|
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
|
||||||
|
|
@ -92,6 +93,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
||||||
private val sendTransactionSignerInfoInterceptor
|
private val sendTransactionSignerInfoInterceptor
|
||||||
get() = entryPoint.getSendTransactionSignerInfoInterceptor()
|
get() = entryPoint.getSendTransactionSignerInfoInterceptor()
|
||||||
|
|
||||||
|
private val deviceKeyManager: DeviceKeyManager
|
||||||
|
get() = entryPoint.getDeviceKeyManager()
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
private val appScope = MainScope()
|
private val appScope = MainScope()
|
||||||
|
|
@ -132,6 +136,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
|
appScope.launch {
|
||||||
|
deviceKeyManager.generateIfMissing()
|
||||||
|
}
|
||||||
walletsRepository = entryPoint.getWalletsRepository()
|
walletsRepository = entryPoint.getWalletsRepository()
|
||||||
|
|
||||||
apiConfigsManager.initialize()
|
apiConfigsManager.initialize()
|
||||||
|
|
|
||||||
|
|
@ -102,5 +102,9 @@
|
||||||
{
|
{
|
||||||
"name": "AND_15154_YIELD_PROMO_ENABLED",
|
"name": "AND_15154_YIELD_PROMO_ENABLED",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
|
||||||
|
"version": "undefined"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
tangemBlockchainSdk = "develop-1527"
|
tangemBlockchainSdk = "develop-1527"
|
||||||
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||||
tangemCardSdk = "develop-614"
|
tangemCardSdk = "develop-620"
|
||||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||||
tangemVico = "tangem-master-21"
|
tangemVico = "tangem-master-21"
|
||||||
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
|
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,42 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(deps.plugins.android.library)
|
alias(deps.plugins.android.library)
|
||||||
alias(deps.plugins.kotlin.android)
|
alias(deps.plugins.kotlin.android)
|
||||||
|
alias(deps.plugins.kotlin.kapt)
|
||||||
|
alias(deps.plugins.hilt.android)
|
||||||
id("configuration")
|
id("configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.tangem.lib.auth"
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.tangem.lib.auth
|
||||||
|
|
||||||
|
interface AuthFeatureToggles {
|
||||||
|
val isBackendAuthenticationEnabled: Boolean
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.tangem.lib.auth.devicekey
|
||||||
|
|
||||||
|
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue