From 363908390aa263cf1f7f3a91a3e5a6c4ce3f2d12 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Mar 2023 16:58:08 +0000 Subject: [PATCH 1/9] Support for MSC3906 v2 --- spec/unit/rendezvous/rendezvousv1.spec.ts | 652 ++++++++++++++++++ ...endezvous.spec.ts => rendezvousv2.spec.ts} | 68 +- src/rendezvous/MSC3906Rendezvous.ts | 139 +++- src/rendezvous/RendezvousChannel.ts | 11 +- src/rendezvous/RendezvousCode.ts | 6 +- src/rendezvous/RendezvousFlow.ts | 26 + .../MSC3903ECDHv2RendezvousChannel.ts | 4 +- src/rendezvous/index.ts | 1 + 8 files changed, 851 insertions(+), 56 deletions(-) create mode 100644 spec/unit/rendezvous/rendezvousv1.spec.ts rename spec/unit/rendezvous/{rendezvous.spec.ts => rendezvousv2.spec.ts} (89%) create mode 100644 src/rendezvous/RendezvousFlow.ts diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts new file mode 100644 index 00000000000..8026be52097 --- /dev/null +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -0,0 +1,652 @@ +/* +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 MockHttpBackend from "matrix-mock-request"; + +import "../../olm-loader"; +import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + ECDHv2RendezvousCode as ECDHRendezvousCode, + MSC3903ECDHPayload, + MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel, +} from "../../../src/rendezvous/channels"; +import { MatrixClient } from "../../../src"; +import { + MSC3886SimpleHttpRendezvousTransport, + MSC3886SimpleHttpRendezvousTransportDetails, +} from "../../../src/rendezvous/transports"; +import { DummyTransport } from "./DummyTransport"; +import { decodeBase64 } from "../../../src/crypto/olmlib"; +import { logger } from "../../../src/logger"; +import { DeviceInfo } from "../../../src/crypto/deviceinfo"; + +function makeMockClient(opts: { + userId: string; + deviceId: string; + deviceKey?: string; + msc3882Enabled: boolean; + msc3886Enabled: boolean; + devices?: Record>; + verificationFunction?: ( + userId: string, + deviceId: string, + verified: boolean, + blocked: boolean, + known: boolean, + ) => void; + crossSigningIds?: Record; +}): MatrixClient { + return { + getVersions() { + return { + unstable_features: { + "org.matrix.msc3882": opts.msc3882Enabled, + "org.matrix.msc3886": opts.msc3886Enabled, + }, + }; + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + getDeviceEd25519Key() { + return opts.deviceKey; + }, + baseUrl: "https://example.com", + crypto: { + getStoredDevice(userId: string, deviceId: string) { + return opts.devices?.[deviceId] ?? null; + }, + setDeviceVerification: opts.verificationFunction, + crossSigningInfo: { + getId(key: string) { + return opts.crossSigningIds?.[key]; + }, + }, + }, + } as unknown as MatrixClient; +} + +function makeTransport(name: string, uri = "https://test.rz/123456") { + return new DummyTransport(name, { type: "http.v1", uri }); +} + +describe("RendezvousV1", function () { + beforeAll(async function () { + await global.Olm.init(); + }); + + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetch; + let transports: DummyTransport[]; + + beforeEach(function () { + httpBackend = new MockHttpBackend(); + fetchFn = httpBackend.fetchFn as typeof global.fetch; + transports = []; + }); + + afterEach(function () { + transports.forEach((x) => x.cleanup()); + }); + + it("generate and cancel", async function () { + const alice = makeMockClient({ + userId: "@alice:example.com", + deviceId: "DEVICEID", + msc3886Enabled: false, + msc3882Enabled: true, + }); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + const aliceTransport = new MSC3886SimpleHttpRendezvousTransport({ + client: alice, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + + expect(aliceRz.code).toBeUndefined(); + + const codePromise = aliceRz.generateCode(); + await httpBackend.flush(""); + + await aliceRz.generateCode(); + + expect(typeof aliceRz.code).toBe("string"); + + await codePromise; + + const code = JSON.parse(aliceRz.code!) as RendezvousCode; + + expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toBeUndefined(); + expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); + expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); + expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( + "https://fallbackserver/rz/123", + ); + + httpBackend.when("DELETE", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 204, + headers: {}, + }, + }; + + const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined); + await httpBackend.flush(""); + expect(cancelPromise).resolves.toBeUndefined(); + httpBackend.verifyNoOutstandingExpectation(); + httpBackend.verifyNoOutstandingRequests(); + + await aliceRz.close(); + }); + + it("no protocols", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: false, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.finish", + outcome: "unsupported", + }); + })(); + + await aliceStartProm; + await bobStartPromise; + }); + + it("new device declines protocol with outcome unsupported", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("new device requests an invalid protocol", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("decline on existing device", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + await aliceRz.declineLoginOnExistingDevice(); + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + }); + + it("approve on existing device + no verification", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + const bobCompleteProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + })(); + + await confirmProm; + await bobCompleteProm; + }); + + async function completeLogin(devices: Record>, startInV1FallbackMode: boolean) { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const aliceVerification = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + devices, + deviceKey: "aaaa", + verificationFunction: aliceVerification, + crossSigningIds: { + master: "mmmmm", + }, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, startInV1FallbackMode); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + if (startInV1FallbackMode) { + // expect v1 + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + } else { + // expect v2 + expect(protocols).toEqual({ + type: "m.login.protocols", + protocols: ["org.matrix.msc3906.login_token"], + }); + } + + // send the v1 response always + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + // the rest is v1 payloads: + const bobLoginProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + })(); + + expect(await confirmProm).toEqual("BOB"); + await bobLoginProm; + + return { + aliceTransport, + aliceEcdh, + aliceRz, + bobTransport, + bobEcdh, + }; + } + + it("approve on existing device + verification", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + true, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("approve on existing device + verification with v1 fallback", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + false, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("device not online within timeout", async function () { + const { aliceRz } = await completeLogin({}, true); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("device appears online within timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1000); + await aliceRz.verifyNewDeviceOnExistingDevice(2000); + }); + + it("device appears online after timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1500); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("mismatched device key", async function () { + const { aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "XXXX", + }, + }, + true, + ); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/); + }); +}); diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts similarity index 89% rename from spec/unit/rendezvous/rendezvous.spec.ts rename to spec/unit/rendezvous/rendezvousv2.spec.ts index 59a3ac71613..1c8ef509329 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. @@ -17,7 +17,13 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import "../../olm-loader"; -import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + MSC3906Rendezvous, + RendezvousCode, + RendezvousFailureReason, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "../../../src/rendezvous"; import { ECDHv2RendezvousCode as ECDHRendezvousCode, MSC3903ECDHPayload, @@ -86,7 +92,7 @@ function makeTransport(name: string, uri = "https://test.rz/123456") { return new DummyTransport(name, { type: "http.v1", uri }); } -describe("Rendezvous", function () { +describe("RendezvousV2", function () { beforeAll(async function () { await global.Olm.init(); }); @@ -143,6 +149,7 @@ describe("Rendezvous", function () { const code = JSON.parse(aliceRz.code!) as RendezvousCode; expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toEqual(SETUP_ADDITIONAL_DEVICE_FLOW_V2.name); expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( @@ -201,8 +208,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -211,8 +217,8 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.finish", - outcome: "unsupported", + type: "m.login.failure", + reason: "unsupported", }); })(); @@ -220,7 +226,7 @@ describe("Rendezvous", function () { await bobStartPromise; }); - it("new device declines protocol with outcome unsupported", async function () { + it("new device declines protocol with reason unsupported", async function () { const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); @@ -256,7 +262,6 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols logger.info("Bob waiting for protocols"); @@ -265,11 +270,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + await bobEcdh.send({ type: "m.login.failure", reason: "unsupported" }); })(); await aliceStartProm; @@ -313,8 +318,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -323,11 +327,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "bad protocol" }); })(); await aliceStartProm; @@ -371,8 +375,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -381,11 +384,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -393,7 +396,7 @@ describe("Rendezvous", function () { await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + expect(loginToken).toEqual({ type: "m.login.declined" }); }); it("approve on existing device + no verification", async function () { @@ -431,8 +434,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -441,11 +443,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -455,8 +457,8 @@ describe("Rendezvous", function () { const bobCompleteProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success" }); })(); await confirmProm; @@ -505,8 +507,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -515,11 +516,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -529,8 +530,8 @@ describe("Rendezvous", function () { const bobLoginProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success", device_id: "BOB", device_key: "bbbb" }); })(); expect(await confirmProm).toEqual("BOB"); @@ -556,8 +557,7 @@ describe("Rendezvous", function () { const bobVerifyProm = (async () => { const verified = await bobEcdh.receive(); expect(verified).toEqual({ - type: "m.login.finish", - outcome: "verified", + type: "m.login.verified", verifying_device_id: "ALICE", verifying_device_key: "aaaa", master_key: "mmmmm", diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index f431c8358d0..4fe6fb888d7 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -16,7 +16,14 @@ limitations under the License. import { UnstableValue } from "matrix-events-sdk"; -import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { + RendezvousChannel, + RendezvousFailureListener, + RendezvousFailureReason, + RendezvousFlow, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "."; import { MatrixClient } from "../client"; import { CrossSigningInfo } from "../crypto/CrossSigning"; import { DeviceInfo } from "../crypto/deviceinfo"; @@ -25,11 +32,23 @@ import { logger } from "../logger"; import { sleep } from "../utils"; enum PayloadType { - Start = "m.login.start", + /** + * @deprecated Only used in MSC3906 v1 + */ Finish = "m.login.finish", Progress = "m.login.progress", + Protocol = "m.login.protocol", + Protocols = "m.login.protocols", + Approved = "m.login.approved", + Success = "m.login.success", + Verified = "m.login.verified", + Failure = "m.login.failure", + Declined = "m.login.declined", } +/** + * @deprecated Only used in MSC3906 v1 + */ enum Outcome { Success = "success", Failure = "failure", @@ -38,10 +57,21 @@ enum Outcome { Unsupported = "unsupported", } +enum FailureReason { + Cancelled = "cancelled", + Unsupported = "unsupported", + E2EESecurityError = "e2ee_security_error", + IncompatibleIntent = "incompatible_intent", +} + export interface MSC3906RendezvousPayload { type: PayloadType; intent?: RendezvousIntent; + /** + * @deprecated Only used in MSC3906 v1. Instead the type field should be used in future + */ outcome?: Outcome; + reason?: FailureReason; device_id?: string; device_key?: string; verifying_device_id?: string; @@ -64,18 +94,24 @@ export class MSC3906Rendezvous { private newDeviceId?: string; private newDeviceKey?: string; private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V2.name; + private v1FallbackEnabled: boolean; private _code?: string; /** * @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 + * @param startInV1FallbackMode - Whether to start in v1 fallback mode */ public constructor( private channel: RendezvousChannel, private client: MatrixClient, public onFailure?: RendezvousFailureListener, - ) {} + startInV1FallbackMode = false, + ) { + this.v1FallbackEnabled = startInV1FallbackMode ?? false; + } /** * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. @@ -92,7 +128,10 @@ export class MSC3906Rendezvous { return; } - this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + const raw = this.v1FallbackEnabled + ? await this.channel.generateCode(this.ourIntent) + : await this.channel.generateCode(this.ourIntent, this.flow); + this._code = JSON.stringify(raw); } public async startAfterShowingCode(): Promise { @@ -104,33 +143,85 @@ export class MSC3906Rendezvous { // determine available protocols if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) { logger.info("Server doesn't support MSC3882"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Unsupported } + : { type: PayloadType.Failure, reason: FailureReason.Unsupported }, + ); await this.cancel(RendezvousFailureReason.HomeserverLacksSupport); return undefined; } - await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] }); + await this.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Protocols, + protocols: [LOGIN_TOKEN_PROTOCOL.name], + }); logger.info("Waiting for other device to chose protocol"); - const { type, protocol, outcome } = await this.receive(); + const { type, protocol, outcome, reason, intent } = await this.receive(); + // even if we didn't start in v1 fallback we might detect that the other device is v1 + if (type === PayloadType.Finish || type === PayloadType.Progress) { + // this is a PDU from a v1 flow so use fallback mode + this.v1FallbackEnabled = true; + } + + // fallback for v1 flow if (type === PayloadType.Finish) { + this.v1FallbackEnabled = true; // new device decided not to complete - switch (outcome ?? "") { - case "unsupported": - await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + let reason: RendezvousFailureReason; + if (intent) { + reason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + } else if (outcome === Outcome.Unsupported) { + reason = RendezvousFailureReason.UnsupportedAlgorithm; + } else { + reason = RendezvousFailureReason.Unknown; + } + await this.cancel(reason); + return undefined; + } + + // v2 flow + if (type === PayloadType.Failure) { + // new device decided not to complete + let failureReason: RendezvousFailureReason; + switch (reason ?? "") { + case FailureReason.Cancelled: + failureReason = RendezvousFailureReason.UserCancelled; + break; + case FailureReason.IncompatibleIntent: + failureReason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + break; + case FailureReason.Unsupported: + failureReason = RendezvousFailureReason.UnsupportedAlgorithm; break; default: - await this.cancel(RendezvousFailureReason.Unknown); + failureReason = RendezvousFailureReason.Unknown; } + await this.cancel(failureReason); + return undefined; + } + + // v1 unexpected payload + if (this.v1FallbackEnabled && type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); return undefined; } - if (type !== PayloadType.Progress) { + // v2 unexpected payload + if (!this.v1FallbackEnabled && type !== PayloadType.Protocol) { await this.cancel(RendezvousFailureReason.Unknown); return undefined; } + // invalid protocol if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); return undefined; @@ -149,21 +240,31 @@ export class MSC3906Rendezvous { public async declineLoginOnExistingDevice(): Promise { logger.info("User declined sign in"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Declined } + : { type: PayloadType.Declined }, + ); } public async approveLoginOnExistingDevice(loginToken: string): Promise { - // eslint-disable-next-line camelcase - await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl }); + await this.channel.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Approved, + login_token: loginToken, + homeserver: this.client.baseUrl, + }); logger.info("Waiting for outcome"); const res = await this.receive(); if (!res) { return undefined; } - const { outcome, device_id: deviceId, device_key: deviceKey } = res; + const { type, outcome, device_id: deviceId, device_key: deviceKey } = res; - if (outcome !== "success") { + if ( + (this.v1FallbackEnabled && outcome !== "success") || + (!this.v1FallbackEnabled && type !== PayloadType.Success) + ) { throw new Error("Linking failed"); } @@ -201,8 +302,8 @@ export class MSC3906Rendezvous { const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!; await this.send({ - type: PayloadType.Finish, - outcome: Outcome.Verified, + type: this.v1FallbackEnabled ? PayloadType.Finish : PayloadType.Verified, + outcome: this.v1FallbackEnabled ? Outcome.Verified : undefined, verifying_device_id: this.client.getDeviceId()!, verifying_device_key: this.client.getDeviceEd25519Key()!, master_key: masterPublicKey, diff --git a/src/rendezvous/RendezvousChannel.ts b/src/rendezvous/RendezvousChannel.ts index 549ebc83f51..5dbaa58c214 100644 --- a/src/rendezvous/RendezvousChannel.ts +++ b/src/rendezvous/RendezvousChannel.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from "."; +import { RendezvousCode, RendezvousIntent, RendezvousFailureReason, RendezvousFlow } from "."; export interface RendezvousChannel { /** @@ -40,9 +40,18 @@ export interface RendezvousChannel { close(): Promise; /** + * Always uses the MSC3906 v1 flow. + * * @returns a representation of the channel that can be encoded in a QR or similar + * + * @deprecated use generateCode instead */ generateCode(intent: RendezvousIntent): Promise; + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + generateCode(intent: RendezvousIntent, flow: RendezvousFlow): Promise; + cancel(reason: RendezvousFailureReason): Promise; } diff --git a/src/rendezvous/RendezvousCode.ts b/src/rendezvous/RendezvousCode.ts index 86608aa1c44..7c0871ee605 100644 --- a/src/rendezvous/RendezvousCode.ts +++ b/src/rendezvous/RendezvousCode.ts @@ -14,10 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousTransportDetails, RendezvousIntent } from "."; +import { RendezvousTransportDetails, RendezvousIntent, RendezvousFlow } from "."; export interface RendezvousCode { intent: RendezvousIntent; + /** + * In MSC3906 v1 there wasn't a flow, hence why it's optional for now. + */ + flow?: RendezvousFlow; rendezvous?: { transport: RendezvousTransportDetails; algorithm: string; diff --git a/src/rendezvous/RendezvousFlow.ts b/src/rendezvous/RendezvousFlow.ts new file mode 100644 index 00000000000..4431a47ddfe --- /dev/null +++ b/src/rendezvous/RendezvousFlow.ts @@ -0,0 +1,26 @@ +/* +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 { UnstableValue } from "../NamespacedValue"; + +export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue( + "m.setup.additional_device.v2", + "org.matrix.msc3906.setup.additional_device.v2", +); + +export type RendezvousFlow = + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName; diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index be60ee5c9aa..f968201e9b0 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -24,6 +24,7 @@ import { RendezvousTransportDetails, RendezvousTransport, RendezvousFailureReason, + RendezvousFlow, } from ".."; import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib"; import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; @@ -85,7 +86,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); } - public async generateCode(intent: RendezvousIntent): Promise { + public async generateCode(intent: RendezvousIntent, flow?: RendezvousFlow): Promise { if (this.transport.ready) { throw new Error("Code already generated"); } @@ -98,6 +99,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { key: encodeUnpaddedBase64(this.ourPublicKey), transport: await this.transport.details(), }, + flow, intent, }; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 379b13351b8..fbeef27b81f 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -21,3 +21,4 @@ export * from "./RendezvousError"; export * from "./RendezvousFailureReason"; export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; +export * from "./RendezvousFlow"; From b1d778866942ad620084c5cb23adc59bec88c334 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Mar 2023 16:58:08 +0000 Subject: [PATCH 2/9] Support for MSC3906 v2 --- spec/unit/rendezvous/rendezvousv1.spec.ts | 652 ++++++++++++++++++ ...endezvous.spec.ts => rendezvousv2.spec.ts} | 68 +- src/rendezvous/MSC3906Rendezvous.ts | 139 +++- src/rendezvous/RendezvousChannel.ts | 11 +- src/rendezvous/RendezvousCode.ts | 6 +- src/rendezvous/RendezvousFlow.ts | 26 + .../MSC3903ECDHv2RendezvousChannel.ts | 4 +- src/rendezvous/index.ts | 1 + 8 files changed, 851 insertions(+), 56 deletions(-) create mode 100644 spec/unit/rendezvous/rendezvousv1.spec.ts rename spec/unit/rendezvous/{rendezvous.spec.ts => rendezvousv2.spec.ts} (89%) create mode 100644 src/rendezvous/RendezvousFlow.ts diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts new file mode 100644 index 00000000000..8026be52097 --- /dev/null +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -0,0 +1,652 @@ +/* +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 MockHttpBackend from "matrix-mock-request"; + +import "../../olm-loader"; +import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + ECDHv2RendezvousCode as ECDHRendezvousCode, + MSC3903ECDHPayload, + MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel, +} from "../../../src/rendezvous/channels"; +import { MatrixClient } from "../../../src"; +import { + MSC3886SimpleHttpRendezvousTransport, + MSC3886SimpleHttpRendezvousTransportDetails, +} from "../../../src/rendezvous/transports"; +import { DummyTransport } from "./DummyTransport"; +import { decodeBase64 } from "../../../src/crypto/olmlib"; +import { logger } from "../../../src/logger"; +import { DeviceInfo } from "../../../src/crypto/deviceinfo"; + +function makeMockClient(opts: { + userId: string; + deviceId: string; + deviceKey?: string; + msc3882Enabled: boolean; + msc3886Enabled: boolean; + devices?: Record>; + verificationFunction?: ( + userId: string, + deviceId: string, + verified: boolean, + blocked: boolean, + known: boolean, + ) => void; + crossSigningIds?: Record; +}): MatrixClient { + return { + getVersions() { + return { + unstable_features: { + "org.matrix.msc3882": opts.msc3882Enabled, + "org.matrix.msc3886": opts.msc3886Enabled, + }, + }; + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + getDeviceEd25519Key() { + return opts.deviceKey; + }, + baseUrl: "https://example.com", + crypto: { + getStoredDevice(userId: string, deviceId: string) { + return opts.devices?.[deviceId] ?? null; + }, + setDeviceVerification: opts.verificationFunction, + crossSigningInfo: { + getId(key: string) { + return opts.crossSigningIds?.[key]; + }, + }, + }, + } as unknown as MatrixClient; +} + +function makeTransport(name: string, uri = "https://test.rz/123456") { + return new DummyTransport(name, { type: "http.v1", uri }); +} + +describe("RendezvousV1", function () { + beforeAll(async function () { + await global.Olm.init(); + }); + + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetch; + let transports: DummyTransport[]; + + beforeEach(function () { + httpBackend = new MockHttpBackend(); + fetchFn = httpBackend.fetchFn as typeof global.fetch; + transports = []; + }); + + afterEach(function () { + transports.forEach((x) => x.cleanup()); + }); + + it("generate and cancel", async function () { + const alice = makeMockClient({ + userId: "@alice:example.com", + deviceId: "DEVICEID", + msc3886Enabled: false, + msc3882Enabled: true, + }); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + const aliceTransport = new MSC3886SimpleHttpRendezvousTransport({ + client: alice, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + + expect(aliceRz.code).toBeUndefined(); + + const codePromise = aliceRz.generateCode(); + await httpBackend.flush(""); + + await aliceRz.generateCode(); + + expect(typeof aliceRz.code).toBe("string"); + + await codePromise; + + const code = JSON.parse(aliceRz.code!) as RendezvousCode; + + expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toBeUndefined(); + expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); + expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); + expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( + "https://fallbackserver/rz/123", + ); + + httpBackend.when("DELETE", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 204, + headers: {}, + }, + }; + + const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined); + await httpBackend.flush(""); + expect(cancelPromise).resolves.toBeUndefined(); + httpBackend.verifyNoOutstandingExpectation(); + httpBackend.verifyNoOutstandingRequests(); + + await aliceRz.close(); + }); + + it("no protocols", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: false, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.finish", + outcome: "unsupported", + }); + })(); + + await aliceStartProm; + await bobStartPromise; + }); + + it("new device declines protocol with outcome unsupported", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("new device requests an invalid protocol", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("decline on existing device", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + await aliceRz.declineLoginOnExistingDevice(); + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + }); + + it("approve on existing device + no verification", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + const bobCompleteProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + })(); + + await confirmProm; + await bobCompleteProm; + }); + + async function completeLogin(devices: Record>, startInV1FallbackMode: boolean) { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const aliceVerification = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + devices, + deviceKey: "aaaa", + verificationFunction: aliceVerification, + crossSigningIds: { + master: "mmmmm", + }, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, startInV1FallbackMode); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + if (startInV1FallbackMode) { + // expect v1 + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + } else { + // expect v2 + expect(protocols).toEqual({ + type: "m.login.protocols", + protocols: ["org.matrix.msc3906.login_token"], + }); + } + + // send the v1 response always + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + // the rest is v1 payloads: + const bobLoginProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + })(); + + expect(await confirmProm).toEqual("BOB"); + await bobLoginProm; + + return { + aliceTransport, + aliceEcdh, + aliceRz, + bobTransport, + bobEcdh, + }; + } + + it("approve on existing device + verification", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + true, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("approve on existing device + verification with v1 fallback", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + false, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("device not online within timeout", async function () { + const { aliceRz } = await completeLogin({}, true); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("device appears online within timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1000); + await aliceRz.verifyNewDeviceOnExistingDevice(2000); + }); + + it("device appears online after timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1500); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("mismatched device key", async function () { + const { aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "XXXX", + }, + }, + true, + ); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/); + }); +}); diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts similarity index 89% rename from spec/unit/rendezvous/rendezvous.spec.ts rename to spec/unit/rendezvous/rendezvousv2.spec.ts index 59a3ac71613..1c8ef509329 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. @@ -17,7 +17,13 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import "../../olm-loader"; -import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + MSC3906Rendezvous, + RendezvousCode, + RendezvousFailureReason, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "../../../src/rendezvous"; import { ECDHv2RendezvousCode as ECDHRendezvousCode, MSC3903ECDHPayload, @@ -86,7 +92,7 @@ function makeTransport(name: string, uri = "https://test.rz/123456") { return new DummyTransport(name, { type: "http.v1", uri }); } -describe("Rendezvous", function () { +describe("RendezvousV2", function () { beforeAll(async function () { await global.Olm.init(); }); @@ -143,6 +149,7 @@ describe("Rendezvous", function () { const code = JSON.parse(aliceRz.code!) as RendezvousCode; expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toEqual(SETUP_ADDITIONAL_DEVICE_FLOW_V2.name); expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( @@ -201,8 +208,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -211,8 +217,8 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.finish", - outcome: "unsupported", + type: "m.login.failure", + reason: "unsupported", }); })(); @@ -220,7 +226,7 @@ describe("Rendezvous", function () { await bobStartPromise; }); - it("new device declines protocol with outcome unsupported", async function () { + it("new device declines protocol with reason unsupported", async function () { const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); @@ -256,7 +262,6 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols logger.info("Bob waiting for protocols"); @@ -265,11 +270,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + await bobEcdh.send({ type: "m.login.failure", reason: "unsupported" }); })(); await aliceStartProm; @@ -313,8 +318,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -323,11 +327,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "bad protocol" }); })(); await aliceStartProm; @@ -371,8 +375,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -381,11 +384,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -393,7 +396,7 @@ describe("Rendezvous", function () { await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + expect(loginToken).toEqual({ type: "m.login.declined" }); }); it("approve on existing device + no verification", async function () { @@ -431,8 +434,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -441,11 +443,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -455,8 +457,8 @@ describe("Rendezvous", function () { const bobCompleteProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success" }); })(); await confirmProm; @@ -505,8 +507,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -515,11 +516,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -529,8 +530,8 @@ describe("Rendezvous", function () { const bobLoginProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success", device_id: "BOB", device_key: "bbbb" }); })(); expect(await confirmProm).toEqual("BOB"); @@ -556,8 +557,7 @@ describe("Rendezvous", function () { const bobVerifyProm = (async () => { const verified = await bobEcdh.receive(); expect(verified).toEqual({ - type: "m.login.finish", - outcome: "verified", + type: "m.login.verified", verifying_device_id: "ALICE", verifying_device_key: "aaaa", master_key: "mmmmm", diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index f431c8358d0..4fe6fb888d7 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -16,7 +16,14 @@ limitations under the License. import { UnstableValue } from "matrix-events-sdk"; -import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { + RendezvousChannel, + RendezvousFailureListener, + RendezvousFailureReason, + RendezvousFlow, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "."; import { MatrixClient } from "../client"; import { CrossSigningInfo } from "../crypto/CrossSigning"; import { DeviceInfo } from "../crypto/deviceinfo"; @@ -25,11 +32,23 @@ import { logger } from "../logger"; import { sleep } from "../utils"; enum PayloadType { - Start = "m.login.start", + /** + * @deprecated Only used in MSC3906 v1 + */ Finish = "m.login.finish", Progress = "m.login.progress", + Protocol = "m.login.protocol", + Protocols = "m.login.protocols", + Approved = "m.login.approved", + Success = "m.login.success", + Verified = "m.login.verified", + Failure = "m.login.failure", + Declined = "m.login.declined", } +/** + * @deprecated Only used in MSC3906 v1 + */ enum Outcome { Success = "success", Failure = "failure", @@ -38,10 +57,21 @@ enum Outcome { Unsupported = "unsupported", } +enum FailureReason { + Cancelled = "cancelled", + Unsupported = "unsupported", + E2EESecurityError = "e2ee_security_error", + IncompatibleIntent = "incompatible_intent", +} + export interface MSC3906RendezvousPayload { type: PayloadType; intent?: RendezvousIntent; + /** + * @deprecated Only used in MSC3906 v1. Instead the type field should be used in future + */ outcome?: Outcome; + reason?: FailureReason; device_id?: string; device_key?: string; verifying_device_id?: string; @@ -64,18 +94,24 @@ export class MSC3906Rendezvous { private newDeviceId?: string; private newDeviceKey?: string; private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V2.name; + private v1FallbackEnabled: boolean; private _code?: string; /** * @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 + * @param startInV1FallbackMode - Whether to start in v1 fallback mode */ public constructor( private channel: RendezvousChannel, private client: MatrixClient, public onFailure?: RendezvousFailureListener, - ) {} + startInV1FallbackMode = false, + ) { + this.v1FallbackEnabled = startInV1FallbackMode ?? false; + } /** * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. @@ -92,7 +128,10 @@ export class MSC3906Rendezvous { return; } - this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + const raw = this.v1FallbackEnabled + ? await this.channel.generateCode(this.ourIntent) + : await this.channel.generateCode(this.ourIntent, this.flow); + this._code = JSON.stringify(raw); } public async startAfterShowingCode(): Promise { @@ -104,33 +143,85 @@ export class MSC3906Rendezvous { // determine available protocols if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) { logger.info("Server doesn't support MSC3882"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Unsupported } + : { type: PayloadType.Failure, reason: FailureReason.Unsupported }, + ); await this.cancel(RendezvousFailureReason.HomeserverLacksSupport); return undefined; } - await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] }); + await this.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Protocols, + protocols: [LOGIN_TOKEN_PROTOCOL.name], + }); logger.info("Waiting for other device to chose protocol"); - const { type, protocol, outcome } = await this.receive(); + const { type, protocol, outcome, reason, intent } = await this.receive(); + // even if we didn't start in v1 fallback we might detect that the other device is v1 + if (type === PayloadType.Finish || type === PayloadType.Progress) { + // this is a PDU from a v1 flow so use fallback mode + this.v1FallbackEnabled = true; + } + + // fallback for v1 flow if (type === PayloadType.Finish) { + this.v1FallbackEnabled = true; // new device decided not to complete - switch (outcome ?? "") { - case "unsupported": - await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + let reason: RendezvousFailureReason; + if (intent) { + reason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + } else if (outcome === Outcome.Unsupported) { + reason = RendezvousFailureReason.UnsupportedAlgorithm; + } else { + reason = RendezvousFailureReason.Unknown; + } + await this.cancel(reason); + return undefined; + } + + // v2 flow + if (type === PayloadType.Failure) { + // new device decided not to complete + let failureReason: RendezvousFailureReason; + switch (reason ?? "") { + case FailureReason.Cancelled: + failureReason = RendezvousFailureReason.UserCancelled; + break; + case FailureReason.IncompatibleIntent: + failureReason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + break; + case FailureReason.Unsupported: + failureReason = RendezvousFailureReason.UnsupportedAlgorithm; break; default: - await this.cancel(RendezvousFailureReason.Unknown); + failureReason = RendezvousFailureReason.Unknown; } + await this.cancel(failureReason); + return undefined; + } + + // v1 unexpected payload + if (this.v1FallbackEnabled && type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); return undefined; } - if (type !== PayloadType.Progress) { + // v2 unexpected payload + if (!this.v1FallbackEnabled && type !== PayloadType.Protocol) { await this.cancel(RendezvousFailureReason.Unknown); return undefined; } + // invalid protocol if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); return undefined; @@ -149,21 +240,31 @@ export class MSC3906Rendezvous { public async declineLoginOnExistingDevice(): Promise { logger.info("User declined sign in"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Declined } + : { type: PayloadType.Declined }, + ); } public async approveLoginOnExistingDevice(loginToken: string): Promise { - // eslint-disable-next-line camelcase - await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl }); + await this.channel.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Approved, + login_token: loginToken, + homeserver: this.client.baseUrl, + }); logger.info("Waiting for outcome"); const res = await this.receive(); if (!res) { return undefined; } - const { outcome, device_id: deviceId, device_key: deviceKey } = res; + const { type, outcome, device_id: deviceId, device_key: deviceKey } = res; - if (outcome !== "success") { + if ( + (this.v1FallbackEnabled && outcome !== "success") || + (!this.v1FallbackEnabled && type !== PayloadType.Success) + ) { throw new Error("Linking failed"); } @@ -201,8 +302,8 @@ export class MSC3906Rendezvous { const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!; await this.send({ - type: PayloadType.Finish, - outcome: Outcome.Verified, + type: this.v1FallbackEnabled ? PayloadType.Finish : PayloadType.Verified, + outcome: this.v1FallbackEnabled ? Outcome.Verified : undefined, verifying_device_id: this.client.getDeviceId()!, verifying_device_key: this.client.getDeviceEd25519Key()!, master_key: masterPublicKey, diff --git a/src/rendezvous/RendezvousChannel.ts b/src/rendezvous/RendezvousChannel.ts index 549ebc83f51..5dbaa58c214 100644 --- a/src/rendezvous/RendezvousChannel.ts +++ b/src/rendezvous/RendezvousChannel.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from "."; +import { RendezvousCode, RendezvousIntent, RendezvousFailureReason, RendezvousFlow } from "."; export interface RendezvousChannel { /** @@ -40,9 +40,18 @@ export interface RendezvousChannel { close(): Promise; /** + * Always uses the MSC3906 v1 flow. + * * @returns a representation of the channel that can be encoded in a QR or similar + * + * @deprecated use generateCode instead */ generateCode(intent: RendezvousIntent): Promise; + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + generateCode(intent: RendezvousIntent, flow: RendezvousFlow): Promise; + cancel(reason: RendezvousFailureReason): Promise; } diff --git a/src/rendezvous/RendezvousCode.ts b/src/rendezvous/RendezvousCode.ts index 86608aa1c44..7c0871ee605 100644 --- a/src/rendezvous/RendezvousCode.ts +++ b/src/rendezvous/RendezvousCode.ts @@ -14,10 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousTransportDetails, RendezvousIntent } from "."; +import { RendezvousTransportDetails, RendezvousIntent, RendezvousFlow } from "."; export interface RendezvousCode { intent: RendezvousIntent; + /** + * In MSC3906 v1 there wasn't a flow, hence why it's optional for now. + */ + flow?: RendezvousFlow; rendezvous?: { transport: RendezvousTransportDetails; algorithm: string; diff --git a/src/rendezvous/RendezvousFlow.ts b/src/rendezvous/RendezvousFlow.ts new file mode 100644 index 00000000000..4431a47ddfe --- /dev/null +++ b/src/rendezvous/RendezvousFlow.ts @@ -0,0 +1,26 @@ +/* +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 { UnstableValue } from "../NamespacedValue"; + +export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue( + "m.setup.additional_device.v2", + "org.matrix.msc3906.setup.additional_device.v2", +); + +export type RendezvousFlow = + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName; diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index be60ee5c9aa..f968201e9b0 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -24,6 +24,7 @@ import { RendezvousTransportDetails, RendezvousTransport, RendezvousFailureReason, + RendezvousFlow, } from ".."; import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib"; import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; @@ -85,7 +86,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); } - public async generateCode(intent: RendezvousIntent): Promise { + public async generateCode(intent: RendezvousIntent, flow?: RendezvousFlow): Promise { if (this.transport.ready) { throw new Error("Code already generated"); } @@ -98,6 +99,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { key: encodeUnpaddedBase64(this.ourPublicKey), transport: await this.transport.details(), }, + flow, intent, }; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 379b13351b8..fbeef27b81f 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -21,3 +21,4 @@ export * from "./RendezvousError"; export * from "./RendezvousFailureReason"; export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; +export * from "./RendezvousFlow"; From 935632b3b4bfd3b5b0a74273ebf049092a54f945 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 16:41:14 -0500 Subject: [PATCH 3/9] Refactor tests and increase coverage --- spec/unit/rendezvous/rendezvousv1.spec.ts | 145 ++++++- spec/unit/rendezvous/rendezvousv2.spec.ts | 450 +++++++--------------- 2 files changed, 271 insertions(+), 324 deletions(-) diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts index 8026be52097..45b84aa802a 100644 --- a/spec/unit/rendezvous/rendezvousv1.spec.ts +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -174,7 +174,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -192,7 +192,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -221,6 +221,127 @@ describe("RendezvousV1", function () { await bobStartPromise; }); + it("other device already signed in", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ + type: "m.login.finish", + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.OtherDeviceAlreadySignedIn); + }); + + it("invalid payload after protocols", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ + type: "invalid", + }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); + }); + it("new device declines protocol with outcome unsupported", async function () { const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); const bobTransport = makeTransport("Bob", "https://test.rz/999999"); @@ -228,7 +349,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -246,7 +367,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -286,7 +407,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -304,7 +425,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -344,7 +465,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -362,7 +483,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -404,7 +525,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -422,7 +543,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -471,7 +592,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const aliceVerification = jest.fn(); const alice = makeMockClient({ @@ -496,7 +617,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, diff --git a/spec/unit/rendezvous/rendezvousv2.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts index 1c8ef509329..3221abff6a5 100644 --- a/spec/unit/rendezvous/rendezvousv2.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -39,7 +39,7 @@ import { decodeBase64 } from "../../../src/crypto/olmlib"; import { logger } from "../../../src/logger"; import { DeviceInfo } from "../../../src/crypto/deviceinfo"; -function makeMockClient(opts: { +interface MockClientOpts { userId: string; deviceId: string; deviceKey?: string; @@ -54,7 +54,9 @@ function makeMockClient(opts: { known: boolean, ) => void; crossSigningIds?: Record; -}): MatrixClient { +} + +function makeMockClient(opts: MockClientOpts): MatrixClient { return { getVersions() { return { @@ -111,6 +113,90 @@ describe("RendezvousV2", function () { transports.forEach((x) => x.cleanup()); }); + async function setupRendezvous(aliceOpts: Partial = {}) { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + ...aliceOpts, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + return { + alice, + aliceTransport, + aliceStartProm, + aliceEcdh, + aliceRz, + aliceOnFailure, + bobTransport, + bobEcdh, + bobOnFailure, + }; + } + + async function completeToProtocolsPayload( + next: (x: any, protocolsPayload: any) => Promise, + aliceOpts: Partial = {}, + ) { + const x = await setupRendezvous(aliceOpts); + const { bobEcdh, aliceStartProm } = x; + const bobStartPromise = (async () => { + await bobEcdh.connect(); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob received protocols: ${JSON.stringify(protocols)}`); + + await next(x, protocols); + })(); + + await aliceStartProm; + await bobStartPromise; + + return x; + } + + async function completeToSendingProtocolPayload(protocolPayload: any, aliceOpts: Partial = {}) { + const x = await completeToProtocolsPayload( + async ({ bobEcdh }: { bobEcdh: MSC3903ECDHRendezvousChannel }, protocolsPayload) => { + expect(protocolsPayload).toEqual({ + type: "m.login.protocols", + protocols: ["org.matrix.msc3906.login_token"], + }); + await bobEcdh.send(protocolPayload); + }, + aliceOpts, + ); + + return x; + } + it("generate and cancel", async function () { const alice = makeMockClient({ userId: "@alice:example.com", @@ -174,225 +260,56 @@ describe("RendezvousV2", function () { }); it("no protocols", async function () { - const aliceTransport = makeTransport("Alice"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: false, - msc3886Enabled: false, - }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, + await completeToProtocolsPayload( + async (_, protocolsPayload) => { + expect(protocolsPayload).toEqual({ + type: "m.login.failure", + reason: "unsupported", + }); + }, + { + msc3882Enabled: false, + msc3886Enabled: false, + }, ); + }); - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.failure", - reason: "unsupported", - }); - })(); + it("other device already signed in", async function () { + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.failure", + reason: "incompatible_intent", + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + }); + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.OtherDeviceAlreadySignedIn); + }); - await aliceStartProm; - await bobStartPromise; + it("invalid payload after protocols", async function () { + const { aliceOnFailure } = await completeToSendingProtocolPayload({ type: "invalid" }); + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); }); it("new device declines protocol with reason unsupported", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.failure", + reason: "unsupported", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.failure", reason: "unsupported" }); - })(); - - await aliceStartProm; - await bobStartPromise; - expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); it("new device requests an invalid protocol", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "bad protocol", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "bad protocol" }); - })(); - - await aliceStartProm; - await bobStartPromise; expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); it("decline on existing device", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceRz, bobEcdh } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); @@ -400,58 +317,10 @@ describe("RendezvousV2", function () { }); it("approve on existing device + no verification", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceRz, bobEcdh, alice } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); @@ -466,65 +335,22 @@ describe("RendezvousV2", function () { }); async function completeLogin(devices: Record>) { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); const aliceVerification = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, - devices, - deviceKey: "aaaa", - verificationFunction: aliceVerification, - crossSigningIds: { - master: "mmmmm", - }, - }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; + const { aliceRz, bobEcdh, alice, aliceEcdh, aliceTransport, bobTransport } = + await completeToSendingProtocolPayload( + { + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", + }, + { + devices, + deviceKey: "aaaa", + verificationFunction: aliceVerification, + crossSigningIds: { + master: "mmmmm", + }, + }, + ); const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); From 59ee0a20ecdfd839b8057cb9cd9b6e7ceeaa775a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 15:27:32 -0500 Subject: [PATCH 4/9] Reduce cognitive complexity --- src/rendezvous/MSC3906Rendezvous.ts | 80 +++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index 4fe6fb888d7..b1e2c60dc79 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -134,6 +134,10 @@ export class MSC3906Rendezvous { this._code = JSON.stringify(raw); } + /** + * + * @returns the checksum of the secure channel if the rendezvous set up was successful, otherwise undefined + */ public async startAfterShowingCode(): Promise { const checksum = await this.channel.connect(); @@ -158,17 +162,43 @@ export class MSC3906Rendezvous { }); logger.info("Waiting for other device to chose protocol"); - const { type, protocol, outcome, reason, intent } = await this.receive(); + const nextPayload = await this.receive(); + + this.checkForV1Fallback(nextPayload); + const protocol = this.v1FallbackEnabled + ? await this.handleV1ProtocolPayload(nextPayload) + : await this.handleV2ProtocolPayload(nextPayload); + + // invalid protocol + if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { + await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + return undefined; + } + + return checksum; + } + + private checkForV1Fallback({ type }: MSC3906RendezvousPayload): void { // even if we didn't start in v1 fallback we might detect that the other device is v1 if (type === PayloadType.Finish || type === PayloadType.Progress) { // this is a PDU from a v1 flow so use fallback mode this.v1FallbackEnabled = true; } + } - // fallback for v1 flow + /** + * + * @returns true if the protocol was received successfully, false otherwise + */ + private async handleV1ProtocolPayload({ + type, + protocol, + outcome, + reason, + intent, + }: MSC3906RendezvousPayload): Promise { if (type === PayloadType.Finish) { - this.v1FallbackEnabled = true; // new device decided not to complete let reason: RendezvousFailureReason; if (intent) { @@ -182,9 +212,29 @@ export class MSC3906Rendezvous { reason = RendezvousFailureReason.Unknown; } await this.cancel(reason); - return undefined; + return; + } + + // unexpected payload + if (type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); + return; } + return protocol; + } + + /** + * + * @returns true if the protocol was received successfully, false otherwise + */ + private async handleV2ProtocolPayload({ + type, + protocol, + outcome, + reason, + intent, + }: MSC3906RendezvousPayload): Promise { // v2 flow if (type === PayloadType.Failure) { // new device decided not to complete @@ -206,28 +256,16 @@ export class MSC3906Rendezvous { failureReason = RendezvousFailureReason.Unknown; } await this.cancel(failureReason); - return undefined; - } - - // v1 unexpected payload - if (this.v1FallbackEnabled && type !== PayloadType.Progress) { - await this.cancel(RendezvousFailureReason.Unknown); - return undefined; + return; } - // v2 unexpected payload - if (!this.v1FallbackEnabled && type !== PayloadType.Protocol) { + // unexpected payload + if (type !== PayloadType.Protocol) { await this.cancel(RendezvousFailureReason.Unknown); - return undefined; - } - - // invalid protocol - if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { - await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); - return undefined; + return; } - return checksum; + return protocol; } private async receive(): Promise { From f9324d35334d7ee916ef0f464a998e546f4f1e4e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Mar 2023 16:58:08 +0000 Subject: [PATCH 5/9] Support for MSC3906 v2 --- spec/unit/rendezvous/rendezvousv1.spec.ts | 652 ++++++++++++++++++ ...endezvous.spec.ts => rendezvousv2.spec.ts} | 68 +- src/rendezvous/MSC3906Rendezvous.ts | 139 +++- src/rendezvous/RendezvousChannel.ts | 11 +- src/rendezvous/RendezvousCode.ts | 6 +- src/rendezvous/RendezvousFlow.ts | 26 + .../MSC3903ECDHv2RendezvousChannel.ts | 4 +- src/rendezvous/index.ts | 1 + 8 files changed, 851 insertions(+), 56 deletions(-) create mode 100644 spec/unit/rendezvous/rendezvousv1.spec.ts rename spec/unit/rendezvous/{rendezvous.spec.ts => rendezvousv2.spec.ts} (89%) create mode 100644 src/rendezvous/RendezvousFlow.ts diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts new file mode 100644 index 00000000000..8026be52097 --- /dev/null +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -0,0 +1,652 @@ +/* +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 MockHttpBackend from "matrix-mock-request"; + +import "../../olm-loader"; +import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + ECDHv2RendezvousCode as ECDHRendezvousCode, + MSC3903ECDHPayload, + MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel, +} from "../../../src/rendezvous/channels"; +import { MatrixClient } from "../../../src"; +import { + MSC3886SimpleHttpRendezvousTransport, + MSC3886SimpleHttpRendezvousTransportDetails, +} from "../../../src/rendezvous/transports"; +import { DummyTransport } from "./DummyTransport"; +import { decodeBase64 } from "../../../src/crypto/olmlib"; +import { logger } from "../../../src/logger"; +import { DeviceInfo } from "../../../src/crypto/deviceinfo"; + +function makeMockClient(opts: { + userId: string; + deviceId: string; + deviceKey?: string; + msc3882Enabled: boolean; + msc3886Enabled: boolean; + devices?: Record>; + verificationFunction?: ( + userId: string, + deviceId: string, + verified: boolean, + blocked: boolean, + known: boolean, + ) => void; + crossSigningIds?: Record; +}): MatrixClient { + return { + getVersions() { + return { + unstable_features: { + "org.matrix.msc3882": opts.msc3882Enabled, + "org.matrix.msc3886": opts.msc3886Enabled, + }, + }; + }, + getUserId() { + return opts.userId; + }, + getDeviceId() { + return opts.deviceId; + }, + getDeviceEd25519Key() { + return opts.deviceKey; + }, + baseUrl: "https://example.com", + crypto: { + getStoredDevice(userId: string, deviceId: string) { + return opts.devices?.[deviceId] ?? null; + }, + setDeviceVerification: opts.verificationFunction, + crossSigningInfo: { + getId(key: string) { + return opts.crossSigningIds?.[key]; + }, + }, + }, + } as unknown as MatrixClient; +} + +function makeTransport(name: string, uri = "https://test.rz/123456") { + return new DummyTransport(name, { type: "http.v1", uri }); +} + +describe("RendezvousV1", function () { + beforeAll(async function () { + await global.Olm.init(); + }); + + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetch; + let transports: DummyTransport[]; + + beforeEach(function () { + httpBackend = new MockHttpBackend(); + fetchFn = httpBackend.fetchFn as typeof global.fetch; + transports = []; + }); + + afterEach(function () { + transports.forEach((x) => x.cleanup()); + }); + + it("generate and cancel", async function () { + const alice = makeMockClient({ + userId: "@alice:example.com", + deviceId: "DEVICEID", + msc3886Enabled: false, + msc3882Enabled: true, + }); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + const aliceTransport = new MSC3886SimpleHttpRendezvousTransport({ + client: alice, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + + expect(aliceRz.code).toBeUndefined(); + + const codePromise = aliceRz.generateCode(); + await httpBackend.flush(""); + + await aliceRz.generateCode(); + + expect(typeof aliceRz.code).toBe("string"); + + await codePromise; + + const code = JSON.parse(aliceRz.code!) as RendezvousCode; + + expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toBeUndefined(); + expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); + expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); + expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( + "https://fallbackserver/rz/123", + ); + + httpBackend.when("DELETE", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 204, + headers: {}, + }, + }; + + const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined); + await httpBackend.flush(""); + expect(cancelPromise).resolves.toBeUndefined(); + httpBackend.verifyNoOutstandingExpectation(); + httpBackend.verifyNoOutstandingRequests(); + + await aliceRz.close(); + }); + + it("no protocols", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: false, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.finish", + outcome: "unsupported", + }); + })(); + + await aliceStartProm; + await bobStartPromise; + }); + + it("new device declines protocol with outcome unsupported", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("new device requests an invalid protocol", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); + }); + + it("decline on existing device", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + await aliceRz.declineLoginOnExistingDevice(); + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + }); + + it("approve on existing device + no verification", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + const bobCompleteProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + })(); + + await confirmProm; + await bobCompleteProm; + }); + + async function completeLogin(devices: Record>, startInV1FallbackMode: boolean) { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signs in and generates a code + const aliceOnFailure = jest.fn(); + const aliceVerification = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + devices, + deviceKey: "aaaa", + verificationFunction: aliceVerification, + crossSigningIds: { + master: "mmmmm", + }, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, startInV1FallbackMode); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob is try to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + if (startInV1FallbackMode) { + // expect v1 + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + } else { + // expect v2 + expect(protocols).toEqual({ + type: "m.login.protocols", + protocols: ["org.matrix.msc3906.login_token"], + }); + } + + // send the v1 response always + await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + })(); + + await aliceStartProm; + await bobStartPromise; + + const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); + + // the rest is v1 payloads: + const bobLoginProm = (async () => { + const loginToken = await bobEcdh.receive(); + expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + })(); + + expect(await confirmProm).toEqual("BOB"); + await bobLoginProm; + + return { + aliceTransport, + aliceEcdh, + aliceRz, + bobTransport, + bobEcdh, + }; + } + + it("approve on existing device + verification", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + true, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("approve on existing device + verification with v1 fallback", async function () { + const { bobEcdh, aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "bbbb", + }, + }, + false, + ); + const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice(); + + const bobVerifyProm = (async () => { + const verified = await bobEcdh.receive(); + expect(verified).toEqual({ + type: "m.login.finish", + outcome: "verified", + verifying_device_id: "ALICE", + verifying_device_key: "aaaa", + master_key: "mmmmm", + }); + })(); + + await verifyProm; + await bobVerifyProm; + }); + + it("device not online within timeout", async function () { + const { aliceRz } = await completeLogin({}, true); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("device appears online within timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1000); + await aliceRz.verifyNewDeviceOnExistingDevice(2000); + }); + + it("device appears online after timeout", async function () { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices, true); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1500); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(); + }); + + it("mismatched device key", async function () { + const { aliceRz } = await completeLogin( + { + BOB: { + getFingerprint: () => "XXXX", + }, + }, + true, + ); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/); + }); +}); diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts similarity index 89% rename from spec/unit/rendezvous/rendezvous.spec.ts rename to spec/unit/rendezvous/rendezvousv2.spec.ts index 59a3ac71613..1c8ef509329 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +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. @@ -17,7 +17,13 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import "../../olm-loader"; -import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { + MSC3906Rendezvous, + RendezvousCode, + RendezvousFailureReason, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "../../../src/rendezvous"; import { ECDHv2RendezvousCode as ECDHRendezvousCode, MSC3903ECDHPayload, @@ -86,7 +92,7 @@ function makeTransport(name: string, uri = "https://test.rz/123456") { return new DummyTransport(name, { type: "http.v1", uri }); } -describe("Rendezvous", function () { +describe("RendezvousV2", function () { beforeAll(async function () { await global.Olm.init(); }); @@ -143,6 +149,7 @@ describe("Rendezvous", function () { const code = JSON.parse(aliceRz.code!) as RendezvousCode; expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); + expect(code.flow).toEqual(SETUP_ADDITIONAL_DEVICE_FLOW_V2.name); expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( @@ -201,8 +208,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -211,8 +217,8 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.finish", - outcome: "unsupported", + type: "m.login.failure", + reason: "unsupported", }); })(); @@ -220,7 +226,7 @@ describe("Rendezvous", function () { await bobStartPromise; }); - it("new device declines protocol with outcome unsupported", async function () { + it("new device declines protocol with reason unsupported", async function () { const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); const bobTransport = makeTransport("Bob", "https://test.rz/999999"); transports.push(aliceTransport, bobTransport); @@ -256,7 +262,6 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); // wait for protocols logger.info("Bob waiting for protocols"); @@ -265,11 +270,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.finish", outcome: "unsupported" }); + await bobEcdh.send({ type: "m.login.failure", reason: "unsupported" }); })(); await aliceStartProm; @@ -313,8 +318,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -323,11 +327,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "bad protocol" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "bad protocol" }); })(); await aliceStartProm; @@ -371,8 +375,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -381,11 +384,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -393,7 +396,7 @@ describe("Rendezvous", function () { await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.finish", outcome: "declined" }); + expect(loginToken).toEqual({ type: "m.login.declined" }); }); it("approve on existing device + no verification", async function () { @@ -431,8 +434,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -441,11 +443,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -455,8 +457,8 @@ describe("Rendezvous", function () { const bobCompleteProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success" }); })(); await confirmProm; @@ -505,8 +507,7 @@ describe("Rendezvous", function () { const bobStartPromise = (async () => { const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + logger.info(`Bob checksums is ${bobChecksum}`); // wait for protocols logger.info("Bob waiting for protocols"); @@ -515,11 +516,11 @@ describe("Rendezvous", function () { logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); expect(protocols).toEqual({ - type: "m.login.progress", + type: "m.login.protocols", protocols: ["org.matrix.msc3906.login_token"], }); - await bobEcdh.send({ type: "m.login.progress", protocol: "org.matrix.msc3906.login_token" }); + await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); })(); await aliceStartProm; @@ -529,8 +530,8 @@ describe("Rendezvous", function () { const bobLoginProm = (async () => { const loginToken = await bobEcdh.receive(); - expect(loginToken).toEqual({ type: "m.login.progress", login_token: "token", homeserver: alice.baseUrl }); - await bobEcdh.send({ type: "m.login.finish", outcome: "success", device_id: "BOB", device_key: "bbbb" }); + expect(loginToken).toEqual({ type: "m.login.approved", login_token: "token", homeserver: alice.baseUrl }); + await bobEcdh.send({ type: "m.login.success", device_id: "BOB", device_key: "bbbb" }); })(); expect(await confirmProm).toEqual("BOB"); @@ -556,8 +557,7 @@ describe("Rendezvous", function () { const bobVerifyProm = (async () => { const verified = await bobEcdh.receive(); expect(verified).toEqual({ - type: "m.login.finish", - outcome: "verified", + type: "m.login.verified", verifying_device_id: "ALICE", verifying_device_key: "aaaa", master_key: "mmmmm", diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index f431c8358d0..4fe6fb888d7 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -16,7 +16,14 @@ limitations under the License. import { UnstableValue } from "matrix-events-sdk"; -import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; +import { + RendezvousChannel, + RendezvousFailureListener, + RendezvousFailureReason, + RendezvousFlow, + RendezvousIntent, + SETUP_ADDITIONAL_DEVICE_FLOW_V2, +} from "."; import { MatrixClient } from "../client"; import { CrossSigningInfo } from "../crypto/CrossSigning"; import { DeviceInfo } from "../crypto/deviceinfo"; @@ -25,11 +32,23 @@ import { logger } from "../logger"; import { sleep } from "../utils"; enum PayloadType { - Start = "m.login.start", + /** + * @deprecated Only used in MSC3906 v1 + */ Finish = "m.login.finish", Progress = "m.login.progress", + Protocol = "m.login.protocol", + Protocols = "m.login.protocols", + Approved = "m.login.approved", + Success = "m.login.success", + Verified = "m.login.verified", + Failure = "m.login.failure", + Declined = "m.login.declined", } +/** + * @deprecated Only used in MSC3906 v1 + */ enum Outcome { Success = "success", Failure = "failure", @@ -38,10 +57,21 @@ enum Outcome { Unsupported = "unsupported", } +enum FailureReason { + Cancelled = "cancelled", + Unsupported = "unsupported", + E2EESecurityError = "e2ee_security_error", + IncompatibleIntent = "incompatible_intent", +} + export interface MSC3906RendezvousPayload { type: PayloadType; intent?: RendezvousIntent; + /** + * @deprecated Only used in MSC3906 v1. Instead the type field should be used in future + */ outcome?: Outcome; + reason?: FailureReason; device_id?: string; device_key?: string; verifying_device_id?: string; @@ -64,18 +94,24 @@ export class MSC3906Rendezvous { private newDeviceId?: string; private newDeviceKey?: string; private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V2.name; + private v1FallbackEnabled: boolean; private _code?: string; /** * @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 + * @param startInV1FallbackMode - Whether to start in v1 fallback mode */ public constructor( private channel: RendezvousChannel, private client: MatrixClient, public onFailure?: RendezvousFailureListener, - ) {} + startInV1FallbackMode = false, + ) { + this.v1FallbackEnabled = startInV1FallbackMode ?? false; + } /** * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. @@ -92,7 +128,10 @@ export class MSC3906Rendezvous { return; } - this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + const raw = this.v1FallbackEnabled + ? await this.channel.generateCode(this.ourIntent) + : await this.channel.generateCode(this.ourIntent, this.flow); + this._code = JSON.stringify(raw); } public async startAfterShowingCode(): Promise { @@ -104,33 +143,85 @@ export class MSC3906Rendezvous { // determine available protocols if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) { logger.info("Server doesn't support MSC3882"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Unsupported } + : { type: PayloadType.Failure, reason: FailureReason.Unsupported }, + ); await this.cancel(RendezvousFailureReason.HomeserverLacksSupport); return undefined; } - await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] }); + await this.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Protocols, + protocols: [LOGIN_TOKEN_PROTOCOL.name], + }); logger.info("Waiting for other device to chose protocol"); - const { type, protocol, outcome } = await this.receive(); + const { type, protocol, outcome, reason, intent } = await this.receive(); + // even if we didn't start in v1 fallback we might detect that the other device is v1 + if (type === PayloadType.Finish || type === PayloadType.Progress) { + // this is a PDU from a v1 flow so use fallback mode + this.v1FallbackEnabled = true; + } + + // fallback for v1 flow if (type === PayloadType.Finish) { + this.v1FallbackEnabled = true; // new device decided not to complete - switch (outcome ?? "") { - case "unsupported": - await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + let reason: RendezvousFailureReason; + if (intent) { + reason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + } else if (outcome === Outcome.Unsupported) { + reason = RendezvousFailureReason.UnsupportedAlgorithm; + } else { + reason = RendezvousFailureReason.Unknown; + } + await this.cancel(reason); + return undefined; + } + + // v2 flow + if (type === PayloadType.Failure) { + // new device decided not to complete + let failureReason: RendezvousFailureReason; + switch (reason ?? "") { + case FailureReason.Cancelled: + failureReason = RendezvousFailureReason.UserCancelled; + break; + case FailureReason.IncompatibleIntent: + failureReason = + this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE + ? RendezvousFailureReason.OtherDeviceNotSignedIn + : RendezvousFailureReason.OtherDeviceAlreadySignedIn; + break; + case FailureReason.Unsupported: + failureReason = RendezvousFailureReason.UnsupportedAlgorithm; break; default: - await this.cancel(RendezvousFailureReason.Unknown); + failureReason = RendezvousFailureReason.Unknown; } + await this.cancel(failureReason); + return undefined; + } + + // v1 unexpected payload + if (this.v1FallbackEnabled && type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); return undefined; } - if (type !== PayloadType.Progress) { + // v2 unexpected payload + if (!this.v1FallbackEnabled && type !== PayloadType.Protocol) { await this.cancel(RendezvousFailureReason.Unknown); return undefined; } + // invalid protocol if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); return undefined; @@ -149,21 +240,31 @@ export class MSC3906Rendezvous { public async declineLoginOnExistingDevice(): Promise { logger.info("User declined sign in"); - await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined }); + await this.send( + this.v1FallbackEnabled + ? { type: PayloadType.Finish, outcome: Outcome.Declined } + : { type: PayloadType.Declined }, + ); } public async approveLoginOnExistingDevice(loginToken: string): Promise { - // eslint-disable-next-line camelcase - await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl }); + await this.channel.send({ + type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Approved, + login_token: loginToken, + homeserver: this.client.baseUrl, + }); logger.info("Waiting for outcome"); const res = await this.receive(); if (!res) { return undefined; } - const { outcome, device_id: deviceId, device_key: deviceKey } = res; + const { type, outcome, device_id: deviceId, device_key: deviceKey } = res; - if (outcome !== "success") { + if ( + (this.v1FallbackEnabled && outcome !== "success") || + (!this.v1FallbackEnabled && type !== PayloadType.Success) + ) { throw new Error("Linking failed"); } @@ -201,8 +302,8 @@ export class MSC3906Rendezvous { const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!; await this.send({ - type: PayloadType.Finish, - outcome: Outcome.Verified, + type: this.v1FallbackEnabled ? PayloadType.Finish : PayloadType.Verified, + outcome: this.v1FallbackEnabled ? Outcome.Verified : undefined, verifying_device_id: this.client.getDeviceId()!, verifying_device_key: this.client.getDeviceEd25519Key()!, master_key: masterPublicKey, diff --git a/src/rendezvous/RendezvousChannel.ts b/src/rendezvous/RendezvousChannel.ts index 549ebc83f51..5dbaa58c214 100644 --- a/src/rendezvous/RendezvousChannel.ts +++ b/src/rendezvous/RendezvousChannel.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from "."; +import { RendezvousCode, RendezvousIntent, RendezvousFailureReason, RendezvousFlow } from "."; export interface RendezvousChannel { /** @@ -40,9 +40,18 @@ export interface RendezvousChannel { close(): Promise; /** + * Always uses the MSC3906 v1 flow. + * * @returns a representation of the channel that can be encoded in a QR or similar + * + * @deprecated use generateCode instead */ generateCode(intent: RendezvousIntent): Promise; + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + generateCode(intent: RendezvousIntent, flow: RendezvousFlow): Promise; + cancel(reason: RendezvousFailureReason): Promise; } diff --git a/src/rendezvous/RendezvousCode.ts b/src/rendezvous/RendezvousCode.ts index 86608aa1c44..7c0871ee605 100644 --- a/src/rendezvous/RendezvousCode.ts +++ b/src/rendezvous/RendezvousCode.ts @@ -14,10 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RendezvousTransportDetails, RendezvousIntent } from "."; +import { RendezvousTransportDetails, RendezvousIntent, RendezvousFlow } from "."; export interface RendezvousCode { intent: RendezvousIntent; + /** + * In MSC3906 v1 there wasn't a flow, hence why it's optional for now. + */ + flow?: RendezvousFlow; rendezvous?: { transport: RendezvousTransportDetails; algorithm: string; diff --git a/src/rendezvous/RendezvousFlow.ts b/src/rendezvous/RendezvousFlow.ts new file mode 100644 index 00000000000..4431a47ddfe --- /dev/null +++ b/src/rendezvous/RendezvousFlow.ts @@ -0,0 +1,26 @@ +/* +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 { UnstableValue } from "../NamespacedValue"; + +export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue( + "m.setup.additional_device.v2", + "org.matrix.msc3906.setup.additional_device.v2", +); + +export type RendezvousFlow = + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName; diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index be60ee5c9aa..f968201e9b0 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -24,6 +24,7 @@ import { RendezvousTransportDetails, RendezvousTransport, RendezvousFailureReason, + RendezvousFlow, } from ".."; import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib"; import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; @@ -85,7 +86,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); } - public async generateCode(intent: RendezvousIntent): Promise { + public async generateCode(intent: RendezvousIntent, flow?: RendezvousFlow): Promise { if (this.transport.ready) { throw new Error("Code already generated"); } @@ -98,6 +99,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { key: encodeUnpaddedBase64(this.ourPublicKey), transport: await this.transport.details(), }, + flow, intent, }; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 379b13351b8..fbeef27b81f 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -21,3 +21,4 @@ export * from "./RendezvousError"; export * from "./RendezvousFailureReason"; export * from "./RendezvousIntent"; export * from "./RendezvousTransport"; +export * from "./RendezvousFlow"; From ccb6e1ff4be4386781ba05fa62f01e6f7ee72218 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 16:41:14 -0500 Subject: [PATCH 6/9] Refactor tests and increase coverage --- spec/unit/rendezvous/rendezvousv1.spec.ts | 145 ++++++- spec/unit/rendezvous/rendezvousv2.spec.ts | 450 +++++++--------------- 2 files changed, 271 insertions(+), 324 deletions(-) diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts index 8026be52097..45b84aa802a 100644 --- a/spec/unit/rendezvous/rendezvousv1.spec.ts +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -174,7 +174,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -192,7 +192,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -221,6 +221,127 @@ describe("RendezvousV1", function () { await bobStartPromise; }); + it("other device already signed in", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ + type: "m.login.finish", + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.OtherDeviceAlreadySignedIn); + }); + + it("invalid payload after protocols", async function () { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + expect(code.rendezvous.key).toBeDefined(); + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + const bobStartPromise = (async () => { + const bobChecksum = await bobEcdh.connect(); + logger.info(`Bob checksums is ${bobChecksum} now sending intent`); + // await bobEcdh.send({ type: 'm.login.progress', intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE }); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); + + expect(protocols).toEqual({ + type: "m.login.progress", + protocols: ["org.matrix.msc3906.login_token"], + }); + + await bobEcdh.send({ + type: "invalid", + }); + })(); + + await aliceStartProm; + await bobStartPromise; + + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); + }); + it("new device declines protocol with outcome unsupported", async function () { const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); const bobTransport = makeTransport("Bob", "https://test.rz/999999"); @@ -228,7 +349,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -246,7 +367,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -286,7 +407,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -304,7 +425,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -344,7 +465,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -362,7 +483,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -404,7 +525,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const alice = makeMockClient({ userId: "alice", @@ -422,7 +543,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, @@ -471,7 +592,7 @@ describe("RendezvousV1", function () { aliceTransport.otherParty = bobTransport; bobTransport.otherParty = aliceTransport; - // alice is already signs in and generates a code + // alice is already signed in and generates a code const aliceOnFailure = jest.fn(); const aliceVerification = jest.fn(); const alice = makeMockClient({ @@ -496,7 +617,7 @@ describe("RendezvousV1", function () { const aliceStartProm = aliceRz.startAfterShowingCode(); - // bob is try to sign in and scans the code + // bob wants to sign in and scans the code const bobOnFailure = jest.fn(); const bobEcdh = new MSC3903ECDHRendezvousChannel( bobTransport, diff --git a/spec/unit/rendezvous/rendezvousv2.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts index 1c8ef509329..3221abff6a5 100644 --- a/spec/unit/rendezvous/rendezvousv2.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -39,7 +39,7 @@ import { decodeBase64 } from "../../../src/crypto/olmlib"; import { logger } from "../../../src/logger"; import { DeviceInfo } from "../../../src/crypto/deviceinfo"; -function makeMockClient(opts: { +interface MockClientOpts { userId: string; deviceId: string; deviceKey?: string; @@ -54,7 +54,9 @@ function makeMockClient(opts: { known: boolean, ) => void; crossSigningIds?: Record; -}): MatrixClient { +} + +function makeMockClient(opts: MockClientOpts): MatrixClient { return { getVersions() { return { @@ -111,6 +113,90 @@ describe("RendezvousV2", function () { transports.forEach((x) => x.cleanup()); }); + async function setupRendezvous(aliceOpts: Partial = {}) { + const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); + const bobTransport = makeTransport("Bob", "https://test.rz/999999"); + transports.push(aliceTransport, bobTransport); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is already signed in and generates a code + const aliceOnFailure = jest.fn(); + const alice = makeMockClient({ + userId: "alice", + deviceId: "ALICE", + msc3882Enabled: true, + msc3886Enabled: false, + ...aliceOpts, + }); + const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; + + const aliceStartProm = aliceRz.startAfterShowingCode(); + + // bob wants to sign in and scans the code + const bobOnFailure = jest.fn(); + const bobEcdh = new MSC3903ECDHRendezvousChannel( + bobTransport, + decodeBase64(code.rendezvous.key), // alice's public key + bobOnFailure, + ); + + return { + alice, + aliceTransport, + aliceStartProm, + aliceEcdh, + aliceRz, + aliceOnFailure, + bobTransport, + bobEcdh, + bobOnFailure, + }; + } + + async function completeToProtocolsPayload( + next: (x: any, protocolsPayload: any) => Promise, + aliceOpts: Partial = {}, + ) { + const x = await setupRendezvous(aliceOpts); + const { bobEcdh, aliceStartProm } = x; + const bobStartPromise = (async () => { + await bobEcdh.connect(); + + // wait for protocols + logger.info("Bob waiting for protocols"); + const protocols = await bobEcdh.receive(); + + logger.info(`Bob received protocols: ${JSON.stringify(protocols)}`); + + await next(x, protocols); + })(); + + await aliceStartProm; + await bobStartPromise; + + return x; + } + + async function completeToSendingProtocolPayload(protocolPayload: any, aliceOpts: Partial = {}) { + const x = await completeToProtocolsPayload( + async ({ bobEcdh }: { bobEcdh: MSC3903ECDHRendezvousChannel }, protocolsPayload) => { + expect(protocolsPayload).toEqual({ + type: "m.login.protocols", + protocols: ["org.matrix.msc3906.login_token"], + }); + await bobEcdh.send(protocolPayload); + }, + aliceOpts, + ); + + return x; + } + it("generate and cancel", async function () { const alice = makeMockClient({ userId: "@alice:example.com", @@ -174,225 +260,56 @@ describe("RendezvousV2", function () { }); it("no protocols", async function () { - const aliceTransport = makeTransport("Alice"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: false, - msc3886Enabled: false, - }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, + await completeToProtocolsPayload( + async (_, protocolsPayload) => { + expect(protocolsPayload).toEqual({ + type: "m.login.failure", + reason: "unsupported", + }); + }, + { + msc3882Enabled: false, + msc3886Enabled: false, + }, ); + }); - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.failure", - reason: "unsupported", - }); - })(); + it("other device already signed in", async function () { + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.failure", + reason: "incompatible_intent", + intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE, + }); + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.OtherDeviceAlreadySignedIn); + }); - await aliceStartProm; - await bobStartPromise; + it("invalid payload after protocols", async function () { + const { aliceOnFailure } = await completeToSendingProtocolPayload({ type: "invalid" }); + expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown); }); it("new device declines protocol with reason unsupported", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.failure", + reason: "unsupported", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum} now sending intent`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.failure", reason: "unsupported" }); - })(); - - await aliceStartProm; - await bobStartPromise; - expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); it("new device requests an invalid protocol", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceOnFailure } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "bad protocol", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "bad protocol" }); - })(); - - await aliceStartProm; - await bobStartPromise; expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm); }); it("decline on existing device", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceRz, bobEcdh } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; await aliceRz.declineLoginOnExistingDevice(); const loginToken = await bobEcdh.receive(); @@ -400,58 +317,10 @@ describe("RendezvousV2", function () { }); it("approve on existing device + no verification", async function () { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, + const { aliceRz, bobEcdh, alice } = await completeToSendingProtocolPayload({ + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); @@ -466,65 +335,22 @@ describe("RendezvousV2", function () { }); async function completeLogin(devices: Record>) { - const aliceTransport = makeTransport("Alice", "https://test.rz/123456"); - const bobTransport = makeTransport("Bob", "https://test.rz/999999"); - transports.push(aliceTransport, bobTransport); - aliceTransport.otherParty = bobTransport; - bobTransport.otherParty = aliceTransport; - - // alice is already signs in and generates a code - const aliceOnFailure = jest.fn(); const aliceVerification = jest.fn(); - const alice = makeMockClient({ - userId: "alice", - deviceId: "ALICE", - msc3882Enabled: true, - msc3886Enabled: false, - devices, - deviceKey: "aaaa", - verificationFunction: aliceVerification, - crossSigningIds: { - master: "mmmmm", - }, - }); - const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); - aliceTransport.onCancelled = aliceOnFailure; - await aliceRz.generateCode(); - const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; - - expect(code.rendezvous.key).toBeDefined(); - - const aliceStartProm = aliceRz.startAfterShowingCode(); - - // bob is try to sign in and scans the code - const bobOnFailure = jest.fn(); - const bobEcdh = new MSC3903ECDHRendezvousChannel( - bobTransport, - decodeBase64(code.rendezvous.key), // alice's public key - bobOnFailure, - ); - - const bobStartPromise = (async () => { - const bobChecksum = await bobEcdh.connect(); - logger.info(`Bob checksums is ${bobChecksum}`); - - // wait for protocols - logger.info("Bob waiting for protocols"); - const protocols = await bobEcdh.receive(); - - logger.info(`Bob protocols: ${JSON.stringify(protocols)}`); - - expect(protocols).toEqual({ - type: "m.login.protocols", - protocols: ["org.matrix.msc3906.login_token"], - }); - - await bobEcdh.send({ type: "m.login.protocol", protocol: "org.matrix.msc3906.login_token" }); - })(); - - await aliceStartProm; - await bobStartPromise; + const { aliceRz, bobEcdh, alice, aliceEcdh, aliceTransport, bobTransport } = + await completeToSendingProtocolPayload( + { + type: "m.login.protocol", + protocol: "org.matrix.msc3906.login_token", + }, + { + devices, + deviceKey: "aaaa", + verificationFunction: aliceVerification, + crossSigningIds: { + master: "mmmmm", + }, + }, + ); const confirmProm = aliceRz.approveLoginOnExistingDevice("token"); From 7bb10a15cf837f2eafca137a3efabc2e94f50e1b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 15:27:32 -0500 Subject: [PATCH 7/9] Reduce cognitive complexity --- src/rendezvous/MSC3906Rendezvous.ts | 80 +++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index 4fe6fb888d7..b1e2c60dc79 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -134,6 +134,10 @@ export class MSC3906Rendezvous { this._code = JSON.stringify(raw); } + /** + * + * @returns the checksum of the secure channel if the rendezvous set up was successful, otherwise undefined + */ public async startAfterShowingCode(): Promise { const checksum = await this.channel.connect(); @@ -158,17 +162,43 @@ export class MSC3906Rendezvous { }); logger.info("Waiting for other device to chose protocol"); - const { type, protocol, outcome, reason, intent } = await this.receive(); + const nextPayload = await this.receive(); + + this.checkForV1Fallback(nextPayload); + const protocol = this.v1FallbackEnabled + ? await this.handleV1ProtocolPayload(nextPayload) + : await this.handleV2ProtocolPayload(nextPayload); + + // invalid protocol + if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { + await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + return undefined; + } + + return checksum; + } + + private checkForV1Fallback({ type }: MSC3906RendezvousPayload): void { // even if we didn't start in v1 fallback we might detect that the other device is v1 if (type === PayloadType.Finish || type === PayloadType.Progress) { // this is a PDU from a v1 flow so use fallback mode this.v1FallbackEnabled = true; } + } - // fallback for v1 flow + /** + * + * @returns true if the protocol was received successfully, false otherwise + */ + private async handleV1ProtocolPayload({ + type, + protocol, + outcome, + reason, + intent, + }: MSC3906RendezvousPayload): Promise { if (type === PayloadType.Finish) { - this.v1FallbackEnabled = true; // new device decided not to complete let reason: RendezvousFailureReason; if (intent) { @@ -182,9 +212,29 @@ export class MSC3906Rendezvous { reason = RendezvousFailureReason.Unknown; } await this.cancel(reason); - return undefined; + return; + } + + // unexpected payload + if (type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); + return; } + return protocol; + } + + /** + * + * @returns true if the protocol was received successfully, false otherwise + */ + private async handleV2ProtocolPayload({ + type, + protocol, + outcome, + reason, + intent, + }: MSC3906RendezvousPayload): Promise { // v2 flow if (type === PayloadType.Failure) { // new device decided not to complete @@ -206,28 +256,16 @@ export class MSC3906Rendezvous { failureReason = RendezvousFailureReason.Unknown; } await this.cancel(failureReason); - return undefined; - } - - // v1 unexpected payload - if (this.v1FallbackEnabled && type !== PayloadType.Progress) { - await this.cancel(RendezvousFailureReason.Unknown); - return undefined; + return; } - // v2 unexpected payload - if (!this.v1FallbackEnabled && type !== PayloadType.Protocol) { + // unexpected payload + if (type !== PayloadType.Protocol) { await this.cancel(RendezvousFailureReason.Unknown); - return undefined; - } - - // invalid protocol - if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { - await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); - return undefined; + return; } - return checksum; + return protocol; } private async receive(): Promise { From b6699ba4c3463c84589d12cde18a25ed502c56b1 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 9 Mar 2023 17:32:06 -0500 Subject: [PATCH 8/9] Make default be v1 --- spec/unit/rendezvous/rendezvousv1.spec.ts | 23 ++++++++++++++--------- spec/unit/rendezvous/rendezvousv2.spec.ts | 14 ++++++++++++-- src/rendezvous/MSC3906Rendezvous.ts | 9 ++++----- src/rendezvous/RendezvousFlow.ts | 6 +++++- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/spec/unit/rendezvous/rendezvousv1.spec.ts b/spec/unit/rendezvous/rendezvousv1.spec.ts index 45b84aa802a..6243d8f6941 100644 --- a/spec/unit/rendezvous/rendezvousv1.spec.ts +++ b/spec/unit/rendezvous/rendezvousv1.spec.ts @@ -127,7 +127,7 @@ describe("RendezvousV1", function () { fetchFn, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); expect(aliceRz.code).toBeUndefined(); @@ -183,7 +183,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -237,7 +237,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -298,7 +298,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -358,7 +358,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -416,7 +416,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -474,7 +474,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -534,7 +534,7 @@ describe("RendezvousV1", function () { msc3886Enabled: false, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, true); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -608,7 +608,12 @@ describe("RendezvousV1", function () { }, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice, undefined, startInV1FallbackMode); + const aliceRz = new MSC3906Rendezvous( + aliceEcdh, + alice, + undefined, + startInV1FallbackMode ? "org.matrix.msc3906.v1" : "org.matrix.msc3906.setup.additional_device.v2", + ); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; diff --git a/spec/unit/rendezvous/rendezvousv2.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts index 3221abff6a5..b272c2908e4 100644 --- a/spec/unit/rendezvous/rendezvousv2.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -130,7 +130,12 @@ describe("RendezvousV2", function () { ...aliceOpts, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + const aliceRz = new MSC3906Rendezvous( + aliceEcdh, + alice, + undefined, + "org.matrix.msc3906.setup.additional_device.v2", + ); aliceTransport.onCancelled = aliceOnFailure; await aliceRz.generateCode(); const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode; @@ -219,7 +224,12 @@ describe("RendezvousV2", function () { fetchFn, }); const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport); - const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + const aliceRz = new MSC3906Rendezvous( + aliceEcdh, + alice, + undefined, + "org.matrix.msc3906.setup.additional_device.v2", + ); expect(aliceRz.code).toBeUndefined(); diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index b1e2c60dc79..a3ead60012a 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -22,7 +22,7 @@ import { RendezvousFailureReason, RendezvousFlow, RendezvousIntent, - SETUP_ADDITIONAL_DEVICE_FLOW_V2, + SETUP_ADDITIONAL_DEVICE_FLOW_V1, } from "."; import { MatrixClient } from "../client"; import { CrossSigningInfo } from "../crypto/CrossSigning"; @@ -94,7 +94,6 @@ export class MSC3906Rendezvous { private newDeviceId?: string; private newDeviceKey?: string; private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; - private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V2.name; private v1FallbackEnabled: boolean; private _code?: string; @@ -102,15 +101,15 @@ export class MSC3906Rendezvous { * @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 - * @param startInV1FallbackMode - Whether to start in v1 fallback mode + * @param flow - The flow to use. Defaults to MSC3906 v1 for backwards compatibility. */ public constructor( private channel: RendezvousChannel, private client: MatrixClient, public onFailure?: RendezvousFailureListener, - startInV1FallbackMode = false, + private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V1, ) { - this.v1FallbackEnabled = startInV1FallbackMode ?? false; + this.v1FallbackEnabled = flow === SETUP_ADDITIONAL_DEVICE_FLOW_V1; } /** diff --git a/src/rendezvous/RendezvousFlow.ts b/src/rendezvous/RendezvousFlow.ts index 4431a47ddfe..018cb240991 100644 --- a/src/rendezvous/RendezvousFlow.ts +++ b/src/rendezvous/RendezvousFlow.ts @@ -16,11 +16,15 @@ limitations under the License. import { UnstableValue } from "../NamespacedValue"; +export const SETUP_ADDITIONAL_DEVICE_FLOW_V1 = "org.matrix.msc3906.v1"; + export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue( "m.setup.additional_device.v2", "org.matrix.msc3906.setup.additional_device.v2", ); +// v1 is never included in the JSON, but we give it a name for the sake of determining the flow to use export type RendezvousFlow = | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name - | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName; + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName + | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V1; From c4ea55b046eb1bb4133755d5c56862a0e43c1951 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 5 Apr 2023 18:14:57 +0100 Subject: [PATCH 9/9] Apply feedback from review --- spec/unit/rendezvous/rendezvousv2.spec.ts | 4 +- src/rendezvous/MSC3906Rendezvous.ts | 131 +++++++++++++++------- src/rendezvous/RendezvousFlow.ts | 27 +++-- 3 files changed, 111 insertions(+), 51 deletions(-) diff --git a/spec/unit/rendezvous/rendezvousv2.spec.ts b/spec/unit/rendezvous/rendezvousv2.spec.ts index b272c2908e4..daea359f0e8 100644 --- a/spec/unit/rendezvous/rendezvousv2.spec.ts +++ b/spec/unit/rendezvous/rendezvousv2.spec.ts @@ -22,7 +22,7 @@ import { RendezvousCode, RendezvousFailureReason, RendezvousIntent, - SETUP_ADDITIONAL_DEVICE_FLOW_V2, + SETUP_ADDITIONAL_DEVICE_FLOW, } from "../../../src/rendezvous"; import { ECDHv2RendezvousCode as ECDHRendezvousCode, @@ -245,7 +245,7 @@ describe("RendezvousV2", function () { const code = JSON.parse(aliceRz.code!) as RendezvousCode; expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE); - expect(code.flow).toEqual(SETUP_ADDITIONAL_DEVICE_FLOW_V2.name); + expect(code.flow).toEqual(SETUP_ADDITIONAL_DEVICE_FLOW.name); expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"); expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1"); expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual( diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index a3ead60012a..79b7751335e 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -31,18 +31,48 @@ import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature"; import { logger } from "../logger"; import { sleep } from "../utils"; +/** + * These are the possible types of payload that are used in + * [MSC3906](https://github.com/matrix-org/matrix-spec-proposals/pull/3906) payloads. + * The values are used in the `type` field. + */ enum PayloadType { /** * @deprecated Only used in MSC3906 v1 */ Finish = "m.login.finish", + /** + * Indicates that a new device is ready to proceed with the setup process. + */ Progress = "m.login.progress", + /** + * Used by the new device to indicate which protocol to use. + */ Protocol = "m.login.protocol", + /** + * Used for the new device to indicate which protocols are supported by the existing device and + * homeserver. + */ Protocols = "m.login.protocols", + /** + * Indicates that the sign of the new device was approved by the user on the existing device. + */ Approved = "m.login.approved", + /** + * Indicates that the new device has signed in successfully. + */ Success = "m.login.success", + /** + * Indicates that the new device has been successfully verified by the existing device. + */ Verified = "m.login.verified", + /** + * Indicates that the login failed. + */ Failure = "m.login.failure", + /** + * Indicates that the user declined the login on the existing device. + */ Declined = "m.login.declined", } @@ -57,6 +87,9 @@ enum Outcome { Unsupported = "unsupported", } +/** + * Used in the `reason` field of the `m.login.failure` payload. + */ enum FailureReason { Cancelled = "cancelled", Unsupported = "unsupported", @@ -64,7 +97,11 @@ enum FailureReason { IncompatibleIntent = "incompatible_intent", } +/** + * This represents an [MSC3906](https://github.com/matrix-org/matrix-spec-proposals/pull/3906) payload. + */ export interface MSC3906RendezvousPayload { + /** The type of the payload */ type: PayloadType; intent?: RendezvousIntent; /** @@ -83,25 +120,35 @@ export interface MSC3906RendezvousPayload { homeserver?: string; } +/** + * Represents the use of an `m.login.token` obtained from an existing device to sign in on a new device. + */ const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc3906.login_token"); /** - * Implements MSC3906 to allow a user to sign in on a new device using QR code. - * This implementation only supports generating a QR code on a device that is already signed in. + * This class can be used to complete a "rendezvous flow" as defined in MSC3906. + * + * Currently it only supports being used on a device that is already signed in that wishes to help sign in + * another device. + * * Note that this is UNSTABLE and may have breaking changes without notice. */ export class MSC3906Rendezvous { private newDeviceId?: string; private newDeviceKey?: string; private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; - private v1FallbackEnabled: boolean; + // if true then we follow the v1 flow, otherwise we follow the v2 flow + private usingV1Flow: boolean; private _code?: string; /** - * @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 - * @param flow - The flow to use. Defaults to MSC3906 v1 for backwards compatibility. + * Creates an instance that can be used to manage the execution of a rendezvous flow. + * + * @param channel - The rendezvous channel that should be used for communication with the other device + * @param client - The Matrix client that should be used. + * @param onFailure - Optional callback function to be notified of rendezvous failures. + * @param flow - The rendezvous flow to use. Defaults to setting up an additional device using MSC3906 v1, + * for backwards compatibility. */ public constructor( private channel: RendezvousChannel, @@ -109,31 +156,36 @@ export class MSC3906Rendezvous { public onFailure?: RendezvousFailureListener, private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V1, ) { - this.v1FallbackEnabled = flow === SETUP_ADDITIONAL_DEVICE_FLOW_V1; + this.usingV1Flow = flow === SETUP_ADDITIONAL_DEVICE_FLOW_V1; } /** - * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. + * @returns The code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. */ public get code(): string | undefined { return this._code; } /** - * Generate the code including doing partial set up of the channel where required. + * Generate the code including doing partial set up of the channel where required. This code could be encoded in a QR. */ public async generateCode(): Promise { if (this._code) { return; } - const raw = this.v1FallbackEnabled + const raw = this.usingV1Flow ? await this.channel.generateCode(this.ourIntent) : await this.channel.generateCode(this.ourIntent, this.flow); this._code = JSON.stringify(raw); } /** + * Call this after the code has been shown to the user (perhaps in a QR). It will poll for the other device + * at the rendezvous point and start the process of setting up the new device. + * + * If successful then the user should be asked to approve the login of the other device whilst displaying the + * returned checksum code which the user should verify matches the code shown on the other device. * * @returns the checksum of the secure channel if the rendezvous set up was successful, otherwise undefined */ @@ -147,7 +199,7 @@ export class MSC3906Rendezvous { if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) { logger.info("Server doesn't support MSC3882"); await this.send( - this.v1FallbackEnabled + this.usingV1Flow ? { type: PayloadType.Finish, outcome: Outcome.Unsupported } : { type: PayloadType.Failure, reason: FailureReason.Unsupported }, ); @@ -156,16 +208,21 @@ export class MSC3906Rendezvous { } await this.send({ - type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Protocols, + type: this.usingV1Flow ? PayloadType.Progress : PayloadType.Protocols, protocols: [LOGIN_TOKEN_PROTOCOL.name], }); logger.info("Waiting for other device to chose protocol"); const nextPayload = await this.receive(); - this.checkForV1Fallback(nextPayload); + // even if we didn't start in v1 mode we might detect that the other device is v1: + // - the finish payload is only used in v1 + // - a progress payload is only sent at this point in v1, in v2 the use of it is different + if (nextPayload.type === PayloadType.Finish || nextPayload.type === PayloadType.Progress) { + this.usingV1Flow = true; + } - const protocol = this.v1FallbackEnabled + const protocol = this.usingV1Flow ? await this.handleV1ProtocolPayload(nextPayload) : await this.handleV2ProtocolPayload(nextPayload); @@ -178,18 +235,6 @@ export class MSC3906Rendezvous { return checksum; } - private checkForV1Fallback({ type }: MSC3906RendezvousPayload): void { - // even if we didn't start in v1 fallback we might detect that the other device is v1 - if (type === PayloadType.Finish || type === PayloadType.Progress) { - // this is a PDU from a v1 flow so use fallback mode - this.v1FallbackEnabled = true; - } - } - - /** - * - * @returns true if the protocol was received successfully, false otherwise - */ private async handleV1ProtocolPayload({ type, protocol, @@ -223,10 +268,6 @@ export class MSC3906Rendezvous { return protocol; } - /** - * - * @returns true if the protocol was received successfully, false otherwise - */ private async handleV2ProtocolPayload({ type, protocol, @@ -275,18 +316,26 @@ export class MSC3906Rendezvous { await this.channel.send(payload); } + /** + * Call this if the user has declined the login. + */ public async declineLoginOnExistingDevice(): Promise { logger.info("User declined sign in"); await this.send( - this.v1FallbackEnabled - ? { type: PayloadType.Finish, outcome: Outcome.Declined } - : { type: PayloadType.Declined }, + this.usingV1Flow ? { type: PayloadType.Finish, outcome: Outcome.Declined } : { type: PayloadType.Declined }, ); } + /** + * Call this if the user has approved the login. + * + * @param loginToken - the login token to send to the new device for it to complete the login flow + * @returns if the new device successfully completed the login flow and provided their device id then the device id is + * returned, otherwise undefined + */ public async approveLoginOnExistingDevice(loginToken: string): Promise { await this.channel.send({ - type: this.v1FallbackEnabled ? PayloadType.Progress : PayloadType.Approved, + type: this.usingV1Flow ? PayloadType.Progress : PayloadType.Approved, login_token: loginToken, homeserver: this.client.baseUrl, }); @@ -298,10 +347,7 @@ export class MSC3906Rendezvous { } const { type, outcome, device_id: deviceId, device_key: deviceKey } = res; - if ( - (this.v1FallbackEnabled && outcome !== "success") || - (!this.v1FallbackEnabled && type !== PayloadType.Success) - ) { + if ((this.usingV1Flow && outcome !== "success") || (!this.usingV1Flow && type !== PayloadType.Success)) { throw new Error("Linking failed"); } @@ -339,8 +385,8 @@ export class MSC3906Rendezvous { const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!; await this.send({ - type: this.v1FallbackEnabled ? PayloadType.Finish : PayloadType.Verified, - outcome: this.v1FallbackEnabled ? Outcome.Verified : undefined, + type: this.usingV1Flow ? PayloadType.Finish : PayloadType.Verified, + outcome: this.usingV1Flow ? Outcome.Verified : undefined, verifying_device_id: this.client.getDeviceId()!, verifying_device_key: this.client.getDeviceEd25519Key()!, master_key: masterPublicKey, @@ -350,7 +396,8 @@ export class MSC3906Rendezvous { } /** - * Verify the device and cross-sign it. + * Wait for a device to be visible via the homeserver and then verify/cross-sign it. + * * @param timeout - time in milliseconds to wait for device to come online * @returns the new device info if the device was verified */ diff --git a/src/rendezvous/RendezvousFlow.ts b/src/rendezvous/RendezvousFlow.ts index 018cb240991..29fe8f85bff 100644 --- a/src/rendezvous/RendezvousFlow.ts +++ b/src/rendezvous/RendezvousFlow.ts @@ -16,15 +16,28 @@ limitations under the License. import { UnstableValue } from "../NamespacedValue"; -export const SETUP_ADDITIONAL_DEVICE_FLOW_V1 = "org.matrix.msc3906.v1"; - -export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue( - "m.setup.additional_device.v2", +/** + * A rendezvous flow which allows a user to set up a new device with the help of an existing device. + * It is described in [MSC3906](https://github.com/matrix-org/matrix-spec-proposals/pull/3906) + */ +export const SETUP_ADDITIONAL_DEVICE_FLOW = new UnstableValue( + "m.setup.additional_device", "org.matrix.msc3906.setup.additional_device.v2", ); -// v1 is never included in the JSON, but we give it a name for the sake of determining the flow to use +/** + * Used to represent an older "v1" revision of the MSC3906 rendezvous flow to setup a new device. + * + * @deprecated Use MSC3906 v2 using {@link SETUP_ADDITIONAL_DEVICE_FLOW} instead. + */ +export const SETUP_ADDITIONAL_DEVICE_FLOW_V1 = "org.matrix.msc3906.v1"; + +/** + * Used to identify a rendezvous flow that is being used. The identifier is transmitted in a QR code or + * some other mechanism that is convenient to the user. + */ export type RendezvousFlow = - | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name - | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName + | typeof SETUP_ADDITIONAL_DEVICE_FLOW.name + | typeof SETUP_ADDITIONAL_DEVICE_FLOW.altName + // v1 is never included in the JSON, but we give it a name for the sake of determining the flow to use | typeof SETUP_ADDITIONAL_DEVICE_FLOW_V1;