Skip to content

Large diffs are not rendered by default.

444 changes: 444 additions & 0 deletions spec/unit/rendezvous/rendezvousv2.spec.ts

Large diffs are not rendered by default.

253 changes: 219 additions & 34 deletions src/rendezvous/MSC3906Rendezvous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,69 @@ limitations under the License.

import { UnstableValue } from "matrix-events-sdk";

import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import {
RendezvousChannel,
RendezvousFailureListener,
RendezvousFailureReason,
RendezvousFlow,
RendezvousIntent,
SETUP_ADDITIONAL_DEVICE_FLOW_V1,
} 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";

/**
* These are the possible types of payload that are used in
* [MSC3906](https://github.com/matrix-org/matrix-spec-proposals/pull/3906) payloads.
* The values are used in the `type` field.
*/
enum PayloadType {
Start = "m.login.start",
/**
* @deprecated Only used in MSC3906 v1
*/
Finish = "m.login.finish",
/**
* Indicates that a new device is ready to proceed with the setup process.
*/
Progress = "m.login.progress",
/**
* Used by the new device to indicate which protocol to use.
*/
Protocol = "m.login.protocol",
/**
* Used for the new device to indicate which protocols are supported by the existing device and
* homeserver.
*/
Protocols = "m.login.protocols",
/**
* Indicates that the sign of the new device was approved by the user on the existing device.
*/
Approved = "m.login.approved",
/**
* Indicates that the new device has signed in successfully.
*/
Success = "m.login.success",
/**
* Indicates that the new device has been successfully verified by the existing device.
*/
Verified = "m.login.verified",
/**
* Indicates that the login failed.
*/
Failure = "m.login.failure",
/**
* Indicates that the user declined the login on the existing device.
*/
Declined = "m.login.declined",
}

/**
* @deprecated Only used in MSC3906 v1
*/
enum Outcome {
Success = "success",
Failure = "failure",
Expand All @@ -38,10 +87,28 @@ enum Outcome {
Unsupported = "unsupported",
}

/**
* Used in the `reason` field of the `m.login.failure` payload.
*/
enum FailureReason {
Cancelled = "cancelled",
Unsupported = "unsupported",
E2EESecurityError = "e2ee_security_error",
IncompatibleIntent = "incompatible_intent",
}

/**
* This represents an [MSC3906](https://github.com/matrix-org/matrix-spec-proposals/pull/3906) payload.
*/
export interface MSC3906RendezvousPayload {
/** The type of the payload */
type: PayloadType;
intent?: RendezvousIntent;
/**
* @deprecated Only used in MSC3906 v1. Instead the type field should be used in future
*/
outcome?: Outcome;
reason?: FailureReason;
device_id?: string;
device_key?: string;
verifying_device_id?: string;
Expand All @@ -53,48 +120,75 @@ export interface MSC3906RendezvousPayload {
homeserver?: string;
}

/**
* Represents the use of an `m.login.token` obtained from an existing device to sign in on a new device.
*/
const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc3906.login_token");

/**
* Implements MSC3906 to allow a user to sign in on a new device using QR code.
* This implementation only supports generating a QR code on a device that is already signed in.
* This class can be used to complete a "rendezvous flow" as defined in MSC3906.
*
* Currently it only supports being used on a device that is already signed in that wishes to help sign in
* another device.
*
* Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC3906Rendezvous {
private newDeviceId?: string;
private newDeviceKey?: string;
private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
// if true then we follow the v1 flow, otherwise we follow the v2 flow
private usingV1Flow: boolean;
private _code?: string;

/**
* @param channel - The secure channel used for communication
* @param client - The Matrix client in used on the device already logged in
* @param onFailure - Callback for when the rendezvous fails
* Creates an instance that can be used to manage the execution of a rendezvous flow.
*
* @param channel - The rendezvous channel that should be used for communication with the other device
* @param client - The Matrix client that should be used.
* @param onFailure - Optional callback function to be notified of rendezvous failures.
* @param flow - The rendezvous flow to use. Defaults to setting up an additional device using MSC3906 v1,
* for backwards compatibility.
*/
public constructor(
private channel: RendezvousChannel<MSC3906RendezvousPayload>,
private client: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {}
private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V1,
) {
this.usingV1Flow = flow === SETUP_ADDITIONAL_DEVICE_FLOW_V1;
}

/**
* Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
* @returns The code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
*/
public get code(): string | undefined {
return this._code;
}

/**
* Generate the code including doing partial set up of the channel where required.
* Generate the code including doing partial set up of the channel where required. This code could be encoded in a QR.
*/
public async generateCode(): Promise<void> {
if (this._code) {
return;
}

this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent));
const raw = this.usingV1Flow
? await this.channel.generateCode(this.ourIntent)
: await this.channel.generateCode(this.ourIntent, this.flow);
this._code = JSON.stringify(raw);
}

/**
* Call this after the code has been shown to the user (perhaps in a QR). It will poll for the other device
* at the rendezvous point and start the process of setting up the new device.
*
* If successful then the user should be asked to approve the login of the other device whilst displaying the
* returned checksum code which the user should verify matches the code shown on the other device.
*
* @returns the checksum of the secure channel if the rendezvous set up was successful, otherwise undefined
*/
public async startAfterShowingCode(): Promise<string | undefined> {
const checksum = await this.channel.connect();

Expand All @@ -104,39 +198,114 @@ export class MSC3906Rendezvous {
// determine available protocols
if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
logger.info("Server doesn't support MSC3882");
await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported });
await this.send(
this.usingV1Flow
? { type: PayloadType.Finish, outcome: Outcome.Unsupported }
: { type: PayloadType.Failure, reason: FailureReason.Unsupported },
);
await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
return undefined;
}

await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] });
await this.send({
type: this.usingV1Flow ? PayloadType.Progress : PayloadType.Protocols,
protocols: [LOGIN_TOKEN_PROTOCOL.name],
});

logger.info("Waiting for other device to chose protocol");
const { type, protocol, outcome } = await this.receive();
const nextPayload = await this.receive();

// even if we didn't start in v1 mode we might detect that the other device is v1:
// - the finish payload is only used in v1
// - a progress payload is only sent at this point in v1, in v2 the use of it is different
if (nextPayload.type === PayloadType.Finish || nextPayload.type === PayloadType.Progress) {
this.usingV1Flow = true;
}

const protocol = this.usingV1Flow
? await this.handleV1ProtocolPayload(nextPayload)
: await this.handleV2ProtocolPayload(nextPayload);

// invalid protocol
if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) {
await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
return undefined;
}

return checksum;
}

private async handleV1ProtocolPayload({
type,
protocol,
outcome,
reason,
intent,
}: MSC3906RendezvousPayload): Promise<string | void> {
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);
let reason: RendezvousFailureReason;
if (intent) {
reason =
this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? RendezvousFailureReason.OtherDeviceNotSignedIn
: RendezvousFailureReason.OtherDeviceAlreadySignedIn;
} else if (outcome === Outcome.Unsupported) {
reason = RendezvousFailureReason.UnsupportedAlgorithm;
} else {
reason = RendezvousFailureReason.Unknown;
}
return undefined;
await this.cancel(reason);
return;
}

// unexpected payload
if (type !== PayloadType.Progress) {
await this.cancel(RendezvousFailureReason.Unknown);
return undefined;
return;
}

if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) {
await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
return undefined;
return protocol;
}

private async handleV2ProtocolPayload({
type,
protocol,
outcome,
reason,
intent,
}: MSC3906RendezvousPayload): Promise<string | void> {
// v2 flow
if (type === PayloadType.Failure) {
// new device decided not to complete
let failureReason: RendezvousFailureReason;
switch (reason ?? "") {
case FailureReason.Cancelled:
failureReason = RendezvousFailureReason.UserCancelled;
break;
case FailureReason.IncompatibleIntent:
failureReason =
this.ourIntent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? RendezvousFailureReason.OtherDeviceNotSignedIn
: RendezvousFailureReason.OtherDeviceAlreadySignedIn;
break;
case FailureReason.Unsupported:
failureReason = RendezvousFailureReason.UnsupportedAlgorithm;
break;
default:
failureReason = RendezvousFailureReason.Unknown;
}
await this.cancel(failureReason);
return;
}

return checksum;
// unexpected payload
if (type !== PayloadType.Protocol) {
await this.cancel(RendezvousFailureReason.Unknown);
return;
}

return protocol;
}

private async receive(): Promise<MSC3906RendezvousPayload> {
Expand All @@ -147,23 +316,38 @@ export class MSC3906Rendezvous {
await this.channel.send(payload);
}

/**
* Call this if the user has declined the login.
*/
public async declineLoginOnExistingDevice(): Promise<void> {
logger.info("User declined sign in");
await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined });
await this.send(
this.usingV1Flow ? { type: PayloadType.Finish, outcome: Outcome.Declined } : { type: PayloadType.Declined },
);
}

/**
* Call this if the user has approved the login.
*
* @param loginToken - the login token to send to the new device for it to complete the login flow
* @returns if the new device successfully completed the login flow and provided their device id then the device id is
* returned, otherwise undefined
*/
public async approveLoginOnExistingDevice(loginToken: string): Promise<string | undefined> {
// eslint-disable-next-line camelcase
await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl });
await this.channel.send({
type: this.usingV1Flow ? PayloadType.Progress : PayloadType.Approved,
login_token: loginToken,
homeserver: this.client.baseUrl,
});

logger.info("Waiting for outcome");
const res = await this.receive();
if (!res) {
return undefined;
}
const { outcome, device_id: deviceId, device_key: deviceKey } = res;
const { type, outcome, device_id: deviceId, device_key: deviceKey } = res;

if (outcome !== "success") {
if ((this.usingV1Flow && outcome !== "success") || (!this.usingV1Flow && type !== PayloadType.Success)) {
throw new Error("Linking failed");
}

Expand Down Expand Up @@ -201,8 +385,8 @@ export class MSC3906Rendezvous {
const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!;

await this.send({
type: PayloadType.Finish,
outcome: Outcome.Verified,
type: this.usingV1Flow ? PayloadType.Finish : PayloadType.Verified,
outcome: this.usingV1Flow ? Outcome.Verified : undefined,
verifying_device_id: this.client.getDeviceId()!,
verifying_device_key: this.client.getDeviceEd25519Key()!,
master_key: masterPublicKey,
Expand All @@ -212,7 +396,8 @@ export class MSC3906Rendezvous {
}

/**
* Verify the device and cross-sign it.
* Wait for a device to be visible via the homeserver and then verify/cross-sign it.
*
* @param timeout - time in milliseconds to wait for device to come online
* @returns the new device info if the device was verified
*/
Expand Down
Loading