A code to scan, not seven pictures to read aloud

Verification was SAS and nothing else: compare emoji, in the same order, out
loud. Element offers scanning first and it is what most people use - so
verifying moho from Element meant Element offering its camera and this side
having nothing to be scanned.

The showing half only, and deliberately. Scanning one needs a camera or a
picture to read, which is its own piece of work; on a desktop the device
holding the camera is the other one, so showing is the half that matters here.

Answers nothing rather than an error where a code cannot be made, because that
is an ordinary state and not a failure: a QR code proves an identity the other
side can already check, so it needs cross-signing set up. Without it there is
nothing to encode and emoji remain the way through - which is why the dialog
draws the code beside the existing flow rather than instead of it.

SVG rather than a bitmap. The daemon has no idea how large this will be drawn,
and a code scaled up from a fixed grid of pixels is one a camera has to work
harder to read. The `qrcode` crate was already here for reading Discord's
login code; this is the same crate pointed the other way.

Part of #163.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 17:04:57 -04:00
co-authored by Claude Opus 5
parent d06a449e92
commit 2929ff7bde
4 changed files with 72 additions and 1 deletions
Generated
+14
View File
@@ -3531,6 +3531,7 @@ dependencies = [
"itertools 0.14.0",
"js_option",
"matrix-sdk-common",
"matrix-sdk-qrcode",
"pbkdf2",
"rand 0.10.2",
"rmp-serde",
@@ -3550,6 +3551,19 @@ dependencies = [
"zeroize",
]
[[package]]
name = "matrix-sdk-qrcode"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc199f424cd31ad8a63717965779f6ecc877b3ecebe31db8030ef3be8200b0cd"
dependencies = [
"byteorder",
"qrcode",
"ruma",
"thiserror 2.0.19",
"vodozemac",
]
[[package]]
name = "matrix-sdk-sqlite"
version = "0.18.0"
+5 -1
View File
@@ -40,6 +40,8 @@ sha2 = "0.10"
# The login captcha's proof of work; see backend/sneedchat/captcha.rs.
argon2 = { version = "0.5", default-features = false, features = ["alloc"] }
rand = "0.8"
# `image` for reading Discord's login code, `svg` (a default) for drawing
# Matrix's verification one - the same crate answers both directions.
qrcode = { version = "0.14", features = ["image"] }
image = { version = "0.25", default-features = false, features = ["png"] }
chrono = { version = "0.4", default-features = false, features = ["std"] }
@@ -66,7 +68,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring",
# `experimental-send-custom-to-device` is what lets a call's media keys be
# sent straight to the devices in the call, Olm-encrypted, which is how
# Element distributes them - see backend/matrix/crypto.rs.
matrix-sdk-crypto = { version = "0.18", features = ["experimental-send-custom-to-device"] }
# `qrcode` is what lets a verification be done by scanning rather than by
# reading emoji aloud - the method Element offers first and most people use.
matrix-sdk-crypto = { version = "0.18", features = ["experimental-send-custom-to-device", "qrcode"] }
# Pinned to exactly what matrix-sdk-crypto 0.18 resolves to, for the same
# reason the ruma crates below are: this is where its own response types live
# - the verification state a decrypted message carries among them - and a
+38
View File
@@ -260,6 +260,44 @@ pub async fn start_user_verification(state: &AppState, account_id: &str, user_id
/// Accepts or declines an incoming verification request (one the *other*
/// session started - see tick's incoming-request scan below).
/// The QR code for a verification, as an SVG to draw.
///
/// The method Element offers first and the one most people use: point one
/// device at the other rather than reading seven pictures aloud in the same
/// order. Until this existed, verifying moho from Element meant Element
/// offering to scan and moho having nothing to be scanned.
///
/// This is the showing half only. Scanning one needs a camera or a picture to
/// read, which is its own piece of work - and the showing half is the useful
/// one on a desktop, because the device with the camera is the phone.
///
/// Answers None rather than an error where a code cannot be made. That is the
/// ordinary case for a reason worth knowing: a QR code proves an identity the
/// other side can already check, so it needs cross-signing set up. Without it
/// there is nothing to encode, and emoji remain the way through.
pub async fn qr_code(state: &AppState, account_id: &str, verification_id: &str) -> Result<Option<String>> {
let Some(v) = state.runtime.get_matrix_verification(verification_id) else {
bail!("no such verification");
};
if v.account_id != account_id {
bail!("that verification belongs to another account");
}
let Some(qr) = v.request.generate_qr_code().await.context("making the QR code")? else {
return Ok(None);
};
let code = qr.to_qr_code().context("encoding the QR code")?;
// SVG rather than a bitmap: the daemon has no idea how large this will be
// drawn, and a QR code scaled up from a fixed grid of pixels is a QR code
// a camera has to work harder to read.
let svg = code
.render()
.min_dimensions(240, 240)
.dark_color(qrcode::render::svg::Color("#000000"))
.light_color(qrcode::render::svg::Color("#ffffff"))
.build();
Ok(Some(svg))
}
pub async fn respond_to_request(state: &AppState, account_id: &str, verification_id: &str, accept: bool) -> Result<()> {
let (config, session) = account_session(state, account_id).await?;
let v = state.runtime.get_matrix_verification(verification_id).context("no such verification")?;
+15
View File
@@ -3464,6 +3464,21 @@ pub async fn dispatch(
}
}
// The QR code for a verification in progress, drawn so another device
// can scan it. None where one cannot be made, which is the ordinary
// case without cross-signing - a code proves an identity the other
// side can already check, and without one there is nothing to encode.
"matrixVerificationQrCode" => {
let (account_id, verification_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "verificationId")) {
(Some(a), Some(v)) => (a, v),
_ => return (None, Some("matrixVerificationQrCode requires \"accountId\" and \"verificationId\"".to_string())),
};
match backend::matrix::verification::qr_code(state, account_id, verification_id).await {
Ok(svg) => (Some(serde_json::json!({ "svg": svg })), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"respondMatrixVerification" => {
let (account_id, verification_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "verificationId")) {
(Some(a), Some(v)) => (a, v),