From dd47297dfd4bc774c509d06ccc1316fbb70e6514 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 13 Oct 2022 21:15:52 +0100 Subject: [PATCH] QR login + E2EE set up --- .../sdk/internal/rendezvous/Rendezvous.kt | 210 ++++++++++++++++++ .../features/login/qr/QrCodeLoginViewModel.kt | 62 +++--- 2 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt new file mode 100644 index 0000000000..2f85a97c55 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt @@ -0,0 +1,210 @@ +/* + * 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 org.matrix.android.sdk.internal.rendezvous + +import android.net.Uri +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel +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 +import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.util.MatrixJsonParser +import org.matrix.android.sdk.internal.rendezvous.channels.ECDHRendezvousChannel +import org.matrix.android.sdk.internal.rendezvous.model.ECDHRendezvousCode +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousIntent +import org.matrix.android.sdk.internal.rendezvous.transports.SimpleHttpRendezvousTransport +import timber.log.Timber + +internal enum class PayloadType(val value: String) { + @Json(name = "m.login.start") Start("m.login.start"), + @Json(name = "m.login.finish") Finish("m.login.finish"), + @Json(name = "m.login.progress") Progress("m.login.progress") +} + +@JsonClass(generateAdapter = true) +internal data class Payload( + @Json val type: PayloadType, + @Json val intent: RendezvousIntent? = null, + @Json val outcome: String? = null, + @Json val protocols: List? = null, + @Json val protocol: String? = null, + @Json val homeserver: String? = null, + @Json val login_token: String? = null, + @Json val device_id: String? = null, + @Json val device_key: String? = null, + @Json val verifying_device_id: String? = null, + @Json val verifying_device_key: String? = null, + @Json val master_key: String? = null +) + +private val TAG = LoggerTag(Rendezvous::class.java.simpleName, LoggerTag.RENDEZVOUS).value + +/** + * Implementation of MSC3906 to sign in + E2EE set up using a QR code. + */ +class Rendezvous( + val channel: RendezvousChannel, + val theirIntent: RendezvousIntent, +) { + companion object { + fun buildChannelFromCode(code: String, onCancelled: (reason: RendezvousFailureReason) -> Unit): Rendezvous { + val parsed = MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code) ?: throw RuntimeException("Invalid code") + + val transport = SimpleHttpRendezvousTransport(onCancelled, parsed.rendezvous.transport.uri) + + return Rendezvous( + ECDHRendezvousChannel(transport, parsed.rendezvous.key), + parsed.intent + ) + } + } + + private val adapter = MatrixJsonParser.getMoshi().adapter(Payload::class.java) + // not yet implemented: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE + val ourIntent: RendezvousIntent = RendezvousIntent.LOGIN_ON_NEW_DEVICE + + private suspend fun areIntentsIncompatible(): Boolean { + val incompatible = theirIntent == ourIntent + + Timber.tag(TAG).d("ourIntent: $ourIntent, theirIntent: $theirIntent, incompatible: $incompatible") + + if (incompatible) { + send(Payload(PayloadType.Finish, intent = ourIntent)) + val reason = if (ourIntent == RendezvousIntent.LOGIN_ON_NEW_DEVICE) RendezvousFailureReason.OtherDeviceNotSignedIn else RendezvousFailureReason.OtherDeviceAlreadySignedIn + channel.cancel(reason) + } + + return incompatible + } + + suspend fun startAfterScanningCode(): String? { + val checksum = channel.connect(); + + Timber.tag(TAG).i("Connected to secure channel with checksum: $checksum") + + if (areIntentsIncompatible()) { + return null + } + + // get protocols + Timber.tag(TAG).i("Waiting for protocols"); + val protocolsResponse = receive() + + if (protocolsResponse?.protocols == null || !protocolsResponse.protocols.contains("login_token")) { + send(Payload(PayloadType.Finish, outcome = "unsupported")) + Timber.tag(TAG).i("No supported protocol") + cancel(RendezvousFailureReason.Unknown) + return null + } + + send(Payload(PayloadType.Progress, protocol = "login_token")) + + return checksum + } + + suspend fun waitForLoginOnNewDevice(authenticationService: AuthenticationService): Session? { + Timber.tag(TAG).i("Waiting for login_token"); + + val loginToken = receive() + + if (loginToken?.type == PayloadType.Finish) { + when (loginToken.outcome) { + "declined" -> { + Timber.tag(TAG).i("Login declined by other device") + channel.cancel(RendezvousFailureReason.UserDeclined) + return null + } + "unsupported" -> { + Timber.tag(TAG).i("Not supported") + channel.cancel(RendezvousFailureReason.HomeserverLacksSupport) + return null + } + } + channel.cancel(RendezvousFailureReason.Unknown) + return null + } + + val homeserver = loginToken?.homeserver ?: throw RuntimeException("No homeserver returned") + val login_token = loginToken.login_token ?: throw RuntimeException("No login token returned") + + Timber.tag(TAG).i("Got login_token: $login_token for $homeserver"); + + val hsConfig = HomeServerConnectionConfig(homeServerUri = Uri.parse(homeserver)) + return authenticationService.loginUsingQrLoginToken(hsConfig, login_token) + } + + suspend fun completeVerificationOnNewDevice(session: Session) { + val userId = session.myUserId + val crypto = session.cryptoService() + val deviceId = crypto.getMyDevice().deviceId + val deviceKey = crypto.getMyDevice().fingerprint() + send(Payload(PayloadType.Progress, outcome = "success", device_id = deviceId, device_key = deviceKey)) + + // await confirmation of verification + + val verificationResponse = receive() + val verifyingDeviceId = verificationResponse?.verifying_device_id ?: throw RuntimeException("No verifying device id returned") + val verifyingDeviceFromServer = crypto.getCryptoDeviceInfo(userId, verifyingDeviceId) + if (verifyingDeviceFromServer?.fingerprint() != verificationResponse.verifying_device_key) { + Timber.tag(TAG).w("Verifying device $verifyingDeviceId doesn't match: $verifyingDeviceFromServer") + return; + } + + // set other device as verified + Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified"); + crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId) + + // TODO: what do we do with the master key? +// verificationResponse.master_key ?.let { +// // set master key as trusted +// crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, it) +// } + + // request secrets from the verifying device + Timber.tag(TAG).i("Requesting secrets from $verifyingDeviceId") + + session.sharedSecretStorageService() .let { + it.requestSecret(MASTER_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(SELF_SIGNING_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(USER_SIGNING_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(KEYBACKUP_SECRET_SSSS_NAME, verifyingDeviceId) + } + } + + private suspend fun receive(): Payload? { + val data = channel.receive()?: return null + return adapter.fromJson(data.toString(Charsets.UTF_8)) + } + + private suspend fun send(payload: Payload) { + channel.send(adapter.toJson(payload).toByteArray(Charsets.UTF_8)); + } + + suspend fun cancel(reason: RendezvousFailureReason) { + channel.cancel(reason) + } + + suspend fun close() { + channel.close() + } +} diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt index 877d32a11d..d9c30690a7 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt @@ -16,19 +16,32 @@ package im.vector.app.features.login.qr +import android.content.Context import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.platform.VectorViewModel -import kotlinx.coroutines.delay +import im.vector.app.features.home.HomeActivity +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.internal.rendezvous.Rendezvous +import org.matrix.android.sdk.internal.rendezvous.RendezvousFailureReason +import timber.log.Timber class QrCodeLoginViewModel @AssistedInject constructor( @Assisted private val initialState: QrCodeLoginViewState, + private val applicationContext: Context, + private val authenticationService: AuthenticationService, + private val activeSessionHolder: ActiveSessionHolder, ) : VectorViewModel(initialState) { + val TAG: String = QrCodeLoginViewModel::class.java.simpleName @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -75,28 +88,32 @@ class QrCodeLoginViewModel @AssistedInject constructor( _viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen) viewModelScope.launch(Dispatchers.IO) { - val confirmationCode = rendezvous.startAfterScanningCode() - Timber.tag(TAG).i("Established secure channel with checksum: $confirmationCode") - confirmationCode ?.let { - onConnectionEstablished(it) - rendezvous.completeOnNewDevice() - } - } + try { + val confirmationCode = rendezvous.startAfterScanningCode() + Timber.tag(TAG).i("Established secure channel with checksum: $confirmationCode") + confirmationCode?.let { + onConnectionEstablished(it) + val session = rendezvous.waitForLoginOnNewDevice(authenticationService) + onSigningIn() + session?.let { + activeSessionHolder.setActiveSession(session) + authenticationService.reset() - // TODO. UI test purpose. Fixme remove! - viewModelScope.launch { - delay(3000) - onFailed(QrCodeLoginErrorType.TIMEOUT, true) - delay(3000) - onConnectionEstablished("1234-ABCD-5678-EFGH") - delay(3000) - onSigningIn() - delay(3000) - onFailed(QrCodeLoginErrorType.DEVICE_IS_NOT_SUPPORTED, false) + session.configureAndStart(applicationContext) + + rendezvous.completeVerificationOnNewDevice(session) + + _viewEvents.post(QrCodeLoginViewEvents.NavigateToHomeScreen) + } + } + } catch (failure: Throwable) { + Timber.tag(TAG).e(failure, "Error occurred during sign in") + onFailed(RendezvousFailureReason.Unknown) + } } } - private fun onFailed(errorType: QrCodeLoginErrorType, canTryAgain: Boolean) { + private fun onFailed(reason: RendezvousFailureReason) { setState { copy( connectionStatus = QrCodeLoginConnectionStatus.Failed(errorType, canTryAgain) @@ -121,13 +138,6 @@ class QrCodeLoginViewModel @AssistedInject constructor( } } - /** - * TODO. UI test purpose. Fixme accordingly. - */ - private fun isValidQrCode(qrCode: String): Boolean { - return qrCode.startsWith("http") - } - /** * TODO. UI test purpose. Fixme accordingly. */