Merge pull request #7369 from vector-im/feature/hughns/qr_code_login

Implement logic for sign in with QR
This commit is contained in:
Johannes Marbach 2022-10-19 17:10:19 +02:00 committed by GitHub
commit 451f5f824a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1203 additions and 81 deletions

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

@ -0,0 +1 @@
Add logic for sign in with QR code

View File

@ -3386,9 +3386,16 @@
<string name="qr_code_login_header_failed_device_is_not_supported_description">Linking with this device is not supported.</string>
<string name="qr_code_login_header_failed_timeout_description">The linking wasnt completed in the required time.</string>
<string name="qr_code_login_header_failed_denied_description">The request was denied on the other device.</string>
<string name="qr_code_login_new_device_instruction_1">Open ${app_name} on your other device</string>
<string name="qr_code_login_new_device_instruction_2">Go to Settings -> Security &amp; Privacy -> Show All Sessions</string>
<string name="qr_code_login_new_device_instruction_3">Select \'Show QR code in this device\'</string>
<string name="qr_code_login_header_failed_other_description">The request failed.</string>
<string name="qr_code_login_header_failed_e2ee_security_issue_description">A security issue was encountered setting up secure messaging. One of the following may be compromised: Your homeserver; Your internet connection(s); Your device(s);</string>
<string name="qr_code_login_header_failed_other_device_already_signed_in_description">The other device is already signed in.</string>
<string name="qr_code_login_header_failed_other_device_not_signed_in_description">The other device must be signed in.</string>
<string name="qr_code_login_header_failed_invalid_qr_code_description">That QR code is invalid.</string>
<string name="qr_code_login_header_failed_user_cancelled_description">The sign in was cancelled on the other device.</string>
<string name="qr_code_login_header_failed_homeserver_is_not_supported_description">The homeserver doesn\'t support sign in with QR code.</string>
<string name="qr_code_login_new_device_instruction_1">Open the app on your other device</string>
<string name="qr_code_login_new_device_instruction_2">Go to Settings -> Security &amp; Privacy</string>
<string name="qr_code_login_new_device_instruction_3">Select \'Show QR code\'</string>
<string name="qr_code_login_link_a_device_scan_qr_code_instruction_1">Start at the sign in screen</string>
<string name="qr_code_login_link_a_device_scan_qr_code_instruction_2">Select \'Sign in with QR code\'</string>
<string name="qr_code_login_link_a_device_show_qr_code_instruction_1">Start at the sign in screen</string>

View File

@ -27,6 +27,7 @@ open class LoggerTag(name: String, parentTag: LoggerTag? = null) {
object SYNC : LoggerTag("SYNC")
object VOIP : LoggerTag("VOIP")
object CRYPTO : LoggerTag("CRYPTO")
object RENDEZVOUS : LoggerTag("RZ")
val value: String = if (parentTag == null) {
name

View File

@ -0,0 +1,229 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous
import android.net.Uri
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.rendezvous.channels.ECDHRendezvousChannel
import org.matrix.android.sdk.api.rendezvous.model.ECDHRendezvousCode
import org.matrix.android.sdk.api.rendezvous.model.Outcome
import org.matrix.android.sdk.api.rendezvous.model.Payload
import org.matrix.android.sdk.api.rendezvous.model.PayloadType
import org.matrix.android.sdk.api.rendezvous.model.Protocol
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.api.rendezvous.model.RendezvousIntent
import org.matrix.android.sdk.api.rendezvous.transports.SimpleHttpRendezvousTransport
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 timber.log.Timber
/**
* Implementation of MSC3906 to sign in + E2EE set up using a QR code.
*/
class Rendezvous(
val channel: RendezvousChannel,
val theirIntent: RendezvousIntent,
) {
companion object {
private val TAG = LoggerTag(Rendezvous::class.java.simpleName, LoggerTag.RENDEZVOUS).value
@Throws(RendezvousError::class)
fun buildChannelFromCode(code: String): Rendezvous {
val parsed = try {
// we rely on moshi validating the code and throwing exception if invalid JSON or doesn't
MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code)
} catch (a: Throwable) {
throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
} ?: throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
val transport = SimpleHttpRendezvousTransport(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
@Throws(RendezvousError::class)
private suspend fun checkCompatibility() {
val incompatible = theirIntent == ourIntent
Timber.tag(TAG).d("ourIntent: $ourIntent, theirIntent: $theirIntent, incompatible: $incompatible")
if (incompatible) {
// inform the other side
send(Payload(PayloadType.FINISH, intent = ourIntent))
if (ourIntent == RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
throw RendezvousError("The other device isn't signed in", RendezvousFailureReason.OtherDeviceNotSignedIn)
} else {
throw RendezvousError("The other device is already signed in", RendezvousFailureReason.OtherDeviceAlreadySignedIn)
}
}
}
@Throws(RendezvousError::class)
suspend fun startAfterScanningCode(): String {
val checksum = channel.connect()
Timber.tag(TAG).i("Connected to secure channel with checksum: $checksum")
checkCompatibility()
// get protocols
Timber.tag(TAG).i("Waiting for protocols")
val protocolsResponse = receive()
if (protocolsResponse?.protocols == null || !protocolsResponse.protocols.contains(Protocol.LOGIN_TOKEN)) {
send(Payload(PayloadType.FINISH, outcome = Outcome.UNSUPPORTED))
throw RendezvousError("Unsupported protocols", RendezvousFailureReason.UnsupportedHomeserver)
}
send(Payload(PayloadType.PROGRESS, protocol = Protocol.LOGIN_TOKEN))
return checksum
}
@Throws(RendezvousError::class)
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) {
Outcome.DECLINED -> {
throw RendezvousError("Login declined by other device", RendezvousFailureReason.UserDeclined)
}
Outcome.UNSUPPORTED -> {
throw RendezvousError("Homeserver lacks support", RendezvousFailureReason.UnsupportedHomeserver)
}
else -> {
throw RendezvousError("Unknown error", RendezvousFailureReason.Unknown)
}
}
}
val homeserver = loginToken?.homeserver ?: throw RendezvousError("No homeserver returned", RendezvousFailureReason.ProtocolError)
val token = loginToken.loginToken ?: throw RendezvousError("No login token returned", RendezvousFailureReason.ProtocolError)
Timber.tag(TAG).i("Got login_token now attempting to sign in with $homeserver")
val hsConfig = HomeServerConnectionConfig(homeServerUri = Uri.parse(homeserver))
return authenticationService.loginUsingQrLoginToken(hsConfig, token)
}
@Throws(RendezvousError::class)
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 = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey))
// await confirmation of verification
val verificationResponse = receive()
if (verificationResponse?.outcome == Outcome.VERIFIED) {
val verifyingDeviceId = verificationResponse.verifyingDeviceId
?: throw RendezvousError("No verifying device id returned", RendezvousFailureReason.ProtocolError)
val verifyingDeviceFromServer = crypto.getCryptoDeviceInfo(userId, verifyingDeviceId)
if (verifyingDeviceFromServer?.fingerprint() != verificationResponse.verifyingDeviceKey) {
Timber.tag(TAG).w(
"Verifying device $verifyingDeviceId key doesn't match: ${
verifyingDeviceFromServer?.fingerprint()
} vs ${verificationResponse.verifyingDeviceKey})"
)
// inform the other side
send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR))
throw RendezvousError("Key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue)
}
verificationResponse.masterKey?.let { masterKeyFromVerifyingDevice ->
// verifying device provided us with a master key, so use it to check integrity
// see what the homeserver told us
val localMasterKey = crypto.crossSigningService().getMyCrossSigningKeys()?.masterKey()
// n.b. if no local master key this is a problem, as well as it not matching
if (localMasterKey?.unpaddedBase64PublicKey != masterKeyFromVerifyingDevice) {
Timber.tag(TAG).w("Master key from verifying device doesn't match: $masterKeyFromVerifyingDevice vs $localMasterKey")
// inform the other side
send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR))
throw RendezvousError("Master key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue)
}
// set other device as verified
Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified")
crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId)
Timber.tag(TAG).i("Setting master key as trusted")
crypto.crossSigningService().markMyMasterKeyAsTrusted()
} ?: run {
// set other device as verified anyway
Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified")
crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId)
Timber.tag(TAG).i("No master key given by verifying device")
}
// 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)
}
} else {
Timber.tag(TAG).i("Not doing verification")
}
}
@Throws(RendezvousError::class)
private suspend fun receive(): Payload? {
val data = channel.receive() ?: return null
val payload = try {
adapter.fromJson(data.toString(Charsets.UTF_8))
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Failed to parse payload")
throw RendezvousError("Invalid payload received", RendezvousFailureReason.Unknown)
}
return payload
}
private suspend fun send(payload: Payload) {
channel.send(adapter.toJson(payload).toByteArray(Charsets.UTF_8))
}
suspend fun close() {
channel.close()
}
}

View File

@ -0,0 +1,51 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
/**
* Representation of a rendezvous channel such as that described by MSC3903.
*/
interface RendezvousChannel {
val transport: RendezvousTransport
/**
* @returns the checksum/confirmation digits to be shown to the user
*/
@Throws(RendezvousError::class)
suspend fun connect(): String
/**
* Send a payload via the channel.
* @param data payload to send
*/
@Throws(RendezvousError::class)
suspend fun send(data: ByteArray)
/**
* Receive a payload from the channel.
* @returns the received payload
*/
@Throws(RendezvousError::class)
suspend fun receive(): ByteArray?
/**
* Closes the channel and cleans up.
*/
suspend fun close()
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous
enum class RendezvousFailureReason(val canRetry: Boolean = true) {
UserDeclined,
OtherDeviceNotSignedIn,
OtherDeviceAlreadySignedIn,
Unknown,
Expired,
UserCancelled,
InvalidCode,
UnsupportedAlgorithm(false),
UnsupportedTransport(false),
UnsupportedHomeserver(false),
ProtocolError,
E2EESecurityIssue(false)
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous
import okhttp3.MediaType
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails
interface RendezvousTransport {
var ready: Boolean
@Throws(RendezvousError::class)
suspend fun details(): RendezvousTransportDetails
@Throws(RendezvousError::class)
suspend fun send(contentType: MediaType, data: ByteArray)
@Throws(RendezvousError::class)
suspend fun receive(): ByteArray?
suspend fun close()
}

View File

@ -0,0 +1,183 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.channels
import android.util.Base64
import com.squareup.moshi.JsonClass
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.rendezvous.RendezvousChannel
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
import org.matrix.android.sdk.api.rendezvous.RendezvousTransport
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.api.rendezvous.model.SecureRendezvousChannelAlgorithm
import org.matrix.android.sdk.api.util.MatrixJsonParser
import org.matrix.android.sdk.internal.crypto.verification.SASDefaultVerificationTransaction
import org.matrix.olm.OlmSAS
import timber.log.Timber
import java.security.SecureRandom
import java.util.LinkedList
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Implements X25519 ECDH key agreement and AES-256-GCM encryption channel as per MSC3903:
* https://github.com/matrix-org/matrix-spec-proposals/pull/3903
*/
class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPublicKeyBase64: String?) : RendezvousChannel {
companion object {
private const val ALGORITHM_SPEC = "AES/GCM/NoPadding"
private const val KEY_SPEC = "AES"
private val TAG = LoggerTag(ECDHRendezvousChannel::class.java.simpleName, LoggerTag.RENDEZVOUS).value
}
@JsonClass(generateAdapter = true)
internal data class ECDHPayload(
val algorithm: SecureRendezvousChannelAlgorithm? = null,
val key: String? = null,
val ciphertext: String? = null,
val iv: String? = null
)
private val olmSASMutex = Mutex()
private var olmSAS: OlmSAS?
private val ourPublicKey: ByteArray
private val ecdhAdapter = MatrixJsonParser.getMoshi().adapter(ECDHPayload::class.java)
private var theirPublicKey: ByteArray? = null
private var aesKey: ByteArray? = null
init {
theirPublicKeyBase64?.let {
theirPublicKey = Base64.decode(it, Base64.NO_WRAP)
}
olmSAS = OlmSAS()
ourPublicKey = Base64.decode(olmSAS!!.publicKey, Base64.NO_WRAP)
}
@Throws(RendezvousError::class)
override suspend fun connect(): String {
val sas = olmSAS ?: throw RendezvousError("Channel closed", RendezvousFailureReason.Unknown)
val isInitiator = theirPublicKey == null
if (isInitiator) {
Timber.tag(TAG).i("Waiting for other device to send their public key")
val res = this.receiveAsPayload() ?: throw RendezvousError("No reply from other device", RendezvousFailureReason.ProtocolError)
if (res.key == null) {
throw RendezvousError(
"Unsupported algorithm: ${res.algorithm}",
RendezvousFailureReason.UnsupportedAlgorithm,
)
}
theirPublicKey = Base64.decode(res.key, Base64.NO_WRAP)
} else {
// send our public key unencrypted
Timber.tag(TAG).i("Sending public key")
send(
ECDHPayload(
algorithm = SecureRendezvousChannelAlgorithm.ECDH_V1,
key = Base64.encodeToString(ourPublicKey, Base64.NO_WRAP)
)
)
}
olmSASMutex.withLock {
sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
val initiatorKey = Base64.encodeToString(if (isInitiator) ourPublicKey else theirPublicKey, Base64.NO_WRAP)
val recipientKey = Base64.encodeToString(if (isInitiator) theirPublicKey else ourPublicKey, Base64.NO_WRAP)
val aesInfo = "${SecureRendezvousChannelAlgorithm.ECDH_V1.value}|$initiatorKey|$recipientKey"
aesKey = sas.generateShortCode(aesInfo, 32)
val rawChecksum = sas.generateShortCode(aesInfo, 5)
return SASDefaultVerificationTransaction.getDecimalCodeRepresentation(rawChecksum, separator = "-")
}
}
private suspend fun send(payload: ECDHPayload) {
transport.send("application/json".toMediaType(), ecdhAdapter.toJson(payload).toByteArray(Charsets.UTF_8))
}
override suspend fun send(data: ByteArray) {
if (aesKey == null) {
throw IllegalStateException("Shared secret not established")
}
send(encrypt(data))
}
private suspend fun receiveAsPayload(): ECDHPayload? {
transport.receive()?.toString(Charsets.UTF_8)?.let {
return ecdhAdapter.fromJson(it)
} ?: return null
}
override suspend fun receive(): ByteArray? {
if (aesKey == null) {
throw IllegalStateException("Shared secret not established")
}
val payload = receiveAsPayload() ?: return null
return decrypt(payload)
}
override suspend fun close() {
val sas = olmSAS ?: throw IllegalStateException("Channel already closed")
olmSASMutex.withLock {
// this does a double release check already so we don't re-check ourselves
sas.releaseSas()
olmSAS = null
}
transport.close()
}
private fun encrypt(plainText: ByteArray): ECDHPayload {
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val cipherText = LinkedList<Byte>()
val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
val ivParameterSpec = IvParameterSpec(iv)
encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
cipherText.addAll(encryptCipher.update(plainText).toList())
cipherText.addAll(encryptCipher.doFinal().toList())
return ECDHPayload(
ciphertext = Base64.encodeToString(cipherText.toByteArray(), Base64.NO_WRAP),
iv = Base64.encodeToString(iv, Base64.NO_WRAP)
)
}
private fun decrypt(payload: ECDHPayload): ByteArray {
val iv = Base64.decode(payload.iv, Base64.NO_WRAP)
val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
val ivParameterSpec = IvParameterSpec(iv)
encryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
val plainText = LinkedList<Byte>()
plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList())
plainText.addAll(encryptCipher.doFinal().toList())
return plainText.toByteArray()
}
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ECDHRendezvous(
val transport: SimpleHttpRendezvousTransportDetails,
val algorithm: SecureRendezvousChannelAlgorithm,
val key: String
)

View File

@ -0,0 +1,25 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ECDHRendezvousCode(
val intent: RendezvousIntent,
val rendezvous: ECDHRendezvous
)

View File

@ -0,0 +1,38 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class Outcome(val value: String) {
@Json(name = "success")
SUCCESS("success"),
@Json(name = "declined")
DECLINED("declined"),
@Json(name = "unsupported")
UNSUPPORTED("unsupported"),
@Json(name = "verified")
VERIFIED("verified"),
@Json(name = "e2ee_security_error")
E2EE_SECURITY_ERROR("e2ee_security_error")
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class Payload(
val type: PayloadType,
val intent: RendezvousIntent? = null,
val outcome: Outcome? = null,
val protocols: List<Protocol>? = null,
val protocol: Protocol? = null,
val homeserver: String? = null,
@Json(name = "login_token") val loginToken: String? = null,
@Json(name = "device_id") val deviceId: String? = null,
@Json(name = "device_key") val deviceKey: String? = null,
@Json(name = "verifying_device_id") val verifyingDeviceId: String? = null,
@Json(name = "verifying_device_key") val verifyingDeviceKey: String? = null,
@Json(name = "master_key") val masterKey: String? = null
)

View File

@ -0,0 +1,32 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
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")
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class Protocol(val value: String) {
@Json(name = "org.matrix.msc3906.login_token")
LOGIN_TOKEN("org.matrix.msc3906.login_token")
}

View File

@ -0,0 +1,21 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
class RendezvousError(val description: String, val reason: RendezvousFailureReason) : Exception(description)

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class RendezvousIntent {
@Json(name = "login.start") LOGIN_ON_NEW_DEVICE,
@Json(name = "login.reciprocate") RECIPROCATE_LOGIN_ON_EXISTING_DEVICE
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,10 +14,11 @@
* limitations under the License.
*/
package im.vector.app.features.login.qr
package org.matrix.android.sdk.api.rendezvous.model
enum class QrCodeLoginErrorType {
DEVICE_IS_NOT_SUPPORTED,
TIMEOUT,
REQUEST_WAS_DENIED,
}
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
open class RendezvousTransportDetails(
val type: RendezvousTransportType
)

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class RendezvousTransportType(val value: String) {
@Json(name = "org.matrix.msc3886.http.v1")
MSC3886_SIMPLE_HTTP_V1("org.matrix.msc3886.http.v1")
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class SecureRendezvousChannelAlgorithm(val value: String) {
@Json(name = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SimpleHttpRendezvousTransportDetails(
val uri: String
) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1)

View File

@ -0,0 +1,173 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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.api.rendezvous.transports
import kotlinx.coroutines.delay
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
import org.matrix.android.sdk.api.rendezvous.RendezvousTransport
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails
import org.matrix.android.sdk.api.rendezvous.model.SimpleHttpRendezvousTransportDetails
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Implementation of the Simple HTTP transport MSC3886: https://github.com/matrix-org/matrix-spec-proposals/pull/3886
*/
class SimpleHttpRendezvousTransport(rendezvousUri: String?) : RendezvousTransport {
companion object {
private val TAG = LoggerTag(SimpleHttpRendezvousTransport::class.java.simpleName, LoggerTag.RENDEZVOUS).value
}
override var ready = false
private var cancelled = false
private var uri: String?
private var etag: String? = null
private var expiresAt: Date? = null
init {
uri = rendezvousUri
}
override suspend fun details(): RendezvousTransportDetails {
val uri = uri ?: throw IllegalStateException("Rendezvous not set up")
return SimpleHttpRendezvousTransportDetails(uri)
}
@Throws(RendezvousError::class)
override suspend fun send(contentType: MediaType, data: ByteArray) {
if (cancelled) {
throw IllegalStateException("Rendezvous cancelled")
}
val method = if (uri != null) "PUT" else "POST"
val uri = this.uri ?: throw RuntimeException("No rendezvous URI")
val httpClient = okhttp3.OkHttpClient.Builder().build()
val request = Request.Builder()
.url(uri)
.method(method, data.toRequestBody())
.header("content-type", contentType.toString())
etag?.let {
request.header("if-match", it)
}
val response = httpClient.newCall(request.build()).execute()
if (response.code == 404) {
throw get404Error()
}
etag = response.header("etag")
Timber.tag(TAG).i("Sent data to $uri new etag $etag")
if (method == "POST") {
val location = response.header("location") ?: throw RuntimeException("No rendezvous URI found in response")
response.header("expires")?.let {
val format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
expiresAt = format.parse(it)
}
// resolve location header which could be relative or absolute
this.uri = response.request.url.toUri().resolve(location).toString()
ready = true
}
}
@Throws(RendezvousError::class)
override suspend fun receive(): ByteArray? {
if (cancelled) {
throw IllegalStateException("Rendezvous cancelled")
}
val uri = uri ?: throw IllegalStateException("Rendezvous not set up")
val httpClient = okhttp3.OkHttpClient.Builder().build()
while (true) {
Timber.tag(TAG).i("Polling: $uri after etag $etag")
val request = Request.Builder()
.url(uri)
.get()
etag?.let {
request.header("if-none-match", it)
}
val response = httpClient.newCall(request.build()).execute()
try {
// expired
if (response.code == 404) {
throw get404Error()
}
// rely on server expiring the channel rather than checking ourselves
if (response.header("content-type") != "application/json") {
response.header("etag")?.let {
etag = it
}
} else if (response.code == 200) {
response.header("etag")?.let {
etag = it
}
return response.body?.bytes()
}
// sleep for a second before polling again
// we rely on the server expiring the channel rather than checking it ourselves
delay(1000)
} finally {
response.close()
}
}
}
private fun get404Error(): RendezvousError {
if (expiresAt != null && Date() > expiresAt) {
return RendezvousError("Expired", RendezvousFailureReason.Expired)
}
return RendezvousError("Received unexpected 404", RendezvousFailureReason.Unknown)
}
override suspend fun close() {
cancelled = true
ready = false
uri?.let {
try {
val httpClient = okhttp3.OkHttpClient.Builder().build()
val request = Request.Builder()
.url(it)
.delete()
.build()
httpClient.newCall(request).execute()
} catch (e: Throwable) {
Timber.tag(TAG).w(e, "Failed to delete channel")
}
}
}
}

View File

@ -82,6 +82,33 @@ internal abstract class SASDefaultVerificationTransaction(
// older devices have limited support of emoji but SDK offers images for the 64 verification emojis
// so always send that we support EMOJI
val KNOWN_SHORT_CODES = listOf(SasMode.EMOJI, SasMode.DECIMAL)
/**
* decimal: generate five bytes by using HKDF.
* Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive),
* and add 1000 (resulting in a number between 1000 and 9191 inclusive).
* Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers.
* In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000,
* the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000.
* (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic,
* and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.)
* The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers,
* or with the three numbers on separate lines.
*/
fun getDecimalCodeRepresentation(byteArray: ByteArray, separator: String = " "): String {
val b0 = byteArray[0].toUnsignedInt() // need unsigned byte
val b1 = byteArray[1].toUnsignedInt() // need unsigned byte
val b2 = byteArray[2].toUnsignedInt() // need unsigned byte
val b3 = byteArray[3].toUnsignedInt() // need unsigned byte
val b4 = byteArray[4].toUnsignedInt() // need unsigned byte
// (B0 << 5 | B1 >> 3) + 1000
val first = (b0.shl(5) or b1.shr(3)) + 1000
// ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000
val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000
// ((B3 & 0x3f) << 7 | B4 >> 1) + 1000
val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000
return "$first$separator$second$separator$third"
}
}
override var state: VerificationTxState = VerificationTxState.None
@ -371,33 +398,6 @@ internal abstract class SASDefaultVerificationTransaction(
return getDecimalCodeRepresentation(shortCodeBytes!!)
}
/**
* decimal: generate five bytes by using HKDF.
* Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive),
* and add 1000 (resulting in a number between 1000 and 9191 inclusive).
* Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers.
* In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000,
* the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000.
* (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic,
* and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.)
* The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers,
* or with the three numbers on separate lines.
*/
fun getDecimalCodeRepresentation(byteArray: ByteArray): String {
val b0 = byteArray[0].toUnsignedInt() // need unsigned byte
val b1 = byteArray[1].toUnsignedInt() // need unsigned byte
val b2 = byteArray[2].toUnsignedInt() // need unsigned byte
val b3 = byteArray[3].toUnsignedInt() // need unsigned byte
val b4 = byteArray[4].toUnsignedInt() // need unsigned byte
// (B0 << 5 | B1 >> 3) + 1000
val first = (b0.shl(5) or b1.shr(3)) + 1000
// ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000
val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000
// ((B3 & 0x3f) << 7 | B4 >> 1) + 1000
val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000
return "$first $second $third"
}
override fun getEmojiCodeRepresentation(): List<EmojiRepresentation> {
return getEmojiCodeRepresentation(shortCodeBytes!!)
}

View File

@ -22,4 +22,5 @@ sealed class QrCodeLoginAction : VectorViewModelAction {
data class OnQrCodeScanned(val qrCode: String) : QrCodeLoginAction()
object GenerateQrCode : QrCodeLoginAction()
object ShowQrCode : QrCodeLoginAction()
object TryAgain : QrCodeLoginAction()
}

View File

@ -24,7 +24,9 @@ import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.viewModel
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.SimpleFragmentActivity
import im.vector.app.features.home.HomeActivity
import im.vector.lib.core.utils.compat.getParcelableCompat
import timber.log.Timber
@ -37,32 +39,35 @@ class QrCodeLoginActivity : SimpleFragmentActivity() {
super.onCreate(savedInstanceState)
views.toolbar.visibility = View.GONE
val qrCodeLoginArgs: QrCodeLoginArgs? = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG)
if (isFirstCreation()) {
when (qrCodeLoginArgs?.loginType) {
QrCodeLoginType.LOGIN -> {
showInstructionsFragment(qrCodeLoginArgs)
}
QrCodeLoginType.LINK_A_DEVICE -> {
if (qrCodeLoginArgs.showQrCodeImmediately) {
handleNavigateToShowQrCodeScreen()
} else {
showInstructionsFragment(qrCodeLoginArgs)
}
}
null -> {
Timber.i("QrCodeLoginArgs is null. This is not expected.")
finish()
return
}
}
navigateToInitialFragment()
}
observeViewEvents()
}
private fun navigateToInitialFragment() {
val qrCodeLoginArgs: QrCodeLoginArgs? = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG)
when (qrCodeLoginArgs?.loginType) {
QrCodeLoginType.LOGIN -> {
showInstructionsFragment(qrCodeLoginArgs)
}
QrCodeLoginType.LINK_A_DEVICE -> {
if (qrCodeLoginArgs.showQrCodeImmediately) {
handleNavigateToShowQrCodeScreen()
} else {
showInstructionsFragment(qrCodeLoginArgs)
}
}
null -> {
Timber.i("QrCodeLoginArgs is null. This is not expected.")
finish()
}
}
}
private fun showInstructionsFragment(qrCodeLoginArgs: QrCodeLoginArgs) {
addFragment(
replaceFragment(
views.container,
QrCodeLoginInstructionsFragment::class.java,
qrCodeLoginArgs,
@ -75,10 +80,16 @@ class QrCodeLoginActivity : SimpleFragmentActivity() {
when (it) {
QrCodeLoginViewEvents.NavigateToStatusScreen -> handleNavigateToStatusScreen()
QrCodeLoginViewEvents.NavigateToShowQrCodeScreen -> handleNavigateToShowQrCodeScreen()
QrCodeLoginViewEvents.NavigateToHomeScreen -> handleNavigateToHomeScreen()
QrCodeLoginViewEvents.NavigateToInitialScreen -> handleNavigateToInitialScreen()
}
}
}
private fun handleNavigateToInitialScreen() {
navigateToInitialFragment()
}
private fun handleNavigateToShowQrCodeScreen() {
addFragment(
views.container,
@ -95,6 +106,11 @@ class QrCodeLoginActivity : SimpleFragmentActivity() {
)
}
private fun handleNavigateToHomeScreen() {
val intent = HomeActivity.newIntent(this, firstStartMainActivity = false, existingSession = true)
startActivity(intent)
}
companion object {
private const val FRAGMENT_QR_CODE_INSTRUCTIONS_TAG = "FRAGMENT_QR_CODE_INSTRUCTIONS_TAG"

View File

@ -16,9 +16,11 @@
package im.vector.app.features.login.qr
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
sealed class QrCodeLoginConnectionStatus {
object ConnectingToDevice : QrCodeLoginConnectionStatus()
data class Connected(val securityCode: String, val canConfirmSecurityCode: Boolean) : QrCodeLoginConnectionStatus()
object SigningIn : QrCodeLoginConnectionStatus()
data class Failed(val errorType: QrCodeLoginErrorType, val canTryAgain: Boolean) : QrCodeLoginConnectionStatus()
data class Failed(val errorType: RendezvousFailureReason, val canTryAgain: Boolean) : QrCodeLoginConnectionStatus()
}

View File

@ -63,6 +63,7 @@ class QrCodeLoginInstructionsFragment : VectorBaseFragment<FragmentQrCodeLoginIn
val scannedQrCode = QrCodeScannerActivity.getResultText(activityResult.data)
val wasQrCode = QrCodeScannerActivity.getResultIsQrCode(activityResult.data)
Timber.d("Scanned QR code: $scannedQrCode, was QR code: $wasQrCode")
if (wasQrCode && !scannedQrCode.isNullOrBlank()) {
onQrCodeScanned(scannedQrCode)
} else {
@ -76,7 +77,11 @@ class QrCodeLoginInstructionsFragment : VectorBaseFragment<FragmentQrCodeLoginIn
}
private fun onQrCodeScannerFailed() {
Timber.d("QrCodeLoginInstructionsFragment.onQrCodeScannerFailed")
// The user scanned something unexpected, so we try scanning again.
// This seems to happen particularly with the large QRs needed for rendezvous
// especially when the QR is partially off the screen
Timber.d("QrCodeLoginInstructionsFragment.onQrCodeScannerFailed - showing scanner again")
QrCodeScannerActivity.startForResult(requireActivity(), scanActivityResultLauncher)
}
override fun invalidate() = withState(viewModel) { state ->

View File

@ -28,6 +28,7 @@ import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentQrCodeLoginStatusBinding
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
@AndroidEntryPoint
class QrCodeLoginStatusFragment : VectorBaseFragment<FragmentQrCodeLoginStatusBinding>() {
@ -41,6 +42,13 @@ class QrCodeLoginStatusFragment : VectorBaseFragment<FragmentQrCodeLoginStatusBi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initCancelButton()
initTryAgainButton()
}
private fun initTryAgainButton() {
views.qrCodeLoginStatusTryAgainButton.debouncedClicks {
viewModel.handle(QrCodeLoginAction.TryAgain)
}
}
private fun initCancelButton() {
@ -58,18 +66,26 @@ class QrCodeLoginStatusFragment : VectorBaseFragment<FragmentQrCodeLoginStatusBi
views.qrCodeLoginStatusCancelButton.isVisible = true
views.qrCodeLoginStatusTryAgainButton.isVisible = connectionStatus.canTryAgain
views.qrCodeLoginStatusHeaderView.setTitle(getString(R.string.qr_code_login_header_failed_title))
views.qrCodeLoginStatusHeaderView.setDescription(getErrorCode(connectionStatus.errorType))
views.qrCodeLoginStatusHeaderView.setDescription(getErrorDescription(connectionStatus.errorType))
views.qrCodeLoginStatusHeaderView.setImage(
imageResource = R.drawable.ic_qr_code_login_failed,
backgroundTintColor = ThemeUtils.getColor(requireContext(), R.attr.colorError)
)
}
private fun getErrorCode(errorType: QrCodeLoginErrorType): String {
return when (errorType) {
QrCodeLoginErrorType.DEVICE_IS_NOT_SUPPORTED -> getString(R.string.qr_code_login_header_failed_device_is_not_supported_description)
QrCodeLoginErrorType.TIMEOUT -> getString(R.string.qr_code_login_header_failed_timeout_description)
QrCodeLoginErrorType.REQUEST_WAS_DENIED -> getString(R.string.qr_code_login_header_failed_denied_description)
private fun getErrorDescription(reason: RendezvousFailureReason): String {
return when (reason) {
RendezvousFailureReason.UnsupportedAlgorithm,
RendezvousFailureReason.UnsupportedTransport -> getString(R.string.qr_code_login_header_failed_device_is_not_supported_description)
RendezvousFailureReason.UnsupportedHomeserver -> getString(R.string.qr_code_login_header_failed_homeserver_is_not_supported_description)
RendezvousFailureReason.Expired -> getString(R.string.qr_code_login_header_failed_timeout_description)
RendezvousFailureReason.UserDeclined -> getString(R.string.qr_code_login_header_failed_denied_description)
RendezvousFailureReason.E2EESecurityIssue -> getString(R.string.qr_code_login_header_failed_e2ee_security_issue_description)
RendezvousFailureReason.OtherDeviceAlreadySignedIn -> getString(R.string.qr_code_login_header_failed_other_device_already_signed_in_description)
RendezvousFailureReason.OtherDeviceNotSignedIn -> getString(R.string.qr_code_login_header_failed_other_device_not_signed_in_description)
RendezvousFailureReason.InvalidCode -> getString(R.string.qr_code_login_header_failed_invalid_qr_code_description)
RendezvousFailureReason.UserCancelled -> getString(R.string.qr_code_login_header_failed_user_cancelled_description)
else -> getString(R.string.qr_code_login_header_failed_other_description)
}
}

View File

@ -21,4 +21,6 @@ import im.vector.app.core.platform.VectorViewEvents
sealed class QrCodeLoginViewEvents : VectorViewEvents {
object NavigateToStatusScreen : QrCodeLoginViewEvents()
object NavigateToShowQrCodeScreen : QrCodeLoginViewEvents()
object NavigateToHomeScreen : QrCodeLoginViewEvents()
object NavigateToInitialScreen : QrCodeLoginViewEvents()
}

View File

@ -20,13 +20,24 @@ 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.platform.VectorViewModel
import im.vector.app.core.session.ConfigureAndStartSessionUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.rendezvous.Rendezvous
import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import timber.log.Timber
class QrCodeLoginViewModel @AssistedInject constructor(
@Assisted private val initialState: QrCodeLoginViewState,
private val authenticationService: AuthenticationService,
private val activeSessionHolder: ActiveSessionHolder,
private val configureAndStartSessionUseCase: ConfigureAndStartSessionUseCase,
) : VectorViewModel<QrCodeLoginViewState, QrCodeLoginAction, QrCodeLoginViewEvents>(initialState) {
@AssistedFactory
@ -34,16 +45,28 @@ class QrCodeLoginViewModel @AssistedInject constructor(
override fun create(initialState: QrCodeLoginViewState): QrCodeLoginViewModel
}
companion object : MavericksViewModelFactory<QrCodeLoginViewModel, QrCodeLoginViewState> by hiltMavericksViewModelFactory()
companion object : MavericksViewModelFactory<QrCodeLoginViewModel, QrCodeLoginViewState> by hiltMavericksViewModelFactory() {
val TAG: String = QrCodeLoginViewModel::class.java.simpleName
}
override fun handle(action: QrCodeLoginAction) {
when (action) {
is QrCodeLoginAction.OnQrCodeScanned -> handleOnQrCodeScanned(action)
QrCodeLoginAction.GenerateQrCode -> handleQrCodeViewStarted()
QrCodeLoginAction.ShowQrCode -> handleShowQrCode()
QrCodeLoginAction.TryAgain -> handleTryAgain()
}
}
private fun handleTryAgain() {
setState {
copy(
connectionStatus = null
)
}
_viewEvents.post(QrCodeLoginViewEvents.NavigateToInitialScreen)
}
private fun handleShowQrCode() {
_viewEvents.post(QrCodeLoginViewEvents.NavigateToShowQrCodeScreen)
}
@ -58,20 +81,60 @@ class QrCodeLoginViewModel @AssistedInject constructor(
}
private fun handleOnQrCodeScanned(action: QrCodeLoginAction.OnQrCodeScanned) {
if (isValidQrCode(action.qrCode)) {
setState {
copy(
connectionStatus = QrCodeLoginConnectionStatus.ConnectingToDevice
)
Timber.tag(TAG).d("Scanned code of length ${action.qrCode.length}")
val rendezvous = try { Rendezvous.buildChannelFromCode(action.qrCode) } catch (t: Throwable) {
Timber.tag(TAG).e(t, "Error occurred during sign in")
if (t is RendezvousError) {
onFailed(t.reason)
} else {
onFailed(RendezvousFailureReason.Unknown)
}
return
}
setState {
copy(
connectionStatus = QrCodeLoginConnectionStatus.ConnectingToDevice
)
}
_viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen)
viewModelScope.launch(Dispatchers.IO) {
try {
val confirmationCode = rendezvous.startAfterScanningCode()
Timber.tag(TAG).i("Established secure channel with checksum: $confirmationCode")
onConnectionEstablished(confirmationCode)
val session = rendezvous.waitForLoginOnNewDevice(authenticationService)
onSigningIn()
activeSessionHolder.setActiveSession(session)
authenticationService.reset()
configureAndStartSessionUseCase.execute(session)
rendezvous.completeVerificationOnNewDevice(session)
_viewEvents.post(QrCodeLoginViewEvents.NavigateToHomeScreen)
} catch (t: Throwable) {
Timber.tag(TAG).e(t, "Error occurred during sign in")
if (t is RendezvousError) {
onFailed(t.reason)
} else {
onFailed(RendezvousFailureReason.Unknown)
}
}
_viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen)
}
}
private fun onFailed(errorType: QrCodeLoginErrorType, canTryAgain: Boolean) {
private fun onFailed(reason: RendezvousFailureReason) {
_viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen)
setState {
copy(
connectionStatus = QrCodeLoginConnectionStatus.Failed(errorType, canTryAgain)
connectionStatus = QrCodeLoginConnectionStatus.Failed(reason, reason.canRetry)
)
}
}
@ -93,14 +156,11 @@ class QrCodeLoginViewModel @AssistedInject constructor(
}
}
// TODO. Implement in the logic related PR.
private fun isValidQrCode(qrCode: String): Boolean {
Timber.d("isValidQrCode: $qrCode")
return false
}
// TODO. Implement in the logic related PR.
/**
* QR code generation is not currently supported and this is a placeholder for future
* functionality.
*/
private fun generateQrCodeData(): String {
return "TODO"
return "NOT SUPPORTED"
}
}