From 0b6bc0adf14909b9cf15187cdd6f79cd2d6f0578 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Dec 2025 17:28:53 +0000 Subject: [PATCH 01/47] Secure out-of-band channel for sign in with QR --- proposals/YYYY-secure-qr-channel.md | 941 ++++++++++++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 proposals/YYYY-secure-qr-channel.md diff --git a/proposals/YYYY-secure-qr-channel.md b/proposals/YYYY-secure-qr-channel.md new file mode 100644 index 00000000000..52d163968f4 --- /dev/null +++ b/proposals/YYYY-secure-qr-channel.md @@ -0,0 +1,941 @@ +# MSCYYYY: Secure out-of-band channel for sign in with QR + +This proposal forms part of [MSC4108] to make it easy to sign in on a new device with the help of an existing device. + +It proposes a mechanism for a new an existing device to establish a secure out-of-band channel through which they can +communicate to facilitate the sign in on the new device. + +Table of contents: + +- [Proposal](#proposal) +- [Insecure rendezvous session](#insecure-rendezvous-session) +- [QR code format](#qr-code-format) +- [Secure channel](#secure-channel) +- [Potential issues](#potential-issues) +- [Alternatives](#alternatives) +- [Security considerations](#security-considerations) +- [Unstable prefix](#unstable-prefix) +- [Dependencies](#dependencies) + +## Proposal + +Depending on the pair of devices used, it may be preferable to scan the QR code on either the new or existing device, +based on the availability of a camera. As such, this proposal allows for the generation of the QR on either device. + +This proposal is split into three parts: + +- Creating an insecure rendezvous session on a homeserver +- Encoding the rendezvous session and an ephemeral key in a QR code +- Establishing a secure channel over the insecure rendezvous session using an ephemeral key + +## Insecure rendezvous session + +It is proposed that an HTTP-based protocol be used to establish an ephemeral bi-directional communication session over +which the two devices can exchange the necessary data. This session is described as "insecure" as it provides no +end-to-end confidentiality nor authenticity by itself - these are layered on top of it. + +New optional HTTP endpoints are to be added to the Client-Server API. + +### High-level description + +Suppose that Device A wants to establish communications with Device B. Device A can do so by creating a +_rendezvous session_ via a `POST /_matrix/client/v1/rendezvous` call to an appropriate homeserver. Its response includes +an _rendezvous ID_ which, along with the server [base URL], should be shared out-of-band with Device B. + +The rendezvous ID points to an arbitrary data resource (the "payload") on the homeserver, which is initially populated +using data from A's initial `POST` request. The payload is a string which the homeserver must enforce a maximum length on. + +Anyone who is able to reach the homeserver and has the rendezvous ID - including: Device A; Device B; or a third party; - +can then "receive" the payload by polling via a `GET` request, and "send" a new a new payload by making a `PUT` request. + +In this way, Device A and Device B can communicate by repeatedly inspecting and updating the payload at the rendezvous session. + +### The send mechanism + +Every send request MUST include an `sequence_token` value whose value is the `sequence_token` from the last `GET` +response seen by the requester. (The initiating device may also use the `sequence_token` supplied in the initial `POST` response +to immediately update the payload.) Sends will succeed only if the supplied `sequence_token` matches the server's current +revision of the payload. This prevents concurrent writes to the payload. + +n.b. Once a new payload has been sent there is no mechanism to retrieve previous payloads. + +### Expiry + +The rendezvous session (i.e. the payload) SHOULD expire after a period of time communicated to clients via the +`expires_ts` field on the `POST` and `GET` response bodies. After this point, any further attempts to query or update +the payload MUST fail. The rendezvous session can be manually expired with a `DELETE` call to the rendezvous session. + +### Example API usage + +The actions above can be illustrated as follows: + +```mermaid +sequenceDiagram + participant A as Device A + participant HS as Homeserver
https://matrix.example.com + participant B as Device B + Note over A: Device A determines which rendezvous server to use + + A->>+HS: POST /_matrix/client/v1/rendezvous
{"data":"Hello from A"} + HS->>-A: 200 OK
{"id":"abc-def-123-456","sequence_token": "1", "expires_ts": 1234567} + + A-->>B: Rendezvous ID and homeserver base URL shared out of band as QR code: e.g. id=abc-def-123-456 baseURL=https://matrix.example.com + + Note over A: Device A starts polling for new payloads at the
rendezvous session using the returned `sequence_token` + activate A + + Note over B: Device B resolves the servername to the homeserver + + B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + HS->>-B: 200 OK
{"sequence_token": "1", "data": "Hello from A"} + loop Device A polls the rendezvous session for a new payload + A->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + alt is not modified + HS->>-A: 200 OK
{"sequence_token": "1", "data": "Hello from A", "expires_ts": 1234567} + end + end + + note over B: Device B sends a new payload + B->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "1", "data": "Hello from B"} + HS->>-B: 200 OK
{"sequence_token": "2"} + + Note over B: Device B starts polling for new payloads at the
rendezvous session using the new `sequence_token` + activate B + + loop Device B polls the rendezvous session for a new payload + B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + alt is not modified + HS->>-B: 200 OK
{"sequence_token": "2", "data": "Hello from A", "expires_ts": 1234567} + end + end + + note over A: Device A then receives the new payload + opt modified + HS->>A: 200 OK
{"sequence_token": "2", "data": "Hello from B", "expires_ts": 1234567} + end + deactivate A + + note over A: Device A sends a new payload + A->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "2", "data": "Hello again from A"} + HS->>-A: 200 OK
{"sequence_token": "3"} + + note over B: Device B then receives the new payload + opt modified + HS->>B: 200 OK
{"sequence_token": "3", "data": "Hello again from B", "expires_ts": 1234567} + end + + deactivate B +``` + +### `POST /_matrix/client/v1/rendezvous` - Create a rendezvous session and send initial payload + +Rate-limited: Yes +Requires authentication: Optional - depending on server policy + +Request body is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`data`|required `string`|The data payload to be sent| + +For example: + +```http +POST /_matrix/client/v1/rendezvous HTTP/1.1 +Content-Type: application/json + +{ + "data": "initial data" +} +``` + +HTTP response codes, and Matrix error codes: + +- `200 OK` - rendezvous session created +- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create the rendezvous session +- `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled +- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 UTF8 character limit +- `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited + +Response body for `200 OK` is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`id`|required `string`|Opaque identifier for the rendezvous session| +|`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| +|`expires_ts`|required `integer`|The timestamp (in milliseconds since the epoch) at which the rendezvous session will expire| + +Example response: + +```http +HTTP 200 OK +Content-Type: application/json + +{ + "id": "abcdEFG12345", + "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=", + "expires_ts": 1662560931000 +} +``` + +The server can chose what level of authentication is required to create a rendezvous session. Suitable policies might +include: + +- Public/open - anyone can create a rendezvous session without an access token. This allows for the QR code to be + created on either the new or existing Matrix client. +- Requires authenticated user - this would reduce abuse to known users, but would restrict the mechanism so that the QR + code must be created on the existing Matrix client (and therefore the new Matrix client must have a camera). + +The expiry time is detailed [below](#maximum-duration-of-a-rendezvous). + +### `PUT /_matrix/client/v1/rendezvous/{rendezvousId}` - Send a payload to an existing rendezvous + +Rate-limited: Yes +Requires authentication: No + +Request body is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`sequence_token`|required `string`|The value of `sequence_token` from the last payload seen by the requesting device.| +|`data`|required `string`|The data payload to be sent.| + +For example: + +```http +PUT /_matrix/client/v1/rendezvous/abcdEFG12345 HTTP/1.1 +Content-Type: application/json + +{ + "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=", + "data": "new data" +} +``` + +HTTP response codes, and Matrix error codes: + +- `200 OK` - payload updated +- `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired) +- `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled +- `409 Conflict` (`M_CONCURRENT_WRITE`, a new error code) - when the `sequence_token` does not match +- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 UTF8 character limit +- `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited + +The response body for `200 OK` is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| + +For example: + +```http +HTTP 200 OK +Content-Type: application/json + +{ + "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=" +} +``` + +### `GET /_matrix/client/v1/rendezvous/{rendezvousId}` - Receive a payload from a rendezvous session + +Rate-limited: Yes +Requires authentication: No + +HTTP response codes, and Matrix error codes: + +- `200 OK` - payload returned +- `403 Forbidden` (`M_FORBIDDEN`) - request is not allowed due to the unsafe content policy (see below) +- `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired) +- `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled +- `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited + +Response body for `200 OK` is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`data`|required `string`|The data payload from the last POST or PUT.| +|`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| +|`expires_ts`|required `integer`|The timestamp (in milliseconds since the epoch) at which the rendezvous session will expire| + +```http +HTTP 200 OK +Content-Type: application/json + +{ + "data": "data from the previous POST/PUT", + "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=", + "expires_ts": 1662560931000 +} +``` + +To help mitigate the threat of [unsafe content](#unsafe-content), the server SHOULD inspect the `Sec-Fetch-*` +[Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) (or other suitable headers) to identify +top-level navigation requests and return a `403` HTTP response with error code `M_FORBIDDEN` instead. + +A future optimisation could be allow the client to "long-poll" by sending the previous `sequence_token` as a query parameter +and then the server returns when the is new data or some timeout has passed. + +### `DELETE /_matrix/client/v1/rendezvous/{rendezvousId}` - cancel a rendezvous session + +Rate-limited: Yes +Requires authentication: No + +HTTP response codes: + +- `200 OK` - rendezvous session cancelled +- `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired) +- `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled +- `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited + +### Implementation notes + +#### Maximum payload size + +The server MUST enforce a maximum payload size of 4096 UTF8 characters. + +#### `sequence_token` values + +The `sequence_token` values should be unique to the rendezvous session and the last modified time so that two clients can +distinguish between identical payloads sent by either client. + +#### Maximum duration of a rendezvous + +The rendezvous session needs to persist for the duration of the login including allowing the user another time to +confirm that the secure channel has been established and complete any extra homeserver mandated login steps such as MFA. + +Clients should handle the case of the rendezvous session being cancelled or timed out by the server. + +The server MUST enforce a timeout on each rendezvous. When picking a value to use: + +- the minimum timeout SHOULD be 120 seconds for usability +- the maximum timeout SHOULD be 300 seconds for security + +#### Choice of server + +Ultimately it will be up to the Matrix client implementation to decide which rendezvous server to use. + +However, it is suggested that the following logic is used by the device/client to choose the rendezvous server in order +of preference: + +1. If the client is already logged in: try and use the current homeserver. +1. If the client is not logged in and it is known which homeserver the user wants to connect to: try and use that homeserver. +1. Otherwise use a default server. + +### Threat analysis + +#### Denial of Service attack surface + +Because the rendezvous session protocol allows for the creation of arbitrary channels and storage of arbitrary data, it +is possible to use it as a denial of service attack surface. + +As such, the following standard mitigations such as the following may be deemed appropriate by homeserver +implementations and administrators: + +- rate limiting of requests +- limiting the number of concurrent sessions + +Furthermore, this proposal limits the maximum payload size to 4KB. + +#### Data exfiltration + +Because the rendezvous session protocol allows for the storage of arbitrary data, it +is possible to use it to circumvent firewalls and other network security measures. + +Implementation may want to block their production IP addresses from being able to make requests to the rendezvous +endpoints in order to avoid attackers using it as a dead-drop for exfiltrating data. + +#### Unsafe content + +Because the rendezvous session is not authenticated, it is possible for an attacker to use it to distribute malicious +content. + +This could lead to a reputational problem for the homeserver domain or IPs, as well as potentially causing harm to users. + +Mitigations that are included in this proposal: + +- the low maximum payload size (4KB) +- payload is restricted to string +- the rendezvous session should be short-lived +- use of `Sec-Fetch-*` headers to not return payload content when browser has navigated to the session URL + +## QR code format + +To get a good trade-off between visual compactness and high level of error correction we use a binary mode QR with a +similar structure to that of the existing Device Verification QR code encoding described in [Client-Server +API](https://spec.matrix.org/v1.9/client-server-api/#qr-code-format). + +It is proposed that the QR code format that is currently used in the Client-Server API for +[device verification](https://spec.matrix.org/v1.16/client-server-api/#qr-code-format) be extended to be more general +purpose and accommodate this new use case, and future use cases. + +The "QR code version" would be repurposed to be a "QR code type" and used as the way to distinguish the format of the +subsequent data. + +The existing cross verification code would be type `0x02`. I suspect that type `0x01` and `0x00` might correspond to +earlier iterations of the cross signing flow and so might want to be "reserved". + +This proposal then adds a new type `0x03`. + +The QR codes to be displayed and scanned using this format will encode binary strings in the general form: + +- the ASCII string `MATRIX` +- one byte indicating the QR code type: `0x03` which identifies that the QR is part of this proposal +- one byte indicating the intent of the device generating the QR: + - `0x00` a new device wishing to login and self-verify + - `0x01` an existing device wishing to facilitate the login of a new device and self-verify that other device +- the ephemeral Curve25519 public key that will be used for [secure channel establishment](#establishment), as 32 bytes +- the rendezvous session ID encoded as: + - two bytes in network byte order (big-endian) indicating the length in bytes of the rendezvous session ID as a UTF-8 + string + - the rendezvous session ID as a UTF-8 string +- the [base URL] of the homeserver for client-server connections encoded as: + - two bytes in network byte order (big-endian) indicating the length in bytes of the base URL as a UTF-8 string + - the base URL as a UTF-8 string + +If a new version of this QR sign in capability is needed in future (perhaps with updated secure channel protocol) then +an additional type can then be allocated which would clearly distinguish this later version. + +### Example for QR code generated on new device + +A full example for a new device using ephemeral public key `2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws` (base64 +encoded) at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on homeserver with base URL +`https://matrix-client.matrix.org` is as follows: +(Whitespace is for readability only) + +``` +4D 41 54 52 49 58 +03 00 +d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b +00 24 +65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 +00 20 +68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 +``` + +Which looks as follows as a QR with error correction level Q: + +![Example QR for intent 0x00](images/YYYY-qr-intent00.png) + +### Example for QR code generated on existing device + +A full example for an existing device using ephemeral public key `2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws` (base64 +encoded), at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on homeserver with base URL +`https://matrix-client.matrix.org` is as follows: (Whitespace is for readability only) + +``` +4D 41 54 52 49 58 +03 01 +d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b +00 24 +65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 +00 20 +68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 +``` + +Which looks as follows as a QR with error correction level Q: + +![Example QR for intent 0x01](images/YYYY-qr-intent01.png) + +## Secure channel + +The above rendezvous session is insecure, providing no confidentiality nor authenticity against the rendezvous server or +even arbitrary network participants which possess the rendezvous session ID and server base URL. +To provide a secure channel on top of this insecure rendezvous session transport, we propose the following scheme. + +This scheme is essentially [ECIES](https://en.wikipedia.org/wiki/Integrated_Encryption_Scheme#Formal_description_of_ECIES) +instantiated with X25519, HKDF-SHA256 for the KDF and ChaCha20-Poly1305 (as specified by +[RFC8439](https://datatracker.ietf.org/doc/html/rfc8439#section-2.8)) for the authenticated encryption. Therefore, +existing security analyses of ECIES are applicable in this setting too. Nevertheless we include below a short +description of our instantiation of ECIES and discuss some potential pitfalls and attacks. + +The primary limitation of ECIES is that there is no authentication for the initiating party (the one to send the first +payload; Device S in the text below). Thus the recipient party (the one to receive the first payload; Device G in the +text below) has no assurance as to who actually sent the payload. In QR code login, we work around this problem by +exploiting the fact that both of these devices are physically present during the exchange and offloading the check that +they are both in the correct state to the user performing the QR code login process. + +### Establishment + +Participants: + +- Device G (the device generating the QR code) +- Device S (the device scanning the QR code) + +Regardless of which device generates the QR code, either device can be the existing (already signed in) device. The +other device is then the new device (one seeking to be signed in). + +Symmetric encryption uses a separate encryption key for each sender, both derived from a shared secret using HKDF. A +separate deterministic, monotonically-incrementing nonce is used for each sender. Devices initially set both nonces to +`0` and increment the corresponding nonce by `1` for each message sent and received. + +1. **Ephemeral key pair generation** + + Both devices generate an _ephemeral_ Curve25519 key pair: + +- Device G generates **(Gp, Gs)**, where **Gp** is its public key and **Gs** the private (secret) key. +- Device S generates **(Sp, Ss)**, where **Sp** is its public key and **Ss** the private (secret) key. + +2. **Create rendezvous session** + +Device G creates a rendezvous session by making a `POST` request (as described previously) to the nominated homeserver +with an empty payload. It parses the **ID** received. + +3. **Initial key exchange** + +Device G displays a QR code containing sufficient information for the scanning device to locate the rendezvous session +and establish the secure channel. + +The information to be encoded is: + +- Its public key **Gp** +- The insecure rendezvous session **ID** +- An indicator (the **intent**) to say if this is the new device which wishes to login, or an existing device +that wishes to facilitate the login of the new device +- the Matrix homeserver **[base URL]** + +The format of this QR is defined in detail in a [separate section](#qr-code-format) of this proposal. + +Device S scans and parses the QR code to obtain **Gp**, the rendezvous session **ID**, **intent** and the Matrix homeserver +**[base URL]**. + +At this point Device S should check that the received intent matches what the user has asked to do on the device. + +4. **Device S sends the initial payload** + +Device S computes a shared secret **SH** by performing ECDH between **Ss** and **Gp**. It then discards **Ss** and +derives two 32-byte symmetric encryption keys from **SH** using HKDF-SHA256. One of those keys, **EncKey_S** is +used for messages encrypted by device S, while the other, **EncKey_G** is used for encryption by device G. + +The keys are generated with the following HKDF parameters: + +**EncKey_S** + +- `MATRIX_QR_CODE_LOGIN_ENCKEY_S|Gp|Sp` as the info, where **Gp** and **Sp** stand for the generating + device's and the scanning device's ephemeral public keys, encoded as unpadded base64. +- An all-zero salt. + +**EncKey_G** + +- `MATRIX_QR_CODE_LOGIN_ENCKEY_G|Gp|Sp` as the info, where **Gp** and **Sp** stand for the generating + device's and the scanning device's ephemeral public keys, encoded as unpadded base64. +- An all-zero salt. + +With this, Device S has established its side of the secure channel. Device S then derives a confirmation payload that +Device G can use to confirm that the channel is secure. It contains: + +- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305. +- Its public ephemeral key **Sp**. + +``` +Nonce_S := 0 +SH := ECDH(Ss, Gp) +EncKey_S := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_ENCKEY_S|" || Gp || "|" || Sp, salt=0, size=32) + +// Stored, but not yet used +EncKey_G := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_ENCKEY_G|" || Gp || "|" || Sp, salt=0, size=32) + +NonceBytes_S := ToLowEndianBytes(Nonce_S)[..12] +TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey_S, NonceBytes_S, "MATRIX_QR_CODE_LOGIN_INITIATE") +Nonce_S := Nonce_S + 1 +LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) +``` + +Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request. + +5. **Device G confirms** + +Device G receives **LoginInitiateMessage** (potentially coming from Device S) from the insecure rendezvous session by +polling with `GET` requests. + +It then does the reverse of the previous step, obtaining **Sp**, deriving the shared secret using **Gs** and **Sp**, +discarding **Gs**, deriving the two symmetric encryption keys **EncKey_S** and **EncKey_G**, then finally +decrypting (and authenticating) the **TaggedCiphertext** using **EncKey_S**, obtaining a plaintext. + +It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. + +It then responds with a dummy payload containing the string `MATRIX_QR_CODE_LOGIN_OK` encrypted with **SH** calculated +as follows: + +``` +Nonce_G := 0 +NonceBytes_G := ToLowEndianBytes(Nonce_G)[..12] +TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey_G, NonceBytes_G, "MATRIX_QR_CODE_LOGIN_OK") +Nonce_G := Nonce_G + 1 +LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext) +``` + +Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. + +6. **Verification by Device S** + +Device S receives a response over the insecure rendezvous session by polling with `GET` requests, potentially from +Device G. + +It decrypts (and authenticates) it using the previously computed encryption key, which will succeed provided the payload +was indeed sent by Device G. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. + +``` +Nonce_G := 1 +(TaggedCiphertext, Sp) := Unpack(Message) +NonceBytes := ToLowEndianBytes(Nonce)[..12] +Plaintext := ChaCha20Poly1305_Decrypt(EncKey_G, NonceBytes, TaggedCiphertext) +Nonce_G := Nonce_G + 1 + +unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": + FAIL +``` + +If the above was successful, Device S then calculates a two digit **CheckCode** code derived from **SH**, **Gp** and +**Sp**: + +``` +CheckBytes := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp || "|" || Sp , salt=0, size=2) +CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) +``` + +Device S then displays an indicator to the user that the secure channel has been established and that the **CheckCode** +should be entered on the other device when prompted. Example wording could say "Secure connection established. Enter the +code XY on your other device." + +7. **Out-of-band confirmation** + +**Warning**: *This step is crucial for the security of the scheme since it overcomes the aforementioned limitation of +ECIES.* + +Device G asks the user to enter the **CheckCode** that is being displayed on Device S. + +The purpose of the code being entered is to ensure that the user has actually checked their other device rather than +just pressing "continue", and that the Device S has been able to determine that the channel is secure. + +The exact points in the flow that the user is prompted for the **CheckCode** is described in [MSC4108]. + +Device G compares the code that the user has entered with the **CheckCode** that it calculates using the same mechanism +as before: + +``` +CheckBytes := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp "|" || Sp , salt=0, size=2) +CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) +``` + +If the code that the user enters matches then the secure channel is established. + +Subsequent payloads sent from G should be encrypted using **EncKey_G**, while payloads sent from S should be +encrypted with **EncKey_S**, incrementing the corresponding nonce for each message sent/received. + +### Sequence diagram + +The sequence diagram for the secure channel establishment is as follows: + +```mermaid +sequenceDiagram + participant G as Device G + participant Z as Homeserver + participant S as Device S + + note over G,S: 1) Devices G and S each generate an ephemeral Curve25519 key pair + + activate G + note over G: 2) Device G creates a rendezvous session as follows + G->>+Z: POST /_matrix/client/v1/rendezvous
{"data": ""} + Z->>-G: 200 OK
{"id": "abc-def", "sequence_token": "1", "expires_ts": 1234567} + + note over G: 3) Device G generates and displays a QR code containing:
its ephemeral public key, the rendezvous session ID, the server base URL + + G-->>S: Device S scans the QR code shown by Device G + deactivate G + + activate S + note over S: Device S validates QR scanned and the rendezvous session ID + + S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def + Z->>-S: 200 OK
{"sequence_token": "1", "expires_ts": 1234567, "data": ""} + + note over S: 4) Device S computes SH, EncKey_S, EncKey_G and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session + S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "1", "data": ""} + Z->>-S: 200 OK
{"sequence_token": "2"} + deactivate S + + G->>+Z: GET /_matrix/client/v1/rendezvous/abc-def + activate G + Z->>-G: 200 OK
{"sequence_token": "2", "expires_ts": 1234567, "data": ""} + note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after calculating SH, EncKey_S and EncKey_G + note over G: Device G checks that the plaintext matches MATRIX_QR_CODE_LOGIN_INITIATE + + note over G: Device G computes LoginOkMessage and sends to the rendezvous session + + G->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "2", "data": ""} + Z->>-G: 200 OK
{"sequence_token": "3"} + deactivate G + + activate S + S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def + Z->>-S: 200 OK
{"sequence_token": "3", "expires_ts": 1234567, "data": ""} + + note over S: 6) Device S attempts to parse Data as LoginOkMessage + note over S: Device S checks that the plaintext matches MATRIX_QR_CODE_LOGIN_OK + + note over S: If okay, Device S calculates the CheckCode to be displayed + note over S: Device S displays a green checkmark, "secure connection established" and the CheckCode + note over S: Device S knows that the channel is secure + deactivate S + + note over G: 7) Device G asks the user to confirm that the other device is showing a green checkmark and enter the CheckCode + note over G: If the user enters the correct CheckCode and confirms that a green checkmark is shown then Device G knows that the channel is secure +``` + +### Secure operations + +Conceptually, once established, the secure channel offers two operations, `SecureSend` and `SecureReceive`, which wrap +the `Send` and `Receive` operations offered by the rendezvous session API to securely send and receive data between two devices. + +At the end of the establishment phase, the next nonce for each device should be `1`. + +Device G sets: + +``` +Nonce_G := 1 +Nonce_S := 1 +``` + +Device S sets: + +``` +Nonce_G := 1 +Nonce_S := 1 +``` + +### Threat analysis + +In an attack scenario, we add a participant called Specter with the following capabilities: + +- Specter is present for QR code generation/scanning ("shoulder-surfing") and can scan the code themselves. +- Specter has full control over the network (in a Dolev-Yao sense), being able to observe and modify all traffic. +- Specter controls both the homeserver and the rendezvous server. + +#### Replay protection + +Due to use of ephemeral key pairs which are immediately discarded after use, each QR code login session derives a unique +secret so payloads from earlier sessions cannot be replayed. Each payload in the session is unique and expected only +once. Finally, the use of deterministic nonces prevents any possibility of replay. + +#### Pure Dolev-Yao attacker + +An attacker with control over the network but _not_ present for the QR code scanning cannot thwart the process since +they are unable to obtain the ephemeral key **Gp** of Device G. + +#### Shoulder-surfing attacker (Specter) + +Since Device G has no way of authenticating Device S, an attacker present for the QR code scanning can learn **Gp** and +attempt to mimic Device S in order to get their Device S signed in instead. + +- In step 3, Specter can shoulder surf the QR code scanning to obtain **Gp**. +- In step 4, Specter can intercept S's payload and replace it with a payload of their own, replacing **Sp** with its +own key. +- The attack is only thwarted in step 7, because Device S won't ever display the indicator of success to the user. The +user then must cancel the process on Device G, preventing it from sharing any sensitive material. + +### Choice of message prefix + +During the secure channel establishment the messages have been prefixed with `MATRIX_QR_CODE_LOGIN_` rather than +something more generic. The purpose is to bind the protocol to this specific application. + +Whilst the could be other uses for the secure channel mechanism or we might establish communication between devices +using another mechanism (e.g. NFC or sound), this proposal only considers the scenario where the communication is +initiated via QR code and we make the prefix explicitly named to match. + +## Potential issues + +Because this is an entirely new set of functionality it should not cause issue with any existing Matrix functions or capabilities. + +The proposed protocol requires the devices to have IP connectivity to the server which might not be the case in P2P scenarios. + +## Alternatives + +### Alternative to the rendezvous session protocol + +#### ETag based rendezvous API + +An earlier iteration of this MSC used an alternative rendezvous API that was based on +[MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886). + +However, it was found to have issues including: + +- the ETags were getting mangled by proxies and load balancers +- the semantics of the API are different from the rest of the Matrix Client-Server API +- the CORS header changes required additional configuration work + +The present iteration of the rendezvous API described in this MSC attempts to "feel" more like a Matrix Client-Server +API. + +#### Send-to-Device messaging + +If you squint then this proposal looks similar in some regards to the existing +[Send-to-device messaging](https://spec.matrix.org/v1.9/client-server-api/#send-to-device-messaging) capability. + +Whilst to-device messaging already provides a mechanism for secure communication between two Matrix clients/devices, a +key consideration for the anticipated login with QR capability is that one of the clients is not yet authenticated with +a homeserver. + +Furthermore the client might not know which homeserver the user wishes to connect to. + +Conceptually, one could create a new type of "guest" login that would allow the unauthenticated client to connect to a +homeserver for the purposes of communicating with an existing authenticated client via to-device messages. + +Some considerations for this: + +Where the "actual" homeserver is not known then the "guest" homeserver nominated by the new client would need to be +federated with the "actual" homeserver. + +The "guest" homeserver would probably want to automatically clean up the "guest" accounts after a short period of time. + +The "actual" homeserver operator might not want to open up full "guest" access so a second type of "guest" account might +be required. + +Does the new device/client need to accept the T&Cs of the "guest" homeserver? + +#### Other existing protocols + +One could try and do something with STUN or TURN or [COAP](https://datatracker.ietf.org/doc/html/rfc7252). + +#### Implementation details + +Rather than requiring the devices to poll for updates, "long-polling" could be used instead similar to `/sync`. Or WebSockets. + +#### Unauthenticated device could crated "redirect channel" without payload + +In the current proposal the server operator may choose to not allow unauthenticated devices to create a rendezvous +session to reduce abuse/attack vectors. + +In this scenario it means that the unauthenticated client cannot create the QR code. + +An alternative would be to do something like this: + +1. Unauthenticated device (UD) creates a "redirect channel" on HS1 and sets that in the QR code. +1. The authenticated device (AD) creates a rendezvous channel on HS2. +1. HS2 POSTS to the redirect channel on HS1 with the homeserver and rendezvous channel ID. HS1 validates its from HS2. +1. HS1 returns the homeserver (HS2) and rendezvous channel ID to UD, who then uses that channel as normal. + +This has the following properties: + +1. It limits how much information can be persisted on an unauthenticated channel. We can severely restrict the size + of the request ID for example. +1. An abuser must use a domain they own if they want to encode dodgy data in the rendezvous channel ID. We can then ban + abusive domains. +1. An unauthenticated device can only receive information, rather than create a 2-way channel. Not sure that's at all + useful thing to assert, but it is nonetheless a property. +1. For each redirect channel created, you can only send one payload. This makes it easier to heavily ratelimit. + +Erik [said](https://github.com/matrix-org/matrix-spec-proposals/pull/4108#discussion_r2336295451): +> I think this sort of flow would reduce potential abuse vectors, but equally makes things more complicated and may not +> be worth it. + +### Alternative QR code formats + +An earlier version of this proposal kept the "version" byte at `0x02` and added additional "mode" +values of `0x03` (which is now intent `0x00`) and `0x04` (which is now intent `0x01`). + +The current usage of converting the "version" to be a "type" _feels_ like a more intuitive use of +the bytespace. + +Another alternative was to use a human readable prefix such as `MATRIX_LOGIN` instead of `MATRIX`. +This was discounted on the basis of wanting to keep the QR reasonably compact. + +## Security considerations + +This proposed mechanism has been designed to protects users and their devices from the following threats: + +- A malicious actor who is able to scan the QR code generated by the legitimate user. +- A malicious actor who can intercept and modify traffic on the application layer, even if protected by encryption like TLS. +- Both of the above at the same time. + +Additionally, the homeserver is able to define and enforce policies that can prevent a sign in on a new device. +Such policies depend on the homeserver in use and could include, but are not limited to, time of day, day of the week, +source IP address and geolocation. + +A threat analysis has been done within each of the key layers in the proposal above. + +## Unstable prefix + +### Rendezvous API prefix + +While this feature is in development the new API endpoints should be exposed using the following unstable prefix: + +- `/_matrix/client/unstable/io.element.mscYYYY/rendezvous` instead of `/_matrix/client/v1/rendezvous` + +Additionally, the feature is to be advertised as unstable feature in the `GET /_matrix/client/versions` response, with the +key `io.element.mscYYYY` set to true. So, the response could look then as following: + +```json +{ + "versions": ["..."], + "unstable_features": { + "io.element.mscYYYY": true + } +} +``` + +### Unstable QR code format + +The unstable value of `IO_ELEMENT_MSCYYYY` should be used instead of `MATRIX` in the QR code. + +A full example for an existing device using ephemeral public key `2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws` (base64 +encoded), at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on homeserver `https://matrix-client.matrix.org` is as follows: (Whitespace is for readability only) + +``` +49 4F 5F 45 4C 45 4D 45 4E 54 5F 4D 53 43 34 31 30 38 +03 01 +d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b +00 24 +65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 +00 20 +68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 +``` + +Which looks as follows as a QR with error correction level Q: + +![Example QR for intent 0x01](images/YYYY-qr-intent01-unstable.png) + +It is suggested that this unstable QR prefix convention could be used by future proposals. + +### M_CONCURRENT_WRITE errcode + +The unstable value of `IO_ELEMENT_MSCYYYY_CONCURRENT_WRITE` should be used instead of `M_CONCURRENT_WRITE`. + +## Dependencies + +None. + +[base URL]: https://spec.matrix.org/v1.16/client-server-api/#getwell-knownmatrixclient +[MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 From e298500053ae4ead47174287c0d2f976c803b57a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Dec 2025 17:40:20 +0000 Subject: [PATCH 02/47] MSC4388 --- ...r-channel.md => 4388-secure-qr-channel.md} | 28 +++++++++--------- proposals/images/4388-qr-intent00.png | Bin 0 -> 918 bytes .../images/4388-qr-intent01-unstable.png | Bin 0 -> 910 bytes proposals/images/4388-qr-intent01.png | Bin 0 -> 918 bytes 4 files changed, 14 insertions(+), 14 deletions(-) rename proposals/{YYYY-secure-qr-channel.md => 4388-secure-qr-channel.md} (98%) create mode 100644 proposals/images/4388-qr-intent00.png create mode 100644 proposals/images/4388-qr-intent01-unstable.png create mode 100644 proposals/images/4388-qr-intent01.png diff --git a/proposals/YYYY-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md similarity index 98% rename from proposals/YYYY-secure-qr-channel.md rename to proposals/4388-secure-qr-channel.md index 52d163968f4..f4270a6a45a 100644 --- a/proposals/YYYY-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -1,4 +1,4 @@ -# MSCYYYY: Secure out-of-band channel for sign in with QR +# MSC4388: Secure out-of-band channel for sign in with QR This proposal forms part of [MSC4108] to make it easy to sign in on a new device with the help of an existing device. @@ -424,9 +424,9 @@ d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 00 24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 -68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/YYYY-qr-intent00.png' +68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/4388-qr-intent00.png' --> -![Example QR for intent 0x00](images/YYYY-qr-intent00.png) +![Example QR for intent 0x00](images/4388-qr-intent00.png) ### Example for QR code generated on existing device @@ -454,9 +454,9 @@ d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 00 24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 -68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/YYYY-qr-intent01.png' +68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/4388-qr-intent01.png' --> -![Example QR for intent 0x01](images/YYYY-qr-intent01.png) +![Example QR for intent 0x01](images/4388-qr-intent01.png) ## Secure channel @@ -882,29 +882,29 @@ A threat analysis has been done within each of the key layers in the proposal ab While this feature is in development the new API endpoints should be exposed using the following unstable prefix: -- `/_matrix/client/unstable/io.element.mscYYYY/rendezvous` instead of `/_matrix/client/v1/rendezvous` +- `/_matrix/client/unstable/io.element.msc4388rendezvous` instead of `/_matrix/client/v1/rendezvous` Additionally, the feature is to be advertised as unstable feature in the `GET /_matrix/client/versions` response, with the -key `io.element.mscYYYY` set to true. So, the response could look then as following: +key `io.element.msc4388` set to true. So, the response could look then as following: ```json { "versions": ["..."], "unstable_features": { - "io.element.mscYYYY": true + "io.element.msc4388": true } } ``` ### Unstable QR code format -The unstable value of `IO_ELEMENT_MSCYYYY` should be used instead of `MATRIX` in the QR code. +The unstable value of `IO_ELEMENT_MSC4388` should be used instead of `MATRIX` in the QR code. A full example for an existing device using ephemeral public key `2IZoarIZe3gOMAqdSiFHSAcA15KfOasxueUUNwJI7Ws` (base64 encoded), at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on homeserver `https://matrix-client.matrix.org` is as follows: (Whitespace is for readability only) ``` -49 4F 5F 45 4C 45 4D 45 4E 54 5F 4D 53 43 34 31 30 38 +49 4F 5F 45 4C 45 4D 45 4E 54 5F 4D 53 43 34 33 38 38 03 01 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b 00 24 @@ -917,21 +917,21 @@ Which looks as follows as a QR with error correction level Q: -![Example QR for intent 0x01](images/YYYY-qr-intent01-unstable.png) +![Example QR for intent 0x01](images/4388-qr-intent01-unstable.png) It is suggested that this unstable QR prefix convention could be used by future proposals. ### M_CONCURRENT_WRITE errcode -The unstable value of `IO_ELEMENT_MSCYYYY_CONCURRENT_WRITE` should be used instead of `M_CONCURRENT_WRITE`. +The unstable value of `IO_ELEMENT_MSC4388_CONCURRENT_WRITE` should be used instead of `M_CONCURRENT_WRITE`. ## Dependencies diff --git a/proposals/images/4388-qr-intent00.png b/proposals/images/4388-qr-intent00.png new file mode 100644 index 0000000000000000000000000000000000000000..9cef72ef3abf29fd1d086a87054180f545be2c2a GIT binary patch literal 918 zcmV;H18Mw;P)=>)<}1Mk__+XF$(gT##04O8U{U)DW@k3o#Fg&0My=@&$!)t{ zy?Raj_ZR)=U;HE*qTlk~lKUjr+Ct29yR$` z*f8Vl{#z$i{OE$vM%eK=0LLk#aeYx$LqmFSabv=Aqx_3XBuLzzvak<60K zZQbyevYvCoNtt7rV(B6E;LnaVoT;~Pl4B0MM6-(Wy@p3#av3`)9G6y5#PwajlHayW ziKr`+ltA3z^WIw}zeJEb1<1*q1h+JEr{WoY0;%I;8MsjAXRG0yp#gEHHkg}SFV@5g zenlGa$U3rni(ckqNg93!G3_=uh~b7l$!{utF*VVj1W{3woVkkNqt%BZOuqE~Qf5b~>q-}*avhyfm@;TGb=gvC>{M1K=~&4pSnQim`3 zr;(+OoHhKmG_jkKYm%J3|D7MId57(}4@N*l3;Xq{h=O04&@Z!;64MsVViwkL-1!pG zBh(YD$zbsHq7f!#=ww_kba;*$UD{`~wtmmV%pqAB@>-H`Z$ zP5#nJ4?l^f?AL7YqB*)b@^bC7m+y_=p<=~nb5p~+_!C&gk*Lbethv7J@ycbiDxR=0 z9D$>N1Fd`VD{DC79Uuu?2#YuON@o9=hc3?YgWr&PD`Sw?ZBfHFTV9DMULkXNwu7#` z_43o2wUwD5Cmbj??7sT!<$ErJ?9=QBws}tY11mVUx--`IpCEcTFNG-h8E9xU2e=cj z?HF3!qK0q5pX8V3KlYfd-!l@Z;AX<(dPA(8`?coQo)3IxGUN&IcZ%GrJM z*2^~wL88@~1)*odV&kmfC)HE5O|x|mDzmMqAO)95BYHGacT6nWc;0^A$Kh{hfX)wu z`3Zg#r}7GZ^`jzsNN7p)O1Cfdax}zMya>7~G8)0gS;ZOt=B7+f(UsKT`__3M-=b`9 zYV<(boB7ys>!Fi#!}w7VHJaKAQK2g()^KbrnhoxaITl?~-lgFi3O0%ZNI!x>VMfX- zP6jeq8J!UcG^fl(DjsNJNYqF}u@GuqZa?qir@acTVN@$aI-v7M&~R?j8PTR6Bn^%; zcB|qHVN2?s?APSqbo*N`zwR%XVK5bcee(9(UM}-FY(GNh{!)d%>#>(7hxJF2d3Z?X zw6GK{N6e6St1IJ5g>K??9Cs*O6GrS7h8g%f&vbK%7Yax9$pEm6%W1e=6XaTl_A0?n kx%{k`KK`%!?|&}O50>f$(_o!meE!gW4;pp;tmvbSSi`fE%;T4?O--&moDVm*6yf0D1pD5RNr}MFXPo6XNtl?KU zujoGV^iROCD_Pd?NFT}DF+@=*#PFW8l|S~}i26*Eg*YLtpVKOMbX`KoW-aEiZhWt@ zopVOWo>QGu9U%4SufQ74G+5W?bBVkrvx)M(h9_Q2odOh&q*Ywv_U_-vA6utLB&Anj z2unQrZ$8`j4MEz2BIIN)IzD8UpyI{NaL9z$I&!7p7pvi1%w5EtKG8hn2C*hq@H^31 z%w-+i`-58MbIlt5K;H5AL*=Oey3j z_sB)VA4@Z%Mb<7$?886vLo*LJ9((}FM+y7w=SCF#E;J`JBV1+e3yQ_8tl`M{3ZJwz z5Uj~y^F3?$jbIB+0a3GT4y{I3@Jrg+F=w+y3|Fo%?ycWu9%#VXm}c=wN67!%ry99* z{CIN4oMt#f3~qkwx0z#qOb&j9y++Oj zJ&3m;PTMmAC$cJz>$8|cP7%EHZs^=AxXi<{gf@f;7OD`}RdAWl346l5{T3k!fBl~G zo4EX6K&9{^Qy+pwKVlt6pM?_8@gKrHqVAM@PHqyNV=|{9*0FdE=lU~QVS0q$B6}!d sR&eBhiU-0R6Q1u6X?W}Z;{O-_1wb?VEVkkRz5oCK07*qoM6N<$g8h%a_5c6? literal 0 HcmV?d00001 From 86baa154a489fcd7c5c97bd52e2597f71f14c851 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 12 Dec 2025 09:34:08 +0000 Subject: [PATCH 03/47] Overview diagram --- proposals/4388-secure-qr-channel.md | 40 ++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index f4270a6a45a..c36945c7627 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -28,6 +28,44 @@ This proposal is split into three parts: - Encoding the rendezvous session and an ephemeral key in a QR code - Establishing a secure channel over the insecure rendezvous session using an ephemeral key +The overall flow looks like this: + +```mermaid +sequenceDiagram + participant G as Device generating QR + participant HS as Homeserver + participant S as Device scanning QR + + note over G,S: Generating and scanning devices each pick ephemeral Curve25519 keys to use to set up the secure channel + + note over G: Generating device picks a suitable homeserver to use for the rendezvous session + + G->>+HS: Creates a rendezvous session on the homeserver + HS->>-G: ID for the rendezvous + + note over G: Generates and displays a QR code containing:
ephemeral key of generating device, rendezvous ID, homeserver base URL,
device disposition (whether device is new or existing) + + G-->>S: Other device scans the QR code + + note over S: Scanning device checks that the disposition matches what it expects + + S->>+HS: Scanning device checks that it can communicate with the
rendezvous session on the homeserver identified by information in QR + HS->>-S: OK + + G<<->>S: Generating and scanning device then communicate by
sending and receiving to/from the rendezvous session via the homeserver + + note over G,S: Devices perform Elliptic Curve Diffie-Hellman protocol
(using the ephemeral keys from earlier on) to agree a shared secret + S->>G: Scanning device sends LoginInitiateMessage handshake message + G->>S: Generating device validates and responds with LoginOkMessage handshake message + + note over G,S: Devices ask user to confirm that the connection isn't being Man-In-The-Middle attacked by
entering a two digit CheckCode shown on scanning device into the generating device + + S-->>G: User enters two digit code + note over G,S: Devices can now communicate securely using the shared secret to encrypt future messages via the rendezvous session + + S<<->>G: Devices then send and receive messages as described in MSC4108 +``` + ## Insecure rendezvous session It is proposed that an HTTP-based protocol be used to establish an ephemeral bi-directional communication session over @@ -36,7 +74,7 @@ end-to-end confidentiality nor authenticity by itself - these are layered on top New optional HTTP endpoints are to be added to the Client-Server API. -### High-level description +### Concept Suppose that Device A wants to establish communications with Device B. Device A can do so by creating a _rendezvous session_ via a `POST /_matrix/client/v1/rendezvous` call to an appropriate homeserver. Its response includes From 620aa5e59fa500f865febe87d648516605403510 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 12 Dec 2025 10:32:34 +0000 Subject: [PATCH 04/47] Move example API usage back to after the API definitions --- proposals/4388-secure-qr-channel.md | 124 ++++++++++++++-------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index c36945c7627..7757837c140 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -103,68 +103,6 @@ The rendezvous session (i.e. the payload) SHOULD expire after a period of time c `expires_ts` field on the `POST` and `GET` response bodies. After this point, any further attempts to query or update the payload MUST fail. The rendezvous session can be manually expired with a `DELETE` call to the rendezvous session. -### Example API usage - -The actions above can be illustrated as follows: - -```mermaid -sequenceDiagram - participant A as Device A - participant HS as Homeserver
https://matrix.example.com - participant B as Device B - Note over A: Device A determines which rendezvous server to use - - A->>+HS: POST /_matrix/client/v1/rendezvous
{"data":"Hello from A"} - HS->>-A: 200 OK
{"id":"abc-def-123-456","sequence_token": "1", "expires_ts": 1234567} - - A-->>B: Rendezvous ID and homeserver base URL shared out of band as QR code: e.g. id=abc-def-123-456 baseURL=https://matrix.example.com - - Note over A: Device A starts polling for new payloads at the
rendezvous session using the returned `sequence_token` - activate A - - Note over B: Device B resolves the servername to the homeserver - - B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 - HS->>-B: 200 OK
{"sequence_token": "1", "data": "Hello from A"} - loop Device A polls the rendezvous session for a new payload - A->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 - alt is not modified - HS->>-A: 200 OK
{"sequence_token": "1", "data": "Hello from A", "expires_ts": 1234567} - end - end - - note over B: Device B sends a new payload - B->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "1", "data": "Hello from B"} - HS->>-B: 200 OK
{"sequence_token": "2"} - - Note over B: Device B starts polling for new payloads at the
rendezvous session using the new `sequence_token` - activate B - - loop Device B polls the rendezvous session for a new payload - B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 - alt is not modified - HS->>-B: 200 OK
{"sequence_token": "2", "data": "Hello from A", "expires_ts": 1234567} - end - end - - note over A: Device A then receives the new payload - opt modified - HS->>A: 200 OK
{"sequence_token": "2", "data": "Hello from B", "expires_ts": 1234567} - end - deactivate A - - note over A: Device A sends a new payload - A->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "2", "data": "Hello again from A"} - HS->>-A: 200 OK
{"sequence_token": "3"} - - note over B: Device B then receives the new payload - opt modified - HS->>B: 200 OK
{"sequence_token": "3", "data": "Hello again from B", "expires_ts": 1234567} - end - - deactivate B -``` - ### `POST /_matrix/client/v1/rendezvous` - Create a rendezvous session and send initial payload Rate-limited: Yes @@ -327,6 +265,68 @@ HTTP response codes: - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited +### Example API usage + +The actions above can be illustrated as follows: + +```mermaid +sequenceDiagram + participant A as Device A + participant HS as Homeserver
https://matrix.example.com + participant B as Device B + Note over A: Device A determines which rendezvous server to use + + A->>+HS: POST /_matrix/client/v1/rendezvous
{"data":"Hello from A"} + HS->>-A: 200 OK
{"id":"abc-def-123-456","sequence_token": "1", "expires_ts": 1234567} + + A-->>B: Rendezvous ID and homeserver base URL shared out of band as QR code: e.g. id=abc-def-123-456 baseURL=https://matrix.example.com + + Note over A: Device A starts polling for new payloads at the
rendezvous session using the returned `sequence_token` + activate A + + Note over B: Device B resolves the servername to the homeserver + + B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + HS->>-B: 200 OK
{"sequence_token": "1", "data": "Hello from A"} + loop Device A polls the rendezvous session for a new payload + A->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + alt is not modified + HS->>-A: 200 OK
{"sequence_token": "1", "data": "Hello from A", "expires_ts": 1234567} + end + end + + note over B: Device B sends a new payload + B->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "1", "data": "Hello from B"} + HS->>-B: 200 OK
{"sequence_token": "2"} + + Note over B: Device B starts polling for new payloads at the
rendezvous session using the new `sequence_token` + activate B + + loop Device B polls the rendezvous session for a new payload + B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 + alt is not modified + HS->>-B: 200 OK
{"sequence_token": "2", "data": "Hello from A", "expires_ts": 1234567} + end + end + + note over A: Device A then receives the new payload + opt modified + HS->>A: 200 OK
{"sequence_token": "2", "data": "Hello from B", "expires_ts": 1234567} + end + deactivate A + + note over A: Device A sends a new payload + A->>+HS: PUT /_matrix/client/v1/rendezvous/abc-def-123-456
{"sequence_token": "2", "data": "Hello again from A"} + HS->>-A: 200 OK
{"sequence_token": "3"} + + note over B: Device B then receives the new payload + opt modified + HS->>B: 200 OK
{"sequence_token": "3", "data": "Hello again from B", "expires_ts": 1234567} + end + + deactivate B +``` + ### Implementation notes #### Maximum payload size From ac711d44ce9dbea31ee81240aecc75e15e0317dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Wed, 17 Dec 2025 16:35:25 +0100 Subject: [PATCH 05/47] Switch from ECIES to HPKE --- proposals/4388-secure-qr-channel.md | 228 ++++++++++++++++++---------- 1 file changed, 147 insertions(+), 81 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 7757837c140..c60aac44d87 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -502,17 +502,22 @@ The above rendezvous session is insecure, providing no confidentiality nor authe even arbitrary network participants which possess the rendezvous session ID and server base URL. To provide a secure channel on top of this insecure rendezvous session transport, we propose the following scheme. -This scheme is essentially [ECIES](https://en.wikipedia.org/wiki/Integrated_Encryption_Scheme#Formal_description_of_ECIES) -instantiated with X25519, HKDF-SHA256 for the KDF and ChaCha20-Poly1305 (as specified by -[RFC8439](https://datatracker.ietf.org/doc/html/rfc8439#section-2.8)) for the authenticated encryption. Therefore, -existing security analyses of ECIES are applicable in this setting too. Nevertheless we include below a short -description of our instantiation of ECIES and discuss some potential pitfalls and attacks. - -The primary limitation of ECIES is that there is no authentication for the initiating party (the one to send the first -payload; Device S in the text below). Thus the recipient party (the one to receive the first payload; Device G in the -text below) has no assurance as to who actually sent the payload. In QR code login, we work around this problem by -exploiting the fact that both of these devices are physically present during the exchange and offloading the check that -they are both in the correct state to the user performing the QR code login process. +This scheme is essentially [HPKE](https://www.rfc-editor.org/rfc/rfc9180) in [base +mode](https://www.rfc-editor.org/rfc/rfc9180.html#name-hpke-modes) instantiated with X25519, HKDF-SHA256 for the KDF and +ChaCha20-Poly1305 (as specified by [RFC8439](https://datatracker.ietf.org/doc/html/rfc8439#section-2.8)) for the +authenticated encryption. Therefore, existing security analyses of HPKE are applicable in this setting too. Nevertheless +we include below a short description of our instantiation of HPKE and discuss some potential pitfalls and attacks. + +The primary limitation of HPKE in base mode is that there is no authentication for the initiating party (the one to send +the first payload; Device S in the text below). Thus the recipient party (the one to receive the first payload; Device G +in the text below) has no assurance as to who actually sent the payload. In QR code login, we work around this problem +by exploiting the fact that both of these devices are physically present during the exchange and offloading the check +that they are both in the correct state to the user performing the QR code login process. + +Additionally the HPKE RFC exclusively defines unidirectional encryption. The scheme found in the [Oblivious +HTTP](https://www.rfc-editor.org/rfc/rfc9458) RFC under the [Encapsulation of +Responses](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) section is used to enable +bidirectional encryption in our chosen HPKE scheme. ### Establishment @@ -524,9 +529,12 @@ Participants: Regardless of which device generates the QR code, either device can be the existing (already signed in) device. The other device is then the new device (one seeking to be signed in). -Symmetric encryption uses a separate encryption key for each sender, both derived from a shared secret using HKDF. A -separate deterministic, monotonically-incrementing nonce is used for each sender. Devices initially set both nonces to -`0` and increment the corresponding nonce by `1` for each message sent and received. +Symmetric encryption uses a separate encryption key for each sender, both derived from a shared secret using HKDF. +Separate nonces are used for each direction of the communication channel. Device S will create a base nonce from the +shared secret, while Device G will create a new random base nonce. Each base nonce is mixed with a +monotonically-incrementing sequence number. Devices initially set both numbers to `0` and increment the corresponding +number by `1` for each message sent and received. The per-message nonce is is the result of XORing the base nonce with +the current sequence number, encoded as a big-endian integer of the same length as base nonce. 1. **Ephemeral key pair generation** @@ -562,41 +570,48 @@ At this point Device S should check that the received intent matches what the us 4. **Device S sends the initial payload** -Device S computes a shared secret **SH** by performing ECDH between **Ss** and **Gp**. It then discards **Ss** and -derives two 32-byte symmetric encryption keys from **SH** using HKDF-SHA256. One of those keys, **EncKey_S** is -used for messages encrypted by device S, while the other, **EncKey_G** is used for encryption by device G. +Device S performs an ECDH operation using **Ss** and **Gp** to compute the ECDH shared secret **SharedSecret**. This +value is input to the HPKE key schedule, where it is first expanded into a common secret **Secret** using HKDF-SHA256. +After this step, **Ss** is discarded. -The keys are generated with the following HKDF parameters: +From this common **Secret**, the HPKE key schedule derives the following values: -**EncKey_S** +* a symmetric encryption key **AeadKey_S** +* an **ExporterSecret** +* a **BaseNonce** -- `MATRIX_QR_CODE_LOGIN_ENCKEY_S|Gp|Sp` as the info, where **Gp** and **Sp** stand for the generating - device's and the scanning device's ephemeral public keys, encoded as unpadded base64. -- An all-zero salt. +These values are then used to initialize an [HPKE encryption +context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption). This context encapsulates the +derived key material and maintains the cryptographic state needed to protect messages. It provides functions for +encrypting messages in a single direction using AEAD, and exposes an exporter interface for deriving additional secrets +bound to the established context. -**EncKey_G** +**Context_S** -- `MATRIX_QR_CODE_LOGIN_ENCKEY_G|Gp|Sp` as the info, where **Gp** and **Sp** stand for the generating - device's and the scanning device's ephemeral public keys, encoded as unpadded base64. -- An all-zero salt. +``` +SharedSecret := ECDH(Ss, Gp) +Secret := LabeledExtract(shared_secret, "secret", SharedSecret) -With this, Device S has established its side of the secure channel. Device S then derives a confirmation payload that -Device G can use to confirm that the channel is secure. It contains: +Mode := 0x00 +PskIdHash := LabeledExtract("", "psk_id_hash", "") +InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") +KeyScheduleContext := concat(Mode, PskIdHash, InfoHash) -- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305. -- Its public ephemeral key **Sp**. +AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) +BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) +ExporterSecret := LabeledExpand(secret, "exp", key_schedule_context, 32) +Context_S := Context(AeadKey_S, BaseNonce_S, 0, ExporterSecret) ``` -Nonce_S := 0 -SH := ECDH(Ss, Gp) -EncKey_S := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_ENCKEY_S|" || Gp || "|" || Sp, salt=0, size=32) -// Stored, but not yet used -EncKey_G := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_ENCKEY_G|" || Gp || "|" || Sp, salt=0, size=32) +With this, Device S has established its sending side of the secure channel. Device S then derives a confirmation payload +that Device G can use to confirm that the channel is secure. It contains: + +- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305. +- Its public ephemeral key **Sp**. -NonceBytes_S := ToLowEndianBytes(Nonce_S)[..12] -TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey_S, NonceBytes_S, "MATRIX_QR_CODE_LOGIN_INITIATE") -Nonce_S := Nonce_S + 1 +``` +TaggedCiphertext := Context_S_S.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) ``` @@ -608,20 +623,65 @@ Device G receives **LoginInitiateMessage** (potentially coming from Device S) fr polling with `GET` requests. It then does the reverse of the previous step, obtaining **Sp**, deriving the shared secret using **Gs** and **Sp**, -discarding **Gs**, deriving the two symmetric encryption keys **EncKey_S** and **EncKey_G**, then finally -decrypting (and authenticating) the **TaggedCiphertext** using **EncKey_S**, obtaining a plaintext. +discarding **Gs**, deriving the HPKE context `Context_G`, then finally decrypting (and authenticating) the +**TaggedCiphertext** using **AeadKey_S**, obtaining a plaintext. + +**Context_G** + +``` +(TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) + +SharedSecret := ECDH(Gs, Sp) +Secret := LabeledExtract(shared_secret, "secret", SharedSecret) + +Mode := 0x00 +PskIdHash := LabeledExtract("", "psk_id_hash", "") +InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") +KeyScheduleContext := concat(Mode, PskIdHash, InfoHash) + +AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) +BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) +ExporterSecret := LabeledExpand(secret, "exp", key_schedule_context, 32) + +Context_G := Context(AeadKey_S, BaseNonce_S, 0, ExporterSecret) +``` It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. -It then responds with a dummy payload containing the string `MATRIX_QR_CODE_LOGIN_OK` encrypted with **SH** calculated -as follows: +``` +Plaintext := Context_G.Open(TaggedCiphertext, "") + +unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": + FAIL +``` + +It then derives a response context, which enables bidirectional communication: + +**ResponseContext_G** ``` -Nonce_G := 0 -NonceBytes_G := ToLowEndianBytes(Nonce_G)[..12] -TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey_G, NonceBytes_G, "MATRIX_QR_CODE_LOGIN_OK") -Nonce_G := Nonce_G + 1 -LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext) +Secret := Context_G.Export("MATRIX_QR_CODE_LOGIN response", 32) + +ResponseNonce := random(32) +Salt := Sp || ResponseNonce + +AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) +AeadNonce := HKDF_SHA256(Secret, "key", salt=Salt, size=12) +ExporterSecret := [0; 32] + +ResponseContext_G := Context(AeadKey, AeadNonce, 0, ExporterSecret) +``` + +**Warning** The exporter interface of the response context **MUST NOT** be used, as it is initialized with a dummy +exporter secret. Aside from this limitation, the response context supports encryption and decryption in the same manner +as the primary context, enabling bidirectional use of HPKE. + +Following the creation of the response context **ResponseContext_G**, it responds with a dummy payload containing the +string `MATRIX_QR_CODE_LOGIN_OK`: + +``` +TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") +LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext) || ResponseNonce ``` Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. @@ -631,25 +691,41 @@ Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to t Device S receives a response over the insecure rendezvous session by polling with `GET` requests, potentially from Device G. -It decrypts (and authenticates) it using the previously computed encryption key, which will succeed provided the payload -was indeed sent by Device G. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. +It proceeds to derive the response context by unpacking the base response nonce from the **LoginOkMessage** and +creating a response context on its own. + +**ResponseContext_S** ``` -Nonce_G := 1 -(TaggedCiphertext, Sp) := Unpack(Message) -NonceBytes := ToLowEndianBytes(Nonce)[..12] -Plaintext := ChaCha20Poly1305_Decrypt(EncKey_G, NonceBytes, TaggedCiphertext) -Nonce_G := Nonce_G + 1 +(TaggedCiphertext, ResponseNonce) := Unpack(LoginOkMessage) + +Secret := Context_S.Export("MATRIX_QR_CODE_LOGIN response", 32) +Salt := Sp || ResponseNonce + +AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) +AeadNonce := HKDF_SHA256(Secret, "nonce", salt=Salt, size=12) +ExporterSecret := [0; 32] + +ResponseContext_S := Context(AeadKey, AeadNonce, 0, ExporterSecret) +``` + +It decrypts (and authenticates) the response using the previously computed response context, which will succeed provided +the payload was indeed sent by Device G. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing +otherwise. + +``` +Plaintext := ResponseContext_S.Open(TaggedCiphertext, "") unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": FAIL ``` -If the above was successful, Device S then calculates a two digit **CheckCode** code derived from **SH**, **Gp** and -**Sp**: +If the above was successful, Device S then calculates a two digit **CheckCode** code using the [HPKE export +interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of its main context, **Context_S**. **Gp** and +**Sp** are used as inputs for the export interface: ``` -CheckBytes := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp || "|" || Sp , salt=0, size=2) +CheckBytes := Context_S.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp || "|" || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` @@ -660,7 +736,7 @@ code XY on your other device." 7. **Out-of-band confirmation** **Warning**: *This step is crucial for the security of the scheme since it overcomes the aforementioned limitation of -ECIES.* +HPKE.* Device G asks the user to enter the **CheckCode** that is being displayed on Device S. @@ -673,14 +749,17 @@ Device G compares the code that the user has entered with the **CheckCode** that as before: ``` -CheckBytes := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp "|" || Sp , salt=0, size=2) +CheckBytes := Context_G.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp "|" || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` If the code that the user enters matches then the secure channel is established. -Subsequent payloads sent from G should be encrypted using **EncKey_G**, while payloads sent from S should be -encrypted with **EncKey_S**, incrementing the corresponding nonce for each message sent/received. +Subsequent payloads sent from G should be encrypted using the response context **ResponseContext_G**, while payloads +sent from S should be encrypted with **Context_S**, incrementing the corresponding nonce for each message sent/received. + +Similarly, payloads received by G should be decrypted using the main context **Context_G**, while payloads received by S +should be decrypted using the response context **ResponseContext_S**. ### Sequence diagram @@ -710,7 +789,7 @@ sequenceDiagram S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def Z->>-S: 200 OK
{"sequence_token": "1", "expires_ts": 1234567, "data": ""} - note over S: 4) Device S computes SH, EncKey_S, EncKey_G and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session + note over S: 4) Device S creates context Context_S and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "1", "data": ""} Z->>-S: 200 OK
{"sequence_token": "2"} deactivate S @@ -718,9 +797,10 @@ sequenceDiagram G->>+Z: GET /_matrix/client/v1/rendezvous/abc-def activate G Z->>-G: 200 OK
{"sequence_token": "2", "expires_ts": 1234567, "data": ""} - note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after calculating SH, EncKey_S and EncKey_G + note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after creating Context_G note over G: Device G checks that the plaintext matches MATRIX_QR_CODE_LOGIN_INITIATE + note over G: Device G creates ResponseContext_G note over G: Device G computes LoginOkMessage and sends to the rendezvous session G->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "2", "data": ""} @@ -732,6 +812,7 @@ sequenceDiagram Z->>-S: 200 OK
{"sequence_token": "3", "expires_ts": 1234567, "data": ""} note over S: 6) Device S attempts to parse Data as LoginOkMessage + note over S: 6) Device S creates ResponseContext_S note over S: Device S checks that the plaintext matches MATRIX_QR_CODE_LOGIN_OK note over S: If okay, Device S calculates the CheckCode to be displayed @@ -748,22 +829,6 @@ sequenceDiagram Conceptually, once established, the secure channel offers two operations, `SecureSend` and `SecureReceive`, which wrap the `Send` and `Receive` operations offered by the rendezvous session API to securely send and receive data between two devices. -At the end of the establishment phase, the next nonce for each device should be `1`. - -Device G sets: - -``` -Nonce_G := 1 -Nonce_S := 1 -``` - -Device S sets: - -``` -Nonce_G := 1 -Nonce_S := 1 -``` - ### Threat analysis In an attack scenario, we add a participant called Specter with the following capabilities: @@ -776,7 +841,8 @@ In an attack scenario, we add a participant called Specter with the following ca Due to use of ephemeral key pairs which are immediately discarded after use, each QR code login session derives a unique secret so payloads from earlier sessions cannot be replayed. Each payload in the session is unique and expected only -once. Finally, the use of deterministic nonces prevents any possibility of replay. +once. Finally, replay of the initial message from S to G is prevented by the use of a randomly generated base nonce in +the response from G to S, while deterministic per-message nonces prevent replay of any subsequent messages. #### Pure Dolev-Yao attacker @@ -789,7 +855,7 @@ Since Device G has no way of authenticating Device S, an attacker present for th attempt to mimic Device S in order to get their Device S signed in instead. - In step 3, Specter can shoulder surf the QR code scanning to obtain **Gp**. -- In step 4, Specter can intercept S's payload and replace it with a payload of their own, replacing **Sp** with its +- In step 4, Specter can intercept S's payload and replace it with a payload of their own, replacing **Sp** with its own key. - The attack is only thwarted in step 7, because Device S won't ever display the indicator of success to the user. The user then must cancel the process on Device G, preventing it from sharing any sensitive material. From 870d2ade592ee889831a43dc5842a6eb56db9df4 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Jan 2026 10:03:54 +0000 Subject: [PATCH 06/47] Standardise on || for concatenation --- proposals/4388-secure-qr-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index c60aac44d87..1b2864490ae 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -595,7 +595,7 @@ Secret := LabeledExtract(shared_secret, "secret", SharedSecret) Mode := 0x00 PskIdHash := LabeledExtract("", "psk_id_hash", "") InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") -KeyScheduleContext := concat(Mode, PskIdHash, InfoHash) +KeyScheduleContext := Mode || PskIdHash || InfoHash AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) @@ -637,7 +637,7 @@ Secret := LabeledExtract(shared_secret, "secret", SharedSecret) Mode := 0x00 PskIdHash := LabeledExtract("", "psk_id_hash", "") InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") -KeyScheduleContext := concat(Mode, PskIdHash, InfoHash) +KeyScheduleContext := Mode || PskIdHash || InfoHash AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) From a2796293bca8d759308a736f388b5a4d1fe1d2c2 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Jan 2026 10:08:13 +0000 Subject: [PATCH 07/47] Fix encoding of LoginOkMessage --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 1b2864490ae..8ab4d3e17f4 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -681,7 +681,7 @@ string `MATRIX_QR_CODE_LOGIN_OK`: ``` TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") -LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext) || ResponseNonce +LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext || ResponseNonce) ``` Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. From 98f94a02822d8ac20ce0b7f05e7cfde09289c891 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Jan 2026 10:37:08 +0000 Subject: [PATCH 08/47] Refer to nonce logic described in HPKE --- proposals/4388-secure-qr-channel.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 8ab4d3e17f4..1c9404f6972 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -530,8 +530,10 @@ Regardless of which device generates the QR code, either device can be the exist other device is then the new device (one seeking to be signed in). Symmetric encryption uses a separate encryption key for each sender, both derived from a shared secret using HKDF. + Separate nonces are used for each direction of the communication channel. Device S will create a base nonce from the -shared secret, while Device G will create a new random base nonce. Each base nonce is mixed with a +shared secret, while Device G will create a new random base nonce. +As described by [HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7), each base nonce is mixed with a monotonically-incrementing sequence number. Devices initially set both numbers to `0` and increment the corresponding number by `1` for each message sent and received. The per-message nonce is is the result of XORing the base nonce with the current sequence number, encoded as a big-endian integer of the same length as base nonce. @@ -684,6 +686,9 @@ TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext || ResponseNonce) ``` +We rely on the `Seal()` operation computing and incrementing the nonce for us as described in +[HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). + Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. 6. **Verification by Device S** @@ -720,6 +725,9 @@ unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": FAIL ``` +We rely on the `Open()` operation computing and incrementing the nonce for us as described in +[HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). + If the above was successful, Device S then calculates a two digit **CheckCode** code using the [HPKE export interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of its main context, **Context_S**. **Gp** and **Sp** are used as inputs for the export interface: @@ -756,11 +764,14 @@ CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) If the code that the user enters matches then the secure channel is established. Subsequent payloads sent from G should be encrypted using the response context **ResponseContext_G**, while payloads -sent from S should be encrypted with **Context_S**, incrementing the corresponding nonce for each message sent/received. +sent from S should be encrypted with **Context_S**. Similarly, payloads received by G should be decrypted using the main context **Context_G**, while payloads received by S should be decrypted using the response context **ResponseContext_S**. +We rely on the `Context.Seal()` and `Context.Open()` operations to compute and increment the corresponding nonces for +each message sent/received, as described in [HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). + ### Sequence diagram The sequence diagram for the secure channel establishment is as follows: From 815583d3c6e1bbe0da10b3e7164d26228cd32306 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 14 Jan 2026 09:31:05 +0000 Subject: [PATCH 09/47] Clean up HPKE section --- proposals/4388-secure-qr-channel.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 1c9404f6972..fcd933eeb7a 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -583,7 +583,8 @@ From this common **Secret**, the HPKE key schedule derives the following values: * a **BaseNonce** These values are then used to initialize an [HPKE encryption -context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption). This context encapsulates the +context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption) +**Context_S**. This context encapsulates the derived key material and maintains the cryptographic state needed to protect messages. It provides functions for encrypting messages in a single direction using AEAD, and exposes an exporter interface for deriving additional secrets bound to the established context. @@ -609,11 +610,11 @@ Context_S := Context(AeadKey_S, BaseNonce_S, 0, ExporterSecret) With this, Device S has established its sending side of the secure channel. Device S then derives a confirmation payload that Device G can use to confirm that the channel is secure. It contains: -- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305. +- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using **Context_S**. - Its public ephemeral key **Sp**. ``` -TaggedCiphertext := Context_S_S.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") +TaggedCiphertext := Context_S.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) ``` @@ -664,8 +665,8 @@ It then derives a response context, which enables bidirectional communication: ``` Secret := Context_G.Export("MATRIX_QR_CODE_LOGIN response", 32) -ResponseNonce := random(32) -Salt := Sp || ResponseNonce +ResponseBaseNonce := random(32) +Salt := Sp || ResponseBaseNonce AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) AeadNonce := HKDF_SHA256(Secret, "key", salt=Salt, size=12) @@ -683,7 +684,7 @@ string `MATRIX_QR_CODE_LOGIN_OK`: ``` TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") -LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext || ResponseNonce) +LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext || ResponseBaseNonce) ``` We rely on the `Seal()` operation computing and incrementing the nonce for us as described in @@ -702,10 +703,10 @@ creating a response context on its own. **ResponseContext_S** ``` -(TaggedCiphertext, ResponseNonce) := Unpack(LoginOkMessage) +(TaggedCiphertext, ResponseBaseNonce) := Unpack(LoginOkMessage) Secret := Context_S.Export("MATRIX_QR_CODE_LOGIN response", 32) -Salt := Sp || ResponseNonce +Salt := Sp || ResponseBaseNonce AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) AeadNonce := HKDF_SHA256(Secret, "nonce", salt=Salt, size=12) From 94ad88ced57afebab8b3cc6cb2db60ae2e18fcb6 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 14 Jan 2026 09:38:21 +0000 Subject: [PATCH 10/47] Update proposals/4388-secure-qr-channel.md --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index fcd933eeb7a..b50063e2799 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -534,7 +534,7 @@ Symmetric encryption uses a separate encryption key for each sender, both derive Separate nonces are used for each direction of the communication channel. Device S will create a base nonce from the shared secret, while Device G will create a new random base nonce. As described by [HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7), each base nonce is mixed with a -monotonically-incrementing sequence number. Devices initially set both numbers to `0` and increment the corresponding +monotonically-incrementing sequence number before being used as a per-message nonce. Devices initially set both sequence numbers to `0` and increment the corresponding number by `1` for each message sent and received. The per-message nonce is is the result of XORing the base nonce with the current sequence number, encoded as a big-endian integer of the same length as base nonce. From ac1d9b5314a93409be4994e265edea25dc58b121 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 14 Jan 2026 09:47:23 +0000 Subject: [PATCH 11/47] Remove | from concatenated strings --- proposals/4388-secure-qr-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index b50063e2799..eb9e13f071f 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -734,7 +734,7 @@ interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of its main **Sp** are used as inputs for the export interface: ``` -CheckBytes := Context_S.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp || "|" || Sp , size=2) +CheckBytes := Context_S.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` @@ -758,7 +758,7 @@ Device G compares the code that the user has entered with the **CheckCode** that as before: ``` -CheckBytes := Context_G.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE|" || Gp "|" || Sp , size=2) +CheckBytes := Context_G.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` From d081b785b42221c9afb0ad55af96ca494437800f Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 21 Jan 2026 17:26:11 +0000 Subject: [PATCH 12/47] Change expires_ts to expires_in_ms --- proposals/4388-secure-qr-channel.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index eb9e13f071f..44d52b75105 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -100,7 +100,7 @@ n.b. Once a new payload has been sent there is no mechanism to retrieve previous ### Expiry The rendezvous session (i.e. the payload) SHOULD expire after a period of time communicated to clients via the -`expires_ts` field on the `POST` and `GET` response bodies. After this point, any further attempts to query or update +`expires_in_ms` field on the `POST` and `GET` response bodies. After this point, any further attempts to query or update the payload MUST fail. The rendezvous session can be manually expired with a `DELETE` call to the rendezvous session. ### `POST /_matrix/client/v1/rendezvous` - Create a rendezvous session and send initial payload @@ -139,7 +139,7 @@ Response body for `200 OK` is `application/json` with contents: |-|-|-| |`id`|required `string`|Opaque identifier for the rendezvous session| |`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| -|`expires_ts`|required `integer`|The timestamp (in milliseconds since the epoch) at which the rendezvous session will expire| +|`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| Example response: @@ -150,7 +150,7 @@ Content-Type: application/json { "id": "abcdEFG12345", "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=", - "expires_ts": 1662560931000 + "expires_in_ms": 300000 } ``` @@ -233,7 +233,7 @@ Response body for `200 OK` is `application/json` with contents: |-|-|-| |`data`|required `string`|The data payload from the last POST or PUT.| |`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| -|`expires_ts`|required `integer`|The timestamp (in milliseconds since the epoch) at which the rendezvous session will expire| +|`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| ```http HTTP 200 OK @@ -242,7 +242,7 @@ Content-Type: application/json { "data": "data from the previous POST/PUT", "sequence_token": "VmbxF13QDusTgOCt8aoa0d2PQcnBOXeIxEqhw5aQ03o=", - "expires_ts": 1662560931000 + "expires_in_ms": 300000 } ``` @@ -277,7 +277,7 @@ sequenceDiagram Note over A: Device A determines which rendezvous server to use A->>+HS: POST /_matrix/client/v1/rendezvous
{"data":"Hello from A"} - HS->>-A: 200 OK
{"id":"abc-def-123-456","sequence_token": "1", "expires_ts": 1234567} + HS->>-A: 200 OK
{"id":"abc-def-123-456","sequence_token": "1", "expires_in_ms": 300000} A-->>B: Rendezvous ID and homeserver base URL shared out of band as QR code: e.g. id=abc-def-123-456 baseURL=https://matrix.example.com @@ -291,7 +291,7 @@ sequenceDiagram loop Device A polls the rendezvous session for a new payload A->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 alt is not modified - HS->>-A: 200 OK
{"sequence_token": "1", "data": "Hello from A", "expires_ts": 1234567} + HS->>-A: 200 OK
{"sequence_token": "1", "data": "Hello from A", "expires_in_ms": 300000} end end @@ -305,13 +305,13 @@ sequenceDiagram loop Device B polls the rendezvous session for a new payload B->>+HS: GET /_matrix/client/v1/rendezvous/abc-def-123-456 alt is not modified - HS->>-B: 200 OK
{"sequence_token": "2", "data": "Hello from A", "expires_ts": 1234567} + HS->>-B: 200 OK
{"sequence_token": "2", "data": "Hello from A", "expires_in_ms": 300000} end end note over A: Device A then receives the new payload opt modified - HS->>A: 200 OK
{"sequence_token": "2", "data": "Hello from B", "expires_ts": 1234567} + HS->>A: 200 OK
{"sequence_token": "2", "data": "Hello from B", "expires_in_ms": 300000} end deactivate A @@ -321,7 +321,7 @@ sequenceDiagram note over B: Device B then receives the new payload opt modified - HS->>B: 200 OK
{"sequence_token": "3", "data": "Hello again from B", "expires_ts": 1234567} + HS->>B: 200 OK
{"sequence_token": "3", "data": "Hello again from B", "expires_in_ms": 300000} end deactivate B @@ -788,7 +788,7 @@ sequenceDiagram activate G note over G: 2) Device G creates a rendezvous session as follows G->>+Z: POST /_matrix/client/v1/rendezvous
{"data": ""} - Z->>-G: 200 OK
{"id": "abc-def", "sequence_token": "1", "expires_ts": 1234567} + Z->>-G: 200 OK
{"id": "abc-def", "sequence_token": "1", "expires_in_ms": 300000} note over G: 3) Device G generates and displays a QR code containing:
its ephemeral public key, the rendezvous session ID, the server base URL @@ -799,7 +799,7 @@ sequenceDiagram note over S: Device S validates QR scanned and the rendezvous session ID S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def - Z->>-S: 200 OK
{"sequence_token": "1", "expires_ts": 1234567, "data": ""} + Z->>-S: 200 OK
{"sequence_token": "1", "expires_in_ms": 300000, "data": ""} note over S: 4) Device S creates context Context_S and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "1", "data": ""} @@ -808,7 +808,7 @@ sequenceDiagram G->>+Z: GET /_matrix/client/v1/rendezvous/abc-def activate G - Z->>-G: 200 OK
{"sequence_token": "2", "expires_ts": 1234567, "data": ""} + Z->>-G: 200 OK
{"sequence_token": "2", "expires_in_ms": 300000, "data": ""} note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after creating Context_G note over G: Device G checks that the plaintext matches MATRIX_QR_CODE_LOGIN_INITIATE @@ -821,7 +821,7 @@ sequenceDiagram activate S S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def - Z->>-S: 200 OK
{"sequence_token": "3", "expires_ts": 1234567, "data": ""} + Z->>-S: 200 OK
{"sequence_token": "3", "expires_in_ms": 300000, "data": ""} note over S: 6) Device S attempts to parse Data as LoginOkMessage note over S: 6) Device S creates ResponseContext_S From d8424cc04ee0c3c2201dc4d347692d50ddc7b582 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 23 Jan 2026 10:31:12 +0000 Subject: [PATCH 13/47] Use binary packing for LoginInitiateMEssage and LoginOkMessage --- proposals/4388-secure-qr-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 44d52b75105..b905a5c362e 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -615,7 +615,7 @@ that Device G can use to confirm that the channel is secure. It contains: ``` TaggedCiphertext := Context_S.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") -LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) +LoginInitiateMessage := UnpaddedBase64(Sp || TaggedCiphertext) ``` Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request. @@ -684,7 +684,7 @@ string `MATRIX_QR_CODE_LOGIN_OK`: ``` TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") -LoginOkMessage := UnpaddedBase64Encode(TaggedCiphertext || ResponseBaseNonce) +LoginOkMessage := UnpaddedBase64Encode(ResponseBaseNonce || TaggedCiphertext) ``` We rely on the `Seal()` operation computing and incrementing the nonce for us as described in From fbc626b2259e26efc07d7742f158aa798523dfdb Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 23 Jan 2026 17:31:42 +0000 Subject: [PATCH 14/47] Fix unstable rendezvious endpoint path --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index b905a5c362e..aa3f9bb910e 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -998,7 +998,7 @@ A threat analysis has been done within each of the key layers in the proposal ab While this feature is in development the new API endpoints should be exposed using the following unstable prefix: -- `/_matrix/client/unstable/io.element.msc4388rendezvous` instead of `/_matrix/client/v1/rendezvous` +- `/_matrix/client/unstable/io.element.msc4388/rendezvous` instead of `/_matrix/client/v1/rendezvous` Additionally, the feature is to be advertised as unstable feature in the `GET /_matrix/client/versions` response, with the key `io.element.msc4388` set to true. So, the response could look then as following: From 270c17df0d4e02f8d3d337709fe36753535e67fa Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 23 Jan 2026 19:17:19 +0000 Subject: [PATCH 15/47] Use HPKE functions directly where possible and don't repeat what is in the RFCs --- proposals/4388-secure-qr-channel.md | 159 ++++++++++------------------ 1 file changed, 58 insertions(+), 101 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index aa3f9bb910e..f784a80329a 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -502,11 +502,10 @@ The above rendezvous session is insecure, providing no confidentiality nor authe even arbitrary network participants which possess the rendezvous session ID and server base URL. To provide a secure channel on top of this insecure rendezvous session transport, we propose the following scheme. -This scheme is essentially [HPKE](https://www.rfc-editor.org/rfc/rfc9180) in [base +We use [HPKE](https://www.rfc-editor.org/rfc/rfc9180) in [base mode](https://www.rfc-editor.org/rfc/rfc9180.html#name-hpke-modes) instantiated with X25519, HKDF-SHA256 for the KDF and ChaCha20-Poly1305 (as specified by [RFC8439](https://datatracker.ietf.org/doc/html/rfc8439#section-2.8)) for the -authenticated encryption. Therefore, existing security analyses of HPKE are applicable in this setting too. Nevertheless -we include below a short description of our instantiation of HPKE and discuss some potential pitfalls and attacks. +authenticated encryption. The primary limitation of HPKE in base mode is that there is no authentication for the initiating party (the one to send the first payload; Device S in the text below). Thus the recipient party (the one to receive the first payload; Device G @@ -514,30 +513,23 @@ in the text below) has no assurance as to who actually sent the payload. In QR c by exploiting the fact that both of these devices are physically present during the exchange and offloading the check that they are both in the correct state to the user performing the QR code login process. -Additionally the HPKE RFC exclusively defines unidirectional encryption. The scheme found in the [Oblivious +Additionally the HPKE RFC exclusively defines unidirectional encryption. So, we use the scheme found in the [Oblivious HTTP](https://www.rfc-editor.org/rfc/rfc9458) RFC under the [Encapsulation of -Responses](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) section is used to enable -bidirectional encryption in our chosen HPKE scheme. +Responses](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) section to enable +bidirectional encryption. + +The existing security analyses of HPKE are applicable to our usage. We also discuss some potential pitfalls and attacks. ### Establishment Participants: -- Device G (the device generating the QR code) -- Device S (the device scanning the QR code) +- Device G: the device generating the QR code +- Device S: the device scanning the QR code Regardless of which device generates the QR code, either device can be the existing (already signed in) device. The other device is then the new device (one seeking to be signed in). -Symmetric encryption uses a separate encryption key for each sender, both derived from a shared secret using HKDF. - -Separate nonces are used for each direction of the communication channel. Device S will create a base nonce from the -shared secret, while Device G will create a new random base nonce. -As described by [HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7), each base nonce is mixed with a -monotonically-incrementing sequence number before being used as a per-message nonce. Devices initially set both sequence numbers to `0` and increment the corresponding -number by `1` for each message sent and received. The per-message nonce is is the result of XORing the base nonce with -the current sequence number, encoded as a big-endian integer of the same length as base nonce. - 1. **Ephemeral key pair generation** Both devices generate an _ephemeral_ Curve25519 key pair: @@ -572,49 +564,31 @@ At this point Device S should check that the received intent matches what the us 4. **Device S sends the initial payload** -Device S performs an ECDH operation using **Ss** and **Gp** to compute the ECDH shared secret **SharedSecret**. This -value is input to the HPKE key schedule, where it is first expanded into a common secret **Secret** using HKDF-SHA256. -After this step, **Ss** is discarded. +Device S performs an ECDH operation using **Ss** and **Gp** to compute the shared secret **SharedSecret**. -From this common **Secret**, the HPKE key schedule derives the following values: +The **SharedSecret** is then used to initialize an +[HPKE encryption context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption) +**Context_DeviceS_Send** using the `KeySchedule()` function. -* a symmetric encryption key **AeadKey_S** -* an **ExporterSecret** -* a **BaseNonce** - -These values are then used to initialize an [HPKE encryption -context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption) -**Context_S**. This context encapsulates the -derived key material and maintains the cryptographic state needed to protect messages. It provides functions for -encrypting messages in a single direction using AEAD, and exposes an exporter interface for deriving additional secrets -bound to the established context. +After this step, **Ss** is discarded. -**Context_S** +**Context_DeviceS_Send** ``` SharedSecret := ECDH(Ss, Gp) -Secret := LabeledExtract(shared_secret, "secret", SharedSecret) - -Mode := 0x00 -PskIdHash := LabeledExtract("", "psk_id_hash", "") -InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") -KeyScheduleContext := Mode || PskIdHash || InfoHash -AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) -BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) -ExporterSecret := LabeledExpand(secret, "exp", key_schedule_context, 32) - -Context_S := Context(AeadKey_S, BaseNonce_S, 0, ExporterSecret) +Context_DeviceS_Send := KeySchedule(mode=0x00, shared_secret=SharedSecret, info="MATRIX_QR_CODE_LOGIN") ``` With this, Device S has established its sending side of the secure channel. Device S then derives a confirmation payload that Device G can use to confirm that the channel is secure. It contains: -- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using **Context_S**. +- The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using + the `ContextS.Seal()` function of context **Context_DeviceS_Send**. - Its public ephemeral key **Sp**. ``` -TaggedCiphertext := Context_S.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") +TaggedCiphertext := Context_DeviceS_Send.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") LoginInitiateMessage := UnpaddedBase64(Sp || TaggedCiphertext) ``` @@ -625,71 +599,60 @@ Device S then sends the **LoginInitiateMessage** as the `data` payload to the re Device G receives **LoginInitiateMessage** (potentially coming from Device S) from the insecure rendezvous session by polling with `GET` requests. -It then does the reverse of the previous step, obtaining **Sp**, deriving the shared secret using **Gs** and **Sp**, -discarding **Gs**, deriving the HPKE context `Context_G`, then finally decrypting (and authenticating) the -**TaggedCiphertext** using **AeadKey_S**, obtaining a plaintext. +It then does the reverse of the previous step, obtaining **Sp**, deriving the shared secret using ECDH on **Gs** and **Sp**, +discarding **Gs**, deriving an HPKE context `Context_DeviceG_Receive`: -**Context_G** +**Context_DeviceG_Receive** ``` (TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) SharedSecret := ECDH(Gs, Sp) -Secret := LabeledExtract(shared_secret, "secret", SharedSecret) - -Mode := 0x00 -PskIdHash := LabeledExtract("", "psk_id_hash", "") -InfoHash := LabeledExtract("", "info_hash", "MATRIX_QR_CODE_LOGIN") -KeyScheduleContext := Mode || PskIdHash || InfoHash -AeadKey_S := LabeledExpand(secret, "key", KeyScheduleContext, 32) -BaseNonce := LabeledExpand(secret, "base_nonce", key_schedule_context, 12) -ExporterSecret := LabeledExpand(secret, "exp", key_schedule_context, 32) - -Context_G := Context(AeadKey_S, BaseNonce_S, 0, ExporterSecret) +Context_DeviceG_Receive := KeySchedule(mode=0x00, shared_secret=SharedSecret, info="MATRIX_QR_CODE_LOGIN") ``` +It then decrypts (and authenticates) the message using the `ContextR.Open()` function of **Context_DeviceG_Receive**. It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. ``` -Plaintext := Context_G.Open(TaggedCiphertext, "") +Plaintext := Context_DeviceG_Receive.Open(TaggedCiphertext, "") unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": FAIL ``` -It then derives a response context, which enables bidirectional communication: +It then derives its own HPKE context **Context_DeviceG_Send** for sending based on the scheme from the +[Oblivious HTTP](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) RFC, using a secret exported +from **Context_DeviceG_Receive**: -**ResponseContext_G** +**Context_DeviceG_Send** ``` -Secret := Context_G.Export("MATRIX_QR_CODE_LOGIN response", 32) +Secret := Context_DeviceG_Receive.Export("MATRIX_QR_CODE_LOGIN response", 32) -ResponseBaseNonce := random(32) -Salt := Sp || ResponseBaseNonce +ResponseNonce := random(32) +Salt := Sp || ResponseNonce -AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) +AeadKey := HKDF_SHA256(Secret, "key", salt=Salt, size=32) AeadNonce := HKDF_SHA256(Secret, "key", salt=Salt, size=12) ExporterSecret := [0; 32] -ResponseContext_G := Context(AeadKey, AeadNonce, 0, ExporterSecret) +Context_DeviceG_Send := Context(AeadKey, AeadNonce, 0, ExporterSecret) ``` -**Warning** The exporter interface of the response context **MUST NOT** be used, as it is initialized with a dummy -exporter secret. Aside from this limitation, the response context supports encryption and decryption in the same manner -as the primary context, enabling bidirectional use of HPKE. +**Warning** The exporter interface of the **Context_DeviceG_Send** context **MUST NOT** be used, as it is initialized +with a dummy exporter secret. Aside from this limitation, the response context supports encryption and decryption in the +same manner as the primary context, enabling bidirectional use of HPKE. -Following the creation of the response context **ResponseContext_G**, it responds with a dummy payload containing the +Following the creation of the response context **Context_DeviceG_Send**, it responds with a dummy payload containing the string `MATRIX_QR_CODE_LOGIN_OK`: ``` -TaggedCiphertext := ResponseContext_G.Seal("MATRIX_QR_CODE_LOGIN_OK", "") -LoginOkMessage := UnpaddedBase64Encode(ResponseBaseNonce || TaggedCiphertext) +TaggedCiphertext := Context_DeviceG_Send.Seal("MATRIX_QR_CODE_LOGIN_OK", "") +LoginOkMessage := UnpaddedBase64Encode(ResponseNonce || TaggedCiphertext) ``` -We rely on the `Seal()` operation computing and incrementing the nonce for us as described in -[HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). - Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. 6. **Verification by Device S** @@ -700,41 +663,38 @@ Device G. It proceeds to derive the response context by unpacking the base response nonce from the **LoginOkMessage** and creating a response context on its own. -**ResponseContext_S** +**Context_DeviceS_Receive** ``` -(TaggedCiphertext, ResponseBaseNonce) := Unpack(LoginOkMessage) +(TaggedCiphertext, ResponseNonce) := Unpack(LoginOkMessage) -Secret := Context_S.Export("MATRIX_QR_CODE_LOGIN response", 32) -Salt := Sp || ResponseBaseNonce +Secret := Context_DeviceS_Send.Export("MATRIX_QR_CODE_LOGIN response", 32) +Salt := Sp || ResponseNonce AeadKey_G := HKDF_SHA256(Secret, "key", salt=Salt, size=32) AeadNonce := HKDF_SHA256(Secret, "nonce", salt=Salt, size=12) ExporterSecret := [0; 32] -ResponseContext_S := Context(AeadKey, AeadNonce, 0, ExporterSecret) +Context_DeviceS_Receive := Context(AeadKey, AeadNonce, 0, ExporterSecret) ``` -It decrypts (and authenticates) the response using the previously computed response context, which will succeed provided +It decrypts (and authenticates) the response using the **Context_DeviceS_Receive** context, which will succeed provided the payload was indeed sent by Device G. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. ``` -Plaintext := ResponseContext_S.Open(TaggedCiphertext, "") +Plaintext := Context_DeviceS_Receive.Open(TaggedCiphertext, "") unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": FAIL ``` -We rely on the `Open()` operation computing and incrementing the nonce for us as described in -[HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). - If the above was successful, Device S then calculates a two digit **CheckCode** code using the [HPKE export -interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of its main context, **Context_S**. **Gp** and +interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of context **Context_DeviceS_Send**. **Gp** and **Sp** are used as inputs for the export interface: ``` -CheckBytes := Context_S.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) +CheckBytes := Context_DeviceS_Send.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` @@ -758,20 +718,17 @@ Device G compares the code that the user has entered with the **CheckCode** that as before: ``` -CheckBytes := Context_G.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) +CheckBytes := Context_DeviceG_Receive.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) ``` If the code that the user enters matches then the secure channel is established. -Subsequent payloads sent from G should be encrypted using the response context **ResponseContext_G**, while payloads -sent from S should be encrypted with **Context_S**. - -Similarly, payloads received by G should be decrypted using the main context **Context_G**, while payloads received by S -should be decrypted using the response context **ResponseContext_S**. +Subsequent payloads sent from G should be encrypted using the context **Context_DeviceG_Send**, while payloads +sent from S should be encrypted with **Context_DeviceS_Send**. -We rely on the `Context.Seal()` and `Context.Open()` operations to compute and increment the corresponding nonces for -each message sent/received, as described in [HPKE](https://www.rfc-editor.org/rfc/rfc9180.html#section-5.2-7). +Similarly, payloads received by G should be decrypted using the context **Context_DeviceG_Receive**, while payloads received by S +should be decrypted using the context **Context_DeviceG_Receive**. ### Sequence diagram @@ -801,7 +758,7 @@ sequenceDiagram S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def Z->>-S: 200 OK
{"sequence_token": "1", "expires_in_ms": 300000, "data": ""} - note over S: 4) Device S creates context Context_S and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session + note over S: 4) Device S creates context Context_DeviceS_Send and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "1", "data": ""} Z->>-S: 200 OK
{"sequence_token": "2"} deactivate S @@ -809,10 +766,10 @@ sequenceDiagram G->>+Z: GET /_matrix/client/v1/rendezvous/abc-def activate G Z->>-G: 200 OK
{"sequence_token": "2", "expires_in_ms": 300000, "data": ""} - note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after creating Context_G + note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after creating Context_DeviceG_Receive note over G: Device G checks that the plaintext matches MATRIX_QR_CODE_LOGIN_INITIATE - note over G: Device G creates ResponseContext_G + note over G: Device G creates Context_DeviceG_Send note over G: Device G computes LoginOkMessage and sends to the rendezvous session G->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "2", "data": ""} @@ -824,7 +781,7 @@ sequenceDiagram Z->>-S: 200 OK
{"sequence_token": "3", "expires_in_ms": 300000, "data": ""} note over S: 6) Device S attempts to parse Data as LoginOkMessage - note over S: 6) Device S creates ResponseContext_S + note over S: 6) Device S creates Context_DeviceS_Receive note over S: Device S checks that the plaintext matches MATRIX_QR_CODE_LOGIN_OK note over S: If okay, Device S calculates the CheckCode to be displayed From 69a99971038105f3eebd4aaebcad8c4da111d4a9 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 9 Feb 2026 15:50:55 +0000 Subject: [PATCH 16/47] Apply suggestions from code review Co-authored-by: Olivier 'reivilibre' --- proposals/4388-secure-qr-channel.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index f784a80329a..191ba88e02d 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -2,8 +2,8 @@ This proposal forms part of [MSC4108] to make it easy to sign in on a new device with the help of an existing device. -It proposes a mechanism for a new an existing device to establish a secure out-of-band channel through which they can -communicate to facilitate the sign in on the new device. +It proposes a mechanism for a new and an existing device to establish a secure out-of-band channel through which they can +communicate to facilitate the sign in of the new device. Table of contents: @@ -138,7 +138,7 @@ Response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| |`id`|required `string`|Opaque identifier for the rendezvous session| -|`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed| |`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| Example response: @@ -201,7 +201,7 @@ The response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| -|`sequence_token`|required `string`|The token opaque token to identify if the payload has changed| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed| For example: From a44815f22d6067e2a70e20ba34e8e6ee00ab4a7c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 25 Feb 2026 18:18:31 +0000 Subject: [PATCH 17/47] Bind secure channel to rendezvous session via additional authentication data on the HPKE Seal/Open invocations --- proposals/4388-secure-qr-channel.md | 75 ++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 191ba88e02d..04abfdba615 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -518,6 +518,9 @@ HTTP](https://www.rfc-editor.org/rfc/rfc9458) RFC under the [Encapsulation of Responses](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) section to enable bidirectional encryption. +We bind the secure channel to the specific rendezvous session by including the homeserver base URL, rendezvous session +ID and sequence token as additional authentication data in calls to the HPKE `Seal()` and `Open()` functions. + The existing security analyses of HPKE are applicable to our usage. We also discuss some potential pitfalls and attacks. ### Establishment @@ -540,7 +543,7 @@ other device is then the new device (one seeking to be signed in). 2. **Create rendezvous session** Device G creates a rendezvous session by making a `POST` request (as described previously) to the nominated homeserver -with an empty payload. It parses the **ID** received. +with an empty payload. It parses the **ID** received and **sequence token**. 3. **Initial key exchange** @@ -584,15 +587,20 @@ With this, Device S has established its sending side of the secure channel. Devi that Device G can use to confirm that the channel is secure. It contains: - The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using - the `ContextS.Seal()` function of context **Context_DeviceS_Send**. + the `ContextS.Seal()` function of context **Context_DeviceS_Send** with additional authentication data: + - the homeserver **base URL** from the QR code + - the rendezvous session **ID** from the QR code + - the **sequence token** returned by the homeserver when calling `GET` on the rendezvous session - Its public ephemeral key **Sp**. ``` -TaggedCiphertext := Context_DeviceS_Send.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", "") +Aad := BaseUrl || RendezvousId || SequenceToken +TaggedCiphertext := Context_DeviceS_Send.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", Aad) LoginInitiateMessage := UnpaddedBase64(Sp || TaggedCiphertext) ``` -Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request. +Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request +and noting the new **sequence token**. 5. **Device G confirms** @@ -612,11 +620,17 @@ SharedSecret := ECDH(Gs, Sp) Context_DeviceG_Receive := KeySchedule(mode=0x00, shared_secret=SharedSecret, info="MATRIX_QR_CODE_LOGIN") ``` -It then decrypts (and authenticates) the message using the `ContextR.Open()` function of **Context_DeviceG_Receive**. +It then decrypts (and authenticates) the message using the `ContextR.Open()` function of **Context_DeviceG_Receive** +with the additional authentication data: +- the homeserver **base URL** as before +- the rendezvous session **ID** as before +- the **sequence token** returned by the homeserver when the original `POST` request was made to the rendezvous session + It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. ``` -Plaintext := Context_DeviceG_Receive.Open(TaggedCiphertext, "") +Aad := BaseUrl || RendezvousId || SequenceToken +Plaintext := Context_DeviceG_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": FAIL @@ -646,10 +660,12 @@ with a dummy exporter secret. Aside from this limitation, the response context s same manner as the primary context, enabling bidirectional use of HPKE. Following the creation of the response context **Context_DeviceG_Send**, it responds with a dummy payload containing the -string `MATRIX_QR_CODE_LOGIN_OK`: +string `MATRIX_QR_CODE_LOGIN_OK` that is sealed with the additional authentication data similar to before, but the +**sequence token** is the one that was received with the `GET` request that returned **LoginInitiateMessage**: ``` -TaggedCiphertext := Context_DeviceG_Send.Seal("MATRIX_QR_CODE_LOGIN_OK", "") +Aad := BaseUrl || RendezvousId || SequenceToken +TaggedCiphertext := Context_DeviceG_Send.Seal("MATRIX_QR_CODE_LOGIN_OK", Aad) LoginOkMessage := UnpaddedBase64Encode(ResponseNonce || TaggedCiphertext) ``` @@ -679,11 +695,14 @@ Context_DeviceS_Receive := Context(AeadKey, AeadNonce, 0, ExporterSecret) ``` It decrypts (and authenticates) the response using the **Context_DeviceS_Receive** context, which will succeed provided -the payload was indeed sent by Device G. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing +the payload was indeed sent by Device G. The additional authentication data is as before with the **sequence token** +being the one that was received when the `PUT` was made for **LoginInitiateMessage**. +It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. ``` -Plaintext := Context_DeviceS_Receive.Open(TaggedCiphertext, "") +Aad := BaseUrl || RendezvousId || SequenceToken +Plaintext := Context_DeviceS_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": FAIL @@ -725,10 +744,20 @@ CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) If the code that the user enters matches then the secure channel is established. Subsequent payloads sent from G should be encrypted using the context **Context_DeviceG_Send**, while payloads -sent from S should be encrypted with **Context_DeviceS_Send**. +sent from S should be encrypted with **Context_DeviceS_Send**. Each call to the `Seal()` function should use the +additional authentication data of the form where the **sequence token** is from the last `GET` that the device received: + +``` +Aad := BaseUrl || RendezvousId || SequenceToken +``` Similarly, payloads received by G should be decrypted using the context **Context_DeviceG_Receive**, while payloads received by S -should be decrypted using the context **Context_DeviceG_Receive**. +should be decrypted using the context **Context_DeviceG_Receive**. Each call to the `Open()` function should use the +additional authentication data of the form where the **sequence token** is from the last `PUT` that the device made: + +``` +Aad := BaseUrl || RendezvousId || SequenceToken +``` ### Sequence diagram @@ -756,33 +785,33 @@ sequenceDiagram note over S: Device S validates QR scanned and the rendezvous session ID S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def - Z->>-S: 200 OK
{"sequence_token": "1", "expires_in_ms": 300000, "data": ""} + Z->>-S: 200 OK
{"sequence_token": "SEQ1", "expires_in_ms": 300000, "data": ""} - note over S: 4) Device S creates context Context_DeviceS_Send and LoginInitiateMessage.
It sends LoginInitiateMessage via the rendezvous session - S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "1", "data": ""} - Z->>-S: 200 OK
{"sequence_token": "2"} + note over S: 4) Device S creates context Context_DeviceS_Send and LoginInitiateMessage (sealed using sequence token SEQ1).
It sends LoginInitiateMessage via the rendezvous session + S->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "SEQ1", "data": ""} + Z->>-S: 200 OK
{"sequence_token": "SEQ2"} deactivate S G->>+Z: GET /_matrix/client/v1/rendezvous/abc-def activate G - Z->>-G: 200 OK
{"sequence_token": "2", "expires_in_ms": 300000, "data": ""} + Z->>-G: 200 OK
{"sequence_token": "SEQ2", "expires_in_ms": 300000, "data": ""} note over G: 5) Device G attempts to parse Data as LoginInitiateMessage after creating Context_DeviceG_Receive - note over G: Device G checks that the plaintext matches MATRIX_QR_CODE_LOGIN_INITIATE + note over G: Device G checks that the plaintext (unsealed using sequence token SEQ1) matches MATRIX_QR_CODE_LOGIN_INITIATE note over G: Device G creates Context_DeviceG_Send - note over G: Device G computes LoginOkMessage and sends to the rendezvous session + note over G: Device G computes LoginOkMessage (sealed using sequence token SEQ2) and sends to the rendezvous session - G->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "2", "data": ""} - Z->>-G: 200 OK
{"sequence_token": "3"} + G->>+Z: PUT /_matrix/client/v1/rendezvous/abc-def
{"sequence_token": "SEQ2", "data": ""} + Z->>-G: 200 OK
{"sequence_token": "SEQ3"} deactivate G activate S S->>+Z: GET /_matrix/client/v1/rendezvous/abc-def - Z->>-S: 200 OK
{"sequence_token": "3", "expires_in_ms": 300000, "data": ""} + Z->>-S: 200 OK
{"sequence_token": "SEQ3", "expires_in_ms": 300000, "data": ""} note over S: 6) Device S attempts to parse Data as LoginOkMessage note over S: 6) Device S creates Context_DeviceS_Receive - note over S: Device S checks that the plaintext matches MATRIX_QR_CODE_LOGIN_OK + note over S: Device S checks that the plaintext (unsealed using sequence token SEQ2) matches MATRIX_QR_CODE_LOGIN_OK note over S: If okay, Device S calculates the CheckCode to be displayed note over S: Device S displays a green checkmark, "secure connection established" and the CheckCode From db880793b4c12a463ef408b36e2fc03e4214bfd0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 25 Feb 2026 18:42:50 +0000 Subject: [PATCH 18/47] Ensure no leading zero in check code and notes on acceptable limitations --- proposals/4388-secure-qr-channel.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 04abfdba615..7031d0a5718 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -714,7 +714,7 @@ interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of context * ``` CheckBytes := Context_DeviceS_Send.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) -CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) +CheckCode := NumToString((CheckBytes[0] % 9) + 1) || NumToString(CheckBytes[1] % 10) ``` Device S then displays an indicator to the user that the secure channel has been established and that the **CheckCode** @@ -731,6 +731,10 @@ Device G asks the user to enter the **CheckCode** that is being displayed on Dev The purpose of the code being entered is to ensure that the user has actually checked their other device rather than just pressing "continue", and that the Device S has been able to determine that the channel is secure. +Because the actual value of the code is not significant from a cryptographic point of view it is acceptable that the +digits 6, 7, 8 and 9 are slightly less likely to appear. Furthermore we also ensure that the first digit of the code is +not `0` to avoid confusion the user might have about whether to enter a leading zero. + The exact points in the flow that the user is prompted for the **CheckCode** is described in [MSC4108]. Device G compares the code that the user has entered with the **CheckCode** that it calculates using the same mechanism @@ -738,7 +742,7 @@ as before: ``` CheckBytes := Context_DeviceG_Receive.Export("MATRIX_QR_CODE_LOGIN_CHECKCODE" || Gp || Sp , size=2) -CheckCode := NumToString(CheckBytes[0] % 10) || NumToString(CheckBytes[1] % 10) +CheckCode := NumToString((CheckBytes[0] % 9) + 1) || NumToString(CheckBytes[1] % 10) ``` If the code that the user enters matches then the secure channel is established. From 1963cf15b4b72400f7347aba4e1595cb507b9f5c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 25 Feb 2026 19:21:31 +0000 Subject: [PATCH 19/47] Add alternative about making `Sec-Fetch` logic explicit --- proposals/4388-secure-qr-channel.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 7031d0a5718..fb1eb11016b 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -250,6 +250,8 @@ To help mitigate the threat of [unsafe content](#unsafe-content), the server SHO [Fetch Metadata Request Headers](https://www.w3.org/TR/fetch-metadata/) (or other suitable headers) to identify top-level navigation requests and return a `403` HTTP response with error code `M_FORBIDDEN` instead. +The exact header values to use are an implementation detail for the server implementation. + A future optimisation could be allow the client to "long-poll" by sending the previous `sequence_token` as a query parameter and then the server returns when the is new data or some timeout has passed. @@ -957,6 +959,14 @@ Erik [said](https://github.com/matrix-org/matrix-spec-proposals/pull/4108#discus > I think this sort of flow would reduce potential abuse vectors, but equally makes things more complicated and may not > be worth it. +#### Define the Sec-Fetch unsafe content logic + +We could explicitly define exactly which `Sec-Fetch-*` headers are to be checked and which values disallowed. + +However, it has been deliberately left as an implementation detail such that alternative protections can be used if +appropriate for a particular deployment scenario, or if a better mechanism becomes available. + + ### Alternative QR code formats An earlier version of this proposal kept the "version" byte at `0x02` and added additional "mode" From 7d1d41320a92408ebee672eb98d06743f87ecd91 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 25 Feb 2026 19:22:25 +0000 Subject: [PATCH 20/47] With note about MUST and SHOULD --- proposals/4388-secure-qr-channel.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index fb1eb11016b..b47942bcbc9 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -963,10 +963,11 @@ Erik [said](https://github.com/matrix-org/matrix-spec-proposals/pull/4108#discus We could explicitly define exactly which `Sec-Fetch-*` headers are to be checked and which values disallowed. +It could also be made a MUST rather than SHOULD. + However, it has been deliberately left as an implementation detail such that alternative protections can be used if appropriate for a particular deployment scenario, or if a better mechanism becomes available. - ### Alternative QR code formats An earlier version of this proposal kept the "version" byte at `0x02` and added additional "mode" From f07df05dd3d8bfd2dd0e503f8d5f331ae82fe15d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 16 Mar 2026 15:17:03 +0000 Subject: [PATCH 21/47] Add discovery endpoint and remove unstable_features --- proposals/4388-secure-qr-channel.md | 45 +++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index b47942bcbc9..2cc24516eeb 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -103,6 +103,39 @@ The rendezvous session (i.e. the payload) SHOULD expire after a period of time c `expires_in_ms` field on the `POST` and `GET` response bodies. After this point, any further attempts to query or update the payload MUST fail. The rendezvous session can be manually expired with a `DELETE` call to the rendezvous session. +### `GET /_matrix/client/v1/rendezvous` - Discover if the rendezvous API is available + +Rate-limited: Yes +Requires authentication: Optional - depending on server policy + +Clients can use this endpoint to determine if the rendezvous API is available to them. Because the server policy may +require authentication, clients should make this request with their access token if they have one. + +HTTP response codes, and Matrix error codes: + +- `200 OK` - rendezvous API is available to the requester +- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create the rendezvous session +- `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled +- `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited + +The response body for `200 OK` is `application/json` with an empty body. It means that the requester is able to create +a rendezvous session using `POST /_matrix/client/v1/rendezvous`. + +Example response: + +```http +HTTP 200 OK +Content-Type: application/json + +{} +``` + +The body could be extended in future to provide any other information that the requester might require to use the +rendezvous API. + +The server can chose what level of authentication is required to create a rendezvous session. Please see the description +of `POST /_matrix/client/v1/rendezvous` for a description of this. + ### `POST /_matrix/client/v1/rendezvous` - Create a rendezvous session and send initial payload Rate-limited: Yes @@ -1001,18 +1034,6 @@ While this feature is in development the new API endpoints should be exposed usi - `/_matrix/client/unstable/io.element.msc4388/rendezvous` instead of `/_matrix/client/v1/rendezvous` -Additionally, the feature is to be advertised as unstable feature in the `GET /_matrix/client/versions` response, with the -key `io.element.msc4388` set to true. So, the response could look then as following: - -```json -{ - "versions": ["..."], - "unstable_features": { - "io.element.msc4388": true - } -} -``` - ### Unstable QR code format The unstable value of `IO_ELEMENT_MSC4388` should be used instead of `MATRIX` in the QR code. From 4af64571b356269df4f594ad4734bf73a14d34a8 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 16 Mar 2026 15:47:38 +0000 Subject: [PATCH 22/47] Add note about Context_DeviceG_Send exporter secret --- proposals/4388-secure-qr-channel.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 2cc24516eeb..3ce7aeb7360 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -1001,6 +1001,17 @@ It could also be made a MUST rather than SHOULD. However, it has been deliberately left as an implementation detail such that alternative protections can be used if appropriate for a particular deployment scenario, or if a better mechanism becomes available. +#### Initialise `Context_DeviceG_Send` with a real exporter secret + +It has been suggested that the "warning" about not using the exporter interface of `Context_DeviceG_Send` could be +removed. + +To do so we could instead initialize `Context_DeviceG_Send`` with some real exporter secret, but that would be more +expensive and we wouldn't use the exporter interface of the response context. + +Another alternative would be to not use the HPKE context for the other direction, but then we would need to reimplement +the nonce handling. + ### Alternative QR code formats An earlier version of this proposal kept the "version" byte at `0x02` and added additional "mode" From 309b0f7d18bbaedc81da17cb2528992e7075bfe7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 17 Mar 2026 11:46:45 +0000 Subject: [PATCH 23/47] Attempt to clarify maximum size of data --- proposals/4388-secure-qr-channel.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 3ce7aeb7360..1178b317fca 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -163,7 +163,7 @@ HTTP response codes, and Matrix error codes: - `200 OK` - rendezvous session created - `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create the rendezvous session - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled -- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 UTF8 character limit +- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 byte limit - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited Response body for `200 OK` is `application/json` with contents: @@ -227,7 +227,7 @@ HTTP response codes, and Matrix error codes: - `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired) - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `409 Conflict` (`M_CONCURRENT_WRITE`, a new error code) - when the `sequence_token` does not match -- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 UTF8 character limit +- `413 Payload Too Large` (`M_TOO_LARGE`) - the supplied `data` value is larger than the 4096 byte limit - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited The response body for `200 OK` is `application/json` with contents: @@ -366,7 +366,7 @@ sequenceDiagram #### Maximum payload size -The server MUST enforce a maximum payload size of 4096 UTF8 characters. +The server MUST enforce a maximum `data` field size of 4096 bytes. #### `sequence_token` values From d638bdd3950491d09c1090f3a33e0420fc3f48ab Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 17 Mar 2026 11:52:51 +0000 Subject: [PATCH 24/47] Clarify the PUT semantics --- proposals/4388-secure-qr-channel.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 1178b317fca..2c0964b2243 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -221,6 +221,10 @@ Content-Type: application/json } ``` +The server MUST perform a compare-and-swap operation by checking that the `sequence_token` matches +the current sequence token for the session. If it does not match then the `data` MUST not be +accepted and the `M_CONCURRENT_WRITE` error is returned. + HTTP response codes, and Matrix error codes: - `200 OK` - payload updated From 69869eca868c75bc8d096671f6468077dd29b82a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 18 Mar 2026 12:33:45 +0000 Subject: [PATCH 25/47] Add create_available to discovery response body --- proposals/4388-secure-qr-channel.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 2c0964b2243..44308b51308 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -114,12 +114,15 @@ require authentication, clients should make this request with their access token HTTP response codes, and Matrix error codes: - `200 OK` - rendezvous API is available to the requester -- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create the rendezvous session +- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create a rendezvous session - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited -The response body for `200 OK` is `application/json` with an empty body. It means that the requester is able to create -a rendezvous session using `POST /_matrix/client/v1/rendezvous`. +The response body for `200 OK` is `application/json` with contents: + +|Field|Type|| +|-|-|-| +|`create_available`|required `boolean`|`true` if the requester is able to create a rendezvous session using `POST /_matrix/client/v1/rendezvous` otherwise `false`| Example response: @@ -127,7 +130,9 @@ Example response: HTTP 200 OK Content-Type: application/json -{} +{ + "create_available": true +} ``` The body could be extended in future to provide any other information that the requester might require to use the From 62672cfc7bb84863bf1157e99836bc5086c8f761 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 18 Mar 2026 12:35:04 +0000 Subject: [PATCH 26/47] Correction on discovery response codes --- proposals/4388-secure-qr-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 44308b51308..d0a5dff8434 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -113,8 +113,8 @@ require authentication, clients should make this request with their access token HTTP response codes, and Matrix error codes: -- `200 OK` - rendezvous API is available to the requester -- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to create a rendezvous session +- `200 OK` - rendezvous API discovery supported +- `403 Forbidden` (`M_FORBIDDEN`) - the requester is not authorized to use the rendezvous API - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited From f95061f745d8d58add13a1e3ba06bb24aeb23b38 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 8 Apr 2026 15:05:56 +0100 Subject: [PATCH 27/47] Apply suggestions from review Co-authored-by: Hubert Chathi --- proposals/4388-secure-qr-channel.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index d0a5dff8434..13298b84860 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -78,19 +78,19 @@ New optional HTTP endpoints are to be added to the Client-Server API. Suppose that Device A wants to establish communications with Device B. Device A can do so by creating a _rendezvous session_ via a `POST /_matrix/client/v1/rendezvous` call to an appropriate homeserver. Its response includes -an _rendezvous ID_ which, along with the server [base URL], should be shared out-of-band with Device B. +a _rendezvous ID_ which, along with the server [base URL], should be shared out-of-band with Device B. The rendezvous ID points to an arbitrary data resource (the "payload") on the homeserver, which is initially populated using data from A's initial `POST` request. The payload is a string which the homeserver must enforce a maximum length on. Anyone who is able to reach the homeserver and has the rendezvous ID - including: Device A; Device B; or a third party; - -can then "receive" the payload by polling via a `GET` request, and "send" a new a new payload by making a `PUT` request. +can then "receive" the payload by polling via a `GET` request, and "send" a new payload by making a `PUT` request. In this way, Device A and Device B can communicate by repeatedly inspecting and updating the payload at the rendezvous session. ### The send mechanism -Every send request MUST include an `sequence_token` value whose value is the `sequence_token` from the last `GET` +Every send request MUST include a `sequence_token` value whose value is the `sequence_token` from the last `GET` response seen by the requester. (The initiating device may also use the `sequence_token` supplied in the initial `POST` response to immediately update the payload.) Sends will succeed only if the supplied `sequence_token` matches the server's current revision of the payload. This prevents concurrent writes to the payload. @@ -295,7 +295,7 @@ top-level navigation requests and return a `403` HTTP response with error code ` The exact header values to use are an implementation detail for the server implementation. A future optimisation could be allow the client to "long-poll" by sending the previous `sequence_token` as a query parameter -and then the server returns when the is new data or some timeout has passed. +and then the server returns when there is new data or some timeout has passed. ### `DELETE /_matrix/client/v1/rendezvous/{rendezvousId}` - cancel a rendezvous session @@ -465,7 +465,7 @@ The QR codes to be displayed and scanned using this format will encode binary st - the ASCII string `MATRIX` - one byte indicating the QR code type: `0x03` which identifies that the QR is part of this proposal - one byte indicating the intent of the device generating the QR: - - `0x00` a new device wishing to login and self-verify + - `0x00` a new device wishing to log in and self-verify - `0x01` an existing device wishing to facilitate the login of a new device and self-verify that other device - the ephemeral Curve25519 public key that will be used for [secure channel establishment](#establishment), as 32 bytes - the rendezvous session ID encoded as: @@ -911,7 +911,7 @@ user then must cancel the process on Device G, preventing it from sharing any se During the secure channel establishment the messages have been prefixed with `MATRIX_QR_CODE_LOGIN_` rather than something more generic. The purpose is to bind the protocol to this specific application. -Whilst the could be other uses for the secure channel mechanism or we might establish communication between devices +Whilst there could be other uses for the secure channel mechanism or we might establish communication between devices using another mechanism (e.g. NFC or sound), this proposal only considers the scenario where the communication is initiated via QR code and we make the prefix explicitly named to match. @@ -973,7 +973,7 @@ One could try and do something with STUN or TURN or [COAP](https://datatracker.i Rather than requiring the devices to poll for updates, "long-polling" could be used instead similar to `/sync`. Or WebSockets. -#### Unauthenticated device could crated "redirect channel" without payload +#### Unauthenticated device could create a "redirect channel" without payload In the current proposal the server operator may choose to not allow unauthenticated devices to create a rendezvous session to reduce abuse/attack vectors. From d2ba84f1b8fcf72b2502e417afff508f0601642a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 8 Apr 2026 15:21:09 +0100 Subject: [PATCH 28/47] Incorporate helpful note from @uhoreg --- proposals/4388-secure-qr-channel.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 13298b84860..37d45832854 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -717,6 +717,23 @@ Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to t 6. **Verification by Device S** +> [!TIP] +> _A helpful [note](https://github.com/matrix-org/matrix-spec-proposals/pull/4388/changes#r3025116401) from @uhoreg to +> future readers:_ +> +> The security of this scheme is established by Device S decrypting and verifying the **LoginOkMessage** message as +> described in this step. The messages are encrypted by the result of the ECDH of G and S's ephemeral keys (plus extra +> steps). S knows that it has G's public key because it was obtained from the QR scan (we assume that the attacker can't +> modify the displayed QR code). So if it is able to decrypt and verify the message, then it knows: +> +> - that it was received directly from G, since nobody other than S and G could obtain the shared secret to encrypt +> the message. In other words, the message from G to S was not tampered with. +> - that G has received the correct public key from S, otherwise the message from G would have been encrypted using +> the wrong key, and would be undecryptable/unverifiable. +> +> Since, at this point, S knows that both devices have the correct public key for the other device, it knows that the +> encrypted channel is secure. + Device S receives a response over the insecure rendezvous session by polling with `GET` requests, potentially from Device G. From 5b6dc1eded7aa66cbd465469ad8a5b19fa6b883b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 10 Apr 2026 11:41:24 +0100 Subject: [PATCH 29/47] Specify maximum length of rendezvous ID and sequence token + delimit AAD inputs Resolves https://github.com/matrix-org/matrix-spec-proposals/pull/4388#discussion_r3025010846 --- proposals/4388-secure-qr-channel.md | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 37d45832854..6079a771ee3 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -88,6 +88,8 @@ can then "receive" the payload by polling via a `GET` request, and "send" a new In this way, Device A and Device B can communicate by repeatedly inspecting and updating the payload at the rendezvous session. +The maximum length of a rendezvous ID is 65,535 bytes. + ### The send mechanism Every send request MUST include a `sequence_token` value whose value is the `sequence_token` from the last `GET` @@ -97,6 +99,8 @@ revision of the payload. This prevents concurrent writes to the payload. n.b. Once a new payload has been sent there is no mechanism to retrieve previous payloads. +The maximum length of a `sequence_token` is 65,535 bytes. + ### Expiry The rendezvous session (i.e. the payload) SHOULD expire after a period of time communicated to clients via the @@ -175,8 +179,8 @@ Response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| -|`id`|required `string`|Opaque identifier for the rendezvous session| -|`sequence_token`|required `string`|The opaque token to identify if the payload has changed| +|`id`|required `string`|Opaque identifier for the rendezvous session. Maximum length 65,535 bytes| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Maximum length 65,535 bytes| |`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| Example response: @@ -243,7 +247,7 @@ The response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| -|`sequence_token`|required `string`|The opaque token to identify if the payload has changed| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Maximum length 65,535 bytes| For example: @@ -474,6 +478,7 @@ The QR codes to be displayed and scanned using this format will encode binary st - the rendezvous session ID as a UTF-8 string - the [base URL] of the homeserver for client-server connections encoded as: - two bytes in network byte order (big-endian) indicating the length in bytes of the base URL as a UTF-8 string + (n.b. a base URL longer than 65,535 bytes cannot be encoded and should be rejected) - the base URL as a UTF-8 string If a new version of this QR sign in capability is needed in future (perhaps with updated secure channel protocol) then @@ -638,11 +643,22 @@ that Device G can use to confirm that the channel is secure. It contains: - Its public ephemeral key **Sp**. ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) TaggedCiphertext := Context_DeviceS_Send.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", Aad) LoginInitiateMessage := UnpaddedBase64(Sp || TaggedCiphertext) ``` +We define the result of `EncodeStringAsBytes(StringInput)` to be a sequence of bytes: + +- two bytes in network byte order (big-endian) indicating the length in bytes of the `StringInput` as a UTF-8 string +- the `StringInput` as a UTF-8 string + +e.g. `EncodeStringAsBytes("abcdef")` returns `[0x00, 0x06, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66]` + +n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceToken` to 65535 bytes, and that a +`BaseUrl` longer than 65535 bytes will have failed at the point of encoding a QR, we don't specify a handling for +`StringInput` of length greater than 65535 bytes. + Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request and noting the new **sequence token**. @@ -673,7 +689,7 @@ with the additional authentication data: It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) Plaintext := Context_DeviceG_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": @@ -708,7 +724,7 @@ string `MATRIX_QR_CODE_LOGIN_OK` that is sealed with the additional authenticati **sequence token** is the one that was received with the `GET` request that returned **LoginInitiateMessage**: ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) TaggedCiphertext := Context_DeviceG_Send.Seal("MATRIX_QR_CODE_LOGIN_OK", Aad) LoginOkMessage := UnpaddedBase64Encode(ResponseNonce || TaggedCiphertext) ``` @@ -762,7 +778,7 @@ It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) Plaintext := Context_DeviceS_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": @@ -813,7 +829,7 @@ sent from S should be encrypted with **Context_DeviceS_Send**. Each call to the additional authentication data of the form where the **sequence token** is from the last `GET` that the device received: ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) ``` Similarly, payloads received by G should be decrypted using the context **Context_DeviceG_Receive**, while payloads received by S @@ -821,7 +837,7 @@ should be decrypted using the context **Context_DeviceG_Receive**. Each call to additional authentication data of the form where the **sequence token** is from the last `PUT` that the device made: ``` -Aad := BaseUrl || RendezvousId || SequenceToken +Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) ``` ### Sequence diagram From 470ed053a9f23df4fa3d3b055676c65a36602b40 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 21 May 2026 07:55:13 +0100 Subject: [PATCH 30/47] Update proposals/4388-secure-qr-channel.md Co-authored-by: Erik Johnston --- proposals/4388-secure-qr-channel.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 6079a771ee3..de935c29380 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -313,6 +313,8 @@ HTTP response codes: - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited +Cancelling a session will cause all future reads and writes to fail with a `404 M_NOT_FOUND`. + ### Example API usage The actions above can be illustrated as follows: From c992731a2b2a8be2c7c132c446c63acd3ba8b2f7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 May 2026 09:06:47 +0100 Subject: [PATCH 31/47] Update proposals/4388-secure-qr-channel.md Co-authored-by: Erik Johnston --- proposals/4388-secure-qr-channel.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index de935c29380..e47cbce000b 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -199,10 +199,10 @@ Content-Type: application/json The server can chose what level of authentication is required to create a rendezvous session. Suitable policies might include: -- Public/open - anyone can create a rendezvous session without an access token. This allows for the QR code to be - created on either the new or existing Matrix client. -- Requires authenticated user - this would reduce abuse to known users, but would restrict the mechanism so that the QR - code must be created on the existing Matrix client (and therefore the new Matrix client must have a camera). +- Requires authenticated user - this would reduce abuse to known users. This should be the default. +- Public/open - anyone can create a rendezvous session without an access token. This allows for clients to use this + server as its default when creating QR codes as a new session (as it doesn't yet know what server the user will + log in to). The expiry time is detailed [below](#maximum-duration-of-a-rendezvous). From d125239e087b2ca90a4fb38c16d785c252ce5957 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 May 2026 09:55:43 +0100 Subject: [PATCH 32/47] Incorporate review feedback Co-Authored-By: Erik Johnston --- proposals/4388-secure-qr-channel.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index e47cbce000b..cef59efb82b 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -69,9 +69,13 @@ sequenceDiagram ## Insecure rendezvous session It is proposed that an HTTP-based protocol be used to establish an ephemeral bi-directional communication session over -which the two devices can exchange the necessary data. This session is described as "insecure" as it provides no +which the two devices can exchange the necessary data. + +This session is described as "insecure" as it provides no end-to-end confidentiality nor authenticity by itself - these are layered on top of it. +The name "rendezvous" is used as it is the designated place where the two clients will meet. + New optional HTTP endpoints are to be added to the Client-Server API. ### Concept @@ -83,12 +87,22 @@ a _rendezvous ID_ which, along with the server [base URL], should be shared out- The rendezvous ID points to an arbitrary data resource (the "payload") on the homeserver, which is initially populated using data from A's initial `POST` request. The payload is a string which the homeserver must enforce a maximum length on. +The maximum length of a rendezvous ID is 65,535 bytes. + +Note that the rendezvous session is not a channel that two clients can use to send a sequence of messages between them, +but rather a single shared mutable spot whose contents can be inspected and overwritten by either party. Each new write +replaces the previous payload entirely; the homeserver retains no history of prior payloads. + Anyone who is able to reach the homeserver and has the rendezvous ID - including: Device A; Device B; or a third party; - can then "receive" the payload by polling via a `GET` request, and "send" a new payload by making a `PUT` request. In this way, Device A and Device B can communicate by repeatedly inspecting and updating the payload at the rendezvous session. -The maximum length of a rendezvous ID is 65,535 bytes. +This has consequences for how clients use the session: a client cannot tell whether its previous payload has been +received by the remote client until that remote client itself writes a new payload and the original client observes the +change. This favours a "ping-pong" architecture, where a client sends a message, waits for a reply, and only then sends +its next message. If a client needs to send several messages in a row without an intervening reply, it should update +the payload to contain a list of those messages rather than overwriting it repeatedly. ### The send mechanism From 9aabff85bfe8c97a338e9dc6e075b8c3b3f42d46 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 May 2026 11:50:12 +0100 Subject: [PATCH 33/47] Add note about client handling of M_CONCURRENT_WRITE --- proposals/4388-secure-qr-channel.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index cef59efb82b..679c4785916 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -245,8 +245,9 @@ Content-Type: application/json ``` The server MUST perform a compare-and-swap operation by checking that the `sequence_token` matches -the current sequence token for the session. If it does not match then the `data` MUST not be -accepted and the `M_CONCURRENT_WRITE` error is returned. +the current sequence token for the session. If the `sequence_token` does not match then the `data` MUST not be +accepted and the `M_CONCURRENT_WRITE` error is returned. On receipt of a `M_CONCURRENT_WRITE` the client can do a `GET` +to fetch the latest data and `sequence_token` and then retry. HTTP response codes, and Matrix error codes: From 98d3b04bf3841d82f1571073d8eec7d141146965 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 May 2026 12:19:00 +0100 Subject: [PATCH 34/47] Make PUT requests idempotent --- proposals/4388-secure-qr-channel.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 679c4785916..8e16bd5df26 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -106,11 +106,17 @@ the payload to contain a list of those messages rather than overwriting it repea ### The send mechanism -Every send request MUST include a `sequence_token` value whose value is the `sequence_token` from the last `GET` +Every send (`PUT`) request MUST include a `sequence_token` value whose value is the `sequence_token` from the last `GET` response seen by the requester. (The initiating device may also use the `sequence_token` supplied in the initial `POST` response to immediately update the payload.) Sends will succeed only if the supplied `sequence_token` matches the server's current revision of the payload. This prevents concurrent writes to the payload. +To make sends idempotent (so that clients can safely retry a request whose response was lost), the server MUST also +accept a send request whose `sequence_token` does not match the current revision if the supplied `data` is byte-for-byte +identical to the current payload. In that case the server MUST NOT advance the payload or generate a new +`sequence_token`, and MUST return the current `sequence_token` in the response, as if the client's previous (successful) +request were being acknowledged again. Any other mismatch of `sequence_token` MUST be rejected as a concurrent write. + n.b. Once a new payload has been sent there is no mechanism to retrieve previous payloads. The maximum length of a `sequence_token` is 65,535 bytes. @@ -249,6 +255,13 @@ the current sequence token for the session. If the `sequence_token` does not mat accepted and the `M_CONCURRENT_WRITE` error is returned. On receipt of a `M_CONCURRENT_WRITE` the client can do a `GET` to fetch the latest data and `sequence_token` and then retry. +To support idempotent retries (e.g. when a client did not receive the response to a previous `PUT` and so does not know +whether it succeeded), the server MUST treat the request as successful - without advancing the payload or issuing a new +`sequence_token` - if the supplied `sequence_token` does not match the current sequence token but the supplied `data` +is byte-for-byte identical to the current payload. In this case the server returns `200 OK` with the current +`sequence_token`, rather than `M_CONCURRENT_WRITE`. This allows a client to safely repeat the same `PUT` request without +first issuing a `GET`. + HTTP response codes, and Matrix error codes: - `200 OK` - payload updated From abcba864fd050f161e210804174e7e690f6ff59d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Jun 2026 17:21:25 +0100 Subject: [PATCH 35/47] Apply suggestions from code review Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- proposals/4388-secure-qr-channel.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 8e16bd5df26..71271677473 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -337,7 +337,8 @@ Requires authentication: No HTTP response codes: - `200 OK` - rendezvous session cancelled -- `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired) +- `404 Not Found` (`M_NOT_FOUND`) - rendezvous session ID is not valid (it could have expired, + or been cancelled by a previous `DELETE` request). - `404 Not Found` (`M_UNRECOGNIZED`) - the rendezvous API is not enabled - `429 Too Many Requests` (`M_LIMIT_EXCEEDED`) - the request has been rate limited @@ -622,7 +623,8 @@ other device is then the new device (one seeking to be signed in). 2. **Create rendezvous session** Device G creates a rendezvous session by making a `POST` request (as described previously) to the nominated homeserver -with an empty payload. It parses the **ID** received and **sequence token**. +with an empty payload. It parses the response from the homeserver to extract the rendezvous session **ID** +and **sequence token**. 3. **Initial key exchange** @@ -662,8 +664,8 @@ SharedSecret := ECDH(Ss, Gp) Context_DeviceS_Send := KeySchedule(mode=0x00, shared_secret=SharedSecret, info="MATRIX_QR_CODE_LOGIN") ``` -With this, Device S has established its sending side of the secure channel. Device S then derives a confirmation payload -that Device G can use to confirm that the channel is secure. It contains: +With this, Device S has established its sending side of the secure channel. It then derives a confirmation +payload, **LoginInitiateMessage**, that Device G can use to confirm that the channel is secure. It contains: - The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using the `ContextS.Seal()` function of context **Context_DeviceS_Send** with additional authentication data: @@ -703,7 +705,7 @@ discarding **Gs**, deriving an HPKE context `Context_DeviceG_Receive`: **Context_DeviceG_Receive** ``` -(TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) +(Sp, TaggedCiphertext) := Unpack(LoginInitiateMessage) SharedSecret := ECDH(Gs, Sp) @@ -749,7 +751,7 @@ Context_DeviceG_Send := Context(AeadKey, AeadNonce, 0, ExporterSecret) with a dummy exporter secret. Aside from this limitation, the response context supports encryption and decryption in the same manner as the primary context, enabling bidirectional use of HPKE. -Following the creation of the response context **Context_DeviceG_Send**, it responds with a dummy payload containing the +Device G then responds with a dummy payload, **LoginOkMessage**, containing the string `MATRIX_QR_CODE_LOGIN_OK` that is sealed with the additional authentication data similar to before, but the **sequence token** is the one that was received with the `GET` request that returned **LoginInitiateMessage**: @@ -789,7 +791,7 @@ creating a response context on its own. **Context_DeviceS_Receive** ``` -(TaggedCiphertext, ResponseNonce) := Unpack(LoginOkMessage) +(ResponseNonce, TaggedCiphertext) := Unpack(LoginOkMessage) Secret := Context_DeviceS_Send.Export("MATRIX_QR_CODE_LOGIN response", 32) Salt := Sp || ResponseNonce @@ -801,7 +803,7 @@ ExporterSecret := [0; 32] Context_DeviceS_Receive := Context(AeadKey, AeadNonce, 0, ExporterSecret) ``` -It decrypts (and authenticates) the response using the **Context_DeviceS_Receive** context, which will succeed provided +It then decrypts (and authenticates) the tagged ciphertext from the `LoginOkMessage` using the **Context_DeviceS_Receive** context, which will succeed provided the payload was indeed sent by Device G. The additional authentication data is as before with the **sequence token** being the one that was received when the `PUT` was made for **LoginInitiateMessage**. It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing @@ -815,7 +817,7 @@ unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": FAIL ``` -If the above was successful, Device S then calculates a two digit **CheckCode** code using the [HPKE export +If the above was successful, Device S then calculates a two digit **CheckCode** code in the range [10, 99] using the [HPKE export interface](https://www.rfc-editor.org/rfc/rfc9180.html#hpke-export) of context **Context_DeviceS_Send**. **Gp** and **Sp** are used as inputs for the export interface: From 7b4020cc6134bdf39b38ccc3788df0da748ae4c4 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Jun 2026 17:22:30 +0100 Subject: [PATCH 36/47] Apply suggestion from @richvdh Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 71271677473..592cd26afe0 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -741,7 +741,7 @@ ResponseNonce := random(32) Salt := Sp || ResponseNonce AeadKey := HKDF_SHA256(Secret, "key", salt=Salt, size=32) -AeadNonce := HKDF_SHA256(Secret, "key", salt=Salt, size=12) +AeadNonce := HKDF_SHA256(Secret, "nonce", salt=Salt, size=12) ExporterSecret := [0; 32] Context_DeviceG_Send := Context(AeadKey, AeadNonce, 0, ExporterSecret) From 2efb43adeca6f6d791f19a3c62b1cd7cbe2d65ae Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Jun 2026 17:26:43 +0100 Subject: [PATCH 37/47] Ordering of LoginInitiateMessage contents --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 592cd26afe0..b977f9853ee 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -667,12 +667,12 @@ Context_DeviceS_Send := KeySchedule(mode=0x00, shared_secret=SharedSecret, in With this, Device S has established its sending side of the secure channel. It then derives a confirmation payload, **LoginInitiateMessage**, that Device G can use to confirm that the channel is secure. It contains: +- Its public ephemeral key **Sp**. - The string `MATRIX_QR_CODE_LOGIN_INITIATE`, encrypted and authenticated with ChaCha20-Poly1305 using the `ContextS.Seal()` function of context **Context_DeviceS_Send** with additional authentication data: - the homeserver **base URL** from the QR code - the rendezvous session **ID** from the QR code - the **sequence token** returned by the homeserver when calling `GET` on the rendezvous session -- Its public ephemeral key **Sp**. ``` Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) From c56d9fa8fbc960a4610ecd167a83aa9429ffc4e3 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Jun 2026 17:38:01 +0100 Subject: [PATCH 38/47] Readability improvements Co-Authored-By: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- proposals/4388-secure-qr-channel.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index b977f9853ee..c7a4dc240d6 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -648,15 +648,11 @@ At this point Device S should check that the received intent matches what the us 4. **Device S sends the initial payload** -Device S performs an ECDH operation using **Ss** and **Gp** to compute the shared secret **SharedSecret**. +Device S performs an ECDH operation using **Ss** and **Gp** to compute the shared secret **SharedSecret**. It then discards **Ss**. -The **SharedSecret** is then used to initialize an +The **SharedSecret** is used to initialise an [HPKE encryption context](https://www.rfc-editor.org/rfc/rfc9180.html#name-encryption-and-decryption) -**Context_DeviceS_Send** using the `KeySchedule()` function. - -After this step, **Ss** is discarded. - -**Context_DeviceS_Send** +**Context_DeviceS_Send** using the `KeySchedule()` function, as follows: ``` SharedSecret := ECDH(Ss, Gp) @@ -700,9 +696,7 @@ Device G receives **LoginInitiateMessage** (potentially coming from Device S) fr polling with `GET` requests. It then does the reverse of the previous step, obtaining **Sp**, deriving the shared secret using ECDH on **Gs** and **Sp**, -discarding **Gs**, deriving an HPKE context `Context_DeviceG_Receive`: - -**Context_DeviceG_Receive** +discarding **Gs**, deriving an HPKE context **Context_DeviceG_Receive** as follows: ``` (Sp, TaggedCiphertext) := Unpack(LoginInitiateMessage) @@ -730,9 +724,7 @@ unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": It then derives its own HPKE context **Context_DeviceG_Send** for sending based on the scheme from the [Oblivious HTTP](https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses) RFC, using a secret exported -from **Context_DeviceG_Receive**: - -**Context_DeviceG_Send** +from **Context_DeviceG_Receive**, and a dummy exporter secret, as follows: ``` Secret := Context_DeviceG_Receive.Export("MATRIX_QR_CODE_LOGIN response", 32) @@ -785,10 +777,8 @@ Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to t Device S receives a response over the insecure rendezvous session by polling with `GET` requests, potentially from Device G. -It proceeds to derive the response context by unpacking the base response nonce from the **LoginOkMessage** and -creating a response context on its own. - -**Context_DeviceS_Receive** +It proceeds to derive the HPKE response context by unpacking the base response nonce from the **LoginOkMessage** and +creating the **Context_DeviceS_Receive** response context as follows: ``` (ResponseNonce, TaggedCiphertext) := Unpack(LoginOkMessage) From 914ae870b1c59b219947adbae40df7a233822642 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Jun 2026 18:05:53 +0100 Subject: [PATCH 39/47] Constrain rendezvous ID and sequence_token to the opaque identifier grammar https://spec.matrix.org/v1.18/appendices/#opaque-identifiers --- proposals/4388-secure-qr-channel.md | 28 +++++++++--------- proposals/images/4388-qr-intent00.png | Bin 918 -> 897 bytes .../images/4388-qr-intent01-unstable.png | Bin 910 -> 906 bytes proposals/images/4388-qr-intent01.png | Bin 918 -> 895 bytes 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index c7a4dc240d6..1ec218e5eb1 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -87,7 +87,7 @@ a _rendezvous ID_ which, along with the server [base URL], should be shared out- The rendezvous ID points to an arbitrary data resource (the "payload") on the homeserver, which is initially populated using data from A's initial `POST` request. The payload is a string which the homeserver must enforce a maximum length on. -The maximum length of a rendezvous ID is 65,535 bytes. +The rendezvous ID must comply with the [opaque identifier grammar]. Note that the rendezvous session is not a channel that two clients can use to send a sequence of messages between them, but rather a single shared mutable spot whose contents can be inspected and overwritten by either party. Each new write @@ -119,7 +119,7 @@ request were being acknowledged again. Any other mismatch of `sequence_token` MU n.b. Once a new payload has been sent there is no mechanism to retrieve previous payloads. -The maximum length of a `sequence_token` is 65,535 bytes. +The `sequence_token` must comply with the [opaque identifier grammar]. ### Expiry @@ -199,8 +199,8 @@ Response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| -|`id`|required `string`|Opaque identifier for the rendezvous session. Maximum length 65,535 bytes| -|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Maximum length 65,535 bytes| +|`id`|required `string`|Opaque identifier for the rendezvous session. Must comply with the [opaque identifier grammar]| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Must comply with the [opaque identifier grammar] |`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| Example response: @@ -275,7 +275,7 @@ The response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| -|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Maximum length 65,535 bytes| +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Must comply with the [opaque identifier grammar]| For example: @@ -504,8 +504,7 @@ The QR codes to be displayed and scanned using this format will encode binary st - `0x01` an existing device wishing to facilitate the login of a new device and self-verify that other device - the ephemeral Curve25519 public key that will be used for [secure channel establishment](#establishment), as 32 bytes - the rendezvous session ID encoded as: - - two bytes in network byte order (big-endian) indicating the length in bytes of the rendezvous session ID as a UTF-8 - string + - one byte indicating the length in bytes of the rendezvous session ID as a UTF-8 string - the rendezvous session ID as a UTF-8 string - the [base URL] of the homeserver for client-server connections encoded as: - two bytes in network byte order (big-endian) indicating the length in bytes of the base URL as a UTF-8 string @@ -526,7 +525,7 @@ encoded) at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on home 4D 41 54 52 49 58 03 00 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 @@ -539,7 +538,7 @@ Generated with: nix-shell -p qrencode --run 'echo "4D 41 54 52 49 58 03 00 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/4388-qr-intent00.png' @@ -556,7 +555,7 @@ encoded), at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on hom 4D 41 54 52 49 58 03 01 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 @@ -569,7 +568,7 @@ Generated with: nix-shell -p qrencode --run 'echo "4D 41 54 52 49 58 03 01 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/4388-qr-intent01.png' @@ -683,7 +682,7 @@ We define the result of `EncodeStringAsBytes(StringInput)` to be a sequence of b e.g. `EncodeStringAsBytes("abcdef")` returns `[0x00, 0x06, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66]` -n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceToken` to 65535 bytes, and that a +n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceToken` to 255 bytes (according to [opaque identifier grammar]), and that a `BaseUrl` longer than 65535 bytes will have failed at the point of encoding a QR, we don't specify a handling for `StringInput` of length greater than 65535 bytes. @@ -1120,7 +1119,7 @@ encoded), at rendezvous session ID `e8da6355-550b-4a32-a193-1619d9830668` on hom 49 4F 5F 45 4C 45 4D 45 4E 54 5F 4D 53 43 34 33 38 38 03 01 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67 @@ -1133,7 +1132,7 @@ Generated with: nix-shell -p qrencode --run 'echo "49 4F 5F 45 4C 45 4D 45 4E 54 5F 4D 53 43 34 33 38 38 03 01 d8 86 68 6a b2 19 7b 78 0e 30 0a 9d 4a 21 47 48 07 00 d7 92 9f 39 ab 31 b9 e5 14 37 02 48 ed 6b -00 24 +24 65 38 64 61 36 33 35 35 2D 35 35 30 62 2D 34 61 33 32 2D 61 31 39 33 2D 31 36 31 39 64 39 38 33 30 36 36 38 00 20 68 74 74 70 73 3A 2F 2F 6D 61 74 72 69 78 2D 63 6C 69 65 6E 74 2E 6d 61 74 72 69 78 2e 6f 72 67" | xxd -r -p | qrencode -8 -l Q -t PNG -o ./proposals/images/4388-qr-intent01-unstable.png' @@ -1152,3 +1151,4 @@ None. [base URL]: https://spec.matrix.org/v1.16/client-server-api/#getwell-knownmatrixclient [MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 +[opaque identifier grammar]: https://spec.matrix.org/v1.18/appendices/#opaque-identifiers diff --git a/proposals/images/4388-qr-intent00.png b/proposals/images/4388-qr-intent00.png index 9cef72ef3abf29fd1d086a87054180f545be2c2a..f70de481a6cb23d7bdae278db0911d960855555b 100644 GIT binary patch delta 821 zcmV-51IqlC2Z0BWS$`8rL_t(oh3%KIt>Z8ZhH0o!XD=W?YpByo_5x14fU}Z1tsy`Q z*qsW&yq|e_@2M2~DZ`&Z(f$|H9ouiz*5CA%Rz zqP~(jQ-83EC$~nxGE=9>TkEq`@)^-a&xy!7JWJ1m*%H#EsehFzcO_?}>B#z%-5T6# zf7js_NBt4opV!a6+~W8gp^)thyU|wc=8sNp@hi}Rng^uore>JS95nodsE{ed5NNj8 z3)v0@zXCK_k60cFqN+R5{Lc3_$9F?)29kJ2e^~cuDvlq_d#q|IXpVhKI_^d)V}z@)=qi{qvxY+n z4Vl8A%+_GM{`nQ>P0p#0vMeGj4~Y|s)$l7~2W5On(SN9qR&HsygrOvT}`G)7D}qEPn$ZD=_B1nbY3jIus9)SpSi&rCz>Bl#!!7n@y- zSHq70e=|>bb%LMp#u#v{;AfNii(|)%S+Ek?$16Bz-h)Be(Jb&uG~kd~!BMU;pligp z_h{bZTYtqd5R;_2uz8_|xhOf#49u_WH{4AS6}nHuxz$}J5l%;V1Ooo}OMFXK|}6O%4(iyn@TQ2>Tf7+3+8%UxudP%M zHAv;3usDLi>d(I1$ z?1t1QxvSy{5u!(suR-x=g8gcFHV0~s5y?2se$H14{>c6c;s!5Rcf?avocjzj!K)C9 zPcB}utX9KO|25*m;q!fxLHk5j@VoED{}%rRPgdlqd*{Yf00000NkvXXu0mjfsxY0p delta 842 zcmV-Q1GW5t2bKqrS$`-=L_t(oh3%I;j-)UUg$p@wMqj{^uR-Q3!3Fra09?tLuYtq` zAah_*`wC`fHrT|K?zTp)=?}?myI#F|P5k#4{pVl&BpRaM^4^mBB-h$PrGS8=_ZmX%Oedt`KOVkj+{08wluMul53Kjz5ksbs(FX)xerD_ zL<{@%seg!qUzyM^vy>9k7R_Q7)^ObU644{n6RgQ#@hxfig~Mj}El$m{IJ6pA!A}`y zSl_IYYTPHgw|}d7M|}s8(`d zYWZo+ERN)wY~WFnW}}94Di}tqEe#uqG?C}8d4E^O(fdn6B04Tcp4p)ae)Ukv&*!ik zVobsaFpw)*4M*mL9wu>1%n=D!+ne_)Im=kYY=xg-wBg`HR>hG&tt4|1yv%Os+$*@` z;fzNZLrAa?s(7!0OFoC9VAn_v0g~|Nch0Zk@_PZD!n34n92WD4bsTdRN+5(!LLJd} zN<%(Hhg{S!wQ#3UhU_(*%ag2-9^tpRJ+v?@IPQN$F0%O5iTCG=RJ`_g@xR4?0lRwk UPbone4*&oF07*qoM6N<$g6d1Jvj6}9 diff --git a/proposals/images/4388-qr-intent01-unstable.png b/proposals/images/4388-qr-intent01-unstable.png index 30ecd14c7018ece5794bdfd96462e31e005d5dc1..bfb42ff163fbd765d2934fb68028b4787efc1052 100644 GIT binary patch delta 830 zcmV-E1Ht@`2Z{%fS$`Z!L_t(oh3%J3j-)UQg^jYv4lWQW*T^ncf(ua81>j2A)LR`KM<;HIl^;`8!rsH0W#nf#XPl&FvXot>UXUdSNiBY)CptCBMU+-0k5*0{g& zuXAYPIR7cT-~WE*rHRXxd5w4xqBei%q=nyvz3mg@MXt99oRJ6>_jkaGg}Dw-HkY8` zCp*Y;#LeZV*N(s1nqsxSNPsuaudtl!R!C_I)k)hX{J>F^59$@O6$V7Af0Es@8?vVtEL z3WtYaw*}gG=W=}fp_NCtb>RzdF?eA3uDe!#a#`VxiEtGSmw1DDr}C(a zR|r08_?h9cs5k@17BK{P9V_@@>FH%OFt~a1w@zAlG&r%Fq}6Un0a`z4_?c@@)Ca_i z8pKFr?tia%+c@fFyUJqsKFG-*k3tmuwld5h#?`tZl{LUES-}tMQTVGxBY0X#;Ex_! zIkW37NOYeq^kBq(KR(mQZz>QKRLasHVg5$$+d0-6iC&ocEE+dl*OArm+v4ycbx30G zn78bPi&b3a;qwwx!a&;x)S!l=u`>KNNmrVwEPv=tAKz=_$7iU<%2CucS~L_5zoAby zPZxM{%1Ee&^V3Ack>DrTXMmruhO->nj4H-F44+K?Yd&e@^8JD^F^6Qy=37Q$R&X&} z#3+*kb*XIpwSF^~?*;Ql36p;(%#G+rtm9Kcyf8@&NN^SY%nu5VU@?DkLIEO)`?ua2 zxi>T;jIXXsJB|(m_I9y?%lSi@p{y8pF delta 834 zcmV-I1HJr;2aX4jS$`l&L_t(oh3%KU4dXBjg=wfzXD=W?YpByo$pTKiK)jMVtsy`Q z*qsW)yhq3PQ`{9|eMS%j9|BpV9v>;HI$)BOFnLp7l*{QjN*$}v4u768Dl$?)8G$*-%-AIzR zUwP=_lK<@f{QN$b9xhL!Dfxxnkobd5{?bVgKZ&O7*KF{jIl4LWa_zI1?~UJ~V#Q~3 zQ^ULX6IjKOsLIW(xxVf3%4M`Fp0F_-fun!}t$Xq-YdGQ^APHLti#PX5X8)OoF3$3U z-;jDMW02QvQGdfXTV9DMULkXNwu7#`_43o2wUwD5Cmbj??7sT!<$ErJ?9=QBws}tY z11mVUx--`IpCEcTFNG-h8E9xU2e=cj?HF3!qK0q5pX8V3KlYfd-!l@Z;3a!VOr#&)|1BE&43e7|{;=w|;y1 z7HMFdT_KVKm3R{?I0^*8PD%W2#LC%y^VZ8Z3qhjQngyX}!(!vC;3w5nv`w>h4=S^* zs2~NGNPi=GG*Wj=EZTV9e%{C7Z)bqc4}|#%eiNti3V!vYB6>(@N%Ts$FZFUX#8tcq zx+*dn!Nys|8UE&`Oi$63)ZqKpc^}`RY;S7xK-!!6*mCQklXJuPQ4uwo+6qyjD<#%& zY%H1$?u|JXT~gkq;TsAziUUYLfE%r_4nv9%y1n)JQ|I5Ncg+ zKkwtGy$Y>iR4YR|pz}x2aBk8W(WW0H4URK*tKtk{OX{BN*W}-H`&%!+?k|{OFcp7& z^7h+aF7r8TKSJmJQiZ?kv6m-@^+%Ff$(_o!meE#pMrss!t!=bQKFThhGm&-JOV5MZ0**;TEq_C9O3pZ@ta-7UgPZN2 zJiOw_KVtj$^UuA!;`p7RkZlvY-X44Uqmx(s4m2U>0mpSiHOyrU8h&M7aZ-pO&}b2) zT!(^lZA(7R*p>uQ9UIa3bKbW+d9H}fpV5D!KdoD;hRYxJTZAwQg*HQ4DvpZdN~OSCjYj`}K z(u1zdT3wg@=O@mWoKx#%S?Xh1Bu>6n{EoOmX&;g_YJa_zR~l|%*RZ(qD4aUDw~C`) zt_nYat6*hlLo}x1;<0^1cQc=GYysNP@S9})5uH)i$A$VUY50|?tF0&hA-^+fkiPx; zB|iiF4H4o(;U_pX8XPP5RVDx8NRPl~D%0ZwZ#id%pTLw*EbvJbV3%3Jk*+?VY7BgP zi{@$HDu0fKnBYFdjs*dcw2RABWssoY75u8`_~s7BDISIOOII}<^}A+5coYu|pA+^9tl-H1 z93=A}%pqhp$(xS*n;0I8#nIqUdUw!pIPDw_bbr;8n7}8}w~xH!w-qZGs+VI{H>A>& zhGV{h(;{`S5p>%X)%SZ&d&e0^J`f)4ag}=U-+Ot<0X&0lOt-YQumV zurmdMxsRXcb}zU@Om7DR1O5rnh}7dFhxorg^p}76MKmVAm*Zn=pBY7aG|= zme>3z4^3S1pWVN|f9|D)L{TZk@Sd}kKla>+ z`b?9BI3cZ{(|;;>bX`KoW-aEiZhWt@opVOWo>QGu9U%4SufQ74G+5W?bBVkrvx)M( zh9_Q2odOh&q*Ywv_U_-vA6utLB&Anj2unQrZ$8`j4MEz2BIIN)IzD8UpyI{NaL9z$ zI&!7p7pvi1%w5EtKG8hn2C*hq@H^31%w-+i`-58MbAQbm{y^UG_(TUWJkTfkUBz#v zF8PblhRm9=O5N8#+xQaz&mFG9sFd0jyt0BL|05%0G$V)S65W`*f=hvr-z@yrKhZ-B zusaR65GOh;R?V9HL-g$ntz4vjAWSLbDEG)k!yijCqea#(OYFlx^FuQaI39ce$wvwM z?dL`m{C_SqCp05mW$g=!#jLF1$oUGNv@{T`$zbz6Yxs>|3rzu0vuqBnMpp1k+SxH@ zvqcP7t}pJb-)0_Yz}c8)@kvL>|J$b;xpe$^a>kryI719>e(SfHV}DEze#Jl%WdGs2 ze;dD|f!_|nbuhYwp6mLg;hZrBpPQ{68yRV2fPZ8KhuN+f=Y-2)o;jcje)Z7Ek$=c? z&p6(>1b8@+)o{$5q6c Date: Wed, 3 Jun 2026 18:07:11 +0100 Subject: [PATCH 40/47] Fix table inconsistency --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 1ec218e5eb1..876b3899918 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -200,7 +200,7 @@ Response body for `200 OK` is `application/json` with contents: |Field|Type|| |-|-|-| |`id`|required `string`|Opaque identifier for the rendezvous session. Must comply with the [opaque identifier grammar]| -|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Must comply with the [opaque identifier grammar] +|`sequence_token`|required `string`|The opaque token to identify if the payload has changed. Must comply with the [opaque identifier grammar]| |`expires_in_ms`|required `integer`|The number of milliseconds remaining until the rendezvous session expires| Example response: From e43db14cfe92addfd99ce9b811290d83d586cc9a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 4 Jun 2026 09:12:08 +0100 Subject: [PATCH 41/47] Replace EncodeStringAsBytes() with EncodeStringAsBytes8() and EncodeStringAsBytes16() --- proposals/4388-secure-qr-channel.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 876b3899918..fef0fd8fdba 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -670,22 +670,31 @@ payload, **LoginInitiateMessage**, that Device G can use to confirm that the cha - the **sequence token** returned by the homeserver when calling `GET` on the rendezvous session ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) TaggedCiphertext := Context_DeviceS_Send.Seal("MATRIX_QR_CODE_LOGIN_INITIATE", Aad) LoginInitiateMessage := UnpaddedBase64(Sp || TaggedCiphertext) ``` -We define the result of `EncodeStringAsBytes(StringInput)` to be a sequence of bytes: +We define the result of `EncodeStringAsBytes16(StringInput)` to be a sequence of bytes: - two bytes in network byte order (big-endian) indicating the length in bytes of the `StringInput` as a UTF-8 string - the `StringInput` as a UTF-8 string -e.g. `EncodeStringAsBytes("abcdef")` returns `[0x00, 0x06, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66]` +e.g. `EncodeStringAsBytes16("abcdef")` returns `[0x00, 0x06, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66]` -n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceToken` to 255 bytes (according to [opaque identifier grammar]), and that a -`BaseUrl` longer than 65535 bytes will have failed at the point of encoding a QR, we don't specify a handling for +n.b. Because a `BaseUrl` longer than 65535 bytes will have failed at the point of encoding a QR, we don't specify a handling for `StringInput` of length greater than 65535 bytes. +We define the result of `EncodeStringAsBytes8(StringInput)` to be a sequence of bytes: + +- one byte indicating the length in bytes of the `StringInput` as a UTF-8 string +- the `StringInput` as a UTF-8 string + +e.g. `EncodeStringAsBytes8("abcdef")` returns `[0x06, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66]` + +n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceToken` to 255 bytes (according to the +[opaque identifier grammar]) we don't specify a handling for `StringInput` of length greater than 266 bytes. + Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request and noting the new **sequence token**. @@ -714,7 +723,7 @@ with the additional authentication data: It checks that the plaintext matches the string `MATRIX_QR_CODE_LOGIN_INITIATE`, failing and aborting if not. ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) Plaintext := Context_DeviceG_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_INITIATE": @@ -747,7 +756,7 @@ string `MATRIX_QR_CODE_LOGIN_OK` that is sealed with the additional authenticati **sequence token** is the one that was received with the `GET` request that returned **LoginInitiateMessage**: ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) TaggedCiphertext := Context_DeviceG_Send.Seal("MATRIX_QR_CODE_LOGIN_OK", Aad) LoginOkMessage := UnpaddedBase64Encode(ResponseNonce || TaggedCiphertext) ``` @@ -799,7 +808,7 @@ It then verifies the plaintext matches `MATRIX_QR_CODE_LOGIN_OK`, failing otherwise. ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) Plaintext := Context_DeviceS_Receive.Open(TaggedCiphertext, Aad) unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": @@ -850,7 +859,7 @@ sent from S should be encrypted with **Context_DeviceS_Send**. Each call to the additional authentication data of the form where the **sequence token** is from the last `GET` that the device received: ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) ``` Similarly, payloads received by G should be decrypted using the context **Context_DeviceG_Receive**, while payloads received by S @@ -858,7 +867,7 @@ should be decrypted using the context **Context_DeviceG_Receive**. Each call to additional authentication data of the form where the **sequence token** is from the last `PUT` that the device made: ``` -Aad := EncodeStringAsBytes(BaseUrl) || EncodeStringAsBytes(RendezvousId) || EncodeStringAsBytes(SequenceToken) +Aad := EncodeStringAsBytes16(BaseUrl) || EncodeStringAsBytes8(RendezvousId) || EncodeStringAsBytes8(SequenceToken) ``` ### Sequence diagram From 64ff8a1f8633f6ef6f7ef3ed0cb2a8eddbdb4190 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 8 Jun 2026 10:25:27 +0100 Subject: [PATCH 42/47] sequence tokens do not need to be unique to a rendezvous session --- proposals/4388-secure-qr-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index fef0fd8fdba..9e7fbe4adcb 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -414,7 +414,7 @@ The server MUST enforce a maximum `data` field size of 4096 bytes. #### `sequence_token` values -The `sequence_token` values should be unique to the rendezvous session and the last modified time so that two clients can +The `sequence_token` values should be unique to the last modified time so that two clients can distinguish between identical payloads sent by either client. #### Maximum duration of a rendezvous From 3c267fd063090d3e0b260f2046cf13b56766e428 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Jul 2026 16:41:19 +0100 Subject: [PATCH 43/47]
    to heading --- proposals/4388-secure-qr-channel.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 9e7fbe4adcb..de7f0ee121e 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -612,20 +612,20 @@ Participants: Regardless of which device generates the QR code, either device can be the existing (already signed in) device. The other device is then the new device (one seeking to be signed in). -1. **Ephemeral key pair generation** +#### 1. Ephemeral key pair generation Both devices generate an _ephemeral_ Curve25519 key pair: - Device G generates **(Gp, Gs)**, where **Gp** is its public key and **Gs** the private (secret) key. - Device S generates **(Sp, Ss)**, where **Sp** is its public key and **Ss** the private (secret) key. -2. **Create rendezvous session** +#### 2. Create rendezvous session Device G creates a rendezvous session by making a `POST` request (as described previously) to the nominated homeserver with an empty payload. It parses the response from the homeserver to extract the rendezvous session **ID** and **sequence token**. -3. **Initial key exchange** +#### 3. Initial key exchange Device G displays a QR code containing sufficient information for the scanning device to locate the rendezvous session and establish the secure channel. @@ -645,7 +645,7 @@ Device S scans and parses the QR code to obtain **Gp**, the rendezvous session * At this point Device S should check that the received intent matches what the user has asked to do on the device. -4. **Device S sends the initial payload** +#### 4. Device S sends the initial payload Device S performs an ECDH operation using **Ss** and **Gp** to compute the shared secret **SharedSecret**. It then discards **Ss**. @@ -698,7 +698,7 @@ n.b. Because this proposal restricts the length of `RendezvousId` and `SequenceT Device S then sends the **LoginInitiateMessage** as the `data` payload to the rendezvous session using a `PUT` request and noting the new **sequence token**. -5. **Device G confirms** +#### 5. Device G confirms Device G receives **LoginInitiateMessage** (potentially coming from Device S) from the insecure rendezvous session by polling with `GET` requests. @@ -763,7 +763,7 @@ LoginOkMessage := UnpaddedBase64Encode(ResponseNonce || TaggedCiphertext) Device G sends **LoginOkMessage** as the `data` payload via a `PUT` request to the insecure rendezvous session. -6. **Verification by Device S** +#### 6. Verification by Device S > [!TIP] > _A helpful [note](https://github.com/matrix-org/matrix-spec-proposals/pull/4388/changes#r3025116401) from @uhoreg to @@ -828,7 +828,7 @@ Device S then displays an indicator to the user that the secure channel has been should be entered on the other device when prompted. Example wording could say "Secure connection established. Enter the code XY on your other device." -7. **Out-of-band confirmation** +#### 7. Out-of-band confirmation **Warning**: *This step is crucial for the security of the scheme since it overcomes the aforementioned limitation of HPKE.* From 6222c6265c0676183ceef56a2e09de9408af2746 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Jul 2026 16:43:39 +0100 Subject: [PATCH 44/47] MSC4108 doesn't say where the check code is in the flow MSC4388 is authoritative --- proposals/4388-secure-qr-channel.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index de7f0ee121e..17c65d7c190 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -842,8 +842,6 @@ Because the actual value of the code is not significant from a cryptographic poi digits 6, 7, 8 and 9 are slightly less likely to appear. Furthermore we also ensure that the first digit of the code is not `0` to avoid confusion the user might have about whether to enter a leading zero. -The exact points in the flow that the user is prompted for the **CheckCode** is described in [MSC4108]. - Device G compares the code that the user has entered with the **CheckCode** that it calculates using the same mechanism as before: From 95e6b6d228e49835a9bc32e1144debad360c3b68 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Jul 2026 16:45:56 +0100 Subject: [PATCH 45/47] Refactor "secure operations" section --- proposals/4388-secure-qr-channel.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 17c65d7c190..702ac9ab260 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -852,6 +852,11 @@ CheckCode := NumToString((CheckBytes[0] % 9) + 1) || NumToString(CheckBytes[1] % If the code that the user enters matches then the secure channel is established. +### Secure operations + +Conceptually, once established, the secure channel offers two operations, `SecureSend` and `SecureReceive`, which wrap +the `Send` and `Receive` operations offered by the rendezvous session API to securely send and receive data between two devices. + Subsequent payloads sent from G should be encrypted using the context **Context_DeviceG_Send**, while payloads sent from S should be encrypted with **Context_DeviceS_Send**. Each call to the `Seal()` function should use the additional authentication data of the form where the **sequence token** is from the last `GET` that the device received: @@ -931,11 +936,6 @@ sequenceDiagram note over G: If the user enters the correct CheckCode and confirms that a green checkmark is shown then Device G knows that the channel is secure ``` -### Secure operations - -Conceptually, once established, the secure channel offers two operations, `SecureSend` and `SecureReceive`, which wrap -the `Send` and `Receive` operations offered by the rendezvous session API to securely send and receive data between two devices. - ### Threat analysis In an attack scenario, we add a participant called Specter with the following capabilities: From 96239e7933fc88f9284deabd247bfb6ba35a98d1 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 3 Jul 2026 10:40:34 +0100 Subject: [PATCH 46/47] Defer to HPKE for replay protection nonces --- proposals/4388-secure-qr-channel.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index 702ac9ab260..e144521aa9e 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -948,8 +948,9 @@ In an attack scenario, we add a participant called Specter with the following ca Due to use of ephemeral key pairs which are immediately discarded after use, each QR code login session derives a unique secret so payloads from earlier sessions cannot be replayed. Each payload in the session is unique and expected only -once. Finally, replay of the initial message from S to G is prevented by the use of a randomly generated base nonce in -the response from G to S, while deterministic per-message nonces prevent replay of any subsequent messages. +once. Finally, replay of messages within a session is prevented by the sequence-number-derived nonces of the HPKE +encryption contexts, while replay of the initial message from S to G is rendered useless by the randomly generated base +nonce mixed into the response HPKE context derivation. #### Pure Dolev-Yao attacker From 726e115440a9e9125367b2bc23b690e1faec8787 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 3 Jul 2026 11:21:42 +0100 Subject: [PATCH 47/47] Clarifications to sequence_token and recommended implementation --- proposals/4388-secure-qr-channel.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/proposals/4388-secure-qr-channel.md b/proposals/4388-secure-qr-channel.md index e144521aa9e..1c591f4d50a 100644 --- a/proposals/4388-secure-qr-channel.md +++ b/proposals/4388-secure-qr-channel.md @@ -119,7 +119,11 @@ request were being acknowledged again. Any other mismatch of `sequence_token` MU n.b. Once a new payload has been sent there is no mechanism to retrieve previous payloads. -The `sequence_token` must comply with the [opaque identifier grammar]. +The `sequence_token` MUST: + +- comply with the [opaque identifier grammar] +- change for every successful write (even if the `data` is the same as before) so that two clients can + distinguish between identical payloads sent by either client ### Expiry @@ -414,8 +418,11 @@ The server MUST enforce a maximum `data` field size of 4096 bytes. #### `sequence_token` values -The `sequence_token` values should be unique to the last modified time so that two clients can -distinguish between identical payloads sent by either client. +A recommended implementation is a hash/digest of: + +- the rendezvous ID +- a monotonic counter incremented on each successful write +- the last `data` value written #### Maximum duration of a rendezvous