Skip to content

Support for v2 of MSC3903 #8123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/8123.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updates to protocol used for Sign in with QR code
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2023 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.amshove.kluent.invoking
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldThrow
import org.amshove.kluent.with
import org.junit.Test
import org.matrix.android.sdk.InstrumentedTest
import org.matrix.android.sdk.api.rendezvous.channels.ECDHRendezvousChannel
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.common.CommonTestHelper

class RendezvousTest : InstrumentedTest {

@Test
fun shouldSuccessfullyBuildChannels() = CommonTestHelper.runCryptoTest(context()) { _, _ ->
val cases = listOf(
// v1:
"{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
"\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
"{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
"\"intent\":\"login.reciprocate\"}",
// v2:
"{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256\"," +
"\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
"{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
"\"intent\":\"login.reciprocate\"}",
)

cases.forEach { input ->
Rendezvous.buildChannelFromCode(input).channel shouldBeInstanceOf ECDHRendezvousChannel::class
}
}

@Test
fun shouldFailToBuildChannelAsUnsupportedAlgorithm() {
invoking {
Rendezvous.buildChannelFromCode(
"{\"rendezvous\":{\"algorithm\":\"bad algo\"," +
"\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
"{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
"\"intent\":\"login.reciprocate\"}"
)
} shouldThrow RendezvousError::class with {
this.reason shouldBeEqualTo RendezvousFailureReason.UnsupportedAlgorithm
}
}

@Test
fun shouldFailToBuildChannelAsUnsupportedTransport() {
invoking {
Rendezvous.buildChannelFromCode(
"{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
"\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
"{\"type\":\"bad transport\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
"\"intent\":\"login.reciprocate\"}"
)
} shouldThrow RendezvousError::class with {
this.reason shouldBeEqualTo RendezvousFailureReason.UnsupportedTransport
}
}

@Test
fun shouldFailToBuildChannelWithInvalidIntent() {
invoking {
Rendezvous.buildChannelFromCode(
"{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
"\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
"{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
"\"intent\":\"foo\"}"
)
} shouldThrow RendezvousError::class with {
this.reason shouldBeEqualTo RendezvousFailureReason.InvalidCode
}
}

@Test
fun shouldFailToBuildChannelAsInvalidCode() {
val cases = listOf(
"{}",
"rubbish",
""
)

cases.forEach { input ->
invoking {
Rendezvous.buildChannelFromCode(input)
} shouldThrow RendezvousError::class with {
this.reason shouldBeEqualTo RendezvousFailureReason.InvalidCode
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ 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.RendezvousCode
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.model.RendezvousTransportType
import org.matrix.android.sdk.api.rendezvous.model.SecureRendezvousChannelAlgorithm
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
Expand All @@ -53,18 +56,37 @@ class Rendezvous(

@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
// we first check that the code is valid JSON and has right high-level structure
val genericParsed = try {
// we rely on moshi validating the code and throwing exception if invalid JSON or algorithm doesn't match
MatrixJsonParser.getMoshi().adapter(RendezvousCode::class.java).fromJson(code)
} catch (a: Throwable) {
throw RendezvousError("Malformed code", RendezvousFailureReason.InvalidCode)
} ?: throw RendezvousError("Code is null", RendezvousFailureReason.InvalidCode)

// then we check that algorithm is supported
if (!SecureRendezvousChannelAlgorithm.values().map { it.value }.contains(genericParsed.rendezvous.algorithm)) {
throw RendezvousError("Unsupported algorithm", RendezvousFailureReason.UnsupportedAlgorithm)
}

// and, that the transport is supported
if (!RendezvousTransportType.values().map { it.value }.contains(genericParsed.rendezvous.transport.type)) {
throw RendezvousError("Unsupported transport", RendezvousFailureReason.UnsupportedTransport)
}

// now that we know the overall structure looks sensible, we rely on moshi validating the code and
// throwing exception if other parts are invalid
val supportedParsed = try {
MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code)
} catch (a: Throwable) {
throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
} ?: throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
throw RendezvousError("Malformed ECDH rendezvous code", RendezvousFailureReason.InvalidCode)
} ?: throw RendezvousError("ECDH rendezvous code is null", RendezvousFailureReason.InvalidCode)

val transport = SimpleHttpRendezvousTransport(parsed.rendezvous.transport.uri)
val transport = SimpleHttpRendezvousTransport(supportedParsed.rendezvous.transport.uri)

return Rendezvous(
ECDHRendezvousChannel(transport, parsed.rendezvous.key),
parsed.intent
ECDHRendezvousChannel(transport, supportedParsed.rendezvous.algorithm, supportedParsed.rendezvous.key),
supportedParsed.intent
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ 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 {
class ECDHRendezvousChannel(
override var transport: RendezvousTransport,
private val algorithm: SecureRendezvousChannelAlgorithm,
theirPublicKeyBase64: String?,
) : RendezvousChannel {
companion object {
private const val ALGORITHM_SPEC = "AES/GCM/NoPadding"
private const val KEY_SPEC = "AES"
Expand All @@ -53,7 +57,7 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
val algorithm: SecureRendezvousChannelAlgorithm? = null,
val key: String? = null,
val ciphertext: String? = null,
val iv: String? = null
val iv: String? = null,
)

private val olmSASMutex = Mutex()
Expand All @@ -65,10 +69,22 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu

init {
theirPublicKeyBase64?.let {
theirPublicKey = Base64.decode(it, Base64.NO_WRAP)
theirPublicKey = decodeBase64(it)
}
olmSAS = OlmSAS()
ourPublicKey = Base64.decode(olmSAS!!.publicKey, Base64.NO_WRAP)
ourPublicKey = decodeBase64(olmSAS!!.publicKey)
}

fun encodeBase64(input: ByteArray?): String? {
if (algorithm == SecureRendezvousChannelAlgorithm.ECDH_V2) {
return Base64.encodeToString(input, Base64.NO_WRAP or Base64.NO_PADDING)
}
return Base64.encodeToString(input, Base64.NO_WRAP)
}

fun decodeBase64(input: String?): ByteArray {
// for decoding we aren't concerned about padding
return Base64.decode(input, Base64.NO_WRAP)
}

@Throws(RendezvousError::class)
Expand All @@ -86,25 +102,25 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
RendezvousFailureReason.UnsupportedAlgorithm,
)
}
theirPublicKey = Base64.decode(res.key, Base64.NO_WRAP)
theirPublicKey = decodeBase64(res.key)
} 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)
algorithm = algorithm,
key = encodeBase64(ourPublicKey)
)
)
}

olmSASMutex.withLock {
sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
sas.setTheirPublicKey(encodeBase64(theirPublicKey))
sas.setTheirPublicKey(encodeBase64(theirPublicKey))

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"
val initiatorKey = encodeBase64(if (isInitiator) ourPublicKey else theirPublicKey)
val recipientKey = encodeBase64(if (isInitiator) theirPublicKey else ourPublicKey)
val aesInfo = "${algorithm.value}|$initiatorKey|$recipientKey"

aesKey = sas.generateShortCode(aesInfo, 32)

Expand Down Expand Up @@ -162,20 +178,20 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
cipherText.addAll(encryptCipher.doFinal().toList())

return ECDHPayload(
ciphertext = Base64.encodeToString(cipherText.toByteArray(), Base64.NO_WRAP),
iv = Base64.encodeToString(iv, Base64.NO_WRAP)
ciphertext = encodeBase64(cipherText.toByteArray()),
iv = encodeBase64(iv)
)
}

private fun decrypt(payload: ECDHPayload): ByteArray {
val iv = Base64.decode(payload.iv, Base64.NO_WRAP)
val iv = decodeBase64(payload.iv)
val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
val ivParameterSpec = IvParameterSpec(iv)
encryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)

val plainText = LinkedList<Byte>()
plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList())
plainText.addAll(encryptCipher.update(decodeBase64(payload.ciphertext)).toList())
plainText.addAll(encryptCipher.doFinal().toList())

return plainText.toByteArray()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2023 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)
open class Rendezvous(
val transport: RendezvousTransportDetails,
val algorithm: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2023 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)
open class RendezvousCode(
open val intent: RendezvousIntent,
open val rendezvous: Rendezvous
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
open class RendezvousTransportDetails(
val type: RendezvousTransportType
val type: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ 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")
ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256"),
@Json(name = "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256")
ECDH_V2("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256")
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SimpleHttpRendezvousTransportDetails(
val uri: String
) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1)
) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1.name)