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/internal/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt new file mode 100644 index 0000000000..dd7b91582b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/Rendezvous.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel +import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.util.MatrixJsonParser +import org.matrix.android.sdk.internal.rendezvous.channels.ECDHRendezvousChannel +import org.matrix.android.sdk.internal.rendezvous.model.ECDHRendezvousCode +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousIntent +import org.matrix.android.sdk.internal.rendezvous.transports.SimpleHttpRendezvousTransport +import timber.log.Timber + +internal enum class PayloadType(val value: String) { + @Json(name = "m.login.start") Start("m.login.start"), + @Json(name = "m.login.finish") Finish("m.login.finish"), + @Json(name = "m.login.progress") Progress("m.login.progress") +} + +@JsonClass(generateAdapter = true) +internal data class Payload( + @Json val type: PayloadType, + @Json val intent: RendezvousIntent? = null, + @Json val outcome: String? = null, + @Json val protocols: List? = null, + @Json val protocol: String? = null, + @Json val homeserver: String? = null, + @Json val login_token: String? = null, + @Json val device_id: String? = null, + @Json val device_key: String? = null, + @Json val verifying_device_id: String? = null, + @Json val verifying_device_key: String? = null, + @Json val master_key: String? = null +) + +private val TAG = LoggerTag(Rendezvous::class.java.simpleName, LoggerTag.RENDEZVOUS).value + +data class Rendezvous( + val channel: RendezvousChannel, + val theirIntent: RendezvousIntent +) { + companion object { + fun buildChannelFromCode(code: String, onCancelled: (reason: RendezvousFailureReason) -> Unit): Rendezvous { + val parsed = MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code) ?: throw RuntimeException("Invalid code") + + val transport = SimpleHttpRendezvousTransport(onCancelled, parsed.rendezvous.transport.uri) + + return Rendezvous( + ECDHRendezvousChannel(transport, parsed.rendezvous.key), + parsed.intent + ) + } + } + + private val adapter = MatrixJsonParser.getMoshi().adapter(Payload::class.java) + // not yet implemented: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE + val ourIntent: RendezvousIntent = RendezvousIntent.LOGIN_ON_NEW_DEVICE + + private suspend fun areIntentsIncompatible(): Boolean { + val incompatible = theirIntent == ourIntent + + Timber.tag(TAG).d("ourIntent: $ourIntent, theirIntent: $theirIntent, incompatible: $incompatible") + + if (incompatible) { + send(Payload(PayloadType.Finish, intent = ourIntent)) + val reason = if (ourIntent == RendezvousIntent.LOGIN_ON_NEW_DEVICE) RendezvousFailureReason.OtherDeviceNotSignedIn else RendezvousFailureReason.OtherDeviceAlreadySignedIn + channel.cancel(reason) + } + + return incompatible + } + + suspend fun startAfterScanningCode(): String? { + val checksum = channel.connect(); + + Timber.tag(TAG).i("Connected to secure channel with checksum: $checksum") + + if (areIntentsIncompatible()) { + return null + } + + // get protocols + Timber.tag(TAG).i("Waiting for protocols"); + val protocolsResponse = receive() + + if (protocolsResponse?.protocols == null || !protocolsResponse.protocols.contains("login_token")) { + send(Payload(PayloadType.Finish, outcome = "unsupported")) + Timber.tag(TAG).i("No supported protocol") + cancel(RendezvousFailureReason.Unknown) + return null + } + + send(Payload(PayloadType.Progress, protocol = "login_token")) + + return checksum + } + + suspend fun completeOnNewDevice(): Session? { + Timber.tag(TAG).i("Waiting for login_token"); + + val loginToken = receive() + + if (loginToken?.type == PayloadType.Finish) { + when (loginToken.outcome) { + "declined" -> { + Timber.tag(TAG).i("Login declined by other device") + channel.cancel(RendezvousFailureReason.UserDeclined) + return null + } + "unsupported" -> { + Timber.tag(TAG).i("Not supported") + channel.cancel(RendezvousFailureReason.HomeserverLacksSupport) + return null + } + } + channel.cancel(RendezvousFailureReason.Unknown) + return null + } + + val homeserver = loginToken?.homeserver ?: throw RuntimeException("No homeserver returned") + val login_token = loginToken.login_token ?: throw RuntimeException("No login token returned") + + Timber.tag(TAG).i("Got login_token: $login_token for $homeserver"); + + // TODO: set view to be state logging in? + + // use token to login +// const login = await sendLoginRequest(homeserver, undefined, "m.login.token", { token: login_token }); +// +// await setLoggedIn(login); +// +// const { deviceId, userId } = login; +// +// const client = MatrixClientPeg.get(); +// + + val newSession: Session? = null + + newSession ?.let { + session -> + val userId = session.myUserId + val crypto = session.cryptoService() + val deviceId = crypto.getMyDevice().deviceId + val deviceKey = crypto.getMyDevice().fingerprint() + send(Payload(PayloadType.Progress, outcome = "success", device_id = deviceId, device_key = deviceKey)) + + // await confirmation of verification + + val verificationResponse = receive() + val verifyingDeviceId = verificationResponse?.verifying_device_id ?: throw RuntimeException("No verifying device id returned") + val verifyingDeviceFromServer = crypto.getCryptoDeviceInfo(userId, verifyingDeviceId) + if (verifyingDeviceFromServer?.fingerprint() == verificationResponse.verifying_device_key) { + // set other device as verified + Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified"); + crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId) + + verificationResponse.master_key ?.let { + // set master key as trusted + crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, it) + + } + + // request secrets from the verifying device + Timber.tag(TAG).i("Requesting secrets from $verifyingDeviceId") + + session.sharedSecretStorageService() .let { + it.requestSecret(verifyingDeviceId, MASTER_KEY_SSSS_NAME) + it.requestSecret(verifyingDeviceId, SELF_SIGNING_KEY_SSSS_NAME) + it.requestSecret(verifyingDeviceId, USER_SIGNING_KEY_SSSS_NAME) + it.requestSecret(verifyingDeviceId, KEYBACKUP_SECRET_SSSS_NAME) + } + } else { + Timber.tag(TAG).i("Verifying device $verifyingDeviceId doesn't match: $verifyingDeviceFromServer") + } + } + + return newSession + } + + private suspend fun receive(): Payload? { + val data = channel.receive()?: return null + return adapter.fromJson(data.toString(Charsets.UTF_8)) + } + + private suspend fun send(payload: Payload) { + channel.send(adapter.toJson(payload).toByteArray(Charsets.UTF_8)); + } + + suspend fun cancel(reason: RendezvousFailureReason) { + channel.cancel(reason) + } + + suspend fun close() { + channel.close() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousChannel.kt new file mode 100644 index 0000000000..43552f46be --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousChannel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous + +import org.matrix.android.sdk.internal.rendezvous.model.ECDHRendezvousCode +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousIntent + +interface RendezvousChannel { + var transport: RendezvousTransport; + /** + * @returns the checksum/confirmation digits to be shown to the user + */ + suspend fun connect(): String + /** + * Send a payload via the channel. + * @param data payload to send + */ + suspend fun send(data: ByteArray) + /** + * Receive a payload from the channel. + * @returns the received payload + */ + suspend fun receive(): ByteArray? + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + suspend fun close() + // TODO: this should be transport independent in the future + suspend fun generateCode(intent: RendezvousIntent): ECDHRendezvousCode + suspend fun cancel(reason: RendezvousFailureReason) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousFailureReason.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousFailureReason.kt new file mode 100644 index 0000000000..0e2ea8c758 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousFailureReason.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous + +enum class RendezvousFailureReason(val value: String, val canRetry: Boolean = true) { + UserDeclined("user_declined"), + OtherDeviceNotSignedIn("other_device_not_signed_in"), + OtherDeviceAlreadySignedIn("other_device_already_signed_in"), + Unknown("unknown"), + Expired("expired"), + UserCancelled("user_cancelled"), + InvalidCode("invalid_code"), + UnsupportedAlgorithm("unsupported_algorithm", false), + DataMismatch("data_mismatch"), + UnsupportedTransport("unsupported_transport", false), + HomeserverLacksSupport("homeserver_lacks_support", false) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousTransport.kt new file mode 100644 index 0000000000..753b0bc6fa --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/RendezvousTransport.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous + +import okhttp3.MediaType +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousTransportDetails + +interface RendezvousTransport { + var ready: Boolean; + var onCancelled: ((reason: RendezvousFailureReason) -> Unit)?; + suspend fun details(): RendezvousTransportDetails; + suspend fun send(contentType: MediaType, data: ByteArray); + suspend fun receive(): ByteArray?; + suspend fun cancel(reason: RendezvousFailureReason); +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/channels/ECDHRendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/channels/ECDHRendezvousChannel.kt new file mode 100644 index 0000000000..33837bc425 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/channels/ECDHRendezvousChannel.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.channels + +import android.util.Base64 +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import okhttp3.MediaType.Companion.toMediaType +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.util.MatrixJsonParser +import org.matrix.android.sdk.internal.extensions.toUnsignedInt +import org.matrix.android.sdk.internal.rendezvous.RendezvousFailureReason +import org.matrix.android.sdk.internal.rendezvous.RendezvousChannel +import org.matrix.android.sdk.internal.rendezvous.RendezvousTransport +import org.matrix.android.sdk.internal.rendezvous.model.ECDHRendezvous +import org.matrix.android.sdk.internal.rendezvous.model.ECDHRendezvousCode +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousError +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousIntent +import org.matrix.android.sdk.internal.rendezvous.model.SecureRendezvousChannelAlgorithm +import org.matrix.android.sdk.internal.rendezvous.transports.SimpleHttpRendezvousTransportDetails +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 + +@JsonClass(generateAdapter = true) +data class ECDHPayload( + @Json val algorithm: SecureRendezvousChannelAlgorithm? = null, + @Json val key: String? = null, + @Json val ciphertext: String? = null, + @Json val iv: String? = null +) + +private val TAG = LoggerTag(ECDHRendezvousChannel::class.java.simpleName, LoggerTag.RENDEZVOUS).value + +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" +} + +const val ALGORITHM_SPEC = "AES/GCM/NoPadding" +const val KEY_SPEC = "AES" + +/** + * 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 { + 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) + } + + override suspend fun connect(): String { + if (olmSAS == null) { + throw RuntimeException("Channel closed") + } + val isInitiator = theirPublicKey == null + + if (isInitiator) { + Timber.tag(TAG).i("Waiting for other device to send their public key") + val res = this.receiveAsPayload() ?: throw RuntimeException("No reply from other device") + + 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) + )) + } + + olmSAS!!.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 = olmSAS!!.generateShortCode(aesInfo, 32) + + Timber.tag(TAG).i("Our public key: ${Base64.encodeToString(ourPublicKey, Base64.NO_WRAP)}") + Timber.tag(TAG).i("Their public key: ${Base64.encodeToString(theirPublicKey, Base64.NO_WRAP)}") + Timber.tag(TAG).i("AES info: $aesInfo") + Timber.tag(TAG).i("AES key: ${Base64.encodeToString(aesKey, Base64.NO_WRAP)}") + + val rawChecksum = olmSAS!!.generateShortCode(aesInfo, 5) + return getDecimalCodeRepresentation(rawChecksum) + } + + 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 RuntimeException("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 RuntimeException("Shared secret not established") + } + val payload = receiveAsPayload() ?: return null + return decrypt(payload) + } + + override suspend fun generateCode(intent: RendezvousIntent): ECDHRendezvousCode { + return ECDHRendezvousCode( + intent, + rendezvous = ECDHRendezvous( + transport.details() as SimpleHttpRendezvousTransportDetails, + SecureRendezvousChannelAlgorithm.ECDH_V1, + key = Base64.encodeToString(ourPublicKey, Base64.NO_WRAP) + ) + ) + } + + override suspend fun cancel(reason: RendezvousFailureReason) { + try { + transport.cancel(reason) + } finally { + close() + } + } + + override suspend fun close() { + olmSAS?.releaseSas() + olmSAS = null + } + + private fun encrypt(plainText: ByteArray): ECDHPayload { + Timber.tag(TAG).i("Encrypting: ${plainText.toString(Charsets.UTF_8)}") + 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()) + + val plainTextBytes = plainText.toByteArray() + + Timber.tag(TAG).i("Decrypted: ${plainTextBytes.toString(Charsets.UTF_8)}") + return plainTextBytes + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/ECDHRendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/ECDHRendezvous.kt new file mode 100644 index 0000000000..e296dce09d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/ECDHRendezvous.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.internal.rendezvous.transports.SimpleHttpRendezvousTransportDetails + +@JsonClass(generateAdapter = true) +data class ECDHRendezvous( + @Json val transport: SimpleHttpRendezvousTransportDetails, + @Json val algorithm: SecureRendezvousChannelAlgorithm, + @Json val key: String +) + +@JsonClass(generateAdapter = true) +data class ECDHRendezvousCode( + @Json val intent: RendezvousIntent, + @Json val rendezvous: ECDHRendezvous +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/EmbeddedRendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/EmbeddedRendezvous.kt new file mode 100644 index 0000000000..d490de0133 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/EmbeddedRendezvous.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +open class EmbeddedRendezvous( + @Json(name = "transport") val transport: RendezvousTransportDetails, + @Json(name = "algorithm") val algorithm: SecureRendezvousChannelAlgorithm +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousError.kt new file mode 100644 index 0000000000..ead273e8ce --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousError.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import org.matrix.android.sdk.internal.rendezvous.RendezvousFailureReason + +class RendezvousError(val description: String, val reason: RendezvousFailureReason): RuntimeException(description) { +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousIntent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousIntent.kt new file mode 100644 index 0000000000..6285c1e57a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousIntent.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json + +enum class RendezvousIntent { + @Json(name = "login.start") LOGIN_ON_NEW_DEVICE, + @Json(name = "login.reciprocate") RECIPROCATE_LOGIN_ON_EXISTING_DEVICE +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportDetails.kt new file mode 100644 index 0000000000..1b1826194f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportDetails.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +open class RendezvousTransportDetails( + @Json val type: RendezvousTransportType +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportType.kt new file mode 100644 index 0000000000..c3b6ba7ac8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/RendezvousTransportType.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json + +enum class RendezvousTransportType(val value: String) { + @Json(name = "http.v1") MSC3886_SIMPLE_HTTP_V1("http.v1") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/SecureRendezvousChannelAlgorithm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/SecureRendezvousChannelAlgorithm.kt new file mode 100644 index 0000000000..ddc0ae20e7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/model/SecureRendezvousChannelAlgorithm.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.model + +import com.squareup.moshi.Json + +enum class SecureRendezvousChannelAlgorithm(val value: String) { + @Json(name = "m.rendezvous.v1.curve25519-aes-sha256") ECDH_V1("m.rendezvous.v1.curve25519-aes-sha256") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/transports/SimpleHttpRendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/transports/SimpleHttpRendezvousTransport.kt new file mode 100644 index 0000000000..3e5e1121ea --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/rendezvous/transports/SimpleHttpRendezvousTransport.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.rendezvous.transports + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +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.internal.rendezvous.RendezvousFailureReason +import org.matrix.android.sdk.internal.rendezvous.RendezvousTransport +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousTransportDetails +import org.matrix.android.sdk.internal.rendezvous.model.RendezvousTransportType +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date + +private val TAG = LoggerTag(SimpleHttpRendezvousTransport::class.java.simpleName, LoggerTag.RENDEZVOUS).value + +@JsonClass(generateAdapter = true) +data class SimpleHttpRendezvousTransportDetails( + @Json val uri: String +): RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1) + +/** + * Implementation of the Simple HTTP transport MSC3886: https://github.com/matrix-org/matrix-spec-proposals/pull/3886 + */ +class SimpleHttpRendezvousTransport(override var onCancelled: ((reason: RendezvousFailureReason) -> Unit)?, rendezvousUri: String?) : RendezvousTransport { + 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) + } + + override suspend fun send(contentType: MediaType, data: ByteArray) { + if (cancelled) { + return + } + + val method = if (uri != null) "PUT" else "POST" + // TODO: properly determine endpoint + val uri = if (uri != null) uri!! else "https://rendezvous.lab.element.dev" + + Timber.tag(TAG).i("Sending data: ${data.toString(Charsets.UTF_8)} to $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) { + cancel(RendezvousFailureReason.Unknown) + } + 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") + expiresAt = format.parse(it) + } + + // resolve location header which could be relative or absolute + this.uri = response.request.url.toUri().resolve(location).toString() + ready = true + } + } + + override suspend fun receive(): ByteArray? { + val uri = uri ?: throw IllegalStateException("Rendezvous not set up") + var done = false + val httpClient = okhttp3.OkHttpClient.Builder().build() + while (!done) { + if (cancelled) { + return null + } + 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 { + + Timber.tag(TAG).i("Received polling response: ${response.code} from $uri") + + if (response.code == 404) { + cancel(RendezvousFailureReason.Unknown) + return null + } + + // 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 + } + val data = response.body?.bytes() + Timber.tag(TAG).i("Received data: ${data?.toString(Charsets.UTF_8)} from $uri with etag $etag") + return data + } + + done = false + delay(1000) + } finally { + response.close() + } + } + + return null + } + + override suspend fun cancel(reason: RendezvousFailureReason) { + var mappedReason = reason + if (mappedReason == RendezvousFailureReason.Unknown && + expiresAt != null && Date() > expiresAt) { + mappedReason = RendezvousFailureReason.Expired + } + + cancelled = true + ready = false + onCancelled ?.let { it(mappedReason) } + + if (mappedReason == RendezvousFailureReason.UserDeclined) { + uri ?.let { + try { + val httpClient = okhttp3.OkHttpClient.Builder().build() + val request = Request.Builder() + .url(it) + .delete() + .build() + httpClient.newCall(request).execute() + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to delete channel") + } + } + } + } +}