Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
22e8126
chore: Bump vodozemac
poljar Dec 5, 2025
146fc6f
feat(qr-login): Support HPKE for the cryptographic channel
poljar Dec 5, 2025
e38c3ff
feat(qr-login): Properly handle HPKE errors in the secure channel
poljar Jan 16, 2026
901597e
refactor(qr-login): Move some more error variants into the MessageDec…
poljar Jan 16, 2026
84e7086
MSC4388 support for the rendezvous channel
poljar Jan 23, 2026
4b589a6
feat(oauth): Add support to MSC4388 for the QR login
poljar Feb 6, 2026
0ef9dce
test(oauth): Add some more tests for the MSC4388 QR login variant
poljar Feb 6, 2026
b3d3fc4
feat(qr-login): Include some additional authenticated data in the HPK…
poljar Feb 23, 2026
1452bb2
feat(qr-login): Simplify the check code interface
poljar Feb 27, 2026
7da8882
feat(qr-login): Add a method to check if the server supports MSC4388 …
poljar Mar 17, 2026
f248738
feat(qr-login): Use the old QR login variant if the homeserver does n…
poljar Mar 17, 2026
6a53c64
feat(ffi): Check for QR login support using the new discovery endpoin…
poljar Mar 17, 2026
b5a51aa
feat(ffi): Request the MSC4388 variant of the QR code login by default
poljar Mar 17, 2026
ccfa1ec
chore: Enable the wasm_js feature for yet another getrandom version
poljar Mar 18, 2026
8f0f7da
chore: Bump vodozemac
poljar Mar 18, 2026
b559387
fix(qrlogin): Return false upon a 403 response when we check for MSC4…
poljar Mar 18, 2026
3fa8dc0
fix(qr-login): Support the new variant of the m.login.protocols message
poljar Mar 19, 2026
a442a95
chore: Bump ruma
poljar Mar 19, 2026
0054d35
chore(qr-login): Add a note about an implementation mistake for MSC41…
poljar Mar 20, 2026
b2f7b43
refactor(qr-code): Make the checkcode sender a type alias over a gene…
poljar Apr 29, 2026
3a6c47b
Let the user confirm that they are done in the browser when doing qr …
poljar Apr 8, 2026
0591a47
Fix ffi compilation
poljar Apr 29, 2026
94061ac
feat(qr-code): Add yet another state to the login granting process
poljar Apr 8, 2026
f5dce40
doc(qr-login): Fix the examples now that we have more states for the …
poljar Apr 30, 2026
4019d15
Don't depend on getrandom 3 twice
poljar Apr 30, 2026
a0f1ef5
docs(qr-code): Document the continuation message sender
poljar May 4, 2026
55a6306
fix(qr-login): Ensure some of the string are short enough to be put i…
poljar Apr 8, 2026
d8cd83f
Fix some clippy issues
poljar May 5, 2026
0ae11bd
feat: Limit the rendezvous ID and sequence token to u8::MAX bytes
poljar Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
788 changes: 604 additions & 184 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ gloo-timers = { version = "0.3.0", default-features = false }
gloo-utils = { version = "0.2.0", default-features = false, features = ["serde"] }
growable-bloom-filter = { version = "2.1.1", default-features = false }
hkdf = { version = "0.12.4", default-features = false }
hmac = { version = "0.12.1", default-features = false }
hmac = { version = "0.12.1", default-features = false, features = ["std"] }
http = { version = "1.3.1", default-features = false }
imbl = { version = "6.1.0", default-features = false }
indexed_db_futures = { version = "0.7.0", package = "matrix_indexed_db_futures", default-features = false }
Expand Down Expand Up @@ -94,6 +94,7 @@ ruma = { version = "0.15.1", features = [
"unstable-msc4306",
"unstable-msc4308",
"unstable-msc4310",
"unstable-msc4388",
] }
sentry = { version = "0.47.0", default-features = false }
sentry-tracing = { version = "0.47.0", default-features = false }
Expand All @@ -119,7 +120,7 @@ uniffi_bindgen = { version = "0.31.0", default-features = false, features = ["ca
url = { version = "2.5.7", default-features = false }
uuid = { version = "1.18.1", default-features = false }
vergen-gitcl = { version = "1.0.8", default-features = false }
vodozemac = { version = "0.10.0", default-features = false, features = ["libolm-compat", "insecure-pk-encryption", "experimental-session-config"] }
vodozemac = { git = "https://github.com/matrix-org/vodozemac/", rev = "e5aa25d7", default-features = false, features = ["libolm-compat", "insecure-pk-encryption", "experimental-session-config"] }
wasm-bindgen = { version = "0.2.105", default-features = false }
wasm-bindgen-test = { version = "0.3.55", default-features = false, features = ["std"] }
web-sys = { version = "0.3.82", default-features = false }
Expand Down
34 changes: 30 additions & 4 deletions bindings/matrix-sdk-ffi/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ use ruma::{
client::{
alias::get_alias,
discovery::get_authorization_server_metadata::v1::{
AccountManagementActionData, DeviceDeleteData, DeviceViewData,
AccountManagementActionData, DeviceDeleteData, DeviceViewData, GrantType,
},
profile::{AvatarUrl, DisplayName},
room::create_room::{RoomPowerLevelsContentOverride, v3::CreationContent},
Expand Down Expand Up @@ -1976,10 +1976,36 @@ impl Client {
.any(|focus| matches!(focus, RtcFocusInfo::LiveKit(_))))
}

/// Checks if the server supports login using a QR code.
/// Checks if this client will be able to log in another client using a QR
/// code.
///
/// This checks if OAuth 2.0 was used to log in this client and if the auth
/// and homeserver support all the necessary APIs for the QR code login
/// to work.
pub async fn is_login_with_qr_code_supported(&self) -> Result<bool, ClientError> {
Ok(matches!(self.inner.auth_api(), Some(AuthApi::OAuth(_)))
&& self.inner.unstable_features().await?.contains(&ruma::api::FeatureFlag::Msc4108))
// We need to be using OAuth 2.0 API + Device Authorization Grant available
// and either MSC4108 or MSC4388 available.

// We need to be using the OAuth 2.0 API
if !matches!(self.inner.auth_api(), Some(AuthApi::OAuth(_))) {
return Ok(false);
}

let metadata =
self.inner.oauth().cached_server_metadata().await.map_err(ClientError::from_err)?;

// We need the Device Authorization Grant available
if !metadata.grant_types_supported.contains(&GrantType::DeviceCode) {
return Ok(false);
}

// We can use MSC4388 (from MSC4108 version 2025) if available:
if self.inner.oauth().msc_4388_rendezvous_server_supported().await? {
Ok(true)
} else {
// Otherwise we can use MSC4108 version 2024:
Ok(self.inner.unstable_features().await?.contains(&ruma::api::FeatureFlag::Msc4108))
}
}

/// Get server vendor information from the federation API.
Expand Down
112 changes: 81 additions & 31 deletions bindings/matrix-sdk-ffi/src/qr_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ use std::sync::Arc;
use matrix_sdk::authentication::oauth::{
OAuth,
qrcode::{
self, CheckCodeSender as SdkCheckCodeSender, CheckCodeSenderError,
DeviceCodeErrorResponseType, GeneratedQrProgress, LoginFailureReason, QrProgress,
self, CheckCodeSender as SdkCheckCodeSender,
ContinuationMessageSender as SdkContinuationMessageSender, DeviceCodeErrorResponseType,
GeneratedQrProgress, LoginFailureReason, QrProgress, SenderError,
},
};
use matrix_sdk_base::crypto::types::qr_login::{self, QrCodeIntent};
Expand Down Expand Up @@ -120,7 +121,8 @@ impl LoginWithQrCodeHandler {
.registration_data()
.map_err(|_| HumanQrLoginError::OAuthMetadataInvalid)?;

let login = self.oauth.login_with_qr_code(Some(&registration_data)).generate();
let mut login = self.oauth.login_with_qr_code(Some(&registration_data)).generate();
login.with_msc4388_support();

let mut progress = login.subscribe_to_progress();

Expand Down Expand Up @@ -214,7 +216,8 @@ impl GrantLoginWithQrCodeHandler {
self: Arc<Self>,
progress_listener: Box<dyn GrantGeneratedQrLoginProgressListener>,
) -> Result<(), HumanQrGrantLoginError> {
let grant = self.oauth.grant_login_with_qr_code().generate();
let mut grant = self.oauth.grant_login_with_qr_code().generate();
grant.with_msc4388_support();

let mut progress = grant.subscribe_to_progress();

Expand Down Expand Up @@ -361,15 +364,14 @@ impl From<qrcode::QRCodeLoginError> for HumanQrLoginError {
}

QRCodeLoginError::SecureChannel(e) => match e {
SecureChannelError::Utf8(_)
| SecureChannelError::MessageDecode(_)
| SecureChannelError::Json(_)
| SecureChannelError::RendezvousChannel(_) => HumanQrLoginError::Unknown,
SecureChannelError::MessageDecode(_)
| SecureChannelError::RendezvousChannel(_)
| SecureChannelError::QrCodeCreationError(_) => HumanQrLoginError::Unknown,
SecureChannelError::UnsupportedQrCodeType => {
HumanQrLoginError::UnsupportedQrCodeType
}
SecureChannelError::SecureChannelMessage { .. }
| SecureChannelError::Ecies(_)
| SecureChannelError::Decryption(_)
| SecureChannelError::InvalidCheckCode
| SecureChannelError::CannotReceiveCheckCode => {
HumanQrLoginError::ConnectionInsecure
Expand All @@ -390,11 +392,11 @@ impl From<qrcode::QRCodeLoginError> for HumanQrLoginError {
}
}

impl From<CheckCodeSenderError> for HumanQrLoginError {
fn from(value: CheckCodeSenderError) -> Self {
impl From<SenderError> for HumanQrLoginError {
fn from(value: SenderError) -> Self {
match value {
CheckCodeSenderError::AlreadySent => HumanQrLoginError::CheckCodeAlreadySent,
CheckCodeSenderError::CannotSend => HumanQrLoginError::CheckCodeCannotBeSent,
SenderError::AlreadySent => HumanQrLoginError::CheckCodeAlreadySent,
SenderError::CannotSend => HumanQrLoginError::CheckCodeCannotBeSent,
}
}
}
Expand Down Expand Up @@ -468,13 +470,12 @@ impl From<qrcode::QRCodeGrantLoginError> for HumanQrGrantLoginError {
}
QRCodeGrantLoginError::NotFound => Self::NotFound,
QRCodeGrantLoginError::SecureChannel(e) => match e {
SecureChannelError::Utf8(_)
| SecureChannelError::MessageDecode(_)
| SecureChannelError::Json(_)
| SecureChannelError::RendezvousChannel(_) => Self::Unknown(e.to_string()),
SecureChannelError::MessageDecode(_)
| SecureChannelError::RendezvousChannel(_)
| SecureChannelError::QrCodeCreationError(_) => Self::Unknown(e.to_string()),
SecureChannelError::UnsupportedQrCodeType => Self::UnsupportedQrCodeType,
SecureChannelError::SecureChannelMessage { .. }
| SecureChannelError::Ecies(_)
| SecureChannelError::Decryption(_)
| SecureChannelError::InvalidCheckCode
| SecureChannelError::CannotReceiveCheckCode => Self::ConnectionInsecure,
SecureChannelError::InvalidIntent => Self::OtherDeviceAlreadySignedIn,
Expand Down Expand Up @@ -531,8 +532,6 @@ impl From<qrcode::LoginProgress<QrProgress>> for QrLoginProgress {
match value {
LoginProgress::Starting => Self::Starting,
LoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => {
let check_code = check_code.to_digit();

Self::EstablishingSecureChannel {
check_code,
check_code_string: format!("{check_code:02}"),
Expand Down Expand Up @@ -611,9 +610,13 @@ pub enum GrantQrLoginProgress {
},
/// The secure channel has been confirmed using the [`CheckCode`] and this
/// device is waiting for the authorization to complete.
WaitingForAuth {
OpeningVerificationUri {
/// A URI to open in a (secure) system browser to verify the new login.
verification_uri: String,
continuation_sender: Arc<ContinuationMessageSender>,
},
WaitingForAuth {
continuation_sender: Arc<ContinuationMessageSender>,
},
/// We are syncing secrets.
SyncingSecrets,
Expand All @@ -633,15 +636,20 @@ impl From<qrcode::GrantLoginProgress<QrProgress>> for GrantQrLoginProgress {
match value {
GrantLoginProgress::Starting => Self::Starting,
GrantLoginProgress::EstablishingSecureChannel(QrProgress { check_code }) => {
let check_code = check_code.to_digit();

Self::EstablishingSecureChannel {
check_code,
check_code_string: format!("{check_code:02}"),
}
}
GrantLoginProgress::WaitingForAuth { verification_uri } => {
Self::WaitingForAuth { verification_uri: verification_uri.into() }
GrantLoginProgress::OpeningVerificationUri {
verification_uri,
continuation_sender,
} => Self::OpeningVerificationUri {
verification_uri: verification_uri.into(),
continuation_sender: Arc::new(continuation_sender.into()),
},
GrantLoginProgress::WaitingForAuth { continuation_sender } => {
Self::WaitingForAuth { continuation_sender: Arc::new(continuation_sender.into()) }
}
GrantLoginProgress::SyncingSecrets => Self::SyncingSecrets,
GrantLoginProgress::Done => Self::Done,
Expand All @@ -658,16 +666,24 @@ pub enum GrantGeneratedQrLoginProgress {
Starting,
/// We have established the secure channel and now need to display the
/// QR code so that the existing device can scan it.
QrReady { qr_code: Arc<QrCodeData> },
QrReady {
qr_code: Arc<QrCodeData>,
},
/// The existing device has scanned the QR code and is displaying the
/// checkcode. We now need to ask the user to enter the checkcode so that
/// we can verify that the channel is indeed secure.
QrScanned { check_code_sender: Arc<CheckCodeSender> },
QrScanned {
check_code_sender: Arc<CheckCodeSender>,
},
/// The secure channel has been confirmed using the [`CheckCode`] and this
/// device is waiting for the authorization to complete.
WaitingForAuth {
OpeningVerificationUri {
/// A URI to open in a (secure) system browser to verify the new login.
verification_uri: String,
continuation_sender: Arc<ContinuationMessageSender>,
},
WaitingForAuth {
continuation_sender: Arc<ContinuationMessageSender>,
},
/// We are syncing secrets.
SyncingSecrets,
Expand All @@ -692,18 +708,25 @@ impl From<qrcode::GrantLoginProgress<GeneratedQrProgress>> for GrantGeneratedQrL
GrantLoginProgress::EstablishingSecureChannel(GeneratedQrProgress::QrScanned(
inner,
)) => Self::QrScanned { check_code_sender: Arc::new(CheckCodeSender { inner }) },
GrantLoginProgress::WaitingForAuth { verification_uri } => {
Self::WaitingForAuth { verification_uri: verification_uri.into() }
GrantLoginProgress::OpeningVerificationUri {
verification_uri,
continuation_sender,
} => Self::OpeningVerificationUri {
verification_uri: verification_uri.into(),
continuation_sender: Arc::new(continuation_sender.into()),
},
GrantLoginProgress::WaitingForAuth { continuation_sender } => {
Self::WaitingForAuth { continuation_sender: Arc::new(continuation_sender.into()) }
}
GrantLoginProgress::SyncingSecrets => Self::SyncingSecrets,
GrantLoginProgress::Done => Self::Done,
}
}
}

#[derive(Debug, uniffi::Object)]
/// Used to pass back the [`CheckCode`] entered by the user to verify that the
/// secure channel is indeed secure.
#[derive(Debug, uniffi::Object)]
pub struct CheckCodeSender {
inner: SdkCheckCodeSender,
}
Expand All @@ -721,3 +744,30 @@ impl CheckCodeSender {
self.inner.send(code).await.map_err(HumanQrLoginError::from)
}
}

/// Struct used to let the QR code granting logic know that it can continue with
/// the process since applications might suspend things while the verification
/// URI is open.
#[derive(Debug, Clone, uniffi::Object)]
pub struct ContinuationMessageSender {
inner: SdkContinuationMessageSender,
}

#[matrix_sdk_ffi_macros::export]
impl ContinuationMessageSender {
/// Confirm the continuation of the login granting process.
pub async fn confirm(&self) -> Result<(), HumanQrLoginError> {
self.inner.confirm().await.map_err(HumanQrLoginError::from)
}

/// Cancel the the login granting process.
pub async fn cancel(&self) -> Result<(), HumanQrLoginError> {
self.inner.cancel().await.map_err(HumanQrLoginError::from)
}
}

impl From<SdkContinuationMessageSender> for ContinuationMessageSender {
fn from(value: SdkContinuationMessageSender) -> Self {
Self { inner: value }
}
}
2 changes: 1 addition & 1 deletion crates/matrix-sdk-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ tracing-subscriber = { workspace = true, features = ["fmt", "ansi"] }
wasm-bindgen.workspace = true
wasm-bindgen-futures = { version = "0.4.33", optional = true }
web-sys = { workspace = true, features = ["console"] }
getrandom03 = { package = "getrandom", version = "0.3.4", default-features = false, features = ["wasm_js"] }

[dev-dependencies]
assert_matches.workspace = true
Expand All @@ -66,7 +67,6 @@ wasm-bindgen-test.workspace = true
[target.'cfg(target_family = "wasm")'.dev-dependencies]
# Enable the JS feature for getrandom.
getrandom = { workspace = true, default-features = false, features = ["wasm_js"] }
getrandom3 = { version = "0.3.4", package = "getrandom", default-features = false, features = ["wasm_js"] }
js-sys.workspace = true

[lints]
Expand Down
2 changes: 1 addition & 1 deletion crates/matrix-sdk-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ experimental-encrypted-state-events = [
"ruma/unstable-msc4362"
]

js = ["ruma/js", "vodozemac/js", "matrix-sdk-common/js"]
js = ["ruma/js", "vodozemac/wasm_js", "matrix-sdk-common/js"]
qrcode = ["dep:matrix-sdk-qrcode"]
experimental-algorithms = []
uniffi = ["dep:uniffi"]
Expand Down
30 changes: 25 additions & 5 deletions crates/matrix-sdk-crypto/src/types/qr_login/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub use msc_4108::Msc4108IntentData;
use url::Url;
use vodozemac::{Curve25519PublicKey, base64_decode, base64_encode};

pub use crate::types::qr_login::msc_4388::{LimitedString, LimitedUrl, RendezvousId};

/// Error type for the decoding of the [`QrCodeData`].
#[derive(Debug, Error)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))]
Expand Down Expand Up @@ -77,6 +79,20 @@ pub enum LoginQrCodeDecodeError {
},
}

/// Error type for the creation of a new [`QrCodeData`] struct.
#[derive(Debug, Error)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))]
pub enum QrCodeCreationError {
/// The base URL of the homeserver needs to be at most [`u16::MAX`] bytes
/// long, otherwise it doesn't fit into the QR code.
#[error("The base URL of the homeserver is too long")]
TooLongBaseUrl,
/// The rendezvous ID of the channel needs to be at most [`u16::MAX`] bytes
/// long, otherwise it doesn't fit into the QR code.
#[error("The rendezvous ID is too long")]
TooLongRendezvousId,
}

/// Intent-specific data of the [`QrCodeData`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QrCodeIntentData<'a> {
Expand All @@ -98,7 +114,7 @@ pub enum QrCodeIntentData<'a> {
Msc4388 {
/// The ID of the rendezvous session, can be used to exchange messages
/// with the other device.
rendezvous_id: &'a str,
rendezvous_id: &'a RendezvousId,
/// The base URL of the homeserver that the device generating the QR is
/// using.
base_url: &'a Url,
Expand Down Expand Up @@ -187,15 +203,19 @@ impl QrCodeData {
rendezvous_id: String,
base_url: Url,
intent: QrCodeIntent,
) -> Self {
Self {
) -> Result<Self, QrCodeCreationError> {
let rendezvous_id =
RendezvousId::new(rendezvous_id).ok_or(QrCodeCreationError::TooLongRendezvousId)?;
let base_url = LimitedUrl::new(base_url).ok_or(QrCodeCreationError::TooLongBaseUrl)?;

Ok(Self {
inner: QrCodeDataInner::Msc4388(msc_4388::QrCodeData {
intent: intent.into(),
public_key,
rendezvous_id,
base_url,
}),
}
})
}

/// Attempt to decode a slice of bytes into a [`QrCodeData`] object.
Expand Down Expand Up @@ -263,7 +283,7 @@ impl QrCodeData {
},
QrCodeDataInner::Msc4388(qr_code_data) => QrCodeIntentData::Msc4388 {
rendezvous_id: &qr_code_data.rendezvous_id,
base_url: &qr_code_data.base_url,
base_url: qr_code_data.base_url.as_url(),
},
}
}
Expand Down
Loading