From b4ebc8bc25b6f5aafc2b426911208efce3f9792c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 30 Oct 2025 16:06:30 +0100 Subject: [PATCH] feat(oauth): add flow for granting login by scanning a QR code Signed-off-by: Johannes Marbach --- crates/matrix-sdk/CHANGELOG.md | 3 + .../src/authentication/oauth/mod.rs | 87 +- .../src/authentication/oauth/qrcode/grant.rs | 837 +++++++++++++++++- .../src/authentication/oauth/qrcode/login.rs | 6 +- .../src/authentication/oauth/qrcode/mod.rs | 2 +- 5 files changed, 921 insertions(+), 14 deletions(-) diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 1fcc36653..3d5fbc9c6 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. ### Features +- Extend `authentication::oauth::OAuth::grant_login_with_qr_code` to support granting + login by scanning a QR code on the existing device. + ([#5818](https://github.com/matrix-org/matrix-rust-sdk/pull/5818)) - Add a new `RequestConfig::skip_auth()` option. This is useful to ensure that certain request won't ever include an authorization header. ([#5822](https://github.com/matrix-org/matrix-rust-sdk/pull/5822)) diff --git a/crates/matrix-sdk/src/authentication/oauth/mod.rs b/crates/matrix-sdk/src/authentication/oauth/mod.rs index d2f3870be..a181af492 100644 --- a/crates/matrix-sdk/src/authentication/oauth/mod.rs +++ b/crates/matrix-sdk/src/authentication/oauth/mod.rs @@ -216,7 +216,10 @@ mod tests; #[cfg(feature = "e2e-encryption")] use self::cross_process::{CrossProcessRefreshLockGuard, CrossProcessRefreshManager}; #[cfg(feature = "e2e-encryption")] -use self::qrcode::{GrantLoginWithGeneratedQrCode, LoginWithGeneratedQrCode, LoginWithQrCode}; +use self::qrcode::{ + GrantLoginWithGeneratedQrCode, GrantLoginWithScannedQrCode, LoginWithGeneratedQrCode, + LoginWithQrCode, +}; pub use self::{ account_management_url::{AccountManagementActionFull, AccountManagementUrlBuilder}, auth_code_builder::{OAuthAuthCodeUrlBuilder, OAuthAuthorizationData}, @@ -1537,7 +1540,7 @@ pub struct GrantLoginWithQrCodeBuilder<'a> { #[cfg(feature = "e2e-encryption")] impl<'a> GrantLoginWithQrCodeBuilder<'a> { /// Create a new builder with the default device creation timeout. - pub fn new(client: &'a Client) -> Self { + fn new(client: &'a Client) -> Self { Self { client, device_creation_timeout: Duration::from_secs(10) } } @@ -1552,6 +1555,86 @@ impl<'a> GrantLoginWithQrCodeBuilder<'a> { self } + /// This method allows you to grant login to a new device by scanning a + /// QR code generated by the new device. + /// + /// The new device needs to display the QR code which this device can + /// scan and call this method to grant the login. + /// + /// A successful login grant using this method will automatically mark the + /// new device as verified and transfer all end-to-end encryption + /// related secrets, like the private cross-signing keys and the backup + /// key from this device device to the new device. + /// + /// For the reverse flow where this device generates the QR code + /// for the new device to scan, use + /// [`GrantLoginWithQrCodeBuilder::generate`]. + /// + /// # Arguments + /// + /// * `data` - The data scanned from a QR code. + /// + /// # Example + /// + /// ```no_run + /// use anyhow::bail; + /// use futures_util::StreamExt; + /// use matrix_sdk::{ + /// Client, authentication::oauth::{ + /// qrcode::{GrantLoginProgress, QrCodeData, QrCodeModeData, QrProgress}, + /// } + /// }; + /// use std::{error::Error, io::stdin}; + /// # _ = async { + /// # let bytes = unimplemented!(); + /// // You'll need to use a different library to scan and extract the raw bytes from the QR + /// // code. + /// let qr_code_data = QrCodeData::from_bytes(bytes)?; + /// + /// // 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 to capture + /// // the checkcode in order to display it to the other device and to obtain the verification URL to + /// // open it in a browser so the user can consent to the new login. + /// let mut grant = oauth.grant_login_with_qr_code().scan(&qr_code_data); + /// let mut progress = grant.subscribe_to_progress(); + /// + /// // Create a task which will show us the progress and allows us to receive + /// // and feed back data. + /// let task = tokio::spawn(async move { + /// while let Some(state) = progress.next().await { + /// match state { + /// GrantLoginProgress::Starting | GrantLoginProgress::SyncingSecrets => (), + /// GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + /// println!("Please enter the checkcode on your other device: {:?}", check_code); + /// } + /// GrantLoginProgress::WaitingForAuth { verification_uri } => { + /// println!("Please open {verification_uri} to confirm the new login") + /// }, + /// GrantLoginProgress::Done => break, + /// } + /// } + /// Ok::<(), Box>(()) + /// }); + /// + /// // Now run the future to grant the login. + /// grant.await?; + /// task.abort(); + /// + /// println!("Successfully granted login"); + /// # anyhow::Ok(()) }; + /// ``` + pub fn scan(self, data: &'a QrCodeData) -> GrantLoginWithScannedQrCode<'a> { + GrantLoginWithScannedQrCode::new(self.client, data, self.device_creation_timeout) + } + /// This method allows you to grant login to a new device by generating a QR /// code on this device to be scanned by the new device. /// diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/grant.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/grant.rs index b62e31fea..267b4323b 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/grant.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/grant.rs @@ -16,9 +16,17 @@ use std::time::{Duration, Instant}; use eyeball::SharedObservable; use futures_core::Stream; -use matrix_sdk_base::{boxed_into_future, crypto::types::SecretsBundle}; +use matrix_sdk_base::{ + boxed_into_future, + crypto::types::{ + SecretsBundle, + qr_login::{QrCodeData, QrCodeMode}, + }, +}; use oauth2::VerificationUriComplete; use url::Url; +#[cfg(doc)] +use vodozemac::ecies::CheckCode; use super::{ LoginProtocolType, QrAuthMessage, @@ -28,7 +36,7 @@ use crate::{ Client, authentication::oauth::qrcode::{ CheckCodeSender, GeneratedQrProgress, LoginFailureReason, QRCodeGrantLoginError, - SecureChannelError, + QrProgress, SecureChannelError, }, }; @@ -166,9 +174,9 @@ pub enum GrantLoginProgress { #[default] Starting, /// The secure channel is being established by exchanging the QR code - /// and/or checkcode. + /// and/or [`CheckCode`]. EstablishingSecureChannel(Q), - /// The secure channel has been confirmed using the checkcode and this + /// 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. @@ -181,6 +189,99 @@ pub enum GrantLoginProgress { Done, } +/// Named future for granting login by scanning a QR code on this, existing, +/// device that was generated by the other, new, device. +#[derive(Debug)] +pub struct GrantLoginWithScannedQrCode<'a> { + client: &'a Client, + qr_code_data: &'a QrCodeData, + device_creation_timeout: Duration, + state: SharedObservable>, +} + +impl<'a> GrantLoginWithScannedQrCode<'a> { + pub(crate) fn new( + client: &'a Client, + qr_code_data: &'a QrCodeData, + device_creation_timeout: Duration, + ) -> GrantLoginWithScannedQrCode<'a> { + GrantLoginWithScannedQrCode { + client, + qr_code_data, + device_creation_timeout, + state: Default::default(), + } + } +} + +impl GrantLoginWithScannedQrCode<'_> { + /// Subscribe to the progress of QR code login. + /// + /// It's necessary to subscribe to this to capture the [`CheckCode`] in + /// order to display it to the other device and to obtain the + /// verification URL for consenting to the login. + pub fn subscribe_to_progress( + &self, + ) -> impl Stream> + use<> { + self.state.subscribe() + } +} + +impl<'a> IntoFuture for GrantLoginWithScannedQrCode<'a> { + type Output = Result<(), QRCodeGrantLoginError>; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + // Before we get here, the other device has created a new rendezvous session + // and presented a QR code which this device has scanned. + // -- MSC4108 Secure channel setup steps 1-3 + + // First things first, establish the secure channel. Since we're the one that + // scanned the QR code, we're certain that the secure channel is + // secure, under the assumption that we didn't scan the wrong QR code. + // -- MSC4108 Secure channel setup steps 3-5 + let mut channel = EstablishedSecureChannel::from_qr_code( + self.client.inner.http_client.inner.clone(), + self.qr_code_data, + QrCodeMode::Reciprocate, + ) + .await?; + + // The other side isn't yet sure that it's talking to the right device, show + // a check code so they can confirm. + // -- MSC4108 Secure channel setup step 6 + let check_code = channel.check_code().to_owned(); + self.state + .set(GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code })); + + // The user now enters the checkcode on the other device which verifies it + // and will only continue requesting the login if the code matches. + // -- MSC4108 Secure channel setup step 7 + + // Inform the other device about the available login protocols and the + // homeserver to use. + // -- MSC4108 OAuth 2.0 login step 1 + let message = QrAuthMessage::LoginProtocols { + protocols: vec![LoginProtocolType::DeviceAuthorizationGrant], + homeserver: self.client.homeserver(), + }; + channel.send_json(message).await?; + + // Proceed with granting the login. + // -- MSC4108 OAuth 2.0 login remaining steps + finish_login_grant( + self.client, + &mut channel, + self.device_creation_timeout, + &export_secrets_bundle(self.client).await?, + &self.state, + ) + .await + }) + } +} + /// Named future for granting login by generating a QR code on this, existing, /// device to be scanned by the other, new, device. #[derive(Debug)] @@ -203,7 +304,7 @@ impl GrantLoginWithGeneratedQrCode<'_> { /// Subscribe to the progress of QR code login. /// /// It's necessary to subscribe to this to capture the QR code in order to - /// display it to the other device, to feed the checkcode entered by the + /// display it to the other device, to feed the [`CheckCode`] entered by the /// user back in and to obtain the verification URL for consenting to /// the login. pub fn subscribe_to_progress( @@ -275,10 +376,7 @@ impl<'a> IntoFuture for GrantLoginWithGeneratedQrCode<'a> { mod test { use assert_matches2::{assert_let, assert_matches}; use futures_util::{StreamExt, join}; - use matrix_sdk_base::crypto::types::{ - SecretsBundle, - qr_login::{QrCodeData, QrCodeMode}, - }; + use matrix_sdk_base::crypto::types::SecretsBundle; use matrix_sdk_common::executor::spawn; use matrix_sdk_test::async_test; use oauth2::{EndUserVerificationUrl, VerificationUriComplete}; @@ -294,6 +392,7 @@ mod test { messages::{AuthorizationGrant, LoginProtocolType}, secure_channel::{EstablishedSecureChannel, test::MockedRendezvousServer}, }, + http_client::HttpClient, test_utils::mocks::MatrixMockServer, }; @@ -430,6 +529,137 @@ mod test { ); } + async fn request_login_with_generated_qr_code( + behaviour: BobBehaviour, + channel: SecureChannel, + check_code_rx: oneshot::Receiver, + server: MatrixMockServer, + homeserver: Url, + device_authorization_grant: Option, + secrets_bundle: Option, + ) { + // Wait for Alice to scan the qr code and connect the secure channel. + let channel = + channel.connect().await.expect("Bob should be able to connect the secure channel"); + + // Wait for Alice to send us the checkcode and use it to verify the channel. + let check_code = check_code_rx.await.expect("Bob should receive the checkcode"); + let mut bob = channel + .confirm(check_code) + .expect("Bob should be able to confirm the channel is secure"); + + // Receive the LoginProtocols message. + let message = bob + .receive_json() + .await + .expect("Bob should receive the LoginProtocolAccepted message from Alice"); + assert_let!( + QrAuthMessage::LoginProtocols { protocols, homeserver: alice_homeserver } = message + ); + assert_eq!(protocols, vec![LoginProtocolType::DeviceAuthorizationGrant]); + assert_eq!(alice_homeserver, homeserver); + + 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_authorization_grant + .expect("Bob needs the 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_authorization_grant + .expect("Bob needs the 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_login_with_generated_qr_code() { let server = MatrixMockServer::new().await; @@ -574,6 +804,262 @@ mod test { ); } + #[async_test] + async fn test_grant_login_with_scanned_qr_code() { + 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 a secure channel on the new client (Bob) and extract the QR code. + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let channel = SecureChannel::login(client, &rendezvous_server.homeserver_url) + .await + .expect("Bob should be able to create a secure channel."); + let qr_code_data = channel.qr_code_data().clone(); + + // 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 future using the QR code. + let oauth = alice.oauth(); + let grant = oauth + .grant_login_with_qr_code() + .device_creation_timeout(Duration::from_secs(2)) + .scan(&qr_code_data); + let secrets_bundle = export_secrets_bundle(&alice) + .await + .expect("Alice should be able to export the secrets bundle"); + let (checkcode_tx, checkcode_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = grant.subscribe_to_progress(); + let mut state = grant.state.get(); + let verification_uri_complete = + device_authorization_grant.clone().verification_uri_complete.unwrap().into_secret(); + assert_matches!(state.clone(), GrantLoginProgress::Starting); + let updates_task = spawn(async move { + let mut checkcode_tx = Some(checkcode_tx); + + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Starting => { + assert_matches!(state, GrantLoginProgress::Starting); + } + GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + assert_matches!(state, GrantLoginProgress::Starting); + checkcode_tx + .take() + .expect("The checkcode should only be forwarded once") + .send(check_code.to_digit()) + .expect("Alice should be able to forward the checkcode"); + } + GrantLoginProgress::WaitingForAuth { verification_uri } => { + assert_matches!( + state, + GrantLoginProgress::EstablishingSecureChannel(QrProgress { .. }) + ); + 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; + } + }); + + // Let Bob request the login and run through the process. + let bob_task = spawn(async move { + request_login_with_generated_qr_code( + BobBehaviour::HappyPath, + channel, + checkcode_rx, + server, + alice.homeserver(), + Some(device_authorization_grant), + Some(secrets_bundle), + ) + .await; + }); + + // Wait for all tasks to finish. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { grant.await.expect("Alice should be able to grant the login") }, + async { bob_task.await.expect("Bob's task should finish") } + ); + } + + #[async_test] + async fn test_grant_login_with_scanned_qr_code_with_homeserver_swap() { + 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(), + }; + + let login_server = MatrixMockServer::new().await; + + login_server.mock_upload_keys().ok().expect(1).named("upload_keys").mount().await; + login_server + .mock_upload_cross_signing_keys() + .ok() + .expect(1) + .named("upload_xsigning_keys") + .mount() + .await; + login_server + .mock_upload_cross_signing_signatures() + .ok() + .expect(1) + .named("upload_xsigning_signatures") + .mount() + .await; + + // Create a secure channel on the new client (Bob) and extract the QR code. + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let channel = SecureChannel::login(client, &rendezvous_server.homeserver_url) + .await + .expect("Bob should be able to create a secure channel."); + let qr_code_data = channel.qr_code_data().clone(); + + // Create the existing client (Alice). + let user_id = owned_user_id!("@alice:example.org"); + let device_id = owned_device_id!("ALICE_DEVICE"); + let alice = login_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 future using the QR code. + let oauth = alice.oauth(); + let grant = oauth + .grant_login_with_qr_code() + .device_creation_timeout(Duration::from_secs(2)) + .scan(&qr_code_data); + let secrets_bundle = export_secrets_bundle(&alice) + .await + .expect("Alice should be able to export the secrets bundle"); + let (checkcode_tx, checkcode_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = grant.subscribe_to_progress(); + let mut state = grant.state.get(); + let verification_uri_complete = + device_authorization_grant.clone().verification_uri_complete.unwrap().into_secret(); + assert_matches!(state.clone(), GrantLoginProgress::Starting); + let updates_task = spawn(async move { + let mut checkcode_tx = Some(checkcode_tx); + + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Starting => { + assert_matches!(state, GrantLoginProgress::Starting); + } + GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + assert_matches!(state, GrantLoginProgress::Starting); + checkcode_tx + .take() + .expect("The checkcode should only be forwarded once") + .send(check_code.to_digit()) + .expect("Alice should be able to forward the checkcode"); + } + GrantLoginProgress::WaitingForAuth { verification_uri } => { + assert_matches!( + state, + GrantLoginProgress::EstablishingSecureChannel(QrProgress { .. }) + ); + 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; + } + }); + + // Let Bob request the login and run through the process. + let bob_task = spawn(async move { + request_login_with_generated_qr_code( + BobBehaviour::HappyPath, + channel, + checkcode_rx, + login_server, + alice.homeserver(), + Some(device_authorization_grant), + Some(secrets_bundle), + ) + .await; + }); + + // Wait for all tasks to finish. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { grant.await.expect("Alice should be able to grant the login") }, + async { bob_task.await.expect("Bob's task should finish") } + ); + } + #[async_test] async fn test_grant_login_with_generated_qr_code_unexpected_message_instead_of_login_protocol() { @@ -692,6 +1178,108 @@ mod test { ); } + #[async_test] + async fn test_grant_login_with_scanned_qr_code_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); + + 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 a secure channel on the new client (Bob) and extract the QR code. + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let channel = SecureChannel::login(client, &rendezvous_server.homeserver_url) + .await + .expect("Bob should be able to create a secure channel."); + let qr_code_data = channel.qr_code_data().clone(); + + // 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 future using the QR code. + let oauth = alice.oauth(); + let grant = oauth + .grant_login_with_qr_code() + .device_creation_timeout(Duration::from_secs(2)) + .scan(&qr_code_data); + let (checkcode_tx, checkcode_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = grant.subscribe_to_progress(); + let mut state = grant.state.get(); + assert_matches!(state.clone(), GrantLoginProgress::Starting); + let updates_task = spawn(async move { + let mut checkcode_tx = Some(checkcode_tx); + + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Starting => { + assert_matches!(state, GrantLoginProgress::Starting); + } + GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + assert_matches!(state, GrantLoginProgress::Starting); + checkcode_tx + .take() + .expect("The checkcode should only be forwarded once") + .send(check_code.to_digit()) + .expect("Alice should be able to forward the checkcode"); + break; + } + _ => { + panic!("Alice should abort the process"); + } + } + state = update; + } + }); + + // Let Bob request the login and run through the process. + let bob_task = spawn(async move { + request_login_with_generated_qr_code( + BobBehaviour::UnexpectedMessageInsteadOfLoginProtocol, + channel, + checkcode_rx, + server, + alice.homeserver(), + None, + None, + ) + .await; + }); + + // Wait for all tasks to finish / fail. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { grant.await.expect_err("Alice should abort the login") }, + async { bob_task.await.expect("Bob's task should finish") } + ); + } + #[async_test] async fn test_grant_login_with_generated_qr_code_device_already_exists() { let server = MatrixMockServer::new().await; @@ -818,6 +1406,117 @@ mod test { ); } + #[async_test] + async fn test_grant_login_with_scanned_qr_code_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 a secure channel on the new client (Bob) and extract the QR code. + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let channel = SecureChannel::login(client, &rendezvous_server.homeserver_url) + .await + .expect("Bob should be able to create a secure channel."); + let qr_code_data = channel.qr_code_data().clone(); + + // 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 future using the QR code. + let oauth = alice.oauth(); + let grant = oauth + .grant_login_with_qr_code() + .device_creation_timeout(Duration::from_secs(2)) + .scan(&qr_code_data); + let (checkcode_tx, checkcode_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = grant.subscribe_to_progress(); + let mut state = grant.state.get(); + assert_matches!(state.clone(), GrantLoginProgress::Starting); + let updates_task = spawn(async move { + let mut checkcode_tx = Some(checkcode_tx); + + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Starting => { + assert_matches!(state, GrantLoginProgress::Starting); + } + GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + assert_matches!(state, GrantLoginProgress::Starting); + checkcode_tx + .take() + .expect("The checkcode should only be forwarded once") + .send(check_code.to_digit()) + .expect("Alice should be able to forward the checkcode"); + } + _ => { + panic!("Alice should abort the process"); + } + } + state = update; + } + }); + + // Let Bob request the login and run through the process. + let bob_task = spawn(async move { + request_login_with_generated_qr_code( + BobBehaviour::DeviceAlreadyExists, + channel, + checkcode_rx, + server, + alice.homeserver(), + Some(device_authorization_grant), + None, + ) + .await; + }); + + // Wait for all tasks to finish. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { grant.await.expect_err("Alice should abort the login") }, + async { bob_task.await.expect("Bob's task should finish") } + ); + } + #[async_test] async fn test_grant_login_with_generated_qr_code_device_not_created() { let server = MatrixMockServer::new().await; @@ -954,4 +1653,124 @@ mod test { async { bob_task.await.expect("Bob's task should finish") } ); } + + #[async_test] + async fn test_grant_login_with_scanned_qr_code_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 a secure channel on the new client (Bob) and extract the QR code. + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let channel = SecureChannel::login(client, &rendezvous_server.homeserver_url) + .await + .expect("Bob should be able to create a secure channel."); + let qr_code_data = channel.qr_code_data().clone(); + + // 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 future using the QR code. + let oauth = alice.oauth(); + let grant = oauth + .grant_login_with_qr_code() + .device_creation_timeout(Duration::from_secs(2)) + .scan(&qr_code_data); + let (checkcode_tx, checkcode_rx) = oneshot::channel(); + + // Spawn the updates task. + let mut updates = grant.subscribe_to_progress(); + let mut state = grant.state.get(); + let verification_uri_complete = + device_authorization_grant.clone().verification_uri_complete.unwrap().into_secret(); + assert_matches!(state.clone(), GrantLoginProgress::Starting); + let updates_task = spawn(async move { + let mut checkcode_tx = Some(checkcode_tx); + + while let Some(update) = updates.next().await { + match &update { + GrantLoginProgress::Starting => { + assert_matches!(state, GrantLoginProgress::Starting); + } + GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => { + assert_matches!(state, GrantLoginProgress::Starting); + checkcode_tx + .take() + .expect("The checkcode should only be forwarded once") + .send(check_code.to_digit()) + .expect("Alice should be able to forward the checkcode"); + } + GrantLoginProgress::WaitingForAuth { verification_uri } => { + assert_matches!( + state, + GrantLoginProgress::EstablishingSecureChannel(QrProgress { .. }) + ); + assert_eq!(verification_uri.as_str(), verification_uri_complete); + } + _ => { + panic!("Alice should abort the process"); + } + } + state = update; + } + }); + + // Let Bob request the login and run through the process. + let bob_task = spawn(async move { + request_login_with_generated_qr_code( + BobBehaviour::DeviceNotCreated, + channel, + checkcode_rx, + server, + alice.homeserver(), + Some(device_authorization_grant), + None, + ) + .await; + }); + + // Wait for all tasks to finish. + join!( + async { updates_task.await.expect("Alice should run through all progress states") }, + async { grant.await.expect_err("Alice should abort the login") }, + async { bob_task.await.expect("Bob's task should finish") } + ); + } } diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs index adbadf31f..af8972f79 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/login.rs @@ -28,6 +28,8 @@ use ruma::{ }; use tracing::trace; use vodozemac::Curve25519PublicKey; +#[cfg(doc)] +use vodozemac::ecies::CheckCode; use super::{ DeviceAuthorizationOAuthError, QRCodeLoginError, SecureChannelError, @@ -247,7 +249,7 @@ pub enum LoginProgress { #[default] Starting, /// We have established the secure channel, but need to exchange the - /// checkcode so the channel can be verified to indeed be secure. + /// [`CheckCode`] so the channel can be verified to indeed be secure. EstablishingSecureChannel(Q), /// We're waiting for the OAuth 2.0 authorization server to give us the /// access token. This will only happen if the other device allows the @@ -278,7 +280,7 @@ impl LoginWithQrCode<'_> { /// Subscribe to the progress of QR code login. /// /// It's usually necessary to subscribe to this to let the existing device - /// know about the checkcode which is used to verify that the two + /// know about the [`CheckCode`] which is used to verify that the two /// devices are communicating in a secure manner. pub fn subscribe_to_progress(&self) -> impl Stream> + use<> { self.state.subscribe() diff --git a/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs b/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs index 9e9cb1aec..130080613 100644 --- a/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs +++ b/crates/matrix-sdk/src/authentication/oauth/qrcode/mod.rs @@ -46,7 +46,7 @@ mod rendezvous_channel; mod secure_channel; pub use self::{ - grant::{GrantLoginProgress, GrantLoginWithGeneratedQrCode}, + grant::{GrantLoginProgress, GrantLoginWithGeneratedQrCode, GrantLoginWithScannedQrCode}, login::{LoginProgress, LoginWithGeneratedQrCode, LoginWithQrCode}, messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage}, };