From 151349fe4f355c42f5b020fa07d5eadfcac11cdf Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 14 Mar 2024 11:32:03 +0000 Subject: [PATCH 01/81] Nonce is optional or not present in the id_token depending on the grant type --- src/oidc/validate.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 38d730ba45d..40f45ad6cda 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -182,11 +182,17 @@ const decodeIdToken = (token: string): IdTokenClaims => { * @param nonce - nonce used in the authentication request * @throws when id token is invalid */ -export const validateIdToken = (idToken: string | undefined, issuer: string, clientId: string, nonce: string): void => { +export const validateIdToken = ( + idToken: string | undefined, + issuer: string, + clientId: string, + nonce: string | undefined, +): IdTokenClaims => { try { if (!idToken) { throw new Error("No ID token"); } + const claims = decodeIdToken(idToken); // The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim. @@ -207,7 +213,7 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli * If a nonce value was sent in the Authentication Request, a nonce Claim MUST be present and its value checked * to verify that it is the same value as the one that was sent in the Authentication Request. */ - if (claims.nonce !== nonce) { + if (nonce && claims.nonce !== nonce) { throw new Error("Invalid nonce"); } @@ -218,6 +224,8 @@ export const validateIdToken = (idToken: string | undefined, issuer: string, cli if (!claims.exp || Date.now() > claims.exp * 1000) { throw new Error("Invalid expiry"); } + + return claims; } catch (error) { logger.error("Invalid ID token", error); throw new Error(OidcError.InvalidIdToken); From 9f8b29d03f9cc27e38316f50c7518538b7c5af1d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 14 Mar 2024 11:44:28 +0000 Subject: [PATCH 02/81] Prototype for MSC4108 Known limitations of the prototype are marked with PROTOTYPE comments --- package.json | 3 +- src/crypto-api.ts | 17 + src/crypto/index.ts | 14 + src/crypto/verification/QRCode.ts | 114 +++++- src/oidc/register.ts | 3 +- src/oidc/validate.ts | 1 + src/rendezvous/MSC4108SignInWithQR.ts | 383 ++++++++++++++++++ src/rendezvous/RendezvousFailureReason.ts | 1 + .../channels/MSC4108SecureChannel.ts | 340 ++++++++++++++++ src/rendezvous/channels/index.ts | 2 +- src/rendezvous/index.ts | 59 ++- .../MSC3886SimpleHttpRendezvousTransport.ts | 37 +- .../transports/MSC4108RendezvousSession.ts | 206 ++++++++++ src/rendezvous/transports/index.ts | 2 +- src/rust-crypto/rust-crypto.ts | 43 ++ yarn.lock | 10 +- 16 files changed, 1216 insertions(+), 19 deletions(-) create mode 100644 src/rendezvous/MSC4108SignInWithQR.ts create mode 100644 src/rendezvous/channels/MSC4108SecureChannel.ts create mode 100644 src/rendezvous/transports/MSC4108RendezvousSession.ts diff --git a/package.json b/package.json index 49a1cb9adf7..ee4f0366bb7 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/matrix-sdk-crypto-wasm": "^4.6.0", + "@noble/ciphers": "^0.5.1", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", @@ -60,7 +61,7 @@ "loglevel": "^1.7.1", "matrix-events-sdk": "0.0.1", "matrix-widget-api": "^1.6.0", - "oidc-client-ts": "^3.0.1", + "oidc-client-ts": "github:hughns/oidc-client-ts#hughns/device-flow", "p-retry": "4", "sdp-transform": "^2.14.1", "unhomoglyph": "^1.0.6", diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 13d94a29599..a4b4b95056d 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -24,12 +24,29 @@ import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/key import { ISignatures } from "./@types/signed"; import { MatrixEvent } from "./models/event"; +export interface QRSecretsBundle { + cross_signing?: { + master_key: string; + self_signing_key: string; + user_signing_key: string; + }; + backup?: { + algorithm: string; + key: string; + backup_version: string; + }; +} + /** * Public interface to the cryptography parts of the js-sdk * * @remarks Currently, this is a work-in-progress. In time, more methods will be added here. */ export interface CryptoApi { + exportSecretsForQRLogin(): Promise; + + importSecretsForQRLogin(secrets: QRSecretsBundle): Promise; + /** * Global override for whether the client should ever send encrypted * messages to unverified devices. This provides the default for rooms which diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 0d8f057eabc..18b9ab5f73f 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -580,6 +580,20 @@ export class Crypto extends TypedEventEmitter { + throw new Error("Method not implemented."); + } + + public async importSecretsForQRLogin(secrets: { + cross_signing?: { master_key: string; self_signing_key: string; user_signing_key: string } | undefined; + backup?: { algorithm: string; key: string; backup_version: string } | undefined; + }): Promise { + throw new Error("Method not implemented."); + } + /** * Initialise the crypto module so that it is ready for use * diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index b4e43252eaf..616b3f6835a 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -28,6 +28,7 @@ import { MatrixClient } from "../../client"; import { IVerificationChannel } from "./request/Channel"; import { MatrixEvent } from "../../models/event"; import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification"; +import { RendezvousIntent } from "../../rendezvous"; export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; @@ -134,18 +135,34 @@ enum Mode { VerifyOtherUser = 0x00, // Verifying someone who isn't us VerifySelfTrusted = 0x01, // We trust the master key VerifySelfUntrusted = 0x02, // We do not trust the master key + LoginInitiate = 0x03, // a new device wishing to initiate a login and self-verify + LoginReciprocate = 0x04, //an existing device wishing to reciprocate the login of a new device and self-verify that other device } -interface IQrData { +export type IQrData = VerificationQrData | LoginQrData; + +interface IBaseQrData { prefix: string; version: number; mode: Mode; +} + +interface VerificationQrData extends IBaseQrData { transactionId?: string; firstKeyB64: string; secondKeyB64: string; secretB64: string; } +export interface LoginQrData extends IBaseQrData { + prefix: typeof BINARY_PREFIX; + version: 2; + mode: Mode.LoginInitiate | Mode.LoginReciprocate; + ephemeralPublicKey: string; + rendezvousSessionUrl: string; + homeserverBaseUrl?: string; +} + export class QRCodeData { public constructor( public readonly mode: Mode, @@ -300,11 +317,98 @@ export class QRCodeData { appendStr(qrData.prefix, "ascii", false); appendByte(qrData.version); appendByte(qrData.mode); - appendStr(qrData.transactionId!, "utf-8"); - appendEncBase64(qrData.firstKeyB64); - appendEncBase64(qrData.secondKeyB64); - appendEncBase64(qrData.secretB64); + if ("firstKeyB64" in qrData) { + appendStr(qrData.transactionId!, "utf-8"); + appendEncBase64(qrData.firstKeyB64); + appendEncBase64(qrData.secondKeyB64); + appendEncBase64(qrData.secretB64); + } else if ("ephemeralPublicKey" in qrData) { + // PROTOTYPE: this is actually 65 bytes not 32 due to it currently using P-256 not Curve25519 + appendEncBase64(qrData.ephemeralPublicKey); + appendStr(qrData.rendezvousSessionUrl, "utf-8"); + if (qrData.homeserverBaseUrl && qrData.mode === Mode.LoginReciprocate) { + appendStr(qrData.homeserverBaseUrl, "utf-8"); + } + } return buf; } + + public static async createForRendezvous( + intent: RendezvousIntent, + publicKey: CryptoKey, + rendezvousSessionUrl: string, + homeserverBaseUrl?: string, + ): Promise { + const rawPublicKey = await global.crypto.subtle.exportKey("raw", publicKey); + const ephemeralPublicKey = encodeUnpaddedBase64(rawPublicKey); + const qrData: LoginQrData = { + prefix: BINARY_PREFIX, + version: CODE_VERSION, + mode: intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? Mode.LoginInitiate : Mode.LoginReciprocate, + ephemeralPublicKey, + rendezvousSessionUrl, + homeserverBaseUrl, + }; + + return QRCodeData.generateBuffer(qrData); + } + + public static async parseForRendezvous(buffer: Buffer): Promise<{ + intent: RendezvousIntent; + publicKey: CryptoKey; + rendezvousSessionUrl: string; + homeserverBaseUrl?: string; + }> { + let offset = 0; + + if (buffer.toString("ascii", offset, 6) !== BINARY_PREFIX) { + throw new Error("QR code does not have the expected prefix"); + } + offset += 6; + + if (buffer.readUInt8(offset) !== CODE_VERSION) { + throw new Error("QR code has an unsupported version"); + } + offset += 1; + + const mode = buffer.readUInt8(offset); + offset += 1; + + if (mode === Mode.LoginInitiate || mode === Mode.LoginReciprocate) { + const ephemeralPublicKey = buffer.slice(offset, offset + 65); // PROTOTYPE: this should be 32, but it's currently using P-256 not Curve25519 + offset += 65; // PROTOTYPE: this should be 32, but it's currently using P-256 not Curve25519 + + const rendezvousSessionUrlLen = buffer.readUInt16BE(offset); + offset += 2; + const rendezvousSessionUrl = buffer.toString("utf-8", offset, offset + rendezvousSessionUrlLen); + offset += rendezvousSessionUrlLen; + + let homeserverBaseUrl: string | undefined; + if (mode === Mode.LoginReciprocate) { + const homeserverBaseUrlLen = buffer.readUInt16BE(offset); + offset += 2; + homeserverBaseUrl = buffer.toString("utf-8", offset, offset + homeserverBaseUrlLen); + offset += homeserverBaseUrlLen; + } + + return { + intent: + mode === Mode.LoginInitiate + ? RendezvousIntent.LOGIN_ON_NEW_DEVICE + : RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + publicKey: await global.crypto.subtle.importKey( + "raw", + ephemeralPublicKey, + { name: "ECDH", namedCurve: "P-256" }, // PROTOTYPE: should use Curve25519 + true, + [], + ), + rendezvousSessionUrl, + homeserverBaseUrl, + }; + } + + throw new Error("QR code has an unsupported mode"); + } } diff --git a/src/oidc/register.ts b/src/oidc/register.ts index 6e4948f5065..eaed61261cf 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -62,11 +62,12 @@ const doRegistration = async ( clientMetadata: OidcRegistrationClientMetadata, ): Promise => { // https://openid.net/specs/openid-connect-registration-1_0.html + // PROTOTYPE: should check which scopes are supported by the OP const metadata: OidcRegistrationRequestBody = { client_name: clientMetadata.clientName, client_uri: clientMetadata.clientUri, response_types: ["code"], - grant_types: ["authorization_code", "refresh_token"], + grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], redirect_uris: clientMetadata.redirectUris, id_token_signed_response_alg: "RS256", token_endpoint_auth_method: "none", diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 40f45ad6cda..62e632b24bc 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -124,6 +124,7 @@ export type ValidatedIssuerMetadata = Partial & | "response_types_supported" | "grant_types_supported" | "code_challenge_methods_supported" + | "device_authorization_endpoint" // PROTOTYPE: this is actually optional, but the typings are wrong > & { // MSC2965 extensions to the OIDC spec account_management_uri?: string; diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts new file mode 100644 index 00000000000..dfda14bc25e --- /dev/null +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -0,0 +1,383 @@ +/* +Copyright 2022 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 { DeviceAuthorizationResponse, OidcClient, DeviceAccessTokenResponse } from "oidc-client-ts"; + +import { RendezvousError, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { MatrixClient } from "../client"; +import { logger } from "../logger"; +import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; +import { QRSecretsBundle } from "../crypto-api"; + +export enum PayloadType { + Protocols = "m.login.protocols", + Protocol = "m.login.protocol", + Failure = "m.login.failure", + Success = "m.login.success", + Secrets = "m.login.secrets", + Accepted = "m.login.accepted", +} + +export interface MSC4108Payload { + type: PayloadType; +} + +interface ProtocolsPayload extends MSC4108Payload { + type: PayloadType.Protocols; + protocols: string[]; + homeserver: string; +} + +interface ProtocolPayload extends MSC4108Payload { + type: PayloadType.Protocol; + protocol: string; +} + +interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload { + protocol: "device_authorization_grant"; + device_authorization_grant: { + verification_uri: string; + verification_uri_complete?: string; + }; +} + +interface FailurePayload extends MSC4108Payload { + type: PayloadType.Failure; + reason: RendezvousFailureReason; + homeserver?: string; +} + +interface SuccessPayload extends MSC4108Payload { + type: PayloadType.Success; + device_id: string; +} + +interface AcceptedPayload extends MSC4108Payload { + type: PayloadType.Accepted; +} + +interface SecretsPayload extends MSC4108Payload { + type: PayloadType.Secrets; + cross_signing?: { + master_key: string; + self_signing_key: string; + user_signing_key: string; + }; + backup?: { + algorithm: string; + key: string; + backup_version: string; + }; +} + +export class MSC4108SignInWithQR { + private ourIntent: RendezvousIntent; + private _code?: Buffer; + public protocol?: string; + private oidcClient?: OidcClient; + private deviceAuthorizationResponse?: DeviceAuthorizationResponse; + + /** + * @param channel - The secure channel used for communication + * @param client - The Matrix client in used on the device already logged in + * @param onFailure - Callback for when the rendezvous fails + */ + public constructor( + private channel: MSC4108SecureChannel, + private didScanCode: boolean, + private client?: MatrixClient, + public onFailure?: RendezvousFailureListener, + ) { + this.ourIntent = client + ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE + : RendezvousIntent.LOGIN_ON_NEW_DEVICE; + } + + /** + * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. + */ + public get code(): Buffer | 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(this.ourIntent, this.client?.getHomeserverUrl()); + } + + public get isExistingDevice(): boolean { + return this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + } + + public get isNewDevice(): boolean { + return !this.isExistingDevice; + } + + public async loginStep1(): Promise<{ homeserverBaseUrl?: string }> { + logger.info(`loginStep1(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) { + // take homeserver from QR code which should already be set + } else { + // send protocols message + // PROTOTYPE: we should be checking that the advertised protocol is available + const protocols: ProtocolsPayload = { + type: PayloadType.Protocols, + protocols: ["device_authorization_grant"], + homeserver: this.client?.getHomeserverUrl() ?? "", + }; + await this.send(protocols); + } + } else { + if (this.isNewDevice) { + // wait for protocols message + logger.info("Waiting for protocols message"); + const message = await this.receive(); + if (message?.type !== PayloadType.Protocols) { + throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); + } + const protocolsMessage = message as ProtocolsPayload; + return { homeserverBaseUrl: protocolsMessage.homeserver }; + } else { + // nothing to do + } + } + return {}; + } + + public async loginStep2(oidcClient: OidcClient): Promise { + if (this.isExistingDevice) { + throw new Error("loginStep2OnNewDevice() is not valid for existing devices"); + } + logger.info("loginStep2()"); + + this.oidcClient = oidcClient; + // do device grant + this.deviceAuthorizationResponse = await oidcClient.startDeviceAuthorization({}); + } + + public async loginStep3(): Promise<{ + verificationUri?: string; + userCode?: string; + }> { + if (this.isNewDevice) { + if (!this.deviceAuthorizationResponse) { + throw new Error("No device authorization response"); + } + + const { + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + user_code: userCode, + } = this.deviceAuthorizationResponse; + // send mock for now, should be using values from step 2: + const protocol: DeviceAuthorizationGrantProtocolPayload = { + type: PayloadType.Protocol, + protocol: "device_authorization_grant", + device_authorization_grant: { + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + }, + }; + if (this.didScanCode) { + // send immediately + await this.send(protocol); + } else { + // we will send it later + } + + return { userCode: userCode }; + } 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 message = await this.receive(); + + if (message && message.type === PayloadType.Protocol) { + const protocolMessage = message as ProtocolPayload; + if (protocolMessage.protocol === "device_authorization_grant") { + const { device_authorization_grant: dag } = + protocolMessage as DeviceAuthorizationGrantProtocolPayload; + const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = + dag; + return { verificationUri: verificationUriComplete ?? verificationUri }; + } + } + + throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnsupportedAlgorithm); + } + } + + public async loginStep4(): Promise { + if (this.isExistingDevice) { + throw new Error("loginStep4() is not valid for existing devices"); + } + + logger.info("loginStep4()"); + + if (this.didScanCode) { + // we already sent the protocol message + } else { + // send it now + if (!this.deviceAuthorizationResponse) { + throw new Error("No device authorization response"); + } + const protocol: DeviceAuthorizationGrantProtocolPayload = { + type: PayloadType.Protocol, + protocol: "device_authorization_grant", + device_authorization_grant: { + verification_uri: this.deviceAuthorizationResponse.verification_uri, + verification_uri_complete: this.deviceAuthorizationResponse.verification_uri_complete, + }, + }; + await this.send(protocol); + } + + // wait for accepted message + const message = await this.receive(); + + if (message.type === PayloadType.Failure) { + throw new RendezvousError("Failed", (message as FailurePayload).reason); + } + if (message.type !== PayloadType.Accepted) { + throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); + } + + if (!this.deviceAuthorizationResponse) { + throw new Error("No device authorization response"); + } + if (!this.oidcClient) { + throw new Error("No oidc client"); + } + // poll for DAG + const res = await this.oidcClient.waitForDeviceAuthorization(this.deviceAuthorizationResponse); + + if (!res) { + throw new RendezvousError( + "No response from device authorization endpoint", + RendezvousFailureReason.UnexpectedMessage, + ); + } + + if ("error" in res) { + let reason = RendezvousFailureReason.Unknown; + if (res.error === "expired_token") { + reason = RendezvousFailureReason.Expired; + } else if (res.error === "access_denied") { + reason = RendezvousFailureReason.UserDeclined; + } + const payload: FailurePayload = { + type: PayloadType.Failure, + reason, + }; + await this.send(payload); + } + + return res as DeviceAccessTokenResponse; + } + + public async loginStep5(deviceId?: string): Promise<{ secrets?: QRSecretsBundle }> { + logger.info("loginStep5()"); + + if (this.isNewDevice) { + if (!deviceId) { + throw new Error("No new device id"); + } + const payload: SuccessPayload = { + type: PayloadType.Success, + device_id: deviceId, + }; + await this.send(payload); + // then wait for secrets + logger.info("Waiting for secrets message"); + const secrets = (await this.receive()) as SecretsPayload; + if (secrets.type !== PayloadType.Secrets) { + throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); + } + return { secrets }; + // then done? + } else { + const payload: AcceptedPayload = { + type: PayloadType.Accepted, + }; + await this.send(payload); + + logger.info("Waiting for outcome message"); + const res = await this.receive(); + + if (res.type === PayloadType.Failure) { + const { reason } = res as FailurePayload; + throw new RendezvousError("Failed", reason); + } + + if (res.type != PayloadType.Success) { + throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); + } + + const { device_id: deviceId } = res as SuccessPayload; + + // PROTOTYPE: we should be validating that the device on the other end of the rendezvous did actually successfully authenticate as this device once we decide how that should be done + + const availableSecrets = (await this.client?.getCrypto()?.exportSecretsForQRLogin()) ?? {}; + // send secrets + const secrets: SecretsPayload = { + type: PayloadType.Secrets, + ...availableSecrets, + }; + await this.send(secrets); + return {}; + // done? + // let the other side close the rendezvous session + } + } + + private async receive(): Promise { + return (await this.channel.secureReceive()) as MSC4108Payload; + } + + private async send(payload: MSC4108Payload): Promise { + await this.channel.secureSend(payload); + } + + public async declineLoginOnExistingDevice(): Promise { + // logger.info("User declined sign in"); + const payload: FailurePayload = { + type: PayloadType.Failure, + reason: RendezvousFailureReason.UserDeclined, + }; + await this.send(payload); + } + + public async cancel(reason: RendezvousFailureReason): Promise { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + + public async close(): Promise { + await this.channel.close(); + } +} diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index b19a91cec0b..8eb39bbc025 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -28,4 +28,5 @@ export enum RendezvousFailureReason { DataMismatch = "data_mismatch", UnsupportedTransport = "unsupported_transport", HomeserverLacksSupport = "homeserver_lacks_support", + UnexpectedMessage = "unexpected_message", } diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts new file mode 100644 index 00000000000..223e5b22452 --- /dev/null +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -0,0 +1,340 @@ +/* +Copyright 2023 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 { xchacha20poly1305 } from "@noble/ciphers/chacha"; // PROTOTYPE: we should use chacha implementation that will be exposed from the matrix rust crypto module + +import { RendezvousError, RendezvousIntent, RendezvousFailureReason, MSC4108Payload } from ".."; +import { encodeUnpaddedBase64, decodeBase64 } from "../../base64"; +import { TextEncoder } from "../../crypto/crypto"; +import { QRCodeData } from "../../crypto/verification/QRCode"; +import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession"; +import { logger } from "../../logger"; + +function makeNonce(input: number): Uint8Array { + const nonce = new Uint8Array(24); + nonce.set([input], 23); + return nonce; +} + +export class MSC4108SecureChannel { + private ephemeralKeyPair?: CryptoKeyPair; + private connected = false; + private EncKey?: CryptoKey; + private OurNonce = 0; + private TheirNonce = 0; + + public constructor( + private rendezvousSession: MSC4108RendezvousSession, + private theirPublicKey?: CryptoKey, + public onFailure?: (reason: RendezvousFailureReason) => void, + ) {} + + public async getKeyPair(): Promise { + if (!this.ephemeralKeyPair) { + this.ephemeralKeyPair = await global.crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", // PROTOTYPE: This should be "Curve25519" + }, + true, + ["deriveBits"], + ); + } + + return this.ephemeralKeyPair; + } + + public async generateCode(intent: RendezvousIntent, homeserverBaseUrl?: string): Promise { + const { url } = this.rendezvousSession; + + if (!url) { + throw new Error("No rendezvous session URL"); + } + + const ephemeralKeyPair = await this.getKeyPair(); + + return QRCodeData.createForRendezvous(intent, ephemeralKeyPair.publicKey, url, homeserverBaseUrl); + } + + public async connect(): Promise { + if (this.connected) { + throw new Error("Channel already connected"); + } + + const ephemeralKeyPair = await this.getKeyPair(); + + const isScanningDevice = this.theirPublicKey; + + if (isScanningDevice) { + /** + Secure Channel step 4. Device S sends the initial message + + Nonce := 0 + SH := ECDH(Ss, Gp) + EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) + TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE") + Nonce := Nonce + 2 + LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) + */ + const Ss = ephemeralKeyPair.privateKey; + const Sp = ephemeralKeyPair.publicKey; + const Gp = this.theirPublicKey; + this.OurNonce = 0; + this.TheirNonce = 1; + + const SHBits = await global.crypto.subtle.deriveBits( + { + name: "ECDH", + public: Gp, + }, + Ss, + 256, + ); + + const SH = await global.crypto.subtle.importKey( + "raw", + SHBits, + { + name: "HKDF", + length: 256, + }, + false, + ["deriveKey"], + ); + + this.EncKey = await global.crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info: new Int8Array([ + ...new TextEncoder().encode("MATRIX_QR_CODE_LOGIN|"), + ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Gp!)), + ...new TextEncoder().encode("|"), + ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Sp!)), + ]).buffer, + }, + SH, + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt"], + ); + { + const TaggedCiphertext = await this.encrypt(new TextEncoder().encode("MATRIX_QR_CODE_LOGIN_INITIATE")); + const LoginInitiateMessage = + encodeUnpaddedBase64(TaggedCiphertext) + + "|" + + encodeUnpaddedBase64(await global.crypto.subtle.exportKey("raw", Sp!)); + logger.info("Sending LoginInitiateMessage"); + await this.rendezvousSession.send(LoginInitiateMessage); + } + + /** + Secure Channel step 6. Verification by Device S + + Nonce_G := 1 + (TaggedCiphertext, Sp) := Unpack(Message) + Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext) + Nonce_G := Nonce_G + 2 + + unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": + FAIL + + */ + { + logger.info("Waiting for LoginOkMessage"); + const TaggedCiphertext = await this.rendezvousSession.receive(); + + if (!TaggedCiphertext) { + throw new RendezvousError("No response from other device", RendezvousFailureReason.Unknown); + } + const CandidateLoginOkMessage = await this.decrypt(decodeBase64(TaggedCiphertext)); + + if (new TextDecoder().decode(CandidateLoginOkMessage) !== "MATRIX_QR_CODE_LOGIN_OK") { + throw new RendezvousError( + "Invalid response from other device", + RendezvousFailureReason.DataMismatch, + ); + } + + // Step 6 is now complete. We trust the channel + } + } else { + /** + Secure Channel step 5. Device G confirms + + Nonce_S := 0 + (TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) + SH := ECDH(Gs, Sp) + EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) + Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext) + Nonce_S := Nonce_S + 2 + + */ + // wait for the other side to send us their public key + this.OurNonce = 1; + this.TheirNonce = 0; + logger.info("Waiting for LoginInitiateMessage"); + const LoginInitiateMessage = await this.rendezvousSession.receive(); + if (!LoginInitiateMessage) { + throw new Error("No response from other device"); + } + const Gs = ephemeralKeyPair.privateKey; + const Gp = ephemeralKeyPair.publicKey; + + const [TaggedCipherTextEncoded, SpEncoded] = LoginInitiateMessage.split("|"); + const TaggedCiphertext = decodeBase64(TaggedCipherTextEncoded); + const Sp = await global.crypto.subtle.importKey( + "raw", + decodeBase64(SpEncoded), + { name: "ECDH", namedCurve: "P-256" }, // PROTOTYPE: this should be Curve25519 + true, + [], + ); + + const SHBits = await global.crypto.subtle.deriveBits( + { + name: "ECDH", + public: Sp, + }, + Gs, + 256, + ); + + const SH = await global.crypto.subtle.importKey( + "raw", + SHBits, + { + name: "HKDF", + length: 256, + }, + false, + ["deriveKey"], + ); + + this.EncKey = await global.crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info: new Int8Array([ + ...new TextEncoder().encode("MATRIX_QR_CODE_LOGIN|"), + ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Gp!)), + ...new TextEncoder().encode("|"), + ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Sp!)), + ]).buffer, + }, + SH, + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt"], + ); + const CandidateLoginInitiateMessage = await this.decrypt(TaggedCiphertext); + + if (new TextDecoder().decode(CandidateLoginInitiateMessage) !== "MATRIX_QR_CODE_LOGIN_INITIATE") { + throw new RendezvousError("Invalid response from other device", RendezvousFailureReason.DataMismatch); + } + + this.theirPublicKey = Sp; + + logger.info("LoginInitiateMessage received"); + + const LoginOkMessage = encodeUnpaddedBase64( + await this.encrypt(new TextEncoder().encode("MATRIX_QR_CODE_LOGIN_OK")), + ); + logger.info("Sending LoginOkMessage"); + await this.rendezvousSession.send(LoginOkMessage); + + // Step 5 is complete. We don't yet trust the channel + + // next step will be for the user to confirm that they see a checkmark on the other device + } + + this.connected = true; + } + + private async decrypt(TaggedCiphertext: Uint8Array): Promise { + if (!this.EncKey) { + throw new Error("Shared secret not set up"); + } + logger.info(`Decrypting with nonce ${this.TheirNonce}`); + const chacha = xchacha20poly1305( + new Uint8Array(await global.crypto.subtle.exportKey("raw", this.EncKey)), + makeNonce(this.TheirNonce), + ); + this.TheirNonce += 2; + return chacha.decrypt(TaggedCiphertext); + } + + private async encrypt(Plaintext: Uint8Array): Promise { + if (!this.EncKey) { + throw new Error("Shared secret not set up"); + } + logger.info(`Encrypting with nonce ${this.OurNonce}`); + const chacha = xchacha20poly1305( + new Uint8Array(await global.crypto.subtle.exportKey("raw", this.EncKey)), + makeNonce(this.OurNonce), + ); + this.OurNonce += 2; + return chacha.encrypt(Plaintext); + } + + public async secureSend(payload: MSC4108Payload): Promise { + if (!this.connected) { + throw new Error("Channel closed"); + } + + logger.info(`=> ${JSON.stringify(payload)}`); + + await this.rendezvousSession.send( + encodeUnpaddedBase64(await this.encrypt(new TextEncoder().encode(JSON.stringify(payload)))), + ); + } + + public async secureReceive(): Promise | undefined> { + if (!this.EncKey) { + throw new Error("Shared secret not set up"); + } + + const rawData = await this.rendezvousSession.receive(); + if (!rawData) { + return undefined; + } + const ciphertext = decodeBase64(rawData); + const plaintext = await this.decrypt(ciphertext); + + const json = JSON.parse(new TextDecoder().decode(plaintext)); + + logger.info(`<= ${JSON.stringify(json)}`); + return json as any as Partial; + } + + public async close(): Promise {} + + public async cancel(reason: RendezvousFailureReason): Promise { + try { + await this.rendezvousSession.cancel(reason); + } finally { + await this.close(); + } + } +} diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts index f157bbeaef1..5ddf8a650de 100644 --- a/src/rendezvous/channels/index.ts +++ b/src/rendezvous/channels/index.ts @@ -14,4 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./MSC3903ECDHv2RendezvousChannel"; +export * from "./MSC4108SecureChannel"; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 379b13351b8..b28ed96fca3 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -14,10 +14,67 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./MSC3906Rendezvous"; +import { QRCodeData } from "../crypto/verification/QRCode"; +import { MatrixClient } from "../matrix"; +import { MSC4108SignInWithQR } from "./MSC4108SignInWithQR"; +import { RendezvousError } from "./RendezvousError"; +import { RendezvousFailureListener, RendezvousFailureReason } from "./RendezvousFailureReason"; +import { RendezvousIntent } from "./RendezvousIntent"; +import { MSC4108SecureChannel } from "./channels"; +import { MSC4108RendezvousSession } from "./transports"; + +export * from "./MSC4108SignInWithQR"; export * from "./RendezvousChannel"; export * from "./RendezvousCode"; export * from "./RendezvousError"; export * from "./RendezvousFailureReason"; export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; + +export async function buildLoginFromScannedCode( + client: MatrixClient | undefined, + code: Buffer, + onFailure: RendezvousFailureListener, +): Promise<{ signin: MSC4108SignInWithQR, homeserverBaseUrl?: string}> { + const scannerIntent = client + ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE + : RendezvousIntent.LOGIN_ON_NEW_DEVICE; + + const { channel, homeserverBaseUrl } = await buildChannelFromCode(scannerIntent, code, onFailure); + + return { signin: new MSC4108SignInWithQR(channel, true, client, onFailure), homeserverBaseUrl }; +} + +async function buildChannelFromCode( + scannerIntent: RendezvousIntent, + code: Buffer, + onFailure: RendezvousFailureListener, +): Promise<{ channel: MSC4108SecureChannel; intent: RendezvousIntent; homeserverBaseUrl?: string }> { + const { + intent: scannedIntent, + publicKey, + rendezvousSessionUrl, + homeserverBaseUrl, + } = await QRCodeData.parseForRendezvous(code); + + if (scannedIntent === scannerIntent) { + throw new RendezvousError( + "The scanned intent is the same as the scanner intent", + scannerIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn, + ); + } + + // need to validate the values + const rendezvousSession = new MSC4108RendezvousSession({ + onFailure, + url: rendezvousSessionUrl, + }); + + return { + channel: new MSC4108SecureChannel(rendezvousSession, publicKey, onFailure), + intent: scannedIntent, + homeserverBaseUrl, + }; +} diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts index 430ee92d1c7..11770fffcf8 100644 --- a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -42,13 +42,22 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende private uri?: string; private etag?: string; private expiresAt?: Date; - private client: MatrixClient; + private client?: MatrixClient; private fallbackRzServer?: string; private fetchFn?: typeof global.fetch; private cancelled = false; private _ready = false; public onFailure?: RendezvousFailureListener; + public constructor({ + onFailure, + details, + fetchFn, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + details: MSC3886SimpleHttpRendezvousTransportDetails; + }); public constructor({ onFailure, client, @@ -59,11 +68,25 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende onFailure?: RendezvousFailureListener; client: MatrixClient; fallbackRzServer?: string; + }); + public constructor({ + fetchFn, + onFailure, + details, + client, + fallbackRzServer, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + details?: MSC3886SimpleHttpRendezvousTransportDetails; + client?: MatrixClient; + fallbackRzServer?: string; }) { this.fetchFn = fetchFn; this.onFailure = onFailure; this.client = client; this.fallbackRzServer = fallbackRzServer; + this.uri = details?.uri; } public get ready(): boolean { @@ -89,12 +112,14 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende } private async getPostEndpoint(): Promise { - try { - if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { - return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + if (this.client) { + try { + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { + return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + } + } catch (err) { + logger.warn("Failed to get unstable features", err); } - } catch (err) { - logger.warn("Failed to get unstable features", err); } return this.fallbackRzServer; diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts new file mode 100644 index 00000000000..fdf8009c1fd --- /dev/null +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -0,0 +1,206 @@ +/* +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 { logger } from "../../logger"; +import { sleep } from "../../utils"; +import { RendezvousFailureListener, RendezvousFailureReason } from ".."; +import { MatrixClient } from "../../matrix"; +import { ClientPrefix } from "../../http-api"; + +/** + * Prototype of the unstable [4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * insecure rendezvous session protocol. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +export class MSC4108RendezvousSession { + public url?: string; + private etag?: string; + private expiresAt?: Date; + private client?: MatrixClient; + private fallbackRzServer?: string; + private fetchFn?: typeof global.fetch; + private cancelled = false; + private _ready = false; + public onFailure?: RendezvousFailureListener; + + public constructor({ + onFailure, + url, + fetchFn, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + url: string; + }); + public constructor({ + onFailure, + client, + fallbackRzServer, + fetchFn, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + client?: MatrixClient; + fallbackRzServer?: string; + }); + public constructor({ + fetchFn, + onFailure, + url, + client, + fallbackRzServer, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + url?: string; + client?: MatrixClient; + fallbackRzServer?: string; + }) { + this.fetchFn = fetchFn; + this.onFailure = onFailure; + this.client = client; + this.fallbackRzServer = fallbackRzServer; + this.url = url; + } + + public get ready(): boolean { + return this._ready; + } + + private fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private async getPostEndpoint(): Promise { + if (this.client) { + try { + // whilst prototyping we can use the MSC3886 endpoint if available + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { + return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + } + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) { + return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc4108/rendezvous`; + } + } catch (err) { + logger.warn("Failed to get unstable features", err); + } + } + + return this.fallbackRzServer; + } + + public async send(data: string): Promise { + if (this.cancelled) { + return; + } + const method = this.url ? "PUT" : "POST"; + const uri = this.url ?? (await this.getPostEndpoint()); + + if (!uri) { + throw new Error("Invalid rendezvous URI"); + } + + const headers: Record = { "content-type": "text/plain" }; + if (this.etag) { + headers["if-match"] = this.etag; + } + + logger.info(`=> ${method} ${uri} with ${data} if-match: ${this.etag}`); + + const res = await this.fetch(uri, { method, headers, body: data }); + if (res.status === 404) { + return this.cancel(RendezvousFailureReason.Unknown); + } + this.etag = res.headers.get("etag") ?? undefined; + + logger.info(`Received etag: ${this.etag}`); + + if (method === "POST") { + const location = res.headers.get("location"); + if (!location) { + throw new Error("No rendezvous URI given"); + } + const expires = res.headers.get("expires"); + if (expires) { + this.expiresAt = new Date(expires); + } + // we would usually expect the final `url` to be set by a proper fetch implementation. + // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback + const baseUrl = res.url ?? uri; + // resolve location header which could be relative or absolute + this.url = new URL(location, `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}`).href; + this._ready = true; + } + } + + public async receive(): Promise { + if (!this.url) { + throw new Error("Rendezvous not set up"); + } + // eslint-disable-next-line no-constant-condition + while (true) { + if (this.cancelled) { + return undefined; + } + + const headers: Record = {}; + if (this.etag) { + headers["if-none-match"] = this.etag; + } + + logger.info(`=> GET ${this.url} if-none-match: ${this.etag}`); + const poll = await this.fetch(this.url, { method: "GET", headers }); + + if (poll.status === 404) { + this.cancel(RendezvousFailureReason.Unknown); + return undefined; + } + + // rely on server expiring the channel rather than checking ourselves + + if (poll.headers.get("content-type") !== "text/plain") { + this.etag = poll.headers.get("etag") ?? undefined; + } else if (poll.status === 200) { + this.etag = poll.headers.get("etag") ?? undefined; + const text = await poll.text(); + logger.info(`Received: ${text} with etag ${this.etag}`); + return text; + } + await sleep(1000); + } + } + + public async cancel(reason: RendezvousFailureReason): Promise { + if (reason === RendezvousFailureReason.Unknown && this.expiresAt && this.expiresAt.getTime() < Date.now()) { + reason = RendezvousFailureReason.Expired; + } + + this.cancelled = true; + this._ready = false; + this.onFailure?.(reason); + + if (this.url && reason === RendezvousFailureReason.UserDeclined) { + try { + await this.fetch(this.url, { method: "DELETE" }); + } catch (e) { + logger.warn(e); + } + } + } +} diff --git a/src/rendezvous/transports/index.ts b/src/rendezvous/transports/index.ts index 6d8d64245e4..3f538181afb 100644 --- a/src/rendezvous/transports/index.ts +++ b/src/rendezvous/transports/index.ts @@ -14,4 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -export * from "./MSC3886SimpleHttpRendezvousTransport"; +export * from "./MSC4108RendezvousSession"; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index ace265b150c..3c6ae785530 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -165,6 +165,49 @@ export class RustCrypto extends TypedEventEmitter { + const crossSigning: RustSdkCryptoJs.CrossSigningKeyExport | null = + await this.olmMachine.exportCrossSigningKeys(); + + const backup: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys(); + + return { + cross_signing: crossSigning + ? { + master_key: crossSigning.masterKey!, + self_signing_key: crossSigning.self_signing_key!, + user_signing_key: crossSigning.userSigningKey!, + } + : undefined, + backup: backup?.decryptionKey?.megolmV1PublicKey + ? { + algorithm: backup.decryptionKey.megolmV1PublicKey.algorithm, + key: backup.decryptionKey.toBase64(), + backup_version: backup.backupVersion!, + } + : undefined, + }; + } + + public async importSecretsForQRLogin(secrets: { + cross_signing?: { master_key: string; self_signing_key: string; user_signing_key: string } | undefined; + backup?: { algorithm: string; key: string; backup_version: string } | undefined; + }): Promise { + if (secrets.cross_signing) { + await this.olmMachine.importCrossSigningKeys( + secrets.cross_signing.master_key, + secrets.cross_signing.self_signing_key, + secrets.cross_signing.user_signing_key, + ); + } + if (secrets.backup) { + // PROTOTYPE: Not implemented + // await this.olmMachine.importBackupKeys(secrets.backup.key, secrets.backup.algorithm, secrets.backup.backup_version); + } + } /** * Return the OlmMachine only if {@link RustCrypto#stop} has not been called. * diff --git a/yarn.lock b/yarn.lock index a55e9bf8d65..38f82a0e5c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1710,6 +1710,11 @@ dependencies: eslint-scope "5.1.1" +"@noble/ciphers@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111" + integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -5104,10 +5109,9 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -oidc-client-ts@^3.0.1: +"oidc-client-ts@github:hughns/oidc-client-ts#hughns/device-flow": version "3.0.1" - resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7" - integrity sha512-xX8unZNtmtw3sOz4FPSqDhkLFnxCDsdo2qhFEH2opgWnF/iXMFoYdBQzkwCxAZVgt3FT3DnuBY3k80EZHT0RYg== + resolved "https://codeload.github.com/hughns/oidc-client-ts/tar.gz/456bdee9ca3c284e6626480e905987bfb79acbaf" dependencies: jwt-decode "^4.0.0" From 62e39821166f7d848385252dafd693d4fb10c384 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 14 Mar 2024 12:16:22 +0000 Subject: [PATCH 03/81] misc --- src/autodiscovery.ts | 2 +- src/rust-crypto/rust-crypto.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 29dac54bdb7..4d95d4d4743 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -49,7 +49,7 @@ export enum AutoDiscoveryError { //IdentityServerTooOld = "The identity server does not meet the minimum version requirements", } -interface AutoDiscoveryState { +export interface AutoDiscoveryState { state: AutoDiscoveryAction; error?: IWellKnownConfig["error"] | null; } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 3c6ae785530..c5fd466fa37 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -197,11 +197,15 @@ export class RustCrypto extends TypedEventEmitter { if (secrets.cross_signing) { + // import the keys await this.olmMachine.importCrossSigningKeys( secrets.cross_signing.master_key, secrets.cross_signing.self_signing_key, secrets.cross_signing.user_signing_key, ); + + // cross sign our device + // PROTOTYPE: Not implemented } if (secrets.backup) { // PROTOTYPE: Not implemented From 7c07234d89f70432a77e64d2d43ab66a346aa040 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Mar 2024 14:22:19 +0000 Subject: [PATCH 04/81] Remove redundant change Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/autodiscovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 4d95d4d4743..29dac54bdb7 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -49,7 +49,7 @@ export enum AutoDiscoveryError { //IdentityServerTooOld = "The identity server does not meet the minimum version requirements", } -export interface AutoDiscoveryState { +interface AutoDiscoveryState { state: AutoDiscoveryAction; error?: IWellKnownConfig["error"] | null; } From 582350f868e21e4e8bd9fdef87281448ae161228 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Mar 2024 14:57:25 +0000 Subject: [PATCH 05/81] Tweak rendezvous index Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index b28ed96fca3..02ff44c7b19 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -30,12 +30,14 @@ export * from "./RendezvousError"; export * from "./RendezvousFailureReason"; export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; +export * from "./transports"; +export * from "./channels"; export async function buildLoginFromScannedCode( client: MatrixClient | undefined, code: Buffer, onFailure: RendezvousFailureListener, -): Promise<{ signin: MSC4108SignInWithQR, homeserverBaseUrl?: string}> { +): Promise<{ signin: MSC4108SignInWithQR; homeserverBaseUrl?: string }> { const scannerIntent = client ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE : RendezvousIntent.LOGIN_ON_NEW_DEVICE; From 964020a280206184a6050bbe7161f7cb13e70632 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Mar 2024 15:03:01 +0000 Subject: [PATCH 06/81] Switch to using rust-crypto backed SecureChannel implementation for MSC4108 Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 1 - src/crypto/verification/QRCode.ts | 21 +- .../channels/MSC4108SecureChannel.ts | 257 ++++-------------- yarn.lock | 5 - 4 files changed, 65 insertions(+), 219 deletions(-) diff --git a/package.json b/package.json index ee4f0366bb7..386abf79faf 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/matrix-sdk-crypto-wasm": "^4.6.0", - "@noble/ciphers": "^0.5.1", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index 616b3f6835a..82dfd24898d 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -18,6 +18,7 @@ limitations under the License. * QR code key verification. */ +import type { Curve25519PublicKey } from "@matrix-org/matrix-sdk-crypto-wasm"; import { crypto } from "../crypto"; import { VerificationBase as Base } from "./Base"; import { newKeyMismatchError, newUserCancelledError } from "./Error"; @@ -323,7 +324,6 @@ export class QRCodeData { appendEncBase64(qrData.secondKeyB64); appendEncBase64(qrData.secretB64); } else if ("ephemeralPublicKey" in qrData) { - // PROTOTYPE: this is actually 65 bytes not 32 due to it currently using P-256 not Curve25519 appendEncBase64(qrData.ephemeralPublicKey); appendStr(qrData.rendezvousSessionUrl, "utf-8"); if (qrData.homeserverBaseUrl && qrData.mode === Mode.LoginReciprocate) { @@ -336,12 +336,11 @@ export class QRCodeData { public static async createForRendezvous( intent: RendezvousIntent, - publicKey: CryptoKey, + publicKey: Curve25519PublicKey, rendezvousSessionUrl: string, homeserverBaseUrl?: string, ): Promise { - const rawPublicKey = await global.crypto.subtle.exportKey("raw", publicKey); - const ephemeralPublicKey = encodeUnpaddedBase64(rawPublicKey); + const ephemeralPublicKey = publicKey.toBase64(); const qrData: LoginQrData = { prefix: BINARY_PREFIX, version: CODE_VERSION, @@ -356,7 +355,7 @@ export class QRCodeData { public static async parseForRendezvous(buffer: Buffer): Promise<{ intent: RendezvousIntent; - publicKey: CryptoKey; + publicKey: Curve25519PublicKey; rendezvousSessionUrl: string; homeserverBaseUrl?: string; }> { @@ -376,8 +375,7 @@ export class QRCodeData { offset += 1; if (mode === Mode.LoginInitiate || mode === Mode.LoginReciprocate) { - const ephemeralPublicKey = buffer.slice(offset, offset + 65); // PROTOTYPE: this should be 32, but it's currently using P-256 not Curve25519 - offset += 65; // PROTOTYPE: this should be 32, but it's currently using P-256 not Curve25519 + const ephemeralPublicKey = buffer.slice(offset, (offset += 32)); const rendezvousSessionUrlLen = buffer.readUInt16BE(offset); offset += 2; @@ -392,18 +390,13 @@ export class QRCodeData { offset += homeserverBaseUrlLen; } + const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); return { intent: mode === Mode.LoginInitiate ? RendezvousIntent.LOGIN_ON_NEW_DEVICE : RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, - publicKey: await global.crypto.subtle.importKey( - "raw", - ephemeralPublicKey, - { name: "ECDH", namedCurve: "P-256" }, // PROTOTYPE: should use Curve25519 - true, - [], - ), + publicKey: new RustCrypto.Curve25519PublicKey(encodeUnpaddedBase64(ephemeralPublicKey)), rendezvousSessionUrl, homeserverBaseUrl, }; diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 223e5b22452..f799f5033d2 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -14,47 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { xchacha20poly1305 } from "@noble/ciphers/chacha"; // PROTOTYPE: we should use chacha implementation that will be exposed from the matrix rust crypto module +import { Curve25519PublicKey, EstablishedSecureChannel, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; import { RendezvousError, RendezvousIntent, RendezvousFailureReason, MSC4108Payload } from ".."; -import { encodeUnpaddedBase64, decodeBase64 } from "../../base64"; -import { TextEncoder } from "../../crypto/crypto"; import { QRCodeData } from "../../crypto/verification/QRCode"; import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession"; import { logger } from "../../logger"; -function makeNonce(input: number): Uint8Array { - const nonce = new Uint8Array(24); - nonce.set([input], 23); - return nonce; -} - +/** + * Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle. + */ export class MSC4108SecureChannel { - private ephemeralKeyPair?: CryptoKeyPair; + private readonly secureChannel: SecureChannel; + private establishedChannel?: EstablishedSecureChannel; private connected = false; - private EncKey?: CryptoKey; - private OurNonce = 0; - private TheirNonce = 0; public constructor( private rendezvousSession: MSC4108RendezvousSession, - private theirPublicKey?: CryptoKey, + private theirPublicKey?: Curve25519PublicKey, public onFailure?: (reason: RendezvousFailureReason) => void, - ) {} - - public async getKeyPair(): Promise { - if (!this.ephemeralKeyPair) { - this.ephemeralKeyPair = await global.crypto.subtle.generateKey( - { - name: "ECDH", - namedCurve: "P-256", // PROTOTYPE: This should be "Curve25519" - }, - true, - ["deriveBits"], - ); - } - - return this.ephemeralKeyPair; + ) { + this.secureChannel = new SecureChannel(); } public async generateCode(intent: RendezvousIntent, homeserverBaseUrl?: string): Promise { @@ -64,9 +44,7 @@ export class MSC4108SecureChannel { throw new Error("No rendezvous session URL"); } - const ephemeralKeyPair = await this.getKeyPair(); - - return QRCodeData.createForRendezvous(intent, ephemeralKeyPair.publicKey, url, homeserverBaseUrl); + return QRCodeData.createForRendezvous(intent, this.secureChannel.public_key(), url, homeserverBaseUrl); } public async connect(): Promise { @@ -74,75 +52,24 @@ export class MSC4108SecureChannel { throw new Error("Channel already connected"); } - const ephemeralKeyPair = await this.getKeyPair(); + if (this.theirPublicKey) { + // We are the scanning device + this.establishedChannel = this.secureChannel.create_outbound_channel(this.theirPublicKey); - const isScanningDevice = this.theirPublicKey; - - if (isScanningDevice) { /** - Secure Channel step 4. Device S sends the initial message - - Nonce := 0 - SH := ECDH(Ss, Gp) - EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) - TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE") - Nonce := Nonce + 2 - LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) - */ - const Ss = ephemeralKeyPair.privateKey; - const Sp = ephemeralKeyPair.publicKey; - const Gp = this.theirPublicKey; - this.OurNonce = 0; - this.TheirNonce = 1; - - const SHBits = await global.crypto.subtle.deriveBits( - { - name: "ECDH", - public: Gp, - }, - Ss, - 256, - ); - - const SH = await global.crypto.subtle.importKey( - "raw", - SHBits, - { - name: "HKDF", - length: 256, - }, - false, - ["deriveKey"], - ); - - this.EncKey = await global.crypto.subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array(0), - info: new Int8Array([ - ...new TextEncoder().encode("MATRIX_QR_CODE_LOGIN|"), - ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Gp!)), - ...new TextEncoder().encode("|"), - ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Sp!)), - ]).buffer, - }, - SH, - { - name: "AES-GCM", - length: 256, - }, - true, - ["encrypt"], - ); + Secure Channel step 4. Device S sends the initial message + + Nonce := 0 + SH := ECDH(Ss, Gp) + EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) + TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE") + Nonce := Nonce + 2 + LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp) + */ { - const TaggedCiphertext = await this.encrypt(new TextEncoder().encode("MATRIX_QR_CODE_LOGIN_INITIATE")); - const LoginInitiateMessage = - encodeUnpaddedBase64(TaggedCiphertext) + - "|" + - encodeUnpaddedBase64(await global.crypto.subtle.exportKey("raw", Sp!)); logger.info("Sending LoginInitiateMessage"); - await this.rendezvousSession.send(LoginInitiateMessage); + const loginInitiateMessage = this.establishedChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); + await this.rendezvousSession.send(loginInitiateMessage); } /** @@ -159,14 +86,14 @@ export class MSC4108SecureChannel { */ { logger.info("Waiting for LoginOkMessage"); - const TaggedCiphertext = await this.rendezvousSession.receive(); + const ciphertext = await this.rendezvousSession.receive(); - if (!TaggedCiphertext) { + if (!ciphertext) { throw new RendezvousError("No response from other device", RendezvousFailureReason.Unknown); } - const CandidateLoginOkMessage = await this.decrypt(decodeBase64(TaggedCiphertext)); + const candidateLoginOkMessage = await this.decrypt(ciphertext); - if (new TextDecoder().decode(CandidateLoginOkMessage) !== "MATRIX_QR_CODE_LOGIN_OK") { + if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") { throw new RendezvousError( "Invalid response from other device", RendezvousFailureReason.DataMismatch, @@ -188,81 +115,24 @@ export class MSC4108SecureChannel { */ // wait for the other side to send us their public key - this.OurNonce = 1; - this.TheirNonce = 0; logger.info("Waiting for LoginInitiateMessage"); - const LoginInitiateMessage = await this.rendezvousSession.receive(); - if (!LoginInitiateMessage) { + const loginInitiateMessage = await this.rendezvousSession.receive(); + if (!loginInitiateMessage) { throw new Error("No response from other device"); } - const Gs = ephemeralKeyPair.privateKey; - const Gp = ephemeralKeyPair.publicKey; - - const [TaggedCipherTextEncoded, SpEncoded] = LoginInitiateMessage.split("|"); - const TaggedCiphertext = decodeBase64(TaggedCipherTextEncoded); - const Sp = await global.crypto.subtle.importKey( - "raw", - decodeBase64(SpEncoded), - { name: "ECDH", namedCurve: "P-256" }, // PROTOTYPE: this should be Curve25519 - true, - [], - ); - - const SHBits = await global.crypto.subtle.deriveBits( - { - name: "ECDH", - public: Sp, - }, - Gs, - 256, - ); - - const SH = await global.crypto.subtle.importKey( - "raw", - SHBits, - { - name: "HKDF", - length: 256, - }, - false, - ["deriveKey"], - ); - - this.EncKey = await global.crypto.subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array(0), - info: new Int8Array([ - ...new TextEncoder().encode("MATRIX_QR_CODE_LOGIN|"), - ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Gp!)), - ...new TextEncoder().encode("|"), - ...new Uint8Array(await global.crypto.subtle.exportKey("raw", Sp!)), - ]).buffer, - }, - SH, - { - name: "AES-GCM", - length: 256, - }, - true, - ["encrypt"], - ); - const CandidateLoginInitiateMessage = await this.decrypt(TaggedCiphertext); - - if (new TextDecoder().decode(CandidateLoginInitiateMessage) !== "MATRIX_QR_CODE_LOGIN_INITIATE") { - throw new RendezvousError("Invalid response from other device", RendezvousFailureReason.DataMismatch); - } - this.theirPublicKey = Sp; + const { channel, message: candidateLoginInitiateMessage } = + this.secureChannel.create_inbound_channel(loginInitiateMessage); + this.establishedChannel = channel; + if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") { + throw new RendezvousError("Invalid response from other device", RendezvousFailureReason.DataMismatch); + } logger.info("LoginInitiateMessage received"); - const LoginOkMessage = encodeUnpaddedBase64( - await this.encrypt(new TextEncoder().encode("MATRIX_QR_CODE_LOGIN_OK")), - ); logger.info("Sending LoginOkMessage"); - await this.rendezvousSession.send(LoginOkMessage); + const loginOkMessage = await this.encrypt("MATRIX_QR_CODE_LOGIN_OK"); + await this.rendezvousSession.send(loginOkMessage); // Step 5 is complete. We don't yet trust the channel @@ -272,30 +142,20 @@ export class MSC4108SecureChannel { this.connected = true; } - private async decrypt(TaggedCiphertext: Uint8Array): Promise { - if (!this.EncKey) { - throw new Error("Shared secret not set up"); + private async decrypt(ciphertext: string): Promise { + if (!this.establishedChannel) { + throw new Error("Channel closed"); } - logger.info(`Decrypting with nonce ${this.TheirNonce}`); - const chacha = xchacha20poly1305( - new Uint8Array(await global.crypto.subtle.exportKey("raw", this.EncKey)), - makeNonce(this.TheirNonce), - ); - this.TheirNonce += 2; - return chacha.decrypt(TaggedCiphertext); + + return this.establishedChannel.decrypt(ciphertext); } - private async encrypt(Plaintext: Uint8Array): Promise { - if (!this.EncKey) { - throw new Error("Shared secret not set up"); + private async encrypt(plaintext: string): Promise { + if (!this.establishedChannel) { + throw new Error("Channel closed"); } - logger.info(`Encrypting with nonce ${this.OurNonce}`); - const chacha = xchacha20poly1305( - new Uint8Array(await global.crypto.subtle.exportKey("raw", this.EncKey)), - makeNonce(this.OurNonce), - ); - this.OurNonce += 2; - return chacha.encrypt(Plaintext); + + return this.establishedChannel.encrypt(plaintext); } public async secureSend(payload: MSC4108Payload): Promise { @@ -303,26 +163,24 @@ export class MSC4108SecureChannel { throw new Error("Channel closed"); } - logger.info(`=> ${JSON.stringify(payload)}`); + const stringifiedPayload = JSON.stringify(payload); + const encryptedPayload = await this.encrypt(stringifiedPayload); + logger.info(`=> ${stringifiedPayload} [${encryptedPayload}]`); - await this.rendezvousSession.send( - encodeUnpaddedBase64(await this.encrypt(new TextEncoder().encode(JSON.stringify(payload)))), - ); + await this.rendezvousSession.send(encryptedPayload); } public async secureReceive(): Promise | undefined> { - if (!this.EncKey) { - throw new Error("Shared secret not set up"); + if (!this.establishedChannel) { + throw new Error("Channel closed"); } - const rawData = await this.rendezvousSession.receive(); - if (!rawData) { + const ciphertext = await this.rendezvousSession.receive(); + if (!ciphertext) { return undefined; } - const ciphertext = decodeBase64(rawData); const plaintext = await this.decrypt(ciphertext); - - const json = JSON.parse(new TextDecoder().decode(plaintext)); + const json = JSON.parse(plaintext); logger.info(`<= ${JSON.stringify(json)}`); return json as any as Partial; @@ -333,6 +191,7 @@ export class MSC4108SecureChannel { public async cancel(reason: RendezvousFailureReason): Promise { try { await this.rendezvousSession.cancel(reason); + this.onFailure?.(reason); } finally { await this.close(); } diff --git a/yarn.lock b/yarn.lock index 38f82a0e5c0..79104afa6ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1710,11 +1710,6 @@ dependencies: eslint-scope "5.1.1" -"@noble/ciphers@^0.5.1": - version "0.5.1" - resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111" - integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA== - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" From f2ab31b00cc7d76e63c551fe6d52945b44c73dd6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Mar 2024 15:04:24 +0000 Subject: [PATCH 07/81] Remove debug logging Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/channels/MSC4108SecureChannel.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index f799f5033d2..ca46d26ffd8 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -164,10 +164,9 @@ export class MSC4108SecureChannel { } const stringifiedPayload = JSON.stringify(payload); - const encryptedPayload = await this.encrypt(stringifiedPayload); - logger.info(`=> ${stringifiedPayload} [${encryptedPayload}]`); + logger.info(`=> ${stringifiedPayload}`); - await this.rendezvousSession.send(encryptedPayload); + await this.rendezvousSession.send(await this.encrypt(stringifiedPayload)); } public async secureReceive(): Promise | undefined> { From a3be3a9b01ecef04030f94ea615b85867e5d18ad Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 14 Mar 2024 17:57:04 +0000 Subject: [PATCH 08/81] Iterate PR Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/test-utils/oidc.ts | 1 + src/rendezvous/MSC4108SignInWithQR.ts | 3 +-- src/rendezvous/channels/index.ts | 4 ++++ src/rendezvous/index.ts | 4 ++++ src/rendezvous/transports/index.ts | 4 ++++ 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spec/test-utils/oidc.ts b/spec/test-utils/oidc.ts index 7b2adc226d7..4f9a01c2ee2 100644 --- a/spec/test-utils/oidc.ts +++ b/spec/test-utils/oidc.ts @@ -44,6 +44,7 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated token_endpoint: issuer + "token", authorization_endpoint: issuer + "auth", registration_endpoint: issuer + "registration", + device_authorization_endpoint: issuer + "device", jwks_uri: issuer + "jwks", response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index dfda14bc25e..e9a0d39c5e5 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -338,9 +338,8 @@ export class MSC4108SignInWithQR { throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); } - const { device_id: deviceId } = res as SuccessPayload; - // PROTOTYPE: we should be validating that the device on the other end of the rendezvous did actually successfully authenticate as this device once we decide how that should be done + // const { device_id: deviceId } = res as SuccessPayload; const availableSecrets = (await this.client?.getCrypto()?.exportSecretsForQRLogin()) ?? {}; // send secrets diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts index 5ddf8a650de..793105a5153 100644 --- a/src/rendezvous/channels/index.ts +++ b/src/rendezvous/channels/index.ts @@ -14,4 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +/** + * @deprecated in favour of MSC4108-based implementation + */ +export * from "./MSC3903ECDHv2RendezvousChannel"; export * from "./MSC4108SecureChannel"; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 02ff44c7b19..adc8a559df2 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -23,6 +23,10 @@ import { RendezvousIntent } from "./RendezvousIntent"; import { MSC4108SecureChannel } from "./channels"; import { MSC4108RendezvousSession } from "./transports"; +/** + * @deprecated in favour of MSC4108-based implementation + */ +export * from "./MSC3906Rendezvous"; export * from "./MSC4108SignInWithQR"; export * from "./RendezvousChannel"; export * from "./RendezvousCode"; diff --git a/src/rendezvous/transports/index.ts b/src/rendezvous/transports/index.ts index 3f538181afb..09349dd25e1 100644 --- a/src/rendezvous/transports/index.ts +++ b/src/rendezvous/transports/index.ts @@ -14,4 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +/** + * @deprecated in favour of MSC4108-based implementation + */ +export * from "./MSC3886SimpleHttpRendezvousTransport"; export * from "./MSC4108RendezvousSession"; From 8a57f9284a5a352128cbc928a467c14b5b82e7c4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Mar 2024 13:06:20 +0000 Subject: [PATCH 09/81] Tweak getPostEndpoint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/transports/MSC4108RendezvousSession.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index fdf8009c1fd..f4a5b791161 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -92,10 +92,14 @@ export class MSC4108RendezvousSession { try { // whilst prototyping we can use the MSC3886 endpoint if available if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { - return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + return this.client.http + .getUrl("/org.matrix.msc3886/rendezvous", undefined, ClientPrefix.Unstable) + .toString(); } if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) { - return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc4108/rendezvous`; + return this.client.http + .getUrl("/org.matrix.msc4108/rendezvous", undefined, ClientPrefix.Unstable) + .toString(); } } catch (err) { logger.warn("Failed to get unstable features", err); From 3ffd01409a318398f139b7838c2da639bf49003f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Mar 2024 16:07:49 +0000 Subject: [PATCH 10/81] Switch to generating/parsing MSC4108 QR codes via Rust Crypto Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/crypto/verification/QRCode.ts | 102 +----------------- src/rendezvous/MSC4108SignInWithQR.ts | 17 ++- .../channels/MSC4108SecureChannel.ts | 21 ++-- src/rendezvous/index.ts | 33 +++--- 4 files changed, 42 insertions(+), 131 deletions(-) diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index 82dfd24898d..aebad528274 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -18,7 +18,6 @@ limitations under the License. * QR code key verification. */ -import type { Curve25519PublicKey } from "@matrix-org/matrix-sdk-crypto-wasm"; import { crypto } from "../crypto"; import { VerificationBase as Base } from "./Base"; import { newKeyMismatchError, newUserCancelledError } from "./Error"; @@ -29,7 +28,6 @@ import { MatrixClient } from "../../client"; import { IVerificationChannel } from "./request/Channel"; import { MatrixEvent } from "../../models/event"; import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification"; -import { RendezvousIntent } from "../../rendezvous"; export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; @@ -136,11 +134,9 @@ enum Mode { VerifyOtherUser = 0x00, // Verifying someone who isn't us VerifySelfTrusted = 0x01, // We trust the master key VerifySelfUntrusted = 0x02, // We do not trust the master key - LoginInitiate = 0x03, // a new device wishing to initiate a login and self-verify - LoginReciprocate = 0x04, //an existing device wishing to reciprocate the login of a new device and self-verify that other device } -export type IQrData = VerificationQrData | LoginQrData; +export type IQrData = VerificationQrData; interface IBaseQrData { prefix: string; @@ -155,15 +151,6 @@ interface VerificationQrData extends IBaseQrData { secretB64: string; } -export interface LoginQrData extends IBaseQrData { - prefix: typeof BINARY_PREFIX; - version: 2; - mode: Mode.LoginInitiate | Mode.LoginReciprocate; - ephemeralPublicKey: string; - rendezvousSessionUrl: string; - homeserverBaseUrl?: string; -} - export class QRCodeData { public constructor( public readonly mode: Mode, @@ -318,90 +305,11 @@ export class QRCodeData { appendStr(qrData.prefix, "ascii", false); appendByte(qrData.version); appendByte(qrData.mode); - if ("firstKeyB64" in qrData) { - appendStr(qrData.transactionId!, "utf-8"); - appendEncBase64(qrData.firstKeyB64); - appendEncBase64(qrData.secondKeyB64); - appendEncBase64(qrData.secretB64); - } else if ("ephemeralPublicKey" in qrData) { - appendEncBase64(qrData.ephemeralPublicKey); - appendStr(qrData.rendezvousSessionUrl, "utf-8"); - if (qrData.homeserverBaseUrl && qrData.mode === Mode.LoginReciprocate) { - appendStr(qrData.homeserverBaseUrl, "utf-8"); - } - } + appendStr(qrData.transactionId!, "utf-8"); + appendEncBase64(qrData.firstKeyB64); + appendEncBase64(qrData.secondKeyB64); + appendEncBase64(qrData.secretB64); return buf; } - - public static async createForRendezvous( - intent: RendezvousIntent, - publicKey: Curve25519PublicKey, - rendezvousSessionUrl: string, - homeserverBaseUrl?: string, - ): Promise { - const ephemeralPublicKey = publicKey.toBase64(); - const qrData: LoginQrData = { - prefix: BINARY_PREFIX, - version: CODE_VERSION, - mode: intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? Mode.LoginInitiate : Mode.LoginReciprocate, - ephemeralPublicKey, - rendezvousSessionUrl, - homeserverBaseUrl, - }; - - return QRCodeData.generateBuffer(qrData); - } - - public static async parseForRendezvous(buffer: Buffer): Promise<{ - intent: RendezvousIntent; - publicKey: Curve25519PublicKey; - rendezvousSessionUrl: string; - homeserverBaseUrl?: string; - }> { - let offset = 0; - - if (buffer.toString("ascii", offset, 6) !== BINARY_PREFIX) { - throw new Error("QR code does not have the expected prefix"); - } - offset += 6; - - if (buffer.readUInt8(offset) !== CODE_VERSION) { - throw new Error("QR code has an unsupported version"); - } - offset += 1; - - const mode = buffer.readUInt8(offset); - offset += 1; - - if (mode === Mode.LoginInitiate || mode === Mode.LoginReciprocate) { - const ephemeralPublicKey = buffer.slice(offset, (offset += 32)); - - const rendezvousSessionUrlLen = buffer.readUInt16BE(offset); - offset += 2; - const rendezvousSessionUrl = buffer.toString("utf-8", offset, offset + rendezvousSessionUrlLen); - offset += rendezvousSessionUrlLen; - - let homeserverBaseUrl: string | undefined; - if (mode === Mode.LoginReciprocate) { - const homeserverBaseUrlLen = buffer.readUInt16BE(offset); - offset += 2; - homeserverBaseUrl = buffer.toString("utf-8", offset, offset + homeserverBaseUrlLen); - offset += homeserverBaseUrlLen; - } - - const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); - return { - intent: - mode === Mode.LoginInitiate - ? RendezvousIntent.LOGIN_ON_NEW_DEVICE - : RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, - publicKey: new RustCrypto.Curve25519PublicKey(encodeUnpaddedBase64(ephemeralPublicKey)), - rendezvousSessionUrl, - homeserverBaseUrl, - }; - } - - throw new Error("QR code has an unsupported mode"); - } } diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index e9a0d39c5e5..a522f92da1f 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceAuthorizationResponse, OidcClient, DeviceAccessTokenResponse } from "oidc-client-ts"; +import { DeviceAccessTokenResponse, DeviceAuthorizationResponse, OidcClient } from "oidc-client-ts"; +import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { RendezvousError, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; @@ -84,8 +85,8 @@ interface SecretsPayload extends MSC4108Payload { } export class MSC4108SignInWithQR { - private ourIntent: RendezvousIntent; - private _code?: Buffer; + private ourIntent: QrCodeMode; + private _code?: Uint8Array; public protocol?: string; private oidcClient?: OidcClient; private deviceAuthorizationResponse?: DeviceAuthorizationResponse; @@ -101,15 +102,13 @@ export class MSC4108SignInWithQR { private client?: MatrixClient, public onFailure?: RendezvousFailureListener, ) { - this.ourIntent = client - ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE - : RendezvousIntent.LOGIN_ON_NEW_DEVICE; + this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login; } /** * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. */ - public get code(): Buffer | undefined { + public get code(): Uint8Array | undefined { return this._code; } @@ -125,7 +124,7 @@ export class MSC4108SignInWithQR { } public get isExistingDevice(): boolean { - return this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + return this.ourIntent === QrCodeMode.Reciprocate; } public get isNewDevice(): boolean { diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index ca46d26ffd8..89f7750ce57 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -14,10 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Curve25519PublicKey, EstablishedSecureChannel, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; - -import { RendezvousError, RendezvousIntent, RendezvousFailureReason, MSC4108Payload } from ".."; -import { QRCodeData } from "../../crypto/verification/QRCode"; +import { + Curve25519PublicKey, + EstablishedSecureChannel, + QrCodeData, + QrCodeMode, + SecureChannel, +} from "@matrix-org/matrix-sdk-crypto-wasm"; + +import { MSC4108Payload, RendezvousError, RendezvousFailureReason } from ".."; import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession"; import { logger } from "../../logger"; @@ -37,14 +42,18 @@ export class MSC4108SecureChannel { this.secureChannel = new SecureChannel(); } - public async generateCode(intent: RendezvousIntent, homeserverBaseUrl?: string): Promise { + public async generateCode(mode: QrCodeMode, homeserverBaseUrl?: string): Promise { const { url } = this.rendezvousSession; if (!url) { throw new Error("No rendezvous session URL"); } - return QRCodeData.createForRendezvous(intent, this.secureChannel.public_key(), url, homeserverBaseUrl); + return new QrCodeData( + this.secureChannel.public_key(), + url, + mode === QrCodeMode.Reciprocate ? homeserverBaseUrl : undefined, + ).to_bytes(); } public async connect(): Promise { diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index adc8a559df2..be83170b0a1 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { QRCodeData } from "../crypto/verification/QRCode"; +import type { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; import { MatrixClient } from "../matrix"; import { MSC4108SignInWithQR } from "./MSC4108SignInWithQR"; import { RendezvousError } from "./RendezvousError"; import { RendezvousFailureListener, RendezvousFailureReason } from "./RendezvousFailureReason"; -import { RendezvousIntent } from "./RendezvousIntent"; import { MSC4108SecureChannel } from "./channels"; import { MSC4108RendezvousSession } from "./transports"; @@ -42,9 +41,8 @@ export async function buildLoginFromScannedCode( code: Buffer, onFailure: RendezvousFailureListener, ): Promise<{ signin: MSC4108SignInWithQR; homeserverBaseUrl?: string }> { - const scannerIntent = client - ? RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE - : RendezvousIntent.LOGIN_ON_NEW_DEVICE; + const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); + const scannerIntent = client ? RustCrypto.QrCodeMode.Reciprocate : RustCrypto.QrCodeMode.Login; const { channel, homeserverBaseUrl } = await buildChannelFromCode(scannerIntent, code, onFailure); @@ -52,21 +50,18 @@ export async function buildLoginFromScannedCode( } async function buildChannelFromCode( - scannerIntent: RendezvousIntent, + scannerMode: QrCodeMode, code: Buffer, onFailure: RendezvousFailureListener, -): Promise<{ channel: MSC4108SecureChannel; intent: RendezvousIntent; homeserverBaseUrl?: string }> { - const { - intent: scannedIntent, - publicKey, - rendezvousSessionUrl, - homeserverBaseUrl, - } = await QRCodeData.parseForRendezvous(code); +): Promise<{ channel: MSC4108SecureChannel; intent: QrCodeMode; homeserverBaseUrl?: string }> { + const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); - if (scannedIntent === scannerIntent) { + const qrCodeData = RustCrypto.QrCodeData.from_bytes(code); + + if (qrCodeData.mode === scannerMode) { throw new RendezvousError( "The scanned intent is the same as the scanner intent", - scannerIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + scannerMode === RustCrypto.QrCodeMode.Login ? RendezvousFailureReason.OtherDeviceNotSignedIn : RendezvousFailureReason.OtherDeviceAlreadySignedIn, ); @@ -75,12 +70,12 @@ async function buildChannelFromCode( // need to validate the values const rendezvousSession = new MSC4108RendezvousSession({ onFailure, - url: rendezvousSessionUrl, + url: qrCodeData.rendevouz_url, }); return { - channel: new MSC4108SecureChannel(rendezvousSession, publicKey, onFailure), - intent: scannedIntent, - homeserverBaseUrl, + channel: new MSC4108SecureChannel(rendezvousSession, qrCodeData.public_key, onFailure), + intent: qrCodeData.mode, + homeserverBaseUrl: qrCodeData.homeserver_url, }; } From c59d4b476e35402590fb1896bde6ab2badf334db Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Mar 2024 16:08:20 +0000 Subject: [PATCH 11/81] Discard changes to src/crypto/verification/QRCode.ts --- src/crypto/verification/QRCode.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/crypto/verification/QRCode.ts b/src/crypto/verification/QRCode.ts index aebad528274..b4e43252eaf 100644 --- a/src/crypto/verification/QRCode.ts +++ b/src/crypto/verification/QRCode.ts @@ -136,15 +136,10 @@ enum Mode { VerifySelfUntrusted = 0x02, // We do not trust the master key } -export type IQrData = VerificationQrData; - -interface IBaseQrData { +interface IQrData { prefix: string; version: number; mode: Mode; -} - -interface VerificationQrData extends IBaseQrData { transactionId?: string; firstKeyB64: string; secondKeyB64: string; From 16db19f1c15b2ca950049b31533527248122d88a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 20 Mar 2024 13:58:42 +0000 Subject: [PATCH 12/81] Label flows and merge steps 2 and 3 --- src/rendezvous/MSC4108SignInWithQR.ts | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index a522f92da1f..53fd89937ed 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -139,8 +139,10 @@ export class MSC4108SignInWithQR { // 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 // PROTOTYPE: we should be checking that the advertised protocol is available const protocols: ProtocolsPayload = { @@ -152,6 +154,7 @@ export class MSC4108SignInWithQR { } } else { if (this.isNewDevice) { + // MSC4108-Flow: ExistingScanned // wait for protocols message logger.info("Waiting for protocols message"); const message = await this.receive(); @@ -161,38 +164,31 @@ export class MSC4108SignInWithQR { const protocolsMessage = message as ProtocolsPayload; return { homeserverBaseUrl: protocolsMessage.homeserver }; } else { + // MSC4108-Flow: NewScanned // nothing to do } } return {}; } - public async loginStep2(oidcClient: OidcClient): Promise { - if (this.isExistingDevice) { - throw new Error("loginStep2OnNewDevice() is not valid for existing devices"); - } - logger.info("loginStep2()"); - - this.oidcClient = oidcClient; - // do device grant - this.deviceAuthorizationResponse = await oidcClient.startDeviceAuthorization({}); - } - - public async loginStep3(): Promise<{ + public async loginStep2And3(oidcClient?: OidcClient): Promise<{ verificationUri?: string; userCode?: string; }> { + logger.info("loginStep2And3()"); if (this.isNewDevice) { - if (!this.deviceAuthorizationResponse) { - throw new Error("No device authorization response"); + if (!oidcClient) { + throw new Error("No oidc client"); } + this.oidcClient = oidcClient; + // start device grant + this.deviceAuthorizationResponse = await oidcClient.startDeviceAuthorization({}); const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete, user_code: userCode, } = this.deviceAuthorizationResponse; - // send mock for now, should be using values from step 2: const protocol: DeviceAuthorizationGrantProtocolPayload = { type: PayloadType.Protocol, protocol: "device_authorization_grant", @@ -202,9 +198,11 @@ export class MSC4108SignInWithQR { }, }; if (this.didScanCode) { + // MSC4108-Flow: NewScanned // send immediately await this.send(protocol); } else { + // MSC4108-Flow: ExistingScanned // we will send it later } @@ -239,8 +237,10 @@ export class MSC4108SignInWithQR { logger.info("loginStep4()"); if (this.didScanCode) { + // MSC4108-Flow: NewScanned // we already sent the protocol message } else { + // MSC4108-Flow: ExistingScanned // send it now if (!this.deviceAuthorizationResponse) { throw new Error("No device authorization response"); From 168e0050f10162da9c4f1edea5b564e4188d729c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 22 Mar 2024 16:28:32 +0000 Subject: [PATCH 13/81] Wire up rust-crypto qr secrets import/export Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/matrix-sdk-crypto-wasm.d.ts | 30 ++++++++++++++ src/crypto/index.ts | 11 ++--- src/rendezvous/MSC4108SignInWithQR.ts | 27 ++++-------- src/rust-crypto/rust-crypto.ts | 57 ++++++-------------------- 4 files changed, 54 insertions(+), 71 deletions(-) create mode 100644 src/@types/matrix-sdk-crypto-wasm.d.ts diff --git a/src/@types/matrix-sdk-crypto-wasm.d.ts b/src/@types/matrix-sdk-crypto-wasm.d.ts new file mode 100644 index 00000000000..f069d4032f8 --- /dev/null +++ b/src/@types/matrix-sdk-crypto-wasm.d.ts @@ -0,0 +1,30 @@ +/* +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 type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; +import { QRSecretsBundle } from "../crypto-api"; + +declare module "@matrix-org/matrix-sdk-crypto-wasm" { + interface OlmMachine { + importSecretsBundle(bundle: RustSdkCryptoJs.SecretsBundle): Promise; + exportSecretsBundle(): Promise; + } + + interface SecretsBundle { + // eslint-disable-next-line @typescript-eslint/naming-convention + to_json(): Promise; + } +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index a673de596fa..330e674144e 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -99,6 +99,7 @@ import { KeyBackupInfo, VerificationRequest as CryptoApiVerificationRequest, OwnDeviceKeys, + QRSecretsBundle, } from "../crypto-api"; import { Device, DeviceMap } from "../models/device"; import { deviceInfoToDevice } from "./device-converter"; @@ -581,17 +582,11 @@ export class Crypto extends TypedEventEmitter { + public async exportSecretsForQRLogin(): Promise { throw new Error("Method not implemented."); } - public async importSecretsForQRLogin(secrets: { - cross_signing?: { master_key: string; self_signing_key: string; user_signing_key: string } | undefined; - backup?: { algorithm: string; key: string; backup_version: string } | undefined; - }): Promise { + public async importSecretsForQRLogin(secrets: QRSecretsBundle): Promise { throw new Error("Method not implemented."); } diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 53fd89937ed..c98cac7414c 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -70,18 +70,8 @@ interface AcceptedPayload extends MSC4108Payload { type: PayloadType.Accepted; } -interface SecretsPayload extends MSC4108Payload { +interface SecretsPayload extends MSC4108Payload, QRSecretsBundle { type: PayloadType.Secrets; - cross_signing?: { - master_key: string; - self_signing_key: string; - user_signing_key: string; - }; - backup?: { - algorithm: string; - key: string; - backup_version: string; - }; } export class MSC4108SignInWithQR { @@ -313,7 +303,7 @@ export class MSC4108SignInWithQR { await this.send(payload); // then wait for secrets logger.info("Waiting for secrets message"); - const secrets = (await this.receive()) as SecretsPayload; + const secrets = await this.receive(); if (secrets.type !== PayloadType.Secrets) { throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); } @@ -340,21 +330,20 @@ export class MSC4108SignInWithQR { // PROTOTYPE: we should be validating that the device on the other end of the rendezvous did actually successfully authenticate as this device once we decide how that should be done // const { device_id: deviceId } = res as SuccessPayload; - const availableSecrets = (await this.client?.getCrypto()?.exportSecretsForQRLogin()) ?? {}; + const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQRLogin(); // send secrets - const secrets: SecretsPayload = { + await this.send({ type: PayloadType.Secrets, - ...availableSecrets, - }; - await this.send(secrets); + ...secretsBundle, + }); return {}; // done? // let the other side close the rendezvous session } } - private async receive(): Promise { - return (await this.channel.secureReceive()) as MSC4108Payload; + private async receive(): Promise { + return (await this.channel.secureReceive()) as T; } private async send(payload: MSC4108Payload): Promise { diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 3ccc8dd5981..96ceb10222d 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -48,6 +48,7 @@ import { KeyBackupCheck, KeyBackupInfo, OwnDeviceKeys, + QRSecretsBundle, UserVerificationStatus, VerificationRequest, } from "../crypto-api"; @@ -165,52 +166,20 @@ export class RustCrypto extends TypedEventEmitter { - const crossSigning: RustSdkCryptoJs.CrossSigningKeyExport | null = - await this.olmMachine.exportCrossSigningKeys(); - - const backup: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys(); - - return { - cross_signing: crossSigning - ? { - master_key: crossSigning.masterKey!, - self_signing_key: crossSigning.self_signing_key!, - user_signing_key: crossSigning.userSigningKey!, - } - : undefined, - backup: backup?.decryptionKey?.megolmV1PublicKey - ? { - algorithm: backup.decryptionKey.megolmV1PublicKey.algorithm, - key: backup.decryptionKey.toBase64(), - backup_version: backup.backupVersion!, - } - : undefined, - }; + public async exportSecretsForQRLogin(): Promise { + try { + const secretsBundle = await this.olmMachine.exportSecretsBundle(); + return secretsBundle.to_json(); + } catch (e) { + // No keys to export + return {}; + } } - public async importSecretsForQRLogin(secrets: { - cross_signing?: { master_key: string; self_signing_key: string; user_signing_key: string } | undefined; - backup?: { algorithm: string; key: string; backup_version: string } | undefined; - }): Promise { - if (secrets.cross_signing) { - // import the keys - await this.olmMachine.importCrossSigningKeys( - secrets.cross_signing.master_key, - secrets.cross_signing.self_signing_key, - secrets.cross_signing.user_signing_key, - ); - - // cross sign our device - // PROTOTYPE: Not implemented - } - if (secrets.backup) { - // PROTOTYPE: Not implemented - // await this.olmMachine.importBackupKeys(secrets.backup.key, secrets.backup.algorithm, secrets.backup.backup_version); - } + public async importSecretsForQRLogin(secrets: QRSecretsBundle): Promise { + console.log("@@", secrets); + const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); + return this.olmMachine.importSecretsBundle(secretsBundle); } /** * Return the OlmMachine only if {@link RustCrypto#stop} has not been called. From 956af05411bf35fe76eec32fd7e96676015fc729 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 22 Mar 2024 16:40:24 +0000 Subject: [PATCH 14/81] Remove spurious console log Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rust-crypto/rust-crypto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 96ceb10222d..0d552c7a78a 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -177,7 +177,6 @@ export class RustCrypto extends TypedEventEmitter { - console.log("@@", secrets); const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); return this.olmMachine.importSecretsBundle(secretsBundle); } From c03d7468f4a13d83701667622d4bf5f0f0f3fefe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 14:11:56 +0000 Subject: [PATCH 15/81] Free rust crypto structures Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rust-crypto/rust-crypto.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index ff53e9db8de..46c07bed2cd 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -175,7 +175,9 @@ export class RustCrypto extends TypedEventEmitter { try { const secretsBundle = await this.olmMachine.exportSecretsBundle(); - return secretsBundle.to_json(); + const secrets = secretsBundle.to_json(); + secretsBundle.free(); + return secrets; } catch (e) { // No keys to export return {}; @@ -184,8 +186,10 @@ export class RustCrypto extends TypedEventEmitter { const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); - return this.olmMachine.importSecretsBundle(secretsBundle); + await this.olmMachine.importSecretsBundle(secretsBundle); + secretsBundle.free(); } + /** * Return the OlmMachine only if {@link RustCrypto#stop} has not been called. * From 28980c0a07e3292e190aaff4f982d523b1092a1a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 15:44:22 +0000 Subject: [PATCH 16/81] Fix free throwing an error Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rust-crypto/rust-crypto.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 46c07bed2cd..5dbdd0cb19c 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -174,7 +174,7 @@ export class RustCrypto extends TypedEventEmitter { try { - const secretsBundle = await this.olmMachine.exportSecretsBundle(); + const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle(); const secrets = secretsBundle.to_json(); secretsBundle.free(); return secrets; @@ -186,8 +186,7 @@ export class RustCrypto extends TypedEventEmitter { const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); - await this.olmMachine.importSecretsBundle(secretsBundle); - secretsBundle.free(); + await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle } /** From 9a00126d2d32f6a5d1e11b93fd0795b59d13f96e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 16:28:32 +0000 Subject: [PATCH 17/81] Check IdP supports device_code scope before requesting it Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/oidc/register.ts | 51 ++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/src/oidc/register.ts b/src/oidc/register.ts index eaed61261cf..5e20ca37db5 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -50,24 +50,36 @@ interface OidcRegistrationRequestBody { } /** - * Make the client registration request - * @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration - * @param clientMetadata - registration metadata - * @returns resolves to the registered client id when registration is successful - * @throws An `Error` with `message` set to an entry in {@link OidcError}, - * when the registration request fails, or the response is invalid. + * Attempts dynamic registration against the configured registration endpoint + * @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown} + * @param clientMetadata - The metadata for the client which to register + * @returns Promise resolved with registered clientId + * @throws when registration is not supported, on failed request or invalid response */ -const doRegistration = async ( - registrationEndpoint: string, +export const registerOidcClient = async ( + delegatedAuthConfig: OidcClientConfig, clientMetadata: OidcRegistrationClientMetadata, ): Promise => { + if (!delegatedAuthConfig.registrationEndpoint) { + throw new Error(OidcError.DynamicRegistrationNotSupported); + } + + const grantTypes: NonEmptyArray = ["authorization_code", "refresh_token"]; + if (grantTypes.some((scope) => !delegatedAuthConfig.metadata.grant_types_supported.includes(scope))) { + throw new Error(OidcError.DynamicRegistrationNotSupported); + } + + const deviceCodeScope = "urn:ietf:params:oauth:grant-type:device_code"; + if (delegatedAuthConfig.metadata.grant_types_supported.includes(deviceCodeScope)) { + grantTypes.push(deviceCodeScope); + } + // https://openid.net/specs/openid-connect-registration-1_0.html - // PROTOTYPE: should check which scopes are supported by the OP const metadata: OidcRegistrationRequestBody = { client_name: clientMetadata.clientName, client_uri: clientMetadata.clientUri, response_types: ["code"], - grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + grant_types: grantTypes, redirect_uris: clientMetadata.redirectUris, id_token_signed_response_alg: "RS256", token_endpoint_auth_method: "none", @@ -83,7 +95,7 @@ const doRegistration = async ( }; try { - const response = await fetch(registrationEndpoint, { + const response = await fetch(delegatedAuthConfig.registrationEndpoint, { method: Method.Post, headers, body: JSON.stringify(metadata), @@ -109,20 +121,3 @@ const doRegistration = async ( } } }; - -/** - * Attempts dynamic registration against the configured registration endpoint - * @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown} - * @param clientMetadata - The metadata for the client which to register - * @returns Promise resolved with registered clientId - * @throws when registration is not supported, on failed request or invalid response - */ -export const registerOidcClient = async ( - delegatedAuthConfig: OidcClientConfig, - clientMetadata: OidcRegistrationClientMetadata, -): Promise => { - if (!delegatedAuthConfig.registrationEndpoint) { - throw new Error(OidcError.DynamicRegistrationNotSupported); - } - return doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata); -}; From 6b01af27349ac6cf90f58a0dc8859235e84f153a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 16:31:37 +0000 Subject: [PATCH 18/81] prettier Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 37b7c56fb1a..41a0a75e52b 100644 --- a/package.json +++ b/package.json @@ -132,4 +132,4 @@ "outputName": "jest-sonar-report.xml", "relativePaths": true } -} \ No newline at end of file +} From cf999b77284a3e2afec55f3cf5e8b06fad8e7a1d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 18:19:41 +0000 Subject: [PATCH 19/81] Remove changes which rely on major oidc-client-ts upstream changes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- src/@types/oidc-client-ts.d.ts | 24 ++++++ src/oidc/validate.ts | 3 +- src/rendezvous/MSC4108SignInWithQR.ts | 105 +------------------------- yarn.lock | 5 +- 5 files changed, 34 insertions(+), 105 deletions(-) create mode 100644 src/@types/oidc-client-ts.d.ts diff --git a/package.json b/package.json index 41a0a75e52b..09e51551b62 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "loglevel": "^1.7.1", "matrix-events-sdk": "0.0.1", "matrix-widget-api": "^1.6.0", - "oidc-client-ts": "github:hughns/oidc-client-ts#hughns/device-flow", + "oidc-client-ts": "^3.0.1", "p-retry": "4", "sdp-transform": "^2.14.1", "unhomoglyph": "^1.0.6", diff --git a/src/@types/oidc-client-ts.d.ts b/src/@types/oidc-client-ts.d.ts new file mode 100644 index 00000000000..988a680badf --- /dev/null +++ b/src/@types/oidc-client-ts.d.ts @@ -0,0 +1,24 @@ +/* +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 "oidc-client-ts"; + +declare module "oidc-client-ts" { + interface OidcMetadata { + // Add the missing device_authorization_endpoint field to the OidcMetadata interface + device_authorization_endpoint?: string; + } +} diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 4deb1e9cae1..2a47d3cce6a 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -83,6 +83,7 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer requiredStringProperty(wellKnown, "revocation_endpoint"), optionalStringProperty(wellKnown, "registration_endpoint"), optionalStringProperty(wellKnown, "account_management_uri"), + optionalStringProperty(wellKnown, "device_authorization_endpoint"), optionalStringArrayProperty(wellKnown, "account_management_actions_supported"), requiredArrayValue(wellKnown, "response_types_supported", "code"), requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"), @@ -118,7 +119,7 @@ export type ValidatedIssuerMetadata = Partial & | "response_types_supported" | "grant_types_supported" | "code_challenge_methods_supported" - | "device_authorization_endpoint" // PROTOTYPE: this is actually optional, but the typings are wrong + | "device_authorization_endpoint" > & { // MSC2965 extensions to the OIDC spec account_management_uri?: string; diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index c98cac7414c..e0c4408fb8f 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceAccessTokenResponse, DeviceAuthorizationResponse, OidcClient } from "oidc-client-ts"; +import { OidcClient } from "oidc-client-ts"; import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; import { RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; @@ -78,8 +78,6 @@ export class MSC4108SignInWithQR { private ourIntent: QrCodeMode; private _code?: Uint8Array; public protocol?: string; - private oidcClient?: OidcClient; - private deviceAuthorizationResponse?: DeviceAuthorizationResponse; /** * @param channel - The secure channel used for communication @@ -167,36 +165,7 @@ export class MSC4108SignInWithQR { }> { logger.info("loginStep2And3()"); if (this.isNewDevice) { - if (!oidcClient) { - throw new Error("No oidc client"); - } - this.oidcClient = oidcClient; - // start device grant - this.deviceAuthorizationResponse = await oidcClient.startDeviceAuthorization({}); - - const { - verification_uri: verificationUri, - verification_uri_complete: verificationUriComplete, - user_code: userCode, - } = this.deviceAuthorizationResponse; - const protocol: DeviceAuthorizationGrantProtocolPayload = { - type: PayloadType.Protocol, - protocol: "device_authorization_grant", - device_authorization_grant: { - verification_uri: verificationUri, - verification_uri_complete: verificationUriComplete, - }, - }; - if (this.didScanCode) { - // MSC4108-Flow: NewScanned - // send immediately - await this.send(protocol); - } else { - // MSC4108-Flow: ExistingScanned - // we will send it later - } - - return { userCode: userCode }; + throw new Error("New device flows around OIDC are not yet implemented"); } 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 @@ -219,74 +188,8 @@ export class MSC4108SignInWithQR { } } - public async loginStep4(): Promise { - if (this.isExistingDevice) { - throw new Error("loginStep4() is not valid for existing devices"); - } - - logger.info("loginStep4()"); - - if (this.didScanCode) { - // MSC4108-Flow: NewScanned - // we already sent the protocol message - } else { - // MSC4108-Flow: ExistingScanned - // send it now - if (!this.deviceAuthorizationResponse) { - throw new Error("No device authorization response"); - } - const protocol: DeviceAuthorizationGrantProtocolPayload = { - type: PayloadType.Protocol, - protocol: "device_authorization_grant", - device_authorization_grant: { - verification_uri: this.deviceAuthorizationResponse.verification_uri, - verification_uri_complete: this.deviceAuthorizationResponse.verification_uri_complete, - }, - }; - await this.send(protocol); - } - - // wait for accepted message - const message = await this.receive(); - - if (message.type === PayloadType.Failure) { - throw new RendezvousError("Failed", (message as FailurePayload).reason); - } - if (message.type !== PayloadType.Accepted) { - throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); - } - - if (!this.deviceAuthorizationResponse) { - throw new Error("No device authorization response"); - } - if (!this.oidcClient) { - throw new Error("No oidc client"); - } - // poll for DAG - const res = await this.oidcClient.waitForDeviceAuthorization(this.deviceAuthorizationResponse); - - if (!res) { - throw new RendezvousError( - "No response from device authorization endpoint", - RendezvousFailureReason.UnexpectedMessage, - ); - } - - if ("error" in res) { - let reason = RendezvousFailureReason.Unknown; - if (res.error === "expired_token") { - reason = RendezvousFailureReason.Expired; - } else if (res.error === "access_denied") { - reason = RendezvousFailureReason.UserDeclined; - } - const payload: FailurePayload = { - type: PayloadType.Failure, - reason, - }; - await this.send(payload); - } - - return res as DeviceAccessTokenResponse; + public async loginStep4(): Promise { + throw new Error("New device flows around OIDC are not yet implemented"); } public async loginStep5(deviceId?: string): Promise<{ secrets?: QRSecretsBundle }> { diff --git a/yarn.lock b/yarn.lock index aed79f77eec..4c24eeb51f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5147,9 +5147,10 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -"oidc-client-ts@github:hughns/oidc-client-ts#hughns/device-flow": +oidc-client-ts@^3.0.1: version "3.0.1" - resolved "https://codeload.github.com/hughns/oidc-client-ts/tar.gz/456bdee9ca3c284e6626480e905987bfb79acbaf" + resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7" + integrity sha512-xX8unZNtmtw3sOz4FPSqDhkLFnxCDsdo2qhFEH2opgWnF/iXMFoYdBQzkwCxAZVgt3FT3DnuBY3k80EZHT0RYg== dependencies: jwt-decode "^4.0.0" From a490ebf62a318e416105828af1847957df6ed8d9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 25 Mar 2024 18:26:37 +0000 Subject: [PATCH 20/81] Fix copyrights Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 2 +- src/rendezvous/channels/MSC4108SecureChannel.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index e0c4408fb8f..4fc31ec44a5 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 89f7750ce57..616662e7df9 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +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. From 42953c871bbb37c9176604448d05e0a8063ebacb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 26 Mar 2024 14:52:56 +0000 Subject: [PATCH 21/81] Make tsc happier Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/matrix-sdk-crypto-wasm.d.ts | 14 ++++++++++++-- src/crypto-api.ts | 14 ++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/@types/matrix-sdk-crypto-wasm.d.ts b/src/@types/matrix-sdk-crypto-wasm.d.ts index f069d4032f8..1a2b0adf695 100644 --- a/src/@types/matrix-sdk-crypto-wasm.d.ts +++ b/src/@types/matrix-sdk-crypto-wasm.d.ts @@ -15,7 +15,6 @@ limitations under the License. */ import type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; -import { QRSecretsBundle } from "../crypto-api"; declare module "@matrix-org/matrix-sdk-crypto-wasm" { interface OlmMachine { @@ -25,6 +24,17 @@ declare module "@matrix-org/matrix-sdk-crypto-wasm" { interface SecretsBundle { // eslint-disable-next-line @typescript-eslint/naming-convention - to_json(): Promise; + to_json(): Promise<{ + cross_signing?: { + master_key: string; + self_signing_key: string; + user_signing_key: string; + }; + backup?: { + algorithm: string; + key: string; + backup_version: string; + }; + }>; } } diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 8823403e5bf..70a4f20235d 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm"; import type { IMegolmSessionData } from "./@types/crypto"; import { Room } from "./models/room"; import { DeviceMap } from "./models/device"; @@ -24,18 +25,7 @@ import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/key import { ISignatures } from "./@types/signed"; import { MatrixEvent } from "./models/event"; -export interface QRSecretsBundle { - cross_signing?: { - master_key: string; - self_signing_key: string; - user_signing_key: string; - }; - backup?: { - algorithm: string; - key: string; - backup_version: string; - }; -} +export type QRSecretsBundle = Awaited>; /** * Public interface to the cryptography parts of the js-sdk From a839a11af6d0cec272e9a5ec484b895791760924 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 26 Mar 2024 17:24:52 +0000 Subject: [PATCH 22/81] Rename m.login.accepted to m.login.protocol_accepted Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 4fc31ec44a5..e81ba7a21ea 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -29,7 +29,7 @@ export enum PayloadType { Failure = "m.login.failure", Success = "m.login.success", Secrets = "m.login.secrets", - Accepted = "m.login.accepted", + ProtocolAccepted = "m.login.protocol_accepted", } export interface MSC4108Payload { @@ -67,7 +67,7 @@ interface SuccessPayload extends MSC4108Payload { } interface AcceptedPayload extends MSC4108Payload { - type: PayloadType.Accepted; + type: PayloadType.ProtocolAccepted; } interface SecretsPayload extends MSC4108Payload, QRSecretsBundle { @@ -214,7 +214,7 @@ export class MSC4108SignInWithQR { // then done? } else { const payload: AcceptedPayload = { - type: PayloadType.Accepted, + type: PayloadType.ProtocolAccepted, }; await this.send(payload); From e414067c3ee0b4c136d6d87dfa051056b5e5a099 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 27 Mar 2024 15:38:09 +0000 Subject: [PATCH 23/81] Improve test coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/crypto/secrets.spec.ts | 10 ++++ spec/unit/oidc/register.spec.ts | 64 +++++++++++++++++++++++ spec/unit/rust-crypto/rust-crypto.spec.ts | 36 +++++++++++++ 3 files changed, 110 insertions(+) diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 27842e8778b..2dc575925f8 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -686,4 +686,14 @@ describe("Secrets", function () { alice.stopClient(); }); }); + + it("should throw Not Implemented for importSecretsForQRLogin", async () => { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); + await expect(alice.getCrypto()?.importSecretsForQRLogin({})).rejects.toThrow("Method not implemented."); + }); + + it("should throw Not Implemented for exportSecretsForQRLogin", async () => { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); + await expect(alice.getCrypto()?.exportSecretsForQRLogin()).rejects.toThrow("Method not implemented."); + }); }); diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts index 372f7d677c4..84b25b470ad 100644 --- a/spec/unit/oidc/register.spec.ts +++ b/spec/unit/oidc/register.spec.ts @@ -90,4 +90,68 @@ describe("registerOidcClient()", () => { OidcError.DynamicRegistrationInvalid, ); }); + + it("should throw when required endpoints are unavailable", async () => { + await expect(() => + registerOidcClient( + { + ...delegatedAuthConfig, + registrationEndpoint: undefined, + }, + metadata, + ), + ).rejects.toThrow(OidcError.DynamicRegistrationNotSupported); + }); + + it("should throw when required scopes are unavailable", async () => { + await expect(() => + registerOidcClient( + { + ...delegatedAuthConfig, + metadata: { + ...delegatedAuthConfig.metadata, + grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]], + }, + }, + metadata, + ), + ).rejects.toThrow(OidcError.DynamicRegistrationNotSupported); + }); + + it("should request device_code scope if it is supported", async () => { + fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { + status: 200, + body: JSON.stringify({ client_id: dynamicClientId }), + }); + + await expect(registerOidcClient(delegatedAuthConfig, metadata)).resolves.toBe(dynamicClientId); + expect(fetchMockJest).toHaveFetched(delegatedAuthConfig.registrationEndpoint!, { + matchPartialBody: true, + body: { + grant_types: ["authorization_code", "refresh_token"], + }, + }); + + await expect( + registerOidcClient( + { + ...delegatedAuthConfig, + metadata: { + ...delegatedAuthConfig.metadata, + grant_types_supported: [ + ...delegatedAuthConfig.metadata.grant_types_supported, + "urn:ietf:params:oauth:grant-type:device_code", + ], + }, + }, + metadata, + ), + ).resolves.toBe(dynamicClientId); + expect(fetchMockJest).toHaveFetched(delegatedAuthConfig.registrationEndpoint!, { + matchPartialBody: true, + body: { + grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + }, + }); + }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index b9c76c9fb27..99cd73931ec 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1395,6 +1395,42 @@ describe("RustCrypto", () => { }); }); }); + + describe("exportSecretsForQRLogin", () => { + let rustCrypto: RustCrypto; + + beforeEach(async () => { + rustCrypto = await makeTestRustCrypto( + new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl: "http://server/", + prefix: "", + onlyData: true, + }), + testData.TEST_USER_ID, + ); + }); + + it("should return an empty object if there is nothing to export", async () => { + await expect(rustCrypto.exportSecretsForQRLogin()).resolves.toEqual({}); + }); + + it("should return a JSON secrets bundle if there is something to export", async () => { + const bundle = { + cross_signing: { + master_key: "bMnVpkHI4S2wXRxy+IpaKM5PIAUUkl6DE+n0YLIW/qs", + user_signing_key: "8tlgLjUrrb/zGJo4YKGhDTIDCEjtJTAS/Sh2AGNLuIo", + self_signing_key: "pfDknmP5a0fVVRE54zhkUgJfzbNmvKcNfIWEW796bQs", + }, + backup: { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + key: "bYYv3aFLQ49jMNcOjuTtBY9EKDby2x1m3gfX81nIKRQ", + backup_version: "9", + }, + }; + await rustCrypto.importSecretsForQRLogin(bundle); + await expect(rustCrypto.exportSecretsForQRLogin()).resolves.toEqual(expect.objectContaining(bundle)); + }); + }); }); /** Build a MatrixHttpApi instance */ From 6ccf8555c4842b3bfd8f4fc451d725b67e1a42df Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 29 Mar 2024 13:03:44 +0000 Subject: [PATCH 24/81] Remove sections related to scanning QR codes to simplify Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/index.ts | 52 ----------------------------------------- 1 file changed, 52 deletions(-) diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index be83170b0a1..1b887d9c3d9 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -14,14 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { MatrixClient } from "../matrix"; -import { MSC4108SignInWithQR } from "./MSC4108SignInWithQR"; -import { RendezvousError } from "./RendezvousError"; -import { RendezvousFailureListener, RendezvousFailureReason } from "./RendezvousFailureReason"; -import { MSC4108SecureChannel } from "./channels"; -import { MSC4108RendezvousSession } from "./transports"; - /** * @deprecated in favour of MSC4108-based implementation */ @@ -35,47 +27,3 @@ export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; export * from "./transports"; export * from "./channels"; - -export async function buildLoginFromScannedCode( - client: MatrixClient | undefined, - code: Buffer, - onFailure: RendezvousFailureListener, -): Promise<{ signin: MSC4108SignInWithQR; homeserverBaseUrl?: string }> { - const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); - const scannerIntent = client ? RustCrypto.QrCodeMode.Reciprocate : RustCrypto.QrCodeMode.Login; - - const { channel, homeserverBaseUrl } = await buildChannelFromCode(scannerIntent, code, onFailure); - - return { signin: new MSC4108SignInWithQR(channel, true, client, onFailure), homeserverBaseUrl }; -} - -async function buildChannelFromCode( - scannerMode: QrCodeMode, - code: Buffer, - onFailure: RendezvousFailureListener, -): Promise<{ channel: MSC4108SecureChannel; intent: QrCodeMode; homeserverBaseUrl?: string }> { - const RustCrypto = await import("@matrix-org/matrix-sdk-crypto-wasm"); - - const qrCodeData = RustCrypto.QrCodeData.from_bytes(code); - - if (qrCodeData.mode === scannerMode) { - throw new RendezvousError( - "The scanned intent is the same as the scanner intent", - scannerMode === RustCrypto.QrCodeMode.Login - ? RendezvousFailureReason.OtherDeviceNotSignedIn - : RendezvousFailureReason.OtherDeviceAlreadySignedIn, - ); - } - - // need to validate the values - const rendezvousSession = new MSC4108RendezvousSession({ - onFailure, - url: qrCodeData.rendevouz_url, - }); - - return { - channel: new MSC4108SecureChannel(rendezvousSession, qrCodeData.public_key, onFailure), - intent: qrCodeData.mode, - homeserverBaseUrl: qrCodeData.homeserver_url, - }; -} From d7dad56d46b7e728149a0934448cee42b13fc5af Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 29 Mar 2024 13:36:07 +0000 Subject: [PATCH 25/81] Add testing and streamline Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../MSC4108RendezvousSession.spec.ts | 460 ++++++++++++++++++ .../transports/MSC4108RendezvousSession.ts | 6 - 2 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts new file mode 100644 index 00000000000..08d0ffabf6c --- /dev/null +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -0,0 +1,460 @@ +/* +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 MockHttpBackend from "matrix-mock-request"; + +import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src"; +import { RendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous"; + +function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { + const client = { + doesServerSupportUnstableFeature(feature: string) { + return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108"); + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + baseUrl: "https://example.com", + } as unknown as MatrixClient; + client.http = new MatrixHttpApi(client, { + baseUrl: client.baseUrl, + prefix: ClientPrefix.Unstable, + onlyData: true, + }); + return client; +} + +describe("MSC4108RendezvousSession", () => { + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetch; + + beforeEach(function () { + httpBackend = new MockHttpBackend(); + fetchFn = httpBackend.fetchFn as typeof global.fetch; + }); + + async function postAndCheckLocation( + msc4108Enabled: boolean, + fallbackRzServer: string, + locationResponse: string, + expectedFinalLocation: string, + ) { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled }); + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer, fetchFn }); + { + // initial POST + const expectedPostLocation = msc4108Enabled + ? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous` + : fallbackRzServer; + + const prom = transport.send("data"); + httpBackend.when("POST", expectedPostLocation).response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: locationResponse, + }, + }, + }; + await httpBackend.flush(""); + await prom; + } + + { + // first GET without etag + const prom = transport.receive(); + httpBackend.when("GET", expectedFinalLocation).response = { + body: "data", + rawBody: true, + response: { + statusCode: 200, + headers: { + "content-type": "text/plain", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toEqual("data"); + httpBackend.verifyNoOutstandingRequests(); + httpBackend.verifyNoOutstandingExpectation(); + } + } + it("should throw an error when no server available", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ client, fetchFn }); + await expect(simpleHttpTransport.send("data")).rejects.toThrow("Invalid rendezvous URI"); + }); + + it("POST to fallback server", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send("data"); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toStrictEqual(undefined); + }); + + it("POST with no location", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send("data"); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: {}, + }, + }; + await Promise.all([expect(prom).rejects.toThrow(), httpBackend.flush("")]); + }); + + it("POST with absolute path response", async function () { + await postAndCheckLocation(false, "https://fallbackserver/rz", "/123", "https://fallbackserver/123"); + }); + + it("POST to built-in MSC3886 implementation", async function () { + await postAndCheckLocation( + true, + "https://fallbackserver/rz", + "123", + "https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123", + ); + }); + + it("POST with relative path response including parent", async function () { + await postAndCheckLocation( + false, + "https://fallbackserver/rz/abc", + "../xyz/123", + "https://fallbackserver/rz/xyz/123", + ); + }); + + it("POST to follow 307 to other server", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send("data"); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 307, + headers: { + location: "https://redirected.fallbackserver/rz", + }, + }, + }; + httpBackend.when("POST", "https://redirected.fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://redirected.fallbackserver/rz/123", + etag: "aaa", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toStrictEqual(undefined); + }); + + it("POST and GET", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { + // initial POST + const prom = simpleHttpTransport.send("foo=baa"); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { + expect(headers["content-type"]).toEqual("text/plain"); + expect(rawData).toEqual("foo=baa"); + }).response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toStrictEqual(undefined); + } + { + // first GET without etag + const prom = simpleHttpTransport.receive(); + httpBackend.when("GET", "https://fallbackserver/rz/123").response = { + body: "foo=baa", + rawBody: true, + response: { + statusCode: 200, + headers: { + "content-type": "text/plain", + "etag": "aaa", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toEqual("foo=baa"); + } + { + // subsequent GET which should have etag from previous request + const prom = simpleHttpTransport.receive(); + httpBackend.when("GET", "https://fallbackserver/rz/123").check(({ headers }) => { + expect(headers["if-none-match"]).toEqual("aaa"); + }).response = { + body: "foo=baa", + rawBody: true, + response: { + statusCode: 200, + headers: { + "content-type": "text/plain", + "etag": "bbb", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toEqual("foo=baa"); + } + }); + + it("POST and PUTs", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { + // initial POST + const prom = simpleHttpTransport.send("foo=baa"); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { + expect(headers["content-type"]).toEqual("text/plain"); + expect(rawData).toEqual("foo=baa"); + }).response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush("", 1); + await prom; + } + { + // first PUT without etag + const prom = simpleHttpTransport.send("a=b"); + httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers, rawData }) => { + expect(headers["if-match"]).toBeUndefined(); + expect(rawData).toEqual("a=b"); + }).response = { + body: null, + rawBody: true, + response: { + statusCode: 202, + headers: { + etag: "aaa", + }, + }, + }; + await httpBackend.flush("", 1); + await prom; + } + { + // subsequent PUT which should have etag from previous request + const prom = simpleHttpTransport.send("c=d"); + httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers }) => { + expect(headers["if-match"]).toEqual("aaa"); + }).response = { + body: null, + rawBody: true, + response: { + statusCode: 202, + headers: { + etag: "bbb", + }, + }, + }; + await httpBackend.flush("", 1); + await prom; + } + }); + + it("POST and DELETE", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { + // Create + const prom = simpleHttpTransport.send("foo=baa"); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { + expect(headers["content-type"]).toEqual("text/plain"); + expect(rawData).toEqual("foo=baa"); + }).response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(""); + expect(await prom).toStrictEqual(undefined); + } + { + // Cancel + const prom = simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + httpBackend.when("DELETE", "https://fallbackserver/rz/123").response = { + body: null, + rawBody: true, + response: { + statusCode: 204, + headers: {}, + }, + }; + await httpBackend.flush(""); + await prom; + } + }); + + it("send after cancelled", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + await expect(simpleHttpTransport.send("data")).resolves.toBeUndefined(); + }); + + it("receive before ready", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + await expect(simpleHttpTransport.receive()).rejects.toThrow(); + }); + + it("404 failure callback", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const onFailure = jest.fn(); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + onFailure, + }); + + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 404, + headers: {}, + }, + }; + await Promise.all([ + expect(simpleHttpTransport.send("foo=baa")).resolves.toBeUndefined(), + httpBackend.flush("", 1), + ]); + expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); + }); + + it("404 failure callback mapped to expired", async function () { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const onFailure = jest.fn(); + const simpleHttpTransport = new MSC4108RendezvousSession({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + onFailure, + }); + + { + // initial POST + const prom = simpleHttpTransport.send("foo=baa"); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + rawBody: true, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + expires: "Thu, 01 Jan 1970 00:00:00 GMT", + }, + }, + }; + await httpBackend.flush(""); + await prom; + } + { + // GET with 404 to simulate expiry + httpBackend.when("GET", "https://fallbackserver/rz/123").response = { + body: "foo=baa", + rawBody: true, + response: { + statusCode: 404, + headers: {}, + }, + }; + await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]); + expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired); + } + }); +}); diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index f4a5b791161..14b0291f872 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -90,12 +90,6 @@ export class MSC4108RendezvousSession { private async getPostEndpoint(): Promise { if (this.client) { try { - // whilst prototyping we can use the MSC3886 endpoint if available - if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { - return this.client.http - .getUrl("/org.matrix.msc3886/rendezvous", undefined, ClientPrefix.Unstable) - .toString(); - } if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) { return this.client.http .getUrl("/org.matrix.msc4108/rendezvous", undefined, ClientPrefix.Unstable) From ba29d4e92826779037eafaedfdd33cfa49d906d9 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 26 Mar 2024 18:47:36 +0000 Subject: [PATCH 26/81] Mock of checkCode --- src/rendezvous/MSC4108SignInWithQR.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index e81ba7a21ea..6bfb32cb3a2 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -79,6 +79,9 @@ export class MSC4108SignInWithQR { private _code?: Uint8Array; public protocol?: string; + // PROTOTYPE: this is mocked for now + public checkCode: string | undefined = "99"; + /** * @param channel - The secure channel used for communication * @param client - The Matrix client in used on the device already logged in From 4b847b1a2118b7d417b2bbe788ed562c95f09038 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 Mar 2024 16:54:50 +0000 Subject: [PATCH 27/81] Implementation of option 3c for when to share secrets Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 45 +++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 6bfb32cb3a2..c21da9f9145 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -22,6 +22,7 @@ import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; import { QRSecretsBundle } from "../crypto-api"; +import { MatrixError } from "../http-api"; export enum PayloadType { Protocols = "m.login.protocols", @@ -45,6 +46,7 @@ interface ProtocolsPayload extends MSC4108Payload { interface ProtocolPayload extends MSC4108Payload { type: PayloadType.Protocol; protocol: string; + device_id: string; } interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload { @@ -63,7 +65,6 @@ interface FailurePayload extends MSC4108Payload { interface SuccessPayload extends MSC4108Payload { type: PayloadType.Success; - device_id: string; } interface AcceptedPayload extends MSC4108Payload { @@ -78,6 +79,7 @@ export class MSC4108SignInWithQR { private ourIntent: QrCodeMode; private _code?: Uint8Array; public protocol?: string; + private expectingNewDeviceId?: string; // PROTOTYPE: this is mocked for now public checkCode: string | undefined = "99"; @@ -179,10 +181,32 @@ export class MSC4108SignInWithQR { if (message && message.type === PayloadType.Protocol) { const protocolMessage = message as ProtocolPayload; if (protocolMessage.protocol === "device_authorization_grant") { - const { device_authorization_grant: dag } = + const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = protocolMessage as DeviceAuthorizationGrantProtocolPayload; const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag; + + // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: + // check if there is already a device online with that device ID + + let deviceAlreadyExists = true; + try { + await this.client?.getDevice(expectingNewDeviceId); + } catch (err: MatrixError | unknown) { + if (err instanceof MatrixError && err.httpStatus === 404) { + deviceAlreadyExists = false; + } + } + + if (deviceAlreadyExists) { + throw new RendezvousError( + "Specified device ID already exists", + RendezvousFailureReason.DataMismatch, + ); + } + + this.expectingNewDeviceId = expectingNewDeviceId; + return { verificationUri: verificationUriComplete ?? verificationUri }; } } @@ -195,16 +219,12 @@ export class MSC4108SignInWithQR { throw new Error("New device flows around OIDC are not yet implemented"); } - public async loginStep5(deviceId?: string): Promise<{ secrets?: QRSecretsBundle }> { + public async loginStep5(): Promise<{ secrets?: QRSecretsBundle }> { logger.info("loginStep5()"); if (this.isNewDevice) { - if (!deviceId) { - throw new Error("No new device id"); - } const payload: SuccessPayload = { type: PayloadType.Success, - device_id: deviceId, }; await this.send(payload); // then wait for secrets @@ -216,6 +236,9 @@ export class MSC4108SignInWithQR { return { secrets }; // then done? } else { + if (!this.expectingNewDeviceId) { + throw new Error("No new device ID expected"); + } const payload: AcceptedPayload = { type: PayloadType.ProtocolAccepted, }; @@ -233,8 +256,12 @@ export class MSC4108SignInWithQR { throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); } - // PROTOTYPE: we should be validating that the device on the other end of the rendezvous did actually successfully authenticate as this device once we decide how that should be done - // const { device_id: deviceId } = res as SuccessPayload; + // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: + const device = await this.client?.getDevice(this.expectingNewDeviceId); + + if (!device) { + throw new RendezvousError("New device not found", RendezvousFailureReason.DataMismatch); + } const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQRLogin(); // send secrets From 9827061cbf4a5f73518b0faf11dee2a71dc970ef Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 27 Mar 2024 16:56:25 +0000 Subject: [PATCH 28/81] Use more accurate return type for secureReceive() Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index c21da9f9145..5190b38aa44 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -230,7 +230,7 @@ export class MSC4108SignInWithQR { // then wait for secrets logger.info("Waiting for secrets message"); const secrets = await this.receive(); - if (secrets.type !== PayloadType.Secrets) { + if (secrets?.type !== PayloadType.Secrets) { throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); } return { secrets }; @@ -247,12 +247,12 @@ export class MSC4108SignInWithQR { logger.info("Waiting for outcome message"); const res = await this.receive(); - if (res.type === PayloadType.Failure) { + if (res?.type === PayloadType.Failure) { const { reason } = res as FailurePayload; throw new RendezvousError("Failed", reason); } - if (res.type != PayloadType.Success) { + if (res?.type !== PayloadType.Success) { throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); } @@ -275,8 +275,8 @@ export class MSC4108SignInWithQR { } } - private async receive(): Promise { - return (await this.channel.secureReceive()) as T; + private async receive(): Promise { + return (await this.channel.secureReceive()) as T | undefined; } private async send(payload: MSC4108Payload): Promise { From 2a47077201b3d3c383cca17e57d48b83b0fccf82 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 2 Apr 2024 11:13:54 +0100 Subject: [PATCH 29/81] Split login step 4 and fix step 3 where didn't scan code Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 5190b38aa44..b36ac0313bf 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -215,7 +215,11 @@ export class MSC4108SignInWithQR { } } - public async loginStep4(): Promise { + public async loginStep4a(): Promise { + throw new Error("New device flows around OIDC are not yet implemented"); + } + + public async loginStep4b(): Promise { throw new Error("New device flows around OIDC are not yet implemented"); } From e5d9437a092fdb9b3ecba813cf3054b65783982d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 2 Apr 2024 17:24:00 +0100 Subject: [PATCH 30/81] Use check code from crypto-wasm --- src/rendezvous/MSC4108SignInWithQR.ts | 12 ++++++++++-- src/rendezvous/channels/MSC4108SecureChannel.ts | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index b36ac0313bf..fe41bd6f568 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -81,8 +81,16 @@ export class MSC4108SignInWithQR { public protocol?: string; private expectingNewDeviceId?: string; - // PROTOTYPE: this is mocked for now - public checkCode: string | undefined = "99"; + public get checkCode(): string | undefined { + const x = this.channel?.getCheckCode(); + + if (!x) { + return undefined; + } + return Array.from(x.as_bytes()) + .map((b) => `${b % 10}`) + .join(""); + } /** * @param channel - The secure channel used for communication diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 616662e7df9..9b1ac58c938 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { + CheckCode, Curve25519PublicKey, EstablishedSecureChannel, QrCodeData, @@ -56,6 +57,10 @@ export class MSC4108SecureChannel { ).to_bytes(); } + public getCheckCode(): CheckCode | undefined { + return this.establishedChannel?.check_code(); + } + public async connect(): Promise { if (this.connected) { throw new Error("Channel already connected"); From c7af7ad2f94e7166ad214b1397780124f980892b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 5 Apr 2024 15:14:12 +0100 Subject: [PATCH 31/81] Support for MSC4108 --- .../transports/MSC4108RendezvousSession.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 14b0291f872..292a315522d 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -115,6 +115,12 @@ export class MSC4108RendezvousSession { } const headers: Record = { "content-type": "text/plain" }; + + // if we didn't create the rendezvous channel, we need to fetch the first etag if needed + if (!this.etag && this.url) { + await this.receive(); + } + if (this.etag) { headers["if-match"] = this.etag; } @@ -130,19 +136,16 @@ export class MSC4108RendezvousSession { logger.info(`Received etag: ${this.etag}`); if (method === "POST") { - const location = res.headers.get("location"); - if (!location) { - throw new Error("No rendezvous URI given"); - } const expires = res.headers.get("expires"); if (expires) { this.expiresAt = new Date(expires); } - // we would usually expect the final `url` to be set by a proper fetch implementation. - // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback - const baseUrl = res.url ?? uri; - // resolve location header which could be relative or absolute - this.url = new URL(location, `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}`).href; + // MSC4108: we expect a JSON response with a rendezvous URL + const json = await res.json(); + if (typeof json.url !== "string") { + throw new Error("No rendezvous URL given"); + } + this.url = json.url; this._ready = true; } } From 6cdeb616aadc16996eb398977f656d84a19c280b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 9 Apr 2024 08:31:45 +0100 Subject: [PATCH 32/81] Discard changes to src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts --- .../MSC3886SimpleHttpRendezvousTransport.ts | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts index 11770fffcf8..430ee92d1c7 100644 --- a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -42,22 +42,13 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende private uri?: string; private etag?: string; private expiresAt?: Date; - private client?: MatrixClient; + private client: MatrixClient; private fallbackRzServer?: string; private fetchFn?: typeof global.fetch; private cancelled = false; private _ready = false; public onFailure?: RendezvousFailureListener; - public constructor({ - onFailure, - details, - fetchFn, - }: { - fetchFn?: typeof global.fetch; - onFailure?: RendezvousFailureListener; - details: MSC3886SimpleHttpRendezvousTransportDetails; - }); public constructor({ onFailure, client, @@ -68,25 +59,11 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende onFailure?: RendezvousFailureListener; client: MatrixClient; fallbackRzServer?: string; - }); - public constructor({ - fetchFn, - onFailure, - details, - client, - fallbackRzServer, - }: { - fetchFn?: typeof global.fetch; - onFailure?: RendezvousFailureListener; - details?: MSC3886SimpleHttpRendezvousTransportDetails; - client?: MatrixClient; - fallbackRzServer?: string; }) { this.fetchFn = fetchFn; this.onFailure = onFailure; this.client = client; this.fallbackRzServer = fallbackRzServer; - this.uri = details?.uri; } public get ready(): boolean { @@ -112,14 +89,12 @@ export class MSC3886SimpleHttpRendezvousTransport implements Rende } private async getPostEndpoint(): Promise { - if (this.client) { - try { - if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { - return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; - } - } catch (err) { - logger.warn("Failed to get unstable features", err); + try { + if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) { + return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; } + } catch (err) { + logger.warn("Failed to get unstable features", err); } return this.fallbackRzServer; From 4a783dcd03701dbcc38c7d00fdd66098d56cafbb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 9 Apr 2024 09:36:56 +0100 Subject: [PATCH 33/81] Iterate UX Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/RendezvousFailureReason.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index 8eb39bbc025..ba614d8eddd 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -18,15 +18,11 @@ export type RendezvousFailureListener = (reason: RendezvousFailureReason) => voi export enum RendezvousFailureReason { UserDeclined = "user_declined", - OtherDeviceNotSignedIn = "other_device_not_signed_in", - OtherDeviceAlreadySignedIn = "other_device_already_signed_in", Unknown = "unknown", Expired = "expired", UserCancelled = "user_cancelled", - InvalidCode = "invalid_code", UnsupportedAlgorithm = "unsupported_algorithm", DataMismatch = "data_mismatch", - UnsupportedTransport = "unsupported_transport", HomeserverLacksSupport = "homeserver_lacks_support", UnexpectedMessage = "unexpected_message", } From f0c62692d13126ab3ff23f6e92bb1e53de034278 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 13:50:18 +0100 Subject: [PATCH 34/81] Wait 10 seconds for new device to come online --- src/rendezvous/MSC4108SignInWithQR.ts | 45 ++++++++++++++++++--------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index fe41bd6f568..dffdca0f24d 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -268,22 +268,37 @@ export class MSC4108SignInWithQR { throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); } - // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: - const device = await this.client?.getDevice(this.expectingNewDeviceId); - - if (!device) { - throw new RendezvousError("New device not found", RendezvousFailureReason.DataMismatch); - } + // PROTOTYPE: this also needs to handle the case of the process being cancelled + // i.e. aborting the waiting and making sure not to share the secrets + 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()!.exportSecretsForQRLogin(); + // send secrets + await this.send({ + type: PayloadType.Secrets, + ...secretsBundle, + }); + return { secrets: secretsBundle }; + // done? + // 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 new Promise((resolve) => setTimeout(resolve, 1000)); + } while (Date.now() < timeout); - const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQRLogin(); - // send secrets - await this.send({ - type: PayloadType.Secrets, - ...secretsBundle, - }); - return {}; - // done? - // let the other side close the rendezvous session + throw new RendezvousError("New device not found", RendezvousFailureReason.DataMismatch); } } From 806581be877bc1c2ebe1a333bfa31d30613076cd Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 13:59:58 +0100 Subject: [PATCH 35/81] Make type safe sends more elegant Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 24 ++++++++----------- .../channels/MSC4108SecureChannel.ts | 6 ++--- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index dffdca0f24d..5d236a75e7b 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -146,12 +146,11 @@ export class MSC4108SignInWithQR { // MSC4108-Flow: NewScanned // send protocols message // PROTOTYPE: we should be checking that the advertised protocol is available - const protocols: ProtocolsPayload = { + await this.send({ type: PayloadType.Protocols, protocols: ["device_authorization_grant"], homeserver: this.client?.getHomeserverUrl() ?? "", - }; - await this.send(protocols); + }); } } else { if (this.isNewDevice) { @@ -235,10 +234,9 @@ export class MSC4108SignInWithQR { logger.info("loginStep5()"); if (this.isNewDevice) { - const payload: SuccessPayload = { + await this.send({ type: PayloadType.Success, - }; - await this.send(payload); + }); // then wait for secrets logger.info("Waiting for secrets message"); const secrets = await this.receive(); @@ -251,10 +249,9 @@ export class MSC4108SignInWithQR { if (!this.expectingNewDeviceId) { throw new Error("No new device ID expected"); } - const payload: AcceptedPayload = { + await this.send({ type: PayloadType.ProtocolAccepted, - }; - await this.send(payload); + }); logger.info("Waiting for outcome message"); const res = await this.receive(); @@ -280,7 +277,7 @@ export class MSC4108SignInWithQR { // if so, return the secrets const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQRLogin(); // send secrets - await this.send({ + await this.send({ type: PayloadType.Secrets, ...secretsBundle, }); @@ -306,17 +303,16 @@ export class MSC4108SignInWithQR { return (await this.channel.secureReceive()) as T | undefined; } - private async send(payload: MSC4108Payload): Promise { + private async send(payload: T): Promise { await this.channel.secureSend(payload); } public async declineLoginOnExistingDevice(): Promise { // logger.info("User declined sign in"); - const payload: FailurePayload = { + await this.send({ type: PayloadType.Failure, reason: RendezvousFailureReason.UserDeclined, - }; - await this.send(payload); + }); } public async cancel(reason: RendezvousFailureReason): Promise { diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 9b1ac58c938..40c75cfe1f6 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -172,7 +172,7 @@ export class MSC4108SecureChannel { return this.establishedChannel.encrypt(plaintext); } - public async secureSend(payload: MSC4108Payload): Promise { + public async secureSend(payload: T): Promise { if (!this.connected) { throw new Error("Channel closed"); } @@ -183,7 +183,7 @@ export class MSC4108SecureChannel { await this.rendezvousSession.send(await this.encrypt(stringifiedPayload)); } - public async secureReceive(): Promise | undefined> { + public async secureReceive(): Promise | undefined> { if (!this.establishedChannel) { throw new Error("Channel closed"); } @@ -196,7 +196,7 @@ export class MSC4108SecureChannel { const json = JSON.parse(plaintext); logger.info(`<= ${JSON.stringify(json)}`); - return json as any as Partial; + return json as Partial | undefined; } public async close(): Promise {} From 2489229fafeff5336787377fc66cb7a7b37e4471 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 14:29:46 +0100 Subject: [PATCH 36/81] Report errors back to other side and handle Failure Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 38 ++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 5d236a75e7b..0bc1040e3e1 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -158,7 +158,17 @@ export class MSC4108SignInWithQR { // wait for protocols message logger.info("Waiting for protocols message"); const message = await this.receive(); + + if (message?.type === PayloadType.Failure) { + const { reason } = message as FailurePayload; + throw new RendezvousError("Failed", reason); + } + if (message?.type !== PayloadType.Protocols) { + await this.send({ + type: PayloadType.Failure, + reason: RendezvousFailureReason.UnexpectedMessage, + }); throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); } const protocolsMessage = message as ProtocolsPayload; @@ -185,6 +195,11 @@ export class MSC4108SignInWithQR { logger.info("Waiting for protocol message"); const message = await this.receive(); + if (message?.type === PayloadType.Failure) { + const { reason } = message as FailurePayload; + throw new RendezvousError("Failed", reason); + } + if (message && message.type === PayloadType.Protocol) { const protocolMessage = message as ProtocolPayload; if (protocolMessage.protocol === "device_authorization_grant") { @@ -206,6 +221,10 @@ export class MSC4108SignInWithQR { } if (deviceAlreadyExists) { + await this.send({ + type: PayloadType.Failure, + reason: RendezvousFailureReason.DataMismatch, + }); throw new RendezvousError( "Specified device ID already exists", RendezvousFailureReason.DataMismatch, @@ -218,6 +237,10 @@ export class MSC4108SignInWithQR { } } + await this.send({ + type: PayloadType.Failure, + reason: RendezvousFailureReason.UnsupportedAlgorithm, + }); throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnsupportedAlgorithm); } } @@ -239,8 +262,17 @@ export class MSC4108SignInWithQR { }); // then wait for secrets logger.info("Waiting for secrets message"); - const secrets = await this.receive(); + const secrets = await this.receive(); + if (secrets?.type === PayloadType.Failure) { + const { reason } = secrets as FailurePayload; + throw new RendezvousError("Failed", reason); + } + if (secrets?.type !== PayloadType.Secrets) { + await this.send({ + type: PayloadType.Failure, + reason: RendezvousFailureReason.UnexpectedMessage, + }); throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); } return { secrets }; @@ -295,6 +327,10 @@ export class MSC4108SignInWithQR { await new Promise((resolve) => setTimeout(resolve, 1000)); } while (Date.now() < timeout); + await this.send({ + type: PayloadType.Failure, + reason: RendezvousFailureReason.DataMismatch, + }); throw new RendezvousError("New device not found", RendezvousFailureReason.DataMismatch); } } From 3b4f9a79e0957f34e0c5f3ab0e32537d1095d26a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 14:45:51 +0100 Subject: [PATCH 37/81] Additional error reasons --- src/rendezvous/MSC4108SignInWithQR.ts | 8 ++++---- src/rendezvous/RendezvousFailureReason.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 0bc1040e3e1..1f6d5d1328f 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -223,11 +223,11 @@ export class MSC4108SignInWithQR { if (deviceAlreadyExists) { await this.send({ type: PayloadType.Failure, - reason: RendezvousFailureReason.DataMismatch, + reason: RendezvousFailureReason.DeviceAlreadyExists, }); throw new RendezvousError( "Specified device ID already exists", - RendezvousFailureReason.DataMismatch, + RendezvousFailureReason.DeviceAlreadyExists, ); } @@ -329,9 +329,9 @@ export class MSC4108SignInWithQR { await this.send({ type: PayloadType.Failure, - reason: RendezvousFailureReason.DataMismatch, + reason: RendezvousFailureReason.DeviceNotFound, }); - throw new RendezvousError("New device not found", RendezvousFailureReason.DataMismatch); + throw new RendezvousError("New device not found", RendezvousFailureReason.DeviceNotFound); } } diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index ba614d8eddd..ae123f24ce2 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -25,4 +25,6 @@ export enum RendezvousFailureReason { DataMismatch = "data_mismatch", HomeserverLacksSupport = "homeserver_lacks_support", UnexpectedMessage = "unexpected_message", + DeviceAlreadyExists = "device_already_exists", + DeviceNotFound = "device_not_found", } From 094cb46e73d4b05a1e141d613759e8f6859b0edf Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 14:52:55 +0100 Subject: [PATCH 38/81] Rename data_mismatch to insecure_channel_detected Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/RendezvousFailureReason.ts | 2 +- src/rendezvous/channels/MSC4108SecureChannel.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index ae123f24ce2..c0124623529 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -22,7 +22,7 @@ export enum RendezvousFailureReason { Expired = "expired", UserCancelled = "user_cancelled", UnsupportedAlgorithm = "unsupported_algorithm", - DataMismatch = "data_mismatch", + InsecureChannelDetected = "insecure_channel_detected", HomeserverLacksSupport = "homeserver_lacks_support", UnexpectedMessage = "unexpected_message", DeviceAlreadyExists = "device_already_exists", diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 40c75cfe1f6..1436dada51b 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -110,7 +110,7 @@ export class MSC4108SecureChannel { if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") { throw new RendezvousError( "Invalid response from other device", - RendezvousFailureReason.DataMismatch, + RendezvousFailureReason.InsecureChannelDetected, ); } @@ -140,7 +140,10 @@ export class MSC4108SecureChannel { this.establishedChannel = channel; if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") { - throw new RendezvousError("Invalid response from other device", RendezvousFailureReason.DataMismatch); + throw new RendezvousError( + "Invalid response from other device", + RendezvousFailureReason.InsecureChannelDetected, + ); } logger.info("LoginInitiateMessage received"); From 6c5d4b6b0025b29e986b39a921f6b05a1a8991ee Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 16:23:55 +0100 Subject: [PATCH 39/81] Updated error codes to match MSC Split client and protocol level errors out Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC3906Rendezvous.ts | 7 +- src/rendezvous/MSC4108SignInWithQR.ts | 115 +++++++++++------- src/rendezvous/RendezvousFailureReason.ts | 28 ++++- .../MSC3903ECDHv2RendezvousChannel.ts | 2 +- .../channels/MSC4108SecureChannel.ts | 17 ++- .../MSC3886SimpleHttpRendezvousTransport.ts | 2 +- .../transports/MSC4108RendezvousSession.ts | 16 ++- 7 files changed, 126 insertions(+), 61 deletions(-) diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index e558c224abe..590f6c9ba6e 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -16,7 +16,12 @@ limitations under the License. import { UnstableValue } from "matrix-events-sdk"; -import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { + RendezvousChannel, + RendezvousFailureListener, + LegacyRendezvousFailureReason as RendezvousFailureReason, + RendezvousIntent, +} from "."; import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client"; import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature"; import { logger } from "../logger"; diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 1f6d5d1328f..a6d6abd4c76 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -17,7 +17,7 @@ limitations under the License. import { OidcClient } from "oidc-client-ts"; import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; +import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; @@ -31,6 +31,7 @@ export enum PayloadType { Success = "m.login.success", Secrets = "m.login.secrets", ProtocolAccepted = "m.login.protocol_accepted", + Declined = "m.login.declined", } export interface MSC4108Payload { @@ -59,10 +60,14 @@ interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload { interface FailurePayload extends MSC4108Payload { type: PayloadType.Failure; - reason: RendezvousFailureReason; + reason: MSC4108FailureReason; homeserver?: string; } +interface DeclinedPayload extends MSC4108Payload { + type: PayloadType.Declined; +} + interface SuccessPayload extends MSC4108Payload { type: PayloadType.Success; } @@ -167,9 +172,12 @@ export class MSC4108SignInWithQR { if (message?.type !== PayloadType.Protocols) { await this.send({ type: PayloadType.Failure, - reason: RendezvousFailureReason.UnexpectedMessage, + reason: MSC4108FailureReason.UnexpectedMessageReceived, }); - throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); } const protocolsMessage = message as ProtocolsPayload; return { homeserverBaseUrl: protocolsMessage.homeserver }; @@ -200,48 +208,59 @@ export class MSC4108SignInWithQR { throw new RendezvousError("Failed", reason); } - if (message && message.type === PayloadType.Protocol) { - const protocolMessage = message as ProtocolPayload; - if (protocolMessage.protocol === "device_authorization_grant") { - const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = - protocolMessage as DeviceAuthorizationGrantProtocolPayload; - const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = - dag; - - // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: - // check if there is already a device online with that device ID - - let deviceAlreadyExists = true; - try { - await this.client?.getDevice(expectingNewDeviceId); - } catch (err: MatrixError | unknown) { - if (err instanceof MatrixError && err.httpStatus === 404) { - deviceAlreadyExists = false; - } - } + if (message?.type !== PayloadType.Protocol) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); + } - if (deviceAlreadyExists) { - await this.send({ - type: PayloadType.Failure, - reason: RendezvousFailureReason.DeviceAlreadyExists, - }); - throw new RendezvousError( - "Specified device ID already exists", - RendezvousFailureReason.DeviceAlreadyExists, - ); - } + const protocolMessage = message as ProtocolPayload; + if (protocolMessage.protocol === "device_authorization_grant") { + const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = + protocolMessage as DeviceAuthorizationGrantProtocolPayload; + const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag; - this.expectingNewDeviceId = expectingNewDeviceId; + // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: + // check if there is already a device online with that device ID - return { verificationUri: verificationUriComplete ?? verificationUri }; + 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: RendezvousFailureReason.UnsupportedAlgorithm, + reason: MSC4108FailureReason.UnsupportedProtocol, }); - throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnsupportedAlgorithm); + throw new RendezvousError( + "Received a request for an unsupported protocol", + MSC4108FailureReason.UnsupportedProtocol, + ); } } @@ -271,9 +290,12 @@ export class MSC4108SignInWithQR { if (secrets?.type !== PayloadType.Secrets) { await this.send({ type: PayloadType.Failure, - reason: RendezvousFailureReason.UnexpectedMessage, + reason: MSC4108FailureReason.UnexpectedMessageReceived, }); - throw new RendezvousError("Unexpected message received", RendezvousFailureReason.UnexpectedMessage); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); } return { secrets }; // then done? @@ -294,7 +316,11 @@ export class MSC4108SignInWithQR { } if (res?.type !== PayloadType.Success) { - throw new RendezvousError("Unexpected message", RendezvousFailureReason.UnexpectedMessage); + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived); } // PROTOTYPE: this also needs to handle the case of the process being cancelled @@ -329,9 +355,9 @@ export class MSC4108SignInWithQR { await this.send({ type: PayloadType.Failure, - reason: RendezvousFailureReason.DeviceNotFound, + reason: MSC4108FailureReason.DeviceNotFound, }); - throw new RendezvousError("New device not found", RendezvousFailureReason.DeviceNotFound); + throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound); } } @@ -345,9 +371,8 @@ export class MSC4108SignInWithQR { public async declineLoginOnExistingDevice(): Promise { // logger.info("User declined sign in"); - await this.send({ - type: PayloadType.Failure, - reason: RendezvousFailureReason.UserDeclined, + await this.send({ + type: PayloadType.Declined, }); } diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index c0124623529..084f4ce77f3 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -16,15 +16,37 @@ limitations under the License. export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void; -export enum RendezvousFailureReason { +export type RendezvousFailureReason = + | LegacyRendezvousFailureReason + | MSC4108FailureReason + | ClientRendezvousFailureReason; + +export enum LegacyRendezvousFailureReason { UserDeclined = "user_declined", Unknown = "unknown", Expired = "expired", UserCancelled = "user_cancelled", UnsupportedAlgorithm = "unsupported_algorithm", - InsecureChannelDetected = "insecure_channel_detected", + UnsupportedProtocol = "unsupported_protocol", HomeserverLacksSupport = "homeserver_lacks_support", - UnexpectedMessage = "unexpected_message", +} + +export enum MSC4108FailureReason { + AuthorizationExpired = "authorization_expired", DeviceAlreadyExists = "device_already_exists", DeviceNotFound = "device_not_found", + UnexpectedMessageReceived = "unexpected_message_received", + UnsupportedProtocol = "unsupported_protocol", + UserCancelled = "user_cancelled", +} + +export enum ClientRendezvousFailureReason { + Expired = "expired", + HomeserverLacksSupport = "homeserver_lacks_support", + InsecureChannelDetected = "insecure_channel_detected", + InvalidCode = "invalid_code", + OtherDeviceNotSignedIn = "other_device_not_signed_in", + OtherDeviceAlreadySignedIn = "other_device_already_signed_in", + Unknown = "unknown", + UserDeclined = "user_declined", } diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index 872c6ea2c78..024d8ab4d25 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -23,7 +23,7 @@ import { RendezvousChannel, RendezvousTransportDetails, RendezvousTransport, - RendezvousFailureReason, + LegacyRendezvousFailureReason as RendezvousFailureReason, } from ".."; import { encodeUnpaddedBase64, decodeBase64 } from "../../base64"; import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 1436dada51b..1965eeb021e 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -23,7 +23,13 @@ import { SecureChannel, } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { MSC4108Payload, RendezvousError, RendezvousFailureReason } from ".."; +import { + ClientRendezvousFailureReason, + MSC4108FailureReason, + MSC4108Payload, + RendezvousError, + RendezvousFailureReason, +} from ".."; import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession"; import { logger } from "../../logger"; @@ -103,14 +109,17 @@ export class MSC4108SecureChannel { const ciphertext = await this.rendezvousSession.receive(); if (!ciphertext) { - throw new RendezvousError("No response from other device", RendezvousFailureReason.Unknown); + throw new RendezvousError( + "No response from other device", + MSC4108FailureReason.UnexpectedMessageReceived, + ); } const candidateLoginOkMessage = await this.decrypt(ciphertext); if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") { throw new RendezvousError( "Invalid response from other device", - RendezvousFailureReason.InsecureChannelDetected, + ClientRendezvousFailureReason.InsecureChannelDetected, ); } @@ -142,7 +151,7 @@ export class MSC4108SecureChannel { if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") { throw new RendezvousError( "Invalid response from other device", - RendezvousFailureReason.InsecureChannelDetected, + ClientRendezvousFailureReason.InsecureChannelDetected, ); } logger.info("LoginInitiateMessage received"); diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts index 430ee92d1c7..a6dc63bb11a 100644 --- a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -20,7 +20,7 @@ import { logger } from "../../logger"; import { sleep } from "../../utils"; import { RendezvousFailureListener, - RendezvousFailureReason, + LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousTransport, RendezvousTransportDetails, } from ".."; diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 292a315522d..95eedb18839 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -16,7 +16,7 @@ limitations under the License. import { logger } from "../../logger"; import { sleep } from "../../utils"; -import { RendezvousFailureListener, RendezvousFailureReason } from ".."; +import { ClientRendezvousFailureReason, RendezvousFailureListener, RendezvousFailureReason } from ".."; import { MatrixClient } from "../../matrix"; import { ClientPrefix } from "../../http-api"; @@ -129,7 +129,7 @@ export class MSC4108RendezvousSession { const res = await this.fetch(uri, { method, headers, body: data }); if (res.status === 404) { - return this.cancel(RendezvousFailureReason.Unknown); + return this.cancel(ClientRendezvousFailureReason.Unknown); } this.etag = res.headers.get("etag") ?? undefined; @@ -169,7 +169,7 @@ export class MSC4108RendezvousSession { const poll = await this.fetch(this.url, { method: "GET", headers }); if (poll.status === 404) { - this.cancel(RendezvousFailureReason.Unknown); + this.cancel(ClientRendezvousFailureReason.Unknown); return undefined; } @@ -188,15 +188,19 @@ export class MSC4108RendezvousSession { } public async cancel(reason: RendezvousFailureReason): Promise { - if (reason === RendezvousFailureReason.Unknown && this.expiresAt && this.expiresAt.getTime() < Date.now()) { - reason = RendezvousFailureReason.Expired; + if ( + reason === ClientRendezvousFailureReason.Unknown && + this.expiresAt && + this.expiresAt.getTime() < Date.now() + ) { + reason = ClientRendezvousFailureReason.Expired; } this.cancelled = true; this._ready = false; this.onFailure?.(reason); - if (this.url && reason === RendezvousFailureReason.UserDeclined) { + if (this.url && reason === ClientRendezvousFailureReason.UserDeclined) { try { await this.fetch(this.url, { method: "DELETE" }); } catch (e) { From 099992befbf7fa9cdae063107103df5926683e1a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 10 Apr 2024 16:29:33 +0100 Subject: [PATCH 40/81] Add description of ClientRendezvousFailureReasons --- src/rendezvous/RendezvousFailureReason.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index 084f4ce77f3..e317981a515 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -41,12 +41,20 @@ export enum MSC4108FailureReason { } export enum ClientRendezvousFailureReason { + /** The sign in request has expired */ Expired = "expired", + /** The homeserver is lacking support for the required features */ HomeserverLacksSupport = "homeserver_lacks_support", + /** The secure channel verification failed meaning that it might be compromised */ InsecureChannelDetected = "insecure_channel_detected", + /** An invalid/incompatible QR code was scanned */ InvalidCode = "invalid_code", + /** The other device is not signed in */ OtherDeviceNotSignedIn = "other_device_not_signed_in", + /** The other device is already signed in */ OtherDeviceAlreadySignedIn = "other_device_already_signed_in", + /** Other */ Unknown = "unknown", + /** The user declined the sign in request */ UserDeclined = "user_declined", } From 65d759fcea2eeb2b4ed56eb802cdbc4344499dd9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 16 Apr 2024 09:59:03 +0100 Subject: [PATCH 41/81] delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index a6d6abd4c76..90be154f820 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -17,7 +17,7 @@ limitations under the License. import { OidcClient } from "oidc-client-ts"; import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; +import { MSC4108FailureReason, RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; From 9c2e0f799110f470b5778e1d0df91a0e72e8fdbb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 17 Apr 2024 13:48:11 +0100 Subject: [PATCH 42/81] Fix types Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 4 ++-- src/rendezvous/channels/MSC4108SecureChannel.ts | 6 +++--- src/rendezvous/transports/MSC4108RendezvousSession.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 90be154f820..fef6f576584 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -17,7 +17,7 @@ limitations under the License. import { OidcClient } from "oidc-client-ts"; import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; -import { MSC4108FailureReason, RendezvousError, RendezvousFailureListener, RendezvousFailureReason } from "."; +import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener } from "."; import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; @@ -376,7 +376,7 @@ export class MSC4108SignInWithQR { }); } - public async cancel(reason: RendezvousFailureReason): Promise { + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { this.onFailure?.(reason); await this.channel.cancel(reason); } diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 1965eeb021e..b344357952c 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -28,7 +28,7 @@ import { MSC4108FailureReason, MSC4108Payload, RendezvousError, - RendezvousFailureReason, + RendezvousFailureListener, } from ".."; import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession"; import { logger } from "../../logger"; @@ -44,7 +44,7 @@ export class MSC4108SecureChannel { public constructor( private rendezvousSession: MSC4108RendezvousSession, private theirPublicKey?: Curve25519PublicKey, - public onFailure?: (reason: RendezvousFailureReason) => void, + public onFailure?: RendezvousFailureListener, ) { this.secureChannel = new SecureChannel(); } @@ -213,7 +213,7 @@ export class MSC4108SecureChannel { public async close(): Promise {} - public async cancel(reason: RendezvousFailureReason): Promise { + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { try { await this.rendezvousSession.cancel(reason); this.onFailure?.(reason); diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 95eedb18839..8330d1bb739 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -16,7 +16,7 @@ limitations under the License. import { logger } from "../../logger"; import { sleep } from "../../utils"; -import { ClientRendezvousFailureReason, RendezvousFailureListener, RendezvousFailureReason } from ".."; +import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousFailureListener } from ".."; import { MatrixClient } from "../../matrix"; import { ClientPrefix } from "../../http-api"; @@ -187,7 +187,7 @@ export class MSC4108RendezvousSession { } } - public async cancel(reason: RendezvousFailureReason): Promise { + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { if ( reason === ClientRendezvousFailureReason.Unknown && this.expiresAt && From cc727c1a8d5b913aba4ad5ff28dfb19466590691 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Apr 2024 14:07:39 +0100 Subject: [PATCH 43/81] Consume feature branch of rust crypto wasm dep Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 7d263f4ade5..f48be209d9d 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0", + "@matrix-org/matrix-sdk-crypto-wasm": "github:matrix-org/matrix-rust-sdk-crypto-wasm#poljar/qr-login", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", diff --git a/yarn.lock b/yarn.lock index 5a3095277c6..762c9538b0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1742,10 +1742,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@^4.9.0": +"@matrix-org/matrix-sdk-crypto-wasm@github:matrix-org/matrix-rust-sdk-crypto-wasm#poljar/qr-login": version "4.9.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-4.9.0.tgz#9dfed83e33f760650596c4e5c520e5e4c53355d2" - integrity sha512-/bgA4QfE7qkK6GFr9hnhjAvRSebGrmEJxukU0ukbudZcYvbzymoBBM8j3HeULXZT8kbw8WH6z63txYTMCBSDOA== + resolved "https://codeload.github.com/matrix-org/matrix-rust-sdk-crypto-wasm/tar.gz/78c5fb5cc29979d0bd188a8b0ec163dd30aa7936" "@matrix-org/olm@3.2.15": version "3.2.15" From b2c9ce14fe83dcc72ce4b339982f8da884a0687e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Apr 2024 14:16:45 +0100 Subject: [PATCH 44/81] Add awful prepare-stacking workaround Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f48be209d9d..98904e02f7d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "lint:knip": "knip", "test": "jest", "test:watch": "jest --watch", - "coverage": "yarn test --coverage" + "coverage": "yarn test --coverage", + "prepare": "cd node_modules/@matrix-org/matrix-sdk-crypto-wasm && npm install && npm run prepare" }, "repository": { "type": "git", From 4c13fcfe860d82237ccf37bbe35ab65566260a62 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Apr 2024 14:49:11 +0100 Subject: [PATCH 45/81] Fix test types Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts | 10 +++++----- spec/unit/rendezvous/ecdhv2.spec.ts | 2 +- spec/unit/rendezvous/rendezvous.spec.ts | 7 ++++++- spec/unit/rendezvous/simpleHttpTransport.spec.ts | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts index 08d0ffabf6c..c172a4b18e1 100644 --- a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src"; -import { RendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous"; +import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous"; function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { const client = { @@ -356,7 +356,7 @@ describe("MSC4108RendezvousSession", () => { } { // Cancel - const prom = simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + const prom = simpleHttpTransport.cancel(ClientRendezvousFailureReason.UserDeclined); httpBackend.when("DELETE", "https://fallbackserver/rz/123").response = { body: null, rawBody: true, @@ -377,7 +377,7 @@ describe("MSC4108RendezvousSession", () => { fallbackRzServer: "https://fallbackserver/rz", fetchFn, }); - await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + await simpleHttpTransport.cancel(ClientRendezvousFailureReason.UserDeclined); await expect(simpleHttpTransport.send("data")).resolves.toBeUndefined(); }); @@ -413,7 +413,7 @@ describe("MSC4108RendezvousSession", () => { expect(simpleHttpTransport.send("foo=baa")).resolves.toBeUndefined(), httpBackend.flush("", 1), ]); - expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); + expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown); }); it("404 failure callback mapped to expired", async function () { @@ -454,7 +454,7 @@ describe("MSC4108RendezvousSession", () => { }, }; await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]); - expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired); + expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired); } }); }); diff --git a/spec/unit/rendezvous/ecdhv2.spec.ts b/spec/unit/rendezvous/ecdhv2.spec.ts index caadfbf6e9c..1fd3f7cac18 100644 --- a/spec/unit/rendezvous/ecdhv2.spec.ts +++ b/spec/unit/rendezvous/ecdhv2.spec.ts @@ -15,7 +15,7 @@ limitations under the License. */ import "../../olm-loader"; -import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels"; import { decodeBase64 } from "../../../src/base64"; import { DummyTransport } from "./DummyTransport"; diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvous.spec.ts index c7a31b8a1ff..f600639d829 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvous.spec.ts @@ -17,7 +17,12 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import "../../olm-loader"; -import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + MSC3906Rendezvous, + RendezvousCode, + LegacyRendezvousFailureReason as RendezvousFailureReason, + RendezvousIntent, +} from "../../../src/rendezvous"; import { ECDHv2RendezvousCode as ECDHRendezvousCode, MSC3903ECDHPayload, diff --git a/spec/unit/rendezvous/simpleHttpTransport.spec.ts b/spec/unit/rendezvous/simpleHttpTransport.spec.ts index 166a6350730..c736d4d115d 100644 --- a/spec/unit/rendezvous/simpleHttpTransport.spec.ts +++ b/spec/unit/rendezvous/simpleHttpTransport.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import type { MatrixClient } from "../../../src"; -import { RendezvousFailureReason } from "../../../src/rendezvous"; +import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous"; import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports"; function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient { From 79e708720b37650d5efe3c09ab725fec03ce5a6d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 18 Apr 2024 15:45:45 +0100 Subject: [PATCH 46/81] Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../MSC4108RendezvousSession.spec.ts | 391 ++++++------------ .../transports/MSC4108RendezvousSession.ts | 12 +- 2 files changed, 137 insertions(+), 266 deletions(-) diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts index c172a4b18e1..245c9cd35e1 100644 --- a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MockHttpBackend from "matrix-mock-request"; +import fetchMock from "fetch-mock-jest"; import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src"; import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous"; @@ -40,13 +40,11 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled return client; } -describe("MSC4108RendezvousSession", () => { - let httpBackend: MockHttpBackend; - let fetchFn: typeof global.fetch; +fetchMock.config.overwriteRoutes = true; - beforeEach(function () { - httpBackend = new MockHttpBackend(); - fetchFn = httpBackend.fetchFn as typeof global.fetch; +describe("MSC4108RendezvousSession", () => { + beforeEach(() => { + fetchMock.reset(); }); async function postAndCheckLocation( @@ -56,92 +54,62 @@ describe("MSC4108RendezvousSession", () => { expectedFinalLocation: string, ) { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled }); - const transport = new MSC4108RendezvousSession({ client, fallbackRzServer, fetchFn }); + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer }); { // initial POST const expectedPostLocation = msc4108Enabled ? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous` : fallbackRzServer; - const prom = transport.send("data"); - httpBackend.when("POST", expectedPostLocation).response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: locationResponse, - }, - }, - }; - await httpBackend.flush(""); - await prom; + fetchMock.postOnce(expectedPostLocation, { + status: 201, + body: { url: locationResponse }, + }); + await transport.send("data"); } { // first GET without etag - const prom = transport.receive(); - httpBackend.when("GET", expectedFinalLocation).response = { + fetchMock.get(locationResponse, { + status: 200, body: "data", - rawBody: true, - response: { - statusCode: 200, - headers: { - "content-type": "text/plain", - }, + headers: { + "content-type": "text/plain", }, - }; - await httpBackend.flush(""); - expect(await prom).toEqual("data"); - httpBackend.verifyNoOutstandingRequests(); - httpBackend.verifyNoOutstandingExpectation(); + }); + await expect(transport.receive()).resolves.toEqual("data"); } } it("should throw an error when no server available", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ client, fetchFn }); - await expect(simpleHttpTransport.send("data")).rejects.toThrow("Invalid rendezvous URI"); + const transport = new MSC4108RendezvousSession({ client }); + await expect(transport.send("data")).rejects.toThrow("Invalid rendezvous URI"); }); it("POST to fallback server", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); - const prom = simpleHttpTransport.send("data"); - httpBackend.when("POST", "https://fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://fallbackserver/rz/123", - }, - }, - }; - await httpBackend.flush(""); - expect(await prom).toStrictEqual(undefined); + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + }); + await fetchMock.flush(true); + await expect(transport.send("data")).resolves.toStrictEqual(undefined); }); it("POST with no location", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); - const prom = simpleHttpTransport.send("data"); - httpBackend.when("POST", "https://fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: {}, - }, - }; - await Promise.all([expect(prom).rejects.toThrow(), httpBackend.flush("")]); + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + }); + await Promise.all([expect(transport.send("data")).rejects.toThrow(), fetchMock.flush(true)]); }); it("POST with absolute path response", async function () { @@ -166,294 +134,197 @@ describe("MSC4108RendezvousSession", () => { ); }); - it("POST to follow 307 to other server", async function () { + // fetch-mock doesn't handle redirects properly, so we can't test this + it.skip("POST to follow 307 to other server", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); - const prom = simpleHttpTransport.send("data"); - httpBackend.when("POST", "https://fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 307, - headers: { - location: "https://redirected.fallbackserver/rz", - }, - }, - }; - httpBackend.when("POST", "https://redirected.fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://redirected.fallbackserver/rz/123", - etag: "aaa", - }, - }, - }; - await httpBackend.flush(""); - expect(await prom).toStrictEqual(undefined); + fetchMock.postOnce("https://fallbackserver/rz", { + status: 307, + redirectUrl: "https://redirected.fallbackserver/rz", + redirected: true, + }); + fetchMock.postOnce("https://redirected.fallbackserver/rz", { + status: 201, + body: { url: "https://redirected.fallbackserver/rz/123" }, + headers: { etag: "aaa" }, + }); + await fetchMock.flush(true); + await expect(transport.send("data")).resolves.toStrictEqual(undefined); }); it("POST and GET", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); { // initial POST - const prom = simpleHttpTransport.send("foo=baa"); - httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { - expect(headers["content-type"]).toEqual("text/plain"); - expect(rawData).toEqual("foo=baa"); - }).response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://fallbackserver/rz/123", - }, + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + }); + await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined); + await fetchMock.flush(true); + expect(fetchMock).toHaveFetched("https://fallbackserver/rz", { + method: "POST", + headers: { "content-type": "text/plain" }, + functionMatcher: (_, opts): boolean => { + return opts.body === "foo=baa"; }, - }; - await httpBackend.flush(""); - expect(await prom).toStrictEqual(undefined); + }); } { // first GET without etag - const prom = simpleHttpTransport.receive(); - httpBackend.when("GET", "https://fallbackserver/rz/123").response = { + fetchMock.getOnce("https://fallbackserver/rz/123", { + status: 200, body: "foo=baa", - rawBody: true, - response: { - statusCode: 200, - headers: { - "content-type": "text/plain", - "etag": "aaa", - }, - }, - }; - await httpBackend.flush(""); - expect(await prom).toEqual("foo=baa"); + headers: { "content-type": "text/plain", "etag": "aaa" }, + }); + await expect(transport.receive()).resolves.toEqual("foo=baa"); + await fetchMock.flush(true); } { // subsequent GET which should have etag from previous request - const prom = simpleHttpTransport.receive(); - httpBackend.when("GET", "https://fallbackserver/rz/123").check(({ headers }) => { - expect(headers["if-none-match"]).toEqual("aaa"); - }).response = { + fetchMock.getOnce("https://fallbackserver/rz/123", { + status: 200, body: "foo=baa", - rawBody: true, - response: { - statusCode: 200, - headers: { - "content-type": "text/plain", - "etag": "bbb", - }, - }, - }; - await httpBackend.flush(""); - expect(await prom).toEqual("foo=baa"); + headers: { "content-type": "text/plain", "etag": "bbb" }, + }); + await expect(transport.receive()).resolves.toEqual("foo=baa"); + await fetchMock.flush(true); + expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", { + method: "GET", + headers: { "if-none-match": "aaa" }, + }); } }); it("POST and PUTs", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); { // initial POST - const prom = simpleHttpTransport.send("foo=baa"); - httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { - expect(headers["content-type"]).toEqual("text/plain"); - expect(rawData).toEqual("foo=baa"); - }).response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://fallbackserver/rz/123", - }, - }, - }; - await httpBackend.flush("", 1); - await prom; - } - { - // first PUT without etag - const prom = simpleHttpTransport.send("a=b"); - httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers, rawData }) => { - expect(headers["if-match"]).toBeUndefined(); - expect(rawData).toEqual("a=b"); - }).response = { - body: null, - rawBody: true, - response: { - statusCode: 202, - headers: { - etag: "aaa", - }, + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + headers: { etag: "aaa" }, + }); + await transport.send("foo=baa"); + await fetchMock.flush(true); + expect(fetchMock).toHaveFetched("https://fallbackserver/rz", { + method: "POST", + headers: { "content-type": "text/plain" }, + functionMatcher: (_, opts): boolean => { + return opts.body === "foo=baa"; }, - }; - await httpBackend.flush("", 1); - await prom; + }); } { // subsequent PUT which should have etag from previous request - const prom = simpleHttpTransport.send("c=d"); - httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers }) => { - expect(headers["if-match"]).toEqual("aaa"); - }).response = { - body: null, - rawBody: true, - response: { - statusCode: 202, - headers: { - etag: "bbb", - }, - }, - }; - await httpBackend.flush("", 1); - await prom; + fetchMock.putOnce("https://fallbackserver/rz/123", { status: 202, headers: { etag: "bbb" } }); + await transport.send("c=d"); + await fetchMock.flush(true); + expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", { + method: "PUT", + headers: { "if-match": "aaa" }, + }); } }); it("POST and DELETE", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); { // Create - const prom = simpleHttpTransport.send("foo=baa"); - httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, rawData }) => { - expect(headers["content-type"]).toEqual("text/plain"); - expect(rawData).toEqual("foo=baa"); - }).response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://fallbackserver/rz/123", - }, + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + }); + await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined); + await fetchMock.flush(true); + expect(fetchMock).toHaveFetched("https://fallbackserver/rz", { + method: "POST", + headers: { "content-type": "text/plain" }, + functionMatcher: (_, opts): boolean => { + return opts.body === "foo=baa"; }, - }; - await httpBackend.flush(""); - expect(await prom).toStrictEqual(undefined); + }); } { // Cancel - const prom = simpleHttpTransport.cancel(ClientRendezvousFailureReason.UserDeclined); - httpBackend.when("DELETE", "https://fallbackserver/rz/123").response = { - body: null, - rawBody: true, - response: { - statusCode: 204, - headers: {}, - }, - }; - await httpBackend.flush(""); - await prom; + fetchMock.deleteOnce("https://fallbackserver/rz/123", { status: 204 }); + await transport.cancel(ClientRendezvousFailureReason.UserDeclined); + await fetchMock.flush(true); } }); it("send after cancelled", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); - await simpleHttpTransport.cancel(ClientRendezvousFailureReason.UserDeclined); - await expect(simpleHttpTransport.send("data")).resolves.toBeUndefined(); + await transport.cancel(ClientRendezvousFailureReason.UserDeclined); + await expect(transport.send("data")).resolves.toBeUndefined(); }); it("receive before ready", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, }); - await expect(simpleHttpTransport.receive()).rejects.toThrow(); + await expect(transport.receive()).rejects.toThrow(); }); it("404 failure callback", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); const onFailure = jest.fn(); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, onFailure, }); - httpBackend.when("POST", "https://fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 404, - headers: {}, - }, - }; - await Promise.all([ - expect(simpleHttpTransport.send("foo=baa")).resolves.toBeUndefined(), - httpBackend.flush("", 1), - ]); + fetchMock.postOnce("https://fallbackserver/rz", { status: 404 }); + await Promise.all([expect(transport.send("foo=baa")).resolves.toBeUndefined(), fetchMock.flush(true)]); expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown); }); it("404 failure callback mapped to expired", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); const onFailure = jest.fn(); - const simpleHttpTransport = new MSC4108RendezvousSession({ + const transport = new MSC4108RendezvousSession({ client, fallbackRzServer: "https://fallbackserver/rz", - fetchFn, onFailure, }); { // initial POST - const prom = simpleHttpTransport.send("foo=baa"); - httpBackend.when("POST", "https://fallbackserver/rz").response = { - body: null, - rawBody: true, - response: { - statusCode: 201, - headers: { - location: "https://fallbackserver/rz/123", - expires: "Thu, 01 Jan 1970 00:00:00 GMT", - }, - }, - }; - await httpBackend.flush(""); - await prom; + fetchMock.postOnce("https://fallbackserver/rz", { + status: 201, + body: { url: "https://fallbackserver/rz/123" }, + headers: { expires: "Thu, 01 Jan 1970 00:00:00 GMT" }, + }); + + await transport.send("foo=baa"); + await fetchMock.flush(true); } { // GET with 404 to simulate expiry - httpBackend.when("GET", "https://fallbackserver/rz/123").response = { - body: "foo=baa", - rawBody: true, - response: { - statusCode: 404, - headers: {}, - }, - }; - await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]); + fetchMock.getOnce("https://fallbackserver/rz/123", { status: 404, body: "foo=baa" }); + await Promise.all([expect(transport.receive()).resolves.toBeUndefined(), fetchMock.flush(true)]); expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired); } }); diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 8330d1bb739..9dc81e1c026 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -17,7 +17,7 @@ limitations under the License. import { logger } from "../../logger"; import { sleep } from "../../utils"; import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousFailureListener } from ".."; -import { MatrixClient } from "../../matrix"; +import { MatrixClient, Method } from "../../matrix"; import { ClientPrefix } from "../../http-api"; /** @@ -107,7 +107,7 @@ export class MSC4108RendezvousSession { if (this.cancelled) { return; } - const method = this.url ? "PUT" : "POST"; + const method = this.url ? Method.Put : Method.Post; const uri = this.url ?? (await this.getPostEndpoint()); if (!uri) { @@ -127,7 +127,7 @@ export class MSC4108RendezvousSession { logger.info(`=> ${method} ${uri} with ${data} if-match: ${this.etag}`); - const res = await this.fetch(uri, { method, headers, body: data }); + const res = await this.fetch(uri, { method, headers, body: data, redirect: "follow" }); if (res.status === 404) { return this.cancel(ClientRendezvousFailureReason.Unknown); } @@ -135,7 +135,7 @@ export class MSC4108RendezvousSession { logger.info(`Received etag: ${this.etag}`); - if (method === "POST") { + if (method === Method.Post) { const expires = res.headers.get("expires"); if (expires) { this.expiresAt = new Date(expires); @@ -166,7 +166,7 @@ export class MSC4108RendezvousSession { } logger.info(`=> GET ${this.url} if-none-match: ${this.etag}`); - const poll = await this.fetch(this.url, { method: "GET", headers }); + const poll = await this.fetch(this.url, { method: Method.Get, headers }); if (poll.status === 404) { this.cancel(ClientRendezvousFailureReason.Unknown); @@ -202,7 +202,7 @@ export class MSC4108RendezvousSession { if (this.url && reason === ClientRendezvousFailureReason.UserDeclined) { try { - await this.fetch(this.url, { method: "DELETE" }); + await this.fetch(this.url, { method: Method.Delete }); } catch (e) { logger.warn(e); } From 7fb1e7e1bd0f4e870ff29155236dc782cba76099 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 09:09:13 +0100 Subject: [PATCH 47/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/crypto/secrets.spec.ts | 4 ++-- spec/unit/rust-crypto/rust-crypto.spec.ts | 8 ++++---- src/crypto-api.ts | 19 +++++++++++++++++-- src/crypto/index.ts | 8 ++++++-- src/rendezvous/MSC4108SignInWithQR.ts | 2 +- src/rust-crypto/rust-crypto.ts | 8 ++++++-- 6 files changed, 36 insertions(+), 13 deletions(-) diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 2dc575925f8..7048d232c8e 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -689,11 +689,11 @@ describe("Secrets", function () { it("should throw Not Implemented for importSecretsForQRLogin", async () => { const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - await expect(alice.getCrypto()?.importSecretsForQRLogin({})).rejects.toThrow("Method not implemented."); + await expect(alice.getCrypto()?.importSecretsForQrLogin({})).rejects.toThrow("Method not implemented."); }); it("should throw Not Implemented for exportSecretsForQRLogin", async () => { const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - await expect(alice.getCrypto()?.exportSecretsForQRLogin()).rejects.toThrow("Method not implemented."); + await expect(alice.getCrypto()?.exportSecretsForQrLogin()).rejects.toThrow("Method not implemented."); }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 255a31230e6..07cc05c1efc 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1440,7 +1440,7 @@ describe("RustCrypto", () => { }); }); - describe("exportSecretsForQRLogin", () => { + describe("exportSecretsForQrLogin", () => { let rustCrypto: RustCrypto; beforeEach(async () => { @@ -1455,7 +1455,7 @@ describe("RustCrypto", () => { }); it("should return an empty object if there is nothing to export", async () => { - await expect(rustCrypto.exportSecretsForQRLogin()).resolves.toEqual({}); + await expect(rustCrypto.exportSecretsForQrLogin()).resolves.toEqual({}); }); it("should return a JSON secrets bundle if there is something to export", async () => { @@ -1471,8 +1471,8 @@ describe("RustCrypto", () => { backup_version: "9", }, }; - await rustCrypto.importSecretsForQRLogin(bundle); - await expect(rustCrypto.exportSecretsForQRLogin()).resolves.toEqual(expect.objectContaining(bundle)); + await rustCrypto.importSecretsForQrLogin(bundle); + await expect(rustCrypto.exportSecretsForQrLogin()).resolves.toEqual(expect.objectContaining(bundle)); }); }); }); diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 83c1ab27bbf..cce33f48b05 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -33,9 +33,24 @@ export type QRSecretsBundle = Awaited>; * @remarks Currently, this is a work-in-progress. In time, more methods will be added here. */ export interface CryptoApi { - exportSecretsForQRLogin(): Promise; + /** + * Boolean check to indicate whether `exportSecretsForQrLogin` and `importSecretsForQrLogin` are supported. + * @experimental - part of MSC4108 + */ + supportsSecretsForQrLogin(): boolean; - importSecretsForQRLogin(secrets: QRSecretsBundle): Promise; + /** + * Export secrets bundle for transmitting to another device as part of OIDC QR login + * @experimental - part of MSC4108 + */ + exportSecretsForQrLogin(): Promise; + + /** + * Import secrets bundle transmitted from another device as part of OIDC QR login + * @param secrets the secrets bundle received from the other device + * @experimental - part of MSC4108 + */ + importSecretsForQrLogin(secrets: QRSecretsBundle): Promise; /** * Global override for whether the client should ever send encrypted diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 99487643de6..1ca9031c1b6 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -583,11 +583,15 @@ export class Crypto extends TypedEventEmitter { + public supportsSecretsForQrLogin(): boolean { + return false; + } + + public async exportSecretsForQrLogin(): Promise { throw new Error("Method not implemented."); } - public async importSecretsForQRLogin(secrets: QRSecretsBundle): Promise { + public async importSecretsForQrLogin(secrets: QRSecretsBundle): Promise { throw new Error("Method not implemented."); } diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index fef6f576584..a9c012decdd 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -333,7 +333,7 @@ export class MSC4108SignInWithQR { if (device) { // if so, return the secrets - const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQRLogin(); + const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQrLogin(); // send secrets await this.send({ type: PayloadType.Secrets, diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index e3b62fd8b24..48623ad93df 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -177,7 +177,11 @@ export class RustCrypto extends TypedEventEmitter { + public supportsSecretsForQrLogin(): boolean { + return true; + } + + public async exportSecretsForQrLogin(): Promise { try { const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle(); const secrets = secretsBundle.to_json(); @@ -189,7 +193,7 @@ export class RustCrypto extends TypedEventEmitter { + public async importSecretsForQrLogin(secrets: QRSecretsBundle): Promise { const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle } From 80bad22a1c8c7a0d5120c00b23eff1ab80ed875e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 17:40:14 +0100 Subject: [PATCH 48/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../MSC4108RendezvousSession.spec.ts | 21 +++++++ .../channels/MSC4108SecureChannel.spec.ts | 55 +++++++++++++++++++ src/@types/matrix-sdk-crypto-wasm.d.ts | 2 +- .../channels/MSC4108SecureChannel.ts | 2 +- 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts index 245c9cd35e1..a4759b4a5fc 100644 --- a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -80,6 +80,27 @@ describe("MSC4108RendezvousSession", () => { await expect(transport.receive()).resolves.toEqual("data"); } } + + it("should use custom fetchFn if provided", async () => { + const sandbox = fetchMock.sandbox(); + const fetchFn = jest.fn().mockImplementation(sandbox); + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); + const transport = new MSC4108RendezvousSession({ + client, + fetchFn, + fallbackRzServer: "https://fallbackserver/rz", + }); + sandbox.postOnce("https://fallbackserver/rz", { + status: 201, + body: { + url: "https://fallbackserver/rz/123", + }, + }); + await transport.send("data"); + await sandbox.flush(true); + expect(fetchFn).toHaveBeenCalledWith("https://fallbackserver/rz", expect.anything()); + }); + it("should throw an error when no server available", async function () { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false }); const transport = new MSC4108RendezvousSession({ client }); diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts new file mode 100644 index 00000000000..223296bc10e --- /dev/null +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -0,0 +1,55 @@ +/* +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 { QrCodeData, QrCodeMode, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; +import { mocked } from "jest-mock"; + +import { MSC4108RendezvousSession, MSC4108SecureChannel } from "../../../../src/rendezvous"; + +describe("MSC4108SecureChannel", () => { + const url = "https://fallbackserver/rz/123"; + + it("should generate qr code data as expected", async () => { + const session = new MSC4108RendezvousSession({ + url, + }); + const channel = new MSC4108SecureChannel(session); + + const code = await channel.generateCode(QrCodeMode.Login); + expect(code).toHaveLength(71); + const text = new TextDecoder().decode(code); + expect(text.startsWith("MATRIX")).toBeTruthy(); + expect(text.endsWith(url)).toBeTruthy(); + }); + + it("should be able to connect as a reciprocating device", async () => { + const mockSession = { + send: jest.fn(), + receive: jest.fn(), + url, + } as unknown as MSC4108RendezvousSession; + const channel = new MSC4108SecureChannel(mockSession); + + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); + const ciphertext = opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); + + mocked(mockSession.receive).mockResolvedValue(ciphertext); + await channel.connect(); + expect(mockSession.send).toHaveBeenCalled(); + expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK"); + }); +}); diff --git a/src/@types/matrix-sdk-crypto-wasm.d.ts b/src/@types/matrix-sdk-crypto-wasm.d.ts index 1a2b0adf695..591ecd6406d 100644 --- a/src/@types/matrix-sdk-crypto-wasm.d.ts +++ b/src/@types/matrix-sdk-crypto-wasm.d.ts @@ -25,7 +25,7 @@ declare module "@matrix-org/matrix-sdk-crypto-wasm" { interface SecretsBundle { // eslint-disable-next-line @typescript-eslint/naming-convention to_json(): Promise<{ - cross_signing?: { + cross_signing: { master_key: string; self_signing_key: string; user_signing_key: string; diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index b344357952c..0fdc12a551b 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -162,7 +162,7 @@ export class MSC4108SecureChannel { // Step 5 is complete. We don't yet trust the channel - // next step will be for the user to confirm that they see a checkmark on the other device + // next step will be for the user to confirm the check code on the other device } this.connected = true; From 144f91a943c525c9bf84ca01b6246db6871c20bf Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 17:48:19 +0100 Subject: [PATCH 49/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/crypto/secrets.spec.ts | 8 +++++++- src/rust-crypto/rust-crypto.ts | 13 ++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 7048d232c8e..488d4cb935e 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -689,7 +689,13 @@ describe("Secrets", function () { it("should throw Not Implemented for importSecretsForQRLogin", async () => { const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - await expect(alice.getCrypto()?.importSecretsForQrLogin({})).rejects.toThrow("Method not implemented."); + await expect( + alice + .getCrypto() + ?.importSecretsForQrLogin({ + cross_signing: { master_key: "", self_signing_key: "", user_signing_key: "" }, + }), + ).rejects.toThrow("Method not implemented."); }); it("should throw Not Implemented for exportSecretsForQRLogin", async () => { diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 48623ad93df..6d3c91ae53e 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -182,15 +182,10 @@ export class RustCrypto extends TypedEventEmitter { - try { - const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle(); - const secrets = secretsBundle.to_json(); - secretsBundle.free(); - return secrets; - } catch (e) { - // No keys to export - return {}; - } + const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle(); + const secrets = secretsBundle.to_json(); + secretsBundle.free(); + return secrets; } public async importSecretsForQrLogin(secrets: QRSecretsBundle): Promise { From 3f39ce2133a29453750512b98ee0eb9ab161a319 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 18:32:05 +0100 Subject: [PATCH 50/81] Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/rust-crypto/rust-crypto.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 07cc05c1efc..76bfa41f4bf 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1454,8 +1454,10 @@ describe("RustCrypto", () => { ); }); - it("should return an empty object if there is nothing to export", async () => { - await expect(rustCrypto.exportSecretsForQrLogin()).resolves.toEqual({}); + it("should throw an error if there is nothing to export", async () => { + await expect(rustCrypto.exportSecretsForQrLogin()).rejects.toThrow( + "The store doesn't contain any cross-signing keys", + ); }); it("should return a JSON secrets bundle if there is something to export", async () => { From 620fc0f93c80d612bc7812826f1b6bc1d221e85b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 18:53:54 +0100 Subject: [PATCH 51/81] delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/crypto/secrets.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 488d4cb935e..837ca30847e 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -690,11 +690,9 @@ describe("Secrets", function () { it("should throw Not Implemented for importSecretsForQRLogin", async () => { const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); await expect( - alice - .getCrypto() - ?.importSecretsForQrLogin({ - cross_signing: { master_key: "", self_signing_key: "", user_signing_key: "" }, - }), + alice.getCrypto()?.importSecretsForQrLogin({ + cross_signing: { master_key: "", self_signing_key: "", user_signing_key: "" }, + }), ).rejects.toThrow("Method not implemented."); }); From 6647338447980e30e411a11845716a423d03ee08 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 22 Apr 2024 19:02:53 +0100 Subject: [PATCH 52/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/crypto/secrets.spec.ts | 5 ++ .../channels/MSC4108SecureChannel.spec.ts | 60 ++++++++++++++----- spec/unit/rust-crypto/rust-crypto.spec.ts | 4 ++ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 837ca30847e..0c159e25d8f 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -687,6 +687,11 @@ describe("Secrets", function () { }); }); + it("should return false for supportsSecretsForQrLogin", async () => { + const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); + expect(alice.getCrypto()?.supportsSecretsForQrLogin()).toBe(false); + }); + it("should throw Not Implemented for importSecretsForQRLogin", async () => { const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); await expect( diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts index 223296bc10e..a466f9959fe 100644 --- a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { QrCodeData, QrCodeMode, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; +import { EstablishedSecureChannel, QrCodeData, QrCodeMode, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; import { mocked } from "jest-mock"; -import { MSC4108RendezvousSession, MSC4108SecureChannel } from "../../../../src/rendezvous"; +import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous"; describe("MSC4108SecureChannel", () => { const url = "https://fallbackserver/rz/123"; @@ -35,21 +35,49 @@ describe("MSC4108SecureChannel", () => { expect(text.endsWith(url)).toBeTruthy(); }); - it("should be able to connect as a reciprocating device", async () => { - const mockSession = { - send: jest.fn(), - receive: jest.fn(), - url, - } as unknown as MSC4108RendezvousSession; - const channel = new MSC4108SecureChannel(mockSession); + describe("should be able to connect as a reciprocating device", () => { + let mockSession: MSC4108RendezvousSession; + let channel: MSC4108SecureChannel; + let opponentChannel: EstablishedSecureChannel; + + beforeEach(async () => { + mockSession = { + send: jest.fn(), + receive: jest.fn(), + url, + } as unknown as MSC4108RendezvousSession; + channel = new MSC4108SecureChannel(mockSession); - const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); - const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); - const ciphertext = opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); + const ciphertext = opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); + + mocked(mockSession.receive).mockResolvedValue(ciphertext); + await channel.connect(); + expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK"); + mocked(mockSession.send).mockReset(); + }); - mocked(mockSession.receive).mockResolvedValue(ciphertext); - await channel.connect(); - expect(mockSession.send).toHaveBeenCalled(); - expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK"); + it("should be able to securely send encrypted payloads", async () => { + const payload = { + type: PayloadType.Secrets, + protocols: ["a", "b", "c"], + homeserver: "https://example.org", + }; + await channel.secureSend(payload); + expect(mockSession.send).toHaveBeenCalled(); + expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe(JSON.stringify(payload)); + }); + + it("should be able to securely receive encrypted payloads", async () => { + const payload = { + type: PayloadType.Secrets, + protocols: ["a", "b", "c"], + homeserver: "https://example.org", + }; + const ciphertext = opponentChannel.encrypt(JSON.stringify(payload)); + mocked(mockSession.receive).mockResolvedValue(ciphertext); + await expect(channel.secureReceive()).resolves.toEqual(payload); + }); }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 76bfa41f4bf..658c0fa898b 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1454,6 +1454,10 @@ describe("RustCrypto", () => { ); }); + it("should return true for supportsSecretsForQrLogin", async () => { + expect(rustCrypto.supportsSecretsForQrLogin()).toBe(true); + }); + it("should throw an error if there is nothing to export", async () => { await expect(rustCrypto.exportSecretsForQrLogin()).rejects.toThrow( "The store doesn't contain any cross-signing keys", From 141e421335af96f485da5cc55c543bf22012f4b0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 09:56:53 +0100 Subject: [PATCH 53/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 106 ++++++++++++++++++ .../channels/MSC4108SecureChannel.spec.ts | 19 ++++ src/rendezvous/MSC4108SignInWithQR.ts | 49 ++++---- 3 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts diff --git a/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts new file mode 100644 index 00000000000..7e85260cb53 --- /dev/null +++ b/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts @@ -0,0 +1,106 @@ +/* +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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; + +import { MSC4108RendezvousSession, MSC4108SecureChannel, MSC4108SignInWithQR } from "../../../src/rendezvous"; +import { defer } from "../../../src/utils"; +import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src"; + +function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { + const baseUrl = "https://example.com"; + const client = { + doesServerSupportUnstableFeature(feature: string) { + return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108"); + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + baseUrl, + getHomeserverUrl() { + return baseUrl; + }, + } as unknown as MatrixClient; + client.http = new MatrixHttpApi(client, { + baseUrl: client.baseUrl, + prefix: ClientPrefix.Unstable, + onlyData: true, + }); + return client; +} + +describe("MSC4108SignInWithQR", () => { + const url = "https://fallbackserver/rz/123"; + + it("should generate qr code data as expected", async () => { + const session = new MSC4108RendezvousSession({ + url, + }); + const channel = new MSC4108SecureChannel(session); + const login = new MSC4108SignInWithQR(channel, false); + + await login.generateCode(); + expect(login.code).toHaveLength(71); + const text = new TextDecoder().decode(login.code); + expect(text.startsWith("MATRIX")).toBeTruthy(); + expect(text.endsWith(url)).toBeTruthy(); + }); + + describe("should be able to connect as a reciprocating device", () => { + let client: MatrixClient; + let ourLogin: MSC4108SignInWithQR; + let opponentLogin: MSC4108SignInWithQR; + + beforeEach(async () => { + let ourData = defer(); + let opponentData = defer(); + + const ourMockSession = { + send: jest.fn(async (newData) => { + ourData.resolve(newData); + ourData = defer(); + }), + receive: jest.fn(() => opponentData.promise), + url, + } as unknown as MSC4108RendezvousSession; + const opponentMockSession = { + send: jest.fn(async (newData) => { + opponentData.resolve(newData); + opponentData = defer(); + }), + receive: jest.fn(() => ourData.promise), + url, + } as unknown as MSC4108RendezvousSession; + + const ourChannel = new MSC4108SecureChannel(ourMockSession); + const qrCodeData = QrCodeData.from_bytes(await ourChannel.generateCode(QrCodeMode.Reciprocate)); + const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key); + + client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true }); + ourLogin = new MSC4108SignInWithQR(ourChannel, true, client); + opponentLogin = new MSC4108SignInWithQR(opponentChannel, false); + }); + + it("should be able to connect with opponent and share homeserver url", async () => { + const [ourResp, opponentResp] = await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + expect(ourResp).toEqual({}); + expect(opponentResp).toEqual({ homeserverBaseUrl: client.baseUrl }); + }); + }); +}); diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts index a466f9959fe..51a61aa6d2a 100644 --- a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -35,6 +35,25 @@ describe("MSC4108SecureChannel", () => { expect(text.endsWith(url)).toBeTruthy(); }); + it("should throw error on invalid initiate response", async () => { + const mockSession = { + send: jest.fn(), + receive: jest.fn(), + url, + } as unknown as MSC4108RendezvousSession; + const channel = new MSC4108SecureChannel(mockSession); + + mocked(mockSession.receive).mockResolvedValue(""); + await expect(channel.connect()).rejects.toThrow("No response from other device"); + + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); + const ciphertext = opponentChannel.encrypt("NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE"); + + mocked(mockSession.receive).mockResolvedValue(ciphertext); + await expect(channel.connect()).rejects.toThrow("Invalid response from other device"); + }); + describe("should be able to connect as a reciprocating device", () => { let mockSession: MSC4108RendezvousSession; let channel: MSC4108SecureChannel; diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index a9c012decdd..fc247f676ae 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -157,34 +157,32 @@ export class MSC4108SignInWithQR { homeserver: this.client?.getHomeserverUrl() ?? "", }); } - } else { - if (this.isNewDevice) { - // MSC4108-Flow: ExistingScanned - // wait for protocols message - logger.info("Waiting for protocols message"); - const message = await this.receive(); + } else if (this.isNewDevice) { + // MSC4108-Flow: ExistingScanned + // wait for protocols message + logger.info("Waiting for protocols message"); + const message = await this.receive(); - if (message?.type === PayloadType.Failure) { - const { reason } = message as FailurePayload; - throw new RendezvousError("Failed", reason); - } + if (message?.type === PayloadType.Failure) { + const { reason } = message as FailurePayload; + throw new RendezvousError("Failed", reason); + } - if (message?.type !== PayloadType.Protocols) { - await this.send({ - type: PayloadType.Failure, - reason: MSC4108FailureReason.UnexpectedMessageReceived, - }); - throw new RendezvousError( - "Unexpected message received", - MSC4108FailureReason.UnexpectedMessageReceived, - ); - } - const protocolsMessage = message as ProtocolsPayload; - return { homeserverBaseUrl: protocolsMessage.homeserver }; - } else { - // MSC4108-Flow: NewScanned - // nothing to do + if (message?.type !== PayloadType.Protocols) { + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UnexpectedMessageReceived, + }); + throw new RendezvousError( + "Unexpected message received", + MSC4108FailureReason.UnexpectedMessageReceived, + ); } + const protocolsMessage = message as ProtocolsPayload; + return { homeserverBaseUrl: protocolsMessage.homeserver }; + } else { + // MSC4108-Flow: NewScanned + // nothing to do } return {}; } @@ -370,7 +368,6 @@ export class MSC4108SignInWithQR { } public async declineLoginOnExistingDevice(): Promise { - // logger.info("User declined sign in"); await this.send({ type: PayloadType.Declined, }); From 29825259d11cc58cf40b48c774785322263c7e03 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 10:51:11 +0100 Subject: [PATCH 54/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 184 ++++++++++++++++++ .../rendezvous/MSC4108SignInWithQR.spec.ts | 106 ---------- 2 files changed, 184 insertions(+), 106 deletions(-) create mode 100644 spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts delete mode 100644 spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts new file mode 100644 index 00000000000..83aaecd778f --- /dev/null +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -0,0 +1,184 @@ +/* +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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; +import { mocked } from "jest-mock"; + +import { + MSC4108RendezvousSession, + MSC4108SecureChannel, + MSC4108SignInWithQR, + PayloadType, +} from "../../../src/rendezvous"; +import { defer } from "../../../src/utils"; +import { ClientPrefix, IHttpOpts, IMyDevice, MatrixClient, MatrixError, MatrixHttpApi } from "../../../src"; + +function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { + const baseUrl = "https://example.com"; + const crypto = { + exportSecretsForQrLogin: jest.fn(), + }; + const client = { + doesServerSupportUnstableFeature(feature: string) { + return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108"); + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + baseUrl, + getHomeserverUrl() { + return baseUrl; + }, + getDevice: jest.fn(), + getCrypto: jest.fn(() => crypto), + } as unknown as MatrixClient; + client.http = new MatrixHttpApi(client, { + baseUrl: client.baseUrl, + prefix: ClientPrefix.Unstable, + onlyData: true, + }); + return client; +} + +describe("MSC4108SignInWithQR", () => { + const url = "https://fallbackserver/rz/123"; + + it("should generate qr code data as expected", async () => { + const session = new MSC4108RendezvousSession({ + url, + }); + const channel = new MSC4108SecureChannel(session); + const login = new MSC4108SignInWithQR(channel, false); + + await login.generateCode(); + expect(login.code).toHaveLength(71); + const text = new TextDecoder().decode(login.code); + expect(text.startsWith("MATRIX")).toBeTruthy(); + expect(text.endsWith(url)).toBeTruthy(); + }); + + describe("should be able to connect as a reciprocating device", () => { + let client: MatrixClient; + let ourLogin: MSC4108SignInWithQR; + let opponentLogin: MSC4108SignInWithQR; + + beforeEach(async () => { + let ourData = defer(); + let opponentData = defer(); + + const ourMockSession = { + send: jest.fn(async (newData) => { + ourData.resolve(newData); + }), + receive: jest.fn(() => { + const prom = opponentData.promise; + prom.then(() => { + opponentData = defer(); + }); + return prom; + }), + url, + } as unknown as MSC4108RendezvousSession; + const opponentMockSession = { + send: jest.fn(async (newData) => { + opponentData.resolve(newData); + }), + receive: jest.fn(() => { + const prom = ourData.promise; + prom.then(() => { + ourData = defer(); + }); + return prom; + }), + url, + } as unknown as MSC4108RendezvousSession; + + const ourChannel = new MSC4108SecureChannel(ourMockSession); + const qrCodeData = QrCodeData.from_bytes(await ourChannel.generateCode(QrCodeMode.Reciprocate)); + const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key); + + client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true }); + ourLogin = new MSC4108SignInWithQR(ourChannel, true, client); + opponentLogin = new MSC4108SignInWithQR(opponentChannel, false); + }); + + it("should be able to connect with opponent and share homeserver url & check code", async () => { + await Promise.all([ + expect(ourLogin.loginStep1()).resolves.toEqual({}), + expect(opponentLogin.loginStep1()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }), + ]); + + expect(ourLogin.checkCode).toBe(opponentLogin.checkCode); + }); + + it("should be able to connect with opponent and share verificationUri", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + const deviceId = "DEADB33F"; + const verificationUri = "https://example.com/verify"; + const verificationUriComplete = "https://example.com/verify/complete"; + + mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); + + await Promise.all([ + expect(ourLogin.loginStep2And3()).resolves.toEqual({ verificationUri: verificationUriComplete }), + // @ts-ignore + opponentLogin.send({ + type: PayloadType.Protocol, + protocol: "device_authorization_grant", + device_authorization_grant: { + verification_uri: verificationUri, + verification_uri_complete: verificationUriComplete, + }, + device_id: deviceId, + }), + ]); + }); + + it("should be able to connect with opponent and share secrets", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + // @ts-ignore + ourLogin.expectingNewDeviceId = "DEADB33F"; + + const ourProm = ourLogin.loginStep5(); + + // Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here + // @ts-ignore + await opponentLogin.receive(); + + mocked(client.getDevice).mockResolvedValue({} as IMyDevice); + + const secrets = { + cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, + }; + mocked(client.getCrypto()!.exportSecretsForQrLogin).mockResolvedValue(secrets); + + const payload = { + secrets: expect.objectContaining(secrets), + }; + await Promise.all([ + expect(ourProm).resolves.toEqual(payload), + expect(opponentLogin.loginStep5()).resolves.toEqual(payload), + ]); + }); + }); +}); diff --git a/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts deleted file mode 100644 index 7e85260cb53..00000000000 --- a/spec/unit/rendezvous/MSC4108SignInWithQR.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* -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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; - -import { MSC4108RendezvousSession, MSC4108SecureChannel, MSC4108SignInWithQR } from "../../../src/rendezvous"; -import { defer } from "../../../src/utils"; -import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src"; - -function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { - const baseUrl = "https://example.com"; - const client = { - doesServerSupportUnstableFeature(feature: string) { - return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108"); - }, - getUserId() { - return opts.userId; - }, - getDeviceId() { - return opts.deviceId; - }, - baseUrl, - getHomeserverUrl() { - return baseUrl; - }, - } as unknown as MatrixClient; - client.http = new MatrixHttpApi(client, { - baseUrl: client.baseUrl, - prefix: ClientPrefix.Unstable, - onlyData: true, - }); - return client; -} - -describe("MSC4108SignInWithQR", () => { - const url = "https://fallbackserver/rz/123"; - - it("should generate qr code data as expected", async () => { - const session = new MSC4108RendezvousSession({ - url, - }); - const channel = new MSC4108SecureChannel(session); - const login = new MSC4108SignInWithQR(channel, false); - - await login.generateCode(); - expect(login.code).toHaveLength(71); - const text = new TextDecoder().decode(login.code); - expect(text.startsWith("MATRIX")).toBeTruthy(); - expect(text.endsWith(url)).toBeTruthy(); - }); - - describe("should be able to connect as a reciprocating device", () => { - let client: MatrixClient; - let ourLogin: MSC4108SignInWithQR; - let opponentLogin: MSC4108SignInWithQR; - - beforeEach(async () => { - let ourData = defer(); - let opponentData = defer(); - - const ourMockSession = { - send: jest.fn(async (newData) => { - ourData.resolve(newData); - ourData = defer(); - }), - receive: jest.fn(() => opponentData.promise), - url, - } as unknown as MSC4108RendezvousSession; - const opponentMockSession = { - send: jest.fn(async (newData) => { - opponentData.resolve(newData); - opponentData = defer(); - }), - receive: jest.fn(() => ourData.promise), - url, - } as unknown as MSC4108RendezvousSession; - - const ourChannel = new MSC4108SecureChannel(ourMockSession); - const qrCodeData = QrCodeData.from_bytes(await ourChannel.generateCode(QrCodeMode.Reciprocate)); - const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key); - - client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true }); - ourLogin = new MSC4108SignInWithQR(ourChannel, true, client); - opponentLogin = new MSC4108SignInWithQR(opponentChannel, false); - }); - - it("should be able to connect with opponent and share homeserver url", async () => { - const [ourResp, opponentResp] = await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); - expect(ourResp).toEqual({}); - expect(opponentResp).toEqual({ homeserverBaseUrl: client.baseUrl }); - }); - }); -}); From ab232c57aae5a1cf4f3fd8fbcd758db8cf10b498 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 11:49:30 +0100 Subject: [PATCH 55/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 25 +++++++++++++++++++ src/rendezvous/MSC4108SignInWithQR.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 83aaecd778f..a96ecb773fe 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -180,5 +180,30 @@ describe("MSC4108SignInWithQR", () => { expect(opponentLogin.loginStep5()).resolves.toEqual(payload), ]); }); + + it("should abort on unexpected errors", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + // @ts-ignore + ourLogin.expectingNewDeviceId = "DEADB33F"; + + // @ts-ignore + await opponentLogin.send({ + type: PayloadType.Success, + }); + mocked(client.getDevice).mockRejectedValue( + new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500), + ); + + await expect(ourLogin.loginStep5()).rejects.toThrow("The message"); + }); + + it("should abort on declined login", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + await ourLogin.declineLoginOnExistingDevice(); + await expect(opponentLogin.loginStep5()).rejects.toThrow("Unexpected message received"); + }); }); }); diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index fc247f676ae..ede1327188f 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -23,6 +23,7 @@ import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; import { QRSecretsBundle } from "../crypto-api"; import { MatrixError } from "../http-api"; +import { sleep } from "../utils"; export enum PayloadType { Protocols = "m.login.protocols", @@ -348,7 +349,7 @@ export class MSC4108SignInWithQR { throw err; } } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep(1000); } while (Date.now() < timeout); await this.send({ From ef9064f3109c0696cc6c1be5189824c8afaf1185 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 11:54:51 +0100 Subject: [PATCH 56/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index a96ecb773fe..f1bf21e3aa5 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -181,6 +181,34 @@ describe("MSC4108SignInWithQR", () => { ]); }); + it("should abort if device doesn't come up by timeout", async () => { + jest.spyOn(global, "setTimeout").mockImplementation((fn) => { + (fn)(); + return -1; + }); + jest.spyOn(Date, "now").mockImplementation(() => { + if (mocked(setTimeout).mock.calls.length === 1) { + return 12345678 + 11000; + } + return 12345678; + }); + + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + // @ts-ignore + ourLogin.expectingNewDeviceId = "DEADB33F"; + + // @ts-ignore + await opponentLogin.send({ + type: PayloadType.Success, + }); + mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); + + const ourProm = ourLogin.loginStep5(); + await expect(ourProm).rejects.toThrow("New device not found"); + }); + it("should abort on unexpected errors", async () => { await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); From a398a9467956584a6b8b94edf5d1efc59374348c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 12:11:34 +0100 Subject: [PATCH 57/81] Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 53 ++++++++++++++++++- .../channels/MSC4108SecureChannel.spec.ts | 15 ++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index f1bf21e3aa5..35b680a31fc 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -67,10 +67,15 @@ describe("MSC4108SignInWithQR", () => { const login = new MSC4108SignInWithQR(channel, false); await login.generateCode(); - expect(login.code).toHaveLength(71); - const text = new TextDecoder().decode(login.code); + const code = login.code; + expect(code).toHaveLength(71); + const text = new TextDecoder().decode(code); expect(text.startsWith("MATRIX")).toBeTruthy(); expect(text.endsWith(url)).toBeTruthy(); + + // Assert that the code is stable + await login.generateCode(); + expect(login.code).toEqual(code); }); describe("should be able to connect as a reciprocating device", () => { @@ -152,6 +157,50 @@ describe("MSC4108SignInWithQR", () => { ]); }); + it("should abort if device already exists", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + const deviceId = "DEADB33F"; + const verificationUri = "https://example.com/verify"; + + mocked(client.getDevice).mockResolvedValue({} as IMyDevice); + + await Promise.all([ + expect(ourLogin.loginStep2And3()).rejects.toThrow("Specified device ID already exists"), + // @ts-ignore + opponentLogin.send({ + type: PayloadType.Protocol, + protocol: "device_authorization_grant", + device_authorization_grant: { + verification_uri: verificationUri, + }, + device_id: deviceId, + }), + ]); + }); + + it("should abort on unsupported protocol", async () => { + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + const deviceId = "DEADB33F"; + const verificationUri = "https://example.com/verify"; + + await Promise.all([ + expect(ourLogin.loginStep2And3()).rejects.toThrow("Received a request for an unsupported protocol"), + // @ts-ignore + opponentLogin.send({ + type: PayloadType.Protocol, + protocol: "device_authorization_grant_v2", + device_authorization_grant: { + verification_uri: verificationUri, + }, + device_id: deviceId, + }), + ]); + }); + it("should be able to connect with opponent and share secrets", async () => { await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts index 51a61aa6d2a..d34b28a3b3f 100644 --- a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -35,6 +35,21 @@ describe("MSC4108SecureChannel", () => { expect(text.endsWith(url)).toBeTruthy(); }); + it("should throw error if attempt to connect multiple times", async () => { + const mockSession = { + send: jest.fn(), + receive: jest.fn(), + url, + } as unknown as MSC4108RendezvousSession; + const channel = new MSC4108SecureChannel(mockSession); + + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); + mocked(mockSession.receive).mockResolvedValue(opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE")); + await channel.connect(); + await expect(channel.connect()).rejects.toThrow("Channel already connected"); + }); + it("should throw error on invalid initiate response", async () => { const mockSession = { send: jest.fn(), From b788fe8ce298c30c85ba42343bf650227886f8f9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 12:34:14 +0100 Subject: [PATCH 58/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index ede1327188f..9101cb5cc48 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -280,13 +280,13 @@ export class MSC4108SignInWithQR { }); // then wait for secrets logger.info("Waiting for secrets message"); - const secrets = await this.receive(); - if (secrets?.type === PayloadType.Failure) { - const { reason } = secrets as FailurePayload; + const payload = await this.receive(); + if (payload?.type === PayloadType.Failure) { + const { reason } = payload; throw new RendezvousError("Failed", reason); } - if (secrets?.type !== PayloadType.Secrets) { + if (payload?.type !== PayloadType.Secrets) { await this.send({ type: PayloadType.Failure, reason: MSC4108FailureReason.UnexpectedMessageReceived, @@ -296,7 +296,7 @@ export class MSC4108SignInWithQR { MSC4108FailureReason.UnexpectedMessageReceived, ); } - return { secrets }; + return { secrets: payload }; // then done? } else { if (!this.expectingNewDeviceId) { From 6bcb7c354319b46d8c0ba535d99bc73b74027520 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 15:07:25 +0100 Subject: [PATCH 59/81] Handle etag missing state Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/RendezvousFailureReason.ts | 2 ++ .../transports/MSC4108RendezvousSession.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index e317981a515..7a0116ca0e1 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -57,4 +57,6 @@ export enum ClientRendezvousFailureReason { Unknown = "unknown", /** The user declined the sign in request */ UserDeclined = "user_declined", + /** The rendezvous request is missing an ETag header */ + ETagMissing = "etag_missing", } diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 9dc81e1c026..8e84052da6e 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -169,16 +169,24 @@ export class MSC4108RendezvousSession { const poll = await this.fetch(this.url, { method: Method.Get, headers }); if (poll.status === 404) { - this.cancel(ClientRendezvousFailureReason.Unknown); + await this.cancel(ClientRendezvousFailureReason.Unknown); return undefined; } // rely on server expiring the channel rather than checking ourselves + const etag = poll.headers.get("etag") ?? undefined; if (poll.headers.get("content-type") !== "text/plain") { - this.etag = poll.headers.get("etag") ?? undefined; + this.etag = etag; } else if (poll.status === 200) { - this.etag = poll.headers.get("etag") ?? undefined; + if (!etag) { + // Some browsers & extensions block the ETag header for anti-tracking purposes + // We try and detect this so the client can give the user a somewhat helpful message + await this.cancel(ClientRendezvousFailureReason.ETagMissing); + return undefined; + } + + this.etag = etag; const text = await poll.text(); logger.info(`Received: ${text} with etag ${this.etag}`); return text; From 6e617c35dd6ab32edcd2372f3afcdd613a81bb1f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 23 Apr 2024 17:16:59 +0100 Subject: [PATCH 60/81] Update tests to match MSC Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../MSC4108RendezvousSession.spec.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts index a4759b4a5fc..57afd3afced 100644 --- a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -47,12 +47,7 @@ describe("MSC4108RendezvousSession", () => { fetchMock.reset(); }); - async function postAndCheckLocation( - msc4108Enabled: boolean, - fallbackRzServer: string, - locationResponse: string, - expectedFinalLocation: string, - ) { + async function postAndCheckLocation(msc4108Enabled: boolean, fallbackRzServer: string, locationResponse: string) { const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled }); const transport = new MSC4108RendezvousSession({ client, fallbackRzServer }); { @@ -69,12 +64,12 @@ describe("MSC4108RendezvousSession", () => { } { - // first GET without etag fetchMock.get(locationResponse, { status: 200, body: "data", headers: { "content-type": "text/plain", + "etag": "aaa", }, }); await expect(transport.receive()).resolves.toEqual("data"); @@ -134,25 +129,19 @@ describe("MSC4108RendezvousSession", () => { }); it("POST with absolute path response", async function () { - await postAndCheckLocation(false, "https://fallbackserver/rz", "/123", "https://fallbackserver/123"); + await postAndCheckLocation(false, "https://fallbackserver/rz", "https://fallbackserver/123"); }); it("POST to built-in MSC3886 implementation", async function () { await postAndCheckLocation( true, "https://fallbackserver/rz", - "123", "https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123", ); }); it("POST with relative path response including parent", async function () { - await postAndCheckLocation( - false, - "https://fallbackserver/rz/abc", - "../xyz/123", - "https://fallbackserver/rz/xyz/123", - ); + await postAndCheckLocation(false, "https://fallbackserver/rz/abc", "https://fallbackserver/rz/xyz/123"); }); // fetch-mock doesn't handle redirects properly, so we can't test this From 0f53b3b1ce864da1752f9ddc5ff08c93ed346868 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 24 Apr 2024 15:46:01 +0100 Subject: [PATCH 61/81] delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e91f9b5774b..3d8cd6c0d7d 100644 --- a/package.json +++ b/package.json @@ -134,4 +134,4 @@ "outputName": "jest-sonar-report.xml", "relativePaths": true } -} \ No newline at end of file +} From f616fceee52cce9126061ca5872ab89ed622fdd3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 11:10:19 +0100 Subject: [PATCH 62/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 8 +- src/oidc/register.ts | 7 +- src/rendezvous/MSC4108SignInWithQR.ts | 90 +++++++++++-------- .../channels/MSC4108SecureChannel.ts | 4 + .../transports/MSC4108RendezvousSession.ts | 14 +-- 5 files changed, 75 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 3d8cd6c0d7d..6ff050e7cd3 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@babel/preset-typescript": "^7.12.7", "@casualbot/jest-sonar-reporter": "2.2.7", "@matrix-org/olm": "3.2.15", + "@peculiar/webcrypto": "^1.4.5", "@types/bs58": "^4.0.1", "@types/content-type": "^1.1.5", "@types/debug": "^4.1.7", @@ -115,8 +116,10 @@ "jest-environment-jsdom": "^29.0.0", "jest-localstorage-mock": "^2.4.6", "jest-mock": "^29.0.0", + "knip": "^5.0.0", "lint-staged": "^15.0.2", "matrix-mock-request": "^2.5.0", + "node-fetch": "^2.7.0", "prettier": "3.2.5", "rimraf": "^5.0.0", "ts-node": "^10.9.2", @@ -124,10 +127,7 @@ "typedoc-plugin-coverage": "^3.0.0", "typedoc-plugin-mdn-links": "^3.0.3", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "^5.3.3", - "node-fetch": "^2.7.0", - "knip": "^5.0.0", - "@peculiar/webcrypto": "^1.4.5" + "typescript": "^5.3.3" }, "@casualbot/jest-sonar-reporter": { "outputDirectory": "coverage", diff --git a/src/oidc/register.ts b/src/oidc/register.ts index 5e20ca37db5..c6415348a73 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -49,6 +49,8 @@ interface OidcRegistrationRequestBody { application_type: "web" | "native"; } +export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code"; + /** * Attempts dynamic registration against the configured registration endpoint * @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown} @@ -69,9 +71,8 @@ export const registerOidcClient = async ( throw new Error(OidcError.DynamicRegistrationNotSupported); } - const deviceCodeScope = "urn:ietf:params:oauth:grant-type:device_code"; - if (delegatedAuthConfig.metadata.grant_types_supported.includes(deviceCodeScope)) { - grantTypes.push(deviceCodeScope); + if (delegatedAuthConfig.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) { + grantTypes.push(DEVICE_CODE_SCOPE); } // https://openid.net/specs/openid-connect-registration-1_0.html diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 9101cb5cc48..565424df5a3 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -24,6 +24,7 @@ import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; import { QRSecretsBundle } from "../crypto-api"; import { MatrixError } from "../http-api"; import { sleep } from "../utils"; +import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc"; export enum PayloadType { Protocols = "m.login.protocols", @@ -47,7 +48,7 @@ interface ProtocolsPayload extends MSC4108Payload { interface ProtocolPayload extends MSC4108Payload { type: PayloadType.Protocol; - protocol: string; + protocol: Exclude; device_id: string; } @@ -59,6 +60,12 @@ interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload { }; } +function isDeviceAuthorizationGrantProtocolPayload( + payload: ProtocolPayload, +): payload is DeviceAuthorizationGrantProtocolPayload { + return payload.protocol === "device_authorization_grant"; +} + interface FailurePayload extends MSC4108Payload { type: PayloadType.Failure; reason: MSC4108FailureReason; @@ -151,25 +158,44 @@ export class MSC4108SignInWithQR { } else { // MSC4108-Flow: NewScanned // send protocols message - // PROTOTYPE: we should be checking that the advertised protocol is available - await this.send({ - type: PayloadType.Protocols, - protocols: ["device_authorization_grant"], - homeserver: this.client?.getHomeserverUrl() ?? "", - }); + + let oidcClientConfig: OidcClientConfig | undefined; + try { + const { issuer } = await this.client!.getAuthIssuer(); + oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer); + } catch (e) { + logger.error("Failed to discover OIDC metadata", e); + } + + if (oidcClientConfig?.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) { + await this.send({ + type: PayloadType.Protocols, + protocols: ["device_authorization_grant"], + homeserver: this.client?.getHomeserverUrl() ?? "", + }); + } 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 message = await this.receive(); + const payload = await this.receive(); - if (message?.type === PayloadType.Failure) { - const { reason } = message as FailurePayload; + if (payload?.type === PayloadType.Failure) { + const { reason } = payload; throw new RendezvousError("Failed", reason); } - if (message?.type !== PayloadType.Protocols) { + if (payload?.type !== PayloadType.Protocols) { await this.send({ type: PayloadType.Failure, reason: MSC4108FailureReason.UnexpectedMessageReceived, @@ -179,8 +205,8 @@ export class MSC4108SignInWithQR { MSC4108FailureReason.UnexpectedMessageReceived, ); } - const protocolsMessage = message as ProtocolsPayload; - return { homeserverBaseUrl: protocolsMessage.homeserver }; + + return { homeserverBaseUrl: payload.homeserver }; } else { // MSC4108-Flow: NewScanned // nothing to do @@ -192,7 +218,6 @@ export class MSC4108SignInWithQR { verificationUri?: string; userCode?: string; }> { - logger.info("loginStep2And3()"); if (this.isNewDevice) { throw new Error("New device flows around OIDC are not yet implemented"); } else { @@ -200,14 +225,14 @@ export class MSC4108SignInWithQR { // 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 message = await this.receive(); + const payload = await this.receive(); - if (message?.type === PayloadType.Failure) { - const { reason } = message as FailurePayload; + if (payload?.type === PayloadType.Failure) { + const { reason } = payload; throw new RendezvousError("Failed", reason); } - if (message?.type !== PayloadType.Protocol) { + if (payload?.type !== PayloadType.Protocol) { await this.send({ type: PayloadType.Failure, reason: MSC4108FailureReason.UnexpectedMessageReceived, @@ -218,15 +243,10 @@ export class MSC4108SignInWithQR { ); } - const protocolMessage = message as ProtocolPayload; - if (protocolMessage.protocol === "device_authorization_grant") { - const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = - protocolMessage as DeviceAuthorizationGrantProtocolPayload; + if (isDeviceAuthorizationGrantProtocolPayload(payload)) { + const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = payload; const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag; - // PROTOTYPE: this is an implementation of option 3c for when to share the secrets: - // check if there is already a device online with that device ID - let deviceAlreadyExists = true; try { await this.client?.getDevice(expectingNewDeviceId); @@ -272,15 +292,13 @@ export class MSC4108SignInWithQR { } public async loginStep5(): Promise<{ secrets?: QRSecretsBundle }> { - logger.info("loginStep5()"); - if (this.isNewDevice) { await this.send({ type: PayloadType.Success, }); // then wait for secrets logger.info("Waiting for secrets message"); - const payload = await this.receive(); + const payload = await this.receive(); if (payload?.type === PayloadType.Failure) { const { reason } = payload; throw new RendezvousError("Failed", reason); @@ -307,14 +325,14 @@ export class MSC4108SignInWithQR { }); logger.info("Waiting for outcome message"); - const res = await this.receive(); + const payload = await this.receive(); - if (res?.type === PayloadType.Failure) { - const { reason } = res as FailurePayload; + if (payload?.type === PayloadType.Failure) { + const { reason } = payload; throw new RendezvousError("Failed", reason); } - if (res?.type !== PayloadType.Success) { + if (payload?.type !== PayloadType.Success) { await this.send({ type: PayloadType.Failure, reason: MSC4108FailureReason.UnexpectedMessageReceived, @@ -322,8 +340,6 @@ export class MSC4108SignInWithQR { throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived); } - // PROTOTYPE: this also needs to handle the case of the process being cancelled - // i.e. aborting the waiting and making sure not to share the secrets const timeout = Date.now() + 10000; // wait up to 10 seconds do { // is the device visible via the Homeserver? @@ -333,13 +349,15 @@ export class MSC4108SignInWithQR { if (device) { // if so, return the secrets const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQrLogin(); + if (this.channel.cancelled) { + throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled); + } // send secrets await this.send({ type: PayloadType.Secrets, ...secretsBundle, }); return { secrets: secretsBundle }; - // done? // let the other side close the rendezvous session } } catch (err: MatrixError | unknown) { @@ -360,7 +378,7 @@ export class MSC4108SignInWithQR { } } - private async receive(): Promise { + private async receive(): Promise { return (await this.channel.secureReceive()) as T | undefined; } diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 0fdc12a551b..4ff6b6ed449 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -221,4 +221,8 @@ export class MSC4108SecureChannel { await this.close(); } } + + public get cancelled(): boolean { + return this.rendezvousSession.cancelled; + } } diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 8e84052da6e..9d699e5e613 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -23,7 +23,7 @@ import { ClientPrefix } from "../../http-api"; /** * Prototype of the unstable [4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) * insecure rendezvous session protocol. - * Note that this is UNSTABLE and may have breaking changes without notice. + * @experimental Note that this is UNSTABLE and may have breaking changes without notice. */ export class MSC4108RendezvousSession { public url?: string; @@ -32,7 +32,7 @@ export class MSC4108RendezvousSession { private client?: MatrixClient; private fallbackRzServer?: string; private fetchFn?: typeof global.fetch; - private cancelled = false; + private _cancelled = false; private _ready = false; public onFailure?: RendezvousFailureListener; @@ -104,7 +104,7 @@ export class MSC4108RendezvousSession { } public async send(data: string): Promise { - if (this.cancelled) { + if (this._cancelled) { return; } const method = this.url ? Method.Put : Method.Post; @@ -156,7 +156,7 @@ export class MSC4108RendezvousSession { } // eslint-disable-next-line no-constant-condition while (true) { - if (this.cancelled) { + if (this._cancelled) { return undefined; } @@ -204,7 +204,7 @@ export class MSC4108RendezvousSession { reason = ClientRendezvousFailureReason.Expired; } - this.cancelled = true; + this._cancelled = true; this._ready = false; this.onFailure?.(reason); @@ -216,4 +216,8 @@ export class MSC4108RendezvousSession { } } } + + public get cancelled(): boolean { + return this._cancelled; + } } From 06cdc923ecb26a5e644f0329758e23e6dac491fe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 11:41:17 +0100 Subject: [PATCH 63/81] Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 31 ++++++++++++++++++- spec/test-utils/oidc.ts | 7 +++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 35b680a31fc..ad25f37f6cc 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -16,6 +16,7 @@ limitations under the License. import { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; import { mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; import { MSC4108RendezvousSession, @@ -24,7 +25,16 @@ import { PayloadType, } from "../../../src/rendezvous"; import { defer } from "../../../src/utils"; -import { ClientPrefix, IHttpOpts, IMyDevice, MatrixClient, MatrixError, MatrixHttpApi } from "../../../src"; +import { + ClientPrefix, + DEVICE_CODE_SCOPE, + IHttpOpts, + IMyDevice, + MatrixClient, + MatrixError, + MatrixHttpApi, +} from "../../../src"; +import { mockOpenIdConfiguration } from "../../test-utils/oidc"; function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient { const baseUrl = "https://example.com"; @@ -47,6 +57,7 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled }, getDevice: jest.fn(), getCrypto: jest.fn(() => crypto), + getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }), } as unknown as MatrixClient; client.http = new MatrixHttpApi(client, { baseUrl: client.baseUrl, @@ -57,6 +68,24 @@ function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled } describe("MSC4108SignInWithQR", () => { + beforeEach(() => { + fetchMock.get( + "https://issuer/.well-known/openid-configuration", + mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]), + ); + fetchMock.get("https://issuer/jwks", { + status: 200, + headers: { + "Content-Type": "application/json", + }, + keys: [], + }); + }); + + afterEach(() => { + fetchMock.reset(); + }); + const url = "https://fallbackserver/rz/123"; it("should generate qr code data as expected", async () => { diff --git a/spec/test-utils/oidc.ts b/spec/test-utils/oidc.ts index 4f9a01c2ee2..8f2965c9a2a 100644 --- a/spec/test-utils/oidc.ts +++ b/spec/test-utils/oidc.ts @@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien * @param issuer used as the base for all other urls * @returns ValidatedIssuerMetadata */ -export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({ +export const mockOpenIdConfiguration = ( + issuer = "https://auth.org/", + additionalGrantTypes: string[] = [], +): ValidatedIssuerMetadata => ({ issuer, revocation_endpoint: issuer + "revoke", token_endpoint: issuer + "token", @@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated device_authorization_endpoint: issuer + "device", jwks_uri: issuer + "jwks", response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], + grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes], code_challenge_methods_supported: ["S256"], }); From 608f7dd7f8436ef737ee24b2687e0a68c7adce3e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 12:10:21 +0100 Subject: [PATCH 64/81] Simplify Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/oidc/register.ts | 4 ---- src/oidc/validate.ts | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/oidc/register.ts b/src/oidc/register.ts index c6415348a73..ec673e7975a 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -71,10 +71,6 @@ export const registerOidcClient = async ( throw new Error(OidcError.DynamicRegistrationNotSupported); } - if (delegatedAuthConfig.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) { - grantTypes.push(DEVICE_CODE_SCOPE); - } - // https://openid.net/specs/openid-connect-registration-1_0.html const metadata: OidcRegistrationRequestBody = { client_name: clientMetadata.clientName, diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 48507ff8b67..a49a955670b 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -183,7 +183,7 @@ export const validateIdToken = ( issuer: string, clientId: string, nonce: string | undefined, -): IdTokenClaims => { +): void => { try { if (!idToken) { throw new Error("No ID token"); @@ -219,8 +219,6 @@ export const validateIdToken = ( if (!claims.exp || Date.now() > claims.exp * 1000) { throw new Error("Invalid expiry"); } - - return claims; } catch (error) { logger.error("Invalid ID token", error); throw new Error(OidcError.InvalidIdToken); From 402879abe1dd33dcc3e378c32ae6af8559190c75 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 14:14:28 +0100 Subject: [PATCH 65/81] Handle cancellation before sharing secrets Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 48 +++++++++++++++++-- .../transports/MSC4108RendezvousSession.ts | 5 +- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index ad25f37f6cc..739f671ef3d 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -19,6 +19,7 @@ import { mocked } from "jest-mock"; import fetchMock from "fetch-mock-jest"; import { + MSC4108FailureReason, MSC4108RendezvousSession, MSC4108SecureChannel, MSC4108SignInWithQR, @@ -128,6 +129,12 @@ describe("MSC4108SignInWithQR", () => { return prom; }), url, + cancelled: false, + cancel: () => { + // @ts-ignore + ourMockSession.cancelled = true; + ourData.resolve(""); + }, } as unknown as MSC4108RendezvousSession; const opponentMockSession = { send: jest.fn(async (newData) => { @@ -265,10 +272,7 @@ describe("MSC4108SignInWithQR", () => { return -1; }); jest.spyOn(Date, "now").mockImplementation(() => { - if (mocked(setTimeout).mock.calls.length === 1) { - return 12345678 + 11000; - } - return 12345678; + return 12345678 + mocked(setTimeout).mock.calls.length * 1000; }); await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); @@ -311,5 +315,41 @@ describe("MSC4108SignInWithQR", () => { await ourLogin.declineLoginOnExistingDevice(); await expect(opponentLogin.loginStep5()).rejects.toThrow("Unexpected message received"); }); + + it("should not send secrets if user cancels", async () => { + jest.spyOn(global, "setTimeout").mockImplementation((fn) => { + (fn)(); + return -1; + }); + + await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + + // We don't have the new device side of this flow implemented at this time so mock it + // @ts-ignore + ourLogin.expectingNewDeviceId = "DEADB33F"; + + const ourProm = ourLogin.loginStep5(); + const opponentProm = opponentLogin.loginStep5(); + + // Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here + // @ts-ignore + await opponentLogin.receive(); + + const deferred = defer(); + mocked(client.getDevice).mockReturnValue(deferred.promise); + + ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {}); + deferred.resolve({} as IMyDevice); + + const secrets = { + cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, + }; + mocked(client.getCrypto()!.exportSecretsForQrLogin).mockResolvedValue(secrets); + + await Promise.all([ + expect(ourProm).rejects.toThrow("User cancelled"), + expect(opponentProm).rejects.toThrow("Unexpected message received"), + ]); + }); }); }); diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 9d699e5e613..358976f1fc0 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -208,7 +208,10 @@ export class MSC4108RendezvousSession { this._ready = false; this.onFailure?.(reason); - if (this.url && reason === ClientRendezvousFailureReason.UserDeclined) { + if ( + this.url && + (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) + ) { try { await this.fetch(this.url, { method: Method.Delete }); } catch (e) { From 7c3663a1649f2ae714582b4fedc662f5272bd294 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 14:32:51 +0100 Subject: [PATCH 66/81] Remove redundant test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/unit/oidc/register.spec.ts | 37 --------------------------------- 1 file changed, 37 deletions(-) diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts index 84b25b470ad..f0a37e33366 100644 --- a/spec/unit/oidc/register.spec.ts +++ b/spec/unit/oidc/register.spec.ts @@ -117,41 +117,4 @@ describe("registerOidcClient()", () => { ), ).rejects.toThrow(OidcError.DynamicRegistrationNotSupported); }); - - it("should request device_code scope if it is supported", async () => { - fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, { - status: 200, - body: JSON.stringify({ client_id: dynamicClientId }), - }); - - await expect(registerOidcClient(delegatedAuthConfig, metadata)).resolves.toBe(dynamicClientId); - expect(fetchMockJest).toHaveFetched(delegatedAuthConfig.registrationEndpoint!, { - matchPartialBody: true, - body: { - grant_types: ["authorization_code", "refresh_token"], - }, - }); - - await expect( - registerOidcClient( - { - ...delegatedAuthConfig, - metadata: { - ...delegatedAuthConfig.metadata, - grant_types_supported: [ - ...delegatedAuthConfig.metadata.grant_types_supported, - "urn:ietf:params:oauth:grant-type:device_code", - ], - }, - }, - metadata, - ), - ).resolves.toBe(dynamicClientId); - expect(fetchMockJest).toHaveFetched(delegatedAuthConfig.registrationEndpoint!, { - matchPartialBody: true, - body: { - grant_types: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], - }, - }); - }); }); From 5dc93bafb60cb6d87bbbeefa1643602913014a94 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 16:12:23 +0100 Subject: [PATCH 67/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 7 +- .../channels/MSC4108SecureChannel.spec.ts | 7 +- src/rendezvous/MSC4108SignInWithQR.ts | 76 +++++++++-------- .../channels/MSC4108SecureChannel.ts | 84 +++++++++++++------ .../transports/MSC4108RendezvousSession.ts | 54 ++++++++---- 5 files changed, 150 insertions(+), 78 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 739f671ef3d..d419bc72099 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -150,11 +150,14 @@ describe("MSC4108SignInWithQR", () => { url, } as unknown as MSC4108RendezvousSession; + client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true }); + const ourChannel = new MSC4108SecureChannel(ourMockSession); - const qrCodeData = QrCodeData.from_bytes(await ourChannel.generateCode(QrCodeMode.Reciprocate)); + const qrCodeData = QrCodeData.from_bytes( + await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getHomeserverUrl()), + ); const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key); - client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true }); ourLogin = new MSC4108SignInWithQR(ourChannel, true, client); opponentLogin = new MSC4108SignInWithQR(opponentChannel, false); }); diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts index d34b28a3b3f..698338718cd 100644 --- a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -20,6 +20,7 @@ import { mocked } from "jest-mock"; import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous"; describe("MSC4108SecureChannel", () => { + const baseUrl = "https://example.com"; const url = "https://fallbackserver/rz/123"; it("should generate qr code data as expected", async () => { @@ -43,7 +44,7 @@ describe("MSC4108SecureChannel", () => { } as unknown as MSC4108RendezvousSession; const channel = new MSC4108SecureChannel(mockSession); - const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); mocked(mockSession.receive).mockResolvedValue(opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE")); await channel.connect(); @@ -61,7 +62,7 @@ describe("MSC4108SecureChannel", () => { mocked(mockSession.receive).mockResolvedValue(""); await expect(channel.connect()).rejects.toThrow("No response from other device"); - const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); const ciphertext = opponentChannel.encrypt("NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE"); @@ -82,7 +83,7 @@ describe("MSC4108SecureChannel", () => { } as unknown as MSC4108RendezvousSession; channel = new MSC4108SecureChannel(mockSession); - const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate)); + const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); const ciphertext = opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 565424df5a3..5bc3d10a357 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { OidcClient } from "oidc-client-ts"; import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm"; import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener } from "."; @@ -89,32 +88,28 @@ interface SecretsPayload extends MSC4108Payload, QRSecretsBundle { } export class MSC4108SignInWithQR { - private ourIntent: QrCodeMode; + private readonly ourIntent: QrCodeMode; private _code?: Uint8Array; - public protocol?: string; private expectingNewDeviceId?: string; + /** + * Returns the check code for the secure channel or undefined if not generated yet. + */ public get checkCode(): string | undefined { - const x = this.channel?.getCheckCode(); - - if (!x) { - return undefined; - } - return Array.from(x.as_bytes()) - .map((b) => `${b % 10}`) - .join(""); + 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 channel: MSC4108SecureChannel, - private didScanCode: boolean, - private client?: MatrixClient, - public onFailure?: RendezvousFailureListener, + private readonly channel: MSC4108SecureChannel, + private readonly didScanCode: boolean, + private readonly client?: MatrixClient, + private readonly onFailure?: RendezvousFailureListener, ) { this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login; } @@ -134,13 +129,23 @@ export class MSC4108SignInWithQR { return; } - this._code = await this.channel.generateCode(this.ourIntent, this.client?.getHomeserverUrl()); + if (this.ourIntent === QrCodeMode.Reciprocate && this.client) { + this._code = await this.channel.generateCode(this.ourIntent, this.client.getHomeserverUrl()); + } else if (this.ourIntent === QrCodeMode.Login) { + this._code = await this.channel.generateCode(this.ourIntent); + } } + /** + * 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.ourIntent === QrCodeMode.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; } @@ -153,12 +158,9 @@ export class MSC4108SignInWithQR { // 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 + // MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set } else { - // MSC4108-Flow: NewScanned - // send protocols message - + // MSC4108-Flow: NewScanned -send protocols message let oidcClientConfig: OidcClientConfig | undefined; try { const { issuer } = await this.client!.getAuthIssuer(); @@ -185,8 +187,7 @@ export class MSC4108SignInWithQR { } } } else if (this.isNewDevice) { - // MSC4108-Flow: ExistingScanned - // wait for protocols message + // MSC4108-Flow: ExistingScanned - wait for protocols message logger.info("Waiting for protocols message"); const payload = await this.receive(); @@ -208,20 +209,19 @@ export class MSC4108SignInWithQR { return { homeserverBaseUrl: payload.homeserver }; } else { - // MSC4108-Flow: NewScanned - // nothing to do + // MSC4108-Flow: NewScanned - nothing to do } return {}; } - public async loginStep2And3(oidcClient?: OidcClient): Promise<{ + public async loginStep2And3(): Promise<{ verificationUri?: string; userCode?: string; }> { if (this.isNewDevice) { throw new Error("New device flows around OIDC are not yet implemented"); } else { - // The user needs to do step 7 for the out of band confirmation + // 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"); @@ -283,13 +283,8 @@ export class MSC4108SignInWithQR { } } - public async loginStep4a(): Promise { - throw new Error("New device flows around OIDC are not yet implemented"); - } - - public async loginStep4b(): Promise { - throw new Error("New device flows around OIDC are not yet implemented"); - } + // Login step 4 is not implemented as it is only performed by the new device in the flow + // and the new device flow is not yet implemented. public async loginStep5(): Promise<{ secrets?: QRSecretsBundle }> { if (this.isNewDevice) { @@ -386,17 +381,30 @@ export class MSC4108SignInWithQR { 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.Declined, }); } + /** + * 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(); } diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 4ff6b6ed449..883043b9ed8 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { - CheckCode, Curve25519PublicKey, EstablishedSecureChannel, QrCodeData, @@ -49,6 +48,13 @@ export class MSC4108SecureChannel { this.secureChannel = new SecureChannel(); } + /** + * Generate a QR code for the current session. + * @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`. + * @param homeserverBaseUrl the base URL of the homeserver to connect to, required for `Reciprocate` mode. + */ + public async generateCode(mode: QrCodeMode.Login): Promise; + public async generateCode(mode: QrCodeMode.Reciprocate, homeserverBaseUrl: string): Promise; public async generateCode(mode: QrCodeMode, homeserverBaseUrl?: string): Promise { const { url } = this.rendezvousSession; @@ -63,10 +69,23 @@ export class MSC4108SecureChannel { ).to_bytes(); } - public getCheckCode(): CheckCode | undefined { - return this.establishedChannel?.check_code(); + /** + * Returns the check code for the secure channel or undefined if not generated yet. + */ + public getCheckCode(): string | undefined { + const x = this.establishedChannel?.check_code(); + + if (!x) { + return undefined; + } + return Array.from(x.as_bytes()) + .map((b) => `${b % 10}`) + .join(""); } + /** + * Connects and establishes a secure channel with the other device. + */ public async connect(): Promise { if (this.connected) { throw new Error("Channel already connected"); @@ -76,7 +95,7 @@ export class MSC4108SecureChannel { // We are the scanning device this.establishedChannel = this.secureChannel.create_outbound_channel(this.theirPublicKey); - /** + /* Secure Channel step 4. Device S sends the initial message Nonce := 0 @@ -92,17 +111,16 @@ export class MSC4108SecureChannel { await this.rendezvousSession.send(loginInitiateMessage); } - /** - Secure Channel step 6. Verification by Device S - - Nonce_G := 1 - (TaggedCiphertext, Sp) := Unpack(Message) - Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext) - Nonce_G := Nonce_G + 2 + /* + Secure Channel step 6. Verification by Device S - unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": - FAIL + Nonce_G := 1 + (TaggedCiphertext, Sp) := Unpack(Message) + Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext) + Nonce_G := Nonce_G + 2 + unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK": + FAIL */ { logger.info("Waiting for LoginOkMessage"); @@ -126,16 +144,15 @@ export class MSC4108SecureChannel { // Step 6 is now complete. We trust the channel } } else { - /** - Secure Channel step 5. Device G confirms - - Nonce_S := 0 - (TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) - SH := ECDH(Gs, Sp) - EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) - Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext) - Nonce_S := Nonce_S + 2 - + /* + Secure Channel step 5. Device G confirms + + Nonce_S := 0 + (TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage) + SH := ECDH(Gs, Sp) + EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32) + Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext) + Nonce_S := Nonce_S + 2 */ // wait for the other side to send us their public key logger.info("Waiting for LoginInitiateMessage"); @@ -184,6 +201,10 @@ export class MSC4108SecureChannel { return this.establishedChannel.encrypt(plaintext); } + /** + * 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"); @@ -195,6 +216,9 @@ export class MSC4108SecureChannel { 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"); @@ -211,8 +235,17 @@ export class MSC4108SecureChannel { return json as Partial | undefined; } - public async close(): Promise {} + /** + * 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); @@ -222,6 +255,9 @@ export class MSC4108SecureChannel { } } + /** + * Returns whether the rendezvous session has been cancelled. + */ public get cancelled(): boolean { return this.rendezvousSession.cancelled; } diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 358976f1fc0..3eff5ad76fe 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -27,14 +27,14 @@ import { ClientPrefix } from "../../http-api"; */ export class MSC4108RendezvousSession { public url?: string; + private readonly client?: MatrixClient; + private readonly fallbackRzServer?: string; + private readonly fetchFn?: typeof global.fetch; + private readonly onFailure?: RendezvousFailureListener; private etag?: string; private expiresAt?: Date; - private client?: MatrixClient; - private fallbackRzServer?: string; - private fetchFn?: typeof global.fetch; private _cancelled = false; private _ready = false; - public onFailure?: RendezvousFailureListener; public constructor({ onFailure, @@ -76,10 +76,20 @@ export class MSC4108RendezvousSession { this.url = url; } + /** + * 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; + } + private fetch(resource: URL | string, options?: RequestInit): ReturnType { if (this.fetchFn) { return this.fetchFn(resource, options); @@ -103,6 +113,10 @@ export class MSC4108RendezvousSession { return this.fallbackRzServer; } + /** + * Sends data via the rendezvous channel. + * @param data the payload to send + */ public async send(data: string): Promise { if (this._cancelled) { return; @@ -150,6 +164,10 @@ export class MSC4108RendezvousSession { } } + /** + * 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.url) { throw new Error("Rendezvous not set up"); @@ -195,6 +213,11 @@ export class MSC4108RendezvousSession { } } + /** + * 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 ( reason === ClientRendezvousFailureReason.Unknown && @@ -208,19 +231,20 @@ export class MSC4108RendezvousSession { this._ready = false; this.onFailure?.(reason); - if ( - this.url && - (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) - ) { - try { - await this.fetch(this.url, { method: Method.Delete }); - } catch (e) { - logger.warn(e); - } + if (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) { + await this.close(); } } - public get cancelled(): boolean { - return this._cancelled; + /** + * Closes the rendezvous channel. + */ + public async close(): Promise { + if (!this.url) return; + try { + await this.fetch(this.url, { method: Method.Delete }); + } catch (e) { + logger.warn(e); + } } } From 0792434a57c71cdc332ef2c151d53d4c96caabad Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 25 Apr 2024 16:28:33 +0100 Subject: [PATCH 68/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 44 ++++++++++--------- src/rendezvous/MSC4108SignInWithQR.ts | 28 ++++++++---- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index d419bc72099..8fb3d7c0f56 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -164,15 +164,15 @@ describe("MSC4108SignInWithQR", () => { it("should be able to connect with opponent and share homeserver url & check code", async () => { await Promise.all([ - expect(ourLogin.loginStep1()).resolves.toEqual({}), - expect(opponentLogin.loginStep1()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }), + expect(ourLogin.negotiateProtocols()).resolves.toEqual({}), + expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }), ]); expect(ourLogin.checkCode).toBe(opponentLogin.checkCode); }); it("should be able to connect with opponent and share verificationUri", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it const deviceId = "DEADB33F"; @@ -182,7 +182,9 @@ describe("MSC4108SignInWithQR", () => { mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); await Promise.all([ - expect(ourLogin.loginStep2And3()).resolves.toEqual({ verificationUri: verificationUriComplete }), + expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({ + verificationUri: verificationUriComplete, + }), // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, @@ -197,7 +199,7 @@ describe("MSC4108SignInWithQR", () => { }); it("should abort if device already exists", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it const deviceId = "DEADB33F"; @@ -206,7 +208,7 @@ describe("MSC4108SignInWithQR", () => { mocked(client.getDevice).mockResolvedValue({} as IMyDevice); await Promise.all([ - expect(ourLogin.loginStep2And3()).rejects.toThrow("Specified device ID already exists"), + expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"), // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, @@ -220,14 +222,16 @@ describe("MSC4108SignInWithQR", () => { }); it("should abort on unsupported protocol", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it const deviceId = "DEADB33F"; const verificationUri = "https://example.com/verify"; await Promise.all([ - expect(ourLogin.loginStep2And3()).rejects.toThrow("Received a request for an unsupported protocol"), + expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow( + "Received a request for an unsupported protocol", + ), // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, @@ -241,13 +245,13 @@ describe("MSC4108SignInWithQR", () => { }); it("should be able to connect with opponent and share secrets", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore ourLogin.expectingNewDeviceId = "DEADB33F"; - const ourProm = ourLogin.loginStep5(); + const ourProm = ourLogin.shareSecrets(); // Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here // @ts-ignore @@ -265,7 +269,7 @@ describe("MSC4108SignInWithQR", () => { }; await Promise.all([ expect(ourProm).resolves.toEqual(payload), - expect(opponentLogin.loginStep5()).resolves.toEqual(payload), + expect(opponentLogin.shareSecrets()).resolves.toEqual(payload), ]); }); @@ -278,7 +282,7 @@ describe("MSC4108SignInWithQR", () => { return 12345678 + mocked(setTimeout).mock.calls.length * 1000; }); - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore @@ -290,12 +294,12 @@ describe("MSC4108SignInWithQR", () => { }); mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); - const ourProm = ourLogin.loginStep5(); + const ourProm = ourLogin.shareSecrets(); await expect(ourProm).rejects.toThrow("New device not found"); }); it("should abort on unexpected errors", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore @@ -309,14 +313,14 @@ describe("MSC4108SignInWithQR", () => { new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500), ); - await expect(ourLogin.loginStep5()).rejects.toThrow("The message"); + await expect(ourLogin.shareSecrets()).rejects.toThrow("The message"); }); it("should abort on declined login", async () => { - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); await ourLogin.declineLoginOnExistingDevice(); - await expect(opponentLogin.loginStep5()).rejects.toThrow("Unexpected message received"); + await expect(opponentLogin.shareSecrets()).rejects.toThrow("Unexpected message received"); }); it("should not send secrets if user cancels", async () => { @@ -325,14 +329,14 @@ describe("MSC4108SignInWithQR", () => { return -1; }); - await Promise.all([ourLogin.loginStep1(), opponentLogin.loginStep1()]); + await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore ourLogin.expectingNewDeviceId = "DEADB33F"; - const ourProm = ourLogin.loginStep5(); - const opponentProm = opponentLogin.loginStep5(); + const ourProm = ourLogin.shareSecrets(); + const opponentProm = opponentLogin.shareSecrets(); // Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here // @ts-ignore diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 5bc3d10a357..2c38207a74a 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -109,7 +109,7 @@ export class MSC4108SignInWithQR { private readonly channel: MSC4108SecureChannel, private readonly didScanCode: boolean, private readonly client?: MatrixClient, - private readonly onFailure?: RendezvousFailureListener, + public onFailure?: RendezvousFailureListener, ) { this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login; } @@ -150,8 +150,14 @@ export class MSC4108SignInWithQR { return !this.isExistingDevice; } - public async loginStep1(): Promise<{ homeserverBaseUrl?: string }> { - logger.info(`loginStep1(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`); + /** + * 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<{ homeserverBaseUrl?: string }> { + logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`); await this.channel.connect(); if (this.didScanCode) { @@ -214,7 +220,12 @@ export class MSC4108SignInWithQR { return {}; } - public async loginStep2And3(): Promise<{ + /** + * 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(): Promise<{ verificationUri?: string; userCode?: string; }> { @@ -283,10 +294,11 @@ export class MSC4108SignInWithQR { } } - // Login step 4 is not implemented as it is only performed by the new device in the flow - // and the new device flow is not yet implemented. - - public async loginStep5(): Promise<{ secrets?: QRSecretsBundle }> { + /** + * 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?: QRSecretsBundle }> { if (this.isNewDevice) { await this.send({ type: PayloadType.Success, From 1901169c07f7d4fb97471ce9a6a20157b0710d8f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 29 Apr 2024 17:52:54 +0100 Subject: [PATCH 69/81] Apply suggestions from code review Co-authored-by: Hugh Nimmo-Smith --- src/rendezvous/channels/MSC4108SecureChannel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 883043b9ed8..4813cba44c3 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -211,7 +211,7 @@ export class MSC4108SecureChannel { } const stringifiedPayload = JSON.stringify(payload); - logger.info(`=> ${stringifiedPayload}`); + logger.debug(`=> {"type": ${JSON.stringify(payload.type)}, ...}`); await this.rendezvousSession.send(await this.encrypt(stringifiedPayload)); } @@ -231,7 +231,7 @@ export class MSC4108SecureChannel { const plaintext = await this.decrypt(ciphertext); const json = JSON.parse(plaintext); - logger.info(`<= ${JSON.stringify(json)}`); + logger.debug(`<= {"type": ${JSON.stringify(json.type)}, ...}`); return json as Partial | undefined; } From 2aac3959fd9c312c0ace4ebaa711f388b9e261c1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 May 2024 17:03:22 +0100 Subject: [PATCH 70/81] Bump @matrix-org/matrix-sdk-crypto-wasm to 90b63b84df65c19161f94049d83218cb4dfff97d Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index e6c036f37e4..e9cafec9178 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1756,7 +1756,7 @@ "@matrix-org/matrix-sdk-crypto-wasm@github:matrix-org/matrix-rust-sdk-crypto-wasm#poljar/qr-login": version "4.9.0" - resolved "https://codeload.github.com/matrix-org/matrix-rust-sdk-crypto-wasm/tar.gz/78c5fb5cc29979d0bd188a8b0ec163dd30aa7936" + resolved "https://codeload.github.com/matrix-org/matrix-rust-sdk-crypto-wasm/tar.gz/90b63b84df65c19161f94049d83218cb4dfff97d" "@matrix-org/olm@3.2.15": version "3.2.15" From c97ae08fbf03faa2371f4964b8b7b2d6030fb8fc Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 May 2024 17:26:38 +0100 Subject: [PATCH 71/81] prettier Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 986e2f7bc51..3537ab8a28f 100644 --- a/package.json +++ b/package.json @@ -134,4 +134,4 @@ "outputName": "jest-sonar-report.xml", "relativePaths": true } -} \ No newline at end of file +} From 030de055844128895d17561eb95ee0349f9b9995 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 May 2024 17:01:42 +0100 Subject: [PATCH 72/81] Locally expire channel if we are in wait-send state Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../transports/MSC4108RendezvousSession.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 3eff5ad76fe..0b36f3b4b0b 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -33,6 +33,7 @@ export class MSC4108RendezvousSession { private readonly onFailure?: RendezvousFailureListener; private etag?: string; private expiresAt?: Date; + private expiresTimer?: ReturnType; private _cancelled = false; private _ready = false; @@ -152,7 +153,15 @@ export class MSC4108RendezvousSession { if (method === Method.Post) { const expires = res.headers.get("expires"); if (expires) { + if (this.expiresTimer) { + clearTimeout(this.expiresTimer); + this.expiresTimer = undefined; + } this.expiresAt = new Date(expires); + this.expiresTimer = setTimeout(() => { + this.expiresTimer = undefined; + this.cancel(ClientRendezvousFailureReason.Expired); + }, this.expiresAt.getTime() - Date.now()); } // MSC4108: we expect a JSON response with a rendezvous URL const json = await res.json(); @@ -219,6 +228,12 @@ export class MSC4108RendezvousSession { * @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 && @@ -240,6 +255,11 @@ export class MSC4108RendezvousSession { * Closes the rendezvous channel. */ public async close(): Promise { + if (this.expiresTimer) { + clearTimeout(this.expiresTimer); + this.expiresTimer = undefined; + } + if (!this.url) return; try { await this.fetch(this.url, { method: Method.Delete }); From 41bd197403498d37dfb4a6397c0783debc5595ed Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 23 May 2024 13:20:37 +0100 Subject: [PATCH 73/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../channels/MSC4108SecureChannel.spec.ts | 24 ++++++++++++------- .../channels/MSC4108SecureChannel.ts | 21 +++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts index 698338718cd..6daae603491 100644 --- a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EstablishedSecureChannel, QrCodeData, QrCodeMode, SecureChannel } from "@matrix-org/matrix-sdk-crypto-wasm"; +import { EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm"; import { mocked } from "jest-mock"; import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous"; @@ -45,8 +45,11 @@ describe("MSC4108SecureChannel", () => { const channel = new MSC4108SecureChannel(mockSession); const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); - const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); - mocked(mockSession.receive).mockResolvedValue(opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE")); + const { initial_message: ciphertext } = new Ecies().establish_outbound_channel( + qrCodeData.public_key, + "MATRIX_QR_CODE_LOGIN_INITIATE", + ); + mocked(mockSession.receive).mockResolvedValue(ciphertext); await channel.connect(); await expect(channel.connect()).rejects.toThrow("Channel already connected"); }); @@ -63,8 +66,10 @@ describe("MSC4108SecureChannel", () => { await expect(channel.connect()).rejects.toThrow("No response from other device"); const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); - const opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); - const ciphertext = opponentChannel.encrypt("NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE"); + const { initial_message: ciphertext } = new Ecies().establish_outbound_channel( + qrCodeData.public_key, + "NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE", + ); mocked(mockSession.receive).mockResolvedValue(ciphertext); await expect(channel.connect()).rejects.toThrow("Invalid response from other device"); @@ -73,7 +78,7 @@ describe("MSC4108SecureChannel", () => { describe("should be able to connect as a reciprocating device", () => { let mockSession: MSC4108RendezvousSession; let channel: MSC4108SecureChannel; - let opponentChannel: EstablishedSecureChannel; + let opponentChannel: EstablishedEcies; beforeEach(async () => { mockSession = { @@ -84,8 +89,11 @@ describe("MSC4108SecureChannel", () => { channel = new MSC4108SecureChannel(mockSession); const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl)); - opponentChannel = new SecureChannel().create_outbound_channel(qrCodeData.public_key); - const ciphertext = opponentChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); + const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel( + qrCodeData.public_key, + "MATRIX_QR_CODE_LOGIN_INITIATE", + ); + opponentChannel = _opponentChannel; mocked(mockSession.receive).mockResolvedValue(ciphertext); await channel.connect(); diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index 4813cba44c3..a49e8e9e2cb 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -16,10 +16,10 @@ limitations under the License. import { Curve25519PublicKey, - EstablishedSecureChannel, + Ecies, + EstablishedEcies, QrCodeData, QrCodeMode, - SecureChannel, } from "@matrix-org/matrix-sdk-crypto-wasm"; import { @@ -36,8 +36,8 @@ import { logger } from "../../logger"; * Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle. */ export class MSC4108SecureChannel { - private readonly secureChannel: SecureChannel; - private establishedChannel?: EstablishedSecureChannel; + private readonly secureChannel: Ecies; + private establishedChannel?: EstablishedEcies; private connected = false; public constructor( @@ -45,7 +45,7 @@ export class MSC4108SecureChannel { private theirPublicKey?: Curve25519PublicKey, public onFailure?: RendezvousFailureListener, ) { - this.secureChannel = new SecureChannel(); + this.secureChannel = new Ecies(); } /** @@ -93,7 +93,11 @@ export class MSC4108SecureChannel { if (this.theirPublicKey) { // We are the scanning device - this.establishedChannel = this.secureChannel.create_outbound_channel(this.theirPublicKey); + const result = this.secureChannel.establish_outbound_channel( + this.theirPublicKey, + "MATRIX_QR_CODE_LOGIN_INITIATE", + ); + this.establishedChannel = result.channel; /* Secure Channel step 4. Device S sends the initial message @@ -107,8 +111,7 @@ export class MSC4108SecureChannel { */ { logger.info("Sending LoginInitiateMessage"); - const loginInitiateMessage = this.establishedChannel.encrypt("MATRIX_QR_CODE_LOGIN_INITIATE"); - await this.rendezvousSession.send(loginInitiateMessage); + await this.rendezvousSession.send(result.initial_message); } /* @@ -162,7 +165,7 @@ export class MSC4108SecureChannel { } const { channel, message: candidateLoginInitiateMessage } = - this.secureChannel.create_inbound_channel(loginInitiateMessage); + this.secureChannel.establish_inbound_channel(loginInitiateMessage); this.establishedChannel = channel; if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") { From 583675abdcf20fe7fdb093e7628088f1cc19ff18 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 29 May 2024 16:01:19 +0100 Subject: [PATCH 74/81] DRY Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 8fb3d7c0f56..3a46f78059b 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -88,6 +88,9 @@ describe("MSC4108SignInWithQR", () => { }); const url = "https://fallbackserver/rz/123"; + const deviceId = "DEADB33F"; + const verificationUri = "https://example.com/verify"; + const verificationUriComplete = "https://example.com/verify/complete"; it("should generate qr code data as expected", async () => { const session = new MSC4108RendezvousSession({ @@ -174,17 +177,13 @@ describe("MSC4108SignInWithQR", () => { it("should be able to connect with opponent and share verificationUri", async () => { await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); - // We don't have the new device side of this flow implemented at this time so mock it - const deviceId = "DEADB33F"; - const verificationUri = "https://example.com/verify"; - const verificationUriComplete = "https://example.com/verify/complete"; - mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404)); await Promise.all([ expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({ verificationUri: verificationUriComplete, }), + // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, @@ -201,14 +200,11 @@ describe("MSC4108SignInWithQR", () => { it("should abort if device already exists", async () => { await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); - // We don't have the new device side of this flow implemented at this time so mock it - const deviceId = "DEADB33F"; - const verificationUri = "https://example.com/verify"; - mocked(client.getDevice).mockResolvedValue({} as IMyDevice); await Promise.all([ expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"), + // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, @@ -224,14 +220,11 @@ describe("MSC4108SignInWithQR", () => { it("should abort on unsupported protocol", async () => { await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); - // We don't have the new device side of this flow implemented at this time so mock it - const deviceId = "DEADB33F"; - const verificationUri = "https://example.com/verify"; - await Promise.all([ expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow( "Received a request for an unsupported protocol", ), + // We don't have the new device side of this flow implemented at this time so mock it // @ts-ignore opponentLogin.send({ type: PayloadType.Protocol, From dbc9cd4649b525150c3bdbf0f3489eec189be724 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 29 May 2024 16:12:16 +0100 Subject: [PATCH 75/81] Add comment Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 5 +++++ src/rendezvous/transports/MSC4108RendezvousSession.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 2c38207a74a..50261580ea2 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -87,6 +87,11 @@ interface SecretsPayload extends MSC4108Payload, QRSecretsBundle { 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 MSC4108SignInWithQR { private readonly ourIntent: QrCodeMode; private _code?: Uint8Array; diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts index 0b36f3b4b0b..8b18461ed89 100644 --- a/src/rendezvous/transports/MSC4108RendezvousSession.ts +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -21,7 +21,7 @@ import { MatrixClient, Method } from "../../matrix"; import { ClientPrefix } from "../../http-api"; /** - * Prototype of the unstable [4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * Prototype of the unstable [MSC4108](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. */ From 6f253e69437ded26516bfb2be34d610e49d45d7d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 29 May 2024 16:19:08 +0100 Subject: [PATCH 76/81] Add comments Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 10 ++++++++++ src/rendezvous/channels/MSC4108SecureChannel.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index 50261580ea2..f8c636f03e4 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -25,6 +25,11 @@ import { MatrixError } from "../http-api"; import { sleep } from "../utils"; import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc"; +/** + * 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", @@ -35,6 +40,11 @@ export enum PayloadType { 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 MSC4108Payload { type: PayloadType; } diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts index a49e8e9e2cb..8db12ebd2cd 100644 --- a/src/rendezvous/channels/MSC4108SecureChannel.ts +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -33,6 +33,9 @@ import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession import { logger } from "../../logger"; /** + * Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) + * 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 MSC4108SecureChannel { From e4a62098afb875c44d66d38ba129ed2beb356855 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 30 May 2024 14:30:32 +0100 Subject: [PATCH 77/81] Correctly handle m.login.declined after MSC clarification Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/rendezvous/MSC4108SignInWithQR.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index f8c636f03e4..dd18a273659 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -347,11 +347,14 @@ export class MSC4108SignInWithQR { }); logger.info("Waiting for outcome message"); - const payload = await this.receive(); + const payload = await this.receive(); if (payload?.type === PayloadType.Failure) { - const { reason } = payload; - throw new RendezvousError("Failed", reason); + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type === PayloadType.Declined) { + throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined); } if (payload?.type !== PayloadType.Success) { @@ -415,8 +418,9 @@ export class MSC4108SignInWithQR { if (!this.isExistingDevice) { throw new Error("Can only decline login on existing device"); } - await this.send({ - type: PayloadType.Declined, + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UserCancelled, }); } From 1c9cce807231f5b012d057a6096813a6b6fe09e0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 30 May 2024 14:42:59 +0100 Subject: [PATCH 78/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts | 5 ++++- src/rendezvous/MSC4108SignInWithQR.ts | 9 +++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 3a46f78059b..62335301d90 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -24,6 +24,7 @@ import { MSC4108SecureChannel, MSC4108SignInWithQR, PayloadType, + RendezvousError, } from "../../../src/rendezvous"; import { defer } from "../../../src/utils"; import { @@ -313,7 +314,9 @@ describe("MSC4108SignInWithQR", () => { await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]); await ourLogin.declineLoginOnExistingDevice(); - await expect(opponentLogin.shareSecrets()).rejects.toThrow("Unexpected message received"); + await expect(opponentLogin.shareSecrets()).rejects.toThrow( + new RendezvousError("Failed", MSC4108FailureReason.UserCancelled), + ); }); it("should not send secrets if user cancels", async () => { diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index dd18a273659..f0577844028 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -213,8 +213,7 @@ export class MSC4108SignInWithQR { const payload = await this.receive(); if (payload?.type === PayloadType.Failure) { - const { reason } = payload; - throw new RendezvousError("Failed", reason); + throw new RendezvousError("Failed", payload.reason); } if (payload?.type !== PayloadType.Protocols) { @@ -254,8 +253,7 @@ export class MSC4108SignInWithQR { const payload = await this.receive(); if (payload?.type === PayloadType.Failure) { - const { reason } = payload; - throw new RendezvousError("Failed", reason); + throw new RendezvousError("Failed", payload.reason); } if (payload?.type !== PayloadType.Protocol) { @@ -322,8 +320,7 @@ export class MSC4108SignInWithQR { logger.info("Waiting for secrets message"); const payload = await this.receive(); if (payload?.type === PayloadType.Failure) { - const { reason } = payload; - throw new RendezvousError("Failed", reason); + throw new RendezvousError("Failed", payload.reason); } if (payload?.type !== PayloadType.Secrets) { From bdb24b419b7a23aa9f8a0942899f1572419ae98e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 5 Jun 2024 11:56:22 +0100 Subject: [PATCH 79/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 4 ++-- spec/unit/crypto/secrets.spec.ts | 19 ------------------- src/crypto-api.ts | 19 ------------------- src/crypto/index.ts | 13 ------------- src/rendezvous/MSC4108SignInWithQR.ts | 2 +- src/rust-crypto/rust-crypto.ts | 17 ----------------- 6 files changed, 3 insertions(+), 71 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index 62335301d90..f0fc0c25dc2 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -256,7 +256,7 @@ describe("MSC4108SignInWithQR", () => { const secrets = { cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, }; - mocked(client.getCrypto()!.exportSecretsForQrLogin).mockResolvedValue(secrets); + mocked(client.getCrypto()!.exportSecretsBundle!).mockResolvedValue(secrets); const payload = { secrets: expect.objectContaining(secrets), @@ -347,7 +347,7 @@ describe("MSC4108SignInWithQR", () => { const secrets = { cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, }; - mocked(client.getCrypto()!.exportSecretsForQrLogin).mockResolvedValue(secrets); + mocked(client.getCrypto()!.exportSecretsBundle!).mockResolvedValue(secrets); await Promise.all([ expect(ourProm).rejects.toThrow("User cancelled"), diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 9079dd42e57..835c593d8e5 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -687,23 +687,4 @@ describe("Secrets", function () { alice.stopClient(); }); }); - - it("should return false for supportsSecretsForQrLogin", async () => { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - expect(alice.getCrypto()?.supportsSecretsForQrLogin()).toBe(false); - }); - - it("should throw Not Implemented for importSecretsForQRLogin", async () => { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - await expect( - alice.getCrypto()?.importSecretsForQrLogin({ - cross_signing: { master_key: "", self_signing_key: "", user_signing_key: "" }, - }), - ).rejects.toThrow("Method not implemented."); - }); - - it("should throw Not Implemented for exportSecretsForQRLogin", async () => { - const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" }); - await expect(alice.getCrypto()?.exportSecretsForQrLogin()).rejects.toThrow("Method not implemented."); - }); }); diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 06adde82c3f..7f421040a8f 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -33,25 +33,6 @@ export type QRSecretsBundle = Awaited>; * @remarks Currently, this is a work-in-progress. In time, more methods will be added here. */ export interface CryptoApi { - /** - * Boolean check to indicate whether `exportSecretsForQrLogin` and `importSecretsForQrLogin` are supported. - * @experimental - part of MSC4108 - */ - supportsSecretsForQrLogin(): boolean; - - /** - * Export secrets bundle for transmitting to another device as part of OIDC QR login - * @experimental - part of MSC4108 - */ - exportSecretsForQrLogin(): Promise; - - /** - * Import secrets bundle transmitted from another device as part of OIDC QR login - * @param secrets the secrets bundle received from the other device - * @experimental - part of MSC4108 - */ - importSecretsForQrLogin(secrets: QRSecretsBundle): Promise; - /** * Global override for whether the client should ever send encrypted * messages to unverified devices. This provides the default for rooms which diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 2a7d7411959..4379c2e1272 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -94,7 +94,6 @@ import { KeyBackupInfo, OwnDeviceKeys, VerificationRequest as CryptoApiVerificationRequest, - QRSecretsBundle, } from "../crypto-api"; import { Device, DeviceMap } from "../models/device"; import { deviceInfoToDevice } from "./device-converter"; @@ -577,18 +576,6 @@ export class Crypto extends TypedEventEmitter { - throw new Error("Method not implemented."); - } - - public async importSecretsForQrLogin(secrets: QRSecretsBundle): Promise { - throw new Error("Method not implemented."); - } - /** * Initialise the crypto module so that it is ready for use * diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index f0577844028..c62edb82b1e 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -370,7 +370,7 @@ export class MSC4108SignInWithQR { if (device) { // if so, return the secrets - const secretsBundle = await this.client!.getCrypto()!.exportSecretsForQrLogin(); + const secretsBundle = await this.client!.getCrypto()!.exportSecretsBundle!(); if (this.channel.cancelled) { throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled); } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 161f68394c4..c1b8f051953 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -51,7 +51,6 @@ import { KeyBackupCheck, KeyBackupInfo, OwnDeviceKeys, - QRSecretsBundle, UserVerificationStatus, VerificationRequest, } from "../crypto-api"; @@ -179,22 +178,6 @@ export class RustCrypto extends TypedEventEmitter { - const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle(); - const secrets = secretsBundle.to_json(); - secretsBundle.free(); - return secrets; - } - - public async importSecretsForQrLogin(secrets: QRSecretsBundle): Promise { - const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets); - await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle - } - /** * Return the OlmMachine only if {@link RustCrypto#stop} has not been called. * From 039f89705e3b59b7e4a0bf62c307fbe5caf94957 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 5 Jun 2024 11:59:35 +0100 Subject: [PATCH 80/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/crypto-api.ts | 2 -- src/rendezvous/MSC4108SignInWithQR.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 7f421040a8f..740d60dc8bc 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -25,8 +25,6 @@ import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/key import { ISignatures } from "./@types/signed"; import { MatrixEvent } from "./models/event"; -export type QRSecretsBundle = Awaited>; - /** * Public interface to the cryptography parts of the js-sdk * diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts index c62edb82b1e..275d44bd8a4 100644 --- a/src/rendezvous/MSC4108SignInWithQR.ts +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -20,10 +20,10 @@ import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, R import { MatrixClient } from "../client"; import { logger } from "../logger"; import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel"; -import { QRSecretsBundle } from "../crypto-api"; import { MatrixError } from "../http-api"; import { sleep } from "../utils"; import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc"; +import { CryptoApi } from "../crypto-api"; /** * Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108) @@ -93,7 +93,7 @@ interface AcceptedPayload extends MSC4108Payload { type: PayloadType.ProtocolAccepted; } -interface SecretsPayload extends MSC4108Payload, QRSecretsBundle { +interface SecretsPayload extends MSC4108Payload, Awaited>> { type: PayloadType.Secrets; } @@ -311,7 +311,7 @@ export class MSC4108SignInWithQR { * 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?: QRSecretsBundle }> { + public async shareSecrets(): Promise<{ secrets?: Omit }> { if (this.isNewDevice) { await this.send({ type: PayloadType.Success, From 8332cd0d0188cf8e23f50987c243a1f80d2f8ed0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 5 Jun 2024 12:00:40 +0100 Subject: [PATCH 81/81] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts index f0fc0c25dc2..ef9ed201615 100644 --- a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -256,7 +256,7 @@ describe("MSC4108SignInWithQR", () => { const secrets = { cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, }; - mocked(client.getCrypto()!.exportSecretsBundle!).mockResolvedValue(secrets); + client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets); const payload = { secrets: expect.objectContaining(secrets), @@ -347,7 +347,7 @@ describe("MSC4108SignInWithQR", () => { const secrets = { cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" }, }; - mocked(client.getCrypto()!.exportSecretsBundle!).mockResolvedValue(secrets); + client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets); await Promise.all([ expect(ourProm).rejects.toThrow("User cancelled"),