1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

MSC4108 support OIDC QR code login (#4134)

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Michael Telatynski
2024-06-06 09:57:26 +01:00
committed by GitHub
parent 87c2ac3ffa
commit 6436fbb99f
18 changed files with 1877 additions and 18 deletions

View File

@ -0,0 +1,358 @@
/*
Copyright 2024 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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import {
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
PayloadType,
RendezvousError,
} from "../../../src/rendezvous";
import { defer } from "../../../src/utils";
import {
ClientPrefix,
DEVICE_CODE_SCOPE,
IHttpOpts,
IMyDevice,
MatrixClient,
MatrixError,
MatrixHttpApi,
} from "../../../src";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const baseUrl = "https://example.com";
const crypto = {
exportSecretsForQrLogin: jest.fn(),
};
const client = {
doesServerSupportUnstableFeature(feature: string) {
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl,
getHomeserverUrl() {
return baseUrl;
},
getDevice: jest.fn(),
getCrypto: jest.fn(() => crypto),
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
} as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl,
prefix: ClientPrefix.Unstable,
onlyData: true,
});
return client;
}
describe("MSC4108SignInWithQR", () => {
beforeEach(() => {
fetchMock.get(
"https://issuer/.well-known/openid-configuration",
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
);
fetchMock.get("https://issuer/jwks", {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});
afterEach(() => {
fetchMock.reset();
});
const url = "https://fallbackserver/rz/123";
const deviceId = "DEADB33F";
const verificationUri = "https://example.com/verify";
const verificationUriComplete = "https://example.com/verify/complete";
it("should generate qr code data as expected", async () => {
const session = new MSC4108RendezvousSession({
url,
});
const channel = new MSC4108SecureChannel(session);
const login = new MSC4108SignInWithQR(channel, false);
await login.generateCode();
const code = login.code;
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
expect(text.endsWith(url)).toBeTruthy();
// Assert that the code is stable
await login.generateCode();
expect(login.code).toEqual(code);
});
describe("should be able to connect as a reciprocating device", () => {
let client: MatrixClient;
let ourLogin: MSC4108SignInWithQR;
let opponentLogin: MSC4108SignInWithQR;
beforeEach(async () => {
let ourData = defer<string>();
let opponentData = defer<string>();
const ourMockSession = {
send: jest.fn(async (newData) => {
ourData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = opponentData.promise;
prom.then(() => {
opponentData = defer();
});
return prom;
}),
url,
cancelled: false,
cancel: () => {
// @ts-ignore
ourMockSession.cancelled = true;
ourData.resolve("");
},
} as unknown as MSC4108RendezvousSession;
const opponentMockSession = {
send: jest.fn(async (newData) => {
opponentData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = ourData.promise;
prom.then(() => {
ourData = defer();
});
return prom;
}),
url,
} as unknown as MSC4108RendezvousSession;
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.from_bytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getHomeserverUrl()),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key);
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
});
it("should be able to connect with opponent and share homeserver url & check code", async () => {
await Promise.all([
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }),
]);
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
});
it("should be able to connect with opponent and share verificationUri", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
verificationUri: verificationUriComplete,
}),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
verification_uri_complete: verificationUriComplete,
},
device_id: deviceId,
}),
]);
});
it("should abort if device already exists", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});
it("should abort on unsupported protocol", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
"Received a request for an unsupported protocol",
),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant_v2",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});
it("should be able to connect with opponent and share secrets", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
const ourProm = ourLogin.shareSecrets();
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
const payload = {
secrets: expect.objectContaining(secrets),
};
await Promise.all([
expect(ourProm).resolves.toEqual(payload),
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
]);
});
it("should abort if device doesn't come up by timeout", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});
jest.spyOn(Date, "now").mockImplementation(() => {
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
});
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
const ourProm = ourLogin.shareSecrets();
await expect(ourProm).rejects.toThrow("New device not found");
});
it("should abort on unexpected errors", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
);
await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
});
it("should abort on declined login", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
await ourLogin.declineLoginOnExistingDevice();
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
);
});
it("should not send secrets if user cancels", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
const ourProm = ourLogin.shareSecrets();
const opponentProm = opponentLogin.shareSecrets();
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();
const deferred = defer<IMyDevice>();
mocked(client.getDevice).mockReturnValue(deferred.promise);
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
deferred.resolve({} as IMyDevice);
const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
await Promise.all([
expect(ourProm).rejects.toThrow("User cancelled"),
expect(opponentProm).rejects.toThrow("Unexpected message received"),
]);
});
});
});

View File

@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
export const mockOpenIdConfiguration = (
issuer = "https://auth.org/",
additionalGrantTypes: string[] = [],
): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
code_challenge_methods_supported: ["S256"],
});

View File

@ -0,0 +1,341 @@
/*
Copyright 2024 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 fetchMock from "fetch-mock-jest";
import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src";
import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous";
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const client = {
doesServerSupportUnstableFeature(feature: string) {
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl: "https://example.com",
} as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl,
prefix: ClientPrefix.Unstable,
onlyData: true,
});
return client;
}
fetchMock.config.overwriteRoutes = true;
describe("MSC4108RendezvousSession", () => {
beforeEach(() => {
fetchMock.reset();
});
async function postAndCheckLocation(msc4108Enabled: boolean, fallbackRzServer: string, locationResponse: string) {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled });
const transport = new MSC4108RendezvousSession({ client, fallbackRzServer });
{
// initial POST
const expectedPostLocation = msc4108Enabled
? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous`
: fallbackRzServer;
fetchMock.postOnce(expectedPostLocation, {
status: 201,
body: { url: locationResponse },
});
await transport.send("data");
}
{
fetchMock.get(locationResponse, {
status: 200,
body: "data",
headers: {
"content-type": "text/plain",
"etag": "aaa",
},
});
await expect(transport.receive()).resolves.toEqual("data");
}
}
it("should use custom fetchFn if provided", async () => {
const sandbox = fetchMock.sandbox();
const fetchFn = jest.fn().mockImplementation(sandbox);
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fetchFn,
fallbackRzServer: "https://fallbackserver/rz",
});
sandbox.postOnce("https://fallbackserver/rz", {
status: 201,
body: {
url: "https://fallbackserver/rz/123",
},
});
await transport.send("data");
await sandbox.flush(true);
expect(fetchFn).toHaveBeenCalledWith("https://fallbackserver/rz", expect.anything());
});
it("should throw an error when no server available", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({ client });
await expect(transport.send("data")).rejects.toThrow("Invalid rendezvous URI");
});
it("POST to fallback server", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await fetchMock.flush(true);
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
});
it("POST with no location", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
});
await Promise.all([expect(transport.send("data")).rejects.toThrow(), fetchMock.flush(true)]);
});
it("POST with absolute path response", async function () {
await postAndCheckLocation(false, "https://fallbackserver/rz", "https://fallbackserver/123");
});
it("POST to built-in MSC3886 implementation", async function () {
await postAndCheckLocation(
true,
"https://fallbackserver/rz",
"https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123",
);
});
it("POST with relative path response including parent", async function () {
await postAndCheckLocation(false, "https://fallbackserver/rz/abc", "https://fallbackserver/rz/xyz/123");
});
// fetch-mock doesn't handle redirects properly, so we can't test this
it.skip("POST to follow 307 to other server", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 307,
redirectUrl: "https://redirected.fallbackserver/rz",
redirected: true,
});
fetchMock.postOnce("https://redirected.fallbackserver/rz", {
status: 201,
body: { url: "https://redirected.fallbackserver/rz/123" },
headers: { etag: "aaa" },
});
await fetchMock.flush(true);
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
});
it("POST and GET", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// first GET without etag
fetchMock.getOnce("https://fallbackserver/rz/123", {
status: 200,
body: "foo=baa",
headers: { "content-type": "text/plain", "etag": "aaa" },
});
await expect(transport.receive()).resolves.toEqual("foo=baa");
await fetchMock.flush(true);
}
{
// subsequent GET which should have etag from previous request
fetchMock.getOnce("https://fallbackserver/rz/123", {
status: 200,
body: "foo=baa",
headers: { "content-type": "text/plain", "etag": "bbb" },
});
await expect(transport.receive()).resolves.toEqual("foo=baa");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
method: "GET",
headers: { "if-none-match": "aaa" },
});
}
});
it("POST and PUTs", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
headers: { etag: "aaa" },
});
await transport.send("foo=baa");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// subsequent PUT which should have etag from previous request
fetchMock.putOnce("https://fallbackserver/rz/123", { status: 202, headers: { etag: "bbb" } });
await transport.send("c=d");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
method: "PUT",
headers: { "if-match": "aaa" },
});
}
});
it("POST and DELETE", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// Create
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// Cancel
fetchMock.deleteOnce("https://fallbackserver/rz/123", { status: 204 });
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
await fetchMock.flush(true);
}
});
it("send after cancelled", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
await expect(transport.send("data")).resolves.toBeUndefined();
});
it("receive before ready", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
await expect(transport.receive()).rejects.toThrow();
});
it("404 failure callback", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const onFailure = jest.fn();
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
onFailure,
});
fetchMock.postOnce("https://fallbackserver/rz", { status: 404 });
await Promise.all([expect(transport.send("foo=baa")).resolves.toBeUndefined(), fetchMock.flush(true)]);
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown);
});
it("404 failure callback mapped to expired", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const onFailure = jest.fn();
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
onFailure,
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
headers: { expires: "Thu, 01 Jan 1970 00:00:00 GMT" },
});
await transport.send("foo=baa");
await fetchMock.flush(true);
}
{
// GET with 404 to simulate expiry
fetchMock.getOnce("https://fallbackserver/rz/123", { status: 404, body: "foo=baa" });
await Promise.all([expect(transport.receive()).resolves.toBeUndefined(), fetchMock.flush(true)]);
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired);
}
});
});

View File

@ -0,0 +1,126 @@
/*
Copyright 2024 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 { EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked } from "jest-mock";
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
describe("MSC4108SecureChannel", () => {
const baseUrl = "https://example.com";
const url = "https://fallbackserver/rz/123";
it("should generate qr code data as expected", async () => {
const session = new MSC4108RendezvousSession({
url,
});
const channel = new MSC4108SecureChannel(session);
const code = await channel.generateCode(QrCodeMode.Login);
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
expect(text.endsWith(url)).toBeTruthy();
});
it("should throw error if attempt to connect multiple times", async () => {
const mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await channel.connect();
await expect(channel.connect()).rejects.toThrow("Channel already connected");
});
it("should throw error on invalid initiate response", async () => {
const mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
mocked(mockSession.receive).mockResolvedValue("");
await expect(channel.connect()).rejects.toThrow("No response from other device");
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
);
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await expect(channel.connect()).rejects.toThrow("Invalid response from other device");
});
describe("should be able to connect as a reciprocating device", () => {
let mockSession: MSC4108RendezvousSession;
let channel: MSC4108SecureChannel;
let opponentChannel: EstablishedEcies;
beforeEach(async () => {
mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.from_bytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.public_key,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
opponentChannel = _opponentChannel;
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await channel.connect();
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK");
mocked(mockSession.send).mockReset();
});
it("should be able to securely send encrypted payloads", async () => {
const payload = {
type: PayloadType.Secrets,
protocols: ["a", "b", "c"],
homeserver: "https://example.org",
};
await channel.secureSend(payload);
expect(mockSession.send).toHaveBeenCalled();
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe(JSON.stringify(payload));
});
it("should be able to securely receive encrypted payloads", async () => {
const payload = {
type: PayloadType.Secrets,
protocols: ["a", "b", "c"],
homeserver: "https://example.org",
};
const ciphertext = opponentChannel.encrypt(JSON.stringify(payload));
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await expect(channel.secureReceive()).resolves.toEqual(payload);
});
});
});

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import "../../olm-loader";
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import { LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
import { decodeBase64 } from "../../../src/base64";
import { DummyTransport } from "./DummyTransport";

View File

@ -17,7 +17,12 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import "../../olm-loader";
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import {
MSC3906Rendezvous,
RendezvousCode,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from "../../../src/rendezvous";
import {
ECDHv2RendezvousCode as ECDHRendezvousCode,
MSC3903ECDHPayload,

View File

@ -17,7 +17,7 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import type { MatrixClient } from "../../../src";
import { RendezvousFailureReason } from "../../../src/rendezvous";
import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous";
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {

View File

@ -49,6 +49,8 @@ interface OidcRegistrationRequestBody {
application_type: "web" | "native";
}
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
/**
* Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}

View File

@ -16,7 +16,12 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import {
RendezvousChannel,
RendezvousFailureListener,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from ".";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";

View File

@ -0,0 +1,439 @@
/*
Copyright 2024 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 { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener } from ".";
import { MatrixClient } from "../client";
import { logger } from "../logger";
import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel";
import { MatrixError } from "../http-api";
import { sleep } from "../utils";
import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc";
import { CryptoApi } from "../crypto-api";
/**
* Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* secure channels.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export enum PayloadType {
Protocols = "m.login.protocols",
Protocol = "m.login.protocol",
Failure = "m.login.failure",
Success = "m.login.success",
Secrets = "m.login.secrets",
ProtocolAccepted = "m.login.protocol_accepted",
Declined = "m.login.declined",
}
/**
* Type representing the base payload format for [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* messages sent over the secure channel.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export interface MSC4108Payload {
type: PayloadType;
}
interface ProtocolsPayload extends MSC4108Payload {
type: PayloadType.Protocols;
protocols: string[];
homeserver: string;
}
interface ProtocolPayload extends MSC4108Payload {
type: PayloadType.Protocol;
protocol: Exclude<string, "device_authorization_grant">;
device_id: string;
}
interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload {
protocol: "device_authorization_grant";
device_authorization_grant: {
verification_uri: string;
verification_uri_complete?: string;
};
}
function isDeviceAuthorizationGrantProtocolPayload(
payload: ProtocolPayload,
): payload is DeviceAuthorizationGrantProtocolPayload {
return payload.protocol === "device_authorization_grant";
}
interface FailurePayload extends MSC4108Payload {
type: PayloadType.Failure;
reason: MSC4108FailureReason;
homeserver?: string;
}
interface DeclinedPayload extends MSC4108Payload {
type: PayloadType.Declined;
}
interface SuccessPayload extends MSC4108Payload {
type: PayloadType.Success;
}
interface AcceptedPayload extends MSC4108Payload {
type: PayloadType.ProtocolAccepted;
}
interface SecretsPayload extends MSC4108Payload, Awaited<ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>>> {
type: PayloadType.Secrets;
}
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* sign in with QR + OIDC flow.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108SignInWithQR {
private readonly ourIntent: QrCodeMode;
private _code?: Uint8Array;
private expectingNewDeviceId?: string;
/**
* Returns the check code for the secure channel or undefined if not generated yet.
*/
public get checkCode(): string | undefined {
return this.channel?.getCheckCode();
}
/**
* @param channel - The secure channel used for communication
* @param client - The Matrix client in used on the device already logged in
* @param didScanCode - Whether this side of the channel scanned the QR code from the other party
* @param onFailure - Callback for when the rendezvous fails
*/
public constructor(
private readonly channel: MSC4108SecureChannel,
private readonly didScanCode: boolean,
private readonly client?: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {
this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login;
}
/**
* Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
*/
public get code(): Uint8Array | 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;
}
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getHomeserverUrl());
} else if (this.ourIntent === QrCodeMode.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
}
/**
* Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel.
*/
public get isExistingDevice(): boolean {
return this.ourIntent === QrCodeMode.Reciprocate;
}
/**
* Returns true if the device is the new device logging in being reciprocated by the device on the other side of the channel.
*/
public get isNewDevice(): boolean {
return !this.isExistingDevice;
}
/**
* The first step in the OIDC QR login process.
* To be called after the QR code has been rendered or scanned.
* The scanning device has to discover the homeserver details, if they scanned the code then they already have it.
* If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel.
*/
public async negotiateProtocols(): Promise<{ homeserverBaseUrl?: string }> {
logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`);
await this.channel.connect();
if (this.didScanCode) {
// Secure Channel step 6 completed, we trust the channel
if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set
} else {
// MSC4108-Flow: NewScanned -send protocols message
let oidcClientConfig: OidcClientConfig | undefined;
try {
const { issuer } = await this.client!.getAuthIssuer();
oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer);
} catch (e) {
logger.error("Failed to discover OIDC metadata", e);
}
if (oidcClientConfig?.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) {
await this.send<ProtocolsPayload>({
type: PayloadType.Protocols,
protocols: ["device_authorization_grant"],
homeserver: this.client?.getHomeserverUrl() ?? "",
});
} else {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol,
});
throw new RendezvousError(
"Device code grant unsupported",
MSC4108FailureReason.UnsupportedProtocol,
);
}
}
} else if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - wait for protocols message
logger.info("Waiting for protocols message");
const payload = await this.receive<ProtocolsPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocols) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
return { homeserverBaseUrl: payload.homeserver };
} else {
// MSC4108-Flow: NewScanned - nothing to do
}
return {};
}
/**
* The second & third step in the OIDC QR login process.
* To be called after `negotiateProtocols` for the existing device.
* To be called after OIDC negotiation for the new device. (Currently unsupported)
*/
public async deviceAuthorizationGrant(): Promise<{
verificationUri?: string;
userCode?: string;
}> {
if (this.isNewDevice) {
throw new Error("New device flows around OIDC are not yet implemented");
} else {
// The user needs to do step 7 for the out-of-band confirmation
// but, first we receive the protocol chosen by the other device so that
// the confirmation_uri is ready to go
logger.info("Waiting for protocol message");
const payload = await this.receive<ProtocolPayload | DeviceAuthorizationGrantProtocolPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocol) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
if (isDeviceAuthorizationGrantProtocolPayload(payload)) {
const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = payload;
const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag;
let deviceAlreadyExists = true;
try {
await this.client?.getDevice(expectingNewDeviceId);
} catch (err: MatrixError | unknown) {
if (err instanceof MatrixError && err.httpStatus === 404) {
deviceAlreadyExists = false;
}
}
if (deviceAlreadyExists) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceAlreadyExists,
});
throw new RendezvousError(
"Specified device ID already exists",
MSC4108FailureReason.DeviceAlreadyExists,
);
}
this.expectingNewDeviceId = expectingNewDeviceId;
return { verificationUri: verificationUriComplete ?? verificationUri };
}
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol,
});
throw new RendezvousError(
"Received a request for an unsupported protocol",
MSC4108FailureReason.UnsupportedProtocol,
);
}
}
/**
* The fifth (and final) step in the OIDC QR login process.
* To be called after the new device has completed authentication.
*/
public async shareSecrets(): Promise<{ secrets?: Omit<SecretsPayload, "type"> }> {
if (this.isNewDevice) {
await this.send<SuccessPayload>({
type: PayloadType.Success,
});
// then wait for secrets
logger.info("Waiting for secrets message");
const payload = await this.receive<SecretsPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Secrets) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
return { secrets: payload };
// then done?
} else {
if (!this.expectingNewDeviceId) {
throw new Error("No new device ID expected");
}
await this.send<AcceptedPayload>({
type: PayloadType.ProtocolAccepted,
});
logger.info("Waiting for outcome message");
const payload = await this.receive<SuccessPayload | DeclinedPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type === PayloadType.Declined) {
throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined);
}
if (payload?.type !== PayloadType.Success) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived);
}
const timeout = Date.now() + 10000; // wait up to 10 seconds
do {
// is the device visible via the Homeserver?
try {
const device = await this.client?.getDevice(this.expectingNewDeviceId);
if (device) {
// if so, return the secrets
const secretsBundle = await this.client!.getCrypto()!.exportSecretsBundle!();
if (this.channel.cancelled) {
throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled);
}
// send secrets
await this.send<SecretsPayload>({
type: PayloadType.Secrets,
...secretsBundle,
});
return { secrets: secretsBundle };
// let the other side close the rendezvous session
}
} catch (err: MatrixError | unknown) {
if (err instanceof MatrixError && err.httpStatus === 404) {
// not found, so keep waiting until timeout
} else {
throw err;
}
}
await sleep(1000);
} while (Date.now() < timeout);
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceNotFound,
});
throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound);
}
}
private async receive<T extends MSC4108Payload>(): Promise<T | FailurePayload | undefined> {
return (await this.channel.secureReceive()) as T | undefined;
}
private async send<T extends MSC4108Payload>(payload: T): Promise<void> {
await this.channel.secureSend(payload);
}
/**
* Decline the login on the existing device.
*/
public async declineLoginOnExistingDevice(): Promise<void> {
if (!this.isExistingDevice) {
throw new Error("Can only decline login on existing device");
}
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UserCancelled,
});
}
/**
* Cancels the rendezvous session.
* @param reason the reason for the cancellation
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
this.onFailure?.(reason);
await this.channel.cancel(reason);
}
/**
* Closes the rendezvous session.
*/
public async close(): Promise<void> {
await this.channel.close();
}
}

View File

@ -14,23 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export type RendezvousFailureListener = (reason: LegacyRendezvousFailureReason) => void;
export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void;
export type RendezvousFailureReason =
| LegacyRendezvousFailureReason
| MSC4108FailureReason
| ClientRendezvousFailureReason;
export enum LegacyRendezvousFailureReason {
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",
UnsupportedProtocol = "unsupported_protocol",
HomeserverLacksSupport = "homeserver_lacks_support",
}
/**
* @deprecated legacy re-export
*/
export { LegacyRendezvousFailureReason as RendezvousFailureReason };
export enum MSC4108FailureReason {
AuthorizationExpired = "authorization_expired",
DeviceAlreadyExists = "device_already_exists",
DeviceNotFound = "device_not_found",
UnexpectedMessageReceived = "unexpected_message_received",
UnsupportedProtocol = "unsupported_protocol",
UserCancelled = "user_cancelled",
}
export enum ClientRendezvousFailureReason {
/** The sign in request has expired */
Expired = "expired",
/** The homeserver is lacking support for the required features */
HomeserverLacksSupport = "homeserver_lacks_support",
/** The secure channel verification failed meaning that it might be compromised */
InsecureChannelDetected = "insecure_channel_detected",
/** An invalid/incompatible QR code was scanned */
InvalidCode = "invalid_code",
/** The other device is not signed in */
OtherDeviceNotSignedIn = "other_device_not_signed_in",
/** The other device is already signed in */
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
/** Other */
Unknown = "unknown",
/** The user declined the sign in request */
UserDeclined = "user_declined",
/** The rendezvous request is missing an ETag header */
ETagMissing = "etag_missing",
}

View File

@ -23,7 +23,7 @@ import {
RendezvousChannel,
RendezvousTransportDetails,
RendezvousTransport,
RendezvousFailureReason,
LegacyRendezvousFailureReason as RendezvousFailureReason,
} from "..";
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";

View File

@ -0,0 +1,270 @@
/*
Copyright 2024 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 {
Curve25519PublicKey,
Ecies,
EstablishedEcies,
QrCodeData,
QrCodeMode,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import {
ClientRendezvousFailureReason,
MSC4108FailureReason,
MSC4108Payload,
RendezvousError,
RendezvousFailureListener,
} from "..";
import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession";
import { logger } from "../../logger";
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* secure rendezvous session protocol.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
* Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle.
*/
export class MSC4108SecureChannel {
private readonly secureChannel: Ecies;
private establishedChannel?: EstablishedEcies;
private connected = false;
public constructor(
private rendezvousSession: MSC4108RendezvousSession,
private theirPublicKey?: Curve25519PublicKey,
public onFailure?: RendezvousFailureListener,
) {
this.secureChannel = new Ecies();
}
/**
* Generate a QR code for the current session.
* @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`.
* @param homeserverBaseUrl the base URL of the homeserver to connect to, required for `Reciprocate` mode.
*/
public async generateCode(mode: QrCodeMode.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode.Reciprocate, homeserverBaseUrl: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode, homeserverBaseUrl?: string): Promise<Uint8Array> {
const { url } = this.rendezvousSession;
if (!url) {
throw new Error("No rendezvous session URL");
}
return new QrCodeData(
this.secureChannel.public_key(),
url,
mode === QrCodeMode.Reciprocate ? homeserverBaseUrl : undefined,
).to_bytes();
}
/**
* Returns the check code for the secure channel or undefined if not generated yet.
*/
public getCheckCode(): string | undefined {
const x = this.establishedChannel?.check_code();
if (!x) {
return undefined;
}
return Array.from(x.as_bytes())
.map((b) => `${b % 10}`)
.join("");
}
/**
* Connects and establishes a secure channel with the other device.
*/
public async connect(): Promise<void> {
if (this.connected) {
throw new Error("Channel already connected");
}
if (this.theirPublicKey) {
// We are the scanning device
const result = this.secureChannel.establish_outbound_channel(
this.theirPublicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
this.establishedChannel = result.channel;
/*
Secure Channel step 4. Device S sends the initial message
Nonce := 0
SH := ECDH(Ss, Gp)
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE")
Nonce := Nonce + 2
LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp)
*/
{
logger.info("Sending LoginInitiateMessage");
await this.rendezvousSession.send(result.initial_message);
}
/*
Secure Channel step 6. Verification by Device S
Nonce_G := 1
(TaggedCiphertext, Sp) := Unpack(Message)
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext)
Nonce_G := Nonce_G + 2
unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK":
FAIL
*/
{
logger.info("Waiting for LoginOkMessage");
const ciphertext = await this.rendezvousSession.receive();
if (!ciphertext) {
throw new RendezvousError(
"No response from other device",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
const candidateLoginOkMessage = await this.decrypt(ciphertext);
if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") {
throw new RendezvousError(
"Invalid response from other device",
ClientRendezvousFailureReason.InsecureChannelDetected,
);
}
// Step 6 is now complete. We trust the channel
}
} else {
/*
Secure Channel step 5. Device G confirms
Nonce_S := 0
(TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage)
SH := ECDH(Gs, Sp)
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext)
Nonce_S := Nonce_S + 2
*/
// wait for the other side to send us their public key
logger.info("Waiting for LoginInitiateMessage");
const loginInitiateMessage = await this.rendezvousSession.receive();
if (!loginInitiateMessage) {
throw new Error("No response from other device");
}
const { channel, message: candidateLoginInitiateMessage } =
this.secureChannel.establish_inbound_channel(loginInitiateMessage);
this.establishedChannel = channel;
if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") {
throw new RendezvousError(
"Invalid response from other device",
ClientRendezvousFailureReason.InsecureChannelDetected,
);
}
logger.info("LoginInitiateMessage received");
logger.info("Sending LoginOkMessage");
const loginOkMessage = await this.encrypt("MATRIX_QR_CODE_LOGIN_OK");
await this.rendezvousSession.send(loginOkMessage);
// Step 5 is complete. We don't yet trust the channel
// next step will be for the user to confirm the check code on the other device
}
this.connected = true;
}
private async decrypt(ciphertext: string): Promise<string> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
return this.establishedChannel.decrypt(ciphertext);
}
private async encrypt(plaintext: string): Promise<string> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
return this.establishedChannel.encrypt(plaintext);
}
/**
* Sends a payload securely to the other device.
* @param payload the payload to encrypt and send
*/
public async secureSend<T extends MSC4108Payload>(payload: T): Promise<void> {
if (!this.connected) {
throw new Error("Channel closed");
}
const stringifiedPayload = JSON.stringify(payload);
logger.debug(`=> {"type": ${JSON.stringify(payload.type)}, ...}`);
await this.rendezvousSession.send(await this.encrypt(stringifiedPayload));
}
/**
* Receives an encrypted payload from the other device and decrypts it.
*/
public async secureReceive<T extends MSC4108Payload>(): Promise<Partial<T> | undefined> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
const ciphertext = await this.rendezvousSession.receive();
if (!ciphertext) {
return undefined;
}
const plaintext = await this.decrypt(ciphertext);
const json = JSON.parse(plaintext);
logger.debug(`<= {"type": ${JSON.stringify(json.type)}, ...}`);
return json as Partial<T> | undefined;
}
/**
* Closes the secure channel.
*/
public async close(): Promise<void> {
await this.rendezvousSession.close();
}
/**
* Cancels the secure channel.
* @param reason the reason for the cancellation
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
try {
await this.rendezvousSession.cancel(reason);
this.onFailure?.(reason);
} finally {
await this.close();
}
}
/**
* Returns whether the rendezvous session has been cancelled.
*/
public get cancelled(): boolean {
return this.rendezvousSession.cancelled;
}
}

View File

@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3903ECDHv2RendezvousChannel";
export * from "./MSC4108SecureChannel";

View File

@ -14,10 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3906Rendezvous";
export * from "./MSC4108SignInWithQR";
export * from "./RendezvousChannel";
export * from "./RendezvousCode";
export * from "./RendezvousError";
export * from "./RendezvousFailureReason";
export * from "./RendezvousIntent";
export * from "./RendezvousTransport";
export * from "./transports";
export * from "./channels";

View File

@ -20,7 +20,7 @@ import { logger } from "../../logger";
import { sleep } from "../../utils";
import {
RendezvousFailureListener,
RendezvousFailureReason,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousTransport,
RendezvousTransportDetails,
} from "..";

View File

@ -0,0 +1,270 @@
/*
Copyright 2024 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 "../../logger";
import { sleep } from "../../utils";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousFailureListener } from "..";
import { MatrixClient, Method } from "../../matrix";
import { ClientPrefix } from "../../http-api";
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* insecure rendezvous session protocol.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108RendezvousSession {
public url?: string;
private readonly client?: MatrixClient;
private readonly fallbackRzServer?: string;
private readonly fetchFn?: typeof global.fetch;
private readonly onFailure?: RendezvousFailureListener;
private etag?: string;
private expiresAt?: Date;
private expiresTimer?: ReturnType<typeof setTimeout>;
private _cancelled = false;
private _ready = false;
public constructor({
onFailure,
url,
fetchFn,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
url: string;
});
public constructor({
onFailure,
client,
fallbackRzServer,
fetchFn,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
client?: MatrixClient;
fallbackRzServer?: string;
});
public constructor({
fetchFn,
onFailure,
url,
client,
fallbackRzServer,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
url?: string;
client?: MatrixClient;
fallbackRzServer?: string;
}) {
this.fetchFn = fetchFn;
this.onFailure = onFailure;
this.client = client;
this.fallbackRzServer = fallbackRzServer;
this.url = url;
}
/**
* Returns whether the channel is ready to be used.
*/
public get ready(): boolean {
return this._ready;
}
/**
* Returns whether the channel has been cancelled.
*/
public get cancelled(): boolean {
return this._cancelled;
}
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> {
if (this.client) {
try {
if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) {
return this.client.http
.getUrl("/org.matrix.msc4108/rendezvous", undefined, ClientPrefix.Unstable)
.toString();
}
} catch (err) {
logger.warn("Failed to get unstable features", err);
}
}
return this.fallbackRzServer;
}
/**
* Sends data via the rendezvous channel.
* @param data the payload to send
*/
public async send(data: string): Promise<void> {
if (this._cancelled) {
return;
}
const method = this.url ? Method.Put : Method.Post;
const uri = this.url ?? (await this.getPostEndpoint());
if (!uri) {
throw new Error("Invalid rendezvous URI");
}
const headers: Record<string, string> = { "content-type": "text/plain" };
// if we didn't create the rendezvous channel, we need to fetch the first etag if needed
if (!this.etag && this.url) {
await this.receive();
}
if (this.etag) {
headers["if-match"] = this.etag;
}
logger.info(`=> ${method} ${uri} with ${data} if-match: ${this.etag}`);
const res = await this.fetch(uri, { method, headers, body: data, redirect: "follow" });
if (res.status === 404) {
return this.cancel(ClientRendezvousFailureReason.Unknown);
}
this.etag = res.headers.get("etag") ?? undefined;
logger.info(`Received etag: ${this.etag}`);
if (method === Method.Post) {
const expires = res.headers.get("expires");
if (expires) {
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
this.expiresAt = new Date(expires);
this.expiresTimer = setTimeout(() => {
this.expiresTimer = undefined;
this.cancel(ClientRendezvousFailureReason.Expired);
}, this.expiresAt.getTime() - Date.now());
}
// MSC4108: we expect a JSON response with a rendezvous URL
const json = await res.json();
if (typeof json.url !== "string") {
throw new Error("No rendezvous URL given");
}
this.url = json.url;
this._ready = true;
}
}
/**
* Receives data from the rendezvous channel.
* @return the returned promise won't resolve until new data is acquired or the channel is closed either by the server or the other party.
*/
public async receive(): Promise<string | undefined> {
if (!this.url) {
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;
}
logger.info(`=> GET ${this.url} if-none-match: ${this.etag}`);
const poll = await this.fetch(this.url, { method: Method.Get, headers });
if (poll.status === 404) {
await this.cancel(ClientRendezvousFailureReason.Unknown);
return undefined;
}
// rely on server expiring the channel rather than checking ourselves
const etag = poll.headers.get("etag") ?? undefined;
if (poll.headers.get("content-type") !== "text/plain") {
this.etag = etag;
} else if (poll.status === 200) {
if (!etag) {
// Some browsers & extensions block the ETag header for anti-tracking purposes
// We try and detect this so the client can give the user a somewhat helpful message
await this.cancel(ClientRendezvousFailureReason.ETagMissing);
return undefined;
}
this.etag = etag;
const text = await poll.text();
logger.info(`Received: ${text} with etag ${this.etag}`);
return text;
}
await sleep(1000);
}
}
/**
* Cancels the rendezvous channel.
* If the reason is user_declined or user_cancelled then the channel will also be closed.
* @param reason the reason to cancel with
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
if (this._cancelled) return;
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
if (
reason === ClientRendezvousFailureReason.Unknown &&
this.expiresAt &&
this.expiresAt.getTime() < Date.now()
) {
reason = ClientRendezvousFailureReason.Expired;
}
this._cancelled = true;
this._ready = false;
this.onFailure?.(reason);
if (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) {
await this.close();
}
}
/**
* Closes the rendezvous channel.
*/
public async close(): Promise<void> {
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
if (!this.url) return;
try {
await this.fetch(this.url, { method: Method.Delete });
} catch (e) {
logger.warn(e);
}
}
}

View File

@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3886SimpleHttpRendezvousTransport";
export * from "./MSC4108RendezvousSession";