You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-06 12:02:40 +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:
committed by
GitHub
parent
87c2ac3ffa
commit
6436fbb99f
358
spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts
Normal file
358
spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts
Normal 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"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
|
|||||||
* @param issuer used as the base for all other urls
|
* @param issuer used as the base for all other urls
|
||||||
* @returns ValidatedIssuerMetadata
|
* @returns ValidatedIssuerMetadata
|
||||||
*/
|
*/
|
||||||
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
export const mockOpenIdConfiguration = (
|
||||||
|
issuer = "https://auth.org/",
|
||||||
|
additionalGrantTypes: string[] = [],
|
||||||
|
): ValidatedIssuerMetadata => ({
|
||||||
issuer,
|
issuer,
|
||||||
revocation_endpoint: issuer + "revoke",
|
revocation_endpoint: issuer + "revoke",
|
||||||
token_endpoint: issuer + "token",
|
token_endpoint: issuer + "token",
|
||||||
@@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
|
|||||||
device_authorization_endpoint: issuer + "device",
|
device_authorization_endpoint: issuer + "device",
|
||||||
jwks_uri: issuer + "jwks",
|
jwks_uri: issuer + "jwks",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
|
||||||
code_challenge_methods_supported: ["S256"],
|
code_challenge_methods_supported: ["S256"],
|
||||||
});
|
});
|
||||||
|
341
spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts
Normal file
341
spec/unit/rendezvous/MSC4108RendezvousSession.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
126
spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts
Normal file
126
spec/unit/rendezvous/channels/MSC4108SecureChannel.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -15,7 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import "../../olm-loader";
|
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 { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||||
import { decodeBase64 } from "../../../src/base64";
|
import { decodeBase64 } from "../../../src/base64";
|
||||||
import { DummyTransport } from "./DummyTransport";
|
import { DummyTransport } from "./DummyTransport";
|
||||||
|
@@ -17,7 +17,12 @@ limitations under the License.
|
|||||||
import MockHttpBackend from "matrix-mock-request";
|
import MockHttpBackend from "matrix-mock-request";
|
||||||
|
|
||||||
import "../../olm-loader";
|
import "../../olm-loader";
|
||||||
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
import {
|
||||||
|
MSC3906Rendezvous,
|
||||||
|
RendezvousCode,
|
||||||
|
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||||
|
RendezvousIntent,
|
||||||
|
} from "../../../src/rendezvous";
|
||||||
import {
|
import {
|
||||||
ECDHv2RendezvousCode as ECDHRendezvousCode,
|
ECDHv2RendezvousCode as ECDHRendezvousCode,
|
||||||
MSC3903ECDHPayload,
|
MSC3903ECDHPayload,
|
||||||
|
@@ -17,7 +17,7 @@ limitations under the License.
|
|||||||
import MockHttpBackend from "matrix-mock-request";
|
import MockHttpBackend from "matrix-mock-request";
|
||||||
|
|
||||||
import type { MatrixClient } from "../../../src";
|
import type { MatrixClient } from "../../../src";
|
||||||
import { RendezvousFailureReason } from "../../../src/rendezvous";
|
import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous";
|
||||||
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
|
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
|
||||||
|
|
||||||
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {
|
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {
|
||||||
|
@@ -49,6 +49,8 @@ interface OidcRegistrationRequestBody {
|
|||||||
application_type: "web" | "native";
|
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
|
* Attempts dynamic registration against the configured registration endpoint
|
||||||
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
|
||||||
|
@@ -16,7 +16,12 @@ limitations under the License.
|
|||||||
|
|
||||||
import { UnstableValue } from "matrix-events-sdk";
|
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 { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
|
||||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
|
439
src/rendezvous/MSC4108SignInWithQR.ts
Normal file
439
src/rendezvous/MSC4108SignInWithQR.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -14,23 +14,49 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
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 {
|
export enum LegacyRendezvousFailureReason {
|
||||||
UserDeclined = "user_declined",
|
UserDeclined = "user_declined",
|
||||||
OtherDeviceNotSignedIn = "other_device_not_signed_in",
|
|
||||||
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
|
|
||||||
Unknown = "unknown",
|
Unknown = "unknown",
|
||||||
Expired = "expired",
|
Expired = "expired",
|
||||||
UserCancelled = "user_cancelled",
|
UserCancelled = "user_cancelled",
|
||||||
InvalidCode = "invalid_code",
|
|
||||||
UnsupportedAlgorithm = "unsupported_algorithm",
|
UnsupportedAlgorithm = "unsupported_algorithm",
|
||||||
DataMismatch = "data_mismatch",
|
UnsupportedProtocol = "unsupported_protocol",
|
||||||
UnsupportedTransport = "unsupported_transport",
|
|
||||||
HomeserverLacksSupport = "homeserver_lacks_support",
|
HomeserverLacksSupport = "homeserver_lacks_support",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export enum MSC4108FailureReason {
|
||||||
* @deprecated legacy re-export
|
AuthorizationExpired = "authorization_expired",
|
||||||
*/
|
DeviceAlreadyExists = "device_already_exists",
|
||||||
export { LegacyRendezvousFailureReason as RendezvousFailureReason };
|
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",
|
||||||
|
}
|
||||||
|
@@ -23,7 +23,7 @@ import {
|
|||||||
RendezvousChannel,
|
RendezvousChannel,
|
||||||
RendezvousTransportDetails,
|
RendezvousTransportDetails,
|
||||||
RendezvousTransport,
|
RendezvousTransport,
|
||||||
RendezvousFailureReason,
|
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||||
} from "..";
|
} from "..";
|
||||||
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
|
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
|
||||||
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
|
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
|
||||||
|
270
src/rendezvous/channels/MSC4108SecureChannel.ts
Normal file
270
src/rendezvous/channels/MSC4108SecureChannel.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in favour of MSC4108-based implementation
|
||||||
|
*/
|
||||||
export * from "./MSC3903ECDHv2RendezvousChannel";
|
export * from "./MSC3903ECDHv2RendezvousChannel";
|
||||||
|
export * from "./MSC4108SecureChannel";
|
||||||
|
@@ -14,10 +14,16 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in favour of MSC4108-based implementation
|
||||||
|
*/
|
||||||
export * from "./MSC3906Rendezvous";
|
export * from "./MSC3906Rendezvous";
|
||||||
|
export * from "./MSC4108SignInWithQR";
|
||||||
export * from "./RendezvousChannel";
|
export * from "./RendezvousChannel";
|
||||||
export * from "./RendezvousCode";
|
export * from "./RendezvousCode";
|
||||||
export * from "./RendezvousError";
|
export * from "./RendezvousError";
|
||||||
export * from "./RendezvousFailureReason";
|
export * from "./RendezvousFailureReason";
|
||||||
export * from "./RendezvousIntent";
|
export * from "./RendezvousIntent";
|
||||||
export * from "./RendezvousTransport";
|
export * from "./RendezvousTransport";
|
||||||
|
export * from "./transports";
|
||||||
|
export * from "./channels";
|
||||||
|
@@ -20,7 +20,7 @@ import { logger } from "../../logger";
|
|||||||
import { sleep } from "../../utils";
|
import { sleep } from "../../utils";
|
||||||
import {
|
import {
|
||||||
RendezvousFailureListener,
|
RendezvousFailureListener,
|
||||||
RendezvousFailureReason,
|
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||||
RendezvousTransport,
|
RendezvousTransport,
|
||||||
RendezvousTransportDetails,
|
RendezvousTransportDetails,
|
||||||
} from "..";
|
} from "..";
|
||||||
|
270
src/rendezvous/transports/MSC4108RendezvousSession.ts
Normal file
270
src/rendezvous/transports/MSC4108RendezvousSession.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in favour of MSC4108-based implementation
|
||||||
|
*/
|
||||||
export * from "./MSC3886SimpleHttpRendezvousTransport";
|
export * from "./MSC3886SimpleHttpRendezvousTransport";
|
||||||
|
export * from "./MSC4108RendezvousSession";
|
||||||
|
Reference in New Issue
Block a user