New security alert to review old sessions

This commit is contained in:
Valere 2020-04-29 11:14:57 +02:00
parent 67f07bd1bb
commit a806f70b35
24 changed files with 423 additions and 211 deletions

View file

@ -31,6 +31,7 @@ import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.api.util.toOptional
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.session.sync.model.accountdata.UserAccountDataEvent
import io.reactivex.Observable
import io.reactivex.Single
@ -58,6 +59,13 @@ class RxSession(private val session: Session) {
}
}
fun liveMyDeviceInfo(): Observable<List<DeviceInfo>> {
return session.cryptoService().getLiveMyDeviceInfo().asObservable()
.startWithCallable {
session.cryptoService().getMyDeviceInfo()
}
}
fun liveSyncState(): Observable<SyncState> {
return session.getSyncStateLive().asObservable()
}

View file

@ -98,7 +98,9 @@ interface CryptoService {
fun removeRoomKeysRequestListener(listener: GossipingRequestListener)
fun getDevicesList(callback: MatrixCallback<DevicesListResponse>)
fun fetchDevicesList(callback: MatrixCallback<DevicesListResponse>)
fun getMyDeviceInfo() : List<DeviceInfo>
fun getLiveMyDeviceInfo() : LiveData<List<DeviceInfo>>
fun getDeviceInfo(deviceId: String, callback: MatrixCallback<DeviceInfo>)

View file

@ -251,15 +251,33 @@ internal class DefaultCryptoService @Inject constructor(
return myDeviceInfoHolder.get().myDevice
}
override fun getDevicesList(callback: MatrixCallback<DevicesListResponse>) {
override fun fetchDevicesList(callback: MatrixCallback<DevicesListResponse>) {
getDevicesTask
.configureWith {
// this.executionThread = TaskThread.CRYPTO
this.callback = callback
this.callback = object : MatrixCallback<DevicesListResponse> {
override fun onFailure(failure: Throwable) {
callback.onFailure(failure)
}
override fun onSuccess(data: DevicesListResponse) {
// Save in local DB
cryptoStore.saveMyDeviceInfos(data.devices ?: emptyList())
callback.onSuccess(data)
}
}
}
.executeBy(taskExecutor)
}
override fun getLiveMyDeviceInfo(): LiveData<List<DeviceInfo>> {
return cryptoStore.getLiveMyDeviceInfo()
}
override fun getMyDeviceInfo(): List<DeviceInfo> {
return cryptoStore.getMyDeviceInfo()
}
override fun getDeviceInfo(deviceId: String, callback: MatrixCallback<DeviceInfo>) {
getDeviceInfoTask
.configureWith(GetDeviceInfoTask.Params(deviceId)) {
@ -318,6 +336,8 @@ internal class DefaultCryptoService @Inject constructor(
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
internalStart(isInitialSync)
}
// Just update
fetchDevicesList(NoOpMatrixCallback())
}
private suspend fun internalStart(isInitialSync: Boolean) {

View file

@ -29,7 +29,8 @@ data class CryptoDeviceInfo(
override val signatures: Map<String, Map<String, String>>? = null,
val unsigned: JsonDict? = null,
var trustLevel: DeviceTrustLevel? = null,
var isBlocked: Boolean = false
var isBlocked: Boolean = false,
val firsTimeSeenLocalTs: Long? = null
) : CryptoInfo {
val isVerified: Boolean

View file

@ -61,20 +61,4 @@ internal object CryptoInfoMapper {
signatures = keyInfo.signatures
)
}
fun RestDeviceInfo.toCryptoModel(): CryptoDeviceInfo {
return map(this)
}
fun CryptoDeviceInfo.toRest(): RestDeviceInfo {
return map(this)
}
// fun RestKeyInfo.toCryptoModel(): CryptoCrossSigningKey {
// return map(this)
// }
fun CryptoCrossSigningKey.toRest(): RestKeyInfo {
return map(this)
}
}

View file

@ -32,6 +32,7 @@ import im.vector.matrix.android.internal.crypto.model.CryptoCrossSigningKey
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.OlmInboundGroupSessionWrapper
import im.vector.matrix.android.internal.crypto.model.OlmSessionWrapper
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody
import im.vector.matrix.android.internal.crypto.store.db.model.KeysBackupDataEntity
import org.matrix.olm.OlmAccount
@ -218,6 +219,9 @@ internal interface IMXCryptoStore {
// TODO temp
fun getLiveDeviceList(): LiveData<List<CryptoDeviceInfo>>
fun getMyDeviceInfo() : List<DeviceInfo>
fun getLiveMyDeviceInfo() : LiveData<List<DeviceInfo>>
fun saveMyDeviceInfos(info: List<DeviceInfo>)
/**
* Store the crypto algorithm for a room.
*

View file

@ -40,6 +40,7 @@ import im.vector.matrix.android.internal.crypto.model.CryptoCrossSigningKey
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.OlmInboundGroupSessionWrapper
import im.vector.matrix.android.internal.crypto.model.OlmSessionWrapper
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody
import im.vector.matrix.android.internal.crypto.model.toEntity
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
@ -59,6 +60,7 @@ import im.vector.matrix.android.internal.crypto.store.db.model.IncomingGossiping
import im.vector.matrix.android.internal.crypto.store.db.model.IncomingGossipingRequestEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.KeyInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.KeysBackupDataEntity
import im.vector.matrix.android.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.OlmInboundGroupSessionEntity
import im.vector.matrix.android.internal.crypto.store.db.model.OlmInboundGroupSessionEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.OlmSessionEntity
@ -287,10 +289,16 @@ internal class RealmCryptoStore @Inject constructor(
UserEntity.getOrCreate(realm, userId)
.let { u ->
// Add the devices
val currentKnownDevices = u.devices.toList()
val new = devices.map { entry -> entry.value.toEntity() }
new.forEach { entity ->
// Maintain first time seen
val existing = currentKnownDevices.firstOrNull { it.deviceId == entity.deviceId && it.identityKey == entity.identityKey }
entity.firstTimeSeenLocalTs = existing?.firstTimeSeenLocalTs ?: System.currentTimeMillis()
realm.insertOrUpdate(entity)
}
// Ensure all other devices are deleted
u.devices.deleteAllFromRealm()
val new = devices.map { entry -> entry.value.toEntity() }
new.forEach { realm.insertOrUpdate(it) }
u.devices.addAll(new)
}
}
@ -482,6 +490,52 @@ internal class RealmCryptoStore @Inject constructor(
}
}
override fun getMyDeviceInfo(): List<DeviceInfo> {
return monarchy.fetchAllCopiedSync {
it.where<MyDeviceLastSeenInfoEntity>()
}.map {
DeviceInfo(
deviceId = it.deviceId,
lastSeenIp = it.lastSeenIp,
lastSeenTs = it.lastSeenTs,
displayName = it.displayName
)
}
}
override fun getLiveMyDeviceInfo(): LiveData<List<DeviceInfo>> {
return monarchy.findAllMappedWithChanges(
{ realm: Realm ->
realm.where<MyDeviceLastSeenInfoEntity>()
},
{ entity ->
DeviceInfo(
deviceId = entity.deviceId,
lastSeenIp = entity.lastSeenIp,
lastSeenTs = entity.lastSeenTs,
displayName = entity.displayName
)
}
)
}
override fun saveMyDeviceInfos(info: List<DeviceInfo>) {
val entities = info.map {
MyDeviceLastSeenInfoEntity(
lastSeenTs = it.lastSeenTs,
lastSeenIp = it.lastSeenIp,
displayName = it.displayName,
deviceId = it.deviceId
)
}
monarchy.writeAsync { realm ->
realm.where<MyDeviceLastSeenInfoEntity>().findAll().deleteAllFromRealm()
entities.forEach {
realm.insertOrUpdate(it)
}
}
}
override fun storeRoomAlgorithm(roomId: String, algorithm: String) {
doRealmTransaction(realmConfiguration) {
CryptoRoomEntity.getOrCreate(it, roomId).algorithm = algorithm

View file

@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.crypto.store.db
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.internal.crypto.model.MXDeviceInfo
import im.vector.matrix.android.internal.crypto.store.db.mapper.CrossSigningKeysMapper
@ -27,6 +28,8 @@ import im.vector.matrix.android.internal.crypto.store.db.model.DeviceInfoEntityF
import im.vector.matrix.android.internal.crypto.store.db.model.GossipingEventEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.IncomingGossipingRequestEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.KeyInfoEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.OutgoingGossipingRequestEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.TrustLevelEntityFields
import im.vector.matrix.android.internal.crypto.store.db.model.UserEntityFields
@ -40,7 +43,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
// Version 1L added Cross Signing info persistence
companion object {
const val CRYPTO_STORE_SCHEMA_VERSION = 4L
const val CRYPTO_STORE_SCHEMA_VERSION = 5L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
@ -50,6 +53,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
if (oldVersion <= 1) migrateTo2(realm)
if (oldVersion <= 2) migrateTo3(realm)
if (oldVersion <= 3) migrateTo4(realm)
if (oldVersion <= 4) migrateTo5(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -212,4 +216,23 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
} catch (failure: Throwable) {
}
}
private fun migrateTo5(realm: DynamicRealm) {
realm.schema.create("MyDeviceLastSeenInfoEntity")
.addField(MyDeviceLastSeenInfoEntityFields.DEVICE_ID, String::class.java)
.addField(MyDeviceLastSeenInfoEntityFields.DISPLAY_NAME, String::class.java)
.addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_IP, String::class.java)
.addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, Long::class.java)
.setNullable(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, true)
val now = System.currentTimeMillis()
realm.schema.get("DeviceInfoEntity")
?.addField(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, Long::class.java)
?.setNullable(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, true)
?.transform { deviceInfoEntity ->
tryThis {
deviceInfoEntity.setLong(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, now)
}
}
}
}

View file

@ -24,6 +24,7 @@ import im.vector.matrix.android.internal.crypto.store.db.model.GossipingEventEnt
import im.vector.matrix.android.internal.crypto.store.db.model.IncomingGossipingRequestEntity
import im.vector.matrix.android.internal.crypto.store.db.model.KeyInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.KeysBackupDataEntity
import im.vector.matrix.android.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntity
import im.vector.matrix.android.internal.crypto.store.db.model.OlmInboundGroupSessionEntity
import im.vector.matrix.android.internal.crypto.store.db.model.OlmSessionEntity
import im.vector.matrix.android.internal.crypto.store.db.model.OutgoingGossipingRequestEntity
@ -48,6 +49,7 @@ import io.realm.annotations.RealmModule
TrustLevelEntity::class,
GossipingEventEntity::class,
IncomingGossipingRequestEntity::class,
OutgoingGossipingRequestEntity::class
OutgoingGossipingRequestEntity::class,
MyDeviceLastSeenInfoEntity::class
])
internal class RealmCryptoStoreModule

View file

@ -104,7 +104,8 @@ object CryptoMapper {
Timber.e(failure)
null
}
}
},
firsTimeSeenLocalTs = deviceInfoEntity.firstTimeSeenLocalTs
)
}
}

View file

@ -34,7 +34,12 @@ internal open class DeviceInfoEntity(@PrimaryKey var primaryKey: String = "",
var keysMapJson: String? = null,
var signatureMapJson: String? = null,
var unsignedMapJson: String? = null,
var trustLevelEntity: TrustLevelEntity? = null
var trustLevelEntity: TrustLevelEntity? = null,
/**
* We use that to make distinction between old devices (there before mine)
* and new ones. Used for example to detect new unverified login
*/
var firstTimeSeenLocalTs: Long? = null
) : RealmObject() {
// // Deserialize data

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2020 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.matrix.android.internal.crypto.store.db.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
internal open class MyDeviceLastSeenInfoEntity(
/**The device id*/
@PrimaryKey var deviceId: String? = null,
/** The device display name*/
var displayName: String? = null,
/** The last time this device has been seen. */
var lastSeenTs: Long? = null,
/** The last ip address*/
var lastSeenIp: String? = null
) : RealmObject() {
companion object
}

View file

@ -27,14 +27,13 @@ import im.vector.matrix.android.api.session.crypto.verification.SasVerificationT
import im.vector.matrix.android.api.session.crypto.verification.VerificationService
import im.vector.matrix.android.api.session.crypto.verification.VerificationTransaction
import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState
import im.vector.matrix.android.internal.crypto.IncomingRoomKeyRequest
import im.vector.matrix.android.internal.crypto.IncomingRequestCancellation
import im.vector.matrix.android.internal.crypto.IncomingRoomKeyRequest
import im.vector.matrix.android.internal.crypto.IncomingSecretShareRequest
import im.vector.matrix.android.internal.crypto.crosssigning.DeviceTrustLevel
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.riotx.R
import im.vector.riotx.features.popup.DefaultVectorAlert
import im.vector.riotx.features.popup.PopupAlertManager
@ -75,7 +74,7 @@ class KeyRequestHandler @Inject constructor(private val context: Context, privat
session = null
}
override fun onSecretShareRequest(request: IncomingSecretShareRequest) : Boolean {
override fun onSecretShareRequest(request: IncomingSecretShareRequest): Boolean {
// By default riotX will not prompt if the SDK has decided that the request should not be fulfilled
Timber.v("## onSecretShareRequest() : Ignoring $request")
request.ignore?.run()
@ -124,19 +123,11 @@ class KeyRequestHandler @Inject constructor(private val context: Context, privat
deviceInfo.trustLevel = DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false)
// can we get more info on this device?
session?.cryptoService()?.getDevicesList(object : MatrixCallback<DevicesListResponse> {
override fun onSuccess(data: DevicesListResponse) {
data.devices?.find { it.deviceId == deviceId }?.let {
postAlert(context, userId, deviceId, true, deviceInfo, it)
} ?: run {
postAlert(context, userId, deviceId, true, deviceInfo)
}
}
override fun onFailure(failure: Throwable) {
postAlert(context, userId, deviceId, true, deviceInfo)
}
})
session?.cryptoService()?.getMyDeviceInfo()?.firstOrNull { it.deviceId == deviceId }?.let {
postAlert(context, userId, deviceId, true, deviceInfo, it)
} ?: kotlin.run {
postAlert(context, userId, deviceId, true, deviceInfo)
}
} else {
postAlert(context, userId, deviceId, false, deviceInfo)
}

View file

@ -423,7 +423,7 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
}
} catch (failure: Throwable) {
// Just ignore for now
Timber.v("## Failed to restore backup after SSSS recovery")
Timber.e(failure,"## Failed to restore backup after SSSS recovery")
}
}
}

View file

@ -30,6 +30,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationMenuView
import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.riotx.R
import im.vector.riotx.core.extensions.commitTransactionNow
import im.vector.riotx.core.glide.GlideApp
@ -42,6 +43,7 @@ import im.vector.riotx.features.home.room.list.RoomListParams
import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
import im.vector.riotx.features.popup.PopupAlertManager
import im.vector.riotx.features.popup.VerificationVectorAlert
import im.vector.riotx.features.settings.VectorSettingsActivity.Companion.EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS
import im.vector.riotx.features.workers.signout.SignOutViewModel
import kotlinx.android.synthetic.main.fragment_home_detail.*
import timber.log.Timber
@ -86,43 +88,82 @@ class HomeDetailFragment @Inject constructor(
switchDisplayMode(displayMode)
}
unknownDeviceDetectorSharedViewModel.subscribe {
it.unknownSessions.invoke()?.let { unknownDevices ->
Timber.v("## Detector - ${unknownDevices.size} Unknown sessions")
unknownDevices.forEachIndexed { index, deviceInfo ->
Timber.v("## Detector - #$index deviceId:${deviceInfo.second.deviceId} lastSeenTs:${deviceInfo.second.lastSeenTs}")
}
val uid = "Newest_Device"
alertManager.cancelAlert(uid)
if (it.canCrossSign && unknownDevices.isNotEmpty()) {
val newest = unknownDevices.first().second
val user = unknownDevices.first().first
alertManager.postVectorAlert(
VerificationVectorAlert(
uid = uid,
title = getString(R.string.new_session),
description = getString(R.string.new_session_review_with_info, newest.displayName ?: "", newest.deviceId ?: ""),
iconId = R.drawable.ic_shield_warning
).apply {
matrixItem = user
colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent)
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)
?.navigator
?.requestSessionVerification(requireContext(), newest.deviceId ?: "")
}
dismissedAction = Runnable {
unknownDeviceDetectorSharedViewModel.handle(
UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId ?: "")
)
}
}
)
unknownDeviceDetectorSharedViewModel.subscribe { state ->
state.unknownSessions.invoke()?.let { unknownDevices ->
if (state.canCrossSign && unknownDevices.isNotEmpty()) {
Timber.v("## Detector - ${unknownDevices.size} Unknown sessions")
unknownDevices.forEachIndexed { index, detectionInfo ->
Timber.v("## Detector - #$index deviceId:${detectionInfo.deviceInfo.deviceId} lastSeenTs:${detectionInfo.deviceInfo.lastSeenTs} is New: ${detectionInfo.isNew}")
}
val uid = "review_login"
alertManager.cancelAlert(uid)
val olderUnverified = unknownDevices.filter { !it.isNew }
val newest = unknownDevices.firstOrNull { it.isNew }?.deviceInfo
if (newest != null) {
promptForNewUnknownDevices(uid, state, newest)
} else if (olderUnverified.isNotEmpty()) {
// In this case we prompt to go to settings to review logins
promptToReviewChanges(uid, state, olderUnverified.map { it.deviceInfo })
}
}
}
}
}
private fun promptForNewUnknownDevices(uid: String, state: UnknownDevicesState, newest: DeviceInfo) {
val user = state.myMatrixItem
alertManager.postVectorAlert(
VerificationVectorAlert(
uid = uid,
title = getString(R.string.new_session),
description = getString(R.string.new_session_review_with_info, user?.displayName ?: "", newest.deviceId ?: ""),
iconId = R.drawable.ic_shield_warning
).apply {
matrixItem = user
colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent)
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)
?.navigator
?.requestSessionVerification(requireContext(), newest.deviceId ?: "")
}
dismissedAction = Runnable {
unknownDeviceDetectorSharedViewModel.handle(
UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) } ?: emptyList())
)
}
}
)
}
private fun promptToReviewChanges(uid: String, state: UnknownDevicesState, oldUnverified: List<DeviceInfo>) {
val user = state.myMatrixItem
alertManager.postVectorAlert(
VerificationVectorAlert(
uid = uid,
title = getString(R.string.review_logins),
description = getString(R.string.verify_other_sessions),
iconId = R.drawable.ic_shield_warning
).apply {
matrixItem = user
colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent)
contentAction = Runnable {
(weakCurrentActivity?.get() as? VectorBaseActivity)?.let {
// mark as ignored to avoid showing it again
unknownDeviceDetectorSharedViewModel.handle(
UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(oldUnverified.mapNotNull { it.deviceId })
)
it.navigator.openSettings(it, EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS)
}
}
dismissedAction = Runnable {
unknownDeviceDetectorSharedViewModel.handle(
UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(oldUnverified.mapNotNull { it.deviceId })
)
}
}
)
}
private fun onGroupChange(groupSummary: GroupSummary?) {
groupSummary?.let {
// Use GlideApp with activity context to avoid the glideRequests to be paused

View file

@ -22,27 +22,35 @@ import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.NoOpCancellable
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.matrix.rx.rx
import im.vector.matrix.rx.singleBuilder
import im.vector.riotx.core.di.HasScreenInjector
import im.vector.riotx.core.platform.EmptyViewEvents
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.platform.VectorViewModelAction
import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import timber.log.Timber
import java.util.concurrent.TimeUnit
data class UnknownDevicesState(
val unknownSessions: Async<List<Pair<MatrixItem?, DeviceInfo>>> = Uninitialized,
val myMatrixItem: MatrixItem.UserItem? = null,
val unknownSessions: Async<List<DeviceDetectionInfo>> = Uninitialized,
val canCrossSign: Boolean = false
) : MvRxState
data class DeviceDetectionInfo(
val deviceInfo: DeviceInfo,
val isNew: Boolean
)
class UnknownDeviceDetectorSharedViewModel(
session: Session,
private val vectorPreferences: VectorPreferences,
@ -50,72 +58,97 @@ class UnknownDeviceDetectorSharedViewModel(
: VectorViewModel<UnknownDevicesState, UnknownDeviceDetectorSharedViewModel.Action, EmptyViewEvents>(initialState) {
sealed class Action : VectorViewModelAction {
data class IgnoreDevice(val deviceId: String) : Action()
data class IgnoreDevice(val deviceIds: List<String>) : Action()
}
val ignoredDeviceList = ArrayList<String>()
private val ignoredDeviceList = ArrayList<String>()
init {
val currentSessionTs = session.cryptoService().getCryptoDeviceInfo(session.myUserId).firstOrNull {
it.deviceId == session.sessionParams.credentials.deviceId
}?.firsTimeSeenLocalTs ?: System.currentTimeMillis()
Timber.v("## Detector - Current Session first time seen $currentSessionTs")
ignoredDeviceList.addAll(
vectorPreferences.getUnknownDeviceDismissedList().also {
Timber.v("## Detector - Remembered ignored list $it")
}
)
session.rx().liveUserCryptoDevices(session.myUserId)
.debounce(600, TimeUnit.MILLISECONDS)
Observable.combineLatest<List<CryptoDeviceInfo>, List<DeviceInfo>, List<DeviceDetectionInfo>>(
session.rx().liveUserCryptoDevices(session.myUserId),
session.rx().liveMyDeviceInfo(),
BiFunction { cryptoList, infoList ->
infoList
.filter { info ->
// filter verified session, by checking the crypto device info
cryptoList.firstOrNull { info.deviceId == it.deviceId }?.isVerified?.not().orFalse()
}
// filter out ignored devices
.filter { !ignoredDeviceList.contains(it.deviceId) }
.sortedByDescending { it.lastSeenTs }
.map { deviceInfo ->
val deviceKnownSince = cryptoList.firstOrNull { it.deviceId == deviceInfo.deviceId }?.firsTimeSeenLocalTs ?: 0
DeviceDetectionInfo(
deviceInfo,
deviceKnownSince > currentSessionTs + 60_000 // short window to avoid false positive
)
// .also {
// Timber.v("## Detector - first seen Difference with ${deviceInfo.deviceId} is ${deviceKnownSince - currentSessionTs}")
// }
}
}
)
.distinct()
.switchMap { deviceList ->
Timber.v("## Detector - ============================")
Timber.v("## Detector - Crypto device update ${deviceList.map { "${it.deviceId} : ${it.isVerified}" }}")
singleBuilder<DevicesListResponse> {
session.cryptoService().getDevicesList(it)
NoOpCancellable
}.map { resp ->
// Timber.v("## Detector - Device Infos ${resp.devices?.map { "${it.deviceId} : lastSeen:${it.lastSeenTs}" }}")
resp.devices?.filter { info ->
deviceList.firstOrNull { info.deviceId == it.deviceId }?.let {
!it.isVerified
} ?: false
}
?.sortedByDescending { it.lastSeenTs }
?.map {
session.getUser(it.user_id ?: "")?.toMatrixItem() to it
}
?.filter { !ignoredDeviceList.contains(it.second.deviceId) }
?: emptyList()
}
.toObservable()
}
.execute { async ->
copy(unknownSessions = async)
copy(
myMatrixItem = session.getUser(session.myUserId)?.toMatrixItem(),
unknownSessions = async
)
}
session.rx().liveUserCryptoDevices(session.myUserId)
.distinct()
.throttleLast(5_000, TimeUnit.MILLISECONDS)
.subscribe {
// If we have a new crypto device change, we might want to trigger refresh of device info
session.cryptoService().fetchDevicesList(NoOpMatrixCallback())
}.disposeOnClear()
session.rx().liveCrossSigningInfo(session.myUserId)
.execute {
copy(canCrossSign = session.cryptoService().crossSigningService().canCrossSign())
}
// trigger a refresh of lastSeen / last Ip
session.cryptoService().fetchDevicesList(NoOpMatrixCallback())
}
override fun handle(action: Action) {
when (action) {
is Action.IgnoreDevice -> {
ignoredDeviceList.addAll(action.deviceIds)
// local echo
withState { state ->
state.unknownSessions.invoke()?.let {
val updated = it.filter { it.second.deviceId != action.deviceId }
state.unknownSessions.invoke()?.let { detectedSessions ->
val updated = detectedSessions.filter { !action.deviceIds.contains(it.deviceInfo.deviceId) }
setState {
copy(unknownSessions = Success(updated))
}
}
}
ignoredDeviceList.add(action.deviceId)
vectorPreferences.storeUnknownDeviceDismissedList(ignoredDeviceList)
}
}
}
override fun onCleared() {
vectorPreferences.storeUnknownDeviceDismissedList(ignoredDeviceList)
super.onCleared()
}
companion object : MvRxViewModelFactory<UnknownDeviceDetectorSharedViewModel, UnknownDevicesState> {
override fun create(viewModelContext: ViewModelContext, state: UnknownDevicesState): UnknownDeviceDetectorSharedViewModel? {
val session = (viewModelContext.activity as HasScreenInjector).injector().activeSessionHolder().getActiveSession()
return UnknownDeviceDetectorSharedViewModel(session, VectorPreferences(viewModelContext.activity()), state)

View file

@ -26,6 +26,7 @@ import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.settings.devices.VectorSettingsDevicesFragment
import kotlinx.android.synthetic.main.activity_vector_settings.*
import timber.log.Timber
import javax.inject.Inject
@ -58,11 +59,16 @@ class VectorSettingsActivity : VectorBaseActivity(),
if (isFirstCreation()) {
// display the fragment
when (intent.getIntExtra(EXTRA_DIRECT_ACCESS, EXTRA_DIRECT_ACCESS_ROOT)) {
EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS ->
EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS ->
replaceFragment(R.id.vector_settings_page, VectorSettingsAdvancedSettingsFragment::class.java, null, FRAGMENT_TAG)
EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY ->
EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY ->
replaceFragment(R.id.vector_settings_page, VectorSettingsSecurityPrivacyFragment::class.java, null, FRAGMENT_TAG)
else ->
EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS ->
replaceFragment(R.id.vector_settings_page,
VectorSettingsDevicesFragment::class.java,
null,
FRAGMENT_TAG)
else ->
replaceFragment(R.id.vector_settings_page, VectorSettingsRootFragment::class.java, null, FRAGMENT_TAG)
}
}
@ -130,6 +136,7 @@ class VectorSettingsActivity : VectorBaseActivity(),
const val EXTRA_DIRECT_ACCESS_ROOT = 0
const val EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS = 1
const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY = 2
const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS = 3
private const val FRAGMENT_TAG = "VectorSettingsPreferencesFragment"
}

View file

@ -412,7 +412,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor(
refreshCryptographyPreference(it)
}
// TODO Move to a ViewModel...
session.cryptoService().getDevicesList(object : MatrixCallback<DevicesListResponse> {
session.cryptoService().fetchDevicesList(object : MatrixCallback<DevicesListResponse> {
override fun onSuccess(data: DevicesListResponse) {
if (isAdded) {
refreshCryptographyPreference(data.devices ?: emptyList())

View file

@ -16,17 +16,14 @@
package im.vector.riotx.features.settings.devices
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
@ -40,7 +37,7 @@ data class DeviceVerificationInfoBottomSheetViewState(
val deviceInfo: Async<DeviceInfo> = Uninitialized,
val hasAccountCrossSigning: Boolean = false,
val accountCrossSigningIsTrusted: Boolean = false,
val isMine : Boolean = false
val isMine: Boolean = false
) : MvRxState
class DeviceVerificationInfoBottomSheetViewModel @AssistedInject constructor(@Assisted initialState: DeviceVerificationInfoBottomSheetViewState,
@ -79,22 +76,18 @@ class DeviceVerificationInfoBottomSheetViewModel @AssistedInject constructor(@As
isMine = it.invoke()?.deviceId == session.sessionParams.credentials.deviceId
)
}
setState {
copy(deviceInfo = Loading())
}
session.cryptoService().getDeviceInfo(deviceId, object : MatrixCallback<DeviceInfo> {
override fun onSuccess(data: DeviceInfo) {
setState {
copy(deviceInfo = Success(data))
}
}
override fun onFailure(failure: Throwable) {
setState {
copy(deviceInfo = Fail(failure))
session.rx().liveMyDeviceInfo()
.map { devices ->
devices.firstOrNull { it.deviceId == deviceId } ?: DeviceInfo(deviceId = deviceId)
}
.execute {
copy(deviceInfo = it)
}
}
})
}
companion object : MvRxViewModelFactory<DeviceVerificationInfoBottomSheetViewModel, DeviceVerificationInfoBottomSheetViewState> {

View file

@ -20,7 +20,7 @@ import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class DevicesAction : VectorViewModelAction {
object Retry : DevicesAction()
object Refresh : DevicesAction()
data class Delete(val deviceId: String) : DevicesAction()
data class Password(val password: String) : DevicesAction()
data class Rename(val deviceId: String, val newName: String) : DevicesAction()

View file

@ -83,7 +83,7 @@ class DevicesController @Inject constructor(private val errorFormatter: ErrorFor
legacyMode: Boolean,
currentSessionCrossTrusted: Boolean) {
devices
.firstOrNull() {
.firstOrNull {
it.deviceId == myDeviceId
}?.let { deviceInfo ->

View file

@ -29,6 +29,7 @@ import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.Session
@ -39,14 +40,13 @@ import im.vector.matrix.android.api.session.crypto.verification.VerificationTxSt
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
import im.vector.matrix.android.internal.crypto.crosssigning.DeviceTrustLevel
import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo
import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.matrix.android.internal.util.awaitCallback
import im.vector.matrix.rx.rx
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.crypto.verification.SupportedVerificationMethodsProvider
import io.reactivex.subjects.PublishSubject
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
data class DevicesViewState(
val myDeviceId: String = "",
@ -60,9 +60,8 @@ data class DevicesViewState(
class DevicesViewModel @AssistedInject constructor(
@Assisted initialState: DevicesViewState,
private val session: Session,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider)
: VectorViewModel<DevicesViewState, DevicesAction, DevicesViewEvents>(initialState), VerificationService.Listener {
private val session: Session
) : VectorViewModel<DevicesViewState, DevicesAction, DevicesViewEvents>(initialState), VerificationService.Listener {
@AssistedInject.Factory
interface Factory {
@ -82,12 +81,15 @@ class DevicesViewModel @AssistedInject constructor(
private var _currentDeviceId: String? = null
private var _currentSession: String? = null
private val refreshPublisher: PublishSubject<Unit> = PublishSubject.create()
init {
setState {
copy(
hasAccountCrossSigning = session.cryptoService().crossSigningService().getMyCrossSigningKeys() != null,
accountCrossSigningIsTrusted = session.cryptoService().crossSigningService().isCrossSigningVerified()
accountCrossSigningIsTrusted = session.cryptoService().crossSigningService().isCrossSigningVerified(),
myDeviceId = session.sessionParams.credentials.deviceId ?: ""
)
}
session.rx().liveCrossSigningInfo(session.myUserId)
@ -97,16 +99,29 @@ class DevicesViewModel @AssistedInject constructor(
accountCrossSigningIsTrusted = it.invoke()?.get()?.isTrusted() == true
)
}
refreshDevicesList()
session.cryptoService().verificationService().addListener(this)
session.rx().liveMyDeviceInfo()
.execute {
copy(
devices = it
)
}
session.rx().liveUserCryptoDevices(session.myUserId)
.execute {
copy(
cryptoDevices = it
)
}
refreshPublisher.throttleFirst(4_000, TimeUnit.MILLISECONDS)
.subscribe {
session.cryptoService().fetchDevicesList(NoOpMatrixCallback())
session.cryptoService().downloadKeys(listOf(session.myUserId), true, NoOpMatrixCallback())
}
.disposeOnClear()
// then force download
queryRefreshDevicesList()
}
override fun onCleared() {
@ -116,7 +131,7 @@ class DevicesViewModel @AssistedInject constructor(
override fun transactionUpdated(tx: VerificationTransaction) {
if (tx.state == VerificationTxState.Verified) {
refreshDevicesList()
queryRefreshDevicesList()
}
}
@ -125,75 +140,68 @@ class DevicesViewModel @AssistedInject constructor(
* The devices list is the list of the devices where the user is logged in.
* It can be any mobile devices, and any browsers.
*/
private fun refreshDevicesList() {
if (!session.sessionParams.credentials.deviceId.isNullOrEmpty()) {
// display something asap
val localKnown = session.cryptoService().getUserDevices(session.myUserId).map {
DeviceInfo(
user_id = session.myUserId,
deviceId = it.deviceId,
displayName = it.displayName()
)
}
private fun queryRefreshDevicesList() {
refreshPublisher.onNext(Unit)
setState {
copy(
// Keep known list if we have it, and let refresh go in backgroung
devices = this.devices.takeIf { it is Success } ?: Success(localKnown)
)
}
session.cryptoService().getDevicesList(object : MatrixCallback<DevicesListResponse> {
override fun onSuccess(data: DevicesListResponse) {
setState {
copy(
myDeviceId = session.sessionParams.credentials.deviceId ?: "",
devices = Success(data.devices.orEmpty())
)
}
}
override fun onFailure(failure: Throwable) {
setState {
copy(
devices = Fail(failure)
)
}
}
})
// Put cached state
setState {
copy(
myDeviceId = session.sessionParams.credentials.deviceId ?: "",
cryptoDevices = Success(session.cryptoService().getUserDevices(session.myUserId))
)
}
// then force download
session.cryptoService().downloadKeys(listOf(session.myUserId), true, object : MatrixCallback<MXUsersDevicesMap<CryptoDeviceInfo>> {
override fun onSuccess(data: MXUsersDevicesMap<CryptoDeviceInfo>) {
setState {
copy(
cryptoDevices = Success(session.cryptoService().getUserDevices(session.myUserId))
)
}
}
})
} else {
// Should not happen
}
// if (!session.sessionParams.credentials.deviceId.isNullOrEmpty()) {
// // display something asap
// val localKnown = session.cryptoService().getUserDevices(session.myUserId).map {
// DeviceInfo(
// user_id = session.myUserId,
// deviceId = it.deviceId,
// displayName = it.displayName()
// )
// }
//
// setState {
// copy(
// // Keep known list if we have it, and let refresh go in backgroung
// devices = this.devices.takeIf { it is Success } ?: Success(localKnown)
// )
// }
//
// session.cryptoService().fetchDevicesList(object : MatrixCallback<DevicesListResponse> {
// override fun onSuccess(data: DevicesListResponse) {
// setState {
// copy(
// myDeviceId = session.sessionParams.credentials.deviceId ?: "",
// devices = Success(data.devices.orEmpty())
// )
// }
// }
//
// override fun onFailure(failure: Throwable) {
// setState {
// copy(
// devices = Fail(failure)
// )
// }
// }
// })
//
// // Put cached state
// setState {
// copy(
// myDeviceId = session.sessionParams.credentials.deviceId ?: "",
// cryptoDevices = Success(session.cryptoService().getUserDevices(session.myUserId))
// )
// }
//
//
// } else {
// // Should not happen
// }
}
override fun handle(action: DevicesAction) {
return when (action) {
is DevicesAction.Retry -> refreshDevicesList()
is DevicesAction.Delete -> handleDelete(action)
is DevicesAction.Password -> handlePassword(action)
is DevicesAction.Rename -> handleRename(action)
is DevicesAction.PromptRename -> handlePromptRename(action)
is DevicesAction.VerifyMyDevice -> handleInteractiveVerification(action)
is DevicesAction.CompleteSecurity -> handleCompleteSecurity()
is DevicesAction.Refresh -> queryRefreshDevicesList()
is DevicesAction.Delete -> handleDelete(action)
is DevicesAction.Password -> handlePassword(action)
is DevicesAction.Rename -> handleRename(action)
is DevicesAction.PromptRename -> handlePromptRename(action)
is DevicesAction.VerifyMyDevice -> handleInteractiveVerification(action)
is DevicesAction.CompleteSecurity -> handleCompleteSecurity()
is DevicesAction.MarkAsManuallyVerified -> handleVerifyManually(action)
is DevicesAction.VerifyMyDeviceManually -> handleShowDeviceCryptoInfo(action)
}
@ -253,7 +261,7 @@ class DevicesViewModel @AssistedInject constructor(
)
}
// force settings update
refreshDevicesList()
queryRefreshDevicesList()
}
override fun onFailure(failure: Throwable) {
@ -324,7 +332,7 @@ class DevicesViewModel @AssistedInject constructor(
)
}
// force settings update
refreshDevicesList()
queryRefreshDevicesList()
}
})
}
@ -353,7 +361,7 @@ class DevicesViewModel @AssistedInject constructor(
)
}
// force settings update
refreshDevicesList()
queryRefreshDevicesList()
}
override fun onFailure(failure: Throwable) {

View file

@ -102,8 +102,8 @@ class VectorSettingsDevicesFragment @Inject constructor(
override fun onResume() {
super.onResume()
(activity as? VectorBaseActivity)?.supportActionBar?.setTitle(R.string.settings_active_sessions_manage)
viewModel.handle(DevicesAction.Refresh)
}
override fun onDeviceClicked(deviceInfo: DeviceInfo) {
@ -122,7 +122,7 @@ class VectorSettingsDevicesFragment @Inject constructor(
// }
override fun retry() {
viewModel.handle(DevicesAction.Retry)
viewModel.handle(DevicesAction.Refresh)
}
/**

View file

@ -6,7 +6,8 @@
<!-- Sections has been created to limit merge conflicts. -->
<!-- BEGIN Strings added by Valere -->
<string name="review_logins">Review where youre logged in</string>
<string name="verify_other_sessions">Verify your other sessions</string>
<!-- END Strings added by Valere -->