diff --git a/spec/unit/rendezvous/DummyTransport.ts b/spec/unit/rendezvous/DummyTransport.ts new file mode 100644 index 00000000000..617b0380c71 --- /dev/null +++ b/spec/unit/rendezvous/DummyTransport.ts @@ -0,0 +1,92 @@ +/* +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 { logger } from "../../../src/logger"; +import { + RendezvousFailureListener, + RendezvousFailureReason, + RendezvousTransport, + RendezvousTransportDetails, +} from "../../../src/rendezvous"; +import { sleep } from '../../../src/utils'; + +export class DummyTransport implements RendezvousTransport { + otherParty?: DummyTransport; + etag?: string; + lastEtagReceived?: string; + data: T | undefined; + + ready = false; + cancelled = false; + + constructor(private name: string, private mockDetails: D) {} + onCancelled?: RendezvousFailureListener; + + details(): Promise { + return Promise.resolve(this.mockDetails); + } + + async send(data: T): Promise { + logger.info( + `[${this.name}] => [${this.otherParty?.name}] Attempting to send data: ${ + JSON.stringify(data)} where etag matches ${this.etag}`, + ); + // eslint-disable-next-line no-constant-condition + while (!this.cancelled) { + if (!this.etag || (this.otherParty?.etag && this.otherParty?.etag === this.etag)) { + this.data = data; + this.etag = Math.random().toString(); + this.lastEtagReceived = this.etag; + this.otherParty!.etag = this.etag; + this.otherParty!.data = data; + logger.info(`[${this.name}] => [${this.otherParty?.name}] Sent with etag ${this.etag}`); + return; + } + logger.info(`[${this.name}] Sleeping to retry send after etag ${this.etag}`); + await sleep(250); + } + } + + async receive(): Promise { + logger.info(`[${this.name}] Attempting to receive where etag is after ${this.lastEtagReceived}`); + // eslint-disable-next-line no-constant-condition + while (!this.cancelled) { + if (!this.lastEtagReceived || this.lastEtagReceived !== this.etag) { + this.lastEtagReceived = this.etag; + logger.info( + `[${this.otherParty?.name}] => [${this.name}] Received data: ` + + `${JSON.stringify(this.data)} with etag ${this.etag}`, + ); + return this.data; + } + logger.info(`[${this.name}] Sleeping to retry receive after etag ${ + this.lastEtagReceived} as remote is ${this.etag}`); + await sleep(250); + } + + return undefined; + } + + cancel(reason: RendezvousFailureReason): Promise { + this.cancelled = true; + this.onCancelled?.(reason); + return Promise.resolve(); + } + + cleanup() { + this.cancelled = true; + } +} diff --git a/spec/unit/rendezvous/ecdh.spec.ts b/spec/unit/rendezvous/ecdh.spec.ts new file mode 100644 index 00000000000..b8f40b52c3b --- /dev/null +++ b/spec/unit/rendezvous/ecdh.spec.ts @@ -0,0 +1,172 @@ +/* +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 '../../olm-loader'; +import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from '../../../src/rendezvous/channels'; +import { decodeBase64 } from '../../../src/crypto/olmlib'; +import { DummyTransport } from './DummyTransport'; + +function makeTransport(name: string) { + return new DummyTransport(name, { type: 'dummy' }); +} + +describe('ECDHv1', function() { + beforeAll(async function() { + await global.Olm.init(); + }); + + describe('with crypto', () => { + it("initiator wants to sign in", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + const message = { key: "xxx" }; + await alice.send(message); + const bobReceive = await bob.receive(); + expect(bobReceive).toEqual(message); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("initiator wants to reciprocate", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + const message = { key: "xxx" }; + await bob.send(message); + const aliceReceive = await alice.receive(); + expect(aliceReceive).toEqual(message); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("double connect", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + expect(alice.connect()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("closed", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + alice.close(); + + expect(alice.connect()).rejects.toThrow(); + expect(alice.send({})).rejects.toThrow(); + expect(alice.receive()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("require ciphertext", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + // send a message without encryption + await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" }); + expect(bob.receive()).rejects.toThrowError(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("ciphertext before set up", async function() { + const aliceTransport = makeTransport('Alice'); + const bobTransport = makeTransport('Bob'); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + + await bobTransport.send({ iv: "dummy", ciphertext: "dummy" }); + + expect(alice.receive()).rejects.toThrowError(); + + await alice.cancel(RendezvousFailureReason.Unknown); + }); + }); +}); diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvous.spec.ts new file mode 100644 index 00000000000..2e0f492c046 --- /dev/null +++ b/spec/unit/rendezvous/rendezvous.spec.ts @@ -0,0 +1,602 @@ +/* +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 { + ECDHv1RendezvousCode, + MSC3903ECDHPayload, + MSC3903ECDHv1RendezvousChannel, +} 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("Rendezvous", function() { + beforeAll(async function() { + await global.Olm.init(); + }); + + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetchFn; + 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 MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + + 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.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v1.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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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", 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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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 declines 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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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>) { + 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 MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure); + const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice); + aliceTransport.onCancelled = aliceOnFailure; + await aliceRz.generateCode(); + const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode; + + 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 MSC3903ECDHv1RendezvousChannel( + 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 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", + }, + }); + 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({}); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(); + }); + + it("device appears online within timeout", async function() { + const devices: Record> = {}; + const { aliceRz } = await completeLogin(devices); + // 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); + // device appears after 1 second + setTimeout(() => { + devices.BOB = { + getFingerprint: () => "bbbb", + }; + }, 1500); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(); + }); + + it("mismatched device key", async function() { + const { aliceRz } = await completeLogin({ + BOB: { + getFingerprint: () => "XXXX", + }, + }); + expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(/different key/); + }); +}); diff --git a/spec/unit/rendezvous/simpleHttpTransport.spec.ts b/spec/unit/rendezvous/simpleHttpTransport.spec.ts new file mode 100644 index 00000000000..9542cb1d1cd --- /dev/null +++ b/spec/unit/rendezvous/simpleHttpTransport.spec.ts @@ -0,0 +1,451 @@ +/* +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 type { MatrixClient } from "../../../src"; +import { RendezvousFailureReason } from "../../../src/rendezvous"; +import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports"; + +function makeMockClient(opts: { userId: string, deviceId: string, msc3886Enabled: boolean}): MatrixClient { + return { + doesServerSupportUnstableFeature(feature: string) { + return Promise.resolve(opts.msc3886Enabled && feature === "org.matrix.msc3886"); + }, + getUserId() { return opts.userId; }, + getDeviceId() { return opts.deviceId; }, + requestLoginToken() { + return Promise.resolve({ login_token: "token" }); + }, + baseUrl: "https://example.com", + } as unknown as MatrixClient; +} + +describe("SimpleHttpRendezvousTransport", function() { + let httpBackend: MockHttpBackend; + let fetchFn: typeof global.fetch; + + beforeEach(function() { + httpBackend = new MockHttpBackend(); + fetchFn = httpBackend.fetchFn as typeof global.fetch; + }); + + async function postAndCheckLocation( + msc3886Enabled: boolean, + fallbackRzServer: string, + locationResponse: string, + expectedFinalLocation: string, + ) { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fallbackRzServer, fetchFn }); + { // initial POST + const expectedPostLocation = msc3886Enabled ? + `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc3886/rendezvous` : + fallbackRzServer; + + const prom = simpleHttpTransport.send({}); + httpBackend.when("POST", expectedPostLocation).response = { + body: null, + response: { + statusCode: 201, + headers: { + location: locationResponse, + }, + }, + }; + await httpBackend.flush(''); + await prom; + } + const details = await simpleHttpTransport.details(); + expect(details.uri).toBe(expectedFinalLocation); + + { // first GET without etag + const prom = simpleHttpTransport.receive(); + httpBackend.when("GET", expectedFinalLocation).response = { + body: {}, + response: { + statusCode: 200, + headers: { + "content-type": "application/json", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toEqual({}); + httpBackend.verifyNoOutstandingRequests(); + httpBackend.verifyNoOutstandingExpectation(); + } + } + it("should throw an error when no server available", function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn }); + expect(simpleHttpTransport.send({})).rejects.toThrowError("Invalid rendezvous URI"); + }); + + it("POST to fallback server", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send({}); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toStrictEqual(undefined); + }); + + it("POST with no location", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send({}); + expect(prom).rejects.toThrowError(); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: {}, + }, + }; + await httpBackend.flush(''); + }); + + it("POST with absolute path response", async function() { + await postAndCheckLocation( + false, + "https://fallbackserver/rz", + "/123", + "https://fallbackserver/123", + ); + }); + + it("POST to built-in MSC3886 implementation", async function() { + await postAndCheckLocation( + true, + "https://fallbackserver/rz", + "123", + "https://example.com/_matrix/client/unstable/org.matrix.msc3886/rendezvous/123", + ); + }); + + it("POST with relative path response including parent", async function() { + await postAndCheckLocation( + false, + "https://fallbackserver/rz/abc", + "../xyz/123", + "https://fallbackserver/rz/xyz/123", + ); + }); + + it("POST with relative path response including parent", async function() { + await postAndCheckLocation( + false, + "https://fallbackserver/rz/abc", + "../xyz/123", + "https://fallbackserver/rz/xyz/123", + ); + }); + + it("POST to follow 307 to other server", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + const prom = simpleHttpTransport.send({}); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 307, + headers: { + location: "https://redirected.fallbackserver/rz", + }, + }, + }; + httpBackend.when("POST", "https://redirected.fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://redirected.fallbackserver/rz/123", + etag: "aaa", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toStrictEqual(undefined); + }); + + it("POST and GET", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { // initial POST + const prom = simpleHttpTransport.send({ foo: "baa" }); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { + expect(headers["content-type"]).toEqual("application/json"); + expect(data).toEqual({ foo: "baa" }); + }).response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toStrictEqual(undefined); + } + { // first GET without etag + const prom = simpleHttpTransport.receive(); + httpBackend.when("GET", "https://fallbackserver/rz/123").response = { + body: { foo: "baa" }, + response: { + statusCode: 200, + headers: { + "content-type": "application/json", + "etag": "aaa", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toEqual({ foo: "baa" }); + } + { // subsequent GET which should have etag from previous request + const prom = simpleHttpTransport.receive(); + httpBackend.when("GET", "https://fallbackserver/rz/123").check(({ headers }) => { + expect(headers["if-none-match"]).toEqual("aaa"); + }).response = { + body: { foo: "baa" }, + response: { + statusCode: 200, + headers: { + "content-type": "application/json", + "etag": "bbb", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toEqual({ foo: "baa" }); + } + }); + + it("POST and PUTs", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { // initial POST + const prom = simpleHttpTransport.send({ foo: "baa" }); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { + expect(headers["content-type"]).toEqual("application/json"); + expect(data).toEqual({ foo: "baa" }); + }).response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush('', 1); + await prom; + } + { // first PUT without etag + const prom = simpleHttpTransport.send({ a: "b" }); + httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers, data }) => { + expect(headers["if-match"]).toBeUndefined(); + expect(data).toEqual({ a: "b" }); + }).response = { + body: null, + response: { + statusCode: 202, + headers: { + "etag": "aaa", + }, + }, + }; + await httpBackend.flush('', 1); + await prom; + } + { // subsequent PUT which should have etag from previous request + const prom = simpleHttpTransport.send({ c: "d" }); + httpBackend.when("PUT", "https://fallbackserver/rz/123").check(({ headers }) => { + expect(headers["if-match"]).toEqual("aaa"); + }).response = { + body: null, + response: { + statusCode: 202, + headers: { + "etag": "bbb", + }, + }, + }; + await httpBackend.flush('', 1); + await prom; + } + }); + + it("POST and DELETE", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + { // Create + const prom = simpleHttpTransport.send({ foo: "baa" }); + httpBackend.when("POST", "https://fallbackserver/rz").check(({ headers, data }) => { + expect(headers["content-type"]).toEqual("application/json"); + expect(data).toEqual({ foo: "baa" }); + }).response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + }, + }, + }; + await httpBackend.flush(''); + expect(await prom).toStrictEqual(undefined); + } + { // Cancel + const prom = simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + httpBackend.when("DELETE", "https://fallbackserver/rz/123").response = { + body: null, + response: { + statusCode: 204, + headers: {}, + }, + }; + await httpBackend.flush(''); + await prom; + } + }); + + it("details before ready", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + expect(simpleHttpTransport.details()).rejects.toThrowError(); + }); + + it("send after cancelled", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined); + expect(simpleHttpTransport.send({})).resolves.toBeUndefined(); + }); + + it("receive before ready", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + }); + expect(simpleHttpTransport.receive()).rejects.toThrowError(); + }); + + it("404 failure callback", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const onFailure = jest.fn(); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + onFailure, + }); + + expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined(); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 404, + headers: {}, + }, + }; + await httpBackend.flush('', 1); + expect(onFailure).toBeCalledWith(RendezvousFailureReason.Unknown); + }); + + it("404 failure callback mapped to expired", async function() { + const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false }); + const onFailure = jest.fn(); + const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ + client, + fallbackRzServer: "https://fallbackserver/rz", + fetchFn, + onFailure, + }); + + { // initial POST + const prom = simpleHttpTransport.send({ foo: "baa" }); + httpBackend.when("POST", "https://fallbackserver/rz").response = { + body: null, + response: { + statusCode: 201, + headers: { + location: "https://fallbackserver/rz/123", + expires: "Thu, 01 Jan 1970 00:00:00 GMT", + }, + }, + }; + await httpBackend.flush(''); + await prom; + } + { // GET with 404 to simulate expiry + expect(simpleHttpTransport.receive()).resolves.toBeUndefined(); + httpBackend.when("GET", "https://fallbackserver/rz/123").response = { + body: { foo: "baa" }, + response: { + statusCode: 404, + headers: {}, + }, + }; + await httpBackend.flush(''); + expect(onFailure).toBeCalledWith(RendezvousFailureReason.Expired); + } + }); +}); diff --git a/src/crypto/verification/SAS.ts b/src/crypto/verification/SAS.ts index 53c0331cf71..5d418da7964 100644 --- a/src/crypto/verification/SAS.ts +++ b/src/crypto/verification/SAS.ts @@ -32,6 +32,7 @@ import { } from './Error'; import { logger } from '../../logger'; import { IContent, MatrixEvent } from "../../models/event"; +import { generateDecimalSas } from './SASDecimal'; import { EventType } from '../../@types/event'; const START_TYPE = EventType.KeyVerificationStart; @@ -52,22 +53,6 @@ const newMismatchedCommitmentError = errorFactory( "m.mismatched_commitment", "Mismatched commitment", ); -function generateDecimalSas(sasBytes: number[]): [number, number, number] { - /** - * +--------+--------+--------+--------+--------+ - * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | - * +--------+--------+--------+--------+--------+ - * bits: 87654321 87654321 87654321 87654321 87654321 - * \____________/\_____________/\____________/ - * 1st number 2nd number 3rd number - */ - return [ - (sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, - ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, - ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000, - ]; -} - type EmojiMapping = [emoji: string, name: string]; const emojiMapping: EmojiMapping[] = [ diff --git a/src/crypto/verification/SASDecimal.ts b/src/crypto/verification/SASDecimal.ts new file mode 100644 index 00000000000..c8fa73100e6 --- /dev/null +++ b/src/crypto/verification/SASDecimal.ts @@ -0,0 +1,37 @@ +/* +Copyright 2018 - 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. +*/ + +/** + * Implementation of decimal encoding of SAS as per: + * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal + * @param sasBytes the five bytes generated by HKDF + * @returns the derived three numbers between 1000 and 9191 inclusive + */ +export function generateDecimalSas(sasBytes: number[]): [number, number, number] { + /** + * +--------+--------+--------+--------+--------+ + * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | + * +--------+--------+--------+--------+--------+ + * bits: 87654321 87654321 87654321 87654321 87654321 + * \____________/\_____________/\____________/ + * 1st number 2nd number 3rd number + */ + return [ + (sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, + ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, + ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000, + ]; +} diff --git a/src/feature.ts b/src/feature.ts index 5d7a4922406..d617093961e 100644 --- a/src/feature.ts +++ b/src/feature.ts @@ -25,6 +25,7 @@ export enum ServerSupport { export enum Feature { Thread = "Thread", ThreadUnreadNotifications = "ThreadUnreadNotifications", + LoginTokenRequest = "LoginTokenRequest", } type FeatureSupportCondition = { @@ -41,6 +42,9 @@ const featureSupportResolver: Record = { unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"], matrixVersion: "v1.4", }, + [Feature.LoginTokenRequest]: { + unstablePrefixes: ["org.matrix.msc3882"], + }, }; export async function buildFeatureSupportMap(versions: IServerVersions): Promise> { diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts new file mode 100644 index 00000000000..a24216751df --- /dev/null +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -0,0 +1,269 @@ +/* +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 { UnstableValue } from "matrix-events-sdk"; + +import { RendezvousChannel } from "."; +import { MatrixClient } from "../client"; +import { CrossSigningInfo } from "../crypto/CrossSigning"; +import { DeviceInfo } from "../crypto/deviceinfo"; +import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature"; +import { logger } from "../logger"; +import { sleep } from "../utils"; +import { RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from "."; + +enum PayloadType { + Start = 'm.login.start', + Finish = 'm.login.finish', + Progress = 'm.login.progress', +} + +enum Outcome { + Success = 'success', + Failure = 'failure', + Verified = 'verified', + Declined = 'declined', + Unsupported = 'unsupported', +} + +export interface MSC3906RendezvousPayload { + type: PayloadType; + intent?: RendezvousIntent; + outcome?: Outcome; + device_id?: string; + device_key?: string; + verifying_device_id?: string; + verifying_device_key?: string; + master_key?: string; + protocols?: string[]; + protocol?: string; + login_token?: string; + homeserver?: string; +} + +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. + * 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 _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 + */ + public constructor( + private channel: RendezvousChannel, + private client: MatrixClient, + public onFailure?: RendezvousFailureListener, + ) {} + + /** + * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet. + */ + public get code(): string | undefined { + return this._code; + } + + /** + * Generate the code including doing partial set up of the channel where required. + */ + public async generateCode(): Promise { + if (this._code) { + return; + } + + this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent)); + } + + public async startAfterShowingCode(): Promise { + const checksum = await this.channel.connect(); + + logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`); + + const features = await buildFeatureSupportMap(await this.client.getVersions()); + // 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.cancel(RendezvousFailureReason.HomeserverLacksSupport); + return undefined; + } + + await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] }); + + logger.info('Waiting for other device to chose protocol'); + const { type, protocol, outcome } = await this.receive(); + + if (type === PayloadType.Finish) { + // new device decided not to complete + switch (outcome ?? '') { + case 'unsupported': + await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + break; + default: + await this.cancel(RendezvousFailureReason.Unknown); + } + return undefined; + } + + if (type !== PayloadType.Progress) { + await this.cancel(RendezvousFailureReason.Unknown); + return undefined; + } + + if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) { + await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm); + return undefined; + } + + return checksum; + } + + private async receive(): Promise { + return await this.channel.receive() as MSC3906RendezvousPayload; + } + + private async send(payload: MSC3906RendezvousPayload) { + await this.channel.send(payload); + } + + public async declineLoginOnExistingDevice() { + logger.info('User declined sign in'); + await this.send({ type: PayloadType.Finish, outcome: Outcome.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 }); + + logger.info('Waiting for outcome'); + const res = await this.receive(); + if (!res) { + return undefined; + } + const { outcome, device_id: deviceId, device_key: deviceKey } = res; + + if (outcome !== 'success') { + throw new Error('Linking failed'); + } + + this.newDeviceId = deviceId; + this.newDeviceKey = deviceKey; + + return deviceId; + } + + private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise { + if (!this.client.crypto) { + throw new Error('Crypto not available on client'); + } + + if (!this.newDeviceId) { + throw new Error('No new device ID set'); + } + + // check that keys received from the server for the new device match those received from the device itself + if (deviceInfo.getFingerprint() !== this.newDeviceKey) { + throw new Error( + `New device has different keys than expected: ${this.newDeviceKey} vs ${deviceInfo.getFingerprint()}`, + ); + } + + const userId = this.client.getUserId(); + + if (!userId) { + throw new Error('No user ID set'); + } + // mark the device as verified locally + cross sign + logger.info(`Marking device ${this.newDeviceId} as verified`); + const info = await this.client.crypto.setDeviceVerification( + userId, + this.newDeviceId, + true, false, true, + ); + + const masterPublicKey = this.client.crypto.crossSigningInfo.getId('master'); + + await this.send({ + type: PayloadType.Finish, + outcome: Outcome.Verified, + verifying_device_id: this.client.getDeviceId(), + verifying_device_key: this.client.getDeviceEd25519Key(), + master_key: masterPublicKey, + }); + + return info; + } + + /** + * Verify the device and 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 + */ + public async verifyNewDeviceOnExistingDevice( + timeout = 10 * 1000, + ): Promise { + if (!this.newDeviceId) { + throw new Error('No new device to sign'); + } + + if (!this.newDeviceKey) { + logger.info("No new device key to sign"); + return undefined; + } + + if (!this.client.crypto) { + throw new Error('Crypto not available on client'); + } + + const userId = this.client.getUserId(); + + if (!userId) { + throw new Error('No user ID set'); + } + + let deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId); + + if (!deviceInfo) { + logger.info("Going to wait for new device to be online"); + await sleep(timeout); + deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId); + } + + if (deviceInfo) { + return await this.verifyAndCrossSignDevice(deviceInfo); + } + + throw new Error('Device not online within timeout'); + } + + public async cancel(reason: RendezvousFailureReason): Promise { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + + public async close(): Promise { + await this.channel.close(); + } +} diff --git a/src/rendezvous/RendezvousChannel.ts b/src/rendezvous/RendezvousChannel.ts new file mode 100644 index 00000000000..9b1060304c9 --- /dev/null +++ b/src/rendezvous/RendezvousChannel.ts @@ -0,0 +1,52 @@ +/* +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 { + RendezvousCode, + RendezvousIntent, + RendezvousFailureReason, +} from "."; + +export interface RendezvousChannel { + /** + * @returns the checksum/confirmation digits to be shown to the user + */ + connect(): Promise; + + /** + * Send a payload via the channel. + * @param data payload to send + */ + send(data: T): Promise; + + /** + * Receive a payload from the channel. + * @returns the received payload + */ + receive(): Promise | undefined>; + + /** + * Close the channel and clear up any resources. + */ + close(): Promise; + + /** + * @returns a representation of the channel that can be encoded in a QR or similar + */ + generateCode(intent: RendezvousIntent): Promise; + + cancel(reason: RendezvousFailureReason): Promise; +} diff --git a/src/rendezvous/RendezvousCode.ts b/src/rendezvous/RendezvousCode.ts new file mode 100644 index 00000000000..86608aa1c44 --- /dev/null +++ b/src/rendezvous/RendezvousCode.ts @@ -0,0 +1,25 @@ +/* +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 { RendezvousTransportDetails, RendezvousIntent } from "."; + +export interface RendezvousCode { + intent: RendezvousIntent; + rendezvous?: { + transport: RendezvousTransportDetails; + algorithm: string; + }; +} diff --git a/src/rendezvous/RendezvousError.ts b/src/rendezvous/RendezvousError.ts new file mode 100644 index 00000000000..1c1f6af9c7c --- /dev/null +++ b/src/rendezvous/RendezvousError.ts @@ -0,0 +1,23 @@ +/* +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 { RendezvousFailureReason } from "."; + +export class RendezvousError extends Error { + constructor(message: string, public readonly code: RendezvousFailureReason) { + super(message); + } +} diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts new file mode 100644 index 00000000000..c23168dd154 --- /dev/null +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -0,0 +1,31 @@ +/* +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. +*/ + +export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void; + +export enum RendezvousFailureReason { + UserDeclined = 'user_declined', + OtherDeviceNotSignedIn = 'other_device_not_signed_in', + OtherDeviceAlreadySignedIn = 'other_device_already_signed_in', + Unknown = 'unknown', + Expired = 'expired', + UserCancelled = 'user_cancelled', + InvalidCode = 'invalid_code', + UnsupportedAlgorithm = 'unsupported_algorithm', + DataMismatch = 'data_mismatch', + UnsupportedTransport = 'unsupported_transport', + HomeserverLacksSupport = 'homeserver_lacks_support', +} diff --git a/src/rendezvous/RendezvousIntent.ts b/src/rendezvous/RendezvousIntent.ts new file mode 100644 index 00000000000..98b64ecb0ad --- /dev/null +++ b/src/rendezvous/RendezvousIntent.ts @@ -0,0 +1,20 @@ +/* +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. +*/ + +export enum RendezvousIntent { + LOGIN_ON_NEW_DEVICE = "login.start", + RECIPROCATE_LOGIN_ON_EXISTING_DEVICE = "login.reciprocate", +} diff --git a/src/rendezvous/RendezvousTransport.ts b/src/rendezvous/RendezvousTransport.ts new file mode 100644 index 00000000000..231a0f1c48a --- /dev/null +++ b/src/rendezvous/RendezvousTransport.ts @@ -0,0 +1,58 @@ +/* +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 { RendezvousFailureListener, RendezvousFailureReason } from "."; + +export interface RendezvousTransportDetails { + type: string; +} + +/** + * Interface representing a generic rendezvous transport. + */ +export interface RendezvousTransport { + /** + * Ready state of the transport. This is set to true when the transport is ready to be used. + */ + readonly ready: boolean; + + /** + * Listener for cancellation events. This is called when the rendezvous is cancelled or fails. + */ + onFailure?: RendezvousFailureListener; + + /** + * @returns the transport details that can be encoded in a QR or similar + */ + details(): Promise; + + /** + * Send data via the transport. + * @param data the data itself + */ + send(data: T): Promise; + + /** + * Receive data from the transport. + */ + receive(): Promise | undefined>; + + /** + * Cancel the rendezvous. This will call `onCancelled()` if it is set. + * @param reason the reason for the cancellation/failure + */ + cancel(reason: RendezvousFailureReason): Promise; +} diff --git a/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts new file mode 100644 index 00000000000..d3d5fc6807b --- /dev/null +++ b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts @@ -0,0 +1,265 @@ +/* +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 { SAS } from '@matrix-org/olm'; + +import { + RendezvousError, + RendezvousCode, + RendezvousIntent, + RendezvousChannel, + RendezvousTransportDetails, + RendezvousTransport, + RendezvousFailureReason, +} from '..'; +import { encodeBase64, decodeBase64 } from '../../crypto/olmlib'; +import { crypto, subtleCrypto, TextEncoder } from '../../crypto/crypto'; +import { generateDecimalSas } from '../../crypto/verification/SASDecimal'; +import { UnstableValue } from '../../NamespacedValue'; + +const ECDH_V1 = new UnstableValue( + "m.rendezvous.v1.curve25519-aes-sha256", + "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256", +); + +export interface ECDHv1RendezvousCode extends RendezvousCode { + rendezvous: { + transport: RendezvousTransportDetails; + algorithm: typeof ECDH_V1.name | typeof ECDH_V1.altName; + key: string; + }; +} + +export type MSC3903ECDHPayload = PlainTextPayload | EncryptedPayload; + +export interface PlainTextPayload { + algorithm: typeof ECDH_V1.name | typeof ECDH_V1.altName; + key?: string; +} + +export interface EncryptedPayload { + iv: string; + ciphertext: string; +} + +async function importKey(key: Uint8Array): Promise { + if (!subtleCrypto) { + throw new Error('Web Crypto is not available'); + } + + const imported = subtleCrypto.importKey( + 'raw', + key, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + + return imported; +} + +/** + * Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903) + * X25519/ECDH key agreement based secure rendezvous channel. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { + private olmSAS?: SAS; + private ourPublicKey: Uint8Array; + private aesKey?: CryptoKey; + private connected = false; + + public constructor( + private transport: RendezvousTransport, + private theirPublicKey?: Uint8Array, + public onFailure?: (reason: RendezvousFailureReason) => void, + ) { + this.olmSAS = new global.Olm.SAS(); + this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); + } + + public async generateCode(intent: RendezvousIntent): Promise { + if (this.transport.ready) { + throw new Error('Code already generated'); + } + + await this.transport.send({ algorithm: ECDH_V1.name }); + + const rendezvous: ECDHv1RendezvousCode = { + "rendezvous": { + algorithm: ECDH_V1.name, + key: encodeBase64(this.ourPublicKey), + transport: await this.transport.details(), + }, + intent, + }; + + return rendezvous; + } + + public async connect(): Promise { + if (this.connected) { + throw new Error('Channel already connected'); + } + + if (!this.olmSAS) { + throw new Error('Channel closed'); + } + + const isInitiator = !this.theirPublicKey; + + if (isInitiator) { + // wait for the other side to send us their public key + const rawRes = await this.transport.receive(); + if (!rawRes) { + throw new Error('No response from other device'); + } + const res = rawRes as Partial; + const { key, algorithm } = res; + if (!algorithm || !ECDH_V1.matches(algorithm) || !key) { + throw new RendezvousError( + 'Unsupported algorithm: ' + algorithm, + RendezvousFailureReason.UnsupportedAlgorithm, + ); + } + + this.theirPublicKey = decodeBase64(key); + } else { + // send our public key unencrypted + await this.transport.send({ + algorithm: ECDH_V1.name, + key: encodeBase64(this.ourPublicKey), + }); + } + + this.connected = true; + + this.olmSAS.set_their_key(encodeBase64(this.theirPublicKey!)); + + const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey!; + const recipientKey = isInitiator ? this.theirPublicKey! : this.ourPublicKey; + let aesInfo = ECDH_V1.name; + aesInfo += `|${encodeBase64(initiatorKey)}`; + aesInfo += `|${encodeBase64(recipientKey)}`; + + const aesKeyBytes = this.olmSAS.generate_bytes(aesInfo, 32); + + this.aesKey = await importKey(aesKeyBytes); + + // blank the bytes out to make sure not kept in memory + aesKeyBytes.fill(0); + + const rawChecksum = this.olmSAS.generate_bytes(aesInfo, 5); + return generateDecimalSas(Array.from(rawChecksum)).join('-'); + } + + private async encrypt(data: T): Promise { + if (!subtleCrypto) { + throw new Error('Web Crypto is not available'); + } + + const iv = new Uint8Array(32); + crypto.getRandomValues(iv); + + const encodedData = new TextEncoder().encode(JSON.stringify(data)); + + const ciphertext = await subtleCrypto.encrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + this.aesKey as CryptoKey, + encodedData, + ); + + return { + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + }; + } + + public async send(payload: T): Promise { + if (!this.olmSAS) { + throw new Error('Channel closed'); + } + + if (!this.aesKey) { + throw new Error('Shared secret not set up'); + } + + return this.transport.send((await this.encrypt(payload))); + } + + private async decrypt({ iv, ciphertext }: EncryptedPayload): Promise> { + if (!ciphertext || !iv) { + throw new Error('Missing ciphertext and/or iv'); + } + + const ciphertextBytes = decodeBase64(ciphertext); + + if (!subtleCrypto) { + throw new Error('Web Crypto is not available'); + } + + const plaintext = await subtleCrypto.decrypt( + { + name: "AES-GCM", + iv: decodeBase64(iv), + tagLength: 128, + }, + this.aesKey as CryptoKey, + ciphertextBytes, + ); + + return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext))); + } + + public async receive(): Promise | undefined> { + if (!this.olmSAS) { + throw new Error('Channel closed'); + } + if (!this.aesKey) { + throw new Error('Shared secret not set up'); + } + + const rawData = await this.transport.receive(); + if (!rawData) { + return undefined; + } + const data = rawData as Partial; + if (data.ciphertext && data.iv) { + return this.decrypt(data as EncryptedPayload); + } + + throw new Error('Data received but no ciphertext'); + } + + public async close(): Promise { + if (this.olmSAS) { + this.olmSAS.free(); + this.olmSAS = undefined; + } + } + + public async cancel(reason: RendezvousFailureReason): Promise { + try { + await this.transport.cancel(reason); + } finally { + await this.close(); + } + } +} diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts new file mode 100644 index 00000000000..f1139c0629a --- /dev/null +++ b/src/rendezvous/channels/index.ts @@ -0,0 +1,18 @@ +/* +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. +*/ + +export * from './MSC3903ECDHv1RendezvousChannel'; + diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts new file mode 100644 index 00000000000..7e506b45052 --- /dev/null +++ b/src/rendezvous/index.ts @@ -0,0 +1,23 @@ +/* +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. +*/ + +export * from './MSC3906Rendezvous'; +export * from './RendezvousChannel'; +export * from './RendezvousCode'; +export * from './RendezvousError'; +export * from './RendezvousFailureReason'; +export * from './RendezvousIntent'; +export * from './RendezvousTransport'; diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts new file mode 100644 index 00000000000..cffbefe5a26 --- /dev/null +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -0,0 +1,197 @@ +/* +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 { UnstableValue } from 'matrix-events-sdk'; + +import { logger } from '../../logger'; +import { sleep } from '../../utils'; +import { + RendezvousFailureListener, + RendezvousFailureReason, + RendezvousTransport, + RendezvousTransportDetails, +} from '..'; +import { MatrixClient } from '../../matrix'; +import { ClientPrefix } from '../../http-api'; + +const TYPE = new UnstableValue("http.v1", "org.matrix.msc3886.http.v1"); + +export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousTransportDetails { + uri: string; +} + +/** + * Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886) + * simple HTTP rendezvous protocol. + * Note that this is UNSTABLE and may have breaking changes without notice. + */ +export class MSC3886SimpleHttpRendezvousTransport implements RendezvousTransport { + private uri?: string; + private etag?: string; + private expiresAt?: Date; + private client: MatrixClient; + private fallbackRzServer?: string; + private fetchFn?: typeof global.fetch; + private cancelled = false; + private _ready = false; + public onFailure?: RendezvousFailureListener; + + public constructor({ + onFailure, + client, + fallbackRzServer, + fetchFn, + }: { + fetchFn?: typeof global.fetch; + onFailure?: RendezvousFailureListener; + client: MatrixClient; + fallbackRzServer?: string; + }) { + this.fetchFn = fetchFn; + this.onFailure = onFailure; + this.client = client; + this.fallbackRzServer = fallbackRzServer; + } + + public get ready(): boolean { + return this._ready; + } + + public async details(): Promise { + if (!this.uri) { + throw new Error('Rendezvous not set up'); + } + + return { + type: TYPE.name, + uri: this.uri, + }; + } + + private fetch(resource: URL | string, options?: RequestInit): ReturnType { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private async getPostEndpoint(): Promise { + try { + if (await this.client.doesServerSupportUnstableFeature('org.matrix.msc3886')) { + return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`; + } + } catch (err) { + logger.warn('Failed to get unstable features', err); + } + + return this.fallbackRzServer; + } + + public async send(data: T): Promise { + if (this.cancelled) { + return; + } + const method = this.uri ? "PUT" : "POST"; + const uri = this.uri ?? await this.getPostEndpoint(); + + if (!uri) { + throw new Error('Invalid rendezvous URI'); + } + + const headers: Record = { 'content-type': 'application/json' }; + if (this.etag) { + headers['if-match'] = this.etag; + } + + const res = await this.fetch(uri, { method, + headers, + body: JSON.stringify(data), + }); + if (res.status === 404) { + return this.cancel(RendezvousFailureReason.Unknown); + } + this.etag = res.headers.get("etag") ?? undefined; + + if (method === 'POST') { + const location = res.headers.get('location'); + if (!location) { + throw new Error('No rendezvous URI given'); + } + const expires = res.headers.get('expires'); + if (expires) { + this.expiresAt = new Date(expires); + } + // we would usually expect the final `url` to be set by a proper fetch implementation. + // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback + const baseUrl = res.url ?? uri; + // resolve location header which could be relative or absolute + this.uri = new URL(location, `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}`).href; + this._ready = true; + } + } + + public async receive(): Promise | undefined> { + if (!this.uri) { + throw new Error('Rendezvous not set up'); + } + // eslint-disable-next-line no-constant-condition + while (true) { + if (this.cancelled) { + return undefined; + } + + const headers: Record = {}; + if (this.etag) { + headers['if-none-match'] = this.etag; + } + const poll = await this.fetch(this.uri, { method: "GET", headers }); + + if (poll.status === 404) { + this.cancel(RendezvousFailureReason.Unknown); + return undefined; + } + + // rely on server expiring the channel rather than checking ourselves + + if (poll.headers.get('content-type') !== 'application/json') { + this.etag = poll.headers.get("etag") ?? undefined; + } else if (poll.status === 200) { + this.etag = poll.headers.get("etag") ?? undefined; + return poll.json(); + } + await sleep(1000); + } + } + + public async cancel(reason: RendezvousFailureReason): Promise { + if (reason === RendezvousFailureReason.Unknown && + this.expiresAt && this.expiresAt.getTime() < Date.now()) { + reason = RendezvousFailureReason.Expired; + } + + this.cancelled = true; + this._ready = false; + this.onFailure?.(reason); + + if (this.uri && reason === RendezvousFailureReason.UserDeclined) { + try { + await this.fetch(this.uri, { method: "DELETE" }); + } catch (e) { + logger.warn(e); + } + } + } +} diff --git a/src/rendezvous/transports/index.ts b/src/rendezvous/transports/index.ts new file mode 100644 index 00000000000..05594da4543 --- /dev/null +++ b/src/rendezvous/transports/index.ts @@ -0,0 +1,17 @@ +/* +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. +*/ + +export * from './MSC3886SimpleHttpRendezvousTransport';