matrix_sdk_crypto/types/
qr_login.rs

1// Copyright 2024 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Data types for the QR code login mechanism described in [MSC4108]
16//!
17//! [MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
18
19use std::{
20    io::{Cursor, Read},
21    str::{self, Utf8Error},
22};
23
24use byteorder::{BigEndian, ReadBytesExt};
25use thiserror::Error;
26use url::Url;
27use vodozemac::{base64_decode, base64_encode, Curve25519PublicKey};
28
29/// The version of the QR code data, currently only one version is specified.
30const VERSION: u8 = 0x02;
31/// The prefix that is used in the QR code data.
32const PREFIX: &[u8] = b"MATRIX";
33
34/// Error type for the decoding of the [`QrCodeData`].
35#[derive(Debug, Error)]
36#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))]
37pub enum LoginQrCodeDecodeError {
38    /// The QR code data is no long enough, it's missing some fields.
39    #[error("The QR code data is missing some fields.")]
40    NotEnoughData(#[from] std::io::Error),
41    /// One of the URLs in the QR code data is not a valid UTF-8 encoded string.
42    #[error("One of the URLs in the QR code data is not a valid UTF-8 string")]
43    NotUtf8(#[from] Utf8Error),
44    /// One of the URLs in the QR code data could not be parsed.
45    #[error("One of the URLs in the QR code data could not be parsed: {0:?}")]
46    UrlParse(#[from] url::ParseError),
47    /// The QR code data contains an invalid mode, we expect the login (0x03)
48    /// mode or the reciprocate mode (0x04).
49    #[error(
50        "The QR code data contains an invalid QR code login mode, expected 0x03 or 0x04, got {0}"
51    )]
52    InvalidMode(u8),
53    /// The QR code data contains an unsupported version.
54    #[error("The QR code data contains an unsupported version, expected {VERSION}, got {0}")]
55    InvalidVersion(u8),
56    /// The base64 encoded variant of the QR code data is not a valid base64
57    /// string.
58    #[error("The QR code data could not have been decoded from a base64 string: {0:?}")]
59    Base64(#[from] vodozemac::Base64DecodeError),
60    /// The QR code data doesn't contain the expected `MATRIX` prefix.
61    #[error("The QR code data has an unexpected prefix, expected: {expected:?}, got {got:?}")]
62    InvalidPrefix {
63        /// The expected prefix.
64        expected: &'static [u8],
65        /// The prefix we received.
66        got: [u8; 6],
67    },
68}
69
70/// The mode-specific data for the QR code.
71///
72/// The QR code login mechanism supports both, the new device, as well as the
73/// existing device to display the QR code.
74///
75/// Depending on which device is displaying the QR code, additional data will be
76/// attached to the QR code.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum QrCodeModeData {
79    /// Enum variant for the case where the new device is displaying the QR
80    /// code.
81    Login,
82    /// Enum variant for the case where the existing device is displaying the QR
83    /// code.
84    Reciprocate {
85        /// The homeserver the existing device is using. This will let the new
86        /// device know which homeserver it should use as well.
87        server_name: String,
88    },
89}
90
91impl QrCodeModeData {
92    /// Get the [`QrCodeMode`] which is associated to this [`QrCodeModeData`]
93    /// instance.
94    pub fn mode(&self) -> QrCodeMode {
95        self.into()
96    }
97}
98
99/// The mode of the QR code login.
100///
101/// The QR code login mechanism supports both, the new device, as well as the
102/// existing device to display the QR code.
103///
104/// The different modes have an explicit one-byte identifier which gets added to
105/// the QR code data.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum QrCodeMode {
108    /// Enum variant for the case where the new device is displaying the QR
109    /// code.
110    Login = 0x03,
111    /// Enum variant for the case where the existing device is displaying the QR
112    /// code.
113    Reciprocate = 0x04,
114}
115
116impl TryFrom<u8> for QrCodeMode {
117    type Error = LoginQrCodeDecodeError;
118
119    fn try_from(value: u8) -> Result<Self, Self::Error> {
120        match value {
121            0x03 => Ok(Self::Login),
122            0x04 => Ok(Self::Reciprocate),
123            mode => Err(LoginQrCodeDecodeError::InvalidMode(mode)),
124        }
125    }
126}
127
128impl From<&QrCodeModeData> for QrCodeMode {
129    fn from(value: &QrCodeModeData) -> Self {
130        match value {
131            QrCodeModeData::Login => Self::Login,
132            QrCodeModeData::Reciprocate { .. } => Self::Reciprocate,
133        }
134    }
135}
136
137/// Data for the QR code login mechanism.
138///
139/// The [`QrCodeData`] can be serialized and encoded as a QR code or it can be
140/// decoded from a QR code.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct QrCodeData {
143    /// The ephemeral Curve25519 public key. Can be used to establish a shared
144    /// secret using the Diffie-Hellman key agreement.
145    pub public_key: Curve25519PublicKey,
146    /// The URL of the rendezvous session, can be used to exchange messages with
147    /// the other device.
148    pub rendezvous_url: Url,
149    /// Mode specific data, may contain the homeserver URL.
150    pub mode_data: QrCodeModeData,
151}
152
153impl QrCodeData {
154    /// Attempt to decode a slice of bytes into a [`QrCodeData`] object.
155    ///
156    /// The slice of bytes would generally be returned by a QR code decoder.
157    pub fn from_bytes(bytes: &[u8]) -> Result<Self, LoginQrCodeDecodeError> {
158        // The QR data consists of the following values:
159        // 1. The ASCII string MATRIX.
160        // 2. One byte version, only 0x02 is supported.
161        // 3. One byte intent/mode, either 0x03 or 0x04.
162        // 4. 32 bytes for the ephemeral Curve25519 key.
163        // 5. Two bytes for the length of the rendezvous URL, a u16 in big-endian
164        //    encoding.
165        // 6. The UTF-8 encoded string containing the rendezvous URL.
166        // 7. If the intent/mode from point 3. is 0x04, then two bytes for the length of
167        //    the homeserver URL, a u16 in big-endian encoding.
168        // 8. If the intent/mode from point 3. is 0x04, then the UTF-8 encoded string
169        //    containing the homeserver URL.
170        let mut reader = Cursor::new(bytes);
171
172        // 1. Let's get the prefix first and double check if this QR code is intended
173        //    for the QR code login mechanism.
174        let mut prefix = [0u8; PREFIX.len()];
175        reader.read_exact(&mut prefix)?;
176
177        if PREFIX != prefix {
178            return Err(LoginQrCodeDecodeError::InvalidPrefix { expected: PREFIX, got: prefix });
179        }
180
181        // 2. Next up is the version, we continue only if the version matches.
182        let version = reader.read_u8()?;
183        if version == VERSION {
184            // 3. The intent/mode is the next one to parse, we return an error imediatelly
185            //    the intent isn't 0x03 or 0x04.
186            let mode = QrCodeMode::try_from(reader.read_u8()?)?;
187
188            // 4. Let's get the public key and convert it to our strongly typed
189            // Curve25519PublicKey type.
190            let mut public_key = [0u8; Curve25519PublicKey::LENGTH];
191            reader.read_exact(&mut public_key)?;
192            let public_key = Curve25519PublicKey::from_bytes(public_key);
193
194            // 5. We read two bytes for the length of the rendezvous URL.
195            let rendezvous_url_len = reader.read_u16::<BigEndian>()?;
196            // 6. We read and parse the rendezvous URL itself.
197            let mut rendezvous_url = vec![0u8; rendezvous_url_len.into()];
198            reader.read_exact(&mut rendezvous_url)?;
199            let rendezvous_url = Url::parse(str::from_utf8(&rendezvous_url)?)?;
200
201            let mode_data = match mode {
202                QrCodeMode::Login => QrCodeModeData::Login,
203                QrCodeMode::Reciprocate => {
204                    // 7. If the mode is 0x04, we attempt to read the two bytes for the length of
205                    //    the homeserver URL.
206                    let server_name_len = reader.read_u16::<BigEndian>()?;
207
208                    // 8. We read and parse the homeserver URL.
209                    let mut server_name = vec![0u8; server_name_len.into()];
210                    reader.read_exact(&mut server_name)?;
211                    let server_name = String::from_utf8(server_name).map_err(|e| e.utf8_error())?;
212
213                    QrCodeModeData::Reciprocate { server_name }
214                }
215            };
216
217            Ok(Self { public_key, rendezvous_url, mode_data })
218        } else {
219            Err(LoginQrCodeDecodeError::InvalidVersion(version))
220        }
221    }
222
223    /// Encode the [`QrCodeData`] into a list of bytes.
224    ///
225    /// The list of bytes can be used by a QR code generator to create an image
226    /// containing a QR code.
227    pub fn to_bytes(&self) -> Vec<u8> {
228        let rendezvous_url_len = (self.rendezvous_url.as_str().len() as u16).to_be_bytes();
229
230        let encoded = [
231            PREFIX,
232            &[VERSION],
233            &[self.mode_data.mode() as u8],
234            self.public_key.as_bytes().as_slice(),
235            &rendezvous_url_len,
236            self.rendezvous_url.as_str().as_bytes(),
237        ]
238        .concat();
239
240        if let QrCodeModeData::Reciprocate { server_name } = &self.mode_data {
241            let server_name_len = (server_name.as_str().len() as u16).to_be_bytes();
242
243            [encoded.as_slice(), &server_name_len, server_name.as_str().as_bytes()].concat()
244        } else {
245            encoded
246        }
247    }
248
249    /// Attempt to decode a base64 encoded string into a [`QrCodeData`] object.
250    pub fn from_base64(data: &str) -> Result<Self, LoginQrCodeDecodeError> {
251        Self::from_bytes(&base64_decode(data)?)
252    }
253
254    /// Encode the [`QrCodeData`] into a list of bytes.
255    ///
256    /// The list of bytes can be used by a QR code generator to create an image
257    /// containing a QR code.
258    pub fn to_base64(&self) -> String {
259        base64_encode(self.to_bytes())
260    }
261
262    /// Get the mode of this [`QrCodeData`] instance.
263    pub fn mode(&self) -> QrCodeMode {
264        self.mode_data.mode()
265    }
266}
267
268#[cfg(test)]
269mod test {
270    use assert_matches2::assert_let;
271    use similar_asserts::assert_eq;
272
273    use super::*;
274
275    // Test vector for the QR code data, copied from the MSC.
276    const QR_CODE_DATA: &[u8] = &[
277        0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x03, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
278        0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
279        0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
280        0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
281        0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
282        0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
283        0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
284        0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38,
285    ];
286
287    // Test vector for the QR code data, copied from the MSC, with the mode set to
288    // reciprocate.
289    const QR_CODE_DATA_RECIPROCATE: &[u8] = &[
290        0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x04, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b,
291        0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39,
292        0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74,
293        0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73,
294        0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65,
295        0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62,
296        0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39,
297        0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, 0x00, 0x0A, 0x6d, 0x61, 0x74, 0x72, 0x69,
298        0x78, 0x2e, 0x6f, 0x72, 0x67,
299    ];
300
301    // Test vector for the QR code data in base64 format, self-generated.
302    const QR_CODE_DATA_BASE64: &str =
303        "TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0\
304         cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns\
305         aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo\
306         dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv";
307
308    #[test]
309    fn parse_qr_data() {
310        let expected_curve_key =
311            Curve25519PublicKey::from_base64("2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws")
312                .unwrap();
313
314        let expected_rendezvous =
315            Url::parse("https://rendezvous.lab.element.dev/e8da6355-550b-4a32-a193-1619d9830668")
316                .unwrap();
317
318        let data = QrCodeData::from_bytes(QR_CODE_DATA)
319            .expect("We should be able to parse the QR code data");
320
321        assert_eq!(
322            expected_curve_key, data.public_key,
323            "The parsed public key should match the expected one"
324        );
325
326        assert_eq!(
327            expected_rendezvous, data.rendezvous_url,
328            "The parsed rendezvous URL should match expected one",
329        );
330
331        assert_eq!(
332            data.mode(),
333            QrCodeMode::Login,
334            "The mode in the test bytes vector should be Login"
335        );
336
337        assert_eq!(
338            QrCodeModeData::Login,
339            data.mode_data,
340            "The parsed QR code mode should match expected one",
341        );
342    }
343
344    #[test]
345    fn parse_qr_data_reciprocate() {
346        let expected_curve_key =
347            Curve25519PublicKey::from_base64("2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws")
348                .unwrap();
349
350        let expected_rendezvous =
351            Url::parse("https://rendezvous.lab.element.dev/e8da6355-550b-4a32-a193-1619d9830668")
352                .unwrap();
353
354        let data = QrCodeData::from_bytes(QR_CODE_DATA_RECIPROCATE)
355            .expect("We should be able to parse the QR code data");
356
357        assert_eq!(
358            expected_curve_key, data.public_key,
359            "The parsed public key should match the expected one"
360        );
361
362        assert_eq!(
363            expected_rendezvous, data.rendezvous_url,
364            "The parsed rendezvous URL should match expected one",
365        );
366
367        assert_eq!(
368            data.mode(),
369            QrCodeMode::Reciprocate,
370            "The mode in the test bytes vector should be Reciprocate"
371        );
372
373        assert_let!(
374            QrCodeModeData::Reciprocate { server_name } = data.mode_data,
375            "The parsed QR code mode should match the expected one",
376        );
377
378        assert_eq!(
379            server_name, "matrix.org",
380            "We should have correctly found the matrix.org homeserver in the QR code data"
381        );
382    }
383
384    #[test]
385    fn parse_qr_data_base64() {
386        let expected_curve_key =
387            Curve25519PublicKey::from_base64("tMs2dUFaUNY5Z6MVl93eY61kRXxC8Y0toDe6c/cvtzw")
388                .unwrap();
389
390        let expected_rendezvous =
391            Url::parse("https://synapse-oidc.lab.element.dev/_synapse/client/rendezvous/01HX9K00Q1H6KPD47EG4G1T3XG")
392                .unwrap();
393
394        let expected_server_name = "https://synapse-oidc.lab.element.dev/";
395
396        let data = QrCodeData::from_base64(QR_CODE_DATA_BASE64)
397            .expect("We should be able to parse the QR code data");
398
399        assert_eq!(
400            expected_curve_key, data.public_key,
401            "The parsed public key should match the expected one"
402        );
403
404        assert_eq!(
405            data.mode(),
406            QrCodeMode::Reciprocate,
407            "The mode in the test bytes vector should be Reciprocate"
408        );
409
410        assert_eq!(
411            expected_rendezvous, data.rendezvous_url,
412            "The parsed rendezvous URL should match the expected one",
413        );
414
415        assert_let!(QrCodeModeData::Reciprocate { server_name } = data.mode_data);
416
417        assert_eq!(
418            server_name, expected_server_name,
419            "The parsed server name should match the expected one"
420        );
421    }
422
423    #[test]
424    fn qr_code_encoding_roundtrip() {
425        let data = QrCodeData::from_bytes(QR_CODE_DATA)
426            .expect("We should be able to parse the QR code data");
427
428        let encoded = data.to_bytes();
429
430        assert_eq!(
431            QR_CODE_DATA, &encoded,
432            "Decoding and re-encoding the QR code data should yield the same bytes"
433        );
434
435        let data = QrCodeData::from_base64(QR_CODE_DATA_BASE64)
436            .expect("We should be able to parse the QR code data");
437
438        let encoded = data.to_base64();
439
440        assert_eq!(
441            QR_CODE_DATA_BASE64, &encoded,
442            "Decoding and re-encoding the QR code data should yield the same base64 string"
443        );
444    }
445}