Skip to content

Commit 5e27b8c

Browse files
committed
Refactor full rendezvous logic out of react-sdk into js-sdk
1 parent b9b923e commit 5e27b8c

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed

src/rendezvous/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from './code';
2727
export * from './cancellationReason';
2828
export * from './transport';
2929
export * from './channel';
30+
export * from './rendezvous';
3031

3132
/**
3233
* Attempts to parse the given code as a rendezvous and return a channel and transport.

src/rendezvous/rendezvous.ts

+362
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { RendezvousChannel } from ".";
18+
import { LoginTokenPostResponse } from "../@types/auth";
19+
import { MatrixClient } from "../client";
20+
import { CrossSigningInfo, requestKeysDuringVerification } from "../crypto/CrossSigning";
21+
import { DeviceInfo } from "../crypto/deviceinfo";
22+
import { IAuthData } from "../interactive-auth";
23+
import { logger } from "../logger";
24+
import { createClient } from "../matrix";
25+
import { sleep } from "../utils";
26+
import { RendezvousFailureReason } from "./cancellationReason";
27+
import { RendezvousIntent } from "./code";
28+
29+
export enum PayloadType {
30+
Start = 'm.login.start',
31+
Finish = 'm.login.finish',
32+
Progress = 'm.login.progress',
33+
}
34+
35+
export class Rendezvous {
36+
private cli?: MatrixClient;
37+
private newDeviceId?: string;
38+
private newDeviceKey?: string;
39+
private ourIntent: RendezvousIntent;
40+
public code?: string;
41+
public onFailure?: (reason: RendezvousFailureReason) => void;
42+
43+
constructor(public channel: RendezvousChannel, cli?: MatrixClient) {
44+
this.cli = cli;
45+
this.ourIntent = this.isNewDevice ?
46+
RendezvousIntent.LOGIN_ON_NEW_DEVICE :
47+
RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
48+
}
49+
50+
async generateCode(): Promise<void> {
51+
if (this.code) {
52+
return;
53+
}
54+
55+
this.code = JSON.stringify(await this.channel.generateCode(this.ourIntent));
56+
}
57+
58+
private get isNewDevice(): boolean {
59+
return !this.cli;
60+
}
61+
62+
private async areIntentsIncompatible(theirIntent: RendezvousIntent): Promise<boolean> {
63+
const incompatible = theirIntent === this.ourIntent;
64+
65+
logger.info(`ourIntent: ${this.ourIntent}, theirIntent: ${theirIntent}, incompatible: ${incompatible}`);
66+
67+
if (incompatible) {
68+
await this.send({ type: PayloadType.Finish, intent: this.ourIntent });
69+
await this.channel.cancel(
70+
this.isNewDevice ?
71+
RendezvousFailureReason.OtherDeviceNotSignedIn :
72+
RendezvousFailureReason.OtherDeviceAlreadySignedIn,
73+
);
74+
}
75+
76+
return incompatible;
77+
}
78+
79+
async startAfterShowingCode(): Promise<string | undefined> {
80+
return this.start();
81+
}
82+
83+
async startAfterScanningCode(theirIntent: RendezvousIntent): Promise<string | undefined> {
84+
return this.start(theirIntent);
85+
}
86+
87+
private async start(theirIntent?: RendezvousIntent): Promise<string | undefined> {
88+
const didScan = !!theirIntent;
89+
90+
const checksum = await this.channel.connect();
91+
92+
logger.info(`Connected to secure channel with checksum: ${checksum}`);
93+
94+
if (didScan) {
95+
if (await this.areIntentsIncompatible(theirIntent)) {
96+
// a m.login.finish event is sent as part of areIntentsIncompatible
97+
return undefined;
98+
}
99+
}
100+
101+
if (this.cli) {
102+
if (didScan) {
103+
await this.channel.receive(); // wait for ack
104+
}
105+
106+
// determine available protocols
107+
if (!(await this.cli.doesServerSupportUnstableFeature('org.matrix.msc3882'))) {
108+
logger.info("Server doesn't support MSC3882");
109+
await this.send({ type: PayloadType.Finish, outcome: 'unsupported' });
110+
await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
111+
return undefined;
112+
}
113+
114+
await this.send({ type: PayloadType.Progress, protocols: ['login_token'] });
115+
116+
logger.info('Waiting for other device to chose protocol');
117+
const { type, protocol, outcome } = await this.channel.receive();
118+
119+
if (type === PayloadType.Finish) {
120+
// new device decided not to complete
121+
switch (outcome ?? '') {
122+
case 'unsupported':
123+
await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
124+
break;
125+
default:
126+
await this.cancel(RendezvousFailureReason.Unknown);
127+
}
128+
return undefined;
129+
}
130+
131+
if (type !== PayloadType.Progress) {
132+
await this.cancel(RendezvousFailureReason.Unknown);
133+
return undefined;
134+
}
135+
136+
if (protocol !== 'login_token') {
137+
await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
138+
return undefined;
139+
}
140+
} else {
141+
if (!didScan) {
142+
logger.info("Sending ack");
143+
await this.send({ type: PayloadType.Progress });
144+
}
145+
146+
logger.info("Waiting for protocols");
147+
const { protocols } = await this.channel.receive();
148+
149+
if (!Array.isArray(protocols) || !protocols.includes('login_token')) {
150+
await this.send({ type: PayloadType.Finish, outcome: 'unsupported' });
151+
await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
152+
return undefined;
153+
}
154+
155+
await this.send({ type: PayloadType.Progress, protocol: "login_token" });
156+
}
157+
158+
return checksum;
159+
}
160+
161+
async send({ type, ...payload }: { type: PayloadType, [key: string]: any }) {
162+
await this.channel.send({ type, ...payload });
163+
}
164+
165+
async completeLoginOnNewDevice(): Promise<{
166+
userId: string;
167+
deviceId: string;
168+
accessToken: string;
169+
homeserverUrl: string;
170+
} | undefined> {
171+
logger.info('Waiting for login_token');
172+
173+
// eslint-disable-next-line camelcase
174+
const { type, login_token: token, outcome, homeserver } = await this.channel.receive();
175+
176+
if (type === PayloadType.Finish) {
177+
switch (outcome ?? '') {
178+
case 'unsupported':
179+
await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
180+
break;
181+
default:
182+
await this.cancel(RendezvousFailureReason.Unknown);
183+
}
184+
return undefined;
185+
}
186+
187+
if (!homeserver) {
188+
throw new Error("No homeserver returned");
189+
}
190+
// eslint-disable-next-line camelcase
191+
if (!token) {
192+
throw new Error("No login token returned");
193+
}
194+
195+
const client = createClient({
196+
baseUrl: homeserver,
197+
});
198+
199+
const { device_id: deviceId, user_id: userId, access_token: accessToken } =
200+
await client.login("m.login.token", { token });
201+
202+
return {
203+
userId,
204+
deviceId,
205+
accessToken,
206+
homeserverUrl: homeserver,
207+
};
208+
}
209+
210+
async completeVerificationOnNewDevice(client: MatrixClient): Promise<void> {
211+
await this.send({
212+
type: PayloadType.Progress,
213+
outcome: 'success',
214+
device_id: client.getDeviceId(),
215+
device_key: client.getDeviceEd25519Key(),
216+
});
217+
218+
// await confirmation of verification
219+
const {
220+
verifying_device_id: verifyingDeviceId,
221+
master_key: masterKey,
222+
verifying_device_key: verifyingDeviceKey,
223+
} = await this.channel.receive();
224+
225+
const userId = client.getUserId()!;
226+
const verifyingDeviceFromServer =
227+
client.crypto.deviceList.getStoredDevice(userId, verifyingDeviceId);
228+
229+
if (verifyingDeviceFromServer?.getFingerprint() === verifyingDeviceKey) {
230+
// set other device as verified
231+
logger.info(`Setting device ${verifyingDeviceId} as verified`);
232+
await client.setDeviceVerified(userId, verifyingDeviceId, true);
233+
234+
if (masterKey) {
235+
// set master key as trusted
236+
await client.setDeviceVerified(userId, masterKey, true);
237+
}
238+
239+
// request secrets from the verifying device
240+
logger.info(`Requesting secrets from ${verifyingDeviceId}`);
241+
await requestKeysDuringVerification(client, userId, verifyingDeviceId);
242+
} else {
243+
logger.info(`Verifying device ${verifyingDeviceId} doesn't match: ${verifyingDeviceFromServer}`);
244+
}
245+
}
246+
247+
async declineLoginOnExistingDevice() {
248+
logger.info('User declined linking');
249+
await this.send({ type: PayloadType.Finish, outcome: 'declined' });
250+
}
251+
252+
async confirmLoginOnExistingDevice(): Promise<string | undefined> {
253+
const client = this.cli;
254+
255+
logger.info("Requesting login token");
256+
257+
const loginTokenResponse = await client.requestLoginToken();
258+
259+
if (typeof (loginTokenResponse as IAuthData).session === 'string') {
260+
// TODO: handle UIA response
261+
throw new Error("UIA isn't supported yet");
262+
}
263+
// eslint-disable-next-line camelcase
264+
const { login_token } = loginTokenResponse as LoginTokenPostResponse;
265+
266+
// eslint-disable-next-line camelcase
267+
await this.send({ type: PayloadType.Progress, login_token, homeserver: client.baseUrl });
268+
269+
logger.info('Waiting for outcome');
270+
const res = await this.channel.receive();
271+
if (!res) {
272+
return undefined;
273+
}
274+
const { outcome, device_id: deviceId, device_key: deviceKey } = res;
275+
276+
if (outcome !== 'success') {
277+
throw new Error('Linking failed');
278+
}
279+
280+
this.newDeviceId = deviceId;
281+
this.newDeviceKey = deviceKey;
282+
283+
return deviceId;
284+
}
285+
286+
private async checkAndCrossSignDevice(deviceInfo: DeviceInfo) {
287+
// check that keys received from the server for the new device match those received from the device itself
288+
if (deviceInfo.getFingerprint() !== this.newDeviceKey) {
289+
throw new Error(
290+
`New device has different keys than expected: ${this.newDeviceKey} vs ${deviceInfo.getFingerprint()}`,
291+
);
292+
}
293+
294+
// mark the device as verified locally + cross sign
295+
logger.info(`Marking device ${this.newDeviceId} as verified`);
296+
const info = await this.cli.crypto.setDeviceVerification(
297+
this.cli.getUserId(),
298+
this.newDeviceId,
299+
true, false, true,
300+
);
301+
302+
const masterPublicKey = this.cli.crypto.crossSigningInfo.getId('master');
303+
304+
await this.send({
305+
type: PayloadType.Finish,
306+
outcome: 'verified',
307+
verifying_device_id: this.cli.getDeviceId(),
308+
verifying_device_key: this.cli.getDeviceEd25519Key(),
309+
master_key: masterPublicKey,
310+
});
311+
312+
return info;
313+
}
314+
315+
async crossSign(timeout = 10 * 1000): Promise<DeviceInfo | CrossSigningInfo | undefined> {
316+
if (!this.newDeviceId) {
317+
throw new Error('No new device to sign');
318+
}
319+
320+
if (!this.newDeviceKey) {
321+
logger.info("No new device key to sign");
322+
return undefined;
323+
}
324+
325+
const cli = this.cli;
326+
327+
{
328+
const deviceInfo = cli.crypto.getStoredDevice(cli.getUserId(), this.newDeviceId);
329+
330+
if (deviceInfo) {
331+
return await this.checkAndCrossSignDevice(deviceInfo);
332+
}
333+
}
334+
335+
logger.info("New device is not online");
336+
await sleep(timeout);
337+
338+
logger.info("Going to wait for new device to be online");
339+
340+
{
341+
const deviceInfo = cli.crypto.getStoredDevice(cli.getUserId(), this.newDeviceId);
342+
343+
if (deviceInfo) {
344+
return await this.checkAndCrossSignDevice(deviceInfo);
345+
}
346+
}
347+
348+
throw new Error('Device not online within timeout');
349+
}
350+
351+
async userCancelled(): Promise<void> {
352+
this.cancel(RendezvousFailureReason.UserCancelled);
353+
}
354+
355+
async cancel(reason: RendezvousFailureReason) {
356+
await this.channel.cancel(reason);
357+
}
358+
359+
async close() {
360+
await this.channel.close();
361+
}
362+
}

0 commit comments

Comments
 (0)