diff --git a/package.json b/package.json index c995d4b0cce..75d0bbd6657 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^18.0.0", + "@matrix-org/matrix-sdk-crypto-wasm": "matrix-org/matrix-sdk-crypto-wasm#poljar/hpke", "another-json": "^0.2.0", "bs58": "^6.0.0", "content-type": "^1.0.4", @@ -122,6 +122,9 @@ "expect": "30.2.0" }, "pnpm": { + "onlyBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-wasm" + ], "peerDependencyRules": { "allowedVersions": { "eslint": "8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c5255452a1..9d381758beb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^7.12.5 version: 7.28.6 '@matrix-org/matrix-sdk-crypto-wasm': - specifier: ^18.0.0 - version: 18.0.0 + specifier: matrix-org/matrix-sdk-crypto-wasm#poljar/hpke + version: https://codeload.github.com/matrix-org/matrix-sdk-crypto-wasm/tar.gz/ae0ee0f3f30776b44d83d9bdcec82a03d7d4d5b3 another-json: specifier: ^0.2.0 version: 0.2.0 @@ -1048,8 +1048,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@matrix-org/matrix-sdk-crypto-wasm@18.0.0': - resolution: {integrity: sha512-88+n+dvxLI1cjS10UIlKXVYK7TGWbpAnnaDC9fow7ch/hCvdu3dFhJ3tS3/13N9s9+1QFXB4FFuommj+tHJPhQ==} + '@matrix-org/matrix-sdk-crypto-wasm@https://codeload.github.com/matrix-org/matrix-sdk-crypto-wasm/tar.gz/ae0ee0f3f30776b44d83d9bdcec82a03d7d4d5b3': + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-sdk-crypto-wasm/tar.gz/ae0ee0f3f30776b44d83d9bdcec82a03d7d4d5b3} + version: 18.0.0 engines: {node: '>= 18'} '@matrix-org/olm@3.2.15': @@ -4797,7 +4798,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@matrix-org/matrix-sdk-crypto-wasm@18.0.0': {} + '@matrix-org/matrix-sdk-crypto-wasm@https://codeload.github.com/matrix-org/matrix-sdk-crypto-wasm/tar.gz/ae0ee0f3f30776b44d83d9bdcec82a03d7d4d5b3': {} '@matrix-org/olm@3.2.15': {} diff --git a/src/oidc/authorize.ts b/src/oidc/authorize.ts index a250b061b89..a2ce763e646 100644 --- a/src/oidc/authorize.ts +++ b/src/oidc/authorize.ts @@ -29,6 +29,7 @@ import { } from "./validate.ts"; import { sha256 } from "../digest.ts"; import { encodeUnpaddedBase64Url } from "../base64.ts"; +import { OAuthGrantType } from "./register.ts"; // reexport for backwards compatibility export type { BearerTokenResponse }; @@ -277,3 +278,98 @@ export const completeAuthorizationCodeGrant = async ( throw new Error(OidcError.CodeExchangeFailed); } }; + +export interface DeviceAccessTokenResponse { + id_token?: string; + access_token: string; + token_type: string; + refresh_token?: string; + scope?: string; + expires_in?: number; + session_state?: string; +} + +export interface DeviceAccessTokenError { + error: string; + error_description?: string; + error_uri?: string; + session_state?: string; +} + +export interface DeviceAuthorizationResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval?: number; +} +export const startDeviceAuthorization = async ({ + clientId, + scope, + metadata, +}: { + clientId: string; + scope: string; + metadata: ValidatedAuthMetadata; +}): Promise => { + const params = new URLSearchParams({ client_id: clientId, scope: scope }); + + const url = metadata.device_authorization_endpoint; + if (!url) { + throw new Error("No device_authorization_endpoint given"); + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + return (await response.json()) as DeviceAuthorizationResponse; +}; + +export const waitForDeviceAuthorization = async ({ + session, + metadata, + clientId, +}: { + session: DeviceAuthorizationResponse; + metadata: ValidatedAuthMetadata; + clientId: string; +}): Promise => { + let interval = (session.interval ?? 5) * 1000; // poll interval + const expiration = Date.now() + session.expires_in * 1000; + do { + const body = new URLSearchParams({ + device_code: session.device_code, + grant_type: OAuthGrantType.DeviceAuthorization, + // TODO: is auth required here? it is optional in RFC8628 + client_id: clientId, + }); + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (response.ok) { + return (await response.json()) as DeviceAccessTokenResponse; + } + const errorResponse = (await response.json()) as DeviceAccessTokenError; + switch (errorResponse.error) { + case "authorization_pending": + break; + case "slow_down": + interval += 5000; + break; + case "access_denied": + case "expired_token": + return errorResponse; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } while (Date.now() < expiration); + return { error: "expired" }; +}; diff --git a/src/oidc/register.ts b/src/oidc/register.ts index b8baa05af27..761eceac7e0 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -111,6 +111,11 @@ export const registerOidcClient = async ( throw new Error(OidcError.DynamicRegistrationNotSupported); } + // ask for device authorization grant if supported + if (delegatedAuthConfig.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) { + grantTypes.push(OAuthGrantType.DeviceAuthorization); + } + const commonBase = new URL(clientMetadata.clientUri); // https://openid.net/specs/openid-connect-registration-1_0.html diff --git a/src/rendezvous/MSC4108v2025SignInWithQR.ts b/src/rendezvous/MSC4108v2025SignInWithQR.ts new file mode 100644 index 00000000000..6a1412bcf6a --- /dev/null +++ b/src/rendezvous/MSC4108v2025SignInWithQR.ts @@ -0,0 +1,618 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm"; + +import { + ClientRendezvousFailureReason, + MSC4108FailureReason, + RendezvousError, + type RendezvousFailureListener, +} from "./index.ts"; +import { type MatrixClient } from "../client.ts"; +import { logger } from "../logger.ts"; +import { MSC4388SecureChannel } from "./channels/MSC4388SecureChannel.ts"; +import { ClientPrefix, MatrixError, Method } from "../http-api/index.ts"; +import { sleep } from "../utils.ts"; +import { + OAuthGrantType, + type ValidatedAuthMetadata, + type OidcClientConfig, + generateScope, + startDeviceAuthorization, + waitForDeviceAuthorization, + type DeviceAccessTokenResponse, + type DeviceAuthorizationResponse, +} from "../oidc/index.ts"; +import { type CryptoApi } from "../crypto-api/index.ts"; +import { MSC4388RendezvousSession } from "./transports/MSC4388RendezvousSession.ts"; + +/** + * Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * secure channels. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. + */ +export enum PayloadType { + Protocols = "m.login.protocols", + Protocol = "m.login.protocol", + Failure = "m.login.failure", + Success = "m.login.success", + Secrets = "m.login.secrets", + ProtocolAccepted = "m.login.protocol_accepted", + Declined = "m.login.declined", +} + +/** + * Type representing the base payload format for [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * messages sent over the secure channel. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. + */ +export interface MSC4108v2025Payload { + type: PayloadType; +} + +interface ProtocolsPayload extends MSC4108v2025Payload { + type: PayloadType.Protocols; + protocols: string[]; + base_url: string; +} + +interface ProtocolPayload extends MSC4108v2025Payload { + type: PayloadType.Protocol; + protocol: Exclude; + device_id: string; +} + +interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload { + protocol: "device_authorization_grant"; + device_authorization_grant: { + verification_uri: string; + verification_uri_complete?: string; + }; +} + +function isDeviceAuthorizationGrantProtocolPayload( + payload: ProtocolPayload, +): payload is DeviceAuthorizationGrantProtocolPayload { + return payload.protocol === "device_authorization_grant"; +} + +interface FailurePayload extends MSC4108v2025Payload { + type: PayloadType.Failure; + reason: MSC4108FailureReason; + homeserver?: string; +} + +interface DeclinedPayload extends MSC4108v2025Payload { + type: PayloadType.Declined; +} + +interface SuccessPayload extends MSC4108v2025Payload { + type: PayloadType.Success; +} + +interface AcceptedPayload extends MSC4108v2025Payload { + type: PayloadType.ProtocolAccepted; +} + +interface SecretsPayload + extends MSC4108v2025Payload, Awaited>> { + type: PayloadType.Secrets; +} + +/** + * Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * sign in with QR + OIDC flow. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. + */ +export class MSC4108v2025SignInWithQR { + private _code?: Uint8Array; + private expectingNewDeviceId?: string; + private metadata?: ValidatedAuthMetadata; + private grantInProgress?: DeviceAuthorizationResponse; + + /** + * Returns the check code for the secure channel or undefined if not generated yet. + */ + public get checkCode(): string | undefined { + return this.channel?.getCheckCode(); + } + + /** + * @param channel - The secure channel used for communication + * @param client - The Matrix client in used on the device already logged in + * @param didScanCode - Whether this side of the channel scanned the QR code from the other party + * @param onFailure - Callback for when the rendezvous fails + */ + public constructor( + private readonly channel: MSC4388SecureChannel, + private readonly didScanCode: boolean, + private readonly client?: MatrixClient, + public onFailure?: RendezvousFailureListener, + ) {} + + /** + * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. + */ + public get code(): Uint8Array | undefined { + return this._code; + } + + /** + * Generate the code including doing partial set up of the channel where required. + */ + public async generateCode(): Promise { + if (this._code) { + return; + } + this._code = await this.channel.generateCode(); + } + + /** + * Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel. + */ + public get isExistingDevice(): boolean { + return this.channel.intent === QrCodeIntent.Reciprocate; + } + + /** + * Returns true if the device is the new device logging in being reciprocated by the device on the other side of the channel. + */ + public get isNewDevice(): boolean { + return !this.isExistingDevice; + } + + /** + * The first step in the OIDC QR login process. + * To be called after the QR code has been rendered or scanned. + * The scanning device has to discover the homeserver details, if they scanned the code then they already have it. + * If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel. + */ + public async negotiateProtocols(): Promise<{ baseUrl?: string }> { + logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`); + await this.channel.connect(); + + if (this.didScanCode) { + // Secure Channel step 6 completed, we trust the channel + + if (this.isNewDevice) { + // MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set + } else { + // MSC4108-Flow: NewScanned -send protocols message + let oidcClientConfig: OidcClientConfig | undefined; + try { + oidcClientConfig = await this.client!.getAuthMetadata(); + } catch (e) { + logger.error("Failed to discover OIDC metadata", e); + } + + if (oidcClientConfig?.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) { + await this.send({ + type: PayloadType.Protocols, + protocols: ["device_authorization_grant"], + base_url: this.client!.baseUrl, + }); + } else { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnsupportedProtocol, + }); + throw new RendezvousError( + "Device code grant unsupported", + MSC4108FailureReason.UnsupportedProtocol, + ); + } + } + } else if (this.isNewDevice) { + // MSC4108-Flow: ExistingScanned - wait for protocols message + logger.info("Waiting for protocols message"); + const payload = await this.receive(); + + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Protocols) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); + } + + return { baseUrl: payload.base_url }; + } else { + // MSC4108-Flow: NewScanned - nothing to do + // Not supported + } + return {}; + } + + /** + * The second & third step in the OIDC QR login process. + * To be called after `negotiateProtocols` for the existing device. + * To be called after OIDC negotiation for the new device. (Currently unsupported) + */ + public async deviceAuthorizationGrant(input?: { + metadata: ValidatedAuthMetadata; + clientId: string; + deviceId: string; + }): Promise<{ + verificationUri?: string; + userCode?: string; + }> { + if (this.isNewDevice) { + if (!input) { + throw new Error("Input must be provided for new device"); + } + + const { metadata, clientId, deviceId } = input; + + const scope = generateScope(deviceId); + + // MSC4108-Flow: NewDevice - start device authorization grant + const dagResponse = await startDeviceAuthorization({ + clientId, + scope, + metadata, + }); + + this.metadata = metadata; + this.grantInProgress = dagResponse; + + const protocol: DeviceAuthorizationGrantProtocolPayload = { + type: PayloadType.Protocol, + protocol: "device_authorization_grant", + device_id: deviceId, + device_authorization_grant: { + verification_uri: dagResponse.verification_uri, + verification_uri_complete: dagResponse.verification_uri_complete, + }, + }; + + await this.send(protocol); + + return { + verificationUri: dagResponse.verification_uri_complete ?? dagResponse.verification_uri, + userCode: dagResponse.user_code, + }; + } else { + // The user needs to do step 7 for the out-of-band confirmation + // but, first we receive the protocol chosen by the other device so that + // the confirmation_uri is ready to go + logger.info("Waiting for protocol message"); + const payload = await this.receive(); + + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Protocol) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); + } + + if (isDeviceAuthorizationGrantProtocolPayload(payload)) { + const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = payload; + const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag; + + let deviceAlreadyExists = true; + try { + await this.client?.getDevice(expectingNewDeviceId); + } catch (err: MatrixError | unknown) { + if (err instanceof MatrixError && err.httpStatus === 404) { + deviceAlreadyExists = false; + } + } + + if (deviceAlreadyExists) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.DeviceAlreadyExists, + }); + throw new RendezvousError( + "Specified device ID already exists", + MSC4108FailureReason.DeviceAlreadyExists, + ); + } + + this.expectingNewDeviceId = expectingNewDeviceId; + + return { verificationUri: verificationUriComplete ?? verificationUri }; + } + + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnsupportedProtocol, + }); + throw new RendezvousError( + "Received a request for an unsupported protocol", + MSC4108FailureReason.UnsupportedProtocol, + ); + } + } + + public async completeLoginOnNewDevice({ + clientId, + }: { + clientId: string; + }): Promise { + if (!this.isNewDevice || !this.grantInProgress || !this.metadata) { + throw new Error("Can only complete login on new device"); + } + + logger.info("Waiting for protocol accepted message"); + // wait for accepted message + const payload = await this.receive(); + + if (!payload) { + throw new RendezvousError( + "No response from existing device", + MSC4108FailureReason.UnexpectedMessageReceived, + ); + } + if (payload.type === PayloadType.Failure) { + throw new RendezvousError("Failed", (payload as FailurePayload).reason); + } + if (payload.type !== PayloadType.ProtocolAccepted) { + throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived); + } + + // poll for DAG + const res = await waitForDeviceAuthorization({ + session: this.grantInProgress, + metadata: this.metadata, + clientId, + }); + + if (!res) { + throw new RendezvousError( + "No response from device authorization endpoint", + ClientRendezvousFailureReason.Unknown, + ); + } + + if ("error" in res) { + let reason: MSC4108FailureReason = MSC4108FailureReason.UnexpectedMessageReceived; + if (res.error === "expired_token") { + reason = MSC4108FailureReason.AuthorizationExpired; + } else if (res.error === "access_denied") { + reason = MSC4108FailureReason.UserCancelled; + } + const payload: FailurePayload = { + type: PayloadType.Failure, + reason, + }; + await this.send(payload); + return undefined; + } + + return res; + } + + /** + * The fifth (and final) step in the OIDC QR login process. + * To be called after the new device has completed authentication. + */ + public async shareSecrets(): Promise<{ secrets?: Omit }> { + if (this.isNewDevice) { + await this.send({ + type: PayloadType.Success, + }); + // then wait for secrets + logger.info("Waiting for secrets message"); + const payload = await this.receive(); + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Secrets) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); + } + return { + secrets: { + cross_signing: payload.cross_signing, + backup: payload.backup, + }, + }; + // then done? + } else { + if (!this.expectingNewDeviceId) { + throw new Error("No new device ID expected"); + } + await this.send({ + type: PayloadType.ProtocolAccepted, + }); + + logger.info("Waiting for outcome message"); + const payload = await this.receive(); + + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type === PayloadType.Declined) { + throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined); + } + + if (payload?.type !== PayloadType.Success) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived); + } + + const timeout = Date.now() + 10000; // wait up to 10 seconds + do { + // is the device visible via the Homeserver? + try { + const device = await this.client?.getDevice(this.expectingNewDeviceId); + + if (device) { + // if so, return the secrets + const secretsBundle = await this.client!.getCrypto()!.exportSecretsBundle!(); + if (this.channel.cancelled) { + throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled); + } + // send secrets + await this.send({ + type: PayloadType.Secrets, + ...secretsBundle, + }); + return { secrets: secretsBundle }; + // let the other side close the rendezvous session + } + } catch (err: MatrixError | unknown) { + if (err instanceof MatrixError && err.httpStatus === 404) { + // not found, so keep waiting until timeout + } else { + throw err; + } + } + await sleep(1000); + } while (Date.now() < timeout); + + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.DeviceNotFound, + }); + throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound); + } + } + + private async receive(): Promise { + return (await this.channel.secureReceive()) as T | undefined; + } + + private async send(payload: T): Promise { + await this.channel.secureSend(payload); + } + + /** + * Decline the login on the existing device. + */ + public async declineLoginOnExistingDevice(): Promise { + if (!this.isExistingDevice) { + throw new Error("Can only decline login on existing device"); + } + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UserCancelled, + }); + } + + /** + * Cancels the rendezvous session. + * @param reason the reason for the cancellation + */ + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + + /** + * Closes the rendezvous session. + */ + public async close(): Promise { + await this.channel.close(); + } +} + +export async function isSignInWithQRAvailable(client: MatrixClient): Promise { + // check for support of device authoriation grant + const metadata = await client.getAuthMetadata(); + if (!metadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) { + return false; + } + + // check for support of MSC4388 rendezvous endpoint + try { + // 200 OK means supported + const discoveryBody = await client.http.authedRequest<{ create_available: boolean }>( + Method.Get, + "/io.element.msc4388/rendezvous", + undefined, + undefined, + { + prefix: ClientPrefix.Unstable, + }, + ); + + return typeof discoveryBody.create_available === "boolean" ? discoveryBody.create_available : false; + } catch (e) { + // 404 and 403 are expected + if (e instanceof MatrixError && (e.httpStatus === 404 || e.httpStatus === 403)) { + return false; + } + + throw e; + } +} + +export async function linkNewDeviceByGeneratingQR( + client: MatrixClient, + onFailure: RendezvousFailureListener, +): Promise { + const session = new MSC4388RendezvousSession({ + onFailure, + client, + }); + await session.send(""); + const channel = new MSC4388SecureChannel(session, QrCodeIntent.Reciprocate, onFailure); + const flow = new MSC4108v2025SignInWithQR(channel, false, client, onFailure); + + await flow.generateCode(); + + return flow; +} + +export async function signInByGeneratingQR( + tempClient: MatrixClient, + onFailure: RendezvousFailureListener, +): Promise { + // ensure rust crypto is initialized as needed for the secure channel + const RustSdkCryptoJs = await import("@matrix-org/matrix-sdk-crypto-wasm"); + await RustSdkCryptoJs.initAsync(); + + const session = new MSC4388RendezvousSession({ + onFailure, + client: tempClient, + }); + await session.send(""); + const channel = new MSC4388SecureChannel(session, QrCodeIntent.Login, onFailure); + const flow = new MSC4108v2025SignInWithQR(channel, false, tempClient, onFailure); + + await flow.generateCode(); + + return flow; +} diff --git a/src/rendezvous/channels/MSC4388SecureChannel.ts b/src/rendezvous/channels/MSC4388SecureChannel.ts new file mode 100644 index 00000000000..7425c2f5ec8 --- /dev/null +++ b/src/rendezvous/channels/MSC4388SecureChannel.ts @@ -0,0 +1,214 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + HpkeRecipientChannel, + type EstablishedHpkeChannel, + QrCodeData, + type QrCodeIntent, +} from "@matrix-org/matrix-sdk-crypto-wasm"; + +import { + ClientRendezvousFailureReason, + type MSC4108FailureReason, + RendezvousError, + type RendezvousFailureListener, +} from "../index.ts"; +import { type MSC4388RendezvousSession } from "../transports/MSC4388RendezvousSession.ts"; +import { logger } from "../../logger.ts"; +import { type MSC4108v2025Payload } from "../MSC4108v2025SignInWithQR.ts"; + +/** + * Prototype of the unstable [MSC4388](https://github.com/matrix-org/matrix-spec-proposals/pull/4388) + * secure rendezvous session protocol. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. + * Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle. + */ +export class MSC4388SecureChannel { + private readonly recipientChannel: HpkeRecipientChannel; + private establishedChannel?: EstablishedHpkeChannel; + private connected = false; + + public constructor( + private readonly rendezvousSession: MSC4388RendezvousSession, + public readonly intent: QrCodeIntent, + public onFailure?: RendezvousFailureListener, + ) { + this.recipientChannel = new HpkeRecipientChannel(); + } + + /** + * Generate a QR code for the current session. + * @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`. + */ + public async generateCode(): Promise { + const { id, baseUrl } = this.rendezvousSession; + + if (!id) { + throw new Error("No rendezvous session ID"); + } + + if (!baseUrl) { + throw new Error("No rendezvous session base URL"); + } + return QrCodeData.newMsc4388(this.recipientChannel.publicKey, id, baseUrl, this.intent).toBytes(); + } + + /** + * Returns the check code for the secure channel or undefined if not generated yet. + */ + public getCheckCode(): string | undefined { + const x = this.establishedChannel?.checkCode; + + if (!x) { + return undefined; + } + + // in this version of the MSC the is never a leading zero + return String(x.to_digit()); + } + + /** + * Connects and establishes a secure channel with the other device. + */ + public async connect(): Promise { + if (this.connected) { + throw new Error("Channel already connected"); + } + + // We are device G: the generating device + + // wait for the other side to send us their public key + logger.info("Waiting for LoginInitiateMessage"); + const loginInitiateMessage = await this.rendezvousSession.receive(); + if (!loginInitiateMessage) { + throw new Error("No response from other device"); + } + + logger.info("Received LoginInitiateMessage"); + + const { channel: unidirectionalChannel, message: candidateLoginInitiateMessage } = + this.recipientChannel.establishChannel( + loginInitiateMessage, + this.rendezvousSession.getAdditionalAuthenticationDataForReceive(), + ); + + // Verify LoginInitiateMessage + if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") { + throw new RendezvousError( + "Invalid response from other device", + ClientRendezvousFailureReason.InsecureChannelDetected, + ); + } + logger.info("LoginInitiateMessage received"); + + logger.info("Sending LoginOkMessage"); + const { channel, initialResponse: loginOkMessage } = unidirectionalChannel.establishBidirectionalChannel( + "MATRIX_QR_CODE_LOGIN_OK", + this.rendezvousSession.getAdditionalAuthenticationDataForSend(), + ); + + await this.rendezvousSession.send(loginOkMessage); + + this.establishedChannel = channel; + + // Step 5 is complete. We, device G, don't yet trust the channel + + // next step will be for the user to confirm the check code on the other device + + this.connected = true; + } + + private async decrypt(ciphertext: string): Promise { + if (!this.establishedChannel) { + throw new Error("Channel closed"); + } + + return this.establishedChannel.open( + ciphertext, + this.rendezvousSession.getAdditionalAuthenticationDataForReceive(), + ); + } + + private async encrypt(plaintext: string): Promise { + if (!this.establishedChannel) { + throw new Error("Channel closed"); + } + + return this.establishedChannel.seal(plaintext, this.rendezvousSession.getAdditionalAuthenticationDataForSend()); + } + + /** + * Sends a payload securely to the other device. + * @param payload the payload to encrypt and send + */ + public async secureSend(payload: T): Promise { + if (!this.connected) { + throw new Error("Channel closed"); + } + + const stringifiedPayload = JSON.stringify(payload); + logger.debug(`=> {"type": ${JSON.stringify(payload.type)}, ...}`); + + await this.rendezvousSession.send(await this.encrypt(stringifiedPayload)); + } + + /** + * Receives an encrypted payload from the other device and decrypts it. + */ + public async secureReceive(): Promise | undefined> { + if (!this.establishedChannel) { + throw new Error("Channel closed"); + } + + const ciphertext = await this.rendezvousSession.receive(); + if (!ciphertext) { + return undefined; + } + const plaintext = await this.decrypt(ciphertext); + const json = JSON.parse(plaintext); + + logger.debug(`<= {"type": ${JSON.stringify(json.type)}, ...}`); + return json as Partial | undefined; + } + + /** + * Closes the secure channel. + */ + public async close(): Promise { + await this.rendezvousSession.close(); + } + + /** + * Cancels the secure channel. + * @param reason the reason for the cancellation + */ + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { + try { + await this.rendezvousSession.cancel(reason); + this.onFailure?.(reason); + } finally { + await this.close(); + } + } + + /** + * Returns whether the rendezvous session has been cancelled. + */ + public get cancelled(): boolean { + return this.rendezvousSession.cancelled; + } +} diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 498f060e33e..25487f187a8 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -23,3 +23,9 @@ export * from "./RendezvousIntent.ts"; export type * from "./RendezvousTransport.ts"; export * from "./transports/index.ts"; export * from "./channels/index.ts"; +export { + MSC4108v2025SignInWithQR, + isSignInWithQRAvailable, + linkNewDeviceByGeneratingQR, + signInByGeneratingQR, +} from "./MSC4108v2025SignInWithQR.ts"; diff --git a/src/rendezvous/transports/MSC4388RendezvousSession.ts b/src/rendezvous/transports/MSC4388RendezvousSession.ts new file mode 100644 index 00000000000..3d151378b38 --- /dev/null +++ b/src/rendezvous/transports/MSC4388RendezvousSession.ts @@ -0,0 +1,260 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "../../logger.ts"; +import { sleep } from "../../utils.ts"; +import { ClientRendezvousFailureReason, MSC4108FailureReason, type RendezvousFailureListener } from "../index.ts"; +import { type MatrixClient, Method } from "../../matrix.ts"; +import { ClientPrefix, MatrixError } from "../../http-api/index.ts"; + +const API_PREFIX = "/io.element.msc4388/rendezvous"; + +/** + * Prototype of the unstable [MSC4388](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * insecure rendezvous session protocol. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. + */ +export class MSC4388RendezvousSession { + /** + * The rendezvous session ID. + */ + public id?: string; + private readonly client: MatrixClient; + private readonly onFailure?: RendezvousFailureListener; + private sequenceToken?: string; + private lastSequenceTokenSent?: string; + private lastSequenceTokenReceived?: string; + private expiresAt?: Date; + private expiresTimer?: ReturnType; + private _cancelled = false; + private _ready = false; + + /** + * The server base URL for client-server connections. + */ + public readonly baseUrl: string; + + /** + * For use when you are wishing to generate a QR code. The client may be authenticated or not. + */ + public constructor({ onFailure, client }: { onFailure?: RendezvousFailureListener; client: MatrixClient }) { + this.onFailure = onFailure; + this.client = client; + // we parse to a URL to get consistency of / at end of it + this.baseUrl = new URL(client.baseUrl).href; + } + + /** + * Returns whether the channel is ready to be used. + */ + public get ready(): boolean { + return this._ready; + } + + /** + * Returns whether the channel has been cancelled. + */ + public get cancelled(): boolean { + return this._cancelled; + } + + /** + * Sends data via the rendezvous channel. + * @param data the payload to send + */ + public async send(data: string): Promise { + if (this._cancelled) { + return; + } + const requestBody: { + data: string; + sequence_token?: string; + } = { data }; + + if (this.sequenceToken) { + requestBody.sequence_token = this.sequenceToken; + } + + try { + const responseBody = await this.client.http.authedRequest<{ + id?: string; + sequence_token: string; + expires_in_ms: number; + }>( + this.id ? Method.Put : Method.Post, + this.id ? `${API_PREFIX}/${this.id}` : API_PREFIX, + undefined, + requestBody, + { + prefix: ClientPrefix.Unstable, + }, + ); + + // irrespective of whether we created the rendezvous channel, store the sequence token + this.sequenceToken = responseBody.sequence_token; + this.lastSequenceTokenSent = this.sequenceToken; + + logger.info(`Received new sequence_token after send: ${this.sequenceToken}`); + + if (!this.id) { + const { expires_in_ms: expiresInMs, id } = responseBody; + if (typeof expiresInMs !== "number") { + throw new Error("No rendezvous expiry given"); + } + if (typeof id !== "string") { + throw new Error("No rendezvous ID given"); + } + + // set up expiry timer + if (this.expiresTimer) { + clearTimeout(this.expiresTimer); + this.expiresTimer = undefined; + } + this.expiresAt = new Date(Date.now() + expiresInMs); + this.expiresTimer = setTimeout(() => { + this.expiresTimer = undefined; + this.cancel(ClientRendezvousFailureReason.Expired); + }, this.expiresAt.getTime() - Date.now()); + + // store session details: + this.id = id; + + this._ready = true; + } + } catch (e) { + if (e instanceof MatrixError) { + if (e.httpStatus === 404) { + return this.cancel(ClientRendezvousFailureReason.Unknown); + } + if (e.httpStatus === 409) { + logger.error("Concurrent write detected"); + return this.cancel(ClientRendezvousFailureReason.Unknown); + } + } + } + } + + /** + * Receives data from the rendezvous channel. + * @return the returned promise won't resolve until new data is acquired or the channel is closed either by the server or the other party. + */ + public async receive(): Promise { + if (!this.id) { + throw new Error("Rendezvous not set up"); + } + // eslint-disable-next-line no-constant-condition + while (true) { + if (this._cancelled) { + return undefined; + } + + try { + const body = await this.client.http.request<{ data?: string; sequence_token?: string }>( + Method.Get, + `${API_PREFIX}/${this.id}`, + undefined, + undefined, + { + prefix: ClientPrefix.Unstable, + }, + ); + + // rely on server expiring the channel rather than checking ourselves + + if (!body.sequence_token) { + logger.error("No sequence_token in response"); + await this.cancel(ClientRendezvousFailureReason.Unknown); + return undefined; + } + + if (typeof body.data !== "string") { + logger.error("No data in response"); + await this.cancel(ClientRendezvousFailureReason.Unknown); + return undefined; + } + + if (body.sequence_token !== this.sequenceToken) { + // we have new data + this.sequenceToken = body.sequence_token; + this.lastSequenceTokenReceived = this.sequenceToken; + logger.info(`Received: ${body.data} with sequence_token ${this.sequenceToken}`); + return body.data; + } + await sleep(1000); + } catch (e) { + if (e instanceof MatrixError) { + if (e.httpStatus === 404) { + await this.cancel(ClientRendezvousFailureReason.Unknown); + return undefined; + } + } + } + } + } + + /** + * Cancels the rendezvous channel. + * If the reason is user_declined or user_cancelled then the channel will also be closed. + * @param reason the reason to cancel with + */ + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { + if (this._cancelled) return; + if (this.expiresTimer) { + clearTimeout(this.expiresTimer); + this.expiresTimer = undefined; + } + + if ( + reason === ClientRendezvousFailureReason.Unknown && + this.expiresAt && + this.expiresAt.getTime() < Date.now() + ) { + reason = ClientRendezvousFailureReason.Expired; + } + + this._cancelled = true; + this._ready = false; + this.onFailure?.(reason); + + if (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) { + await this.close(); + } + } + + /** + * Closes the rendezvous channel. + */ + public async close(): Promise { + if (this.expiresTimer) { + clearTimeout(this.expiresTimer); + this.expiresTimer = undefined; + } + } + + public getAdditionalAuthenticationDataForSend(): string { + if (!this.baseUrl || !this.id || !this.lastSequenceTokenReceived) { + throw new Error("Rendezvous session not ready"); + } + return this.baseUrl + this.id + this.lastSequenceTokenReceived; + } + + public getAdditionalAuthenticationDataForReceive(): string { + if (!this.baseUrl || !this.id || !this.lastSequenceTokenSent) { + throw new Error("Rendezvous session not ready"); + } + return this.baseUrl + this.id + this.lastSequenceTokenSent; + } +}