diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 313a6c79075..57a0833a6cd 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file. - [**breaking**] Add `authentication::oauth::qrcode::login::LoginProgress::SyncingSecrets` to indicate that secrets are being synced between the two devices. ([#5760](https://github.com/matrix-org/matrix-rust-sdk/pull/5760)) +- Add `authentication::oauth::OAuth::grant_login_with_qr_code` to reciprocate a login by + generating a QR code on the existing device. + ([#5801](https://github.com/matrix-org/matrix-rust-sdk/pull/5801)) ### Refactor diff --git a/crates/matrix-sdk/src/authentication/oauth/mod.rs b/crates/matrix-sdk/src/authentication/oauth/mod.rs index 98c1c1f232d..ae661e23609 100644 --- a/crates/matrix-sdk/src/authentication/oauth/mod.rs +++ b/crates/matrix-sdk/src/authentication/oauth/mod.rs @@ -225,6 +225,8 @@ use self::{ registration::{ClientMetadata, ClientRegistrationResponse, register_client}, }; use super::{AuthData, SessionTokens}; +#[cfg(feature = "e2e-encryption")] +use crate::authentication::oauth::qrcode::{ReciprocateQrCodeAuthError, scanned}; use crate::{Client, HttpError, RefreshTokenError, Result, client::SessionChange, executor::spawn}; pub(crate) struct OAuthCtx { @@ -364,7 +366,7 @@ impl OAuth { as_variant!(data, AuthData::OAuth) } - /// Log in using a QR code. + /// Log in this device using a QR code. /// /// # Arguments /// @@ -380,6 +382,12 @@ impl OAuth { LoginWithQrCodeBuilder { client: &self.client, registration_data } } + /// Grant login to a new device using a QR code. + #[cfg(feature = "e2e-encryption")] + pub fn grant_login_with_qr_code<'a>(&'a self) -> GrantLoginWithQrCodeBuilder<'a> { + GrantLoginWithQrCodeBuilder { client: &self.client } + } + /// Restore or register the OAuth 2.0 client for the server with the given /// metadata, with the given optional [`ClientRegistrationData`]. /// @@ -1505,6 +1513,82 @@ impl<'a> LoginWithQrCodeBuilder<'a> { } } +/// Builder for QR login grant handlers. +#[cfg(feature = "e2e-encryption")] +#[derive(Debug)] +pub struct GrantLoginWithQrCodeBuilder<'a> { + /// The underlying Matrix API client. + client: &'a Client, +} + +#[cfg(feature = "e2e-encryption")] +impl<'a> GrantLoginWithQrCodeBuilder<'a> { + /// Grant login by generating a QR code on this device to be scanned by the + /// new device. + /// + /// # Example + /// + /// ```no_run + /// use anyhow::bail; + /// use futures_util::StreamExt; + /// use matrix_sdk::{ + /// Client, authentication::oauth::{ + /// qrcode::scanned::GrantLoginProgress + /// } + /// }; + /// use std::io::stdin; + /// # _ = async { + /// // Build the client as usual. + /// let client = Client::builder() + /// .server_name_or_homeserver_url("matrix.org") + /// .handle_refresh_tokens() + /// .build() + /// .await?; + /// + /// let oauth = client.oauth(); + /// + /// // Subscribing to the progress is necessary since we need to capture the + /// // verification URL and open it in a browser to let the user consent to + /// // the new login. + /// let mut handler = oauth.grant_login_with_qr_code().scanned().await?; + /// let mut progress = handler.subscribe_to_progress(); + /// + /// // Create a task which will show us the progress and allows us to capture + /// // and open the verification URL. + /// let task = tokio::spawn(async move { + /// while let Some(state) = progress.next().await { + /// match state { + /// GrantLoginProgress::Created | GrantLoginProgress::WaitingForCheckCode | GrantLoginProgress::SyncingSecrets => (), + /// GrantLoginProgress::WaitingForAuth { verification_uri } => { + /// println!("Please open {verification_uri} to confirm the new login") + /// }, + /// GrantLoginProgress::Done => break, + /// } + /// } + /// }); + /// + /// // Now wait for the other device to scan the QR code + /// println!("Please scan the QR code on your other device: {:?}", handler.qr_code_data()); + /// handler.wait_for_scan().await?; + /// + /// // Let the user put in the checkcode so we can verify the channel is secure and + /// // then attempt to finish the grant. + /// println!("Please enter the code displayed on your other device"); + /// let mut s = String::new(); + /// stdin().read_line(&mut s).unwrap(); + /// let check_code = s.trim().parse::().unwrap(); + /// handler.confirm_check_code_and_finish(check_code).await; + /// + /// // Now wait for the process to finish. + /// task.await; + /// + /// println!("Successfully logged in: {:?} {:?}", client.user_id(), client.device_id()); + /// # anyhow::Ok(()) }; + /// ``` + pub async fn scanned(&self) -> Result { + scanned::GrantLoginHandler::new(self.client.clone()).await + } +} /// A full session for the OAuth 2.0 API. #[derive(Debug, Clone)] pub struct OAuthSession { diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs index 64bee7dec86..70b4af3cb06 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs @@ -666,7 +666,7 @@ mod test { server.mock_query_keys().ok().expect(1).named("query_keys").mount().await; let client = HttpClient::new(reqwest::Client::new(), Default::default()); - let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url) .await .expect("Alice should be able to create a secure channel."); @@ -1043,7 +1043,7 @@ mod test { server.mock_who_am_i().ok().named("whoami").mount().await; let client = HttpClient::new(reqwest::Client::new(), Default::default()); - let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url) .await .expect("Alice should be able to create a secure channel."); @@ -1325,7 +1325,7 @@ mod test { server.mock_who_am_i().ok().named("whoami").mount().await; let client = HttpClient::new(reqwest::Client::new(), Default::default()); - let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url) .await .expect("Alice should be able to create a secure channel."); diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs index 9f7a9f66bda..a778b978962 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs @@ -37,6 +37,7 @@ pub use vodozemac::ecies::{Error as EciesError, MessageDecodeError}; mod login; mod messages; +mod reciprocate; mod rendezvous_channel; mod secure_channel; @@ -46,6 +47,7 @@ pub use self::{ LoginWithGeneratedQrCode, LoginWithQrCode, QrProgress, }, messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage}, + reciprocate::{Error as ReciprocateQrCodeAuthError, scanned}, }; use super::CrossProcessRefreshLockError; #[cfg(doc)] diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/mod.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/mod.rs new file mode 100644 index 00000000000..d7a73955a56 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/mod.rs @@ -0,0 +1,223 @@ +// Copyright 2025 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. + +use std::time::{Duration, Instant}; + +use eyeball::SharedObservable; +use matrix_sdk_base::crypto::{store::SecretsBundleExportError, types::SecretsBundle}; +use oauth2::VerificationUriComplete; +use thiserror::Error; +use url::Url; + +use super::{ + LoginProtocolType, QrAuthMessage, SecureChannelError, + secure_channel::{AlmostEstablishedSecureChannel, EstablishedSecureChannel, SecureChannel}, +}; +use crate::{Client, authentication::oauth::qrcode::LoginFailureReason}; + +pub mod scanned; + +/// Error type for failures related to reciprocating a login. +#[derive(Debug, Error)] +pub enum Error { + /// Secrets backup not set up. + #[error("Secrets backup not set up")] + MissingSecretsBackup(Option), + + /// The request took too long to complete. + #[error("The request took too long to complete")] + Timeout, + + /// The check code was incorrect. + #[error("The check code was incorrect")] + InvalidCheckCode, + + /// The device could not be created. + #[error("The device could not be created")] + UnableToCreateDevice, + + /// Auth handshake error. + #[error("Auth handshake error: {0}")] + Unknown(String), + + /// Unsupported protocol. + #[error("Unsupported protocol: {0}")] + UnsupportedProtocol(LoginProtocolType), + + /// The requested device ID is already in use. + #[error("The requested device ID is already in use")] + DeviceIDAlreadyInUse, + + /// Invalid state. + #[error("Invalid state")] + InvalidState, +} + +impl From for Error { + fn from(e: SecureChannelError) -> Self { + match e { + SecureChannelError::InvalidCheckCode => Self::InvalidCheckCode, + e => Self::Unknown(e.to_string()), + } + } +} + +impl From for Error { + fn from(e: SecretsBundleExportError) -> Self { + Self::MissingSecretsBackup(Some(e)) + } +} + +/// The progress of the reciprocation process. +#[derive(Debug)] +pub enum GrantLoginProgress { + /// The secure channel has been confirmed and this device is waiting for the + /// authorization to complete. + WaitingForAuth { + /// A URI to open in a (secure) system browser to verify the new login. + verification_uri: Url, + }, + /// The new device has been granted access and this device is sending the + /// secrets to it. + SyncingSecrets, + /// The process is complete. + Done, +} + +async fn export_secrets_bundle(client: &Client) -> Result { + let secrets_bundle = client + .olm_machine() + .await + .as_ref() + .ok_or_else(|| Error::MissingSecretsBackup(None))? + .store() + .export_secrets_bundle() + .await?; + Ok(secrets_bundle) +} + +async fn finish_login_grant>( + client: &Client, + channel: &mut EstablishedSecureChannel, + secrets_bundle: &SecretsBundle, + state: &SharedObservable

, +) -> Result<(), Error> { + // The new device registers with the authorization server and sends it a device + // authorization authorization request. + // -- MSC4108 OAuth 2.0 login step 2 + + // We wait for the new device to send us the m.login.protocol message with the + // device authorization grant information. -- MSC4108 OAuth 2.0 login step 3 + let message = channel.receive_json().await?; + let QrAuthMessage::LoginProtocol { device_authorization_grant, protocol, device_id } = message + else { + return Err(Error::Unknown( + "Receiving unexpected message when expecting LoginProtocol".to_owned(), + )); + }; + + // We verify the selected protocol. + // -- MSC4108 OAuth 2.0 login step 4 + if protocol != LoginProtocolType::DeviceAuthorizationGrant { + channel + .send_json(QrAuthMessage::LoginFailure { + reason: LoginFailureReason::UnsupportedProtocol, + homeserver: None, + }) + .await?; + return Err(Error::UnsupportedProtocol(protocol)); + } + + // We check that the device ID is still available. + // -- MSC4108 OAuth 2.0 login step 4 continued + if !matches!(client.device_exists(device_id.to_base64().into()).await, Ok(false)) { + channel + .send_json(QrAuthMessage::LoginFailure { + reason: LoginFailureReason::DeviceAlreadyExists, + homeserver: None, + }) + .await?; + return Err(Error::DeviceIDAlreadyInUse); + } + + // We emit an update so that the caller can open the verification URI in a + // system browser to consent to the login. + // -- MSC4108 OAuth 2.0 login step 4 continued + let verification_uri = Url::parse( + device_authorization_grant + .verification_uri_complete + .map(VerificationUriComplete::into_secret) + .unwrap_or(device_authorization_grant.verification_uri.to_string()) + .as_str(), + ) + .map_err(|_| Error::UnableToCreateDevice)?; + state.set(GrantLoginProgress::WaitingForAuth { verification_uri }.into()); + + // We send the new device the m.login.protocol_accepted message to let it know + // that the consent process is in progress. + // -- MSC4108 OAuth 2.0 login step 4 continued + let message = QrAuthMessage::LoginProtocolAccepted; + channel.send_json(&message).await?; + + // The new device displays the user code it received from the authorization + // server and starts polling for an access token. In parallel, the user + // consents to the new login in the browser on this device, while verifying + // the user code displayed on the other device. -- MSC4108 OAuth 2.0 login + // steps 5 & 6 + + // We wait for the new device to send us the m.login.success message + let message: QrAuthMessage = channel.receive_json().await?; + let QrAuthMessage::LoginSuccess = message else { + return Err(Error::Unknown( + "Receiving unexpected message when expecting LoginSuccess".to_owned(), + )); + }; + + // We check that the new device was created successfully allowing for up to 10 + // seconds of delay. -- MSC4108 Secret sharing and device verification step + // 1 + let deadline = Instant::now() + Duration::from_secs(10); + + loop { + if matches!(client.device_exists(device_id.to_base64().into()).await, Ok(true)) { + break; + } else { + // If the deadline hasn't yet passed, give it some time and retry the request. + if Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } else { + // The deadline has passed. Let's fail the login process. + channel + .send_json(QrAuthMessage::LoginFailure { + reason: LoginFailureReason::DeviceNotFound, + homeserver: None, + }) + .await?; + return Err(Error::DeviceIDAlreadyInUse); + } + } + } + + // We send the new device the secrets bundle. + // -- MSC4108 Secret sharing and device verification step 2 + state.set(GrantLoginProgress::SyncingSecrets.into()); + let message = QrAuthMessage::LoginSecrets(secrets_bundle.clone()); + channel.send_json(&message).await?; + + // And we're done. + state.set(GrantLoginProgress::Done.into()); + + Ok(()) +} diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/scanned.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/scanned.rs new file mode 100644 index 00000000000..87f3e5044cc --- /dev/null +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/reciprocate/scanned.rs @@ -0,0 +1,728 @@ +// Copyright 2025 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. + +//! The flow where the existing device grants login by generating a QR code to +//! be scanned by the new device. + +use eyeball::SharedObservable; +use futures_core::Stream; +use matrix_sdk_base::crypto::types::{SecretsBundle, qr_login::QrCodeData}; +use url::Url; + +use super::{ + AlmostEstablishedSecureChannel, Error, SecureChannel, export_secrets_bundle, finish_login_grant, +}; +use crate::Client; + +/// A handler for granting login to a new device from an existing device by way +/// of a QR code. +pub struct GrantLoginHandler { + /// The existing client. + client: Client, + /// The secrets that will be transmitted to the new device. + secrets_bundle: SecretsBundle, + /// The secure channel used to communicate with the new device. + channel: Channel, + /// The QR code data to be scanned by the new device. + qr_code_data: QrCodeData, + /// The current state of the login granting process. + state: SharedObservable, +} + +impl std::fmt::Debug for GrantLoginHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GrantLoginHandler") + .field("qr_code_data", &self.qr_code_data) + .field("state", &self.state) + .finish_non_exhaustive() + } +} + +impl GrantLoginHandler { + /// Subscribe to the progress of the QR code login. + pub fn subscribe_to_progress(&self) -> impl Stream + use<> { + self.state.subscribe() + } + + /// Create a new handler. + /// + /// This will create a rendezvous session and an associated QR code that can + /// be scanned by the new device. + pub(crate) async fn new(client: Client) -> Result { + // Create a new ephemeral key pair and a rendezvous session to grant a + // login with. + // -- MSC4108 Secure channel setup steps 1 & 2 + let homeserver_url = client.homeserver(); + let http_client = client.inner.http_client.clone(); + let secure_channel = SecureChannel::reciprocate(http_client, &homeserver_url).await?; + + // Extract the QR code data so that the caller can present the QR code for + // scanning by the new device. -- MSC4108 Secure channel setup step 3 + let qr_code_data = secure_channel.qr_code_data().clone(); + + let channel = Channel::Insecure(secure_channel); + let secrets_bundle = export_secrets_bundle(&client).await?; + + Ok(Self { client, channel, qr_code_data, secrets_bundle, state: Default::default() }) + } + + /// Get the data for the QR code that the new device needs to scan. + pub fn qr_code_data(&self) -> &QrCodeData { + &self.qr_code_data + } + + /// Wait for the new device to scan the QR code. + pub async fn wait_for_scan(&mut self) -> Result<(), Error> { + let Channel::Insecure(channel) = self.channel.take() else { + return Err(Error::InvalidState); + }; + + // Wait for the secure channel to connect. The other device now needs to scan + // the QR code and send us the LoginInitiateMessage which we respond to + // with the LoginOkMessage. -- MSC4108 step 4 & 5 + self.channel = Channel::Almost(channel.connect().await?); + + // The other device now needs to verify our message, compute the checkcode and + // display it. We emit a progress update to let the caller prompt the + // user to enter the checkcode and feed it back to us. + // -- MSC4108 Secure channel setup step 6 + self.state.set(GrantLoginProgress::WaitingForCheckCode); + + Ok(()) + } + + /// Confirm the check code entered by the user and finish granting the + /// login. + pub async fn confirm_check_code_and_finish(&mut self, check_code: u8) -> Result<(), Error> { + let Channel::Almost(channel) = self.channel.take() else { + return Err(Error::InvalidState); + }; + + // Use the checkcode to verify that the channel is actually secure. + // -- MSC4108 Secure channel setup step 7 + let mut channel = channel.confirm(check_code)?; + + // Since the QR code was generated on this existing device, the new device can + // derive the homeserver to use for logging in from the QR code and we + // don't need to send the m.login.protocols message. + // -- MSC4108 OAuth 2.0 login step 1 + + // Proceed with granting the login. + // -- MSC4108 OAuth 2.0 login remaining steps + finish_login_grant(&self.client, &mut channel, &self.secrets_bundle, &self.state).await + } +} + +/// The progress of granting the login. +#[derive(Debug, Default, Clone)] +pub enum GrantLoginProgress { + /// The QR code has been created and this device is waiting for the new + /// device to scan it. + #[default] + Created, + /// The QR code has been scanned by the new device and this device is + /// waiting for the user to put in the check code. + WaitingForCheckCode, + /// The secure channel has been confirmed using the checkcode and this + /// device is waiting for the authorization to complete. + WaitingForAuth { + /// A URI to open in a (secure) system browser to verify the new login. + verification_uri: Url, + }, + /// The new device has been granted access and this device is sending the + /// secrets to it. + SyncingSecrets, + /// The process is complete. + Done, +} + +impl From for GrantLoginProgress { + fn from(progress: super::GrantLoginProgress) -> Self { + match progress { + super::GrantLoginProgress::WaitingForAuth { verification_uri } => { + Self::WaitingForAuth { verification_uri } + } + super::GrantLoginProgress::SyncingSecrets => Self::SyncingSecrets, + super::GrantLoginProgress::Done => Self::Done, + } + } +} + +#[derive(Default)] +enum Channel { + /// The QR code login process is in progress. + #[default] + InProgress, + /// The secure channel is in the insecure state. + Insecure(SecureChannel), + /// The secure channel is almost established. + Almost(AlmostEstablishedSecureChannel), + /// The secure channel could not be established and was left in an + /// inconsistent state. + Poisoned, +} + +impl Channel { + /// Takes the value of the channel and leaves it Poisoned + /// + /// This is not the same as `std::mem::take(&mut channel)`. + fn take(&mut self) -> Self { + std::mem::replace(self, Self::Poisoned) + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod test { + use assert_matches2::{assert_let, assert_matches}; + use futures_util::{StreamExt, join}; + use matrix_sdk_base::crypto::types::qr_login::QrCodeMode; + use matrix_sdk_common::executor::spawn; + use matrix_sdk_test::async_test; + use oauth2::{EndUserVerificationUrl, VerificationUriComplete}; + use ruma::{owned_device_id, owned_user_id}; + use tokio::sync::oneshot; + use tracing::debug; + use vodozemac::Curve25519PublicKey; + + use super::*; + use crate::{ + authentication::oauth::qrcode::{ + LoginFailureReason, QrAuthMessage, + messages::{AuthorizationGrant, LoginProtocolType}, + secure_channel::{EstablishedSecureChannel, test::MockedRendezvousServer}, + }, + test_utils::mocks::MatrixMockServer, + }; + + enum BobBehaviour { + HappyPath, + UnexpectedMessageInsteadOfLoginProtocol, + DeviceAlreadyExists, + DeviceNotCreated, + } + + async fn request_login( + mut bob: EstablishedSecureChannel, + behaviour: BobBehaviour, + check_code_tx: oneshot::Sender, + device_authorization_grant: AuthorizationGrant, + server: MatrixMockServer, + secrets_bundle: SecretsBundle, + ) { + // Let Alice know about the checkcode so she can verify the channel. + check_code_tx + .send(bob.check_code().to_digit()) + .expect("Bob should be able to send the checkcode"); + + match behaviour { + BobBehaviour::UnexpectedMessageInsteadOfLoginProtocol => { + // Send an unexpected message and exit. + let message = QrAuthMessage::LoginSuccess; + bob.send_json(message).await.unwrap(); + return; + } + BobBehaviour::DeviceAlreadyExists => { + // Mock the endpoint for querying devices so that Alice thinks the device + // already exists. + server.mock_get_device().ok().expect(1..).named("get_device").mount().await; + + // Now send the LoginProtocol message. + let message = QrAuthMessage::LoginProtocol { + protocol: LoginProtocolType::DeviceAuthorizationGrant, + device_authorization_grant, + device_id: Curve25519PublicKey::from_base64( + "wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/sSRQ2oAKk4", + ) + .unwrap(), + }; + bob.send_json(message).await.unwrap(); + + // Alice should fail the login with the appropriate reason. + let message = bob + .receive_json() + .await + .expect("Bob should receive the LoginFailure message from Alice"); + assert_let!(QrAuthMessage::LoginFailure { reason, .. } = message); + assert_matches!(reason, LoginFailureReason::DeviceAlreadyExists); + + return; // Exit. + } + _ => { + // Send the LoginProtocol message. + let message = QrAuthMessage::LoginProtocol { + protocol: LoginProtocolType::DeviceAuthorizationGrant, + device_authorization_grant, + device_id: Curve25519PublicKey::from_base64( + "wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/sSRQ2oAKk4", + ) + .unwrap(), + }; + bob.send_json(message).await.unwrap(); + } + } + + // Receive the LoginProtocolAccepted message. + let message = bob + .receive_json() + .await + .expect("Bob should receive the LoginProtocolAccepted message from Alice"); + assert_let!(QrAuthMessage::LoginProtocolAccepted = message); + + match behaviour { + BobBehaviour::DeviceNotCreated => { + // Don't mock the endpoint for querying devices so that Alice cannot verify that + // we have logged in. + + // Send the LoginSuccess message to claim that we have logged in. + let message = QrAuthMessage::LoginSuccess; + bob.send_json(message).await.unwrap(); + + // Alice should eventually give up querying our device and fail the login with + // the appropriate reason. + let message = bob + .receive_json() + .await + .expect("Bob should receive the LoginFailure message from Alice"); + assert_let!(QrAuthMessage::LoginFailure { reason, .. } = message); + assert_matches!(reason, LoginFailureReason::DeviceNotFound); + + return; // Exit. + } + _ => { + // Mock the endpoint for querying devices so that Alice thinks we have logged + // in. + server.mock_get_device().ok().expect(1..).named("get_device").mount().await; + + // Send the LoginSuccess message. + let message = QrAuthMessage::LoginSuccess; + bob.send_json(message).await.unwrap(); + } + } + + // Receive the LoginSecrets message. + let message = bob + .receive_json() + .await + .expect("Bob should receive the LoginSecrets message from Alice"); + assert_let!(QrAuthMessage::LoginSecrets(bundle) = message); + + // Verify that we received the correct secrets. + assert_eq!( + serde_json::to_value(&secrets_bundle).unwrap(), + serde_json::to_value(&bundle).unwrap() + ); + } + + #[async_test] + async fn test_grant_qr_login() { + let server = MatrixMockServer::new().await; + let rendezvous_server = MockedRendezvousServer::new(server.server(), "abcdEFG12345").await; + debug!("Set up rendezvous server mock at {}", rendezvous_server.rendezvous_url); + + let device_authorization_grant = AuthorizationGrant { + verification_uri_complete: Some(VerificationUriComplete::new( + "https://id.matrix.org/device/abcde".to_owned(), + )), + verification_uri: EndUserVerificationUrl::new( + "https://id.matrix.org/device/abcde?code=ABCDE".to_owned(), + ) + .unwrap(), + }; + + server.mock_upload_keys().ok().expect(1).named("upload_keys").mount().await; + server + .mock_upload_cross_signing_keys() + .ok() + .expect(1) + .named("upload_xsigning_keys") + .mount() + .await; + server + .mock_upload_cross_signing_signatures() + .ok() + .expect(1) + .named("upload_xsigning_signatures") + .mount() + .await; + + // Create the existing client (Alice). + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("ALICE_DEVICE"); + let alice = server + .client_builder_for_crypto_end_to_end(&user_id, &device_id) + .logged_in_with_oauth() + .build() + .await; + alice + .encryption() + .bootstrap_cross_signing(None) + .await + .expect("Alice should be able to set up cross signing"); + + // Prepare the login granting handler. + let mut handler = alice + .oauth() + .grant_login_with_qr_code() + .scanned() + .await + .expect("Alice should be able to create the grant"); + let qr_code_data = handler.qr_code_data().clone(); + let secrets_bundle = handler.secrets_bundle.clone(); + let (check_code_tx, check_code_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = handler.subscribe_to_progress(); + let mut state = handler.state.get(); + let verification_uri_complete = + device_authorization_grant.clone().verification_uri_complete.unwrap().into_secret(); + assert_matches!(state.clone(), GrantLoginProgress::Created); + let updates_task = spawn(async move { + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Created => { + assert_matches!(state, GrantLoginProgress::Created); + } + GrantLoginProgress::WaitingForCheckCode => { + assert_matches!(state, GrantLoginProgress::Created); + } + GrantLoginProgress::WaitingForAuth { verification_uri } => { + assert_matches!(state, GrantLoginProgress::WaitingForCheckCode); + assert_eq!(verification_uri.as_str(), verification_uri_complete); + } + GrantLoginProgress::SyncingSecrets => { + assert_matches!(state, GrantLoginProgress::WaitingForAuth { .. }); + } + GrantLoginProgress::Done => { + assert_matches!(state, GrantLoginProgress::SyncingSecrets); + break; + } + } + state = update; + } + }); + + // Spawn the handler task. + let handler_task = spawn(async move { + handler.wait_for_scan().await.expect("Alice should be able to connect the channel"); + + let check_code = check_code_rx.await.expect("Alice should receive the checkcode"); + handler + .confirm_check_code_and_finish(check_code) + .await + .expect("Alice should be able to confirm the checkcode"); + }); + + // Use the QR code to establish the secure channel from the new client (Bob). + let bob = EstablishedSecureChannel::from_qr_code( + reqwest::Client::new(), + &qr_code_data, + QrCodeMode::Login, + ) + .await + .expect("Bob should be able to connect the secure channel"); + + // Let Bob request the login and run through the process. + request_login( + bob, + BobBehaviour::HappyPath, + check_code_tx, + device_authorization_grant, + server, + secrets_bundle, + ) + .await; + + // Wait for Alice's tasks to finish. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { handler_task.await.expect("Alice should be able to grant the login") }, + ); + } + + #[async_test] + async fn test_grant_qr_login_failure_unexpected_message_instead_of_login_protocol() { + let server = MatrixMockServer::new().await; + let rendezvous_server = MockedRendezvousServer::new(server.server(), "abcdEFG12345").await; + debug!("Set up rendezvous server mock at {}", rendezvous_server.rendezvous_url); + + let device_authorization_grant = AuthorizationGrant { + verification_uri_complete: Some(VerificationUriComplete::new( + "https://id.matrix.org/device/abcde".to_owned(), + )), + verification_uri: EndUserVerificationUrl::new( + "https://id.matrix.org/device/abcde?code=ABCDE".to_owned(), + ) + .unwrap(), + }; + + server.mock_upload_keys().ok().expect(1).named("upload_keys").mount().await; + server + .mock_upload_cross_signing_keys() + .ok() + .expect(1) + .named("upload_xsigning_keys") + .mount() + .await; + server + .mock_upload_cross_signing_signatures() + .ok() + .expect(1) + .named("upload_xsigning_signatures") + .mount() + .await; + + // Create the existing client (Alice). + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("ALICE_DEVICE"); + let alice = server + .client_builder_for_crypto_end_to_end(&user_id, &device_id) + .logged_in_with_oauth() + .build() + .await; + alice + .encryption() + .bootstrap_cross_signing(None) + .await + .expect("Alice should be able to set up cross signing"); + + // Prepare the login granting handler. + let mut handler = alice + .oauth() + .grant_login_with_qr_code() + .scanned() + .await + .expect("Alice should be able to create the grant"); + let qr_code_data = handler.qr_code_data().clone(); + let secrets_bundle = handler.secrets_bundle.clone(); + let (check_code_tx, check_code_rx) = oneshot::channel(); + + // Spawn the handler task. + let handler_task = spawn(async move { + handler.wait_for_scan().await.expect("Alice should be able to connect the channel"); + + let check_code = check_code_rx.await.expect("Alice should receive the checkcode"); + handler + .confirm_check_code_and_finish(check_code) + .await + .expect("Alice should be able to confirm the checkcode"); + }); + + // Use the QR code to establish the secure channel from the new client (Bob). + let bob = EstablishedSecureChannel::from_qr_code( + reqwest::Client::new(), + &qr_code_data, + QrCodeMode::Login, + ) + .await + .expect("Bob should be able to connect the secure channel"); + + // Let Bob request the login and run through the process. + request_login( + bob, + BobBehaviour::UnexpectedMessageInsteadOfLoginProtocol, + check_code_tx, + device_authorization_grant, + server, + secrets_bundle, + ) + .await; + + // Wait for Alice's task to fail. + handler_task.await.expect_err("Alice should abort the grant"); + } + + #[async_test] + async fn test_grant_qr_login_failure_device_already_exists() { + let server = MatrixMockServer::new().await; + let rendezvous_server = MockedRendezvousServer::new(server.server(), "abcdEFG12345").await; + debug!("Set up rendezvous server mock at {}", rendezvous_server.rendezvous_url); + + let device_authorization_grant = AuthorizationGrant { + verification_uri_complete: Some(VerificationUriComplete::new( + "https://id.matrix.org/device/abcde".to_owned(), + )), + verification_uri: EndUserVerificationUrl::new( + "https://id.matrix.org/device/abcde?code=ABCDE".to_owned(), + ) + .unwrap(), + }; + + server.mock_upload_keys().ok().expect(1).named("upload_keys").mount().await; + server + .mock_upload_cross_signing_keys() + .ok() + .expect(1) + .named("upload_xsigning_keys") + .mount() + .await; + server + .mock_upload_cross_signing_signatures() + .ok() + .expect(1) + .named("upload_xsigning_signatures") + .mount() + .await; + + // Create the existing client (Alice). + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("ALICE_DEVICE"); + let alice = server + .client_builder_for_crypto_end_to_end(&user_id, &device_id) + .logged_in_with_oauth() + .build() + .await; + alice + .encryption() + .bootstrap_cross_signing(None) + .await + .expect("Alice should be able to set up cross signing"); + + // Prepare the login granting handler. + let mut handler = alice + .oauth() + .grant_login_with_qr_code() + .scanned() + .await + .expect("Alice should be able to create the grant"); + let qr_code_data = handler.qr_code_data().clone(); + let secrets_bundle = handler.secrets_bundle.clone(); + let (check_code_tx, check_code_rx) = oneshot::channel(); + + // Spawn the handler task. + let handler_task = spawn(async move { + handler.wait_for_scan().await.expect("Alice should be able to connect the channel"); + + let check_code = check_code_rx.await.expect("Alice should receive the checkcode"); + handler + .confirm_check_code_and_finish(check_code) + .await + .expect("Alice should be able to confirm the checkcode"); + }); + + // Use the QR code to establish the secure channel from the new client (Bob). + let bob = EstablishedSecureChannel::from_qr_code( + reqwest::Client::new(), + &qr_code_data, + QrCodeMode::Login, + ) + .await + .expect("Bob should be able to connect the secure channel"); + + // Let Bob request the login and run through the process. + request_login( + bob, + BobBehaviour::DeviceAlreadyExists, + check_code_tx, + device_authorization_grant, + server, + secrets_bundle, + ) + .await; + + // Wait for Alice's task to fail. + handler_task.await.expect_err("Alice should abort the grant"); + } + + #[async_test] + async fn test_grant_qr_login_failure_device_not_created() { + let server = MatrixMockServer::new().await; + let rendezvous_server = MockedRendezvousServer::new(server.server(), "abcdEFG12345").await; + debug!("Set up rendezvous server mock at {}", rendezvous_server.rendezvous_url); + + let device_authorization_grant = AuthorizationGrant { + verification_uri_complete: Some(VerificationUriComplete::new( + "https://id.matrix.org/device/abcde".to_owned(), + )), + verification_uri: EndUserVerificationUrl::new( + "https://id.matrix.org/device/abcde?code=ABCDE".to_owned(), + ) + .unwrap(), + }; + + server.mock_upload_keys().ok().expect(1).named("upload_keys").mount().await; + server + .mock_upload_cross_signing_keys() + .ok() + .expect(1) + .named("upload_xsigning_keys") + .mount() + .await; + server + .mock_upload_cross_signing_signatures() + .ok() + .expect(1) + .named("upload_xsigning_signatures") + .mount() + .await; + + // Create the existing client (Alice). + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("ALICE_DEVICE"); + let alice = server + .client_builder_for_crypto_end_to_end(&user_id, &device_id) + .logged_in_with_oauth() + .build() + .await; + alice + .encryption() + .bootstrap_cross_signing(None) + .await + .expect("Alice should be able to set up cross signing"); + + // Prepare the login granting handler. + let mut handler = alice + .oauth() + .grant_login_with_qr_code() + .scanned() + .await + .expect("Alice should be able to create the grant"); + let qr_code_data = handler.qr_code_data().clone(); + let secrets_bundle = handler.secrets_bundle.clone(); + let (check_code_tx, check_code_rx) = oneshot::channel(); + + // Spawn the handler task. + let handler_task = spawn(async move { + handler.wait_for_scan().await.expect("Alice should be able to connect the channel"); + + let check_code = check_code_rx.await.expect("Alice should receive the checkcode"); + handler + .confirm_check_code_and_finish(check_code) + .await + .expect("Alice should be able to confirm the checkcode"); + }); + + // Use the QR code to establish the secure channel from the new client (Bob). + let bob = EstablishedSecureChannel::from_qr_code( + reqwest::Client::new(), + &qr_code_data, + QrCodeMode::Login, + ) + .await + .expect("Bob should be able to connect the secure channel"); + + // Let Bob request the login and run through the process. + request_login( + bob, + BobBehaviour::DeviceNotCreated, + check_code_tx, + device_authorization_grant, + server, + secrets_bundle, + ) + .await; + + // Wait for Alice's task to fail. + handler_task.await.expect_err("Alice should abort the grant"); + } +} diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/secure_channel.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/secure_channel.rs index d351dee0bc0..184442b5f41 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/secure_channel.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/secure_channel.rs @@ -54,12 +54,12 @@ impl SecureChannel { Ok(Self { channel, qr_code_data, ecies }) } - /// Create a new login to reciprocate an existing login with. - #[cfg(test)] - pub(super) async fn new(http_client: HttpClient, homeserver_url: &Url) -> Result { + /// Create a new secure channel to reciprocate an existing login with. + pub(super) async fn reciprocate( + http_client: HttpClient, + homeserver_url: &Url, + ) -> Result { let mut channel = SecureChannel::login(http_client, homeserver_url).await?; - // We're a bit abusing the QR code data here, since we're passing the homeserver - // URL, but for our tests this is fine. channel.qr_code_data.mode_data = QrCodeModeData::Reciprocate { server_name: homeserver_url.to_string() }; Ok(channel) @@ -354,7 +354,7 @@ pub(super) mod test { let rendezvous_server = MockedRendezvousServer::new(&server, "abcdEFG12345").await; let client = HttpClient::new(reqwest::Client::new(), Default::default()); - let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + let alice = SecureChannel::reciprocate(client, &rendezvous_server.homeserver_url) .await .expect("Alice should be able to create a secure channel."); diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 505911ee819..f7f115e6314 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -50,7 +50,7 @@ use ruma::{ account::whoami, alias::{create_alias, delete_alias, get_alias}, authenticated_media, - device::{delete_devices, get_devices, update_device}, + device::{self, delete_devices, get_devices, update_device}, directory::{get_public_rooms, get_public_rooms_filtered}, discovery::{ discover_homeserver::{self, RtcFocusInfo}, @@ -2312,6 +2312,30 @@ impl Client { self.send(request).await } + /// Check whether a device with a specific ID exists on the server. + /// + /// Returns Ok(true) if the device exists, Ok(false) if the server responded + /// with 404 and the underlying error otherwise. + /// + /// # Arguments + /// + /// * `device_id` - The ID of the device to query. + pub async fn device_exists(&self, device_id: OwnedDeviceId) -> Result { + let request = device::get_device::v3::Request::new(device_id); + match self.send(request).await { + Ok(_) => Ok(true), + Err(err) => { + if let Some(error) = err.as_client_api_error() + && error.status_code == 404 + { + Ok(false) + } else { + Err(err.into()) + } + } + } + } + /// Synchronize the client's state with the latest state on the server. /// /// ## Syncing Events @@ -3128,7 +3152,7 @@ pub(crate) mod tests { ignored_user_list::IgnoredUserListEventContent, media_preview_config::{InviteAvatars, MediaPreviewConfigEventContent, MediaPreviews}, }, - owned_room_id, owned_user_id, room_alias_id, room_id, user_id, + owned_device_id, owned_room_id, owned_user_id, room_alias_id, room_id, user_id, }; use serde_json::json; use stream_assert::{assert_next_matches, assert_pending}; @@ -4197,4 +4221,32 @@ pub(crate) mod tests { let found_room = client.get_dm_room(user_id).expect("DM not found!"); assert!(found_room.get_member_no_sync(user_id).await.unwrap().is_some()); } + + #[async_test] + async fn test_device_exists() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_get_device().ok().expect(1).mount().await; + + assert_matches!(client.device_exists(owned_device_id!("ABCDEF")).await, Ok(true)); + } + + #[async_test] + async fn test_device_exists_404() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + assert_matches!(client.device_exists(owned_device_id!("ABCDEF")).await, Ok(false)); + } + + #[async_test] + async fn test_device_exists_500() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_get_device().error500().expect(1).mount().await; + + assert_matches!(client.device_exists(owned_device_id!("ABCDEF")).await, Err(_)); + } } diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index c71aa2ca05c..afcc79ad6f9 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1427,6 +1427,12 @@ impl MatrixMockServer { self.mock_endpoint(mock, DevicesEndpoint).expect_default_access_token() } + /// Create a prebuilt mock for the endpoint used to query a single device. + pub fn mock_get_device(&self) -> MockEndpoint<'_, GetDeviceEndpoint> { + let mock = Mock::given(method("GET")).and(path_regex("/_matrix/client/v3/devices/.*")); + self.mock_endpoint(mock, GetDeviceEndpoint).expect_default_access_token() + } + /// Create a prebuilt mock for the endpoint used to search in the user /// directory. pub fn mock_user_directory(&self) -> MockEndpoint<'_, UserDirectoryEndpoint> { @@ -4139,6 +4145,16 @@ impl<'a> MockEndpoint<'a, DevicesEndpoint> { } } +/// A prebuilt mock for `GET /devices/{deviceId}` requests. +pub struct GetDeviceEndpoint; + +impl<'a> MockEndpoint<'a, GetDeviceEndpoint> { + /// Returns a successful response. + pub fn ok(self) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::DEVICE)) + } +} + /// A prebuilt mock for `POST /user_directory/search` requests. pub struct UserDirectoryEndpoint; diff --git a/testing/matrix-sdk-test/src/test_json/api_responses.rs b/testing/matrix-sdk-test/src/test_json/api_responses.rs index 6e726aa602d..0dc2429d270 100644 --- a/testing/matrix-sdk-test/src/test_json/api_responses.rs +++ b/testing/matrix-sdk-test/src/test_json/api_responses.rs @@ -25,6 +25,16 @@ pub static DEVICES: Lazy = Lazy::new(|| { }) }); +/// `GET /_matrix/client/v3/device/{deviceId}` +pub static DEVICE: Lazy = Lazy::new(|| { + json!({ + "device_id": "QBUAZIFURK", + "display_name": "android", + "last_seen_ip": "1.2.3.4", + "last_seen_ts": 1474491775024u64 + }) +}); + /// `GET /_matrix/client/v3/directory/room/{roomAlias}` pub static GET_ALIAS: Lazy = Lazy::new(|| { json!({ diff --git a/testing/matrix-sdk-test/src/test_json/mod.rs b/testing/matrix-sdk-test/src/test_json/mod.rs index dfe30ad7a28..285955ae1fa 100644 --- a/testing/matrix-sdk-test/src/test_json/mod.rs +++ b/testing/matrix-sdk-test/src/test_json/mod.rs @@ -18,7 +18,7 @@ pub mod sync; pub mod sync_events; pub use api_responses::{ - DEVICES, GET_ALIAS, KEYS_QUERY, KEYS_QUERY_TWO_DEVICES_ONE_SIGNED, KEYS_UPLOAD, LOGIN, + DEVICE, DEVICES, GET_ALIAS, KEYS_QUERY, KEYS_QUERY_TWO_DEVICES_ONE_SIGNED, KEYS_UPLOAD, LOGIN, LOGIN_RESPONSE_ERR, LOGIN_TYPES, LOGIN_WITH_DISCOVERY, LOGIN_WITH_REFRESH_TOKEN, NOT_FOUND, PUBLIC_ROOMS, PUBLIC_ROOMS_FINAL_PAGE, REFRESH_TOKEN, REFRESH_TOKEN_WITH_REFRESH_TOKEN, REGISTRATION_RESPONSE_ERR, UNKNOWN_TOKEN_SOFT_LOGOUT, VERSIONS, WELL_KNOWN, WHOAMI,