diff --git a/changelog.d/6133.feature b/changelog.d/6133.feature new file mode 100644 index 0000000000..a2624cd56d --- /dev/null +++ b/changelog.d/6133.feature @@ -0,0 +1 @@ +Added support for mandatory backup or passphrase from .well-known configuration. diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapCrossSigningTask.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapCrossSigningTask.kt index 6926c0b30b..c7c367f5ec 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapCrossSigningTask.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapCrossSigningTask.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt index 3be2f020b8..3d078a82ed 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt @@ -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 + } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt index b67970e61f..15ea90ae0a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt @@ -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(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 diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapStep.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapStep.kt index a4fa31ad03..3fb20ccf9f 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapStep.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapStep.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapViewState.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapViewState.kt index 9d5760cbf9..2d27c165e6 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapViewState.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapViewState.kt @@ -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 = Uninitialized, val recoveryKeyCreationInfo: SsssKeyCreationInfo? = null, val initializationWaitingViewData: WaitingViewData? = null, - val recoverySaveFileProcess: Async = Uninitialized + val recoverySaveFileProcess: Async = 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) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationAction.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationAction.kt index a5142ad8bf..c4ae2d278b 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationAction.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationAction.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheetViewModel.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheetViewModel.kt index b65ce4b30b..a7fd166076 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheetViewModel.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt index 7f6678a73c..9203b8ab0a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt @@ -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) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionFragment.kt index f45bc3d44e..85b90e6004 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionFragment.kt @@ -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)) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt index 781677433b..3c7b4ffebd 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt @@ -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 = diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index fb37883f9c..4ddd082f49 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -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 -> { diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt index 5efd49a579..cb31a568e4 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index c47ca0880e..9fe8a1f60e 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -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 by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory 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> { 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 { - session.cryptoService().crossSigningService().initializeCrossSigning( - object : UserInteractiveAuthInterceptor { - override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { - // 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.(response: RegistrationFlowResponse, errCode: String?) -> Unit +) { + awaitCallback { + initializeCrossSigning( + object : UserInteractiveAuthInterceptor { + override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { + promise.block(flowResponse, errCode) + } + }, + callback = it + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt index 68131e8569..45fe04fc61 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt index c451c35f20..3c4d514e47 100644 --- a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt +++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt @@ -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? = null ) @JsonClass(generateAdapter = true) diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt index 91269cb114..fce91b8f15 100644 --- a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt +++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnownExt.kt @@ -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 + } +} diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/SecureBackupMethod.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/SecureBackupMethod.kt new file mode 100644 index 0000000000..c65ad8b8bc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/SecureBackupMethod.kt @@ -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 +} diff --git a/vector/src/main/res/layout/bottom_sheet_bootstrap.xml b/vector/src/main/res/layout/bottom_sheet_bootstrap.xml index c3fa9d2931..3818e50566 100644 --- a/vector/src/main/res/layout/bottom_sheet_bootstrap.xml +++ b/vector/src/main/res/layout/bottom_sheet_bootstrap.xml @@ -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" />