diff --git a/changelog.d/7369.feature b/changelog.d/7369.feature new file mode 100644 index 0000000000..240fac3516 --- /dev/null +++ b/changelog.d/7369.feature @@ -0,0 +1 @@ +Add logic for sign in with QR code diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index fac36ffa52..e6714005a1 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3386,9 +3386,16 @@ Linking with this device is not supported. The linking wasn’t completed in the required time. The request was denied on the other device. - Open ${app_name} on your other device - Go to Settings -> Security & Privacy -> Show All Sessions - Select \'Show QR code in this device\' + The request failed. + 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); + The other device is already signed in. + The other device must be signed in. + That QR code is invalid. + The sign in was cancelled on the other device. + The homeserver doesn\'t support sign in with QR code. + Open the app on your other device + Go to Settings -> Security & Privacy + Select \'Show QR code\' Start at the sign in screen Select \'Sign in with QR code\' Start at the sign in screen diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt index ae65963f37..22af8cebbd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt new file mode 100644 index 0000000000..f724ac4b62 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt @@ -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() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt new file mode 100644 index 0000000000..0956a5b0a0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt @@ -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() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt new file mode 100644 index 0000000000..18e625d825 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt @@ -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) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt new file mode 100644 index 0000000000..81632e951a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt @@ -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() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt new file mode 100644 index 0000000000..c1d6b1b70e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt @@ -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() + + 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() + plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList()) + plainText.addAll(encryptCipher.doFinal().toList()) + + return plainText.toByteArray() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt new file mode 100644 index 0000000000..55bac6397e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt new file mode 100644 index 0000000000..575b5d4bfd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt new file mode 100644 index 0000000000..0ebd1f88b3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt @@ -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") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt new file mode 100644 index 0000000000..04631ce959 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt @@ -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? = 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 +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt new file mode 100644 index 0000000000..33beb1f525 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt @@ -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") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt new file mode 100644 index 0000000000..6fce2fa11c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt @@ -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") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt new file mode 100644 index 0000000000..c52b11a322 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt new file mode 100644 index 0000000000..65037e1252 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt @@ -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 +} diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt similarity index 69% rename from vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt index 9a6cc13de0..1bde43ab7e 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt new file mode 100644 index 0000000000..6fca7efa71 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt @@ -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") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt new file mode 100644 index 0000000000..75f0024fda --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt @@ -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") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt new file mode 100644 index 0000000000..049aa8b756 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt new file mode 100644 index 0000000000..620b599e3d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt @@ -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") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt index 1cbaff059a..29b416bb82 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt @@ -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 { return getEmojiCodeRepresentation(shortCodeBytes!!) } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt index 8854d0720f..5ea46d3dcd 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt @@ -22,4 +22,5 @@ sealed class QrCodeLoginAction : VectorViewModelAction { data class OnQrCodeScanned(val qrCode: String) : QrCodeLoginAction() object GenerateQrCode : QrCodeLoginAction() object ShowQrCode : QrCodeLoginAction() + object TryAgain : QrCodeLoginAction() } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt index f5fd17c0c8..a0c113224d 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt @@ -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" diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt index 330562b874..4bef41b6c1 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt index efd23f2530..40fcbbbb85 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt @@ -63,6 +63,7 @@ class QrCodeLoginInstructionsFragment : VectorBaseFragment diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt index a9c589e469..6ef261e6d9 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt @@ -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() { @@ -41,6 +42,13 @@ class QrCodeLoginStatusFragment : VectorBaseFragment 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) } } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt index dc258408e7..e20ea6b2e8 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt @@ -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() } 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 e979ffa63c..97cca9d791 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 @@ -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(initialState) { @AssistedFactory @@ -34,16 +45,28 @@ class QrCodeLoginViewModel @AssistedInject constructor( override fun create(initialState: QrCodeLoginViewState): QrCodeLoginViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory 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" } }