Add support for mandatory backup or passphrase from .well-known configuration

This commit is contained in:
Jorge Martín 2022-05-23 16:51:42 +02:00
parent 483b1ab503
commit 130ed63b03
19 changed files with 435 additions and 142 deletions

1
changelog.d/6133.feature Normal file
View File

@ -0,0 +1 @@
Added support for mandatory backup or passphrase from .well-known configuration.

View File

@ -67,6 +67,7 @@ data class Params(
val progressListener: BootstrapProgressListener? = null,
val passphrase: String?,
val keySpec: SsssKeySpec? = null,
val forceResetIfSomeSecretsAreMissing: Boolean = false,
val setupMode: SetupMode
)
@ -83,6 +84,7 @@ class BootstrapCrossSigningTask @Inject constructor(
// Ensure cross-signing is initialized. Due to migration it is maybe not always correctly initialized
val shouldSetCrossSigning = !crossSigningService.isCrossSigningInitialized() ||
(params.forceResetIfSomeSecretsAreMissing && !crossSigningService.allPrivateKeysKnown()) ||
(params.setupMode == SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET && !crossSigningService.allPrivateKeysKnown()) ||
(params.setupMode == SetupMode.HARD_RESET)
if (shouldSetCrossSigning) {

View File

@ -26,6 +26,7 @@ import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentBootstrapSetupRecoveryBinding
import im.vector.app.features.raw.wellknown.SecureBackupMethod
import javax.inject.Inject
class BootstrapSetupRecoveryKeyFragment @Inject constructor() :
@ -55,27 +56,40 @@ class BootstrapSetupRecoveryKeyFragment @Inject constructor() :
}
override fun invalidate() = withState(sharedViewModel) { state ->
if (state.step is BootstrapStep.FirstForm) {
if (state.step.keyBackUpExist) {
// Display the set up action
views.bootstrapSetupSecureSubmit.isVisible = true
views.bootstrapSetupSecureUseSecurityKey.isVisible = false
views.bootstrapSetupSecureUseSecurityPassphrase.isVisible = false
views.bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = false
} else {
if (state.step.reset) {
views.bootstrapSetupSecureText.text = getString(R.string.reset_secure_backup_title)
views.bootstrapSetupWarningTextView.isVisible = true
} else {
views.bootstrapSetupSecureText.text = getString(R.string.bottom_sheet_setup_secure_backup_subtitle)
views.bootstrapSetupWarningTextView.isVisible = false
}
// Choose between create a passphrase or use a recovery key
views.bootstrapSetupSecureSubmit.isVisible = false
views.bootstrapSetupSecureUseSecurityKey.isVisible = true
views.bootstrapSetupSecureUseSecurityPassphrase.isVisible = true
views.bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = true
}
val firstFormStep = state.step as? BootstrapStep.FirstForm ?: return@withState
if (firstFormStep.keyBackUpExist) {
renderStateWithExistingKeyBackup()
} else {
renderSetupHeader(needsReset = firstFormStep.reset)
views.bootstrapSetupSecureSubmit.isVisible = false
// Choose between create a passphrase or use a recovery key
renderBackupMethodActions(firstFormStep.methods)
}
}
private fun renderStateWithExistingKeyBackup() = with(views) {
// Display the set up action
bootstrapSetupSecureSubmit.isVisible = true
// Disable creating backup / passphrase options
bootstrapSetupSecureUseSecurityKey.isVisible = false
bootstrapSetupSecureUseSecurityPassphrase.isVisible = false
bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = false
}
private fun renderSetupHeader(needsReset: Boolean) = with(views) {
bootstrapSetupSecureText.text = if (needsReset) {
getString(R.string.reset_secure_backup_title)
} else {
getString(R.string.bottom_sheet_setup_secure_backup_subtitle)
}
bootstrapSetupWarningTextView.isVisible = needsReset
}
private fun renderBackupMethodActions(method: SecureBackupMethod) = with(views) {
bootstrapSetupSecureUseSecurityKey.isVisible = method.isKeyAvailable
bootstrapSetupSecureUseSecurityPassphrase.isVisible = method.isPassphraseAvailable
bootstrapSetupSecureUseSecurityPassphraseSeparator.isVisible = method.isPassphraseAvailable
}
}

View File

@ -33,6 +33,10 @@ import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.platform.WaitingViewData
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.raw.wellknown.SecureBackupMethod
import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
import im.vector.app.features.raw.wellknown.secureBackupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.auth.UIABaseAuth
@ -41,7 +45,9 @@ import org.matrix.android.sdk.api.auth.UserPasswordAuth
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupLastVersionResult
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysVersionResult
@ -61,6 +67,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
private val stringProvider: StringProvider,
private val errorFormatter: ErrorFormatter,
private val session: Session,
private val rawService: RawService,
private val bootstrapTask: BootstrapCrossSigningTask,
private val migrationTask: BackupToQuadSMigrationTask,
) : VectorViewModel<BootstrapViewState, BootstrapActions, BootstrapViewEvents>(initialState) {
@ -83,12 +90,33 @@ class BootstrapSharedViewModel @AssistedInject constructor(
init {
setState {
copy(step = BootstrapStep.CheckingMigration, isRecoverySetup = session.sharedSecretStorageService().isRecoverySetup())
}
// Refresh the well-known configuration
viewModelScope.launch(Dispatchers.IO) {
val wellKnown = rawService.getElementWellknown(session.sessionParams)
setState {
copy(
isSecureBackupRequired = wellKnown?.isSecureBackupRequired().orFalse(),
secureBackupMethod = wellKnown?.secureBackupMethod() ?: SecureBackupMethod.KEY_OR_PASSPHRASE,
)
}
}
when (initialState.setupMode) {
SetupMode.PASSPHRASE_RESET,
SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET,
SetupMode.HARD_RESET -> {
setState {
copy(step = BootstrapStep.FirstForm(keyBackUpExist = false, reset = true))
copy(
step = BootstrapStep.FirstForm(
keyBackUpExist = false,
reset = session.sharedSecretStorageService().isRecoverySetup(),
methods = this.secureBackupMethod
)
)
}
}
SetupMode.CROSS_SIGNING_ONLY -> {
@ -112,7 +140,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
// we just resume plain bootstrap
doesKeyBackupExist = false
setState {
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist))
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod))
}
} else {
// we need to get existing backup passphrase/key and convert to SSSS
@ -126,7 +154,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
doesKeyBackupExist = true
isBackupCreatedFromPassphrase = keyVersion.getAuthDataAsMegolmBackupAuthData()?.privateKeySalt != null
setState {
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist))
copy(step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod))
}
}
}
@ -411,6 +439,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
progressListener = progressListener,
passphrase = state.passphrase,
keySpec = state.migrationRecoveryKey?.let { extractCurveKeyFromRecoveryKey(it)?.let { RawBytesKeySpec(it) } },
forceResetIfSomeSecretsAreMissing = state.isSecureBackupRequired,
setupMode = state.setupMode
)
) { bootstrapResult ->
@ -419,14 +448,22 @@ class BootstrapSharedViewModel @AssistedInject constructor(
_viewEvents.post(BootstrapViewEvents.Dismiss(true))
}
is BootstrapResult.Success -> {
setState {
copy(
recoveryKeyCreationInfo = bootstrapResult.keyInfo,
step = BootstrapStep.SaveRecoveryKey(
// If a passphrase was used, saving key is optional
state.passphrase != null
)
)
val isSecureBackupRequired = state.isSecureBackupRequired
val secureBackupMethod = state.secureBackupMethod
if (state.passphrase != null && isSecureBackupRequired && secureBackupMethod == SecureBackupMethod.PASSPHRASE) {
// Go straight to conclusion, skip the save key step
_viewEvents.post(BootstrapViewEvents.Dismiss(success = true))
} else {
setState {
copy(
recoveryKeyCreationInfo = bootstrapResult.keyInfo,
step = BootstrapStep.SaveRecoveryKey(
// If a passphrase was used, saving key is optional
state.passphrase != null
)
)
}
}
}
is BootstrapResult.InvalidPasswordError -> {
@ -476,7 +513,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
} else {
setState {
copy(
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
// Also reset the passphrase
passphrase = null,
passphraseRepeat = null,
@ -489,7 +526,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
is BootstrapStep.SetupPassphrase -> {
setState {
copy(
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
// Also reset the passphrase
passphrase = null,
passphraseRepeat = null
@ -504,11 +541,25 @@ class BootstrapSharedViewModel @AssistedInject constructor(
}
}
is BootstrapStep.AccountReAuth -> {
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
if (state.canLeave) {
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
} else {
// Go back to the first step
setState {
copy(
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
// Also reset the passphrase
passphrase = null,
passphraseRepeat = null
)
}
}
}
BootstrapStep.Initializing -> {
// do we let you cancel from here?
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
if (state.canLeave) {
_viewEvents.post(BootstrapViewEvents.SkipBootstrap(state.passphrase != null))
}
}
is BootstrapStep.SaveRecoveryKey,
BootstrapStep.DoneSuccess -> {
@ -516,18 +567,20 @@ class BootstrapSharedViewModel @AssistedInject constructor(
}
BootstrapStep.CheckingMigration -> Unit
is BootstrapStep.FirstForm -> {
_viewEvents.post(
when (state.setupMode) {
SetupMode.CROSS_SIGNING_ONLY,
SetupMode.NORMAL -> BootstrapViewEvents.SkipBootstrap()
else -> BootstrapViewEvents.Dismiss(success = false)
}
)
if (state.canLeave) {
_viewEvents.post(
when (state.setupMode) {
SetupMode.CROSS_SIGNING_ONLY,
SetupMode.NORMAL -> BootstrapViewEvents.SkipBootstrap()
else -> BootstrapViewEvents.Dismiss(success = false)
}
)
}
}
is BootstrapStep.GetBackupSecretForMigration -> {
setState {
copy(
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist),
step = BootstrapStep.FirstForm(keyBackUpExist = doesKeyBackupExist, methods = this.secureBackupMethod),
// Also reset the passphrase
passphrase = null,
passphraseRepeat = null,
@ -549,3 +602,5 @@ class BootstrapSharedViewModel @AssistedInject constructor(
}
}
}
private val BootstrapViewState.canLeave: Boolean get() = !isSecureBackupRequired || isRecoverySetup

View File

@ -16,6 +16,8 @@
package im.vector.app.features.crypto.recover
import im.vector.app.features.raw.wellknown.SecureBackupMethod
/**
* TODO The schema is not up to date
*
@ -89,7 +91,7 @@ sealed class BootstrapStep {
object CheckingMigration : BootstrapStep()
// Use will be asked to choose between passphrase or recovery key, or to start process if a key backup exists
data class FirstForm(val keyBackUpExist: Boolean, val reset: Boolean = false) : BootstrapStep()
data class FirstForm(val keyBackUpExist: Boolean, val reset: Boolean = false, val methods: SecureBackupMethod) : BootstrapStep()
object SetupPassphrase : BootstrapStep()
object ConfirmPassphrase : BootstrapStep()

View File

@ -21,6 +21,7 @@ import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import com.nulabinc.zxcvbn.Strength
import im.vector.app.core.platform.WaitingViewData
import im.vector.app.features.raw.wellknown.SecureBackupMethod
import org.matrix.android.sdk.api.session.securestorage.SsssKeyCreationInfo
data class BootstrapViewState(
@ -34,7 +35,10 @@ data class BootstrapViewState(
val passphraseConfirmMatch: Async<Unit> = Uninitialized,
val recoveryKeyCreationInfo: SsssKeyCreationInfo? = null,
val initializationWaitingViewData: WaitingViewData? = null,
val recoverySaveFileProcess: Async<Unit> = Uninitialized
val recoverySaveFileProcess: Async<Unit> = Uninitialized,
val isSecureBackupRequired: Boolean = false,
val secureBackupMethod: SecureBackupMethod = SecureBackupMethod.KEY_OR_PASSPHRASE,
val isRecoverySetup: Boolean = true
) : MavericksState {
constructor(args: BootstrapBottomSheet.Args) : this(setupMode = args.setUpMode)

View File

@ -27,7 +27,7 @@ sealed class VerificationAction : VectorViewModelAction {
object OtherUserDidNotScanned : VerificationAction()
data class SASMatchAction(val otherUserId: String, val sasTransactionId: String) : VerificationAction()
data class SASDoNotMatchAction(val otherUserId: String, val sasTransactionId: String) : VerificationAction()
object GotItConclusion : VerificationAction()
data class GotItConclusion(val verified: Boolean) : VerificationAction()
object SkipVerification : VerificationAction()
object VerifyFromPassphrase : VerificationAction()
data class GotResultFromSsss(val cypherData: String, val alias: String) : VerificationAction()

View File

@ -30,9 +30,13 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
@ -78,6 +82,7 @@ data class VerificationBottomSheetViewState(
val userWantsToCancel: Boolean = false,
val userThinkItsNotHim: Boolean = false,
val quadSContainsSecrets: Boolean = true,
val isVerificationRequired: Boolean = false,
val quadSHasBeenReset: Boolean = false,
val hasAnyOtherSession: Boolean = false
) : MavericksState {
@ -92,6 +97,7 @@ data class VerificationBottomSheetViewState(
class VerificationBottomSheetViewModel @AssistedInject constructor(
@Assisted initialState: VerificationBottomSheetViewState,
private val rawService: RawService,
private val session: Session,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
private val stringProvider: StringProvider) :
@ -108,6 +114,15 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
init {
session.cryptoService().verificationService().addListener(this)
// This is async, but at this point should be in cache
// so it's ok to not wait until result
viewModelScope.launch(Dispatchers.IO) {
val wellKnown = rawService.getElementWellknown(session.sessionParams)
setState {
copy(isVerificationRequired = wellKnown?.isSecureBackupRequired().orFalse())
}
}
val userItem = session.getUser(initialState.otherUserId)
var autoReady = false
@ -182,8 +197,10 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
state.verifyingFrom4S) {
// you cannot cancel anymore
} else {
setState {
copy(userWantsToCancel = true)
if (!state.isVerificationRequired) {
setState {
copy(userWantsToCancel = true)
}
}
}
}
@ -341,7 +358,18 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
?.shortCodeDoesNotMatch()
}
is VerificationAction.GotItConclusion -> {
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
if (state.isVerificationRequired && !action.verified) {
// we should go back to first screen
setState {
copy(
pendingRequest = Uninitialized,
sasTransactionState = null,
qrTransactionState = null
)
}
} else {
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
}
}
is VerificationAction.SkipVerification -> {
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)

View File

@ -84,7 +84,7 @@ class VerificationConclusionController @Inject constructor(
notice(host.eventHtmlRenderer.render(host.stringProvider.getString(R.string.verification_conclusion_compromised)).toEpoxyCharSequence())
}
bottomDone()
bottomGotIt()
}
ConclusionState.CANCELLED -> {
bottomSheetVerificationNoticeItem {
@ -92,18 +92,7 @@ class VerificationConclusionController @Inject constructor(
notice(host.stringProvider.getString(R.string.verify_cancelled_notice).toEpoxyCharSequence())
}
bottomSheetDividerItem {
id("sep0")
}
bottomSheetVerificationActionItem {
id("got_it")
title(host.stringProvider.getString(R.string.sas_got_it))
titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary))
iconRes(R.drawable.ic_arrow_right)
iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary))
listener { host.listener?.onButtonTapped() }
}
bottomGotIt()
}
}
}
@ -120,11 +109,27 @@ class VerificationConclusionController @Inject constructor(
titleColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
iconRes(R.drawable.ic_arrow_right)
iconColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
listener { host.listener?.onButtonTapped() }
listener { host.listener?.onButtonTapped(true) }
}
}
private fun bottomGotIt() {
val host = this
bottomSheetDividerItem {
id("sep0")
}
bottomSheetVerificationActionItem {
id("got_it")
title(host.stringProvider.getString(R.string.sas_got_it))
titleColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
iconRes(R.drawable.ic_arrow_right)
iconColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
listener { host.listener?.onButtonTapped(false) }
}
}
interface Listener {
fun onButtonTapped()
fun onButtonTapped(success: Boolean)
}
}

View File

@ -73,7 +73,7 @@ class VerificationConclusionFragment @Inject constructor(
controller.update(state)
}
override fun onButtonTapped() {
sharedViewModel.handle(VerificationAction.GotItConclusion)
override fun onButtonTapped(success: Boolean) {
sharedViewModel.handle(VerificationAction.GotItConclusion(success))
}
}

View File

@ -88,17 +88,19 @@ class VerificationRequestController @Inject constructor(
}
}
bottomSheetDividerItem {
id("sep1")
}
if (!state.isVerificationRequired) {
bottomSheetDividerItem {
id("sep1")
}
bottomSheetVerificationActionItem {
id("skip")
title(host.stringProvider.getString(R.string.action_skip))
titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorError))
iconRes(R.drawable.ic_arrow_right)
iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorError))
listener { host.listener?.onClickSkip() }
bottomSheetVerificationActionItem {
id("skip")
title(host.stringProvider.getString(R.string.action_skip))
titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorError))
iconRes(R.drawable.ic_arrow_right)
iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorError))
listener { host.listener?.onClickSkip() }
}
}
} else {
val styledText =

View File

@ -50,6 +50,7 @@ import im.vector.app.features.MainActivityArgs
import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.ViewRoom
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.disclaimer.showDisclaimerDialog
import im.vector.app.features.matrixto.MatrixToBottomSheet
import im.vector.app.features.matrixto.OriginOfMatrixTo
@ -226,6 +227,14 @@ class HomeActivity :
is HomeActivityViewEvents.AskPasswordToInitCrossSigning -> handleAskPasswordToInitCrossSigning(it)
is HomeActivityViewEvents.OnNewSession -> handleOnNewSession(it)
HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush()
HomeActivityViewEvents.StartRecoverySetupFlow -> handleStartRecoverySetup()
is HomeActivityViewEvents.ForceVerification -> {
if (it.sendRequest) {
navigator.requestSelfSessionVerification(this)
} else {
navigator.waitSessionVerification(this)
}
}
is HomeActivityViewEvents.OnCrossSignedInvalidated -> handleCrossSigningInvalidated(it)
HomeActivityViewEvents.ShowAnalyticsOptIn -> handleShowAnalyticsOptIn()
HomeActivityViewEvents.NotifyUserForThreadsMigration -> handleNotifyUserForThreadsMigration()
@ -355,6 +364,13 @@ class HomeActivity :
}
}
private fun handleStartRecoverySetup() {
// To avoid IllegalStateException in case the transaction was executed after onSaveInstanceState
lifecycleScope.launchWhenResumed {
navigator.open4SSetup(this@HomeActivity, SetupMode.NORMAL)
}
}
private fun renderState(state: HomeActivityViewState) {
when (val status = state.syncStatusServiceStatus) {
is SyncStatusService.Status.InitialSyncProgressing -> {

View File

@ -27,4 +27,6 @@ sealed interface HomeActivityViewEvents : VectorViewEvents {
object ShowAnalyticsOptIn : HomeActivityViewEvents
object NotifyUserForThreadsMigration : HomeActivityViewEvents
data class MigrateThreads(val checkSession: Boolean) : HomeActivityViewEvents
object StartRecoverySetupFlow : HomeActivityViewEvents
data class ForceVerification(val sendRequest: Boolean) : HomeActivityViewEvents
}

View File

@ -17,7 +17,9 @@
package im.vector.app.features.home
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -28,6 +30,9 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.analytics.store.AnalyticsStore
import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.raw.wellknown.ElementWellKnown
import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.Dispatchers
@ -42,6 +47,8 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
import org.matrix.android.sdk.api.session.getUser
@ -59,8 +66,9 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class HomeActivityViewModel @AssistedInject constructor(
@Assisted initialState: HomeActivityViewState,
@Assisted private val initialState: HomeActivityViewState,
private val activeSessionHolder: ActiveSessionHolder,
private val rawService: RawService,
private val reAuthHelper: ReAuthHelper,
private val analyticsStore: AnalyticsStore,
private val lightweightSettingsStorage: LightweightSettingsStorage,
@ -72,10 +80,17 @@ class HomeActivityViewModel @AssistedInject constructor(
override fun create(initialState: HomeActivityViewState): HomeActivityViewModel
}
companion object : MavericksViewModelFactory<HomeActivityViewModel, HomeActivityViewState> by hiltMavericksViewModelFactory()
companion object : MavericksViewModelFactory<HomeActivityViewModel, HomeActivityViewState> by hiltMavericksViewModelFactory() {
override fun initialState(viewModelContext: ViewModelContext): HomeActivityViewState? {
val activity: HomeActivity = viewModelContext.activity()
val args: HomeActivityArgs? = activity.intent.getParcelableExtra(Mavericks.KEY_ARG)
return args?.let { HomeActivityViewState(accountCreation = it.accountCreation) }
?: super.initialState(viewModelContext)
}
}
private var isInitialized = false
private var checkBootstrap = false
private var hasCheckedBootstrap = false
private var onceTrusted = false
private fun initialize() {
@ -116,17 +131,13 @@ class HomeActivityViewModel @AssistedInject constructor(
safeActiveSession
.flow()
.liveCrossSigningInfo(safeActiveSession.myUserId)
.onEach {
val isVerified = it.getOrNull()?.isTrusted() ?: false
.onEach { info ->
val isVerified = info.getOrNull()?.isTrusted() ?: false
if (!isVerified && onceTrusted) {
// cross signing keys have been reset
// Trigger a popup to re-verify
// Note: user can be null in case of logout
safeActiveSession.getUser(safeActiveSession.myUserId)
?.toMatrixItem()
?.let { user ->
_viewEvents.post(HomeActivityViewEvents.OnCrossSignedInvalidated(user))
}
viewModelScope.launch(Dispatchers.IO) {
val elementWellKnown = rawService.getElementWellknown(safeActiveSession.sessionParams)
sessionHasBeenUnverified(elementWellKnown)
}
}
onceTrusted = isVerified
}
@ -180,15 +191,8 @@ class HomeActivityViewModel @AssistedInject constructor(
.asFlow()
.onEach { status ->
when (status) {
is SyncStatusService.Status.InitialSyncProgressing -> {
// Schedule a check of the bootstrap when the init sync will be finished
checkBootstrap = true
}
is SyncStatusService.Status.Idle -> {
if (checkBootstrap) {
checkBootstrap = false
maybeBootstrapCrossSigningAfterInitialSync()
}
maybeVerifyOrBootstrapCrossSigning()
}
else -> Unit
}
@ -200,6 +204,10 @@ class HomeActivityViewModel @AssistedInject constructor(
}
}
.launchIn(viewModelScope)
if (session.hasAlreadySynced()) {
maybeVerifyOrBootstrapCrossSigning()
}
}
/**
@ -240,12 +248,72 @@ class HomeActivityViewModel @AssistedInject constructor(
}
}
private fun maybeBootstrapCrossSigningAfterInitialSync() {
private fun sessionHasBeenUnverified(elementWellKnown: ElementWellKnown?) {
val session = activeSessionHolder.getSafeActiveSession() ?: return
val isSecureBackupRequired = elementWellKnown?.isSecureBackupRequired() ?: false
if (isSecureBackupRequired) {
// If 4S is forced, force verification
// for stability cancel all pending verifications?
session.cryptoService().verificationService().getExistingVerificationRequests(session.myUserId).forEach {
session.cryptoService().verificationService().cancelVerificationRequest(it)
}
_viewEvents.post(HomeActivityViewEvents.ForceVerification(false))
} else {
// cross signing keys have been reset
// Trigger a popup to re-verify
// Note: user can be null in case of logout
session.getUser(session.myUserId)
?.toMatrixItem()
?.let { user ->
_viewEvents.post(HomeActivityViewEvents.OnCrossSignedInvalidated(user))
}
}
}
private fun maybeVerifyOrBootstrapCrossSigning() {
// The contents of this method should only run once
if (hasCheckedBootstrap) return
hasCheckedBootstrap = true
// We do not use the viewModel context because we do not want to tie this action to activity view model
activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch(Dispatchers.IO) {
val session = activeSessionHolder.getSafeActiveSession() ?: return@launch
val session = activeSessionHolder.getSafeActiveSession() ?: return@launch Unit.also {
Timber.w("## No session to init cross signing or bootstrap")
}
tryOrNull("## MaybeBootstrapCrossSigning: Failed to download keys") {
val elementWellKnown = rawService.getElementWellknown(session.sessionParams)
val isSecureBackupRequired = elementWellKnown?.isSecureBackupRequired() ?: false
// In case of account creation, it is already done before
if (initialState.accountCreation) {
if (isSecureBackupRequired) {
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
} else {
val password = reAuthHelper.data ?: return@launch Unit.also {
Timber.w("No password to init cross signing")
}
// Silently initialize cross signing without 4S
// We do not use the viewModel context because we do not want to cancel this action
Timber.d("Initialize cross signing")
try {
session.cryptoService().crossSigningService().awaitCrossSigninInitialization { response, _ ->
resume(
UserPasswordAuth(
session = response.session,
user = session.myUserId,
password = password
)
)
}
} catch (failure: Throwable) {
Timber.e(failure, "Failed to initialize cross signing")
}
}
return@launch
}
tryOrNull("## MaybeVerifyOrBootstrapCrossSigning: Failed to download keys") {
awaitCallback<MXUsersDevicesMap<CryptoDeviceInfo>> {
session.cryptoService().downloadKeys(listOf(session.myUserId), true, it)
}
@ -255,47 +323,68 @@ class HomeActivityViewModel @AssistedInject constructor(
// Is there already cross signing keys here?
val mxCrossSigningInfo = session.cryptoService().crossSigningService().getMyCrossSigningKeys()
if (mxCrossSigningInfo != null) {
// Cross-signing is already set up for this user, is it trusted?
if (!mxCrossSigningInfo.isTrusted()) {
// New session
_viewEvents.post(
HomeActivityViewEvents.OnNewSession(
session.getUser(session.myUserId)?.toMatrixItem(),
// Always send request instead of waiting for an incoming as per recent EW changes
false
if (isSecureBackupRequired && !session.sharedSecretStorageService().isRecoverySetup()) {
// If 4S is forced, start the full interactive setup flow
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
} else {
// Cross-signing is already set up for this user, is it trusted?
if (!mxCrossSigningInfo.isTrusted()) {
if (isSecureBackupRequired) {
// If 4S is forced, force verification
_viewEvents.post(HomeActivityViewEvents.ForceVerification(true))
} else {
// New session
_viewEvents.post(
HomeActivityViewEvents.OnNewSession(
session.getUser(session.myUserId)?.toMatrixItem(),
// Always send request instead of waiting for an incoming as per recent EW changes
false
)
)
)
}
}
}
} else {
// Try to initialize cross signing in background if possible
Timber.d("Initialize cross signing...")
try {
awaitCallback<Unit> {
session.cryptoService().crossSigningService().initializeCrossSigning(
object : UserInteractiveAuthInterceptor {
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
// We missed server grace period or it's not setup, see if we remember locally password
if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD &&
errCode == null &&
reAuthHelper.data != null) {
promise.resume(
UserPasswordAuth(
session = flowResponse.session,
user = session.myUserId,
password = reAuthHelper.data
)
// Cross signing is not initialized
if (isSecureBackupRequired) {
// If 4S is forced, start the full interactive setup flow
_viewEvents.post(HomeActivityViewEvents.StartRecoverySetupFlow)
} else {
// Initialize cross-signing silently
val password = reAuthHelper.data
if (password == null) {
// Check this is not an SSO account
if (session.homeServerCapabilitiesService().getHomeServerCapabilities().canChangePassword) {
// Ask password to the user: Upgrade security
_viewEvents.post(HomeActivityViewEvents.AskPasswordToInitCrossSigning(session.getUser(session.myUserId)?.toMatrixItem()))
}
// Else (SSO) just ignore for the moment
} else {
// Try to initialize cross signing in background if possible
Timber.d("Initialize cross signing...")
try {
session.cryptoService().crossSigningService().awaitCrossSigninInitialization { response, errCode ->
// We missed server grace period or it's not setup, see if we remember locally password
if (response.nextUncompletedStage() == LoginFlowTypes.PASSWORD &&
errCode == null &&
reAuthHelper.data != null) {
resume(
UserPasswordAuth(
session = response.session,
user = session.myUserId,
password = reAuthHelper.data
)
} else {
promise.resumeWithException(Exception("Cannot silently initialize cross signing, UIA missing"))
}
}
},
callback = it
)
Timber.d("Initialize cross signing SUCCESS")
)
Timber.d("Initialize cross signing SUCCESS")
} else {
resumeWithException(Exception("Cannot silently initialize cross signing, UIA missing"))
}
}
} catch (failure: Throwable) {
Timber.e(failure, "Failed to initialize cross signing")
}
}
} catch (failure: Throwable) {
Timber.e(failure, "Failed to initialize cross signing")
}
}
}
@ -312,3 +401,18 @@ class HomeActivityViewModel @AssistedInject constructor(
}
}
}
private suspend fun CrossSigningService.awaitCrossSigninInitialization(
block: Continuation<UIABaseAuth>.(response: RegistrationFlowResponse, errCode: String?) -> Unit
) {
awaitCallback<Unit> {
initializeCrossSigning(
object : UserInteractiveAuthInterceptor {
override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation<UIABaseAuth>) {
promise.block(flowResponse, errCode)
}
},
callback = it
)
}
}

View File

@ -20,5 +20,6 @@ import com.airbnb.mvrx.MavericksState
import org.matrix.android.sdk.api.session.initsync.SyncStatusService
data class HomeActivityViewState(
val syncStatusServiceStatus: SyncStatusService.Status = SyncStatusService.Status.Idle
val syncStatusServiceStatus: SyncStatusService.Status = SyncStatusService.Status.Idle,
val accountCreation: Boolean = false
) : MavericksState

View File

@ -53,7 +53,19 @@ data class E2EWellKnownConfig(
* (as it was before) for various environments where this is desired.
*/
@Json(name = "default")
val e2eDefault: Boolean? = null
val e2eDefault: Boolean? = null,
@Json(name = "secure_backup_required")
val secureBackupRequired: Boolean? = null,
/**
* The new field secure_backup_setup_methods is an array listing the methods the client should display.
* Supported values currently include key and passphrase.
* If the secure_backup_setup_methods field is not present or exists but does not contain any supported methods,
* clients should fallback to the default value of: ["key", "passphrase"].
*/
@Json(name = "secure_backup_setup_methods")
val secureBackupSetupMethods: List<String>? = null
)
@JsonClass(generateAdapter = true)

View File

@ -29,3 +29,22 @@ suspend fun RawService.getElementWellknown(sessionParams: SessionParams): Elemen
}
fun ElementWellKnown.isE2EByDefault() = elementE2E?.e2eDefault ?: riotE2E?.e2eDefault ?: true
fun ElementWellKnown.isSecureBackupRequired() = elementE2E?.secureBackupRequired
?: riotE2E?.secureBackupRequired
?: false
fun ElementWellKnown?.secureBackupMethod(): SecureBackupMethod {
val methodList = this?.elementE2E?.secureBackupSetupMethods
?: this?.riotE2E?.secureBackupSetupMethods
?: listOf("key", "passphrase")
return if (methodList.contains("key") && methodList.contains("passphrase")) {
SecureBackupMethod.KEY_OR_PASSPHRASE
} else if (methodList.contains("key")) {
SecureBackupMethod.KEY
} else if (methodList.contains("passphrase")) {
SecureBackupMethod.PASSPHRASE
} else {
SecureBackupMethod.KEY_OR_PASSPHRASE
}
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.raw.wellknown
enum class SecureBackupMethod {
KEY,
PASSPHRASE,
KEY_OR_PASSPHRASE;
val isKeyAvailable: Boolean get() = this == KEY || this == KEY_OR_PASSPHRASE
val isPassphraseAvailable: Boolean get() = this == PASSPHRASE || this == KEY_OR_PASSPHRASE
}

View File

@ -25,7 +25,8 @@
android:scaleType="fitCenter"
android:src="@drawable/ic_security_key_24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toTopOf="@id/bootstrapTitleText"
app:layout_constraintBottom_toBottomOf="@id/bootstrapTitleText"
app:tint="?vctr_content_primary"
tools:ignore="MissingPrefix" />
@ -39,10 +40,9 @@
android:ellipsize="end"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/bootstrapIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bootstrapIcon"
app:layout_constraintTop_toTopOf="@id/bootstrapIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/bottom_sheet_setup_secure_backup_title" />
<androidx.fragment.app.FragmentContainerView