1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-25 05:23:13 +03:00

Support sign in + E2EE set up using QR code implementing MSC3886, MSC3903 and MSC3906 (#2747)

* Clean implementation of MSC3886 and MSC3903

* Refactor to use object initialiser instead of lots of args + handle non-compliant fetch better

* Start of some unit tests

* Make AES work on Node.js as well as browser

* Tests for ECDH/X25519

* stric mode linting

* Fix incorrect test

* Refactor full rendezvous logic out of react-sdk into js-sdk

* Use correct unstable import

* Pass fetch around

* Make correct usage of fetch in tests

* fix: you can't call fetch when it's not on window

* Use class names to make it clearer that these are unstable MSC implementations

* Linting

* Clean implementation of MSC3886 and MSC3903

* Refactor to use object initialiser instead of lots of args + handle non-compliant fetch better

* Start of some unit tests

* Make AES work on Node.js as well as browser

* Tests for ECDH/X25519

* stric mode linting

* Fix incorrect test

* Refactor full rendezvous logic out of react-sdk into js-sdk

* Use correct unstable import

* Pass fetch around

* Make correct usage of fetch in tests

* fix: you can't call fetch when it's not on window

* Use class names to make it clearer that these are unstable MSC implementations

* Linting

* Reduce log noise

* Tidy up interface a bit

* Additional test for transport layer

* Linting

* Refactor dummy transport to be re-usable

* Remove redundant condition

* Handle more error cases

* Initial tests for MSC3906

* Reduce scope of PR to only cover generating a code on existing device

* Strict linting

* Additional test cases

* Lint

* additional test cases and remove some code smells

* More test cases

* Strict lint

* Strict lint

* Test case

* Refactor to handle UIA

* Unstable prefixes

* Lint

* Missed due to lack of strict...

* Test server capabilities using Feature

* Remove redundant assignment

* Refactor ro resuse generateDecimal from SAS

* Update src/rendezvous/transports/simpleHttpTransport.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/rendezvous/transports/simpleHttpTransport.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/rendezvous/channels/ecdhV1.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Update src/rendezvous/transports/simpleHttpTransport.ts

Co-authored-by: Travis Ralston <travisr@matrix.org>

* Rename files to titlecase

* Visibility modifiers

* Resolve public mutability

* Refactor logic to reduce duplication

* Refactor to have better defined data types throughout

* Rebase and remove Node.js crypto

* Wipe AES key out after use

* Add typing for MSC3906 layer

* Strict lint

* Fix double connect detection

* Remove unintended debug statement

* Return types

* Use generics

* Make type of MSC3903ECDHPayload explicit

* Use unstable prefix for RendezvousChannelAlgorithm

* Fix

* Extra unstable type

* Test types

Co-authored-by: Travis Ralston <travisr@matrix.org>
Co-authored-by: Kerry <kerrya@element.io>
This commit is contained in:
Hugh Nimmo-Smith
2022-10-19 10:30:15 +01:00
committed by GitHub
parent 7ffdf17213
commit 2464a691ef
19 changed files with 2357 additions and 16 deletions

View File

@@ -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<D extends RendezvousTransportDetails, T> implements RendezvousTransport<T> {
otherParty?: DummyTransport<D, T>;
etag?: string;
lastEtagReceived?: string;
data: T | undefined;
ready = false;
cancelled = false;
constructor(private name: string, private mockDetails: D) {}
onCancelled?: RendezvousFailureListener;
details(): Promise<RendezvousTransportDetails> {
return Promise.resolve(this.mockDetails);
}
async send(data: T): Promise<void> {
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<T | undefined> {
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<void> {
this.cancelled = true;
this.onCancelled?.(reason);
return Promise.resolve();
}
cleanup() {
this.cancelled = true;
}
}

View File

@@ -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<any, MSC3903ECDHPayload>(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);
});
});
});

View File

@@ -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<string, Partial<DeviceInfo>>;
verificationFunction?: (
userId: string, deviceId: string, verified: boolean, blocked: boolean, known: boolean,
) => void;
crossSigningIds?: Record<string, string>;
}): 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<any, MSC3903ECDHPayload>(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<any, MSC3903ECDHPayload>[];
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<string, Partial<DeviceInfo>>) {
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<string, Partial<DeviceInfo>> = {};
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<string, Partial<DeviceInfo>> = {};
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/);
});
});

View File

@@ -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);
}
});
});

View File

@@ -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[] = [

View File

@@ -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,
];
}

View File

@@ -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<string, FeatureSupportCondition> = {
unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"],
matrixVersion: "v1.4",
},
[Feature.LoginTokenRequest]: {
unstablePrefixes: ["org.matrix.msc3882"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {

View File

@@ -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<MSC3906RendezvousPayload>,
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<void> {
if (this._code) {
return;
}
this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent));
}
public async startAfterShowingCode(): Promise<string | undefined> {
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<MSC3906RendezvousPayload> {
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<string | undefined> {
// 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<CrossSigningInfo | DeviceInfo> {
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<DeviceInfo | CrossSigningInfo | undefined> {
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<void> {
this.onFailure?.(reason);
await this.channel.cancel(reason);
}
public async close(): Promise<void> {
await this.channel.close();
}
}

View File

@@ -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<T> {
/**
* @returns the checksum/confirmation digits to be shown to the user
*/
connect(): Promise<string>;
/**
* Send a payload via the channel.
* @param data payload to send
*/
send(data: T): Promise<void>;
/**
* Receive a payload from the channel.
* @returns the received payload
*/
receive(): Promise<Partial<T> | undefined>;
/**
* Close the channel and clear up any resources.
*/
close(): Promise<void>;
/**
* @returns a representation of the channel that can be encoded in a QR or similar
*/
generateCode(intent: RendezvousIntent): Promise<RendezvousCode>;
cancel(reason: RendezvousFailureReason): Promise<void>;
}

View File

@@ -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;
};
}

View File

@@ -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);
}
}

View File

@@ -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',
}

View File

@@ -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",
}

View File

@@ -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<T> {
/**
* 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<RendezvousTransportDetails>;
/**
* Send data via the transport.
* @param data the data itself
*/
send(data: T): Promise<void>;
/**
* Receive data from the transport.
*/
receive(): Promise<Partial<T> | undefined>;
/**
* Cancel the rendezvous. This will call `onCancelled()` if it is set.
* @param reason the reason for the cancellation/failure
*/
cancel(reason: RendezvousFailureReason): Promise<void>;
}

View File

@@ -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<CryptoKey> {
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<T> implements RendezvousChannel<T> {
private olmSAS?: SAS;
private ourPublicKey: Uint8Array;
private aesKey?: CryptoKey;
private connected = false;
public constructor(
private transport: RendezvousTransport<MSC3903ECDHPayload>,
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<ECDHv1RendezvousCode> {
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<string> {
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<PlainTextPayload>;
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<MSC3903ECDHPayload> {
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<void> {
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<Partial<T>> {
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<Partial<T> | 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<EncryptedPayload>;
if (data.ciphertext && data.iv) {
return this.decrypt(data as EncryptedPayload);
}
throw new Error('Data received but no ciphertext');
}
public async close(): Promise<void> {
if (this.olmSAS) {
this.olmSAS.free();
this.olmSAS = undefined;
}
}
public async cancel(reason: RendezvousFailureReason): Promise<void> {
try {
await this.transport.cancel(reason);
} finally {
await this.close();
}
}
}

View File

@@ -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';

23
src/rendezvous/index.ts Normal file
View File

@@ -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';

View File

@@ -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<T extends {}> implements RendezvousTransport<T> {
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<MSC3886SimpleHttpRendezvousTransportDetails> {
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<typeof global.fetch> {
if (this.fetchFn) {
return this.fetchFn(resource, options);
}
return global.fetch(resource, options);
}
private async getPostEndpoint(): Promise<string | undefined> {
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<void> {
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<string, string> = { '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<Partial<T> | 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<string, string> = {};
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<void> {
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);
}
}
}
}

View File

@@ -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';