Compare commits
20 Commits
v33.0.0
...
v33.1.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 222e95d33f | |||
| 2ee43cade7 | |||
| 395c3cfcd6 | |||
| f95954c233 | |||
| fa5f2d389a | |||
| 9fc557fc6b | |||
| 6436fbb99f | |||
| 87c2ac3ffa | |||
| 43022d5b2f | |||
| a0fadeb4ec | |||
| a3cea8ce7d | |||
| c88487da07 | |||
| 89875b8e31 | |||
| 9c94393d76 | |||
| 131e81401a | |||
| 5c27e30302 | |||
| 8dfb6de3cc | |||
| 042610310f | |||
| 8535604200 | |||
| 3ee64722c5 |
@@ -30,7 +30,7 @@ jobs:
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "33.0.0",
|
||||
"version": "33.1.0-rc.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -105,7 +105,7 @@
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsdoc": "^48.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-tsdoc": "^0.3.0",
|
||||
"eslint-plugin-unicorn": "^53.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
@@ -119,7 +119,7 @@
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"prettier": "3.3.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.25.10",
|
||||
|
||||
@@ -630,6 +630,27 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
@@ -654,6 +675,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports that the user
|
||||
// was invited at the time the event was sent, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
|
||||
// and instead get some other error, since the user should
|
||||
// have gotten the key for the event.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room",
|
||||
async () => {
|
||||
@@ -676,6 +721,29 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
|
||||
newBackendOnly(
|
||||
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
|
||||
async () => {
|
||||
// This tests that when the server reports the user's
|
||||
// membership, and reports that the user was joined, then we
|
||||
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
|
||||
// instead get some other error.
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
|
||||
});
|
||||
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
|
||||
await startClientAndAwaitFirstSync();
|
||||
|
||||
const ev = await sendEventAndAwaitDecryption({
|
||||
unsigned: {
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join",
|
||||
},
|
||||
});
|
||||
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("Decryption fails with Unable to decrypt for other errors", async () => {
|
||||
|
||||
@@ -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
|
||||
* @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"],
|
||||
});
|
||||
|
||||
+282
-240
@@ -61,251 +61,293 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
let httpBackend: MockHttpBackend;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async function () {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
httpBackend = new MockHttpBackend();
|
||||
/**
|
||||
* We need to split the tests into regular ones (these) and ones that use fake timers,
|
||||
* because the fake indexeddb uses timers too and appears make tests cause other tests
|
||||
* to fail if we keep enabling/disabling fake timers within the same test suite.
|
||||
*/
|
||||
describe("non-timed tests", () => {
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
await idbStore.startup();
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
jest.useRealTimers();
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
it("sends a to-device message", async function () {
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
// let the code handle the response to the request so we don't get
|
||||
// log output after the test has finished (apparently stopping the
|
||||
// client in aftereach is not sufficient.)
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("retries on retryImmediately()", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
|
||||
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
await client.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
event_id: "!fake:example.org",
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("splits many messages into multiple HTTP requests", async function () {
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i <= 20; ++i) {
|
||||
batch.batch.push({
|
||||
userId: `@user${i}:example.org`,
|
||||
deviceId: FAKE_DEVICE_ID,
|
||||
payload: FAKE_PAYLOAD,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(
|
||||
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
|
||||
).toBeTruthy();
|
||||
})
|
||||
.respond(200, {});
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(1);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
describe("async tests", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
if (storeType === StoreType.IndexedDB) {
|
||||
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
|
||||
let storeStarted = false;
|
||||
idbStore.startup().then(() => {
|
||||
storeStarted = true;
|
||||
});
|
||||
await flushAndRunTimersUntil(() => storeStarted);
|
||||
store = idbStore;
|
||||
} else {
|
||||
store = new MemoryStore();
|
||||
}
|
||||
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
store,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("retries on error", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
|
||||
|
||||
httpBackend
|
||||
.when("PUT", "/sendToDevice/org.example.foo/")
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual(EXPECTED_BODY);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// flush, as per comment in first test
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("stops retrying on 4xx errors", async function () {
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
|
||||
// Asserting that another request is never made is obviously
|
||||
// a bit tricky - we just flush the queue what should hopefully
|
||||
// be plenty of times and assert that nothing comes through.
|
||||
let tries = 0;
|
||||
await flushAndRunTimersUntil(() => ++tries === 10);
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("honours ratelimiting", async function () {
|
||||
// pick something obscure enough it's unlikley to clash with a
|
||||
// retry delay the algorithm uses anyway
|
||||
const retryDelay = 279 * 1000;
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
retry_after_ms: retryDelay,
|
||||
});
|
||||
|
||||
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
|
||||
|
||||
client
|
||||
.queueToDevice({
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
})
|
||||
.then();
|
||||
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
logger.info("Advancing clock to just before expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(retryDelay - 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.requests.length).toEqual(0);
|
||||
|
||||
logger.info("Advancing clock past expected retry time...");
|
||||
|
||||
jest.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 { 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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1515,6 +1515,44 @@ describe("RustCrypto", () => {
|
||||
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import & export secrets bundle", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto(
|
||||
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
}),
|
||||
testData.TEST_USER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if there is nothing to export", async () => {
|
||||
await expect(rustCrypto.exportSecretsBundle()).rejects.toThrow(
|
||||
"The store doesn't contain any cross-signing keys",
|
||||
);
|
||||
});
|
||||
|
||||
it("should correctly import & export a secrets bundle", async () => {
|
||||
const bundle = {
|
||||
cross_signing: {
|
||||
master_key: "bMnVpkHI4S2wXRxy+IpaKM5PIAUUkl6DE+n0YLIW/qs",
|
||||
user_signing_key: "8tlgLjUrrb/zGJo4YKGhDTIDCEjtJTAS/Sh2AGNLuIo",
|
||||
self_signing_key: "pfDknmP5a0fVVRE54zhkUgJfzbNmvKcNfIWEW796bQs",
|
||||
},
|
||||
backup: {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
key: "bYYv3aFLQ49jMNcOjuTtBY9EKDby2x1m3gfX81nIKRQ",
|
||||
backup_version: "9",
|
||||
},
|
||||
};
|
||||
await rustCrypto.importSecretsBundle(bundle);
|
||||
await expect(rustCrypto.exportSecretsBundle()).resolves.toEqual(expect.objectContaining(bundle));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
import { NamespacedValue, UnstableValue } from "../NamespacedValue";
|
||||
import {
|
||||
PolicyRuleEventContent,
|
||||
RoomAvatarEventContent,
|
||||
@@ -302,7 +302,7 @@ export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matr
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new UnstableValue("membership", "io.element.msc4115.membership");
|
||||
export const UNSIGNED_MEMBERSHIP_FIELD = new NamespacedValue("membership", "io.element.msc4115.membership");
|
||||
|
||||
/**
|
||||
* Mapped type from event type to content type for all specified non-state room events.
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
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 type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
declare module "@matrix-org/matrix-sdk-crypto-wasm" {
|
||||
interface OlmMachine {
|
||||
importSecretsBundle(bundle: RustSdkCryptoJs.SecretsBundle): Promise<void>;
|
||||
exportSecretsBundle(): Promise<RustSdkCryptoJs.SecretsBundle>;
|
||||
}
|
||||
|
||||
interface SecretsBundle {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
to_json(): Promise<{
|
||||
cross_signing: {
|
||||
master_key: string;
|
||||
self_signing_key: string;
|
||||
user_signing_key: string;
|
||||
};
|
||||
backup?: {
|
||||
algorithm: string;
|
||||
key: string;
|
||||
backup_version: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ enum GroupKey {
|
||||
}
|
||||
|
||||
export interface IResultRoomEvents {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: ISearchResult[];
|
||||
count?: number;
|
||||
highlights?: string[];
|
||||
results?: ISearchResult[];
|
||||
state?: { [roomId: string]: IStateEventWithRoomId[] };
|
||||
groups?: {
|
||||
[groupKey in GroupKey]: {
|
||||
|
||||
+1
-1
@@ -7109,7 +7109,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// append the new results to our existing results
|
||||
const resultsLength = roomEvents.results?.length ?? 0;
|
||||
for (let i = 0; i < resultsLength; i++) {
|
||||
const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
|
||||
const sr = SearchResult.fromJson(roomEvents.results![i], mapper);
|
||||
const room = this.getRoom(sr.context.getEvent().getRoomId());
|
||||
if (room) {
|
||||
// Copy over a known event sender if we can
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import type { IMegolmSessionData } from "./@types/crypto";
|
||||
import { Room } from "./models/room";
|
||||
import { DeviceMap } from "./models/device";
|
||||
@@ -532,6 +533,23 @@ export interface CryptoApi {
|
||||
* to false.
|
||||
*/
|
||||
startDehydration(createNewKey?: boolean): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Import/export of secret keys
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Export secrets bundle for transmitting to another device as part of OIDC QR login
|
||||
*/
|
||||
exportSecretsBundle?(): Promise<Awaited<ReturnType<SecretsBundle["to_json"]>>>;
|
||||
|
||||
/**
|
||||
* Import secrets bundle transmitted from another device.
|
||||
* @param secrets - The secrets bundle received from the other device
|
||||
*/
|
||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
|
||||
+2
-7
@@ -77,7 +77,6 @@ export interface IUnsigned {
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
[UNSIGNED_MEMBERSHIP_FIELD.name]?: Membership | string;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
@@ -717,13 +716,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @returns The user's room membership, or `undefined` if the server does
|
||||
* not report it.
|
||||
*/
|
||||
public getMembershipAtEvent(): Membership | string | undefined {
|
||||
public getMembershipAtEvent(): Optional<Membership | string> {
|
||||
const unsigned = this.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_MEMBERSHIP_FIELD.name] === "string") {
|
||||
return unsigned[UNSIGNED_MEMBERSHIP_FIELD.name];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -59,6 +64,9 @@ const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc390
|
||||
* Implements MSC3906 to allow a user to sign in on a new device using QR code.
|
||||
* This implementation only supports generating a QR code on a device that is already signed in.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3906Rendezvous {
|
||||
private newDeviceId?: string;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,47 @@ limitations under the License.
|
||||
|
||||
export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void;
|
||||
|
||||
export enum RendezvousFailureReason {
|
||||
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",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -69,6 +69,9 @@ async function importKey(key: Uint8Array): Promise<CryptoKey> {
|
||||
* Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903)
|
||||
* X25519/ECDH key agreement based secure rendezvous channel.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
|
||||
private olmSAS?: SAS;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated in favour of MSC4108-based implementation
|
||||
*/
|
||||
export * from "./MSC3903ECDHv2RendezvousChannel";
|
||||
export * from "./MSC4108SecureChannel";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -20,7 +20,7 @@ import { logger } from "../../logger";
|
||||
import { sleep } from "../../utils";
|
||||
import {
|
||||
RendezvousFailureListener,
|
||||
RendezvousFailureReason,
|
||||
LegacyRendezvousFailureReason as RendezvousFailureReason,
|
||||
RendezvousTransport,
|
||||
RendezvousTransportDetails,
|
||||
} from "..";
|
||||
@@ -37,6 +37,9 @@ export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousT
|
||||
* Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886)
|
||||
* simple HTTP rendezvous protocol.
|
||||
* Note that this is UNSTABLE and may have breaking changes without notice.
|
||||
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
|
||||
* However, we want to keep this implementation around for some time.
|
||||
* TODO: define an end-of-life date for this implementation.
|
||||
*/
|
||||
export class MSC3886SimpleHttpRendezvousTransport<T extends {}> implements RendezvousTransport<T> {
|
||||
private uri?: string;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated in favour of MSC4108-based implementation
|
||||
*/
|
||||
export * from "./MSC3886SimpleHttpRendezvousTransport";
|
||||
export * from "./MSC4108RendezvousSession";
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CrossSigningKey,
|
||||
CrossSigningKeyInfo,
|
||||
CrossSigningStatus,
|
||||
CryptoApi,
|
||||
CryptoCallbacks,
|
||||
Curve25519AuthData,
|
||||
DecryptionFailureCode,
|
||||
@@ -308,6 +309,40 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
|
||||
}
|
||||
|
||||
const authData = <Curve25519AuthData>backupInfo.auth_data;
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
|
||||
|
||||
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
||||
throw new Error(`getBackupDecryptor key mismatch error`);
|
||||
}
|
||||
|
||||
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public async importBackedUpRoomKeys(
|
||||
keys: IMegolmSessionData[],
|
||||
backupVersion: string,
|
||||
opts?: ImportRoomKeysOpts,
|
||||
): Promise<void> {
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// CryptoApi implementation
|
||||
@@ -1169,6 +1204,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
* Signs the given object with the current device and current identity (if available).
|
||||
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
|
||||
*
|
||||
* Helper for {@link RustCrypto#resetKeyBackup}.
|
||||
*
|
||||
* @param obj - The object to sign
|
||||
*/
|
||||
private async signObject<T extends ISignableObject & object>(obj: T): Promise<void> {
|
||||
@@ -1192,48 +1229,14 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
|
||||
*/
|
||||
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
|
||||
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
|
||||
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
|
||||
}
|
||||
|
||||
const authData = <Curve25519AuthData>backupInfo.auth_data;
|
||||
|
||||
if (!(privKey instanceof Uint8Array)) {
|
||||
throw new Error(`getBackupDecryptor expects Uint8Array`);
|
||||
}
|
||||
|
||||
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
|
||||
|
||||
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
|
||||
throw new Error(`getBackupDecryptor key mismatch error`);
|
||||
}
|
||||
|
||||
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
|
||||
*/
|
||||
public async importBackedUpRoomKeys(
|
||||
keys: IMegolmSessionData[],
|
||||
backupVersion: string,
|
||||
opts?: ImportRoomKeysOpts,
|
||||
): Promise<void> {
|
||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
|
||||
* Implementation of {@link CryptoApi#isDehydrationSupported}.
|
||||
*/
|
||||
public async isDehydrationSupported(): Promise<boolean> {
|
||||
return await this.dehydratedDeviceManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoBackend#startDehydration}.
|
||||
* Implementation of {@link CryptoApi#startDehydration}.
|
||||
*/
|
||||
public async startDehydration(createNewKey?: boolean): Promise<void> {
|
||||
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
|
||||
@@ -1242,6 +1245,26 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
|
||||
return await this.dehydratedDeviceManager.start(createNewKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#importSecretsBundle}.
|
||||
*/
|
||||
public async importSecretsBundle(
|
||||
secrets: Parameters<NonNullable<CryptoApi["importSecretsBundle"]>>[0],
|
||||
): Promise<void> {
|
||||
const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets);
|
||||
await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#exportSecretsBundle}.
|
||||
*/
|
||||
public async exportSecretsBundle(): ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>> {
|
||||
const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle();
|
||||
const secrets = secretsBundle.to_json();
|
||||
secretsBundle.free();
|
||||
return secrets;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
|
||||
+30
-12
@@ -2381,22 +2381,40 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
|
||||
// RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF before v113
|
||||
if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return;
|
||||
|
||||
const recvCodecs = RTCRtpReceiver.getCapabilities("video")!.codecs;
|
||||
const sendCodecs = RTCRtpSender.getCapabilities("video")!.codecs;
|
||||
const codecs = [...sendCodecs, ...recvCodecs];
|
||||
|
||||
for (const codec of codecs) {
|
||||
if (codec.mimeType === "video/rtx") {
|
||||
const rtxCodecIndex = codecs.indexOf(codec);
|
||||
codecs.splice(rtxCodecIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const screenshareVideoTransceiver = this.transceivers.get(
|
||||
getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "video"),
|
||||
);
|
||||
|
||||
// setCodecPreferences isn't supported on FF (as of v113)
|
||||
screenshareVideoTransceiver?.setCodecPreferences?.(codecs);
|
||||
if (!screenshareVideoTransceiver || !screenshareVideoTransceiver.setCodecPreferences) return;
|
||||
|
||||
const recvCodecs = RTCRtpReceiver.getCapabilities("video")!.codecs;
|
||||
const sendCodecs = RTCRtpSender.getCapabilities("video")!.codecs;
|
||||
const codecs = [];
|
||||
|
||||
for (const codec of [...recvCodecs, ...sendCodecs]) {
|
||||
if (codec.mimeType !== "video/rtx") {
|
||||
codecs.push(codec);
|
||||
try {
|
||||
screenshareVideoTransceiver.setCodecPreferences(codecs);
|
||||
} catch (e) {
|
||||
// Specifically, Chrome around version 125 and Electron 30 (which is Chromium 124) return an H.264 codec in
|
||||
// the sender's capabilities but throw when you try to set it. Hence... this mess.
|
||||
// Specifically, that codec is:
|
||||
// {
|
||||
// clockRate: 90000,
|
||||
// mimeType: "video/H264",
|
||||
// sdpFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034",
|
||||
// }
|
||||
logger.info(
|
||||
"Working around buggy WebRTC impl: claimed to support codec but threw when setting codec preferences",
|
||||
codec,
|
||||
e,
|
||||
);
|
||||
codecs.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onNegotiationNeeded = async (): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user