diff --git a/.typos.toml b/.typos.toml index f3a44bc416e..3a2312d4f82 100644 --- a/.typos.toml +++ b/.typos.toml @@ -25,6 +25,8 @@ ratatui = "ratatui" # base64 false positives Nd = "Nd" Abl = "Abl" +Som = "Som" +Ba = "Ba" [files] extend-exclude = [ diff --git a/Cargo.lock b/Cargo.lock index 302d478fac4..7d27be4ab60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1689,6 +1689,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "example-qr-login" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "futures-util", + "matrix-sdk", + "qrcode 0.13.0", + "tokio", + "tracing-subscriber", + "url", +] + [[package]] name = "example-secret-storage" version = "0.1.0" @@ -3156,6 +3170,7 @@ dependencies = [ "async-trait", "axum", "backoff", + "base64 0.22.1", "bytes", "bytesize", "chrono", @@ -3185,6 +3200,7 @@ dependencies = [ "mime", "mime2ext", "once_cell", + "openidconnect", "proptest", "rand", "reqwest", @@ -3194,6 +3210,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sha2", + "similar-asserts", "stream_assert", "tempfile", "thiserror", @@ -3207,6 +3224,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "vodozemac", "wasm-bindgen-test", "wiremock", "zeroize", @@ -3457,7 +3475,7 @@ version = "0.7.0" dependencies = [ "byteorder", "image", - "qrcode", + "qrcode 0.14.0", "ruma-common", "thiserror", "vodozemac", @@ -3858,6 +3876,25 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0-alpha.4" +source = "git+https://github.com/poljar/oauth2-rs?rev=f8e28ce5a7f3278ac85b8593ecdd86f2cf51fa2e#f8e28ce5a7f3278ac85b8593ecdd86f2cf51fa2e" +dependencies = [ + "base64 0.21.7", + "chrono", + "getrandom", + "http 1.1.0", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "oauth2-types" version = "0.9.0" @@ -3934,6 +3971,36 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.0-alpha.2" +source = "git+https://github.com/poljar/openidconnect-rs?rev=c7e1dc31b83dd7559125984bfd36b9c0f191585e#c7e1dc31b83dd7559125984bfd36b9c0f191585e" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.1.0", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror", + "url", +] + [[package]] name = "openssl" version = "0.10.64" @@ -4012,7 +4079,7 @@ dependencies = [ "futures-util", "once_cell", "opentelemetry", - "ordered-float", + "ordered-float 4.2.0", "percent-encoding", "rand", "thiserror", @@ -4024,6 +4091,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.2.0" @@ -4528,6 +4604,14 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcode" +version = "0.13.0" +source = "git+https://github.com/kennytm/qrcode-rust/#7aaa476e179d5bdc57df245530a7f97b0a79cd54" +dependencies = [ + "image", +] + [[package]] name = "qrcode" version = "0.14.0" @@ -5324,6 +5408,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde-wasm-bindgen" version = "0.6.5" @@ -5401,6 +5495,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.5" @@ -6563,7 +6666,7 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "vodozemac" version = "0.6.0" -source = "git+https://github.com/matrix-org/vodozemac/?rev=826d0aa22a9b5405535927c7691492db4b92a43b#826d0aa22a9b5405535927c7691492db4b92a43b" +source = "git+https://github.com/matrix-org/vodozemac/?rev=4ef989c6a8eba0bc809e285a081c56320a9bbf1e#4ef989c6a8eba0bc809e285a081c56320a9bbf1e" dependencies = [ "aes", "arrayvec", diff --git a/Cargo.toml b/Cargo.toml index 9e231b936f5..8e5dc95510c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ tracing-core = "0.1.32" uniffi = { version = "0.27.1" } uniffi_bindgen = { version = "0.27.1" } url = "2.5.0" -vodozemac = { git = "https://github.com/matrix-org/vodozemac/", rev = "826d0aa22a9b5405535927c7691492db4b92a43b" } +vodozemac = { git = "https://github.com/matrix-org/vodozemac/", rev = "4ef989c6a8eba0bc809e285a081c56320a9bbf1e" } wiremock = "0.6.0" zeroize = "1.6.0" diff --git a/benchmarks/benches/room_bench.rs b/benchmarks/benches/room_bench.rs index 40f0e633a9c..e9decee401f 100644 --- a/benchmarks/benches/room_bench.rs +++ b/benchmarks/benches/room_bench.rs @@ -64,10 +64,13 @@ pub fn receive_all_members_benchmark(c: &mut Criterion) { let base_client = BaseClient::with_store_config(StoreConfig::new().state_store(sqlite_store)); runtime - .block_on(base_client.set_session_meta(SessionMeta { - user_id: user_id!("@somebody:example.com").to_owned(), - device_id: device_id!("DEVICE_ID").to_owned(), - })) + .block_on(base_client.set_session_meta( + SessionMeta { + user_id: user_id!("@somebody:example.com").to_owned(), + device_id: device_id!("DEVICE_ID").to_owned(), + }, + None, + )) .expect("Could not set session meta"); base_client.get_or_create_room(&room_id, RoomState::Joined); diff --git a/crates/matrix-sdk-base/src/client.rs b/crates/matrix-sdk-base/src/client.rs index 8ecc0ff4a61..f8437805706 100644 --- a/crates/matrix-sdk-base/src/client.rs +++ b/crates/matrix-sdk-base/src/client.rs @@ -32,6 +32,8 @@ use ruma::events::{ room::{history_visibility::HistoryVisibility, message::MessageType}, SyncMessageLikeEvent, }; +#[cfg(doc)] +use ruma::DeviceId; use ruma::{ api::client as api, events::{ @@ -200,13 +202,28 @@ impl BaseClient { /// * `session_meta` - The meta of a session that the user already has from /// a previous login call. /// + /// * `custom_account` - A custom + /// [`matrix_sdk_crypto::vodozemac::olm::Account`] to be used for the + /// identity and one-time keys of this [`BaseClient`]. If no account is + /// provided, a new default one or one from the store will be used. If an + /// account is provided and one already exists in the store for this + /// [`UserId`]/[`DeviceId`] combination, an error will be raised. This is + /// useful if one wishes to create identity keys before knowing the + /// user/device IDs, e.g., to use the identity key as the device ID. + /// /// This method panics if it is called twice. - pub async fn set_session_meta(&self, session_meta: SessionMeta) -> Result<()> { + pub async fn set_session_meta( + &self, + session_meta: SessionMeta, + #[cfg(feature = "e2e-encryption")] custom_account: Option< + crate::crypto::vodozemac::olm::Account, + >, + ) -> Result<()> { debug!(user_id = ?session_meta.user_id, device_id = ?session_meta.device_id, "Restoring login"); self.store.set_session_meta(session_meta.clone(), &self.roominfo_update_sender).await?; #[cfg(feature = "e2e-encryption")] - self.regenerate_olm().await?; + self.regenerate_olm(custom_account).await?; Ok(()) } @@ -215,7 +232,10 @@ impl BaseClient { /// /// In particular, this will clear all its caches. #[cfg(feature = "e2e-encryption")] - pub async fn regenerate_olm(&self) -> Result<()> { + pub async fn regenerate_olm( + &self, + custom_account: Option, + ) -> Result<()> { tracing::debug!("regenerating OlmMachine"); let session_meta = self.session_meta().ok_or(Error::OlmError(OlmError::MissingSession))?; @@ -226,7 +246,7 @@ impl BaseClient { &session_meta.user_id, &session_meta.device_id, self.crypto_store.clone(), - None, + custom_account, ) .await .map_err(OlmError::from)?; @@ -1704,10 +1724,11 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id.to_owned(), - device_id: "FOOBAR".into(), - }) + .set_session_meta( + SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); @@ -1764,10 +1785,11 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id.to_owned(), - device_id: "FOOBAR".into(), - }) + .set_session_meta( + SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); @@ -1823,10 +1845,11 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id.to_owned(), - device_id: "FOOBAR".into(), - }) + .set_session_meta( + SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index 7c54c212c4d..586241d71a5 100644 --- a/crates/matrix-sdk-base/src/rooms/normal.rs +++ b/crates/matrix-sdk-base/src/rooms/normal.rs @@ -1624,10 +1624,14 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id!("@alice:example.org").into(), - device_id: ruma::device_id!("AYEAYEAYE").into(), - }) + .set_session_meta( + SessionMeta { + user_id: user_id!("@alice:example.org").into(), + device_id: ruma::device_id!("AYEAYEAYE").into(), + }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); @@ -1694,10 +1698,14 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id!("@alice:example.org").into(), - device_id: ruma::device_id!("AYEAYEAYE").into(), - }) + .set_session_meta( + SessionMeta { + user_id: user_id!("@alice:example.org").into(), + device_id: ruma::device_id!("AYEAYEAYE").into(), + }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); @@ -2137,10 +2145,14 @@ mod tests { let client = BaseClient::new(); client - .set_session_meta(SessionMeta { - user_id: user_id!("@alice:example.org").into(), - device_id: ruma::device_id!("AYEAYEAYE").into(), - }) + .set_session_meta( + SessionMeta { + user_id: user_id!("@alice:example.org").into(), + device_id: ruma::device_id!("AYEAYEAYE").into(), + }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .unwrap(); diff --git a/crates/matrix-sdk-base/src/test_utils.rs b/crates/matrix-sdk-base/src/test_utils.rs index f59f976b7e9..9ac48491ad8 100644 --- a/crates/matrix-sdk-base/src/test_utils.rs +++ b/crates/matrix-sdk-base/src/test_utils.rs @@ -27,7 +27,11 @@ pub(crate) async fn logged_in_base_client(user_id: Option<&UserId>) -> BaseClien let user_id = user_id.map(|user_id| user_id.to_owned()).unwrap_or_else(|| owned_user_id!("@u:e.uk")); client - .set_session_meta(SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }) + .set_session_meta( + SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }, + #[cfg(feature = "e2e-encryption")] + None, + ) .await .expect("set_session_meta failed!"); client diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 56b50de23be..794d0ee44d8 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -18,6 +18,8 @@ Breaking changes: Additions: +- Expose new method `Client::Oidc::login_with_qr_code()`. + ([#3466](https://github.com/matrix-org/matrix-rust-sdk/pull/3466)) - Add the `ClientBuilder::add_root_certificates()` method which re-exposes the `reqwest::ClientBuilder::add_root_certificate()` functionality. - Add `Room::get_user_power_level(user_id)` and `Room::get_suggested_user_role(user_id)` to be able to fetch power level info about an user without loading the room member list. diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index d51fd2ca93f..fa640fc51da 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -45,6 +45,7 @@ uniffi = ["dep:uniffi", "matrix-sdk-base/uniffi"] experimental-oidc = [ "ruma/unstable-msc2967", + "ruma/unstable-msc4108", "dep:chrono", "dep:http_old", "dep:language-tags", @@ -72,6 +73,7 @@ async-channel = "2.2.1" async-stream = { workspace = true } async-trait = { workspace = true } axum = { version = "0.7.4", optional = true } +base64 = { workspace = true } bytes = "1.1.0" bytesize = "1.1" chrono = { version = "0.4.23", optional = true } @@ -108,9 +110,10 @@ tokio-stream = { workspace = true, features = ["sync"] } tower = { version = "0.4.13", features = ["make"], optional = true } tracing = { workspace = true, features = ["attributes"] } uniffi = { workspace = true, optional = true } -url = { workspace = true } +url = { workspace = true, features = ["serde"] } urlencoding = "2.1.3" uuid = { version = "1.4.1", features = ["serde", "v4"], optional = true } +vodozemac = { workspace = true } zeroize = { workspace = true } [dependencies.image] @@ -126,6 +129,7 @@ tokio = { workspace = true, features = ["macros"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] backoff = { version = "0.4.0", features = ["tokio"] } +openidconnect = { git = "https://github.com/poljar/openidconnect-rs", rev = "c7e1dc31b83dd7559125984bfd36b9c0f191585e" } # only activate reqwest's stream feature on non-wasm, the wasm part seems to not # support *sending* streams, which makes it useless for us. reqwest = { workspace = true, features = ["stream"] } @@ -144,6 +148,7 @@ matrix-sdk-base = { workspace = true, features = ["testing"] } matrix-sdk-test = { workspace = true } once_cell = { workspace = true } serde_urlencoded = "0.7.1" +similar-asserts = "1.5.0" stream_assert = { workspace = true } tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } diff --git a/crates/matrix-sdk/src/authentication.rs b/crates/matrix-sdk/src/authentication/mod.rs similarity index 94% rename from crates/matrix-sdk/src/authentication.rs rename to crates/matrix-sdk/src/authentication/mod.rs index ac19cb43b44..7934c5e42a6 100644 --- a/crates/matrix-sdk/src/authentication.rs +++ b/crates/matrix-sdk/src/authentication/mod.rs @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -// TODO(pixlwave) Move AuthenticationService from the FFI into this module. +//! Types and functions related to authentication in Matrix. + +// TODO:(pixlwave) Move AuthenticationService from the FFI into this module. +// TODO:(poljar) Move the oidc and matrix_auth modules under this module. use std::pin::Pin; @@ -28,6 +31,9 @@ use crate::{ Client, RefreshTokenError, SessionChange, }; +#[cfg(all(feature = "experimental-oidc", feature = "e2e-encryption", not(target_arch = "wasm32")))] +pub mod qrcode; + /// Session tokens, for any kind of authentication. #[allow(missing_debug_implementations, clippy::large_enum_variant)] pub enum SessionTokens { diff --git a/crates/matrix-sdk/src/authentication/qrcode/login.rs b/crates/matrix-sdk/src/authentication/qrcode/login.rs new file mode 100644 index 00000000000..43b2a391d5c --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/login.rs @@ -0,0 +1,925 @@ +// Copyright 2024 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::future::IntoFuture; + +use eyeball::SharedObservable; +use futures_core::Stream; +use mas_oidc_client::types::{ + client_credentials::ClientCredentials, registration::VerifiedClientMetadata, +}; +use matrix_sdk_base::{ + boxed_into_future, + crypto::types::qr_login::{QrCodeData, QrCodeMode}, + SessionMeta, +}; +use openidconnect::DeviceCodeErrorResponseType; +use ruma::OwnedDeviceId; +use tracing::trace; +use vodozemac::ecies::CheckCode; + +use super::{ + messages::LoginFailureReason, oidc_client::OidcClient, DeviceAuhorizationOidcError, + SecureChannelError, +}; +#[cfg(doc)] +use crate::oidc::Oidc; +use crate::{ + authentication::qrcode::{ + messages::QrAuthMessage, secure_channel::EstablishedSecureChannel, QRCodeLoginError, + }, + Client, +}; + +async fn send_unexpected_message_error( + channel: &mut EstablishedSecureChannel, +) -> Result<(), SecureChannelError> { + channel + .send_json(QrAuthMessage::LoginFailure { + reason: LoginFailureReason::UnexpectedMessageReceived, + homeserver: None, + }) + .await +} + +/// Type telling us about the progress of the QR code login. +#[derive(Clone, Debug, Default)] +pub enum LoginProgress { + /// We're just starting up, this is the default and initial state. + #[default] + Starting, + /// We have established the secure channel, but we need to let the other + /// side know about the [`CheckCode`] so they can verify that the secure + /// channel is indeed secure. + EstablishingSecureChannel { + /// The check code we need to, out of band, send to the other device. + check_code: CheckCode, + }, + /// We're waiting for the OIDC provider to give us the access token. This + /// will only happen if the other device allows the OIDC provider to so. + WaitingForToken { + /// The user code the OIDC provider has given us, the OIDC provider + /// might ask the other device to enter this code. + user_code: String, + }, + /// The login process has completed. + Done, +} + +/// Named future for the [`Oidc::login_with_qr_code()`] method. +#[derive(Debug)] +pub struct LoginWithQrCode<'a> { + client: &'a Client, + client_metadata: VerifiedClientMetadata, + qr_code_data: &'a QrCodeData, + state: SharedObservable, +} + +impl<'a> LoginWithQrCode<'a> { + /// 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 + /// devices are communicating in a secure manner. + pub fn subscribe_to_progress(&self) -> impl Stream { + self.state.subscribe() + } +} + +impl<'a> IntoFuture for LoginWithQrCode<'a> { + type Output = Result<(), QRCodeLoginError>; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + // 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. + let mut channel = self.establish_secure_channel().await?; + + trace!("Established the secure channel."); + + // The other side isn't yet sure that it's talking to the right device, show + // a check code so they can confirm. + let check_code = channel.check_code().to_owned(); + self.state.set(LoginProgress::EstablishingSecureChannel { check_code }); + + // Register the client with the OIDC provider. + trace!("Registering the client with the OIDC provider."); + let oidc_client = self.register_client().await?; + + // We want to use the Curve25519 public key for the device ID, so let's generate + // a new vodozemac `Account` now. + let account = vodozemac::olm::Account::new(); + let public_key = account.identity_keys().curve25519; + let device_id = public_key; + + // Let's tell the OIDC provider that we want to log in using the device + // authorization grant described in [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628). + trace!("Requesting device authorization."); + let auth_grant_response = oidc_client.request_device_authorization(device_id).await?; + + // Now we need to inform the other device of the login protocols we picked and + // the URL they should use to log us in. + trace!("Letting the existing device know about the device authorization grant."); + let message = QrAuthMessage::authorization_grant_login_protocol( + (&auth_grant_response).into(), + device_id, + ); + channel.send_json(&message).await?; + + // Let's see if the other device agreed to our proposed protocols. + match channel.receive_json().await? { + QrAuthMessage::LoginProtocolAccepted => (), + QrAuthMessage::LoginFailure { reason, homeserver } => { + return Err(QRCodeLoginError::LoginFailure { reason, homeserver }); + } + message => { + send_unexpected_message_error(&mut channel).await?; + + return Err(QRCodeLoginError::UnexpectedMessage { + expected: "m.login.protocol_accepted", + received: message, + }); + } + } + + // The OIDC provider may or may not show this user code to double check that + // we're talking to the right OIDC provider. Let us display this, so + // the other device can double check this as well. + let user_code = auth_grant_response.user_code(); + self.state + .set(LoginProgress::WaitingForToken { user_code: user_code.secret().to_owned() }); + + // Let's now wait for the access token to be provided to use by the OIDC + // provider. + trace!("Waiting for the OIDC provider to give us the access token."); + let session_tokens = match oidc_client.wait_for_tokens(&auth_grant_response).await { + Ok(t) => t, + Err(e) => { + // If we received an error, and it's one of the ones we should report to the + // other side, do so now. + if let Some(e) = e.as_request_token_error() { + match e { + DeviceCodeErrorResponseType::AccessDenied => { + channel.send_json(QrAuthMessage::LoginDeclined).await?; + } + DeviceCodeErrorResponseType::ExpiredToken => { + channel + .send_json(QrAuthMessage::LoginFailure { + reason: LoginFailureReason::AuthorizationExpired, + homeserver: None, + }) + .await?; + } + _ => (), + } + } + + return Err(e.into()); + } + }; + self.client.oidc().set_session_tokens(session_tokens); + + // We only received an access token from the OIDC provider, we have no clue who + // we are, so we need to figure out our user ID now. + // TODO: This snippet is almost the same as the Oidc::finish_login_method(), why + // is that method even a public method and not called as part of the set session + // tokens method. + trace!("Discovering our own user id."); + let whoami_response = + self.client.whoami().await.map_err(QRCodeLoginError::UserIdDiscovery)?; + self.client + .set_session_meta( + SessionMeta { + user_id: whoami_response.user_id, + device_id: OwnedDeviceId::from(device_id.to_base64()), + }, + Some(account), + ) + .await + .map_err(QRCodeLoginError::SessionTokens)?; + + self.client.oidc().enable_cross_process_lock().await?; + + // Tell the existing device that we're logged in. + trace!("Telling the existing device that we successfully logged in."); + let message = QrAuthMessage::LoginSuccess; + channel.send_json(&message).await?; + + // Let's wait for the secrets bundle to be sent to us, otherwise we won't be a + // fully E2EE enabled device. + trace!("Waiting for the secrets bundle."); + let bundle = match channel.receive_json().await? { + QrAuthMessage::LoginSecrets(bundle) => bundle, + QrAuthMessage::LoginFailure { reason, homeserver } => { + return Err(QRCodeLoginError::LoginFailure { reason, homeserver }); + } + message => { + send_unexpected_message_error(&mut channel).await?; + + return Err(QRCodeLoginError::UnexpectedMessage { + expected: "m.login.secrets", + received: message, + }); + } + }; + + // Import the secrets bundle, this will allow us to sign the device keys with + // the master key when we upload them. + self.client.encryption().import_secrets_bundle(&bundle).await?; + + // Upload the device keys, this will ensure that other devices see us as a fully + // verified device ass soon as this method returns. + self.client + .encryption() + .ensure_device_keys_upload() + .await + .map_err(QRCodeLoginError::DeviceKeyUpload)?; + + // Run and wait for the E2EE initialization tasks, this will ensure that we + // ourselves see us as verified and the recovery/backup states will + // be known. If we did receive all the secrets in the secrets + // bundle, then backups will be enabled after this step as well. + self.client.encryption().run_initialization_tasks(None).await; + self.client.encryption().wait_for_e2ee_initialization_tasks().await; + + trace!("successfully logged in and enabled E2EE."); + + // Tell our listener that we're done. + self.state.set(LoginProgress::Done); + + // And indeed, we are done with the login. + Ok(()) + }) + } +} + +impl<'a> LoginWithQrCode<'a> { + pub(crate) fn new( + client: &'a Client, + client_metadata: VerifiedClientMetadata, + qr_code_data: &'a QrCodeData, + ) -> LoginWithQrCode<'a> { + LoginWithQrCode { client, client_metadata, qr_code_data, state: Default::default() } + } + + async fn establish_secure_channel( + &self, + ) -> Result { + let http_client = self.client.inner.http_client.inner.clone(); + + let channel = EstablishedSecureChannel::from_qr_code( + http_client, + self.qr_code_data, + QrCodeMode::Login, + ) + .await?; + + Ok(channel) + } + + async fn register_client(&self) -> Result { + // Let's figure out the OIDC issuer, this fetches the info from the homeserver. + let issuer = self + .client + .oidc() + .fetch_authentication_issuer() + .await + .map_err(DeviceAuhorizationOidcError::AuthenticationIssuer)?; + + // Now we register the client with the OIDC provider. + let registration_response = + self.client.oidc().register_client(&issuer, self.client_metadata.clone(), None).await?; + + // Now we need to put the relevant data we got from the regustration response + // into the `Client`. + // TODO: Why isn't `oidc().register_client()` doing this automatically? + self.client.oidc().restore_registered_client( + issuer.clone(), + self.client_metadata.clone(), + ClientCredentials::None { client_id: registration_response.client_id.clone() }, + ); + + // We're now switching to the openidconnect crate, it has a bit of a strange API + // where you need to provide the HTTP client in every call you make. + let http_client = self.client.inner.http_client.clone(); + + OidcClient::new( + registration_response.client_id, + issuer, + http_client, + registration_response.client_secret.as_deref(), + ) + .await + } +} + +#[cfg(test)] +mod test { + use assert_matches2::assert_let; + use futures_util::{join, StreamExt}; + use mas_oidc_client::types::{ + iana::oauth::OAuthClientAuthenticationMethod, + oidc::ApplicationType, + registration::{ClientMetadata, Localized}, + requests::GrantType, + }; + use matrix_sdk_base::crypto::types::{qr_login::QrCodeModeData, SecretsBundle}; + use matrix_sdk_test::{async_test, test_json}; + use serde_json::{json, Value}; + use url::Url; + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::{ + authentication::qrcode::{ + messages::LoginProtocolType, + secure_channel::{test::MockedRendezvousServer, SecureChannel}, + }, + config::RequestConfig, + http_client::HttpClient, + }; + + enum AliceBehaviour { + HappyPath, + DeclinedProtocol, + UnexpectedMessage, + UnexpectedMessageInsteadOfSecrets, + RefuseSecrets, + } + + fn client_metadata() -> VerifiedClientMetadata { + let client_uri = Url::parse("https://github.com/matrix-org/matrix-rust-sdk") + .expect("Couldn't parse client URI"); + + ClientMetadata { + application_type: Some(ApplicationType::Native), + redirect_uris: None, + grant_types: Some(vec![GrantType::DeviceCode]), + token_endpoint_auth_method: Some(OAuthClientAuthenticationMethod::None), + client_name: Some(Localized::new("test-matrix-rust-sdk-qrlogin".to_owned(), [])), + contacts: Some(vec!["root@127.0.0.1".to_owned()]), + client_uri: Some(Localized::new(client_uri.clone(), [])), + policy_uri: Some(Localized::new(client_uri.clone(), [])), + tos_uri: Some(Localized::new(client_uri, [])), + ..Default::default() + } + .validate() + .unwrap() + } + + fn open_id_configuration(server: &MockServer) -> Value { + let issuer_url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let account_management_uri = issuer_url.join("account").unwrap(); + let authorization_endpoint = issuer_url.join("authorize").unwrap(); + let device_authorization_endpoint = issuer_url.join("oauth2/device").unwrap(); + let jwks_url = issuer_url.join("oauth2/keys.json").unwrap(); + let registration_endpoint = issuer_url.join("oauth2/registration").unwrap(); + let token_endpoint = issuer_url.join("oauth2/token").unwrap(); + + json!({ + "account_management_actions_supported": [ + "org.matrix.profile", + "org.matrix.sessions_list", + "org.matrix.session_view", + "org.matrix.session_end", + "org.matrix.cross_signing_reset" + ], + "account_management_uri": account_management_uri, + "authorization_endpoint": authorization_endpoint, + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": false, + "claims_supported": [ + "iss", + "sub", + "aud", + "iat", + "exp", + "nonce", + "auth_time", + "at_hash", + "c_hash" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "device_authorization_endpoint": device_authorization_endpoint, + "display_values_supported": [ + "page" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "PS256", + "PS384", + "PS512", + "ES256K" + ], + "issuer": issuer_url.to_string().trim_end_matches("/"), + "jwks_uri": jwks_url, + "prompt_values_supported": [ + "none", + "login", + "create" + ], + "registration_endpoint": registration_endpoint, + "request_parameter_supported": false, + "request_uri_parameter_supported": false, + "response_modes_supported": [ + "form_post", + "query", + "fragment" + ], + "response_types_supported": [ + "code", + "id_token", + "code id_token" + ], + "scopes_supported": [ + "openid", + "email" + ], + "subject_types_supported": [ + "public" + ], + "token_endpoint": token_endpoint, + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + }) + } + + fn keys_json() -> Value { + json!({ + "keys": [ + { + "e": "AQAB", + "kid": "hxdHWoF9mn", + "kty": "RSA", + "n": "u4op7tDV41j-f_-DqsqjjCObiySB0q2CGS1JVjJXbV5jctHP6Wp_oMb2aIImMdHDcnTvxaID\ + WwuKA8o-0SBfkHFifMHHRvePz_l7NxxUMyGX8Bfu_EVkECe50BXpFydcEEl1eIIsPW-F0WJKFYR\ + 5cscmBgRX3zv_w7WFbaOLh711S9DNu21epdSvFSrKRe9oG_FbeOFfDl-YU7BLGFvEozg9Z3hKF\ + SomOlz-t3ABvRUweGuLCpHFKsI6yhGCoqPyS7o5gpfenizdfHLqq-l7kgyr7lSbW_mTSyYutby\ + DpQ_HM98Lt-4a9zwlGfiqPS3svkH6KSd1mBcayCI0Cm9FuQ", + "use": "sig" + }, + { + "crv": "P-256", + "kid": "IRbxoGCBjs", + "kty": "EC", + "use": "sig", + "x": "1AYfsklcgvscvJiNZ1Og7vQePzIBf-flJKlANWJ7D4g", + "y": "L4b-jMZVZlnLhXCpV0EOc6zdEz1e6ONgKQZVE3jOBhY" + }, + { + "crv": "P-384", + "kid": "FjEZp4JjqW", + "kty": "EC", + "use": "sig", + "x": "bZP2bPUEQGeGaDICINswZSTCHdoVmDD3LIJE1Szxw27ruCJBW-sy_lY3dhA2FjWm", + "y": "3HMgAu___-4JG9IXZFXwzr5nU_GUPvmWJHqgS7vzK1S91s0v1GXiqQMHwYA0keYG" + }, + { + "crv": "secp256k1", + "kid": "7ohCuHzgqB", + "kty": "EC", + "use": "sig", + "x": "80KXhBY8JBy8qO9-wMBaGtgOgtagowHJ4dDGfVr4eVw", + "y": "0ALeT-J40AjdIS4S1YDgMrPkyE_rnw9wVm7Dvz_9Np4" + } + ] + }) + } + + fn device_code(server: &MockServer) -> Value { + let issuer_url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let verification_uri = issuer_url.join("link").unwrap(); + let mut verification_uri_complete = issuer_url.join("link").unwrap(); + verification_uri_complete.set_query(Some("code=N32YVC")); + + json!({ + "device_code": "N8NAYD9fOhMulpm37mSthx0xSw2p7vdR", + "expires_in": 1200, + "interval": 5, + "user_code": "N32YVC", + "verification_uri": verification_uri, + "verification_uri_complete": verification_uri_complete, + }) + } + + fn token() -> Value { + json!({ + "access_token": "mat_z65RpDAbvR5aTr7MzD0aPw40xFbwch_09xTgn", + "expires_in": 300, + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJhdWQiOiIwMUhZRlpEQ1\ + BTV1dCREVWWkQyRlRBUVlFViIsInN1YiI6IjAxSFYxNzNTSjQxUDBGMFgxQ0FRU1lBVENQIiwiaWF0IjoxN\ + zE2Mzc1NzIwLCJpc3MiOiJodHRwczovL2F1dGgtb2lkYy5sYWIuZWxlbWVudC5kZXYvIiwiZXhwIjoxNzE2\ + Mzc5MzIwLCJhdF9oYXNoIjoieGZIS21qQW83cEVCRmUwTkM5ODJEQSJ9.HQs7Si5gU_5tm2hYaCa3jg0kPO\ + MXGNdpV88MWzG6N9x3yXK0ZGgn58i38HiQTbiyPuhw8OH6baMSjbcVP-KXSDpsSPZbkmp7Ozb50dC0eIebD\ + aVK0EyZ35KQRVc5BFPQBPbq0r_TrcUgjoLRKpoexvdmjfEb2dE-kKse25jfs-bTHKP6jeAyFgR9Emn0RfVx\ + 32He32-bRP1NfkBnPNnJse32tF1o8gs7zG-cm7kSUx1wiQbvfSGfETx_mJ-aFGABbVGKQlTrCe32HUTvNbp\ + tT2WXa1t7d3eDuEV_6hZS9LFRdIXhgEcGIZMz_ss3WQsSOKN8Yq2NC8_bNxRAQ-1J3A", + "refresh_token": "mar_CHFh124AMHsdishuHgLSx1svdKMVQA_080gj2", + "scope": "openid \ + urn:matrix:org.matrix.msc2967.client:api:* \ + urn:matrix:org.matrix.msc2967.client:device:\ + lKa+6As0PSFtqOMKALottO6hlt3gCpZtaVfHanSUnEE", + "token_type": "Bearer" + }) + } + + fn secrets_bundle() -> SecretsBundle { + let json = json!({ + "cross_signing": { + "master_key": "rTtSv67XGS6k/rg6/yTG/m573cyFTPFRqluFhQY+hSw", + "self_signing_key": "4jbPt7jh5D2iyM4U+3IDa+WthgJB87IQN1ATdkau+xk", + "user_signing_key": "YkFKtkjcsTxF6UAzIIG/l6Nog/G2RigCRfWj3cjNWeM", + }, + }); + + serde_json::from_value(json).expect("We should be able to deserialize a secrets bundle") + } + + /// This is most of the code that is required to be the other side, the + /// existing device, of the QR login dance. + /// + /// TODO: Expose this as a feature user can use. + async fn grant_login( + alice: SecureChannel, + check_code_receiver: tokio::sync::oneshot::Receiver, + behavior: AliceBehaviour, + ) { + let alice = alice.connect().await.expect("Alice should be able to connect the channel"); + + let check_code = + check_code_receiver.await.expect("We should receive the check code from bob"); + + let mut alice = alice + .confirm(check_code.to_digit()) + .expect("Alice should be able to confirm the secure channel"); + + let message = alice + .receive_json() + .await + .expect("Alice should be able to receive the initial message from Bob"); + + assert_let!(QrAuthMessage::LoginProtocol { protocol, .. } = message); + assert_eq!(protocol, LoginProtocolType::DeviceAuthorizationGrant); + + let message = match behavior { + AliceBehaviour::DeclinedProtocol => QrAuthMessage::LoginFailure { + reason: LoginFailureReason::UnsupportedProtocol, + homeserver: None, + }, + AliceBehaviour::UnexpectedMessage => QrAuthMessage::LoginDeclined, + _ => QrAuthMessage::LoginProtocolAccepted, + }; + + alice.send_json(message).await.unwrap(); + + let message: QrAuthMessage = alice.receive_json().await.unwrap(); + assert_let!(QrAuthMessage::LoginSuccess = message); + + let message = match behavior { + AliceBehaviour::UnexpectedMessageInsteadOfSecrets => QrAuthMessage::LoginDeclined, + AliceBehaviour::RefuseSecrets => QrAuthMessage::LoginFailure { + reason: LoginFailureReason::DeviceNotFound, + homeserver: None, + }, + _ => QrAuthMessage::LoginSecrets(secrets_bundle()), + }; + + alice.send_json(message).await.unwrap(); + } + + async fn mock_oidc_provider(server: &MockServer, token_response: ResponseTemplate) { + Mock::given(method("GET")) + .and(path("/_matrix/client/unstable/org.matrix.msc2965/auth_issuer")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "issuer": server.uri(), + + }))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(open_id_configuration(server))) + .expect(1..) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/registration")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "client_id": "01HYFZDCPSWWBDEVZD2FTAQYEV", + "client_id_issued_at": 1716375696 + }))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/oauth2/keys.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(keys_json())) + .expect(1) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(device_code(server))) + .expect(1) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(path("/oauth2/token")) + .respond_with(token_response) + .mount(server) + .await; + } + + #[async_test] + async fn test_qr_login() { + let server = MockServer::start().await; + let rendezvous_server = MockedRendezvousServer::new(&server, "abcdEFG12345").await; + let (sender, receiver) = tokio::sync::oneshot::channel(); + + mock_oidc_provider(&server, ResponseTemplate::new(200).set_body_json(token())).await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/r0/account/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::WHOAMI)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/versions")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::VERSIONS)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/_matrix/client/r0/keys/upload")) + .and(header("authorization", "Bearer mat_z65RpDAbvR5aTr7MzD0aPw40xFbwch_09xTgn")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::KEYS_UPLOAD)) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/_matrix/client/r0/keys/query")) + .and(header("authorization", "Bearer mat_z65RpDAbvR5aTr7MzD0aPw40xFbwch_09xTgn")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + .await + .expect("Alice should be able to create a secure channel."); + + assert_let!( + QrCodeModeData::Reciprocate { homeserver_url } = &alice.qr_code_data().mode_data + ); + + let bob = Client::builder() + .homeserver_url(homeserver_url) + .request_config(RequestConfig::new().disable_retry()) + .build() + .await + .expect("We should be able to build the Client object from the URL in the QR code"); + + let qr_code = alice.qr_code_data().clone(); + + let oidc = bob.oidc(); + let login_bob = oidc.login_with_qr_code(&qr_code, client_metadata()); + let mut updates = login_bob.subscribe_to_progress(); + + let updates_task = tokio::spawn(async move { + let mut sender = Some(sender); + + while let Some(update) = updates.next().await { + match update { + LoginProgress::EstablishingSecureChannel { check_code } => { + sender + .take() + .expect("The establishing secure channel update should be received only once") + .send(check_code) + .expect("Bob should be able to send the check code to Alice"); + } + LoginProgress::Done => break, + _ => (), + } + } + }); + let alice_task = + tokio::spawn(async { grant_login(alice, receiver, AliceBehaviour::HappyPath).await }); + + join!( + async { + login_bob.await.expect("Bob should be able to login"); + }, + async { + alice_task.await.expect("Alice should have completed it's task successfully"); + }, + async { updates_task.await.unwrap() } + ); + + assert!(bob.encryption().cross_signing_status().await.unwrap().is_complete()); + let own_identity = + bob.encryption().get_user_identity(bob.user_id().unwrap()).await.unwrap().unwrap(); + + assert!(own_identity.is_verified()); + } + + async fn test_failure( + token_response: ResponseTemplate, + alice_behavior: AliceBehaviour, + ) -> Result<(), QRCodeLoginError> { + let server = MockServer::start().await; + let rendezvous_server = MockedRendezvousServer::new(&server, "abcdEFG12345").await; + let (sender, receiver) = tokio::sync::oneshot::channel(); + + mock_oidc_provider(&server, token_response).await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/r0/account/whoami")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::WHOAMI)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/_matrix/client/versions")) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::VERSIONS)) + .mount(&server) + .await; + + let client = HttpClient::new(reqwest::Client::new(), Default::default()); + let alice = SecureChannel::new(client, &rendezvous_server.homeserver_url) + .await + .expect("Alice should be able to create a secure channel."); + + assert_let!( + QrCodeModeData::Reciprocate { homeserver_url } = &alice.qr_code_data().mode_data + ); + + let bob = Client::builder() + .homeserver_url(homeserver_url) + .request_config(RequestConfig::new().disable_retry()) + .build() + .await + .expect("We should be able to build the Client object from the URL in the QR code"); + + let qr_code = alice.qr_code_data().clone(); + + let oidc = bob.oidc(); + let login_bob = oidc.login_with_qr_code(&qr_code, client_metadata()); + let mut updates = login_bob.subscribe_to_progress(); + + let _updates_task = tokio::spawn(async move { + let mut sender = Some(sender); + + while let Some(update) = updates.next().await { + match update { + LoginProgress::EstablishingSecureChannel { check_code } => { + sender + .take() + .expect("The establishing secure channel update should be received only once") + .send(check_code) + .expect("Bob should be able to send the check code to Alice"); + } + LoginProgress::Done => break, + _ => (), + } + } + }); + let _alice_task = + tokio::spawn(async move { grant_login(alice, receiver, alice_behavior).await }); + login_bob.await + } + + #[async_test] + async fn test_qr_login_refused_access_token() { + let result = test_failure( + ResponseTemplate::new(400).set_body_json(json!({ + "error": "access_denied", + })), + AliceBehaviour::HappyPath, + ) + .await; + + assert_let!(Err(QRCodeLoginError::Oidc(e)) = result); + assert_eq!( + e.as_request_token_error(), + Some(&DeviceCodeErrorResponseType::AccessDenied), + "The server should have told us that access has been denied." + ); + } + + #[async_test] + async fn test_qr_login_expired_token() { + let result = test_failure( + ResponseTemplate::new(400).set_body_json(json!({ + "error": "expired_token", + })), + AliceBehaviour::HappyPath, + ) + .await; + + assert_let!(Err(QRCodeLoginError::Oidc(e)) = result); + assert_eq!( + e.as_request_token_error(), + Some(&DeviceCodeErrorResponseType::ExpiredToken), + "The server should have told us that access has been denied." + ); + } + + #[async_test] + async fn test_qr_login_declined_protocol() { + let result = test_failure( + ResponseTemplate::new(200).set_body_json(token()), + AliceBehaviour::DeclinedProtocol, + ) + .await; + + assert_let!(Err(QRCodeLoginError::LoginFailure { reason, .. }) = result); + assert_eq!( + reason, + LoginFailureReason::UnsupportedProtocol, + "Alice should have told us that the protocol is unsupported." + ); + } + + #[async_test] + async fn test_qr_login_unexpected_message() { + let result = test_failure( + ResponseTemplate::new(200).set_body_json(token()), + AliceBehaviour::UnexpectedMessage, + ) + .await; + + assert_let!(Err(QRCodeLoginError::UnexpectedMessage { expected, .. }) = result); + assert_eq!(expected, "m.login.protocol_accepted"); + } + + #[async_test] + async fn test_qr_login_unexpected_message_instead_of_secrets() { + let result = test_failure( + ResponseTemplate::new(200).set_body_json(token()), + AliceBehaviour::UnexpectedMessageInsteadOfSecrets, + ) + .await; + + assert_let!(Err(QRCodeLoginError::UnexpectedMessage { expected, .. }) = result); + assert_eq!(expected, "m.login.secrets"); + } + + #[async_test] + async fn test_qr_login_refuse_secrets() { + let result = test_failure( + ResponseTemplate::new(200).set_body_json(token()), + AliceBehaviour::RefuseSecrets, + ) + .await; + + assert_let!(Err(QRCodeLoginError::LoginFailure { reason, .. }) = result); + assert_eq!(reason, LoginFailureReason::DeviceNotFound); + } +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/messages.rs b/crates/matrix-sdk/src/authentication/qrcode/messages.rs new file mode 100644 index 00000000000..6ea48198823 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/messages.rs @@ -0,0 +1,326 @@ +// Copyright 2024 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 matrix_sdk_base::crypto::types::SecretsBundle; +use openidconnect::{ + core::CoreDeviceAuthorizationResponse, EndUserVerificationUrl, VerificationUriComplete, +}; +use ruma::serde::StringEnum; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; +use vodozemac::Curve25519PublicKey; + +#[cfg(doc)] +use crate::authentication::qrcode::QRCodeLoginError::SecureChannel; + +/// Messages that will be exchanged over the [`SecureChannel`] to log in a new +/// device using a QR code. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum QrAuthMessage { + /// Message declaring the available protocols for sign in. Sent by the + /// existing device. + #[serde(rename = "m.login.protocols")] + LoginProtocols { + /// The login protocols the existing device supports. + protocols: Vec, + /// The homeserver we're going to log in to. + homeserver: Url, + }, + + /// Message declaring which protocols from the previous `m.login.protocols` + /// message the new device has picked. Sent by the new device. + #[serde(rename = "m.login.protocol")] + LoginProtocol { + /// The device authorization grant the OIDC provider has given to the + /// new device, contains the URL the existing device should use + /// to confirm the log in. + device_authorization_grant: AuthorizationGrant, + /// The protocol the new device has picked. + protocol: LoginProtocolType, + #[serde( + deserialize_with = "deserialize_curve_key", + serialize_with = "serialize_curve_key" + )] + /// The device ID the new device will be using. + device_id: Curve25519PublicKey, + }, + + /// Message declaring that the protocol in the previous `m.login.protocol` + /// message was accepted. Sent by the existing device. + #[serde(rename = "m.login.protocol_accepted")] + LoginProtocolAccepted, + + /// Message that informs the existing device that it successfully obtained + /// an access token from the OIDC provider. Sent by the new device. + #[serde(rename = "m.login.success")] + LoginSuccess, + + /// Message that informs the existing device that the OIDC provider has + /// declined to give us an access token, i.e. because the user declined + /// the log in. Sent by the new device. + #[serde(rename = "m.login.declined")] + LoginDeclined, + + /// Message signaling that a failure happened during the login. Can be sent + /// by either device. + #[serde(rename = "m.login.failure")] + LoginFailure { + /// The claimed reason for the login failure. + reason: LoginFailureReason, + /// The homeserver that we attempted to log in to. + homeserver: Option, + }, + + /// Message containing end-to-end encryption related secrets, the new device + /// can use these secrets to mark itself as verified, connect to a room + /// key backup, and login other devices via a QR login. Sent by the + /// existing device. + #[serde(rename = "m.login.secrets")] + LoginSecrets(SecretsBundle), +} + +impl QrAuthMessage { + /// Create a new [`QrAuthMessage::LoginProtocol`] message with the + /// [`LoginProtocolType::DeviceAuthorizationGrant`] protocol type. + pub fn authorization_grant_login_protocol( + device_authorization_grant: AuthorizationGrant, + device_id: Curve25519PublicKey, + ) -> QrAuthMessage { + QrAuthMessage::LoginProtocol { + device_id, + device_authorization_grant, + protocol: LoginProtocolType::DeviceAuthorizationGrant, + } + } +} + +impl From<&CoreDeviceAuthorizationResponse> for AuthorizationGrant { + fn from(value: &CoreDeviceAuthorizationResponse) -> Self { + Self { + verification_uri: value.verification_uri().clone(), + verification_uri_complete: value.verification_uri_complete().cloned(), + } + } +} + +/// Data for the device authorization grant login protocol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationGrant { + /// The verification URL the user should open to log the new device in. + pub verification_uri: EndUserVerificationUrl, + + /// The verification URL, with the user code pre-filled, which the user + /// should open to log the new device in. If this URL is available, the + /// user should be presented with it instead of the one in the + /// [`AuthorizationGrant::verification_uri`] field. + pub verification_uri_complete: Option, +} + +/// Reasons why the login might have failed. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)] +#[ruma_enum(rename_all = "snake_case")] +pub enum LoginFailureReason { + /// The Device Authorization Grant expired. + AuthorizationExpired, + /// The device ID specified by the new device already exists in the + /// homeserver provided device list. + DeviceAlreadyExists, + /// The new device is not present in the device list as returned by the + /// homeserver. + DeviceNotFound, + /// Sent by either device to indicate that they received a message of a type + /// that they weren't expecting. + UnexpectedMessageReceived, + /// Sent by a device where no suitable protocol is available or the + /// requested protocol requested is not supported. + UnsupportedProtocol, + /// Sent by either new or existing device to indicate that the user has + /// cancelled the login. + UserCancelled, + #[doc(hidden)] + _Custom(PrivOwnedStr), +} + +/// Enum containing known login protocol types. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, StringEnum)] +#[ruma_enum(rename_all = "snake_case")] +pub enum LoginProtocolType { + /// The `device_authorization_grant` login protocol type. + DeviceAuthorizationGrant, + #[doc(hidden)] + _Custom(PrivOwnedStr), +} + +// Vodozemac serializes Curve25519 keys directly as a byteslice, while Matrix +// likes to base64 encode all byte slices. +// +// This ensures that we serialize/deserialize in a Matrix-compatible way. +pub(crate) fn deserialize_curve_key<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + let key: String = Deserialize::deserialize(de)?; + + Curve25519PublicKey::from_base64(&key).map_err(serde::de::Error::custom) +} + +pub(crate) fn serialize_curve_key(key: &Curve25519PublicKey, s: S) -> Result +where + S: Serializer, +{ + s.serialize_str(&key.to_base64()) +} + +// Wrapper around `Box` that cannot be used in a meaningful way outside of +// this crate. Used for string enums because their `_Custom` variant can't be +// truly private (only `#[doc(hidden)]`). +// TODO: It probably makes sense to move the above messages into Ruma, if for +// nothing else, to get rid of this `PrivOwnedStr`. +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PrivOwnedStr(Box); + +#[cfg(test)] +mod test { + use assert_matches2::assert_let; + use matrix_sdk_base::crypto::types::BackupSecrets; + use serde_json::json; + use similar_asserts::assert_eq; + + use super::*; + + #[test] + fn test_protocols_serialization() { + let json = json!({ + "type": "m.login.protocols", + "protocols": ["device_authorization_grant"], + "homeserver": "https://matrix-client.matrix.org/" + + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginProtocols { protocols, .. } = &message); + assert!(protocols.contains(&LoginProtocolType::DeviceAuthorizationGrant)); + + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_protocol_serialization() { + let json = json!({ + "type": "m.login.protocol", + "protocol": "device_authorization_grant", + "device_authorization_grant": { + "verification_uri_complete": "https://id.matrix.org/device/abcde", + "verification_uri": "https://id.matrix.org/device/abcde?code=ABCDE" + }, + "device_id": "wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/sSRQ2oAKk4" + }); + let curve_key = + Curve25519PublicKey::from_base64("wjLpTLRqbqBzLs63aYaEv2Boi6cFEbbM/sSRQ2oAKk4") + .unwrap(); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginProtocol { protocol, device_id, .. } = &message); + assert_eq!(protocol, &LoginProtocolType::DeviceAuthorizationGrant); + assert_eq!(device_id, &curve_key); + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_protocol_accepted_serialization() { + let json = json!({ + "type": "m.login.protocol_accepted", + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginProtocolAccepted = &message); + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_login_success() { + let json = json!({ + "type": "m.login.success", + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginSuccess = &message); + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_login_declined() { + let json = json!({ + "type": "m.login.declined", + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginDeclined = &message); + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_login_failure() { + let json = json!({ + "type": "m.login.failure", + "reason": "unsupported_protocol", + "homeserver": "https://matrix-client.matrix.org/" + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!(QrAuthMessage::LoginFailure { reason, .. } = &message); + assert_eq!(reason, &LoginFailureReason::UnsupportedProtocol); + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } + + #[test] + fn test_login_secrets() { + let json = json!({ + "type": "m.login.secrets", + "cross_signing": { + "master_key": "rTtSv67XGS6k/rg6/yTG/m573cyFTPFRqluFhQY+hSw", + "self_signing_key": "4jbPt7jh5D2iyM4U+3IDa+WthgJB87IQN1ATdkau+xk", + "user_signing_key": "YkFKtkjcsTxF6UAzIIG/l6Nog/G2RigCRfWj3cjNWeM", + }, + "backup": { + "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2", + "backup_version": "2", + "key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + }); + + let message: QrAuthMessage = serde_json::from_value(json.clone()).unwrap(); + assert_let!( + QrAuthMessage::LoginSecrets(SecretsBundle { cross_signing, backup }) = &message + ); + assert_eq!(cross_signing.master_key, "rTtSv67XGS6k/rg6/yTG/m573cyFTPFRqluFhQY+hSw"); + assert_eq!(cross_signing.self_signing_key, "4jbPt7jh5D2iyM4U+3IDa+WthgJB87IQN1ATdkau+xk"); + assert_eq!(cross_signing.user_signing_key, "YkFKtkjcsTxF6UAzIIG/l6Nog/G2RigCRfWj3cjNWeM"); + + assert_let!(Some(BackupSecrets::MegolmBackupV1Curve25519AesSha2(backup)) = backup); + assert_eq!(backup.backup_version, "2"); + assert_eq!(&backup.key.to_base64(), "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + let serialized = serde_json::to_value(&message).unwrap(); + assert_eq!(json, serialized); + } +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/mod.rs b/crates/matrix-sdk/src/authentication/qrcode/mod.rs new file mode 100644 index 00000000000..752f21e0c8f --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/mod.rs @@ -0,0 +1,216 @@ +// Copyright 2024 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. + +//! Types for the QR code login support defined in [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108). +//! +//! Please note, QR code logins are only supported when using OIDC as the +//! auththentication mechanism, native Matrix authentication does not support +//! it. +//! +//! This currently only implements the case where the new device is scanning the +//! QR code. To log in using a QR code, please take a look at the +//! [`Oidc::login_with_qr_code()`] method + +use as_variant::as_variant; +use matrix_sdk_base::crypto::SecretImportError; +pub use openidconnect::{ + core::CoreErrorResponseType, ConfigurationError, DeviceCodeErrorResponseType, DiscoveryError, + HttpClientError, RequestTokenError, StandardErrorResponse, +}; +use thiserror::Error; +use url::Url; +pub use vodozemac::ecies::{Error as EciesError, MessageDecodeError}; + +#[cfg(doc)] +use crate::oidc::Oidc; +use crate::{oidc::CrossProcessRefreshLockError, HttpError}; + +mod login; +mod messages; +mod oidc_client; +mod rendezvous_channel; +mod secure_channel; + +pub use matrix_sdk_base::crypto::types::qr_login::{ + LoginQrCodeDecodeError, QrCodeData, QrCodeMode, QrCodeModeData, +}; + +pub use self::{ + login::{LoginProgress, LoginWithQrCode}, + messages::{LoginFailureReason, LoginProtocolType, QrAuthMessage}, +}; + +/// The error type for failures while trying to log in a new device using a QR +/// code. +#[derive(Debug, Error)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))] +pub enum QRCodeLoginError { + /// An error happened while we were communicating with the OIDC provider. + #[error(transparent)] + Oidc(#[from] DeviceAuhorizationOidcError), + + /// The other device has signaled to us that the login has failed. + #[error("The login failed, reason: {reason}")] + LoginFailure { + /// The reason, as signaled by the other device, for the login failure. + reason: LoginFailureReason, + /// The homeserver that we attempted to log in to. + homeserver: Option, + }, + + /// An unexpected message was received from the other device. + #[error("We have received an unexpected message, expected: {expected}, got {received:?}")] + UnexpectedMessage { + /// The message we expected. + expected: &'static str, + /// The message we received instead. + received: QrAuthMessage, + }, + + /// An error happened while exchanging messages with the other device. + #[error(transparent)] + SecureChannel(#[from] SecureChannelError), + + /// The cross-process refresh lock failed to be initialized. + #[error(transparent)] + CrossProcessRefreshLock(#[from] CrossProcessRefreshLockError), + + /// An error happened while we were trying to discover our user and device + /// ID, after we have acquired an access token from the OIDC provider. + #[error(transparent)] + UserIdDiscovery(HttpError), + + /// We failed to set the session tokens after we figured out our device and + /// user IDs. + #[error(transparent)] + SessionTokens(crate::Error), + + /// The device keys failed to be uploaded after we successfully logged in. + #[error(transparent)] + DeviceKeyUpload(crate::Error), + + /// The secrets bundle we received from the existing device failed to be + /// imported. + #[error(transparent)] + SecretImport(#[from] SecretImportError), +} + +/// Error type describing failures in the interaction between the device +/// attempting to log in and the OIDC provider. +#[derive(Debug, Error)] +pub enum DeviceAuhorizationOidcError { + /// A generic OIDC error happened while we were attempting to register the + /// device with the OIDC provider. + #[error(transparent)] + Oidc(#[from] crate::oidc::OidcError), + + /// The issuer URL failed to be parsed. + #[error(transparent)] + InvalidIssuerUrl(#[from] url::ParseError), + + /// There was an error with our device configuration right before attempting + /// to wait for the access token to be issued by the OIDC provider. + #[error(transparent)] + Configuration(#[from] ConfigurationError), + + /// An error happened while we attempted to discover the authentication + /// issuer URL. + #[error(transparent)] + AuthenticationIssuer(HttpError), + + /// An error happened while we attempted to request a device authorization + /// from the OIDC provider. + #[error(transparent)] + DeviceAuthorization( + #[from] + RequestTokenError< + HttpClientError, + StandardErrorResponse, + >, + ), + + /// An error happened while waiting for the access token to be issued and + /// sent to us by the OIDC provider. + #[error(transparent)] + RequestToken( + #[from] + RequestTokenError< + HttpClientError, + StandardErrorResponse, + >, + ), + + /// An error happened during the discovery of the OIDC provider metadata. + #[error(transparent)] + Discovery(#[from] DiscoveryError>), +} + +impl DeviceAuhorizationOidcError { + /// If the [`DeviceAuhorizationOidcError`] is of the + /// [`DeviceCodeErrorResponseType`] error variant, return it. + pub fn as_request_token_error(&self) -> Option<&DeviceCodeErrorResponseType> { + let error = as_variant!(self, DeviceAuhorizationOidcError::RequestToken)?; + let request_token_error = as_variant!(error, RequestTokenError::ServerResponse)?; + + Some(request_token_error.error()) + } +} + +/// Error type for failures in when receiving or sending messages over the +/// secure channel. +#[derive(Debug, Error)] +pub enum SecureChannelError { + /// A message we received over the secure channel was not a valid UTF-8 + /// encoded string. + #[error(transparent)] + Utf8(#[from] std::str::Utf8Error), + + /// A message has failed to be decrypted. + #[error(transparent)] + Ecies(#[from] EciesError), + + /// A received message has failed to be decoded. + #[error(transparent)] + MessageDecode(#[from] MessageDecodeError), + + /// A message couldn't be deserialized from JSON. + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// The secure channel failed to be established because it received an + /// unexpected message. + #[error( + "The secure channel setup has received an unexpected message, expected: {expected}, got {received}" + )] + SecureChannelMessage { + /// The secure channel message we expected. + expected: &'static str, + /// The secure channel message we received instead. + received: String, + }, + + /// The secure channel could not have been established, the check code was + /// invalid. + #[error("The secure channel could not have been established, the check code was invalid")] + InvalidCheckCode, + + /// An error happened in the underlying rendezvous channel. + #[error("Error in the rendezvous channel: {0:?}")] + RendezvousChannel(#[from] HttpError), + + /// Both devices have advertised the same intent in the login attempt, i.e. + /// both sides claim to be a new device. + #[error("The secure channel could not have been established, the two devices have the same login intent")] + InvalidIntent, +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs b/crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs new file mode 100644 index 00000000000..3cdc3a0a272 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/oidc_client.rs @@ -0,0 +1,187 @@ +// Copyright 2024 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::pin::Pin; + +use futures_core::Future; +use mas_oidc_client::types::scope::{MatrixApiScopeToken, ScopeToken}; +use openidconnect::{ + core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClient, + CoreClientAuthMethod, CoreDeviceAuthorizationResponse, CoreErrorResponseType, + CoreGenderClaim, CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, + CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken, + CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, + CoreTokenResponse, + }, + AdditionalProviderMetadata, AuthType, ClientId, ClientSecret, DeviceAuthorizationUrl, + EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, HttpClientError, + HttpRequest, IssuerUrl, OAuth2TokenResponse, ProviderMetadata, Scope, StandardErrorResponse, +}; +use vodozemac::Curve25519PublicKey; + +use super::DeviceAuhorizationOidcError; +use crate::{http_client::HttpClient, oidc::OidcSessionTokens}; + +// Obtain the device_authorization_url from the OIDC metadata provider. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct DeviceEndpointProviderMetadata { + device_authorization_endpoint: DeviceAuthorizationUrl, +} +impl AdditionalProviderMetadata for DeviceEndpointProviderMetadata {} + +type DeviceProviderMetadata = ProviderMetadata< + DeviceEndpointProviderMetadata, + CoreAuthDisplay, + CoreClientAuthMethod, + CoreClaimName, + CoreClaimType, + CoreGrantType, + CoreJweContentEncryptionAlgorithm, + CoreJweKeyManagementAlgorithm, + CoreJsonWebKey, + CoreResponseMode, + CoreResponseType, + CoreSubjectIdentifierType, +>; + +/// OpenID Connect Core client. +pub type OidcClientInner< + HasAuthUrl = EndpointSet, + HasDeviceAuthUrl = EndpointSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointMaybeSet, + HasUserInfoUrl = EndpointMaybeSet, +> = openidconnect::Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, + HasUserInfoUrl, +>; + +/// An OIDC specific HTTP client. +/// +/// This is used to communicate with the OIDC provider exclusively. +pub(super) struct OidcClient { + inner: OidcClientInner, + http_client: HttpClient, +} + +impl OidcClient { + pub(super) async fn new( + client_id: String, + issuer_url: String, + http_client: HttpClient, + client_secret: Option<&str>, + ) -> Result { + let client_id = ClientId::new(client_id); + let issuer_url = IssuerUrl::new(issuer_url)?; + let client_secret = client_secret.map(|s| ClientSecret::new(s.to_owned())); + + // We're fetching the provider metadata which will contain the device + // authorization endpoint. We can use this endpoint to attempt to log in + // this new device, though the other, existing device will do that using the + // verification URL. + let provider_metadata = + DeviceProviderMetadata::discover_async(issuer_url, &http_client).await?; + let device_authorization_endpoint = + provider_metadata.additional_metadata().device_authorization_endpoint.clone(); + + let oidc_client = + CoreClient::from_provider_metadata(provider_metadata, client_id.clone(), client_secret) + .set_device_authorization_url(device_authorization_endpoint) + .set_auth_type(AuthType::RequestBody); + + Ok(OidcClient { inner: oidc_client, http_client }) + } + + pub(super) async fn request_device_authorization( + &self, + device_id: Curve25519PublicKey, + ) -> Result { + let scopes = [ + ScopeToken::Openid, + ScopeToken::MatrixApi(MatrixApiScopeToken::Full), + ScopeToken::try_with_matrix_device(device_id.to_base64()).expect( + "We should be able to create a scope token from a \ + Curve25519 public key encoded as base64", + ), + ] + .into_iter() + .map(|scope| Scope::new(scope.to_string())); + + let details: CoreDeviceAuthorizationResponse = self + .inner + .exchange_device_code() + .add_scopes(scopes) + .request_async(&self.http_client) + .await?; + + Ok(details) + } + + pub(super) async fn wait_for_tokens( + &self, + details: &CoreDeviceAuthorizationResponse, + ) -> Result { + let response = self + .inner + .exchange_device_access_token(details)? + .request_async(&self.http_client, tokio::time::sleep, None) + .await?; + + let tokens = OidcSessionTokens { + access_token: response.access_token().secret().to_owned(), + refresh_token: response.refresh_token().map(|t| t.secret().to_owned()), + latest_id_token: None, + }; + + Ok(tokens) + } +} + +impl<'c> openidconnect::AsyncHttpClient<'c> for HttpClient { + type Error = HttpClientError; + + type Future = Pin< + Box< + dyn Future> + + Send + + Sync + + 'c, + >, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + Box::pin(async move { + let response = self.inner.call(request).await?; + + Ok(response) + }) + } +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/rendezvous_channel.rs b/crates/matrix-sdk/src/authentication/qrcode/rendezvous_channel.rs new file mode 100644 index 00000000000..bee384f5921 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/rendezvous_channel.rs @@ -0,0 +1,558 @@ +// Copyright 2024 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; + +use http::{ + header::{CONTENT_TYPE, ETAG, EXPIRES, IF_MATCH, IF_NONE_MATCH, LAST_MODIFIED}, + HeaderMap, HeaderName, Method, StatusCode, +}; +use ruma::api::{ + error::{FromHttpResponseError, HeaderDeserializationError, IntoHttpError, MatrixError}, + EndpointError, +}; +use tracing::{debug, instrument, trace}; +use url::Url; + +use crate::{http_client::HttpClient, HttpError, RumaApiError}; + +const TEXT_PLAIN_CONTENT_TYPE: &str = "text/plain"; +#[cfg(test)] +const POLL_TIMEOUT: Duration = Duration::from_millis(10); +#[cfg(not(test))] +const POLL_TIMEOUT: Duration = Duration::from_secs(1); + +type Etag = String; + +/// Get a header from a [`HeaderMap`] and parse it as a UTF-8 string. +fn get_header( + header_map: &HeaderMap, + header_name: &HeaderName, +) -> Result> { + let header = header_map + .get(header_name) + .ok_or(HeaderDeserializationError::MissingHeader(ETAG.to_string()))?; + + let header = header.to_str()?.to_owned(); + + Ok(header) +} + +/// The result of the [`RendezvousChannel::create_inbound()`] method. +pub(super) struct InboundChannelCreationResult { + /// The connected [`RendezvousChannel`]. + pub channel: RendezvousChannel, + /// The initial message we received when we connected to the + /// [`RendezvousChannel`]. + /// + /// This is currently unused, but left in for completeness sake. + #[allow(dead_code)] + pub initial_message: Vec, +} + +struct RendezvousGetResponse { + pub status_code: StatusCode, + pub etag: String, + // TODO: This is currently unused, but will be required once we implement the reciprocation of + // a login. Left here so we don't forget about it. We should put this into the + // [`RendezvousChannel`] struct, once we parse it into a [`SystemTime`]. + #[allow(dead_code)] + pub expires: String, + #[allow(dead_code)] + pub last_modified: String, + pub content_type: Option, + pub body: Vec, +} + +struct RendezvousMessage { + pub status_code: StatusCode, + pub body: Vec, + pub content_type: String, +} + +pub(super) struct RendezvousChannel { + client: HttpClient, + rendezvous_url: Url, + etag: Etag, +} + +fn response_to_error(status: StatusCode, body: Vec) -> HttpError { + match http::Response::builder().status(status).body(body).map_err(IntoHttpError::from) { + Ok(response) => { + let error = FromHttpResponseError::::Server(RumaApiError::Other( + MatrixError::from_http_response(response), + )); + + error.into() + } + Err(e) => e.into(), + } +} + +impl RendezvousChannel { + /// Create a new outbound [`RendezvousChannel`]. + /// + /// By outbound we mean that we're going to tell the Matrix server to create + /// a new rendezvous session. We're going to send an initial empty message + /// through the channel. + #[cfg(test)] + pub(super) async fn create_outbound( + client: HttpClient, + rendezvous_server: &Url, + ) -> Result { + use ruma::api::client::rendezvous::create_rendezvous_session; + + let request = create_rendezvous_session::unstable::Request::default(); + let response = client + .send(request, None, rendezvous_server.to_string(), None, &[], Default::default()) + .await?; + + let rendezvous_url = response.url; + let etag = response.etag; + + Ok(Self { client, rendezvous_url, etag }) + } + + /// Create a new inbound [`RendezvousChannel`]. + /// + /// By inbound we mean that we're going to attempt to read an initial + /// message from the rendezvous session on the given [`rendezvous_url`]. + pub(super) async fn create_inbound( + client: HttpClient, + rendezvous_url: &Url, + ) -> Result { + // Receive the initial message, which should be empty. But we need the ETAG to + // fully establish the rendezvous channel. + let response = Self::receive_message_impl(&client.inner, None, rendezvous_url).await?; + + let etag = response.etag.clone(); + + let initial_message = RendezvousMessage { + status_code: response.status_code, + body: response.body, + content_type: response.content_type.unwrap_or_else(|| "text/plain".to_owned()), + }; + + let channel = Self { client, rendezvous_url: rendezvous_url.clone(), etag }; + + Ok(InboundChannelCreationResult { channel, initial_message: initial_message.body }) + } + + /// Get the URL of the rendezvous session we're using to exchange messages + /// through the channel. + pub(super) fn rendezvous_url(&self) -> &Url { + &self.rendezvous_url + } + + /// Send the given `message` through the [`RendezvousChannel`] to the other + /// device. + /// + /// The message must be of the `text/plain` content type. + #[instrument(skip_all)] + pub(super) async fn send(&mut self, message: Vec) -> Result<(), HttpError> { + let etag = self.etag.clone(); + + let request = self + .client + .inner + .request(Method::PUT, self.rendezvous_url().to_owned()) + .body(message) + .header(IF_MATCH, etag) + .header(CONTENT_TYPE, TEXT_PLAIN_CONTENT_TYPE); + + debug!("Sending a request to the rendezvous channel {request:?}"); + + let response = request.send().await?; + let status = response.status(); + + debug!("Response for the rendezvous sending request {response:?}"); + + if status.is_success() { + // We successfully send out a message, get the ETAG and update our internal copy + // of the ETAG. + let etag = get_header(response.headers(), &ETAG)?; + self.etag = etag; + + Ok(()) + } else { + let body = response.bytes().await?; + let error = response_to_error(status, body.to_vec()); + + return Err(error); + } + } + + /// Attempt to receive a message from the [`RendezvousChannel`] from the + /// other device. + /// + /// The content should be of the `text/plain` content type but the parsing + /// and verification of this fact is left up to the caller. + /// + /// This method will wait in a loop for the channel to give us a new + /// message. + pub(super) async fn receive(&mut self) -> Result, HttpError> { + loop { + let message = self.receive_single_message().await?; + + trace!( + status_code = %message.status_code, + "Received data from the rendezvous channel" + ); + + if message.status_code == StatusCode::OK + && message.content_type == TEXT_PLAIN_CONTENT_TYPE + && !message.body.is_empty() + { + return Ok(message.body); + } else if message.status_code == StatusCode::NOT_MODIFIED { + tokio::time::sleep(POLL_TIMEOUT).await; + continue; + } else { + let error = response_to_error(message.status_code, message.body); + + return Err(error); + } + } + } + + #[instrument] + async fn receive_message_impl( + client: &reqwest::Client, + etag: Option, + rendezvous_url: &Url, + ) -> Result { + let mut builder = client.request(Method::GET, rendezvous_url.to_owned()); + + if let Some(etag) = etag { + builder = builder.header(IF_NONE_MATCH, etag); + } + + let response = builder.send().await?; + + debug!("Received data from the rendezvous channel {response:?}"); + + let status_code = response.status(); + let headers = response.headers(); + + let etag = get_header(headers, &ETAG)?; + let expires = get_header(headers, &EXPIRES)?; + let last_modified = get_header(headers, &LAST_MODIFIED)?; + let content_type = response + .headers() + .get(CONTENT_TYPE) + .map(|c| c.to_str().map_err(FromHttpResponseError::::from)) + .transpose()? + .map(ToOwned::to_owned); + + let body = response.bytes().await?.to_vec(); + + let response = + RendezvousGetResponse { status_code, etag, expires, last_modified, content_type, body }; + + Ok(response) + } + + async fn receive_single_message(&mut self) -> Result { + let etag = Some(self.etag.clone()); + + let RendezvousGetResponse { status_code, etag, content_type, body, .. } = + Self::receive_message_impl(&self.client.inner, etag, &self.rendezvous_url).await?; + + // We received a response with an ETAG, put it into the copy of our etag. + self.etag = etag; + + let message = RendezvousMessage { + status_code, + body, + content_type: content_type.unwrap_or_else(|| "text/plain".to_owned()), + }; + + Ok(message) + } +} + +#[cfg(test)] +mod test { + use matrix_sdk_test::async_test; + use serde_json::json; + use similar_asserts::assert_eq; + use wiremock::{ + matchers::{header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::config::RequestConfig; + + async fn mock_rendzvous_create(server: &MockServer, rendezvous_url: &Url) { + server + .register( + Mock::given(method("POST")) + .and(path("/_matrix/client/unstable/org.matrix.msc4108/rendezvous")) + .respond_with( + ResponseTemplate::new(200) + .append_header("X-Max-Bytes", "10240") + .append_header("ETag", "1") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_json(json!({ + "url": rendezvous_url, + })), + ), + ) + .await; + } + + #[async_test] + async fn creation() { + let server = MockServer::start().await; + let url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let rendezvous_url = + url.join("abcdEFG12345").expect("We should be able to create a rendezvous URL"); + + mock_rendzvous_create(&server, &rendezvous_url).await; + + let client = HttpClient::new(reqwest::Client::new(), RequestConfig::new().disable_retry()); + + let mut alice = RendezvousChannel::create_outbound(client, &url) + .await + .expect("We should be able to create an outbound rendezvous channel"); + + assert_eq!( + alice.rendezvous_url(), + &rendezvous_url, + "Alice should have configured the rendezvous URL correctly." + ); + + assert_eq!(alice.etag, "1", "Alice should have remembered the ETAG the server gave us."); + + let mut bob = { + let _scope = server + .register_as_scoped( + Mock::given(method("GET")).and(path("/abcdEFG12345")).respond_with( + ResponseTemplate::new(200) + .append_header("Content-Type", "text/plain") + .append_header("ETag", "2") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT"), + ), + ) + .await; + + let client = HttpClient::new(reqwest::Client::new(), RequestConfig::short_retry()); + let InboundChannelCreationResult { channel: bob, initial_message: _ } = + RendezvousChannel::create_inbound(client, &rendezvous_url).await.expect( + "We should be able to create a rendezvous channel from a received message", + ); + + assert_eq!(alice.rendezvous_url(), bob.rendezvous_url()); + + bob + }; + + assert_eq!(bob.etag, "2", "Bob should have remembered the ETAG the server gave us."); + + { + let _scope = server + .register_as_scoped( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "1")) + .respond_with( + ResponseTemplate::new(304) + .append_header("ETag", "1") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT"), + ), + ) + .await; + + let response = alice + .receive_single_message() + .await + .expect("We should be able to wait for data on the rendezvous channel."); + assert_eq!(response.status_code, StatusCode::NOT_MODIFIED); + } + + { + let _scope = server + .register_as_scoped( + Mock::given(method("PUT")) + .and(path("/abcdEFG12345")) + .and(header("Content-Type", "text/plain")) + .respond_with( + ResponseTemplate::new(200) + .append_header("ETag", "1") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT"), + ), + ) + .await; + + bob.send(b"Hello world".to_vec()) + .await + .expect("We should be able to send data to the rendezouvs server."); + } + + { + let _scope = server + .register_as_scoped( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "1")) + .respond_with( + ResponseTemplate::new(200) + .append_header("ETag", "3") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_string("Hello world"), + ), + ) + .await; + + let response = alice + .receive_single_message() + .await + .expect("We should be able to wait and get data on the rendezvous channel."); + + assert_eq!(response.status_code, StatusCode::OK); + assert_eq!(response.body, b"Hello world"); + assert_eq!(response.content_type, TEXT_PLAIN_CONTENT_TYPE); + } + } + + #[async_test] + async fn retry_mechanism() { + let server = MockServer::start().await; + let url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let rendezvous_url = + url.join("abcdEFG12345").expect("We should be able to create a rendezvous URL"); + mock_rendzvous_create(&server, &rendezvous_url).await; + + let client = HttpClient::new(reqwest::Client::new(), RequestConfig::new().disable_retry()); + + let mut alice = RendezvousChannel::create_outbound(client, &url) + .await + .expect("We should be able to create an outbound rendezvous channel"); + + server + .register( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "1")) + .respond_with( + ResponseTemplate::new(304) + .append_header("ETag", "2") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_string(""), + ) + .expect(1), + ) + .await; + + server + .register( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "2")) + .respond_with( + ResponseTemplate::new(200) + .append_header("ETag", "3") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_string("Hello world"), + ) + .expect(1), + ) + .await; + + let response = alice + .receive() + .await + .expect("We should be able to wait and get data on the rendezvous channel."); + + assert_eq!(response, b"Hello world"); + } + + #[async_test] + async fn receive_error() { + let server = MockServer::start().await; + let url = + Url::parse(&server.uri()).expect("We should be able to parse the example homeserver"); + let rendezvous_url = + url.join("abcdEFG12345").expect("We should be able to create a rendezvous URL"); + mock_rendzvous_create(&server, &rendezvous_url).await; + + let client = HttpClient::new(reqwest::Client::new(), RequestConfig::new().disable_retry()); + + let mut alice = RendezvousChannel::create_outbound(client, &url) + .await + .expect("We should be able to create an outbound rendezvous channel"); + + { + let _scope = server + .register_as_scoped( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "1")) + .respond_with( + ResponseTemplate::new(404) + .append_header("ETag", "1") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_string(""), + ) + .expect(1), + ) + .await; + + alice.receive().await.expect_err("We should return an error if we receive a 404"); + } + + { + let _scope = server + .register_as_scoped( + Mock::given(method("GET")) + .and(path("/abcdEFG12345")) + .and(header("if-none-match", "1")) + .respond_with( + ResponseTemplate::new(504) + .append_header("ETag", "1") + .append_header("Content-Type", "text/plain") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_json(json!({ + "errcode": "M_NOT_FOUND", + "error": "No resource was found for this request.", + })), + ) + .expect(1), + ) + .await; + + alice + .receive() + .await + .expect_err("We should return an error if we receive a gateway timeout"); + } + } +} diff --git a/crates/matrix-sdk/src/authentication/qrcode/secure_channel.rs b/crates/matrix-sdk/src/authentication/qrcode/secure_channel.rs new file mode 100644 index 00000000000..e686cfa8d28 --- /dev/null +++ b/crates/matrix-sdk/src/authentication/qrcode/secure_channel.rs @@ -0,0 +1,390 @@ +// Copyright 2024 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. + +#[cfg(test)] +use matrix_sdk_base::crypto::types::qr_login::QrCodeModeData; +use matrix_sdk_base::crypto::types::qr_login::{QrCodeData, QrCodeMode}; +use serde::{de::DeserializeOwned, Serialize}; +use tracing::{instrument, trace}; +#[cfg(test)] +use url::Url; +use vodozemac::ecies::{CheckCode, Ecies, EstablishedEcies, Message, OutboundCreationResult}; +#[cfg(test)] +use vodozemac::ecies::{InboundCreationResult, InitialMessage}; + +use super::{ + rendezvous_channel::{InboundChannelCreationResult, RendezvousChannel}, + SecureChannelError as Error, +}; +use crate::{config::RequestConfig, http_client::HttpClient}; + +const LOGIN_INITIATE_MESSAGE: &str = "MATRIX_QR_CODE_LOGIN_INITIATE"; +const LOGIN_OK_MESSAGE: &str = "MATRIX_QR_CODE_LOGIN_OK"; + +#[cfg(test)] +pub(super) struct SecureChannel { + channel: RendezvousChannel, + qr_code_data: QrCodeData, + ecies: Ecies, +} + +// This is only used in tests because we're only supporting the new device part +// of the QR login flow. It will be needed once we support reciprocating of the +// login. +// +// It's still very much useful to have this, as we're testing the whole flow by +// mocking the reciprocation. +#[cfg(test)] +impl SecureChannel { + pub(super) async fn new(http_client: HttpClient, homeserver_url: &Url) -> Result { + let channel = RendezvousChannel::create_outbound(http_client, homeserver_url).await?; + let rendezvous_url = channel.rendezvous_url().to_owned(); + let mode_data = QrCodeModeData::Reciprocate { homeserver_url: homeserver_url.clone() }; + + let ecies = Ecies::new(); + let public_key = ecies.public_key(); + + let qr_code_data = QrCodeData { public_key, rendezvous_url, mode_data }; + + Ok(Self { channel, qr_code_data, ecies }) + } + + pub(super) fn qr_code_data(&self) -> &QrCodeData { + &self.qr_code_data + } + + #[instrument(skip(self))] + pub(super) async fn connect(mut self) -> Result { + trace!("Trying to connect the secure channel."); + + let message = self.channel.receive().await?; + let message = std::str::from_utf8(&message)?; + let message = InitialMessage::decode(message)?; + + let InboundCreationResult { ecies, message } = + self.ecies.establish_inbound_channel(&message)?; + let message = std::str::from_utf8(&message)?; + + trace!("Received the initial secure channel message"); + + if message == LOGIN_INITIATE_MESSAGE { + let mut secure_channel = EstablishedSecureChannel { channel: self.channel, ecies }; + + trace!("Sending the LOGIN OK message"); + + secure_channel.send(LOGIN_OK_MESSAGE).await?; + + Ok(AlmostEstablishedSecureChannel { secure_channel }) + } else { + Err(Error::SecureChannelMessage { + expected: LOGIN_INITIATE_MESSAGE, + received: message.to_owned(), + }) + } + } +} + +/// An SecureChannel that is yet to be confirmed as with the [`CheckCode`]. +/// Same deal as for the [`SecureChannel`], not used for now. +#[cfg(test)] +pub(super) struct AlmostEstablishedSecureChannel { + secure_channel: EstablishedSecureChannel, +} + +#[cfg(test)] +impl AlmostEstablishedSecureChannel { + /// Confirm that the secure channel is indeed secure. + /// + /// The check code needs to be received out of band from the other side of + /// the secure channel. + pub(super) fn confirm(self, check_code: u8) -> Result { + if check_code == self.secure_channel.check_code().to_digit() { + Ok(self.secure_channel) + } else { + Err(Error::InvalidCheckCode) + } + } +} + +pub(super) struct EstablishedSecureChannel { + channel: RendezvousChannel, + ecies: EstablishedEcies, +} + +impl EstablishedSecureChannel { + /// Establish a secure channel from a scanned QR code. + #[instrument(skip(client))] + pub(super) async fn from_qr_code( + client: reqwest::Client, + qr_code_data: &QrCodeData, + expected_mode: QrCodeMode, + ) -> Result { + if qr_code_data.mode() == expected_mode { + Err(Error::InvalidIntent) + } else { + trace!("Attempting to create a new inbound secure channel from a QR code."); + + let client = HttpClient::new(client, RequestConfig::short_retry()); + let ecies = Ecies::new(); + + // Let's establish an outbound ECIES channel, the other side won't know that + // it's talking to us, the device that scanned the QR code, until it + // receives and successfully decrypts the initial message. We're here encrypting + // the `LOGIN_INITIATE_MESSAGE`. + let OutboundCreationResult { ecies, message } = ecies.establish_outbound_channel( + qr_code_data.public_key, + LOGIN_INITIATE_MESSAGE.as_bytes(), + )?; + + // The other side has crated a rendezvous channel, we're going to connect to it + // and send this initial encrypted message through it. The initial message on + // the rendezvous channel will have an empty body, so we can just + // drop it. + let InboundChannelCreationResult { mut channel, .. } = + RendezvousChannel::create_inbound(client, &qr_code_data.rendezvous_url).await?; + + trace!( + "Received the initial message from the rendezvous channel, sending the LOGIN \ + INITIATE message" + ); + + // Now we're sending the encrypted message through the rendezvous channel to the + // other side. + let encoded_message = message.encode().as_bytes().to_vec(); + channel.send(encoded_message).await?; + + trace!("Waiting for the LOGIN OK message"); + + // We can create our EstablishedSecureChannel struct now and use the + // convenient helpers which transparently decrypt on receival. + let mut ret = Self { channel, ecies }; + let response = ret.receive().await?; + + trace!("Received the LOGIN OK message, maybe."); + + if response == LOGIN_OK_MESSAGE { + Ok(ret) + } else { + Err(Error::SecureChannelMessage { + expected: LOGIN_OK_MESSAGE, + received: response.to_owned(), + }) + } + } + } + + /// Get the [`CheckCode`] which can be used to, out of band, verify that + /// both sides of the channel are indeed communicating with each other and + /// not with a 3rd party. + pub(super) fn check_code(&self) -> &CheckCode { + self.ecies.check_code() + } + + /// Send the given message over to the other side. + /// + /// The message will be encrypted before it is sent over the rendezvous + /// channel. + pub(super) async fn send_json(&mut self, message: impl Serialize) -> Result<(), Error> { + let message = serde_json::to_string(&message)?; + self.send(&message).await + } + + /// Attempt to receive a message from the channel. + /// + /// The message will be decrypted after it has been received over the + /// rendezvous channel. + pub(super) async fn receive_json(&mut self) -> Result { + let message = self.receive().await?; + Ok(serde_json::from_str(&message)?) + } + + async fn send(&mut self, message: &str) -> Result<(), Error> { + let message = self.ecies.encrypt(message.as_bytes()); + let message = message.encode(); + + Ok(self.channel.send(message.as_bytes().to_vec()).await?) + } + + async fn receive(&mut self) -> Result { + let message = self.channel.receive().await?; + let ciphertext = std::str::from_utf8(&message)?; + let message = Message::decode(ciphertext)?; + + let decrypted = self.ecies.decrypt(&message)?; + + Ok(String::from_utf8(decrypted).map_err(|e| e.utf8_error())?) + } +} + +#[cfg(test)] +pub(super) mod test { + use std::sync::{ + atomic::{AtomicU8, Ordering}, + Arc, Mutex, + }; + + use matrix_sdk_base::crypto::types::qr_login::QrCodeMode; + use matrix_sdk_test::async_test; + use serde_json::json; + use similar_asserts::assert_eq; + use url::Url; + use wiremock::{ + matchers::{method, path}, + Mock, MockGuard, MockServer, ResponseTemplate, + }; + + use super::{EstablishedSecureChannel, SecureChannel}; + use crate::http_client::HttpClient; + + #[allow(dead_code)] + pub struct MockedRendezvousServer { + pub homeserver_url: Url, + pub rendezvous_url: Url, + content: Arc>>, + etag: Arc, + post_guard: MockGuard, + put_guard: MockGuard, + get_guard: MockGuard, + } + + impl MockedRendezvousServer { + pub async fn new(server: &MockServer, location: &str) -> Self { + let content: Arc>> = Mutex::default().into(); + let etag = Arc::new(AtomicU8::new(0)); + + let homeserver_url = Url::parse(&server.uri()) + .expect("We should be able to parse the example homeserver"); + + let rendezvous_url = homeserver_url + .join(location) + .expect("We should be able to create a rendezvous URL"); + + let post_guard = server + .register_as_scoped( + Mock::given(method("POST")) + .and(path("/_matrix/client/unstable/org.matrix.msc4108/rendezvous")) + .respond_with( + ResponseTemplate::new(200) + .append_header("X-Max-Bytes", "10240") + .append_header("ETag", "1") + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_json(json!({ + "url": rendezvous_url, + })), + ), + ) + .await; + + let put_guard = server + .register_as_scoped( + Mock::given(method("PUT")).and(path("/abcdEFG12345")).respond_with({ + let content = content.clone(); + let etag = etag.clone(); + + move |request: &wiremock::Request| { + *content.lock().unwrap() = + Some(String::from_utf8(request.body.clone()).unwrap()); + let current_etag = etag.fetch_add(1, Ordering::SeqCst); + + ResponseTemplate::new(200) + .append_header("ETag", (current_etag + 2).to_string()) + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + } + }), + ) + .await; + + let get_guard = server + .register_as_scoped( + Mock::given(method("GET")).and(path("/abcdEFG12345")).respond_with({ + let content = content.clone(); + let etag = etag.clone(); + + move |request: &wiremock::Request| { + let requested_etag = request.headers.get("if-none-match").map(|etag| { + str::parse::(std::str::from_utf8(etag.as_bytes()).unwrap()) + .unwrap() + }); + + let mut content = content.lock().unwrap(); + let current_etag = etag.load(Ordering::SeqCst); + + if requested_etag == Some(current_etag) || requested_etag.is_none() { + let content = content.take(); + + ResponseTemplate::new(200) + .append_header("ETag", (current_etag).to_string()) + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + .set_body_string(content.unwrap_or_default()) + } else { + let etag = requested_etag.unwrap_or_default(); + + ResponseTemplate::new(304) + .append_header("ETag", etag.to_string()) + .append_header("Expires", "Wed, 07 Sep 2022 14:28:51 GMT") + .append_header("Last-Modified", "Wed, 07 Sep 2022 14:27:51 GMT") + } + } + }), + ) + .await; + + Self { content, etag, post_guard, put_guard, get_guard, homeserver_url, rendezvous_url } + } + } + + #[async_test] + async fn creation() { + let server = MockServer::start().await; + 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) + .await + .expect("Alice should be able to create a secure channel."); + + let qr_code_data = alice.qr_code_data().clone(); + + let bob_task = tokio::spawn(async move { + EstablishedSecureChannel::from_qr_code( + reqwest::Client::new(), + &qr_code_data, + QrCodeMode::Login, + ) + .await + .expect("Bob should be able to fully establish the secure channel.") + }); + + let alice_task = tokio::spawn(async move { + alice + .connect() + .await + .expect("Alice should be able to connect the established secure channel") + }); + + let bob = bob_task.await.unwrap(); + let alice = alice_task.await.unwrap(); + + assert_eq!(alice.secure_channel.check_code(), bob.check_code()); + + let alice = alice + .confirm(bob.check_code().to_digit()) + .expect("Alice should be able to confirm the established secure channel."); + + assert_eq!(bob.channel.rendezvous_url(), alice.channel.rendezvous_url()); + } +} diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 6d3ed1a39bd..c5c18d3f986 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -1013,8 +1013,19 @@ impl Client { } } - pub(crate) async fn set_session_meta(&self, session_meta: SessionMeta) -> Result<()> { - self.base_client().set_session_meta(session_meta).await?; + pub(crate) async fn set_session_meta( + &self, + session_meta: SessionMeta, + #[cfg(feature = "e2e-encryption")] custom_account: Option, + ) -> Result<()> { + self.base_client() + .set_session_meta( + session_meta, + #[cfg(feature = "e2e-encryption")] + custom_account, + ) + .await?; + Ok(()) } @@ -2105,7 +2116,13 @@ impl Client { // overwrite the session information shared with the parent too, and it // must be initialized at most once. if let Some(session) = self.session() { - client.set_session_meta(session.into_meta()).await?; + client + .set_session_meta( + session.into_meta(), + #[cfg(feature = "e2e-encryption")] + None, + ) + .await?; } Ok(client) diff --git a/crates/matrix-sdk/src/encryption/mod.rs b/crates/matrix-sdk/src/encryption/mod.rs index 256b15dbd89..384085861df 100644 --- a/crates/matrix-sdk/src/encryption/mod.rs +++ b/crates/matrix-sdk/src/encryption/mod.rs @@ -619,6 +619,18 @@ impl Encryption { self.client.olm_machine().await.as_ref().map(|o| o.identity_keys().curve25519) } + #[cfg(feature = "experimental-oidc")] + pub(crate) async fn import_secrets_bundle( + &self, + bundle: &matrix_sdk_base::crypto::types::SecretsBundle, + ) -> Result<(), SecretImportError> { + let olm_machine = self.client.olm_machine().await; + let olm_machine = + olm_machine.as_ref().expect("This should only be called once we have an OlmMachine"); + + olm_machine.store().import_secrets_bundle(bundle).await + } + /// Get the status of the private cross signing keys. /// /// This can be used to check which private cross signing keys we have @@ -1275,7 +1287,7 @@ impl Encryption { // (get rid of the reference to the current crypto store first) drop(olm_machine_guard); // Recreate the OlmMachine. - self.client.base_client().regenerate_olm().await?; + self.client.base_client().regenerate_olm(None).await?; } Ok(generation_number) } else { @@ -1395,6 +1407,30 @@ impl Encryption { } } + /// Upload the device keys and initial set of one-tim keys to the server. + /// + /// This should only be called when the user logs in for the first time, + /// the method will ensure that other devices see our own device as an + /// end-to-end encryption enabled one. + /// + /// **Warning**: Do not use this method if we're already calling + /// [`Client::send_outgoing_request()`]. This method is intended for + /// explicitly uploading the device keys before starting a sync. + #[cfg(feature = "experimental-oidc")] + pub(crate) async fn ensure_device_keys_upload(&self) -> Result<()> { + let olm = self.client.olm_machine().await; + let olm = olm.as_ref().ok_or(Error::NoOlmMachine)?; + + if let Some((request_id, request)) = olm.upload_device_keys().await? { + self.client.keys_upload(&request_id, &request).await?; + + let (request_id, request) = olm.query_keys_for_users([olm.user_id()]); + self.client.keys_query(&request_id, request.device_keys).await?; + } + + Ok(()) + } + pub(crate) async fn update_state_after_keys_query(&self, response: &get_keys::v3::Response) { self.recovery().update_state_after_keys_query(response).await; diff --git a/crates/matrix-sdk/src/error.rs b/crates/matrix-sdk/src/error.rs index 0b74ff4737b..621b23bdcf1 100644 --- a/crates/matrix-sdk/src/error.rs +++ b/crates/matrix-sdk/src/error.rs @@ -99,7 +99,7 @@ pub enum HttpError { /// An error converting between ruma_*_api types and Hyper types. #[error(transparent)] - Api(FromHttpResponseError), + Api(#[from] FromHttpResponseError), /// An error converting between ruma_client_api types and Hyper types. #[error(transparent)] diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index 24bbe9222c9..c79fb58b19c 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -32,7 +32,7 @@ pub use reqwest; mod account; pub mod attachment; -mod authentication; +pub mod authentication; mod client; pub mod config; mod deduplicating_handler; diff --git a/crates/matrix-sdk/src/matrix_auth/mod.rs b/crates/matrix-sdk/src/matrix_auth/mod.rs index 7e064dbdb2a..aa9cd42a116 100644 --- a/crates/matrix-sdk/src/matrix_auth/mod.rs +++ b/crates/matrix-sdk/src/matrix_auth/mod.rs @@ -862,7 +862,13 @@ impl MatrixAuth { #[cfg(feature = "e2e-encryption")] login_info: Option, ) -> Result<()> { self.set_session_tokens(session.tokens); - self.client.set_session_meta(session.meta).await?; + self.client + .set_session_meta( + session.meta, + #[cfg(feature = "e2e-encryption")] + None, + ) + .await?; #[cfg(feature = "e2e-encryption")] { diff --git a/crates/matrix-sdk/src/oidc/mod.rs b/crates/matrix-sdk/src/oidc/mod.rs index 05c2f98f421..ef5cc725360 100644 --- a/crates/matrix-sdk/src/oidc/mod.rs +++ b/crates/matrix-sdk/src/oidc/mod.rs @@ -186,7 +186,9 @@ use mas_oidc_client::{ IdToken, }, }; -use matrix_sdk_base::{once_cell::sync::OnceCell, SessionMeta}; +use matrix_sdk_base::{ + crypto::types::qr_login::QrCodeData, once_cell::sync::OnceCell, SessionMeta, +}; use rand::{rngs::StdRng, Rng, SeedableRng}; use ruma::api::client::discovery::get_authentication_issuer; use serde::{Deserialize, Serialize}; @@ -207,16 +209,17 @@ mod tests; pub use self::{ auth_code_builder::{OidcAuthCodeUrlBuilder, OidcAuthorizationData}, + cross_process::CrossProcessRefreshLockError, end_session_builder::{OidcEndSessionData, OidcEndSessionUrlBuilder}, }; use self::{ backend::{server::OidcServer, OidcBackend}, - cross_process::{ - CrossProcessRefreshLockError, CrossProcessRefreshLockGuard, CrossProcessRefreshManager, - }, + cross_process::{CrossProcessRefreshLockGuard, CrossProcessRefreshManager}, }; use crate::{ - authentication::AuthData, client::SessionChange, Client, HttpError, RefreshTokenError, Result, + authentication::{qrcode::LoginWithQrCode, AuthData}, + client::SessionChange, + Client, HttpError, RefreshTokenError, Result, }; pub(crate) struct OidcCtx { @@ -349,6 +352,88 @@ impl Oidc { Ok(response.issuer) } + /// Log in using a QR code. + /// + /// This method allows you to log in with a QR code, the existing device + /// needs to display the QR code which this device can scan and call + /// this method to log in. + /// + /// A successful login using this method will automatically mark the device + /// as verified and transfer all end-to-end encryption related secrets, like + /// the private cross-signing keys and the backup key from the existing + /// device to the new device. + /// + /// # Example + /// + /// ```no_run + /// use anyhow::bail; + /// use futures_util::StreamExt; + /// use matrix_sdk::{ + /// authentication::qrcode::{LoginProgress, QrCodeData, QrCodeModeData}, + /// Client, + /// oidc::types::registration::VerifiedClientMetadata, + /// }; + /// # fn client_metadata() -> VerifiedClientMetadata { unimplemented!() } + /// # _ = 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)?; + /// + /// // Fetch the homeserver out of the parsed QR code data. + /// let QrCodeModeData::Reciprocate{ homeserver_url } = qr_code_data.mode_data else { + /// bail!("The QR code is invalid, we did not receive a homeserver in the QR code."); + /// }; + /// + /// // Build the client as usual. + /// let client = Client::builder() + /// .homeserver_url(homeserver_url) + /// .handle_refresh_tokens() + /// .build() + /// .await?; + /// + /// let oidc = client.oidc(); + /// let metadata: VerifiedClientMetadata = client_metadata(); + /// + /// // Subscribing to the progress is necessary since we need to input the check + /// // code on the existing device. + /// let login = oidc.login_with_qr_code(&qr_code_data, metadata); + /// let mut progress = login.subscribe_to_progress(); + /// + /// // Create a task which will show us the progress and tell us the check + /// // code to input in the existing device. + /// let task = tokio::spawn(async move { + /// while let Some(state) = progress.next().await { + /// match state { + /// LoginProgress::Starting => (), + /// LoginProgress::EstablishingSecureChannel { check_code } => { + /// let code = check_code.to_digit(); + /// println!("Please enter the following code into the other device {code:02}"); + /// }, + /// LoginProgress::WaitingForToken { user_code } => { + /// println!("Please use your other device to confirm the log in {user_code}") + /// }, + /// LoginProgress::Done => break, + /// } + /// } + /// }); + /// + /// // Now run the future to complete the login. + /// login.await?; + /// task.abort(); + /// + /// println!("Successfully logged in: {:?} {:?}", client.user_id(), client.device_id()); + /// # anyhow::Ok(()) }; + /// ``` + #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] + pub fn login_with_qr_code<'a>( + &'a self, + data: &'a QrCodeData, + client_metadata: VerifiedClientMetadata, + ) -> LoginWithQrCode<'a> { + LoginWithQrCode::new(&self.client, client_metadata, data) + } + /// The OpenID Connect Provider used for authorization. /// /// Returns `None` if the client registration was not restored with @@ -445,7 +530,7 @@ impl Oidc { /// # Panics /// /// Will panic if no OIDC client has been configured yet. - fn set_session_tokens(&self, session_tokens: OidcSessionTokens) { + pub(crate) fn set_session_tokens(&self, session_tokens: OidcSessionTokens) { let data = self.data().expect("Cannot call OpenID Connect API after logging in with another API"); if let Some(tokens) = data.tokens.get() { @@ -705,7 +790,13 @@ impl Oidc { authorization_data: Default::default(), }; - self.client.set_session_meta(meta).await?; + self.client + .set_session_meta( + meta, + #[cfg(feature = "e2e-encryption")] + None, + ) + .await?; self.deferred_enable_cross_process_refresh_lock().await; self.client @@ -907,7 +998,14 @@ impl Oidc { device_id: whoami_res.device_id.ok_or(OidcError::MissingDeviceId)?, }; - self.client.set_session_meta(session).await.map_err(crate::Error::from)?; + self.client + .set_session_meta( + session, + #[cfg(feature = "e2e-encryption")] + None, + ) + .await + .map_err(crate::Error::from)?; // At this point the Olm machine has been set up. // Enable the cross-process lock for refreshes, if needs be. diff --git a/examples/qr-login/Cargo.toml b/examples/qr-login/Cargo.toml new file mode 100644 index 00000000000..ad17ab128a0 --- /dev/null +++ b/examples/qr-login/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "example-qr-login" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "example-qr-login" +test = false + +[dependencies] +anyhow = "1" +tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] } +clap = { version = "4.0.15", features = ["derive"] } +qrcode = { git = "https://github.com/kennytm/qrcode-rust/" } +futures-util = "0.3.24" +tracing-subscriber = "0.3.16" +url = "2.3.1" + +[dependencies.matrix-sdk] +# when copy-pasting this, please use a git dependency or make sure that you +# have copied the example as it was at the time of the release you use. +path = "../../crates/matrix-sdk" +features = ["experimental-oidc"] diff --git a/examples/qr-login/src/main.rs b/examples/qr-login/src/main.rs new file mode 100644 index 00000000000..32508a637d2 --- /dev/null +++ b/examples/qr-login/src/main.rs @@ -0,0 +1,178 @@ +use std::io::Write; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use futures_util::StreamExt; +use matrix_sdk::{ + authentication::qrcode::{LoginProgress, QrCodeData, QrCodeModeData}, + oidc::types::{ + iana::oauth::OAuthClientAuthenticationMethod, + oidc::ApplicationType, + registration::{ClientMetadata, Localized, VerifiedClientMetadata}, + requests::GrantType, + }, + Client, +}; +use url::Url; + +/// A command line example showcasing how to login using a QR code. +/// +/// Another device, which will display the QR code is needed to use this +/// example. +#[derive(Parser, Debug)] +struct Cli { + /// Set the proxy that should be used for the connection. + #[clap(short, long)] + proxy: Option, + + /// Enable verbose logging output. + #[clap(short, long, action)] + verbose: bool, +} + +/// Generate the OIDC client metadata. +/// +/// For simplicity, we use most of the default values here, but usually this +/// should be adapted to the provider metadata to make interactions as secure as +/// possible, for example by using the most secure signing algorithms supported +/// by the provider. +fn client_metadata() -> VerifiedClientMetadata { + let client_uri = Url::parse("https://github.com/matrix-org/matrix-rust-sdk") + .expect("Couldn't parse client URI"); + + ClientMetadata { + // This is a native application (in contrast to a web application, that runs in a browser). + application_type: Some(ApplicationType::Native), + // Native clients should be able to register the loopback interface and then point to any + // port when needing a redirect URI. An alternative is to use a custom URI scheme registered + // with the OS. + redirect_uris: None, + // We are going to use the Authorization Code flow, and of course we want to be able to + // refresh our access token. + grant_types: Some(vec![GrantType::RefreshToken, GrantType::DeviceCode]), + // A native client shouldn't use authentication as the credentials could be intercepted. + // Other protections are in place for the different requests. + token_endpoint_auth_method: Some(OAuthClientAuthenticationMethod::None), + // The following fields should be displayed in the OIDC provider interface as part of the + // process to get the user's consent. It means that these should contain real data so the + // user can make sure that they allow the proper application. + // We are cheating here because this is an example. + client_name: Some(Localized::new("matrix-rust-sdk-qrlogin".to_owned(), [])), + contacts: Some(vec!["root@127.0.0.1".to_owned()]), + client_uri: Some(Localized::new(client_uri.clone(), [])), + policy_uri: Some(Localized::new(client_uri.clone(), [])), + tos_uri: Some(Localized::new(client_uri, [])), + ..Default::default() + } + .validate() + .unwrap() +} + +async fn print_devices(client: &Client) -> Result<()> { + let user_id = client.user_id().unwrap(); + let own_device = + client.encryption().get_own_device().await?.expect("We should have our own device by now"); + + println!( + "Status of our own device {}", + if own_device.is_cross_signed_by_owner() { "✅" } else { "❌" } + ); + + println!("Devices of user {user_id}"); + + for device in client.encryption().get_user_devices(user_id).await?.devices() { + if device.device_id() + == client.device_id().expect("We should be logged in now and know our device id") + { + continue; + } + + println!( + " {:<10} {:<30} {:<}", + device.device_id(), + device.display_name().unwrap_or("-"), + if device.is_verified() { "✅" } else { "❌" } + ); + } + + Ok(()) +} + +async fn login(proxy: Option) -> Result<()> { + println!("Please scan the QR code and convert the data to base64 before entering it here."); + println!("On Linux/Wayland, this can be achieved using the following command line:"); + println!( + " $ grim -g \"$(slurp)\" - | zbarimg --oneshot -Sbinary PNG:- | base64 -w 0 | wl-copy" + ); + println!("Paste the QR code data here: "); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input).expect("error: unable to read user input"); + let input = input.trim(); + + let data = QrCodeData::from_base64(input).context("Couldn't parse the base64 QR code data")?; + + let QrCodeModeData::Reciprocate { homeserver_url } = &data.mode_data else { + bail!("The QR code is invalid, we did not receive a homeserver in the QR code."); + }; + let mut client = Client::builder().server_name_or_homeserver_url(homeserver_url); + + if let Some(proxy) = proxy { + client = client.proxy(proxy).disable_ssl_verification(); + } + + let client = client.build().await?; + + let metadata = client_metadata(); + let oidc = client.oidc(); + + let login_client = oidc.login_with_qr_code(&data, metadata); + let mut subscriber = login_client.subscribe_to_progress(); + + let task = tokio::spawn(async move { + while let Some(state) = subscriber.next().await { + match state { + LoginProgress::Starting => (), + LoginProgress::EstablishingSecureChannel { check_code } => { + let code = check_code.to_digit(); + println!("Please enter the following code into the other device {code:02}"); + } + LoginProgress::WaitingForToken { user_code } => { + println!("Please use your other device to confirm the log in {user_code}") + } + LoginProgress::Done => break, + } + } + + std::io::stdout().flush().expect("Unable to write to stdout"); + }); + + let result = login_client.await; + task.abort(); + + result?; + + let status = client.encryption().cross_signing_status().await.unwrap(); + let user_id = client.user_id().unwrap(); + + println!( + "Successfully logged in as {user_id} using the qr code, cross-signing status: {status:?}" + ); + + print_devices(&client).await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.verbose { + tracing_subscriber::fmt::init(); + } + + login(cli.proxy).await?; + + Ok(()) +}