From 6436fbb99f554182cddd9a9f9ab254794d2d0aa1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 6 Jun 2024 09:57:26 +0100 Subject: [PATCH] MSC4108 support OIDC QR code login (#4134) Co-authored-by: Hugh Nimmo-Smith Co-authored-by: Hugh Nimmo-Smith --- .../rendezvous/MSC4108SignInWithQR.spec.ts | 358 ++++++++++++++ spec/test-utils/oidc.ts | 7 +- .../MSC4108RendezvousSession.spec.ts | 341 ++++++++++++++ .../channels/MSC4108SecureChannel.spec.ts | 126 +++++ spec/unit/rendezvous/ecdhv2.spec.ts | 2 +- spec/unit/rendezvous/rendezvous.spec.ts | 7 +- .../rendezvous/simpleHttpTransport.spec.ts | 2 +- src/oidc/register.ts | 2 + src/rendezvous/MSC3906Rendezvous.ts | 7 +- src/rendezvous/MSC4108SignInWithQR.ts | 439 ++++++++++++++++++ src/rendezvous/RendezvousFailureReason.ts | 46 +- .../MSC3903ECDHv2RendezvousChannel.ts | 2 +- .../channels/MSC4108SecureChannel.ts | 270 +++++++++++ src/rendezvous/channels/index.ts | 4 + src/rendezvous/index.ts | 6 + .../MSC3886SimpleHttpRendezvousTransport.ts | 2 +- .../transports/MSC4108RendezvousSession.ts | 270 +++++++++++ src/rendezvous/transports/index.ts | 4 + 18 files changed, 1877 insertions(+), 18 deletions(-) create mode 100644 spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts create mode 100644 spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts create mode 100644 spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts create mode 100644 src/rendezvous/MSC4108SignInWithQR.ts create mode 100644 src/rendezvous/channels/MSC4108SecureChannel.ts create mode 100644 src/rendezvous/transports/MSC4108RendezvousSession.ts diff --git a/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts new file mode 100644 index 000000000..ef9ed2016 --- /dev/null +++ b/spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts @@ -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(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(); + let opponentData = defer(); + + 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) => { + (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) => { + (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(); + 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"), + ]); + }); + }); +}); diff --git a/spec/test-utils/oidc.ts b/spec/test-utils/oidc.ts index 4f9a01c2e..8f2965c9a 100644 --- a/spec/test-utils/oidc.ts +++ b/spec/test-utils/oidc.ts @@ -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"], }); diff --git a/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts new file mode 100644 index 000000000..57afd3afc --- /dev/null +++ b/spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts @@ -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(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); + } + }); +}); diff --git a/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts new file mode 100644 index 000000000..6daae6034 --- /dev/null +++ b/spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts @@ -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); + }); + }); +}); diff --git a/spec/unit/rendezvous/ecdhv2.spec.ts b/spec/unit/rendezvous/ecdhv2.spec.ts index caadfbf6e..1fd3f7cac 100644 --- a/spec/unit/rendezvous/ecdhv2.spec.ts +++ b/spec/unit/rendezvous/ecdhv2.spec.ts @@ -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"; diff --git a/spec/unit/rendezvous/rendezvous.spec.ts b/spec/unit/rendezvous/rendezvous.spec.ts index c7a31b8a1..f600639d8 100644 --- a/spec/unit/rendezvous/rendezvous.spec.ts +++ b/spec/unit/rendezvous/rendezvous.spec.ts @@ -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, diff --git a/spec/unit/rendezvous/simpleHttpTransport.spec.ts b/spec/unit/rendezvous/simpleHttpTransport.spec.ts index 166a63507..c736d4d11 100644 --- a/spec/unit/rendezvous/simpleHttpTransport.spec.ts +++ b/spec/unit/rendezvous/simpleHttpTransport.spec.ts @@ -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 { diff --git a/src/oidc/register.ts b/src/oidc/register.ts index 0c5f05565..ec673e797 100644 --- a/src/oidc/register.ts +++ b/src/oidc/register.ts @@ -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} diff --git a/src/rendezvous/MSC3906Rendezvous.ts b/src/rendezvous/MSC3906Rendezvous.ts index 4e2d2eab3..f83aff261 100644 --- a/src/rendezvous/MSC3906Rendezvous.ts +++ b/src/rendezvous/MSC3906Rendezvous.ts @@ -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"; diff --git a/src/rendezvous/MSC4108SignInWithQR.ts b/src/rendezvous/MSC4108SignInWithQR.ts new file mode 100644 index 000000000..275d44bd8 --- /dev/null +++ b/src/rendezvous/MSC4108SignInWithQR.ts @@ -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; + 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>> { + 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 { + 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({ + type: PayloadType.Protocols, + protocols: ["device_authorization_grant"], + homeserver: this.client?.getHomeserverUrl() ?? "", + }); + } else { + await this.send({ + 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(); + + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Protocols) { + await this.send({ + 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(); + + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Protocol) { + await this.send({ + 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({ + 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({ + 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 }> { + if (this.isNewDevice) { + await this.send({ + type: PayloadType.Success, + }); + // then wait for secrets + logger.info("Waiting for secrets message"); + const payload = await this.receive(); + if (payload?.type === PayloadType.Failure) { + throw new RendezvousError("Failed", payload.reason); + } + + if (payload?.type !== PayloadType.Secrets) { + await this.send({ + 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({ + type: PayloadType.ProtocolAccepted, + }); + + logger.info("Waiting for outcome message"); + const payload = await this.receive(); + + 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({ + 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({ + 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({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.DeviceNotFound, + }); + throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound); + } + } + + private async receive(): Promise { + return (await this.channel.secureReceive()) as T | undefined; + } + + private async send(payload: T): Promise { + await this.channel.secureSend(payload); + } + + /** + * Decline the login on the existing device. + */ + public async declineLoginOnExistingDevice(): Promise { + if (!this.isExistingDevice) { + throw new Error("Can only decline login on existing device"); + } + await this.send({ + type: PayloadType.Failure, + reason: MSC4108FailureReason.UserCancelled, + }); + } + + /** + * Cancels the rendezvous session. + * @param reason the reason for the cancellation + */ + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { + this.onFailure?.(reason); + await this.channel.cancel(reason); + } + + /** + * Closes the rendezvous session. + */ + public async close(): Promise { + await this.channel.close(); + } +} diff --git a/src/rendezvous/RendezvousFailureReason.ts b/src/rendezvous/RendezvousFailureReason.ts index 27350d879..7a0116ca0 100644 --- a/src/rendezvous/RendezvousFailureReason.ts +++ b/src/rendezvous/RendezvousFailureReason.ts @@ -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", +} diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index e7998dc14..86a9fd747 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -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"; diff --git a/src/rendezvous/channels/MSC4108SecureChannel.ts b/src/rendezvous/channels/MSC4108SecureChannel.ts new file mode 100644 index 000000000..8db12ebd2 --- /dev/null +++ b/src/rendezvous/channels/MSC4108SecureChannel.ts @@ -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; + public async generateCode(mode: QrCodeMode.Reciprocate, homeserverBaseUrl: string): Promise; + public async generateCode(mode: QrCodeMode, homeserverBaseUrl?: string): Promise { + 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 { + 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 { + if (!this.establishedChannel) { + throw new Error("Channel closed"); + } + + return this.establishedChannel.decrypt(ciphertext); + } + + private async encrypt(plaintext: string): Promise { + 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(payload: T): Promise { + 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(): Promise | 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 | undefined; + } + + /** + * Closes the secure channel. + */ + public async close(): Promise { + await this.rendezvousSession.close(); + } + + /** + * Cancels the secure channel. + * @param reason the reason for the cancellation + */ + public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise { + 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; + } +} diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts index f157bbeae..793105a51 100644 --- a/src/rendezvous/channels/index.ts +++ b/src/rendezvous/channels/index.ts @@ -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"; diff --git a/src/rendezvous/index.ts b/src/rendezvous/index.ts index 379b13351..1b887d9c3 100644 --- a/src/rendezvous/index.ts +++ b/src/rendezvous/index.ts @@ -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"; diff --git a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts index f23a5f47c..01575ed70 100644 --- a/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts +++ b/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts @@ -20,7 +20,7 @@ import { logger } from "../../logger"; import { sleep } from "../../utils"; import { RendezvousFailureListener, - RendezvousFailureReason, + LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousTransport, RendezvousTransportDetails, } from ".."; diff --git a/src/rendezvous/transports/MSC4108RendezvousSession.ts b/src/rendezvous/transports/MSC4108RendezvousSession.ts new file mode 100644 index 000000000..8b18461ed --- /dev/null +++ b/src/rendezvous/transports/MSC4108RendezvousSession.ts @@ -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; + 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 { + if (this.fetchFn) { + return this.fetchFn(resource, options); + } + return global.fetch(resource, options); + } + + private async getPostEndpoint(): Promise { + 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 { + 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 = { "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 { + 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 = {}; + 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 { + 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 { + 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); + } + } +} diff --git a/src/rendezvous/transports/index.ts b/src/rendezvous/transports/index.ts index 6d8d64245..09349dd25 100644 --- a/src/rendezvous/transports/index.ts +++ b/src/rendezvous/transports/index.ts @@ -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";