Skip to content

Commit

Permalink
Merge pull request #7369 from vector-im/feature/hughns/qr_code_login
Browse files Browse the repository at this point in the history
Implement logic for sign in with QR
  • Loading branch information
Johennes authored Oct 19, 2022
2 parents 767be72 + 025a89f commit 451f5f8
Show file tree
Hide file tree
Showing 29 changed files with 1,202 additions and 80 deletions.
1 change: 1 addition & 0 deletions changelog.d/7369.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add logic for sign in with QR code
13 changes: 10 additions & 3 deletions library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3386,9 +3386,16 @@
<string name="qr_code_login_header_failed_device_is_not_supported_description">Linking with this device is not supported.</string>
<string name="qr_code_login_header_failed_timeout_description">The linking wasn’t completed in the required time.</string>
<string name="qr_code_login_header_failed_denied_description">The request was denied on the other device.</string>
<string name="qr_code_login_new_device_instruction_1">Open ${app_name} on your other device</string>
<string name="qr_code_login_new_device_instruction_2">Go to Settings -> Security &amp; Privacy -> Show All Sessions</string>
<string name="qr_code_login_new_device_instruction_3">Select \'Show QR code in this device\'</string>
<string name="qr_code_login_header_failed_other_description">The request failed.</string>
<string name="qr_code_login_header_failed_e2ee_security_issue_description">A security issue was encountered setting up secure messaging. One of the following may be compromised: Your homeserver; Your internet connection(s); Your device(s);</string>
<string name="qr_code_login_header_failed_other_device_already_signed_in_description">The other device is already signed in.</string>
<string name="qr_code_login_header_failed_other_device_not_signed_in_description">The other device must be signed in.</string>
<string name="qr_code_login_header_failed_invalid_qr_code_description">That QR code is invalid.</string>
<string name="qr_code_login_header_failed_user_cancelled_description">The sign in was cancelled on the other device.</string>
<string name="qr_code_login_header_failed_homeserver_is_not_supported_description">The homeserver doesn\'t support sign in with QR code.</string>
<string name="qr_code_login_new_device_instruction_1">Open the app on your other device</string>
<string name="qr_code_login_new_device_instruction_2">Go to Settings -> Security &amp; Privacy</string>
<string name="qr_code_login_new_device_instruction_3">Select \'Show QR code\'</string>
<string name="qr_code_login_link_a_device_scan_qr_code_instruction_1">Start at the sign in screen</string>
<string name="qr_code_login_link_a_device_scan_qr_code_instruction_2">Select \'Sign in with QR code\'</string>
<string name="qr_code_login_link_a_device_show_qr_code_instruction_1">Start at the sign in screen</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit 451f5f8

Please sign in to comment.