Skip to content

Support for MSC3906 v2 #3193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from

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.

190 changes: 164 additions & 26 deletions src/rendezvous/MSC3906Rendezvous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ 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";
Expand All @@ -25,11 +32,23 @@ import { logger } from "../logger";
import { sleep } from "../utils";

enum PayloadType {
Start = "m.login.start",
/**
* @deprecated Only used in MSC3906 v1
*/
Finish = "m.login.finish",
Progress = "m.login.progress",
Protocol = "m.login.protocol",
Protocols = "m.login.protocols",
Approved = "m.login.approved",
Success = "m.login.success",
Verified = "m.login.verified",
Failure = "m.login.failure",
Declined = "m.login.declined",
}

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

enum FailureReason {
Cancelled = "cancelled",
Unsupported = "unsupported",
E2EESecurityError = "e2ee_security_error",
IncompatibleIntent = "incompatible_intent",
}

export interface MSC3906RendezvousPayload {
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 @@ -64,18 +94,23 @@ export class MSC3906Rendezvous {
private newDeviceId?: string;
private newDeviceKey?: string;
private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
private v1FallbackEnabled: 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
* @param flow - The flow to use. Defaults to MSC3906 v1 for backwards compatibility.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please could we have some more words explaining what a "flow" means and how I, as a user of this API, should pick one?

*/
public constructor(
private channel: RendezvousChannel<MSC3906RendezvousPayload>,
private client: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {}
private flow: RendezvousFlow = SETUP_ADDITIONAL_DEVICE_FLOW_V1,
) {
this.v1FallbackEnabled = 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.
Expand All @@ -92,9 +127,16 @@ export class MSC3906Rendezvous {
return;
}

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

/**
*
* @returns the checksum of the secure channel if the rendezvous set up was successful, otherwise undefined
*/
public async startAfterShowingCode(): Promise<string | undefined> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Particularly given this is a public function, please could it have a better doc-comment? What does it actually do, beyond returning a success flag?

const checksum = await this.channel.connect();

Expand All @@ -104,39 +146,125 @@ 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.v1FallbackEnabled
? { 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.v1FallbackEnabled ? 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();

this.checkForV1Fallback(nextPayload);

const protocol = this.v1FallbackEnabled
? 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 checkForV1Fallback({ type }: MSC3906RendezvousPayload): void {
// even if we didn't start in v1 fallback we might detect that the other device is v1
if (type === PayloadType.Finish || type === PayloadType.Progress) {
// this is a PDU from a v1 flow so use fallback mode
this.v1FallbackEnabled = true;
}
}

/**
*
* @returns true if the protocol was received successfully, false otherwise
*/
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;
}

/**
*
* @returns true if the protocol was received successfully, false otherwise
*/
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 @@ -149,21 +277,31 @@ export class MSC3906Rendezvous {

public async declineLoginOnExistingDevice(): Promise<void> {
logger.info("User declined sign in");
await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined });
await this.send(
this.v1FallbackEnabled
? { type: PayloadType.Finish, outcome: Outcome.Declined }
: { type: PayloadType.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 });
await this.channel.send({
type: this.v1FallbackEnabled ? 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.v1FallbackEnabled && outcome !== "success") ||
(!this.v1FallbackEnabled && type !== PayloadType.Success)
) {
throw new Error("Linking failed");
}

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

await this.send({
type: PayloadType.Finish,
outcome: Outcome.Verified,
type: this.v1FallbackEnabled ? PayloadType.Finish : PayloadType.Verified,
outcome: this.v1FallbackEnabled ? Outcome.Verified : undefined,
verifying_device_id: this.client.getDeviceId()!,
verifying_device_key: this.client.getDeviceEd25519Key()!,
master_key: masterPublicKey,
Expand Down
11 changes: 10 additions & 1 deletion src/rendezvous/RendezvousChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from ".";
import { RendezvousCode, RendezvousIntent, RendezvousFailureReason, RendezvousFlow } from ".";

export interface RendezvousChannel<T> {
/**
Expand All @@ -40,9 +40,18 @@ export interface RendezvousChannel<T> {
close(): Promise<void>;

/**
* Always uses the MSC3906 v1 flow.
*
* @returns a representation of the channel that can be encoded in a QR or similar
*
* @deprecated use generateCode instead
*/
generateCode(intent: RendezvousIntent): Promise<RendezvousCode>;

/**
* @returns a representation of the channel that can be encoded in a QR or similar
*/
generateCode(intent: RendezvousIntent, flow: RendezvousFlow): Promise<RendezvousCode>;

cancel(reason: RendezvousFailureReason): Promise<void>;
}
6 changes: 5 additions & 1 deletion src/rendezvous/RendezvousCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { RendezvousTransportDetails, RendezvousIntent } from ".";
import { RendezvousTransportDetails, RendezvousIntent, RendezvousFlow } from ".";

export interface RendezvousCode {
intent: RendezvousIntent;
/**
* In MSC3906 v1 there wasn't a flow, hence why it's optional for now.
*/
flow?: RendezvousFlow;
rendezvous?: {
transport: RendezvousTransportDetails;
algorithm: string;
Expand Down
30 changes: 30 additions & 0 deletions src/rendezvous/RendezvousFlow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Copyright 2023 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 "../NamespacedValue";

export const SETUP_ADDITIONAL_DEVICE_FLOW_V1 = "org.matrix.msc3906.v1";

export const SETUP_ADDITIONAL_DEVICE_FLOW_V2 = new UnstableValue(
"m.setup.additional_device.v2",
"org.matrix.msc3906.setup.additional_device.v2",
);

// v1 is never included in the JSON, but we give it a name for the sake of determining the flow to use
export type RendezvousFlow =
| typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.name
| typeof SETUP_ADDITIONAL_DEVICE_FLOW_V2.altName
| typeof SETUP_ADDITIONAL_DEVICE_FLOW_V1;
4 changes: 3 additions & 1 deletion src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RendezvousTransportDetails,
RendezvousTransport,
RendezvousFailureReason,
RendezvousFlow,
} from "..";
import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib";
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
Expand Down Expand Up @@ -85,7 +86,7 @@ export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey());
}

public async generateCode(intent: RendezvousIntent): Promise<ECDHv2RendezvousCode> {
public async generateCode(intent: RendezvousIntent, flow?: RendezvousFlow): Promise<ECDHv2RendezvousCode> {
if (this.transport.ready) {
throw new Error("Code already generated");
}
Expand All @@ -98,6 +99,7 @@ export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
key: encodeUnpaddedBase64(this.ourPublicKey),
transport: await this.transport.details(),
},
flow,
intent,
Comment on lines +102 to 103
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While trying to get a handle on how these pieces fit together, I find it a bit odd that this is implementing a "MSC3903 rendezvous channel", but there is no mention in MSC3903 of this bit. If this is specific to MSC3906, shouldn't it be in MSC3906Rendezvous rather than here?

};

Expand Down
1 change: 1 addition & 0 deletions src/rendezvous/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export * from "./RendezvousError";
export * from "./RendezvousFailureReason";
export * from "./RendezvousIntent";
export * from "./RendezvousTransport";
export * from "./RendezvousFlow";