mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-22 02:07:58 +00:00
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:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
@@ -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>
|
||||
+11
@@ -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
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+51
@@ -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))
|
||||
}
|
||||
}
|
||||
+5
@@ -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>)
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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()
|
||||
}
|
||||
+46
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
+82
@@ -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()
|
||||
}
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
+7
@@ -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
|
||||
+31
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package io.novafoundation.nova.feature_account_migration.utils.stateMachine
|
||||
|
||||
interface ExchangePayload
|
||||
|
||||
object NoExchangePayload : ExchangePayload
|
||||
+17
@@ -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)
|
||||
}
|
||||
+24
@@ -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>
|
||||
}
|
||||
}
|
||||
+27
@@ -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>
|
||||
}
|
||||
}
|
||||
+23
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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>
|
||||
+85
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user