You've already forked matrix-js-sdk
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:
92
spec/unit/rendezvous/DummyTransport.ts
Normal file
92
spec/unit/rendezvous/DummyTransport.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
172
spec/unit/rendezvous/ecdh.spec.ts
Normal file
172
spec/unit/rendezvous/ecdh.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
602
spec/unit/rendezvous/rendezvous.spec.ts
Normal file
602
spec/unit/rendezvous/rendezvous.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
451
spec/unit/rendezvous/simpleHttpTransport.spec.ts
Normal file
451
spec/unit/rendezvous/simpleHttpTransport.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
37
src/crypto/verification/SASDecimal.ts
Normal file
37
src/crypto/verification/SASDecimal.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
269
src/rendezvous/MSC3906Rendezvous.ts
Normal file
269
src/rendezvous/MSC3906Rendezvous.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
52
src/rendezvous/RendezvousChannel.ts
Normal file
52
src/rendezvous/RendezvousChannel.ts
Normal 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>;
|
||||
}
|
||||
25
src/rendezvous/RendezvousCode.ts
Normal file
25
src/rendezvous/RendezvousCode.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
23
src/rendezvous/RendezvousError.ts
Normal file
23
src/rendezvous/RendezvousError.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/rendezvous/RendezvousFailureReason.ts
Normal file
31
src/rendezvous/RendezvousFailureReason.ts
Normal 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',
|
||||
}
|
||||
20
src/rendezvous/RendezvousIntent.ts
Normal file
20
src/rendezvous/RendezvousIntent.ts
Normal 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",
|
||||
}
|
||||
58
src/rendezvous/RendezvousTransport.ts
Normal file
58
src/rendezvous/RendezvousTransport.ts
Normal 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>;
|
||||
}
|
||||
265
src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts
Normal file
265
src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/rendezvous/channels/index.ts
Normal file
18
src/rendezvous/channels/index.ts
Normal 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
23
src/rendezvous/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/rendezvous/transports/index.ts
Normal file
17
src/rendezvous/transports/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user