Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+51
View File
@@ -0,0 +1,51 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
}
android {
namespace 'io.novafoundation.nova.feature_account_migration'
compileSdk rootProject.compileSdkVersion
buildFeatures {
buildConfig true
viewBinding true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
freeCompilerArgs = ["-Xcontext-receivers"]
}
}
dependencies {
implementation project(":common")
implementation project(":feature-account-api")
implementation project(":feature-cloud-backup-api")
implementation project(':feature-deep-linking')
implementation daggerDep
ksp daggerCompiler
implementation androidDep
implementation constraintDep
implementation cardViewDep
implementation recyclerViewDep
implementation materialDep
implementation kotlinDep
testImplementation jUnitDep
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_account_migration.di
import io.novafoundation.nova.feature_account_migration.di.deeplinks.AccountMigrationDeepLinks
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
interface AccountMigrationFeatureApi {
val accountMigrationMixinProvider: AccountMigrationMixinProvider
val accountMigrationDeepLinks: AccountMigrationDeepLinks
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_account_migration.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_account_migration.presentation.pairing.di.AccountMigrationPairingComponent
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
@Component(
dependencies = [
AccountMigrationFeatureDependencies::class
],
modules = [
AccountMigrationFeatureModule::class
]
)
@FeatureScope
interface AccountMigrationFeatureComponent : AccountMigrationFeatureApi {
fun accountMigrationPairingComponentFactory(): AccountMigrationPairingComponent.Factory
@Component.Factory
interface Factory {
fun create(
deps: AccountMigrationFeatureDependencies,
@BindsInstance router: AccountMigrationRouter
): AccountMigrationFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
AccountFeatureApi::class,
CloudBackupFeatureApi::class
]
)
interface AccountMigrationFeatureDependenciesComponent : AccountMigrationFeatureDependencies
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_account_migration.di
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.splash.SplashPassedObserver
import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
interface AccountMigrationFeatureDependencies {
val resourceManager: ResourceManager
val mnemonicAddAccountRepository: MnemonicAddAccountRepository
val encryptionDefaults: EncryptionDefaults
val accountRepository: AccountRepository
val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory
val automaticInteractionGate: AutomaticInteractionGate
val splashPassedObserver: SplashPassedObserver
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_account_migration.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import javax.inject.Inject
@ApplicationScope
class AccountMigrationFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: AccountMigrationRouter
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val featureDependencies = DaggerAccountMigrationFeatureComponent_AccountMigrationFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java))
.build()
return DaggerAccountMigrationFeatureComponent.factory()
.create(featureDependencies, router)
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_account_migration.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_migration.di.deeplinks.DeepLinkModule
import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils
@Module(includes = [DeepLinkModule::class])
class AccountMigrationFeatureModule {
@Provides
@FeatureScope
fun provideKeyExchangeUtils(): KeyExchangeUtils {
return KeyExchangeUtils()
}
@Provides
@FeatureScope
fun provideExchangeSecretsMixinProvider(
keyExchangeUtils: KeyExchangeUtils
): AccountMigrationMixinProvider {
return AccountMigrationMixinProvider(keyExchangeUtils)
}
@Provides
@FeatureScope
fun provideAccountMigrationInteractor(
addAccountRepository: MnemonicAddAccountRepository,
encryptionDefaults: EncryptionDefaults,
accountRepository: AccountRepository
): AccountMigrationInteractor {
return AccountMigrationInteractor(addAccountRepository, encryptionDefaults, accountRepository)
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_account_migration.di.deeplinks
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.splash.SplashPassedObserver
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_account_migration.presentation.deeplinks.MigrationCompleteDeepLinkHandler
import io.novafoundation.nova.feature_account_migration.presentation.deeplinks.RequestMigrationDeepLinkHandler
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideRequestMigrationDeepLinkHandler(
router: AccountMigrationRouter,
automaticInteractionGate: AutomaticInteractionGate,
splashPassedObserver: SplashPassedObserver,
repository: AccountRepository
) = RequestMigrationDeepLinkHandler(
router,
automaticInteractionGate,
splashPassedObserver,
repository
)
@Provides
@FeatureScope
fun provideMigrationCompleteDeepLinkHandler(
automaticInteractionGate: AutomaticInteractionGate,
accountMigrationMixinProvider: AccountMigrationMixinProvider,
repository: AccountRepository
) = MigrationCompleteDeepLinkHandler(
automaticInteractionGate,
accountMigrationMixinProvider,
repository
)
@Provides
@FeatureScope
fun provideDeepLinks(
requestMigrationHandler: RequestMigrationDeepLinkHandler,
migrationCompleteHandler: MigrationCompleteDeepLinkHandler
): AccountMigrationDeepLinks {
return AccountMigrationDeepLinks(listOf(requestMigrationHandler, migrationCompleteHandler))
}
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_account_migration.di.deeplinks
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
class AccountMigrationDeepLinks(val deepLinkHandlers: List<DeepLinkHandler>)
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_account_migration.domain
import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult
import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository
import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption
import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
class AccountMigrationInteractor(
private val addAccountRepository: MnemonicAddAccountRepository,
private val encryptionDefaults: EncryptionDefaults,
private val accountRepository: AccountRepository
) {
suspend fun isPinCodeSet(): Boolean = accountRepository.isCodeSet()
suspend fun addAccount(name: String, entropy: ByteArray) {
val mnemonic = MnemonicCreator.fromEntropy(entropy)
val advancedEncryption = AdvancedEncryption(
substrateCryptoType = encryptionDefaults.substrateCryptoType,
ethereumCryptoType = encryptionDefaults.ethereumCryptoType,
derivationPaths = AdvancedEncryption.DerivationPaths(
substrate = encryptionDefaults.substrateDerivationPath,
ethereum = encryptionDefaults.ethereumDerivationPath
)
)
val payload = MnemonicAddAccountRepository.Payload(
mnemonic.words,
advancedEncryption,
AddAccountType.MetaAccount(name)
)
val addAccountResult = addAccountRepository.addAccount(payload)
require(addAccountResult is AddAccountResult.AccountAdded)
accountRepository.selectMetaAccount(addAccountResult.metaId)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_account_migration.presentation
import io.novafoundation.nova.common.navigation.ReturnableRouter
interface AccountMigrationRouter : ReturnableRouter {
fun openAccountMigrationPairing(scheme: String)
fun finishMigrationFlow()
fun openPinCodeSet()
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_account_migration.presentation.deeplinks
import android.net.Uri
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_migration.utils.AccountExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import io.novasama.substrate_sdk_android.extensions.fromHex
import kotlinx.coroutines.flow.MutableSharedFlow
private const val MIGRATION_COMPLETE_PATH = "/migration-complete"
class MigrationCompleteDeepLinkHandler(
private val automaticInteractionGate: AutomaticInteractionGate,
private val accountMigrationMixinProvider: AccountMigrationMixinProvider,
private val repository: AccountRepository
) : DeepLinkHandler {
override val callbackFlow = MutableSharedFlow<CallbackEvent>()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(MIGRATION_COMPLETE_PATH)
}
override suspend fun handleDeepLink(data: Uri) = runCatching {
if (repository.isAccountSelected()) {
automaticInteractionGate.awaitInteractionAllowed()
}
val mnemonic = data.getQueryParameter("mnemonic") ?: error("No secret was passed")
val peerPublicKey = data.getQueryParameter("key") ?: error("No key was passed")
val accountName = data.getQueryParameter("name")
val mixin = accountMigrationMixinProvider.getMixin() ?: error("Migration state invalid")
mixin.onPeerSecretsReceived(
secret = mnemonic.fromHex(),
publicKey = peerPublicKey.fromHex(),
exchangePayload = AccountExchangePayload(accountName)
)
}
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_account_migration.presentation.deeplinks
import android.net.Uri
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.common.utils.splash.SplashPassedObserver
import io.novafoundation.nova.common.utils.splash.awaitSplashPassed
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import kotlinx.coroutines.flow.MutableSharedFlow
private const val ACTION_MIGRATE_PATH_REGEX = "/migrate"
class RequestMigrationDeepLinkHandler(
private val router: AccountMigrationRouter,
private val automaticInteractionGate: AutomaticInteractionGate,
private val splashPassedObserver: SplashPassedObserver,
private val repository: AccountRepository
) : DeepLinkHandler {
override val callbackFlow = MutableSharedFlow<CallbackEvent>()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(ACTION_MIGRATE_PATH_REGEX)
}
override suspend fun handleDeepLink(data: Uri): Result<Unit> = runCatching {
if (repository.isAccountSelected()) {
automaticInteractionGate.awaitInteractionAllowed()
} else {
splashPassedObserver.awaitSplashPassed()
}
val scheme = data.getQueryParameter("scheme") ?: error("No scheme was passed")
router.openAccountMigrationPairing(scheme)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_account_migration.presentation.pairing
import android.view.View
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.utils.FragmentPayloadCreator
import io.novafoundation.nova.common.utils.PayloadCreator
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.payload
import io.novafoundation.nova.feature_account_migration.R
import io.novafoundation.nova.feature_account_migration.databinding.FragmentAccountMigrationPairingBinding
import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureApi
import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureComponent
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction
class AccountMigrationPairingFragment : BaseFragment<AccountMigrationPairingViewModel, FragmentAccountMigrationPairingBinding>() {
companion object : PayloadCreator<AccountMigrationPairingPayload> by FragmentPayloadCreator()
override fun createBinding() = FragmentAccountMigrationPairingBinding.inflate(layoutInflater)
override fun applyInsets(rootView: View) {
binder.accountMigrationSkipContainer.applyStatusBarInsets()
binder.accountMigrationPairContainer.applyNavigationBarInsets()
}
override fun initViews() {
binder.accountMigrationSkip.background = getRoundedCornerDrawable(R.color.button_background_secondary, cornerSizeDp = 10)
.withRippleMask(getRippleMask(cornerSizeDp = 10))
binder.accountMigrationSkip.setOnClickListener { viewModel.close() }
binder.accountMigrationPair.prepareForProgress(this)
binder.accountMigrationPair.setOnClickListener { viewModel.acceptMigration() }
}
override fun inject() {
FeatureUtils.getFeature<AccountMigrationFeatureComponent>(
requireContext(),
AccountMigrationFeatureApi::class.java
)
.accountMigrationPairingComponentFactory()
.create(this, payload())
.inject(this)
}
override fun subscribe(viewModel: AccountMigrationPairingViewModel) {
observeBrowserEvents(viewModel)
observeConfirmationAction(viewModel.cloudBackupWarningMixin)
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_account_migration.presentation.pairing
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class AccountMigrationPairingPayload(
val scheme: String
) : Parcelable
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_account_migration.presentation.pairing
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.capitalize
import io.novafoundation.nova.feature_account_migration.R
import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_account_migration.utils.AccountExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import io.novasama.substrate_sdk_android.extensions.toHexString
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class AccountMigrationPairingViewModel(
private val resourceManager: ResourceManager,
private val accountMigrationMixinProvider: AccountMigrationMixinProvider,
private val accountMigrationInteractor: AccountMigrationInteractor,
private val payload: AccountMigrationPairingPayload,
private val router: AccountMigrationRouter,
private val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory
) : BaseViewModel(), Browserable {
val cloudBackupWarningMixin = cloudBackupChangingWarningMixinFactory.create(this)
override val openBrowserEvent = MutableLiveData<Event<String>>()
private val exchangeSecretsMixin = accountMigrationMixinProvider.createAndBindWithScope(this)
init {
handleEvents()
}
fun acceptMigration() {
cloudBackupWarningMixin.launchChangingConfirmationIfNeeded {
exchangeSecretsMixin.acceptKeyExchange()
}
}
private fun handleEvents() {
exchangeSecretsMixin.exchangeEvents
.onEach { handleExchangeSecretsEvent(it) }
.launchIn(this)
}
private suspend fun handleExchangeSecretsEvent(event: ExchangeSecretsMixin.ExternalEvent<AccountExchangePayload>) {
when (event) {
is ExchangeSecretsMixin.ExternalEvent.SendPublicKey -> {
val migrationAcceptedUrl = resourceManager.getString(R.string.account_migration_accepted_url, payload.scheme, event.publicKey.toHexString())
openBrowserEvent.value = Event(migrationAcceptedUrl)
}
is ExchangeSecretsMixin.ExternalEvent.PeerSecretsReceived -> {
val name = event.exchangePayload.accountName ?: fallbackAccountName()
accountMigrationInteractor.addAccount(name, event.decryptedSecret)
finishFlow()
}
}
}
private fun fallbackAccountName(): String {
return resourceManager.getString(R.string.account_migration_fallback_name, payload.scheme.capitalize())
}
private suspend fun finishFlow() {
if (accountMigrationInteractor.isPinCodeSet()) {
router.finishMigrationFlow()
} else {
router.openPinCodeSet()
}
}
fun close() {
router.back()
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_account_migration.presentation.pairing.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingFragment
import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingPayload
@Subcomponent(
modules = [
AccountMigrationPairingModule::class
]
)
@ScreenScope
interface AccountMigrationPairingComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: AccountMigrationPairingPayload
): AccountMigrationPairingComponent
}
fun inject(fragment: AccountMigrationPairingFragment)
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_account_migration.presentation.pairing.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor
import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter
import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingPayload
import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingViewModel
import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
@Module(includes = [ViewModelModule::class])
class AccountMigrationPairingModule {
@Provides
@IntoMap
@ViewModelKey(AccountMigrationPairingViewModel::class)
fun provideViewModel(
resourceManager: ResourceManager,
accountMigrationMixinProvider: AccountMigrationMixinProvider,
accountMigrationInteractor: AccountMigrationInteractor,
payload: AccountMigrationPairingPayload,
router: AccountMigrationRouter,
cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory
): ViewModel {
return AccountMigrationPairingViewModel(
resourceManager,
accountMigrationMixinProvider,
accountMigrationInteractor,
payload,
router,
cloudBackupChangingWarningMixinFactory
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory,
): AccountMigrationPairingViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(AccountMigrationPairingViewModel::class.java)
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_account_migration.utils
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
class AccountExchangePayload(
val accountName: String?
) : ExchangePayload
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_account_migration.utils
import io.novafoundation.nova.common.utils.invokeOnCompletion
import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin
import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils
import io.novafoundation.nova.feature_account_migration.utils.common.RealExchangeSecretsMixin
import kotlinx.coroutines.CoroutineScope
class AccountMigrationMixinProvider(
private val keyExchangeUtils: KeyExchangeUtils
) {
private var exchangeSecretsMixin: ExchangeSecretsMixin<AccountExchangePayload>? = null
fun getMixin(): ExchangeSecretsMixin<AccountExchangePayload>? = exchangeSecretsMixin
fun createAndBindWithScope(coroutineScope: CoroutineScope): ExchangeSecretsMixin<AccountExchangePayload> {
return RealExchangeSecretsMixin<AccountExchangePayload>(
keyExchangeUtils,
secretProvider = { throw InterruptedException("No supported secret exchange") },
exchangePayloadProvider = { throw InterruptedException("No supported secret exchange") },
coroutineScope
).apply {
exchangeSecretsMixin = this
coroutineScope.invokeOnCompletion {
exchangeSecretsMixin = null
}
}
}
}
@@ -0,0 +1,123 @@
package io.novafoundation.nova.feature_account_migration.utils.common
import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin.ExternalEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeStateMachine
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect.Receiver
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect.Sender
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
interface ExchangeSecretsMixin<T : ExchangePayload> {
interface ExternalEvent<in T : ExchangePayload> {
object RequestExchangeKeys : ExternalEvent<ExchangePayload>
class SendPublicKey(val publicKey: ByteArray) : ExternalEvent<ExchangePayload>
class SendEncryptedSecret<T : ExchangePayload>(val exchangePayload: T, val encryptedSecret: ByteArray, val publicKey: ByteArray) : ExternalEvent<T>
class PeerSecretsReceived<T : ExchangePayload>(val exchangePayload: T, val decryptedSecret: ByteArray) : ExternalEvent<T>
}
fun interface SecretProvider {
suspend fun getSecret(): ByteArray
}
fun interface ExchangePayloadProvider<T : ExchangePayload> {
suspend fun getExchangePayload(): T
}
val exchangeEvents: SharedFlow<ExternalEvent<T>>
fun startSharingSecrets()
fun acceptKeyExchange()
fun onPeerAcceptedKeyExchange(publicKey: ByteArray)
fun onPeerSecretsReceived(secret: ByteArray, publicKey: ByteArray, exchangePayload: T)
}
class RealExchangeSecretsMixin<T : ExchangePayload>(
private val keyExchangeUtils: KeyExchangeUtils,
private val secretProvider: ExchangeSecretsMixin.SecretProvider,
private val exchangePayloadProvider: ExchangeSecretsMixin.ExchangePayloadProvider<T>,
private val coroutineScope: CoroutineScope
) : ExchangeSecretsMixin<T>, CoroutineScope by coroutineScope {
private val stateMachine: KeyExchangeStateMachine<T> = KeyExchangeStateMachine(coroutineScope)
override val exchangeEvents = MutableSharedFlow<ExternalEvent<T>>()
init {
launch {
for (sideEffect in stateMachine.sideEffects) {
handleSideEffect(sideEffect)
}
}
}
override fun startSharingSecrets() {
stateMachine.onEvent(KeyExchangeEvent.Sender.InitKeyExchange)
}
override fun acceptKeyExchange() {
val keyPair = keyExchangeUtils.generateEphemeralKeyPair()
stateMachine.onEvent(KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest(keyPair))
}
override fun onPeerAcceptedKeyExchange(publicKey: ByteArray) {
val peerPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(publicKey)
stateMachine.onEvent(KeyExchangeEvent.Sender.PeerAcceptedKeyExchange(peerPublicKey))
}
override fun onPeerSecretsReceived(secret: ByteArray, publicKey: ByteArray, exchangePayload: T) {
val peerPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(publicKey)
stateMachine.onEvent(KeyExchangeEvent.Receiver.PeerSecretsReceived(secret, peerPublicKey, exchangePayload))
}
private fun handleSideEffect(sideEffect: KeyExchangeSideEffect<T>) {
when (sideEffect) {
is Sender -> handleSenderSideEffect(sideEffect)
is Receiver -> handleReceiverSideEffect(sideEffect)
}
}
private fun handleSenderSideEffect(sideEffect: Sender) = launch {
when (sideEffect) {
Sender.RequestPeerAcceptKeyExchange -> exchangeEvents.emit(ExternalEvent.RequestExchangeKeys)
is Sender.SendEncryptedSecrets -> {
val secret = secretProvider.getSecret()
val exchangePayload = exchangePayloadProvider.getExchangePayload()
val keyPair = keyExchangeUtils.generateEphemeralKeyPair()
val encryptedSecret = keyExchangeUtils.encrypt(secret, keyPair, sideEffect.peerPublicKey)
exchangeEvents.emit(
ExternalEvent.SendEncryptedSecret(
exchangePayload,
encryptedSecret,
keyPair.public.bytes()
)
)
}
}
}
private fun handleReceiverSideEffect(sideEffect: Receiver<T>) = launch {
when (sideEffect) {
is Receiver.AcceptKeyExchange -> exchangeEvents.emit(ExternalEvent.SendPublicKey(sideEffect.publicKey.bytes()))
is Receiver.PeerSecretsReceived -> {
val decryptedEntropy = keyExchangeUtils.decrypt(sideEffect.encryptedSecret, sideEffect.keyPair, sideEffect.peerPublicKey)
exchangeEvents.emit(ExternalEvent.PeerSecretsReceived(sideEffect.exchangePayload, decryptedEntropy))
}
}
}
}
@@ -0,0 +1,97 @@
package io.novafoundation.nova.feature_account_migration.utils.common
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.PublicKey
import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
private const val AES_KEY_SIZE = 32 // 256 bits
private const val AES_GCM_IV_LENGTH = 12
private const val AES_GCM_TAG_LENGTH = 128
class KeyExchangeUtils {
// EC Curve specification
private val ecSpec = ECGenParameterSpec("secp256r1")
fun generateEphemeralKeyPair(): KeyPair {
val keyPair = KeyPairGenerator.getInstance("EC").run {
initialize(ecSpec, SecureRandom())
generateKeyPair()
}
return keyPair
}
fun encrypt(encryptionData: ByteArray, keypair: KeyPair, publicKey: PublicKey): ByteArray {
val sharedSecret = getSharedSecret(keypair, publicKey)
val keySpec = deriveAESKeyFromSharedSecret(sharedSecret)
val iv = ByteArray(AES_GCM_IV_LENGTH)
SecureRandom().nextBytes(iv)
val cipher = getCypher()
val gcmSpec = GCMParameterSpec(AES_GCM_TAG_LENGTH, iv)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
val ciphertext = cipher.doFinal(encryptionData)
return iv + ciphertext
}
fun decrypt(encryptedData: ByteArray, keypair: KeyPair, publicKey: PublicKey): ByteArray {
val sharedSecret = getSharedSecret(keypair, publicKey)
val keySpec = deriveAESKeyFromSharedSecret(sharedSecret)
val iv = encryptedData.copyOfRange(0, AES_GCM_IV_LENGTH)
val ciphertext = encryptedData.copyOfRange(AES_GCM_IV_LENGTH, encryptedData.size)
val cipher = getCypher()
val gcmSpec = GCMParameterSpec(AES_GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
return cipher.doFinal(ciphertext)
}
fun mapPublicKeyFromBytes(publicKeyBytes: ByteArray): PublicKey {
val keyFactory = KeyFactory.getInstance("EC")
val keySpec = X509EncodedKeySpec(publicKeyBytes)
return keyFactory.generatePublic(keySpec)
}
private fun getSharedSecret(keypair: KeyPair, peerPublicKey: PublicKey): ByteArray {
val keyAgreement = KeyAgreement.getInstance("ECDH")
keyAgreement.init(keypair.private)
keyAgreement.doPhase(peerPublicKey, true)
return keyAgreement.generateSecret()
}
private fun deriveAESKeyFromSharedSecret(sharedSecret: ByteArray): SecretKeySpec {
val mac = Mac.getInstance("HmacSHA256")
val salt = "ephemeral-salt".toByteArray()
val keySpec = SecretKeySpec(salt, "HmacSHA256")
mac.init(keySpec)
val prk = mac.doFinal(sharedSecret)
mac.init(SecretKeySpec(prk, "HmacSHA256"))
val info = ByteArray(0) // We can set purpose of using this key and make it different for same shared secret depends on info
val t1 = mac.doFinal(info + 0x01.toByte())
val aesKey = t1.copyOf(AES_KEY_SIZE)
return SecretKeySpec(aesKey, "AES")
}
private fun getCypher() = Cipher.getInstance("AES/GCM/NoPadding")
}
fun PublicKey.bytes(): ByteArray {
return this.encoded
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine
interface ExchangePayload
object NoExchangePayload : ExchangePayload
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine
import io.novafoundation.nova.common.utils.stateMachine.StateMachine
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.states.InitialKeyExchangeState
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.states.KeyExchangeState
import kotlinx.coroutines.CoroutineScope
typealias KeyExchangeTransition<T> = StateMachine.Transition<KeyExchangeState<T>, KeyExchangeSideEffect<T>>
typealias KeyExchangeStateMachine<T> = StateMachine<KeyExchangeState<T>, KeyExchangeSideEffect<T>, KeyExchangeEvent<T>>
fun <T : ExchangePayload> KeyExchangeStateMachine(
coroutineScope: CoroutineScope
): StateMachine<KeyExchangeState<T>, KeyExchangeSideEffect<T>, KeyExchangeEvent<T>> {
return StateMachine(initialState = InitialKeyExchangeState(), coroutineScope)
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.events
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import java.security.KeyPair
import java.security.PublicKey
interface KeyExchangeEvent<in T : ExchangePayload> {
interface Sender : KeyExchangeEvent<ExchangePayload> {
data object InitKeyExchange : Sender
data class PeerAcceptedKeyExchange(val peerPublicKey: PublicKey) : Sender
}
interface Receiver<T : ExchangePayload> : KeyExchangeEvent<T> {
data class AcceptKeyExchangeRequest(val keyPair: KeyPair) : Receiver<ExchangePayload>
class PeerSecretsReceived<T : ExchangePayload>(
val encryptedSecrets: ByteArray,
val peerPublicKey: PublicKey,
val exchangePayload: T
) : Receiver<T>
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import java.security.KeyPair
import java.security.PublicKey
sealed interface KeyExchangeSideEffect<in T : ExchangePayload> {
sealed interface Sender : KeyExchangeSideEffect<ExchangePayload> {
data object RequestPeerAcceptKeyExchange : Sender
class SendEncryptedSecrets(val peerPublicKey: PublicKey) : Sender
}
sealed interface Receiver<T : ExchangePayload> : KeyExchangeSideEffect<T> {
class AcceptKeyExchange(val publicKey: PublicKey) : Receiver<ExchangePayload>
class PeerSecretsReceived<T : ExchangePayload>(
val exchangePayload: T,
val encryptedSecret: ByteArray,
val keyPair: KeyPair,
val peerPublicKey: PublicKey
) : Receiver<T>
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
class AwaitPeerAcceptKeyExchangeState<T : ExchangePayload> : KeyExchangeState<T> {
context(KeyExchangeTransition<T>)
override suspend fun performTransition(event: KeyExchangeEvent<T>) {
when (event) {
is KeyExchangeEvent.Sender.InitKeyExchange -> {
emitSideEffect(KeyExchangeSideEffect.Sender.RequestPeerAcceptKeyExchange)
}
is KeyExchangeEvent.Sender.PeerAcceptedKeyExchange -> {
emitSideEffect(KeyExchangeSideEffect.Sender.SendEncryptedSecrets(event.peerPublicKey))
emitState(InitialKeyExchangeState())
}
}
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
import java.security.KeyPair
class AwaitPeerSecretsKeyExchangeState<T : ExchangePayload>(
private val keyPair: KeyPair
) : KeyExchangeState<T> {
context(KeyExchangeTransition<T>)
override suspend fun performTransition(event: KeyExchangeEvent<T>) {
when (event) {
is KeyExchangeEvent.Receiver.PeerSecretsReceived -> {
emitSideEffect(KeyExchangeSideEffect.Receiver.PeerSecretsReceived(event.exchangePayload, event.encryptedSecrets, keyPair, event.peerPublicKey))
emitState(InitialKeyExchangeState())
}
is KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest -> {
emitSideEffect(KeyExchangeSideEffect.Receiver.AcceptKeyExchange(event.keyPair.public))
emitState(AwaitPeerSecretsKeyExchangeState(event.keyPair))
}
}
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
class InitialKeyExchangeState<T : ExchangePayload> : KeyExchangeState<T> {
context(KeyExchangeTransition<T>)
override suspend fun performTransition(event: KeyExchangeEvent<T>) {
when (event) {
is KeyExchangeEvent.Sender.InitKeyExchange -> {
emitSideEffect(KeyExchangeSideEffect.Sender.RequestPeerAcceptKeyExchange)
emitState(AwaitPeerAcceptKeyExchangeState())
}
is KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest -> {
emitSideEffect(KeyExchangeSideEffect.Receiver.AcceptKeyExchange(event.keyPair.public))
emitState(AwaitPeerSecretsKeyExchangeState(event.keyPair))
}
}
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states
import io.novafoundation.nova.common.utils.stateMachine.StateMachine
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent
import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect
interface KeyExchangeState<T : ExchangePayload> : StateMachine.State<KeyExchangeState<T>, KeyExchangeSideEffect<T>, KeyExchangeEvent<T>>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/account_migration_background">
<FrameLayout
android:id="@+id/accountMigrationSkipContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/accountMigrationSkip"
style="@style/TextAppearance.NovaFoundation.SemiBold.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginVertical="5dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:includeFontPadding="false"
android:minHeight="32dp"
android:paddingHorizontal="12dp"
android:text="@string/common_skip"
android:textColor="@color/button_text_accent" />
</FrameLayout>
<ImageView
android:id="@+id/accountMigrationImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:src="@drawable/ic_migration_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/accountMigrationSkipContainer" />
<TextView
android:id="@+id/accountMigrationTitle"
style="@style/TextAppearance.NovaFoundation.Bold.LargeTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/account_migration_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/accountMigrationImage" />
<TextView
android:id="@+id/accountMigrationSubtitle"
style="@style/TextAppearance.NovaFoundation.Regular.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingHorizontal="16dp"
android:text="@string/account_migration_subtitle"
android:textColor="@color/text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/accountMigrationTitle" />
<FrameLayout
android:id="@+id/accountMigrationPairContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent">
<io.novafoundation.nova.common.view.PrimaryButtonV2
android:id="@+id/accountMigrationPair"
style="@style/Widget.Nova.MaterialButton.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="16dp"
android:text="@string/common_continue" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,85 @@
package io.novafoundation.nova.feature_account_migration
import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils
import io.novafoundation.nova.feature_account_migration.utils.common.bytes
import javax.crypto.AEADBadTagException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.experimental.xor
class ExchangeSecretsTest {
private val keyExchangeUtils = KeyExchangeUtils()
@Test
fun checkExchangingSecretsFlow() {
val encodingData = "PolkadotApp"
val peerA = keyExchangeUtils.generateEphemeralKeyPair()
val peerB = keyExchangeUtils.generateEphemeralKeyPair()
val encoded = keyExchangeUtils.encrypt( encodingData.toByteArray(), peerA, peerB.public)
val decoded = keyExchangeUtils.decrypt(encoded, peerB, peerA.public)
val decodedString = decoded.decodeToString()
assertEquals(encodingData, decodedString)
}
@Test(expected = AEADBadTagException::class)
fun checkImposterSabotageFailed() {
val encodingData = "PolkadotApp"
val peerA = keyExchangeUtils.generateEphemeralKeyPair()
val peerB = keyExchangeUtils.generateEphemeralKeyPair()
val imposter = keyExchangeUtils.generateEphemeralKeyPair()
val encoded = keyExchangeUtils.encrypt(encodingData.toByteArray(), peerA, peerB.public)
val imposterDecoded = keyExchangeUtils.decrypt(encoded, imposter, peerA.public)
imposterDecoded.decodeToString()
}
@Test
fun checkSymmetricSecretsBothWays() {
val msgFromA = "Message from A".toByteArray()
val msgFromB = "Reply from B".toByteArray()
val peerA = keyExchangeUtils.generateEphemeralKeyPair()
val peerB = keyExchangeUtils.generateEphemeralKeyPair()
val aToB = keyExchangeUtils.encrypt(msgFromA, peerA, peerB.public)
val decodedByB = keyExchangeUtils.decrypt(aToB, peerB, peerA.public)
assertEquals("Message from A", decodedByB.decodeToString())
val bToA = keyExchangeUtils.encrypt(msgFromB, peerB, peerA.public)
val decodedByA = keyExchangeUtils.decrypt(bToA, peerA, peerB.public)
assertEquals("Reply from B", decodedByA.decodeToString())
}
@Test(expected = AEADBadTagException::class)
fun checkTamperedDataFails() {
val peerA = keyExchangeUtils.generateEphemeralKeyPair()
val peerB = keyExchangeUtils.generateEphemeralKeyPair()
val encoded = keyExchangeUtils.encrypt("Hello".toByteArray(), peerA, peerB.public)
// Change one byte
encoded[encoded.lastIndex - 1] = (encoded.last() xor 0x01)
keyExchangeUtils.decrypt(encoded, peerB, peerA.public)
}
@Test
fun checkPublicKeyMapping() {
val keyPair = keyExchangeUtils.generateEphemeralKeyPair()
val bytes = keyPair.public.bytes()
val mappedPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(bytes)
assertTrue(mappedPublicKey.bytes().contentEquals(keyPair.public.bytes()))
}
}