Compare commits

...

49 Commits

Author SHA1 Message Date
RiotRobot 07476a0ae0 v20.1.0-rc.1 2022-10-04 14:01:35 +01:00
RiotRobot 6348704bec Prepare changelog for v20.1.0-rc.1 2022-10-04 14:01:34 +01:00
mcalinghee f84a33910c Unexpected ignored self key request when it's not shared history (#2724)
* ignore forwarded key process also if the user is not the same
2022-10-04 14:31:21 +02:00
Germain 0ccf7c50f2 Add custom error message for restricted import (#2727) 2022-10-04 09:59:20 +01:00
Šimon Brandner ead33003b7 Check for the spec version when determining private read receipt support (#2722) 2022-10-04 06:50:42 +00:00
Faye Duxovni 7d5360a00f Rename redecryption-related function arguments for clarity (#2709) 2022-10-03 19:51:30 -04:00
renovate[bot] 4b283015ba Lock file maintenance (#2721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-03 08:30:28 +01:00
Michael Telatynski 887e15aac5 Fix IDB initial migration handling causing spurious lazy loading upgrade loops (#2718) 2022-09-30 10:41:04 +00:00
RiotRobot a83d80f502 Merge branch 'master' into develop 2022-09-30 10:59:52 +01:00
RiotRobot 6166a8f7fd v20.0.2 2022-09-30 10:58:34 +01:00
RiotRobot 3efc18cfde Prepare changelog for v20.0.2 2022-09-30 10:58:33 +01:00
ElementRobot 5520aa3e2a [Backport staging] Fix issue in sync when crypto is not supported by client (#2716)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Stanislav Demydiuk <stas-demydiuk@users.noreply.github.com>
2022-09-30 10:57:04 +01:00
Michael Telatynski 5afe373446 Fix release-npm.yml dist-tag npm token passing mechanism (#2717) 2022-09-30 10:56:36 +01:00
Stanislav Demydiuk 9bb5afe5c0 Fix issue in sync when crypto is not supported by client (#2715)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-09-30 08:12:31 +00:00
Michael Telatynski f349663329 Add CI to protect against mixing src and lib imports (#2704) 2022-09-30 09:05:28 +01:00
Faye Duxovni f398e3564d Calculate IndexedDB versions automatically to reduce repeated information and possibilities for error (#2713) 2022-09-29 16:25:19 -04:00
RiotRobot 91171afddd Merge branch 'master' into develop 2022-09-28 17:48:45 +01:00
RiotRobot b54c9d689a v20.0.1 2022-09-28 17:47:24 +01:00
RiotRobot 4e69d7c9ac Prepare changelog for v20.0.1 2022-09-28 17:47:23 +01:00
ElementRobot bf9f595984 [Backport staging] Fix missing return when receiving an invitation without shared history (#2711)
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-09-28 16:40:58 +00:00
Faye Duxovni f410e71bfa Fix missing return when receiving an invitation without shared history (#2710) 2022-09-28 17:36:09 +01:00
RiotRobot 83fca5b57d Merge branch 'master' into develop 2022-09-28 15:46:11 +01:00
RiotRobot 90052670e7 v20.0.0 2022-09-28 15:44:47 +01:00
RiotRobot db49a1a623 Prepare changelog for v20.0.0 2022-09-28 15:44:47 +01:00
ElementRobot 45330c6418 [Backport staging] Bump IDB crypto store version (#2708)
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-09-28 15:43:43 +01:00
Faye Duxovni 4ba083e6af Bump IDB crypto store version (#2705)
* Bump IDB crypto store version

* lint fix
2022-09-28 15:39:37 +01:00
Germain 0403e4bedc Fix incorrect MSC3890 unstable prefix (#2703) 2022-09-28 15:37:13 +02:00
RiotRobot 14aa7846a5 Merge branch 'master' into develop 2022-09-28 14:05:21 +01:00
RiotRobot 2d067ad957 v19.7.0 2022-09-28 14:03:53 +01:00
RiotRobot 418aa3ff6a Prepare changelog for v19.7.0 2022-09-28 14:03:52 +01:00
RiotRobot a587d7c360 Resolve multiple CVEs
CVE-2022-39249
CVE-2022-39250
CVE-2022-39251
CVE-2022-39236
2022-09-28 13:55:15 +01:00
RiotRobot 45348a354e Resetting package fields for development 2022-09-27 16:48:03 +01:00
RiotRobot fa3339fc84 Merge branch 'master' into develop 2022-09-27 16:48:00 +01:00
Germain b332c6c4b9 Account data can return undefined (#2701) 2022-09-27 12:47:05 +00:00
Germain 209a101be7 Add local notification settings capability (#2700) 2022-09-27 11:41:20 +01:00
Michael Telatynski efbf5479d1 Remove redundant rel_branch logic (#2698) 2022-09-26 10:05:58 +01:00
Hugh Nimmo-Smith dacef048be Typings for MSC3824 (#2691) 2022-09-23 16:23:50 -06:00
Hugh Nimmo-Smith caadc6f95b Implementation of MSC3882 login token request (#2687) 2022-09-22 16:36:37 +01:00
Hugh Nimmo-Smith 39bc7e2bb3 Refer to explicit Matrix spec versions instead of Latest (#2668) 2022-09-22 15:28:11 +00:00
Hugh Nimmo-Smith 040f012350 Typings for MSC2965 OIDC provider discovery (#2424) 2022-09-22 16:27:13 +01:00
Hugh Nimmo-Smith 8b2b677f92 Add return type for loginFlows() (#2669)
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-09-22 16:25:01 +01:00
Germain 2b1fab928b Add unstable device_id field for MSC3881 (#2688) 2022-09-22 15:44:54 +02:00
Germain 516f52c5a4 Support to remotely toggle push notifications (#2686) 2022-09-22 08:37:54 +00:00
Michael Telatynski 8599a98b47 Fix backpagination at end logic being spec non-conforming (#2680) 2022-09-21 16:35:07 +01:00
renovate[bot] 7e24cb6cae Update jest monorepo to v29.0.3 (#2682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 09:25:43 +01:00
renovate[bot] 38aa8d18c0 Update all (#2684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 09:13:36 +01:00
Germain 2967ee6309 Read receipts for threads (#2635) 2022-09-21 07:50:44 +00:00
renovate[bot] 2e10b6065c Update typescript-eslint monorepo to v5.38.0 (#2685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 08:42:30 +02:00
renovate[bot] 69057ee035 Update babel monorepo to v7.19.1 (#2681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-21 08:25:16 +02:00
65 changed files with 3429 additions and 1197 deletions
+21 -1
View File
@@ -1,14 +1,22 @@
module.exports = {
plugins: [
"matrix-org",
"import",
],
extends: [
"plugin:matrix-org/babel",
"plugin:import/typescript",
],
env: {
browser: true,
node: true,
},
settings: {
"import/resolver": {
typescript: true,
node: true,
},
},
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: {
@@ -35,7 +43,19 @@ module.exports = {
"no-console": "error",
// restrict EventEmitters to force callers to use TypedEventEmitter
"no-restricted-imports": ["error", "events"],
"no-restricted-imports": ["error", {
name: "events",
message: "Please use TypedEventEmitter instead"
}],
"import/no-restricted-paths": ["error", {
"zones": [{
"target": "./src/",
"from": "./src/index.ts",
"message": "The package index is dynamic between src and lib depending on " +
"whether release or development, target the specific module or matrix.ts instead",
}],
}],
},
overrides: [{
files: [
+2 -1
View File
@@ -36,5 +36,6 @@ jobs:
package=$(cat package.json | jq -er .name)
npm dist-tag add "$package@$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc
INPUT_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.version }}
+10
View File
@@ -23,6 +23,16 @@ jobs:
- name: Typecheck
run: "yarn run lint:types"
- name: Switch js-sdk to release mode
run: |
scripts/switch_package_to_release.js
yarn install
yarn run build:compile
yarn run build:types
- name: Typecheck (release mode)
run: "yarn run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
+42
View File
@@ -1,3 +1,45 @@
Changes in [20.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.1.0-rc.1) (2022-10-04)
============================================================================================================
## ✨ Features
* Add local notification settings capability ([\#2700](https://github.com/matrix-org/matrix-js-sdk/pull/2700)).
* Implementation of MSC3882 login token request ([\#2687](https://github.com/matrix-org/matrix-js-sdk/pull/2687)). Contributed by @hughns.
* Typings for MSC2965 OIDC provider discovery ([\#2424](https://github.com/matrix-org/matrix-js-sdk/pull/2424)). Contributed by @hughns.
* Support to remotely toggle push notifications ([\#2686](https://github.com/matrix-org/matrix-js-sdk/pull/2686)).
* Read receipts for threads ([\#2635](https://github.com/matrix-org/matrix-js-sdk/pull/2635)).
## 🐛 Bug Fixes
* Unexpected ignored self key request when it's not shared history ([\#2724](https://github.com/matrix-org/matrix-js-sdk/pull/2724)). Contributed by @mcalinghee.
* Fix IDB initial migration handling causing spurious lazy loading upgrade loops ([\#2718](https://github.com/matrix-org/matrix-js-sdk/pull/2718)). Fixes vector-im/element-web#23377.
* Fix backpagination at end logic being spec non-conforming ([\#2680](https://github.com/matrix-org/matrix-js-sdk/pull/2680)). Fixes vector-im/element-web#22784.
Changes in [20.0.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.2) (2022-09-30)
==================================================================================================
## 🐛 Bug Fixes
* Fix issue in sync when crypto is not supported by client ([\#2715](https://github.com/matrix-org/matrix-js-sdk/pull/2715)). Contributed by @stas-demydiuk.
Changes in [20.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.1) (2022-09-28)
==================================================================================================
## 🐛 Bug Fixes
* Fix missing return when receiving an invitation without shared history ([\#2710](https://github.com/matrix-org/matrix-js-sdk/pull/2710)).
Changes in [20.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v20.0.0) (2022-09-28)
==================================================================================================
## 🚨 BREAKING CHANGES
* Bump IDB crypto store version ([\#2705](https://github.com/matrix-org/matrix-js-sdk/pull/2705)).
Changes in [19.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.7.0) (2022-09-28)
==================================================================================================
## 🔒 Security
* Fix for [CVE-2022-39249](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39249)
* Fix for [CVE-2022-39250](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39250)
* Fix for [CVE-2022-39251](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39251)
* Fix for [CVE-2022-39236](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D39236)
Changes in [19.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.6.0) (2022-09-27)
==================================================================================================
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "19.6.0",
"version": "20.1.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
@@ -92,9 +92,10 @@
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"eslint": "8.23.0",
"eslint": "8.23.1",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-matrix-org": "^0.6.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
+1 -13
View File
@@ -135,7 +135,6 @@ yarn install --ignore-scripts --pure-lockfile
# ignore leading v on release
release="${1#v}"
tag="v${release}"
rel_branch="release-$tag"
prerelease=0
# We check if this build is a prerelease by looking to
@@ -150,18 +149,7 @@ else
read -p "Making a FINAL RELEASE, press enter to continue " REPLY
fi
# We might already be on the release branch, in which case, yay
# If we're on any branch starting with 'release', or the staging branch
# we don't create a separate release branch (this allows us to use the same
# release branch for releases and release candidates).
curbranch=$(git symbolic-ref --short HEAD)
if [[ "$curbranch" != release* && "$curbranch" != "staging" ]]; then
echo "Creating release branch"
git checkout -b "$rel_branch"
else
echo "Using current branch ($curbranch) for release"
rel_branch=$curbranch
fi
rel_branch=$(git symbolic-ref --short HEAD)
if [ -z "$skip_changelog" ]; then
echo "Generating changelog"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
const fsProm = require('fs/promises');
const PKGJSON = 'package.json';
async function main() {
const pkgJson = JSON.parse(await fsProm.readFile(PKGJSON, 'utf8'));
for (const field of ['main', 'typings']) {
if (pkgJson["matrix_lib_"+field] !== undefined) {
pkgJson[field] = pkgJson["matrix_lib_"+field];
}
}
await fsProm.writeFile(PKGJSON, JSON.stringify(pkgJson, null, 2));
}
main();
+7 -1
View File
@@ -132,7 +132,7 @@ async function aliDownloadsKeys(): Promise<void> {
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
await Promise.all([p1(), p2()]);
await aliTestClient.client.crypto.deviceList.saveIfDirty();
await aliTestClient.client.crypto!.deviceList.saveIfDirty();
// @ts-ignore - protected
aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId];
@@ -494,6 +494,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -504,6 +505,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -567,6 +569,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -584,6 +587,9 @@ describe("MatrixClient crypto", () => {
await firstSync(bobTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
bobTestClient.httpBackend.when('POST', '/keys/query').respond(
200, {},
);
await bobRecvMessage();
await bobEnablesEncryption();
const ciphertext = await bobSendsReplyMessage();
@@ -833,6 +833,45 @@ describe("MatrixClient event timelines", function() {
]);
});
it("should stop paginating when it encounters no `end` token", () => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" +
encodeURIComponent(EVENTS[0].event_id))
.respond(200, () => ({
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
}));
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.check(function(req) {
const params = req.queryParams;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
}).respond(200, () => ({
start: "start_token0",
chunk: [],
}));
return Promise.all([
(async () => {
const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id);
const success = await client.paginateEventTimeline(tl, { backwards: true });
expect(success).toBeFalsy();
expect(tl.getEvents().length).toEqual(1);
expect(tl.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null);
expect(tl.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
})(),
httpBackend.flushAllExpected(),
]);
});
it("should allow you to paginate forwards", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
+24
View File
@@ -889,6 +889,7 @@ describe("MatrixClient", function() {
};
const prom = client.getPushers();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {});
httpBackend.when("GET", "/pushers").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
@@ -1074,6 +1075,29 @@ describe("MatrixClient", function() {
expect(await prom).toStrictEqual(response);
});
});
describe("requestLoginToken", () => {
it("should hit the expected API endpoint with UIA", async () => {
const response = {};
const uiaData = { foo: "baa" };
const prom = client.requestLoginToken(uiaData);
httpBackend
.when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData })
.respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
it("should hit the expected API endpoint without UIA", async () => {
const response = {};
const prom = client.requestLoginToken();
httpBackend
.when("POST", "/unstable/org.matrix.msc3882/login/token", {})
.respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
});
function withThreadId(event, newThreadId) {
+111
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import 'fake-indexeddb/auto';
import { Optional } from "matrix-events-sdk/lib/types";
import HttpBackend from "matrix-mock-request";
@@ -25,6 +27,8 @@ import {
RoomMemberEvent,
UNSTABLE_MSC2716_MARKER,
MatrixClient,
ClientEvent,
IndexedDBCryptoStore,
} from "../../src";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -87,6 +91,8 @@ describe("MatrixClient syncing", () => {
});
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
await client.initCrypto();
const roomId = "!cycles:example.org";
// First sync: an invite
@@ -219,6 +225,44 @@ describe("MatrixClient syncing", () => {
return httpBackend!.flushAllExpected();
});
it("should emit ClientEvent.Room when invited while crypto is disabled", async () => {
const roomId = "!invite:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
httpBackend!.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
client!.once(ClientEvent.Room, (room) => {
fires++;
expect(room.roomId).toBe(roomId);
});
// noinspection ES6MissingAwait
client!.startClient();
await httpBackend!.flushAllExpected();
expect(fires).toBe(1);
});
});
describe("initial sync", () => {
@@ -1426,3 +1470,70 @@ describe("MatrixClient syncing", () => {
return utils.syncPromise(client, numSyncs);
}
});
describe("MatrixClient syncing (IndexedDB version)", () => {
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const syncData = {
next_batch: "batch_token",
rooms: {},
presence: {},
};
it("should emit ClientEvent.Room when invited while using indexeddb crypto store", async () => {
const idbTestClient = new TestClient(
selfUserId,
"DEVICE",
selfAccessToken,
undefined,
{ cryptoStore: new IndexedDBCryptoStore(global.indexedDB, "tests") },
);
const idbHttpBackend = idbTestClient.httpBackend;
const idbClient = idbTestClient.client;
idbHttpBackend.when("GET", "/versions").respond(200, {});
idbHttpBackend.when("GET", "/pushrules").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
await idbClient.initCrypto();
const roomId = "!invite:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
idbHttpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
idbClient.once(ClientEvent.Room, (room) => {
fires++;
expect(room.roomId).toBe(roomId);
});
// noinspection ES6MissingAwait
idbClient.startClient();
await idbHttpBackend.flushAllExpected();
expect(fires).toBe(1);
idbHttpBackend.verifyNoOutstandingExpectation();
idbClient.stopClient();
idbHttpBackend.stop();
});
});
+316 -1
View File
@@ -29,8 +29,11 @@ import {
IDownloadKeyResult,
MatrixEvent,
MatrixEventEvent,
IndexedDBCryptoStore,
Room,
} from "../../src/matrix";
import { IDeviceKeys } from "../../src/crypto/dehydration";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
const ROOM_ID = "!room:id";
@@ -280,10 +283,13 @@ describe("megolm", () => {
it("Alice receives a megolm message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -326,10 +332,13 @@ describe("megolm", () => {
it("Alice receives a megolm message before the session keys", async () => {
// https://github.com/vector-im/element-web/issues/2273
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event, but don't send it yet
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -383,10 +392,13 @@ describe("megolm", () => {
it("Alice gets a second room_key message", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted1 = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -468,6 +480,9 @@ describe("megolm", () => {
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
await Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test').then(() => {
@@ -541,13 +556,16 @@ describe("megolm", () => {
logger.log('Forcing alice to download our device keys');
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
await Promise.all([
aliceTestClient.client.downloadKeys(['@bob:xyz']),
aliceTestClient.httpBackend.flush('/keys/query', 1),
aliceTestClient.httpBackend.flush('/keys/query', 2),
]);
logger.log('Telling alice to block our device');
@@ -592,6 +610,9 @@ describe("megolm", () => {
logger.log("Fetching bob's devices and marking known");
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
@@ -786,6 +807,10 @@ describe("megolm", () => {
logger.log('Forcing alice to download our device keys');
const downloadPromise = aliceTestClient.client.downloadKeys(['@bob:xyz']);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, getTestKeysQueryResponse('@bob:xyz'),
);
// so will this.
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, 'test')
.then(() => {
@@ -805,9 +830,12 @@ describe("megolm", () => {
it("Alice exports megolm keys and imports them to a new device", async () => {
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} }, failures: {} });
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
// establish an olm session with alice
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
@@ -855,6 +883,8 @@ describe("megolm", () => {
await aliceTestClient.client.importRoomKeys(exported);
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
const syncResponse = {
next_batch: 1,
rooms: {
@@ -927,10 +957,13 @@ describe("megolm", () => {
it("Alice can decrypt a message with falsey content", async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -985,10 +1018,13 @@ describe("megolm", () => {
"should successfully decrypt bundled redaction events that don't include a room_id in their /sync data",
async () => {
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
@@ -1045,4 +1081,283 @@ describe("megolm", () => {
expect(redactionEvent.content.reason).toEqual("redaction test");
},
);
it("Alice receives shared history before being invited to a room by the sharer", async () => {
const beccaTestClient = new TestClient(
"@becca:localhost", "foobar", "bazquux",
);
await beccaTestClient.client.initCrypto();
await aliceTestClient.start();
aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
await beccaTestClient.start();
const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {});
beccaTestClient.client.store.storeRoom(beccaRoom);
await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" });
const event = new MatrixEvent({
type: "m.room.message",
sender: "@becca:localhost",
room_id: ROOM_ID,
event_id: "$1",
content: {
msgtype: "m.text",
body: "test message",
},
});
await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
const device = new DeviceInfo(beccaTestClient.client.deviceId);
aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device;
aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId();
// Create an olm session for Becca and Alice's devices
const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload();
const aliceOtkId = Object.keys(aliceOtks)[0];
const aliceOtk = aliceOtks[aliceOtkId];
const p2pSession = new global.Olm.Session();
await beccaTestClient.client.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => {
const account = new global.Olm.Account();
try {
account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount);
p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key);
} finally {
account.free();
}
});
},
);
const content = event.getWireContent();
const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey(
ROOM_ID,
content.sender_key,
content.session_id,
);
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderKey: beccaTestClient.getDeviceKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
plaincontent: {
"algorithm": 'm.megolm.v1.aes-sha2',
"room_id": ROOM_ID,
"sender_key": content.sender_key,
"sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key,
"session_id": content.session_id,
"session_key": groupSessionKey.key,
"chain_index": groupSessionKey.chain_index,
"forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain,
"org.matrix.msc3061.shared_history": true,
},
plaintype: 'm.forwarded_room_key',
});
// Alice receives shared history
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
to_device: { events: [encryptedForwardedKey] },
});
await aliceTestClient.flushSync();
// Alice is invited to the room by Becca
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [
{
sender: '@becca:localhost',
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
},
{
sender: '@becca:localhost',
type: 'm.room.member',
state_key: '@alice:localhost',
content: {
membership: 'invite',
},
},
] } } } },
});
await aliceTestClient.flushSync();
// Alice has joined the room
aliceTestClient.httpBackend.when("GET", "/sync").respond(
200, getSyncResponse(["@alice:localhost", "@becca:localhost"]),
);
await aliceTestClient.flushSync();
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 4,
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [event.event] } },
},
},
});
await aliceTestClient.flushSync();
const room = aliceTestClient.client.getRoom(ROOM_ID);
const roomEvent = room.getLiveTimeline().getEvents()[0];
expect(roomEvent.isEncrypted()).toBe(true);
const decryptedEvent = await testUtils.awaitDecryption(roomEvent);
expect(decryptedEvent.getContent().body).toEqual('test message');
await beccaTestClient.stop();
});
it("Alice receives shared history before being invited to a room by someone else", async () => {
const beccaTestClient = new TestClient(
"@becca:localhost", "foobar", "bazquux",
);
await beccaTestClient.client.initCrypto();
await aliceTestClient.start();
await beccaTestClient.start();
const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {});
beccaTestClient.client.store.storeRoom(beccaRoom);
await beccaTestClient.client.setRoomEncryption(ROOM_ID, { "algorithm": "m.megolm.v1.aes-sha2" });
const event = new MatrixEvent({
type: "m.room.message",
sender: "@becca:localhost",
room_id: ROOM_ID,
event_id: "$1",
content: {
msgtype: "m.text",
body: "test message",
},
});
await beccaTestClient.client.crypto.encryptEvent(event, beccaRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
const device = new DeviceInfo(beccaTestClient.client.deviceId);
aliceTestClient.client.crypto.deviceList.getDeviceByIdentityKey = () => device;
// Create an olm session for Becca and Alice's devices
const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload();
const aliceOtkId = Object.keys(aliceOtks)[0];
const aliceOtk = aliceOtks[aliceOtkId];
const p2pSession = new global.Olm.Session();
await beccaTestClient.client.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
beccaTestClient.client.crypto.cryptoStore.getAccount(txn, (pickledAccount: string) => {
const account = new global.Olm.Account();
try {
account.unpickle(beccaTestClient.client.crypto.olmDevice.pickleKey, pickledAccount);
p2pSession.create_outbound(account, aliceTestClient.getDeviceKey(), aliceOtk.key);
} finally {
account.free();
}
});
},
);
const content = event.getWireContent();
const groupSessionKey = await beccaTestClient.client.crypto.olmDevice.getInboundGroupSessionKey(
ROOM_ID,
content.sender_key,
content.session_id,
);
const encryptedForwardedKey = encryptOlmEvent({
sender: "@becca:localhost",
senderKey: beccaTestClient.getDeviceKey(),
recipient: aliceTestClient,
p2pSession: p2pSession,
plaincontent: {
"algorithm": 'm.megolm.v1.aes-sha2',
"room_id": ROOM_ID,
"sender_key": content.sender_key,
"sender_claimed_ed25519_key": groupSessionKey.sender_claimed_ed25519_key,
"session_id": content.session_id,
"session_key": groupSessionKey.key,
"chain_index": groupSessionKey.chain_index,
"forwarding_curve25519_key_chain": groupSessionKey.forwarding_curve25519_key_chain,
"org.matrix.msc3061.shared_history": true,
},
plaintype: 'm.forwarded_room_key',
});
// Alice receives forwarded history from Becca
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
to_device: { events: [encryptedForwardedKey] },
});
await aliceTestClient.flushSync();
// Alice is invited to the room by Charlie
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 2,
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [
{
sender: '@becca:localhost',
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
},
{
sender: '@charlie:localhost',
type: 'm.room.member',
state_key: '@alice:localhost',
content: {
membership: 'invite',
},
},
] } } } },
});
await aliceTestClient.flushSync();
// Alice has joined the room
aliceTestClient.httpBackend.when("GET", "/sync").respond(
200, getSyncResponse(["@alice:localhost", "@becca:localhost", "@charlie:localhost"]),
);
await aliceTestClient.flushSync();
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 4,
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [event.event] } },
},
},
});
await aliceTestClient.flushSync();
// Decryption should fail, because Alice hasn't received any keys she can trust
const room = aliceTestClient.client.getRoom(ROOM_ID);
const roomEvent = room.getLiveTimeline().getEvents()[0];
expect(roomEvent.isEncrypted()).toBe(true);
const decryptedEvent = await testUtils.awaitDecryption(roomEvent);
expect(decryptedEvent.isDecryptionFailure()).toBe(true);
await beccaTestClient.stop();
});
});
+12 -1
View File
@@ -6,7 +6,7 @@ import '../olm-loader';
import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -371,3 +371,14 @@ export async function awaitDecryption(event: MatrixEvent): Promise<MatrixEvent>
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise(r => e.once(k, r));
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
app_display_name: "app",
app_id: "123",
data: {},
device_display_name: "name",
kind: "http",
lang: "en",
pushkey: "pushpush",
...extra,
});
+61
View File
@@ -22,6 +22,7 @@ import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseBeaconContent,
parseTopicContent,
} from "../../src/content-helpers";
@@ -127,6 +128,66 @@ describe('Beacon content helpers', () => {
});
});
});
describe("parseBeaconContent()", () => {
it("should not explode when parsing an invalid beacon", () => {
// deliberate cast to simulate wire content being invalid
const result = parseBeaconContent({} as any);
expect(result).toEqual({
description: undefined,
uri: undefined,
timestamp: undefined,
});
});
it("should parse unstable values", () => {
const uri = "urigoeshere";
const description = "descriptiongoeshere";
const timestamp = 1234;
const result = parseBeaconContent({
"org.matrix.msc3488.location": {
uri,
description,
},
"org.matrix.msc3488.ts": timestamp,
// relationship not used - just here to satisfy types
"m.relates_to": {
rel_type: "m.reference",
event_id: "$unused",
},
});
expect(result).toEqual({
description,
uri,
timestamp,
});
});
it("should parse stable values", () => {
const uri = "urigoeshere";
const description = "descriptiongoeshere";
const timestamp = 1234;
const result = parseBeaconContent({
"m.location": {
uri,
description,
},
"m.ts": timestamp,
// relationship not used - just here to satisfy types
"m.relates_to": {
rel_type: "m.reference",
event_id: "$unused",
},
});
expect(result).toEqual({
description,
uri,
timestamp,
});
});
});
});
describe('Topic content helpers', () => {
+498 -12
View File
@@ -15,6 +15,8 @@ import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
import { MemoryStore } from "../../src";
import { RoomKeyRequestState } from '../../src/crypto/OutgoingRoomKeyRequestManager';
import { RoomMember } from '../../src/models/room-member';
import { IStore } from '../../src/store';
const Olm = global.Olm;
@@ -40,20 +42,52 @@ async function keyshareEventForEvent(client, event, index): Promise<MatrixEvent>
type: "m.forwarded_room_key",
sender: client.getUserId(),
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": roomId,
"sender_key": eventContent.sender_key,
"sender_claimed_ed25519_key": key.sender_claimed_ed25519_key,
"session_id": eventContent.session_id,
"session_key": key.key,
"chain_index": key.chain_index,
"forwarding_curve25519_key_chain": key.forwarding_curve_key_chain,
"org.matrix.msc3061.shared_history": true,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = "akey";
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = client.crypto.olmDevice.getOutboundGroupSessionKey(eventContent.session_id);
const ksEvent = new MatrixEvent({
type: "m.room_key",
sender: client.getUserId(),
content: {
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": roomId,
"session_id": eventContent.session_id,
"session_key": key.key,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = event.getSenderKey();
ksEvent.getWireType = () => "m.room.encrypted";
ksEvent.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
return ksEvent;
}
@@ -95,7 +129,7 @@ describe("Crypto", function() {
event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };};
event.getForwardingCurve25519KeyChain = () => ["not empty"];
event.isKeySourceUntrusted = () => false;
event.isKeySourceUntrusted = () => true;
event.getClaimedEd25519Key =
() => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
@@ -233,6 +267,7 @@ describe("Crypto", function() {
describe('Key requests', function() {
let aliceClient: MatrixClient;
let bobClient: MatrixClient;
let claraClient: MatrixClient;
beforeEach(async function() {
aliceClient = (new TestClient(
@@ -241,22 +276,35 @@ describe("Crypto", function() {
bobClient = (new TestClient(
"@bob:example.com", "bobdevice",
)).client;
claraClient = (new TestClient(
"@clara:example.com", "claradevice",
)).client;
await aliceClient.initCrypto();
await bobClient.initCrypto();
await claraClient.initCrypto();
});
afterEach(async function() {
aliceClient.stopClient();
bobClient.stopClient();
claraClient.stopClient();
});
it("does not cancel keyshare requests if some messages are not decrypted", async function() {
it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
// Make Bob invited by Alice so Bob will accept Alice's forwarded keys
bobRoom.currentState.setStateEvents([new MatrixEvent({
type: "m.room.member",
sender: "@alice:example.com",
room_id: roomId,
content: { membership: "invite" },
state_key: "@bob:example.com",
})]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
@@ -302,6 +350,9 @@ describe("Crypto", function() {
}
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -314,6 +365,8 @@ describe("Crypto", function() {
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
bobClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
@@ -340,8 +393,24 @@ describe("Crypto", function() {
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeTruthy();
await sleep(1);
// the room key request should be gone since we've now decrypted everything
// the room key request should still be there, since we've
// decrypted everything with an untrusted key
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// Now share a trusted room key event so Bob will re-decrypt the messages.
// Bob will backfill trust when they receive a trusted session with a higher
// index that connects to an untrusted session with a lower index.
const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]);
const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted");
await bobDecryptor.onRoomKeyEvent(roomKeyEvent);
await trustedDecryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[0].isKeySourceUntrusted()).toBeFalsy();
await sleep(1);
// now the room key request should be gone, since there's
// no better key to wait for
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy();
});
@@ -383,6 +452,9 @@ describe("Crypto", function() {
// decryption keys yet
}
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -462,6 +534,420 @@ describe("Crypto", function() {
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
it("should accept forwarded keys which it requested", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const cryptoStore = bobClient.crypto.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody);
expect(outgoingReq).toBeDefined();
await cryptoStore.updateOutgoingRoomKeyRequest(
outgoingReq.requestId, RoomKeyRequestState.Unsent,
{ state: RoomKeyRequestState.Sent },
);
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should accept forwarded keys from the user who invited it to the room", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
// Make Bob invited by Clara
bobRoom.currentState.setStateEvents([new MatrixEvent({
type: "m.room.member",
sender: "@clara:example.com",
room_id: roomId,
content: { membership: "invite" },
state_key: "@bob:example.com",
})]);
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@clara:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should accept forwarded keys from one of its own user's other devices", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = bobClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, bobClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).not.toBeNull();
await decryptEventsPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
});
it("should not accept unexpected forwarded keys for a room it's in", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
claraClient.store.storeRoom(claraRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
await claraClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const device = new DeviceInfo(claraClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
ksEvent.event.sender = claraClient.getUserId(),
ksEvent.sender = new RoomMember(roomId, claraClient.getUserId());
await bobDecryptor.onRoomKeyEvent(ksEvent);
const key = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
events[0].getWireContent().sender_key,
events[0].getWireContent().session_id,
);
expect(key).toBeNull();
});
it("should park forwarded keys for a room it's not in", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
}));
const device = new DeviceInfo(aliceClient.deviceId);
bobClient.crypto.deviceList.getDeviceByIdentityKey = () => device;
bobClient.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const content = events[0].getWireContent();
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
const bobKey = await bobClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
expect(bobKey).toBeNull();
const aliceKey = await aliceClient.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
content.sender_key,
content.session_id,
);
const parked = await bobClient.crypto.cryptoStore.takeParkedSharedHistory(roomId);
expect(parked).toEqual([{
senderId: aliceClient.getUserId(),
senderKey: content.sender_key,
sessionId: content.session_id,
sessionKey: aliceKey.key,
keysClaimed: { ed25519: aliceKey.sender_claimed_ed25519_key },
forwardingCurve25519KeyChain: ["akey"],
}]);
});
});
describe('Secret storage', function() {
@@ -110,6 +110,12 @@ describe("MegolmDecryption", function() {
senderCurve25519Key: "SENDER_CURVE25519",
claimedEd25519Key: "SENDER_ED25519",
};
event.getWireType = () => "m.room.encrypted";
event.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
const mockCrypto = {
decryptEvent: function() {
+6
View File
@@ -214,6 +214,12 @@ describe("MegolmBackup", function() {
const event = new MatrixEvent({
type: 'm.room.encrypted',
});
event.getWireType = () => "m.room.encrypted";
event.getWireContent = () => {
return {
algorithm: "m.olm.v1.curve25519-aes-sha2",
};
};
const decryptedData = {
clearEvent: {
type: 'm.room_key',
+7 -4
View File
@@ -26,6 +26,7 @@ import { logger } from '../../../src/logger';
import * as utils from "../../../src/utils";
import { ICreateClientOpts } from '../../../src/client';
import { ISecretStorageKeyInfo } from '../../../src/crypto/api';
import { DeviceInfo } from '../../../src/crypto/deviceinfo';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -250,13 +251,13 @@ describe("Secrets", function() {
osborne2.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
"VAX": {
verified: 0,
known: false,
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:VAX": vaxDevice.deviceEd25519Key,
"curve25519:VAX": vaxDevice.deviceCurve25519Key,
},
verified: DeviceInfo.DeviceVerification.VERIFIED,
},
});
vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
@@ -280,10 +281,12 @@ describe("Secrets", function() {
Object.values(otks)[0],
);
const request = await secretStorage.request("foo", ["VAX"]);
const secret = await request.promise;
osborne2.client.crypto.deviceList.downloadKeys = () => Promise.resolve({});
osborne2.client.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const request = await secretStorage.request("foo", ["VAX"]);
await request.promise; // return value not used
expect(secret).toBe("bar");
osborne2.stop();
vax.stop();
clearTestClientTimeouts();
+20 -6
View File
@@ -464,7 +464,7 @@ describe("SAS verification", function() {
},
);
alice.client.setDeviceVerified = jest.fn();
alice.client.crypto.setDeviceVerification = jest.fn();
alice.client.getDeviceEd25519Key = () => {
return "alice+base64+ed25519+key";
};
@@ -482,7 +482,7 @@ describe("SAS verification", function() {
return Promise.resolve();
};
bob.client.setDeviceVerified = jest.fn();
bob.client.crypto.setDeviceVerification = jest.fn();
bob.client.getStoredDevice = () => {
return DeviceInfo.fromStorage(
{
@@ -565,10 +565,24 @@ describe("SAS verification", function() {
]);
// make sure Alice and Bob verified each other
expect(alice.client.setDeviceVerified)
.toHaveBeenCalledWith(bob.client.getUserId(), bob.client.deviceId);
expect(bob.client.setDeviceVerified)
.toHaveBeenCalledWith(alice.client.getUserId(), alice.client.deviceId);
expect(alice.client.crypto.setDeviceVerification)
.toHaveBeenCalledWith(
bob.client.getUserId(),
bob.client.deviceId,
true,
null,
null,
{ "ed25519:Dynabook": "bob+base64+ed25519+key" },
);
expect(bob.client.crypto.setDeviceVerification)
.toHaveBeenCalledWith(
alice.client.getUserId(),
alice.client.deviceId,
true,
null,
null,
{ "ed25519:Osborne2": "alice+base64+ed25519+key" },
);
});
});
});
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2022 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 { LocalNotificationSettings } from "../../src/@types/local_notifications";
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX, MatrixClient } from "../../src/matrix";
import { TestClient } from '../TestClient';
let client: MatrixClient;
describe("Local notification settings", () => {
beforeEach(() => {
client = (new TestClient(
"@alice:matrix.org", "123", undefined, undefined, undefined,
)).client;
client.setAccountData = jest.fn();
});
describe("Lets you set local notification settings", () => {
it("stores settings in account data", () => {
const deviceId = "device";
const settings: LocalNotificationSettings = { is_silenced: true };
client.setLocalNotificationSettings(deviceId, settings);
expect(client.setAccountData).toHaveBeenCalledWith(
`${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`,
settings,
);
});
});
});
+1 -1
View File
@@ -565,7 +565,7 @@ describe("MSC3089TreeSpace", () => {
rooms = {};
rooms[tree.roomId] = parentRoom;
(<any>tree).room = parentRoom; // override readonly
client.getRoom = (r) => rooms[r];
client.getRoom = (r) => rooms[r ?? ""];
clientSendStateFn = jest.fn()
.mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
+23
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { MatrixEvent } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import {
@@ -431,6 +433,27 @@ describe('Beacon', () => {
expect(emitSpy).not.toHaveBeenCalled();
});
it("should ignore invalid beacon events", () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
const ev = new MatrixEvent({
type: M_BEACON_INFO.name,
sender: userId,
room_id: roomId,
content: {
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: beacon.beaconInfoId,
},
},
});
beacon.addLocations([ev]);
expect(beacon.latestLocationEvent).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
describe('when beacon is live with a start timestamp is in the future', () => {
it('ignores locations before the beacon start timestamp', () => {
const startTimestamp = now + 60000;
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright 2022 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 MockHttpBackend from 'matrix-mock-request';
import { IHttpOpts, MatrixClient, PUSHER_ENABLED } from "../../src/matrix";
import { mkPusher } from '../test-utils/test-utils';
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
let client: MatrixClient;
let httpBackend: MockHttpBackend;
describe("Pushers", () => {
beforeEach(() => {
httpBackend = new MockHttpBackend();
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as unknown as IHttpOpts["request"],
});
});
describe("supports remotely toggling push notifications", () => {
it("migration support when connecting to a legacy homeserver", async () => {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
unstable_features: {
"org.matrix.msc3881": false,
},
});
httpBackend.when("GET", "/pushers").respond(200, {
pushers: [
mkPusher(),
mkPusher({ [PUSHER_ENABLED.name]: true }),
mkPusher({ [PUSHER_ENABLED.name]: false }),
],
});
const promise = client.getPushers();
await httpBackend.flushAllExpected();
await flushPromises();
const response = await promise;
expect(response.pushers[0][PUSHER_ENABLED.name]).toBe(true);
expect(response.pushers[1][PUSHER_ENABLED.name]).toBe(true);
expect(response.pushers[2][PUSHER_ENABLED.name]).toBe(false);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
/*
Copyright 2022 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 MockHttpBackend from 'matrix-mock-request';
import { ReceiptType } from '../../src/@types/read_receipts';
import { MatrixClient } from "../../src/client";
import { IHttpOpts } from '../../src/http-api';
import { EventType } from '../../src/matrix';
import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt';
import { encodeUri } from '../../src/utils';
import * as utils from "../test-utils/test-utils";
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
let client: MatrixClient;
let httpBackend: MockHttpBackend;
const THREAD_ID = "$thread_event_id";
const ROOM_ID = "!123:matrix.org";
const threadEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a thread",
"m.relates_to": {
"event_id": THREAD_ID,
"m.in_reply_to": {
"event_id": THREAD_ID,
},
"rel_type": "m.thread",
},
},
});
const roomEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: {
"body": "Hello from a room",
},
});
function mockServerSideSupport(client, hasServerSideSupport) {
const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature;
client.doesServerSupportUnstableFeature = (unstableFeature) => {
if (unstableFeature === "org.matrix.msc3771") {
return Promise.resolve(hasServerSideSupport);
} else {
return doesServerSupportUnstableFeature(unstableFeature);
}
};
}
describe("Read receipt", () => {
beforeEach(() => {
httpBackend = new MockHttpBackend();
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as unknown as IHttpOpts["request"],
});
client.isGuest = () => false;
});
describe("sendReceipt", () => {
it("sends a thread read receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toEqual(THREAD_ID);
}).respond(200, {});
mockServerSideSupport(client, true);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: roomEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
}).respond(200, {});
mockServerSideSupport(client, true);
client.sendReceipt(roomEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt when there's no server support", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId(),
}),
).check((request) => {
expect(request.data.thread_id).toBeUndefined();
}).respond(200, {});
mockServerSideSupport(client, false);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
});
});
+30 -16
View File
@@ -32,13 +32,14 @@ import {
RoomEvent,
} from "../../src";
import { EventTimeline } from "../../src/models/event-timeline";
import { IWrappedReceipt, Room } from "../../src/models/room";
import { Room } from "../../src/models/room";
import { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts";
import { Thread, ThreadEvent } from "../../src/models/thread";
import { WrappedReceipt } from "../../src/models/read-receipt";
describe("Room", function() {
const roomId = "!foo:bar";
@@ -1430,6 +1431,19 @@ describe("Room", function() {
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
});
});
describe("hasUserReadUpTo", function() {
it("should acknowledge if an event has been read", function() {
const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts),
]));
expect(room.hasUserReadEvent(userB, eventToAck.getId())).toEqual(true);
});
it("return false for an unknown event", function() {
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
});
});
});
describe("tags", function() {
@@ -2439,8 +2453,8 @@ describe("Room", function() {
const room = new Room(roomId, client, userA);
it("handles missing receipt type", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null;
};
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
@@ -2448,12 +2462,12 @@ describe("Room", function() {
describe("prefers newer receipt", () => {
it("should compare correctly using timelines", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
return { eventId: "eventId1" } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt;
return { eventId: "eventId2" } as WrappedReceipt;
}
return null;
};
@@ -2473,12 +2487,12 @@ describe("Room", function() {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as IWrappedReceipt;
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as IWrappedReceipt;
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt;
}
return null;
};
@@ -2491,9 +2505,9 @@ describe("Room", function() {
room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt;
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
}
return null;
};
@@ -2510,12 +2524,12 @@ describe("Room", function() {
});
it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt;
return { eventId: "eventId1" } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt;
return { eventId: "eventId2" } as WrappedReceipt;
}
return null;
};
@@ -2524,9 +2538,9 @@ describe("Room", function() {
});
it("should give precedence to m.read", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt;
return { eventId: "eventId3" } as WrappedReceipt;
}
return null;
};
+54
View File
@@ -20,6 +20,7 @@ import 'jest-localstorage-mock';
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { defer } from "../../../src/utils";
describe("IndexedDBStore", () => {
afterEach(() => {
@@ -111,4 +112,57 @@ describe("IndexedDBStore", () => {
await store.setPendingEvents(roomId, []);
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull();
});
it("should resolve isNewlyCreated to true if no database existed initially", async () => {
const store = new IndexedDBStore({
indexedDB,
dbName: "db1",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeTruthy();
});
it("should resolve isNewlyCreated to false if database existed already", async () => {
let store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
store = new IndexedDBStore({
indexedDB,
dbName: "db2",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
const deferred = defer<Event>();
// seed db3 to Version 1 so it forces a migration
const req = indexedDB.open("matrix-js-sdk:db3", 1);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("users", { keyPath: ["userId"] });
db.createObjectStore("accountData", { keyPath: ["type"] });
db.createObjectStore("sync", { keyPath: ["clobber"] });
};
req.onsuccess = deferred.resolve;
await deferred.promise;
req.result.close();
const store = new IndexedDBStore({
indexedDB,
dbName: "db3",
localStorage,
});
await store.startup();
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
});
+5 -1
View File
@@ -156,9 +156,13 @@ export interface IPusher {
lang: string;
profile_tag?: string;
pushkey: string;
enabled?: boolean | null | undefined;
"org.matrix.msc3881.enabled"?: boolean | null | undefined;
device_id?: string | null;
"org.matrix.msc3881.device_id"?: string | null;
}
export interface IPusherRequest extends IPusher {
export interface IPusherRequest extends Omit<IPusher, "device_id" | "org.matrix.msc3881.device_id"> {
append?: boolean;
}
+28 -3
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue";
// disable lint because these are wire responses
/* eslint-disable camelcase */
@@ -29,7 +31,7 @@ export interface IRefreshTokenResponse {
/* eslint-enable camelcase */
/**
* Response to GET login flows as per https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3login
* Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login
*/
export interface ILoginFlowsResponse {
flows: LoginFlow[];
@@ -45,13 +47,20 @@ export interface IPasswordFlow extends ILoginFlow {
type: "m.login.password";
}
export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
"delegated_oidc_compatibility",
"org.matrix.msc3824.delegated_oidc_compatibility",
);
/**
* Representation of SSO flow as per https://spec.matrix.org/latest/client-server-api/#client-login-via-sso
* Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso
*/
export interface ISSOFlow extends ILoginFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers?: IIdentityProvider[];
[DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
}
export enum IdentityProviderBrand {
@@ -71,7 +80,7 @@ export interface IIdentityProvider {
}
/**
* Parameters to login request as per https://spec.matrix.org/latest/client-server-api/#login
* Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
*/
/* eslint-disable camelcase */
export interface ILoginParams {
@@ -90,3 +99,19 @@ export enum SSOAction {
/** The user intends to register for a new account */
REGISTER = "register",
}
/**
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
* `m.login.token` issuance request.
* Note that this is UNSTABLE and subject to breaking changes without notice.
*/
export interface LoginTokenPostResponse {
/**
* The token to use with `m.login.token` to authenticate.
*/
login_token: string;
/**
* Expiration in seconds.
*/
expires_in: number;
}
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2022 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.
*/
export type OlmGroupSessionExtraData = {
untrusted?: boolean;
sharedHistory?: boolean;
};
+27
View File
@@ -191,6 +191,33 @@ export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue(
"m.visibility",
"org.matrix.msc3531.visibility");
/**
* https://github.com/matrix-org/matrix-doc/pull/3881
*
* @experimental
*/
export const PUSHER_ENABLED = new UnstableValue(
"enabled",
"org.matrix.msc3881.enabled");
/**
* https://github.com/matrix-org/matrix-doc/pull/3881
*
* @experimental
*/
export const PUSHER_DEVICE_ID = new UnstableValue(
"device_id",
"org.matrix.msc3881.device_id");
/**
* https://github.com/matrix-org/matrix-doc/pull/3890
*
* @experimental
*/
export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
"m.local_notification_settings",
"org.matrix.msc3890.local_notification_settings");
export interface IEncryptedFile {
url: string;
mimetype?: string;
+19
View File
@@ -0,0 +1,19 @@
/*
Copyright 2022 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.
*/
export interface LocalNotificationSettings {
is_silenced: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2022 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 { IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthData;
};
/**
* Helper type to represent HTTP response body for a UIA enabled endpoint
*/
export type UIAResponse<T> = T | IAuthData;
+4 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2022 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.
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk/lib/types";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
@@ -54,7 +56,7 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): T {
public findIn<T>(obj: any): Optional<T> {
let val: T;
if (this.name) {
val = obj?.[this.name];
+120 -25
View File
@@ -158,7 +158,9 @@ import {
} from "./@types/requests";
import {
EventType,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MsgType,
PUSHER_ENABLED,
RelationType,
RoomCreateTypeField,
RoomType,
@@ -188,7 +190,7 @@ import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, Rule
import { IThreepid } from "./@types/threepids";
import { CryptoStore } from "./crypto/store/base";
import { MediaHandler } from "./webrtc/mediaHandler";
import { IRefreshTokenResponse, SSOAction } from "./@types/auth";
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ReceiptType } from "./@types/read_receipts";
import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
@@ -198,7 +200,10 @@ import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { UnstableValue } from "./NamespacedValue";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { MAIN_ROOM_TIMELINE } from "./models/read-receipt";
import { IgnoredInvites } from "./models/invites-ignorer";
import { UIARequest, UIAResponse } from "./@types/uia";
import { LocalNotificationSettings } from "./@types/local_notifications";
export type Store = IStore;
@@ -523,10 +528,16 @@ interface IServerVersions {
unstable_features: Record<string, boolean>;
}
export const M_AUTHENTICATION = new UnstableValue(
"m.authentication",
"org.matrix.msc2965.authentication",
);
export interface IClientWellKnown {
[key: string]: any;
"m.homeserver"?: IWellKnownConfig;
"m.identity_server"?: IWellKnownConfig;
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
}
export interface IWellKnownConfig {
@@ -538,6 +549,13 @@ export interface IWellKnownConfig {
base_url?: string | null;
}
export interface IDelegatedAuthConfig { // MSC2965
/** The OIDC Provider/issuer the client should use */
issuer: string;
/** The optional URL of the web UI where the user can manage their account */
account?: string;
}
interface IKeyBackupPath {
path: string;
queryData?: {
@@ -905,7 +923,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
public identityServer: IIdentityServerProvider;
public http: MatrixHttpApi; // XXX: Intended private, used in code.
public crypto: Crypto; // XXX: Intended private, used in code.
public crypto?: Crypto; // XXX: Intended private, used in code.
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler: CallEventHandler; // XXX: Intended private, used in code.
public supportsCallTransfer = false; // XXX: Intended private, used in code.
@@ -3332,7 +3350,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} roomId The room ID
* @return {Room|null} The Room or null if it doesn't exist or there is no data store.
*/
public getRoom(roomId: string): Room | null {
public getRoom(roomId: string | undefined): Room | null {
if (!roomId) {
return null;
}
return this.store.getRoom(roomId);
}
@@ -3421,7 +3442,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} eventType The event type
* @return {?object} The contents of the given account data event
*/
public getAccountData(eventType: string): MatrixEvent {
public getAccountData(eventType: string): MatrixEvent | undefined {
return this.store.getAccountData(eventType);
}
@@ -4590,7 +4611,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, callback?: Callback): Promise<{}> {
public async sendReceipt(
event: MatrixEvent,
receiptType: ReceiptType,
body: any,
callback?: Callback,
): Promise<{}> {
if (typeof (body) === 'function') {
callback = body as any as Callback; // legacy
body = {};
@@ -4605,10 +4631,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$receiptType: receiptType,
$eventId: event.getId(),
});
// TODO: Add a check for which spec version this will be released in
if (await this.doesServerSupportUnstableFeature("org.matrix.msc3771")) {
const isThread = !!event.threadRootId;
body.thread_id = isThread
? event.threadRootId
: MAIN_ROOM_TIMELINE;
}
const promise = this.http.authedRequest(callback, Method.Post, path, undefined, body || {});
const room = this.getRoom(event.getRoomId());
if (room) {
if (room && this.credentials.userId) {
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
}
return promise;
@@ -4622,7 +4657,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> {
public async sendReadReceipt(
event: MatrixEvent | null,
receiptType = ReceiptType.Read,
callback?: Callback,
): Promise<{} | undefined> {
if (!event) return;
const eventId = event.getId();
const room = this.getRoom(event.getRoomId());
if (room && room.hasPendingEvent(eventId)) {
@@ -5287,6 +5327,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {object} [options]
* @param {boolean} options.preventReEmit don't re-emit events emitted on an event mapped by this mapper on the client
* @param {boolean} options.decrypt decrypt event proactively
* @param {boolean} options.toDevice the event is a to_device event
* @return {Function}
*/
public getEventMapper(options?: MapperOpts): EventMapper {
@@ -5608,13 +5649,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.processBeaconEvents(timelineSet.room, timelineEvents);
this.processThreadEvents(room, threadedEvents, backwards);
const atEnd = res.end === undefined || res.end === res.start;
// if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure
// we can recover from gappy syncs.
if (backwards && res.end == res.start) {
if (backwards && atEnd) {
eventTimeline.setPaginationToken(null, dir);
}
return res.end != res.start;
return !atEnd;
}).finally(() => {
eventTimeline.paginationRequests[dir] = null;
});
@@ -7080,10 +7123,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO
* @return {Promise<ILoginFlowsResponse>} Resolves to the available login flows
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public loginFlows(callback?: Callback): Promise<any> { // TODO: Types
public loginFlows(callback?: Callback): Promise<ILoginFlowsResponse> {
return this.http.request(callback, Method.Get, "/login");
}
@@ -7252,6 +7295,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(undefined, Method.Post, '/account/deactivate', undefined, body);
}
/**
* Make a request for an `m.login.token` to be issued as per
* [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882).
* The server may require User-Interactive auth.
* Note that this is UNSTABLE and subject to breaking changes without notice.
* @param {IAuthData} auth Optional. Auth data to supply for User-Interactive auth.
* @return {Promise<UIAResponse<LoginTokenPostResponse>>} Resolves: On success, the token response
* or UIA auth data.
*/
public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
const body: UIARequest<{}> = { auth };
return this.http.authedRequest(
undefined, // no callback support
Method.Post,
"/org.matrix.msc3882/login/token",
undefined, // no query params
body,
{ prefix: PREFIX_UNSTABLE },
);
}
/**
* Get the fallback URL to use for unknown interactive-auth stages.
*
@@ -7346,7 +7410,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventType: eventType,
});
return this.http.authedRequest(
undefined, Method.Get, path, null, null, {
undefined, Method.Get, path, undefined, undefined, {
prefix: PREFIX_UNSTABLE,
},
);
@@ -7543,7 +7607,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
[ReceiptType.Read]: rrEventId,
};
if (await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) {
if (
(await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable"))
|| (await this.isVersionSupported("v1.4"))
) {
content[ReceiptType.ReadPrivate] = rpEventId;
}
@@ -7651,7 +7718,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
const prefix = PREFIX_V3;
return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix });
return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix });
}
/**
@@ -7888,7 +7955,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
'bind': bind,
};
return this.http.authedRequest(
callback, Method.Post, path, null, data,
callback, Method.Post, path, undefined, data,
);
}
@@ -7907,7 +7974,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
const path = "/account/3pid/add";
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix });
return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix });
}
/**
@@ -7929,7 +7996,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const prefix = await this.isVersionSupported("r0.6.0") ?
PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(
undefined, Method.Post, path, null, data, { prefix },
undefined, Method.Post, path, undefined, data, { prefix },
);
}
@@ -7956,7 +8023,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
id_server: this.getIdentityServerUrl(true),
};
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix });
return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix });
}
/**
@@ -7973,7 +8040,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// eslint-disable-next-line camelcase
): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
const path = "/account/3pid/delete";
return this.http.authedRequest(undefined, Method.Post, path, null, { medium, address });
return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address });
}
/**
@@ -8019,7 +8086,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
};
return this.http.authedRequest<{}>(
callback, Method.Post, path, null, data,
callback, Method.Post, path, undefined, data,
);
}
@@ -8110,8 +8177,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: Array of objects representing pushers
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
return this.http.authedRequest(callback, Method.Get, "/pushers");
public async getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
const response = await this.http.authedRequest(callback, Method.Get, "/pushers");
// Migration path for clients that connect to a homeserver that does not support
// MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration
if (!await this.doesServerSupportUnstableFeature("org.matrix.msc3881")) {
response.pushers = response.pushers.map(pusher => {
if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) {
pusher[PUSHER_ENABLED.name] = true;
}
return pusher;
});
}
return response;
}
/**
@@ -8124,7 +8204,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setPusher(pusher: IPusherRequest, callback?: Callback): Promise<{}> {
const path = "/pushers/set";
return this.http.authedRequest(callback, Method.Post, path, null, pusher);
return this.http.authedRequest(callback, Method.Post, path, undefined, pusher);
}
/**
* Persists local notification settings
* @param {string} deviceId
* @param {LocalNotificationSettings} notificationSettings
* @return {Promise} Resolves: an empty object
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public setLocalNotificationSettings(
deviceId: string,
notificationSettings: LocalNotificationSettings,
): Promise<{}> {
const key = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
return this.setAccountData(key, notificationSettings);
}
/**
@@ -8911,7 +9006,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: eventId,
});
return this.http.authedRequest(undefined, Method.Post, path, null, { score, reason });
return this.http.authedRequest(undefined, Method.Post, path, undefined, { score, reason });
}
/**
@@ -9073,7 +9168,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return this.http.authedRequest(undefined, Method.Get, path, { via }, null, {
return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, {
qsStringifyOptions: { arrayFormat: 'repeat' },
prefix: "/_matrix/client/unstable/im.nheko.summary",
});
+5 -4
View File
@@ -292,16 +292,17 @@ export const makeBeaconContent: MakeBeaconContent = (
});
export type BeaconLocationState = MLocationContent & {
timestamp: number;
uri?: string; // override from MLocationContent to allow optionals
timestamp?: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
const location = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
return {
description,
uri,
description: location?.description,
uri: location?.uri,
timestamp,
};
};
+51 -14
View File
@@ -23,6 +23,7 @@ import * as algorithms from './algorithms';
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
import { IMegolmSessionData } from "./index";
import { OlmGroupSessionExtraData } from "../@types/crypto";
// The maximum size of an event is 65K, and we base64 the content, so this is a
// reasonable approximation to the biggest plaintext we can encrypt.
@@ -122,6 +123,7 @@ interface IInboundGroupSessionKey {
forwarding_curve25519_key_chain: string[];
sender_claimed_ed25519_key: string;
shared_history: boolean;
untrusted: boolean;
}
/* eslint-enable camelcase */
@@ -1101,7 +1103,7 @@ export class OlmDevice {
sessionKey: string,
keysClaimed: Record<string, string>,
exportFormat: boolean,
extraSessionData: Record<string, any> = {},
extraSessionData: OlmGroupSessionExtraData = {},
): Promise<void> {
await this.cryptoStore.doTxn(
'readwrite', [
@@ -1133,17 +1135,42 @@ export class OlmDevice {
"Update for megolm session "
+ senderKey + "/" + sessionId,
);
if (existingSession.first_known_index()
<= session.first_known_index()
&& !(existingSession.first_known_index() == session.first_known_index()
&& !extraSessionData.untrusted
&& existingSessionData.untrusted)) {
// existing session has lower index (i.e. can
// decrypt more), or they have the same index and
// the new sessions trust does not win over the old
// sessions trust, so keep it
logger.log(`Keeping existing megolm session ${sessionId}`);
return;
if (existingSession.first_known_index() <= session.first_known_index()) {
if (!existingSessionData.untrusted || extraSessionData.untrusted) {
// existing session has less-than-or-equal index
// (i.e. can decrypt at least as much), and the
// new session's trust does not win over the old
// session's trust, so keep it
logger.log(`Keeping existing megolm session ${sessionId}`);
return;
}
if (existingSession.first_known_index() < session.first_known_index()) {
// We want to upgrade the existing session's trust,
// but we can't just use the new session because we'll
// lose the lower index. Check that the sessions connect
// properly, and then manually set the existing session
// as trusted.
if (
existingSession.export_session(session.first_known_index())
=== session.export_session(session.first_known_index())
) {
logger.info(
"Upgrading trust of existing megolm session " +
sessionId + " based on newly-received trusted session",
);
existingSessionData.untrusted = false;
this.cryptoStore.storeEndToEndInboundGroupSession(
senderKey, sessionId, existingSessionData, txn,
);
} else {
logger.warn(
"Newly-received megolm session " + sessionId +
" does not match existing session! Keeping existing session",
);
}
return;
}
// If the sessions have the same index, go ahead and store the new trusted one.
}
}
@@ -1427,13 +1454,23 @@ export class OlmDevice {
const claimedKeys = sessionData.keysClaimed || {};
const senderEd25519Key = claimedKeys.ed25519 || null;
const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || [];
// older forwarded keys didn't set the "untrusted"
// property, but can be identified by having a
// non-empty forwarding key chain. These keys should
// be marked as untrusted since we don't know that they
// can be trusted
const untrusted = "untrusted" in sessionData
? sessionData.untrusted
: forwardingKeyChain.length > 0;
result = {
"chain_index": chainIndex,
"key": exportedSession,
"forwarding_curve25519_key_chain":
sessionData.forwardingCurve25519KeyChain || [],
"forwarding_curve25519_key_chain": forwardingKeyChain,
"sender_claimed_ed25519_key": senderEd25519Key,
"shared_history": sessionData.sharedHistory || false,
"untrusted": untrusted,
};
},
);
+24
View File
@@ -539,7 +539,23 @@ export class SecretStorage {
// because someone could be trying to send us bogus data
return;
}
if (!olmlib.isOlmEncrypted(event)) {
logger.error("secret event not properly encrypted");
return;
}
const content = event.getContent();
const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
content.sender_key,
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
logger.log("got secret share for request", content.request_id);
const requestControl = this.requests.get(content.request_id);
if (requestControl) {
@@ -559,6 +575,14 @@ export class SecretStorage {
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
return;
}
// unsure that the sender is trusted. In theory, this check is
// unnecessary since we only accept secret shares from devices that
// we requested from, but it doesn't hurt.
const deviceTrust = this.baseApis.crypto.checkDeviceInfoTrust(event.getSender(), deviceInfo);
if (!deviceTrust.isVerified()) {
logger.log("secret share from unverified device");
return;
}
logger.log(
`Successfully received secret ${requestControl.name} ` +
+1 -1
View File
@@ -23,7 +23,7 @@ limitations under the License.
import { MatrixClient } from "../../client";
import { Room } from "../../models/room";
import { OlmDevice } from "../OlmDevice";
import { MatrixEvent, RoomMember } from "../..";
import { MatrixEvent, RoomMember } from "../../matrix";
import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "..";
import { DeviceInfo } from "../deviceinfo";
import { IRoomEncryption } from "../RoomList";
+114 -17
View File
@@ -35,8 +35,10 @@ import { Room } from '../../models/room';
import { DeviceInfo } from "../deviceinfo";
import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList";
import { MatrixEvent } from "../..";
import { MatrixEvent } from "../../models/event";
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager';
import { OlmGroupSessionExtraData } from "../../@types/crypto";
// determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean {
@@ -1189,8 +1191,9 @@ class MegolmEncryption extends EncryptionAlgorithm {
* {@link module:crypto/algorithms/DecryptionAlgorithm}
*/
class MegolmDecryption extends DecryptionAlgorithm {
// events which we couldn't decrypt due to unknown sessions / indexes: map from
// senderKey|sessionId to Set of MatrixEvents
// events which we couldn't decrypt due to unknown sessions /
// indexes, or which we could only decrypt with untrusted keys:
// map from senderKey|sessionId to Set of MatrixEvents
private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>();
// this gets stubbed out by the unit tests.
@@ -1294,9 +1297,13 @@ class MegolmDecryption extends DecryptionAlgorithm {
);
}
// success. We can remove the event from the pending list, if that hasn't
// already happened.
this.removeEventFromPendingList(event);
// Success. We can remove the event from the pending list, if
// that hasn't already happened. However, if the event was
// decrypted with an untrusted key, leave it on the pending
// list so it will be retried if we find a trusted key later.
if (!res.untrusted) {
this.removeEventFromPendingList(event);
}
const payload = JSON.parse(res.result);
@@ -1391,6 +1398,8 @@ class MegolmDecryption extends DecryptionAlgorithm {
let exportFormat = false;
let keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>;
const extraSessionData: OlmGroupSessionExtraData = {};
if (!content.room_id ||
!content.session_key ||
!content.session_id ||
@@ -1400,12 +1409,59 @@ class MegolmDecryption extends DecryptionAlgorithm {
return;
}
if (!senderKey) {
logger.error("key event has no sender key (not encrypted?)");
if (!olmlib.isOlmEncrypted(event)) {
logger.error("key event not properly encrypted");
return;
}
if (content["org.matrix.msc3061.shared_history"]) {
extraSessionData.sharedHistory = true;
}
if (event.getType() == "m.forwarded_room_key") {
const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey(
olmlib.OLM_ALGORITHM,
senderKey,
);
const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
senderKey,
);
if (senderKeyUser !== event.getSender()) {
logger.error("sending device does not belong to the user it claims to be from");
return;
}
const outgoingRequests = deviceInfo ? await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget(
event.getSender(), deviceInfo.deviceId, [RoomKeyRequestState.Sent],
) : [];
const weRequested = outgoingRequests.some((req) => (
req.requestBody.room_id === content.room_id && req.requestBody.session_id === content.session_id
));
const room = this.baseApis.getRoom(content.room_id);
const memberEvent = room?.getMember(this.userId)?.events.member;
const fromInviter = memberEvent?.getSender() === event.getSender() ||
(memberEvent?.getUnsigned()?.prev_sender === event.getSender() &&
memberEvent?.getPrevContent()?.membership === "invite");
const fromUs = event.getSender() === this.baseApis.getUserId();
if (!weRequested && !fromUs) {
// If someone sends us an unsolicited key and they're
// not one of our other devices and it's not shared
// history, ignore it
if (!extraSessionData.sharedHistory) {
logger.log("forwarded key not shared history - ignoring");
return;
}
// If someone sends us an unsolicited key for a room
// we're already in, and they're not one of our other
// devices or the one who invited us, ignore it
if (room && !fromInviter) {
logger.log("forwarded key not from inviter or from us - ignoring");
return;
}
}
exportFormat = true;
forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ?
content.forwarding_curve25519_key_chain : [];
@@ -1418,7 +1474,6 @@ class MegolmDecryption extends DecryptionAlgorithm {
logger.error("forwarded_room_key event is missing sender_key field");
return;
}
senderKey = content.sender_key;
const ed25519Key = content.sender_claimed_ed25519_key;
if (!ed25519Key) {
@@ -1431,11 +1486,45 @@ class MegolmDecryption extends DecryptionAlgorithm {
keysClaimed = {
ed25519: ed25519Key,
};
// If this is a key for a room we're not in, don't load it
// yet, just park it in case *this sender* invites us to
// that room later
if (!room) {
const parkedData = {
senderId: event.getSender(),
senderKey: content.sender_key,
sessionId: content.session_id,
sessionKey: content.session_key,
keysClaimed,
forwardingCurve25519KeyChain: forwardingKeyChain,
};
await this.crypto.cryptoStore.doTxn(
'readwrite',
['parked_shared_history'],
(txn) => this.crypto.cryptoStore.addParkedSharedHistory(content.room_id, parkedData, txn),
logger.withPrefix("[addParkedSharedHistory]"),
);
return;
}
const sendingDevice = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey);
const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender(), sendingDevice);
if (fromUs && !deviceTrust.isVerified()) {
return;
}
// forwarded keys are always untrusted
extraSessionData.untrusted = true;
// replace the sender key with the sender key of the session
// creator for storage
senderKey = content.sender_key;
} else {
keysClaimed = event.getKeysClaimed();
}
const extraSessionData: any = {};
if (content["org.matrix.msc3061.shared_history"]) {
extraSessionData.sharedHistory = true;
}
@@ -1453,7 +1542,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
);
// have another go at decrypting events sent with this session.
if (await this.retryDecryption(senderKey, content.session_id)) {
if (await this.retryDecryption(senderKey, content.session_id, !extraSessionData.untrusted)) {
// cancel any outstanding room key requests for this session.
// Only do this if we managed to decrypt every message in the
// session, because if we didn't, we leave the other key
@@ -1668,7 +1757,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
session: IMegolmSessionData,
opts: { untrusted?: boolean, source?: string } = {},
): Promise<void> {
const extraSessionData: any = {};
const extraSessionData: OlmGroupSessionExtraData = {};
if (opts.untrusted || session.untrusted) {
extraSessionData.untrusted = true;
}
@@ -1696,7 +1785,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
});
}
// have another go at decrypting events sent with this session.
this.retryDecryption(session.sender_key, session.session_id);
this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted);
});
}
@@ -1707,10 +1796,17 @@ class MegolmDecryption extends DecryptionAlgorithm {
* @private
* @param {String} senderKey
* @param {String} sessionId
* @param {Boolean} forceRedecryptIfUntrusted whether messages that were already
* successfully decrypted using untrusted keys should be re-decrypted
*
* @return {Boolean} whether all messages were successfully decrypted
* @return {Boolean} whether all messages were successfully
* decrypted with trusted keys
*/
private async retryDecryption(senderKey: string, sessionId: string): Promise<boolean> {
private async retryDecryption(
senderKey: string,
sessionId: string,
forceRedecryptIfUntrusted?: boolean,
): Promise<boolean> {
const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) {
return true;
@@ -1725,13 +1821,14 @@ class MegolmDecryption extends DecryptionAlgorithm {
await Promise.all([...pending].map(async (ev) => {
try {
await ev.attemptDecryption(this.crypto, { isRetry: true });
await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted });
} catch (e) {
// don't die if something goes wrong
}
}));
// If decrypted successfully, they'll have been removed from pendingEvents
// If decrypted successfully with trusted keys, they'll have
// been removed from pendingEvents
return !this.pendingEvents.get(senderKey)?.has(sessionId);
}
+21 -1
View File
@@ -30,7 +30,7 @@ import {
registerAlgorithm,
} from "./base";
import { Room } from '../../models/room';
import { MatrixEvent } from "../..";
import { MatrixEvent } from "../../models/event";
import { IEventDecryptionResult } from "../index";
import { IInboundSession } from "../OlmDevice";
@@ -222,6 +222,26 @@ class OlmDecryption extends DecryptionAlgorithm {
);
}
// check that the device that encrypted the event belongs to the user
// that the event claims it's from. We need to make sure that our
// device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out. Some event handlers, such as
// secret sharing, may be more strict and reject events that come from
// unknown devices.
await this.crypto.deviceList.downloadKeys([event.getSender()], false);
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(
olmlib.OLM_ALGORITHM,
deviceKey,
);
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined) {
throw new DecryptionError(
"OLM_BAD_SENDER",
"Message claimed to be from " + event.getSender(), {
real_sender: senderKeyUser,
},
);
}
// check that the original sender matches what the homeserver told us, to
// avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key,
-1
View File
@@ -431,7 +431,6 @@ export class BackupManager {
)
);
});
ret.usable = ret.usable || ret.trusted_locally;
return ret;
}
+17 -9
View File
@@ -2105,6 +2105,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param {?boolean} known whether to mark that the user has been made aware of
* the existence of this device. Null to leave unchanged
*
* @param {?Record<string, any>} keys The list of keys that was present
* during the device verification. This will be double checked with the list
* of keys the given device has currently.
*
* @return {Promise<module:crypto/deviceinfo>} updated DeviceInfo
*/
public async setDeviceVerification(
@@ -2113,6 +2117,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
verified?: boolean,
blocked?: boolean,
known?: boolean,
keys?: Record<string, string>,
): Promise<DeviceInfo | CrossSigningInfo> {
// get rid of any `undefined`s here so we can just check
// for null rather than null or undefined
@@ -2131,6 +2136,10 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
if (!verified) {
throw new Error("Cannot set a cross-signing key as unverified");
}
const gotKeyId = keys ? Object.values(keys)[0] : null;
if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) {
throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`);
}
if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) {
this.storeTrustedSelfKeys(xsk.keys);
@@ -2191,6 +2200,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
let verificationStatus = dev.verified;
if (verified) {
if (keys) {
for (const [keyId, key] of Object.entries(keys)) {
if (dev.keys[keyId] !== key) {
throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`);
}
}
}
verificationStatus = DeviceVerification.VERIFIED;
} else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) {
verificationStatus = DeviceVerification.UNVERIFIED;
@@ -2400,13 +2416,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return null;
}
const forwardingChain = event.getForwardingCurve25519KeyChain();
if (forwardingChain.length > 0) {
// we got the key this event from somewhere else
// TODO: check if we can trust the forwarders.
return null;
}
if (event.isKeySourceUntrusted()) {
// we got the key for this event from a source that we consider untrusted
return null;
@@ -2478,8 +2487,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
ret.encrypted = true;
const forwardingChain = event.getForwardingCurve25519KeyChain();
if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) {
if (event.isKeySourceUntrusted()) {
// we got the key this event from somewhere else
// TODO: check if we can trust the forwarders.
ret.authenticated = false;
+18
View File
@@ -30,6 +30,8 @@ import { logger } from '../logger';
import { IOneTimeKey } from "./dehydration";
import { IClaimOTKsResult, MatrixClient } from "../client";
import { ISignatures } from "../@types/signed";
import { MatrixEvent } from "../models/event";
import { EventType } from "../@types/event";
enum Algorithm {
Olm = "m.olm.v1.curve25519-aes-sha2",
@@ -554,6 +556,22 @@ export function pkVerify(obj: IObject, pubKey: string, userId: string) {
}
}
/**
* Check that an event was encrypted using olm.
*/
export function isOlmEncrypted(event: MatrixEvent): boolean {
if (!event.getSenderKey()) {
logger.error("Event has no sender key (not encrypted?)");
return false;
}
if (event.getWireType() !== EventType.RoomMessageEncrypted ||
!(["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm))) {
logger.error("Event was not encrypted using an appropriate algorithm");
return false;
}
return true;
}
/**
* Encode a typed array of uint8 as base64.
* @param {Uint8Array} uint8Array The data to encode.
+12
View File
@@ -25,6 +25,7 @@ import { ICrossSigningInfo } from "../CrossSigning";
import { PrefixedLogger } from "../../logger";
import { InboundGroupSessionData } from "../OlmDevice";
import { IEncryptedPayload } from "../aes";
import { MatrixEvent } from "../../models/event";
/**
* Internal module. Definitions for storage for the crypto module
@@ -127,6 +128,8 @@ export interface CryptoStore {
roomId: string,
txn?: unknown,
): Promise<[senderKey: string, sessionId: string][]>;
addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
@@ -203,3 +206,12 @@ export interface OutgoingRoomKeyRequest {
requestBody: IRoomKeyRequestBody;
state: RoomKeyRequestState;
}
export interface ParkedSharedHistory {
senderId: string;
senderKey: string;
sessionId: string;
sessionKey: string;
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
forwardingCurve25519KeyChain: string[];
}
@@ -25,6 +25,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -33,7 +34,6 @@ import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { IEncryptedPayload } from "../aes";
export const VERSION = 10;
const PROFILE_TRANSACTIONS = false;
/**
@@ -873,6 +873,50 @@ export class Backend implements CryptoStore {
});
}
public addParkedSharedHistory(
roomId: string,
parkedData: ParkedSharedHistory,
txn?: IDBTransaction,
): void {
if (!txn) {
txn = this.db.transaction(
"parked_shared_history", "readwrite",
);
}
const objectStore = txn.objectStore("parked_shared_history");
const req = objectStore.get([roomId]);
req.onsuccess = () => {
const { parked } = req.result || { parked: [] };
parked.push(parkedData);
objectStore.put({ roomId, parked });
};
}
public takeParkedSharedHistory(
roomId: string,
txn?: IDBTransaction,
): Promise<ParkedSharedHistory[]> {
if (!txn) {
txn = this.db.transaction(
"parked_shared_history", "readwrite",
);
}
const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
return new Promise((resolve, reject) => {
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) {
resolve([]);
return;
}
const data = cursor.value;
cursor.delete();
resolve(data);
};
cursorReq.onerror = reject;
});
}
public doTxn<T>(
mode: Mode,
stores: string | string[],
@@ -905,45 +949,34 @@ export class Backend implements CryptoStore {
}
}
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(
`Upgrading IndexedDBCryptoStore from version ${oldVersion}`
+ ` to ${VERSION}`,
);
if (oldVersion < 1) { // The database did not previously exist.
createDatabase(db);
}
if (oldVersion < 2) {
db.createObjectStore("account");
}
if (oldVersion < 3) {
type DbMigration = (db: IDBDatabase) => void;
const DB_MIGRATIONS: DbMigration[] = [
(db) => { createDatabase(db); },
(db) => { db.createObjectStore("account"); },
(db) => {
const sessionsStore = db.createObjectStore("sessions", {
keyPath: ["deviceKey", "sessionId"],
});
sessionsStore.createIndex("deviceKey", "deviceKey");
}
if (oldVersion < 4) {
},
(db) => {
db.createObjectStore("inbound_group_sessions", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
if (oldVersion < 5) {
db.createObjectStore("device_data");
}
if (oldVersion < 6) {
db.createObjectStore("rooms");
}
if (oldVersion < 7) {
},
(db) => { db.createObjectStore("device_data"); },
(db) => { db.createObjectStore("rooms"); },
(db) => {
db.createObjectStore("sessions_needing_backup", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
if (oldVersion < 8) {
},
(db) => {
db.createObjectStore("inbound_group_sessions_withheld", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
if (oldVersion < 9) {
},
(db) => {
const problemsStore = db.createObjectStore("session_problems", {
keyPath: ["deviceKey", "time"],
});
@@ -952,13 +985,29 @@ export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
db.createObjectStore("notified_error_devices", {
keyPath: ["userId", "deviceId"],
});
}
if (oldVersion < 10) {
},
(db) => {
db.createObjectStore("shared_history_inbound_group_sessions", {
keyPath: ["roomId"],
});
}
},
(db) => {
db.createObjectStore("parked_shared_history", {
keyPath: ["roomId"],
});
},
// Expand as needed.
];
export const VERSION = DB_MIGRATIONS.length;
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(
`Upgrading IndexedDBCryptoStore from version ${oldVersion}`
+ ` to ${VERSION}`,
);
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
}
function createDatabase(db: IDBDatabase): void {
@@ -29,6 +29,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -55,6 +56,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
public static STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions';
public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld';
public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = 'shared_history_inbound_group_sessions';
public static STORE_PARKED_SHARED_HISTORY = 'parked_shared_history';
public static STORE_DEVICE_DATA = 'device_data';
public static STORE_ROOMS = 'rooms';
public static STORE_BACKUP = 'sessions_needing_backup';
@@ -669,6 +671,27 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend.getSharedHistoryInboundGroupSessions(roomId, txn);
}
/**
* Park a shared-history group session for a room we may be invited to later.
*/
public addParkedSharedHistory(
roomId: string,
parkedData: ParkedSharedHistory,
txn?: IDBTransaction,
): void {
this.backend.addParkedSharedHistory(roomId, parkedData, txn);
}
/**
* Pop out all shared-history group sessions for a room.
*/
public takeParkedSharedHistory(
roomId: string,
txn?: IDBTransaction,
): Promise<ParkedSharedHistory[]> {
return this.backend.takeParkedSharedHistory(roomId, txn);
}
/**
* Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may
+14
View File
@@ -25,6 +25,7 @@ import {
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
@@ -58,6 +59,7 @@ export class MemoryCryptoStore implements CryptoStore {
private rooms: { [roomId: string]: IRoomEncryption } = {};
private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Ensure the database exists and is up-to-date.
@@ -526,6 +528,18 @@ export class MemoryCryptoStore implements CryptoStore {
return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
parked.push(parkedData);
this.parkedSharedHistory.set(roomId, parked);
}
public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
this.parkedSharedHistory.delete(roomId);
return Promise.resolve(parked);
}
// Session key backups
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {
+19 -6
View File
@@ -299,7 +299,13 @@ export class VerificationBase<
if (this.doVerification && !this.started) {
this.started = true;
this.resetTimer(); // restart the timeout
Promise.resolve(this.doVerification()).then(this.done.bind(this), this.cancel.bind(this));
new Promise<void>((resolve, reject) => {
const crossSignId = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(this.userId)?.getId();
if (crossSignId === this.deviceId) {
reject(new Error("Device ID is the same as the cross-signing ID"));
}
resolve();
}).then(() => this.doVerification()).then(this.done.bind(this), this.cancel.bind(this));
}
return this.promise;
}
@@ -310,14 +316,14 @@ export class VerificationBase<
// we try to verify all the keys that we're told about, but we might
// not know about all of them, so keep track of the keys that we know
// about, and ignore the rest
const verifiedDevices = [];
const verifiedDevices: [string, string, string][] = [];
for (const [keyId, keyInfo] of Object.entries(keys)) {
const deviceId = keyId.split(':', 2)[1];
const device = this.baseApis.getStoredDevice(userId, deviceId);
if (device) {
verifier(keyId, device, keyInfo);
verifiedDevices.push(deviceId);
verifiedDevices.push([deviceId, keyId, device.keys[keyId]]);
} else {
const crossSigningInfo = this.baseApis.crypto.deviceList.getStoredCrossSigningForUser(userId);
if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
@@ -326,7 +332,7 @@ export class VerificationBase<
[keyId]: deviceId,
},
}, deviceId), keyInfo);
verifiedDevices.push(deviceId);
verifiedDevices.push([deviceId, keyId, deviceId]);
} else {
logger.warn(
`verification: Could not find device ${deviceId} to verify`,
@@ -348,8 +354,15 @@ export class VerificationBase<
// TODO: There should probably be a batch version of this, otherwise it's going
// to upload each signature in a separate API call which is silly because the
// API supports as many signatures as you like.
for (const deviceId of verifiedDevices) {
await this.baseApis.setDeviceVerified(userId, deviceId);
for (const [deviceId, keyId, key] of verifiedDevices) {
await this.baseApis.crypto.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key });
}
// if one of the user's own devices is being marked as verified / unverified,
// check the key backup status, since whether or not we use this depends on
// whether it has a signature from a verified device
if (userId == this.baseApis.credentials.userId) {
await this.baseApis.checkKeyBackup();
}
}
+5
View File
@@ -22,6 +22,7 @@ export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent;
export interface MapperOpts {
preventReEmit?: boolean;
decrypt?: boolean;
toDevice?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
@@ -29,6 +30,10 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>) {
if (options.toDevice) {
delete plainOldJsObject.room_id;
}
const room = client.getRoom(plainOldJsObject.room_id);
let event: MatrixEvent;
+7 -7
View File
@@ -365,7 +365,7 @@ export class MatrixHttpApi {
// we're setting opts.json=false so that it doesn't JSON-encode the
// request, which also means it doesn't JSON-decode the response. Either
// way, we have to JSON-parse the response ourselves.
let bodyParser = null;
let bodyParser: ((body: string) => any) | undefined;
if (!rawResponse) {
bodyParser = function(rawBody: string) {
let body = JSON.parse(rawBody);
@@ -472,7 +472,7 @@ export class MatrixHttpApi {
headers["Content-Length"] = "0";
}
promise = this.authedRequest(
promise = this.authedRequest<UploadContentResponseType<O>>(
opts.callback, Method.Post, "/upload", queryParams, body, {
prefix: "/_matrix/media/r0",
headers,
@@ -590,10 +590,10 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
path: string,
queryParams?: Record<string, string | string[]>,
queryParams?: Record<string, string | string[] | undefined>,
data?: CoreOptions["body"],
opts?: O | number, // number is legacy
): IAbortablePromise<ResponseType<T, O>> {
@@ -667,7 +667,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public request<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
path: string,
queryParams?: CoreOptions["qs"],
@@ -711,7 +711,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public requestOtherUrl<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
uri: string,
queryParams?: CoreOptions["qs"],
@@ -778,7 +778,7 @@ export class MatrixHttpApi {
* Generic O should be inferred
*/
private doRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>,
callback: Callback<T> | undefined,
method: Method,
uri: string,
queryParams?: Record<string, string>,
+1 -1
View File
@@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "./event";
import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
import { EventTimeline } from "./event-timeline";
import { FileType } from "../http-api";
import type { ISendEventResponse } from "..";
import type { ISendEventResponse } from "../@types/requests";
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
+3 -2
View File
@@ -15,7 +15,6 @@ limitations under the License.
*/
import { MBeaconEventContent } from "../@types/beacon";
import { M_TIMESTAMP } from "../@types/location";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix";
import { sortEventsByLatestContentTimestamp } from "../utils";
@@ -161,7 +160,9 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
const validLocationEvents = beaconLocationEvents.filter(event => {
const content = event.getContent<MBeaconEventContent>();
const timestamp = M_TIMESTAMP.findIn<number>(content);
const parsed = parseBeaconContent(content);
if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
const { timestamp } = parsed;
return (
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
+2 -1
View File
@@ -287,7 +287,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
public getTimelineForEvent(eventId: string): EventTimeline | null {
public getTimelineForEvent(eventId: string | null): EventTimeline | null {
if (eventId === null) { return null; }
const res = this._eventIdToTimeline.get(eventId);
return (res === undefined) ? null : res;
}
+6 -1
View File
@@ -151,6 +151,7 @@ interface IKeyRequestRecipient {
export interface IDecryptOptions {
emit?: boolean;
isRetry?: boolean;
forceRedecryptIfUntrusted?: boolean;
}
/**
@@ -677,6 +678,8 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
* @param {object} options
* @param {boolean} options.isRetry True if this is a retry (enables more logging)
* @param {boolean} options.emit Emits "event.decrypted" if set to true
* @param {boolean} options.forceRedecryptIfUntrusted whether the message should be
* re-decrypted if it was previously successfully decrypted with an untrusted key
*
* @returns {Promise} promise which resolves (to undefined) when the decryption
* attempt is completed.
@@ -695,7 +698,9 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
throw new Error("Attempt to decrypt event which isn't encrypted");
}
if (this.clearEvent && !this.isDecryptionFailure()) {
const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure();
const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted();
if (alreadyDecrypted && !forceRedecrypt) {
// we may want to just ignore this? let's start with rejecting it.
throw new Error(
"Attempt to decrypt event which has already been decrypted",
+306
View File
@@ -0,0 +1,306 @@
/*
Copyright 2022 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 { ReceiptType } from "../@types/read_receipts";
import { EventTimelineSet, EventType, MatrixEvent } from "../matrix";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
export const MAIN_ROOM_TIMELINE = "main";
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
return new MatrixEvent({
content: {
[event.getId()]: {
[receiptType]: {
[userId]: {
ts: event.getTs(),
threadId: event.threadRootId ?? MAIN_ROOM_TIMELINE,
},
},
},
},
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
export interface Receipt {
ts: number;
thread_id?: string;
}
export interface WrappedReceipt {
eventId: string;
data: Receipt;
}
interface CachedReceipt {
type: ReceiptType;
userId: string;
data: Receipt;
}
type ReceiptCache = {[eventId: string]: CachedReceipt[]};
export interface ReceiptContent {
[eventId: string]: {
[key in ReceiptType]: {
[userId: string]: Receipt;
};
};
}
const ReceiptPairRealIndex = 0;
const ReceiptPairSyntheticIndex = 1;
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
type Receipts = {
[receiptType: string]: {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
export abstract class ReadReceipt<
Events extends string,
Arguments extends ListenerMap<Events>,
SuperclassArguments extends ListenerMap<any> = Arguments,
> extends TypedEventEmitter<Events, Arguments, SuperclassArguments> {
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
private receipts: Receipts = {}; // { receipt_type: { user_id: Receipt } }
private receiptCacheByEventId: ReceiptCache = {}; // { event_id: CachedReceipt[] }
public abstract getUnfilteredTimelineSet(): EventTimelineSet;
public abstract timeline: MatrixEvent[];
/**
* Gets the latest receipt for a given user in the room
* @param userId The id of the user for which we want the receipt
* @param ignoreSynthesized Whether to ignore synthesized receipts or not
* @param receiptType Optional. The type of the receipt we want to get
* @returns the latest receipts of the chosen type for the chosen user
*/
public getReadReceiptForUserId(
userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read,
): WrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? [];
if (ignoreSynthesized) {
return realReceipt;
}
return syntheticReceipt ?? realReceipt;
}
/**
* Get the ID of the event that a given user has read up to, or null if we
* have received no read receipts from them.
* @param {String} userId The user ID to get read receipt event ID for
* @param {Boolean} ignoreSynthesized If true, return only receipts that have been
* sent by the server, not implicit ones generated
* by the JS SDK.
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
// XXX: This is very very ugly and I hope I won't have to ever add a new
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// If we have both, compare them
let comparison: number | null | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) {
comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
}
// The public receipt is more likely to drift out of date so the private
// one has precedence
if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
// If public read receipt is older, return the private one
return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null;
}
public addReceiptToStructure(
eventId: string,
receiptType: ReceiptType,
userId: string,
receipt: Receipt,
synthetic: boolean,
): void {
if (!this.receipts[receiptType]) {
this.receipts[receiptType] = {};
}
if (!this.receipts[receiptType][userId]) {
this.receipts[receiptType][userId] = [null, null];
}
const pair = this.receipts[receiptType][userId];
let existingReceipt = pair[ReceiptPairRealIndex];
if (synthetic) {
existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
}
if (existingReceipt) {
// we only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
existingReceipt.eventId,
eventId,
);
if (ordering !== null && ordering >= 0) {
return;
}
}
const wrappedReceipt: WrappedReceipt = {
eventId,
data: receipt,
};
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
let ordering: number | null = null;
if (realReceipt && syntheticReceipt) {
ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
realReceipt.eventId,
syntheticReceipt.eventId,
);
}
const preferSynthetic = ordering === null || ordering < 0;
// we don't bother caching just real receipts by event ID as there's nothing that would read it.
// Take the current cached receipt before we overwrite the pair elements.
const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (synthetic && preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = wrappedReceipt;
} else if (!synthetic) {
pair[ReceiptPairRealIndex] = wrappedReceipt;
if (!preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = null;
}
}
const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (cachedReceipt === newCachedReceipt) return;
// clean up any previous cache entry
if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) {
const previousEventId = cachedReceipt.eventId;
// Remove the receipt we're about to clobber out of existence from the cache
this.receiptCacheByEventId[previousEventId] = (
this.receiptCacheByEventId[previousEventId].filter(r => {
return r.type !== receiptType || r.userId !== userId;
})
);
if (this.receiptCacheByEventId[previousEventId].length < 1) {
delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys
}
}
// cache the new one
if (!this.receiptCacheByEventId[eventId]) {
this.receiptCacheByEventId[eventId] = [];
}
this.receiptCacheByEventId[eventId].push({
userId: userId,
type: receiptType as ReceiptType,
data: receipt,
});
}
/**
* Get a list of receipts for the given event.
* @param {MatrixEvent} event the event to get receipts for
* @return {Object[]} A list of receipts with a userId, type and data keys or
* an empty list.
*/
public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] {
return this.receiptCacheByEventId[event.getId()] || [];
}
public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void;
/**
* Add a temporary local-echo receipt to the room to reflect in the
* client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged
* @param {ReceiptType} receiptType The type of receipt
*/
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
}
/**
* Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for.
* @return {String[]} A list of user IDs.
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return utils.isSupportedReceiptType(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
}
/**
* Determines if the given user has read a particular event ID with the known
* history of the room. This is not a definitive check as it relies only on
* what is available to the room at the time of execution.
* @param {String} userId The user ID to check the read state of.
* @param {String} eventId The event ID to check if the user read.
* @returns {Boolean} True if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
const readUpToId = this.getEventReadUpTo(userId, false);
if (readUpToId === eventId) return true;
if (this.timeline.length
&& this.timeline[this.timeline.length - 1].getSender()
&& this.timeline[this.timeline.length - 1].getSender() === userId) {
// It doesn't matter where the event is in the timeline, the user has read
// it because they've sent the latest event.
return true;
}
for (let i = this.timeline.length - 1; i >= 0; --i) {
const ev = this.timeline[i];
// If we encounter the target event first, the user hasn't read it
// however if we encounter the readUpToId first then the user has read
// it. These rules apply because we're iterating bottom-up.
if (ev.getId() === eventId) return false;
if (ev.getId() === readUpToId) return true;
}
// We don't know if the user has read it, so assume not.
return false;
}
}
+47 -304
View File
@@ -47,10 +47,16 @@ import {
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread";
import { TypedEventEmitter } from "./typed-event-emitter";
import { ReceiptType } from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import {
MAIN_ROOM_TIMELINE,
ReadReceipt,
Receipt,
ReceiptContent,
synthesizeReceipt,
} from "./read-receipt";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -61,23 +67,6 @@ import { RelationsContainer } from "./relations-container";
export const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
// console.log("synthesizing receipt for "+event.getId());
return new MatrixEvent({
content: {
[event.getId()]: {
[receiptType]: {
[userId]: {
ts: event.getTs(),
},
},
},
},
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
interface IOpts {
storageToken?: string;
pendingEventOrdering?: PendingEventOrdering;
@@ -91,40 +80,6 @@ export interface IRecommendedVersion {
urgent: boolean;
}
interface IReceipt {
ts: number;
}
export interface IWrappedReceipt {
eventId: string;
data: IReceipt;
}
interface ICachedReceipt {
type: ReceiptType;
userId: string;
data: IReceipt;
}
type ReceiptCache = {[eventId: string]: ICachedReceipt[]};
interface IReceiptContent {
[eventId: string]: {
[key in ReceiptType]: {
[userId: string]: IReceipt;
};
};
}
const ReceiptPairRealIndex = 0;
const ReceiptPairSyntheticIndex = 1;
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
type Receipts = {
[receiptType: string]: {
[userId: string]: [IWrappedReceipt, IWrappedReceipt]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
// When inserting a visibility event affecting event `eventId`, we
// need to scan through existing visibility events for `eventId`.
// In theory, this could take an unlimited amount of time if:
@@ -225,15 +180,9 @@ export type RoomEventHandlerMap = {
BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange
>;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> {
export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } }
private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] }
private notificationCounts: Partial<Record<NotificationCountType, number>> = {};
private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = [];
@@ -2400,10 +2349,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
threadId,
} = this.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
eventsByThread[threadId ?? ""] = [];
}
eventsByThread[threadId]?.push(event);
eventsByThread[threadId ?? ""]?.push(event);
if (shouldLiveInRoom) {
this.addLiveEvent(event, options);
@@ -2436,17 +2385,17 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
if (shouldLiveInThread) {
event.setThreadId(threadId);
event.setThreadId(threadId ?? "");
memo[THREAD].push(event);
}
return memo;
}, [[], []]);
}, [[] as MatrixEvent[], [] as MatrixEvent[]]);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [
events,
[],
events as MatrixEvent[],
[] as MatrixEvent[],
];
}
}
@@ -2458,12 +2407,43 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = new Set<string>();
for (const event of events) {
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId);
threadRoots.add(event.relationEventId ?? "");
}
}
return threadRoots;
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
public addReceipt(event: MatrixEvent, synthetic = false): void {
const content = event.getContent<ReceiptContent>();
Object.keys(content).forEach((eventId: string) => {
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
const receipt = content[eventId][receiptType][userId] as Receipt;
const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE;
const receiptDestination: Thread | this | undefined = receiptForMainTimeline
? this
: this.threads.get(receipt.thread_id ?? "");
receiptDestination?.addReceiptToStructure(
eventId,
receiptType as ReceiptType,
userId,
receipt,
synthetic,
);
});
});
});
// send events after we've regenerated the structure & cache, otherwise things that
// listened for the event would read stale data.
this.emit(RoomEvent.Receipt, event, this);
}
/**
* Adds/handles ephemeral events such as typing notifications and read receipts.
* @param {MatrixEvent[]} events A list of events to process
@@ -2554,243 +2534,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
/**
* Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for.
* @return {String[]} A list of user IDs.
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return utils.isSupportedReceiptType(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
}
/**
* Gets the latest receipt for a given user in the room
* @param userId The id of the user for which we want the receipt
* @param ignoreSynthesized Whether to ignore synthesized receipts or not
* @param receiptType Optional. The type of the receipt we want to get
* @returns the latest receipts of the chosen type for the chosen user
*/
public getReadReceiptForUserId(
userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read,
): IWrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? [];
if (ignoreSynthesized) {
return realReceipt;
}
return syntheticReceipt ?? realReceipt;
}
/**
* Get the ID of the event that a given user has read up to, or null if we
* have received no read receipts from them.
* @param {String} userId The user ID to get read receipt event ID for
* @param {Boolean} ignoreSynthesized If true, return only receipts that have been
* sent by the server, not implicit ones generated
* by the JS SDK.
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
// XXX: This is very very ugly and I hope I won't have to ever add a new
// receipt type here again. IMHO this should be done by the server in
// some more intelligent manner or the client should just use timestamps
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// If we have both, compare them
let comparison: number | null | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) {
comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
}
// The public receipt is more likely to drift out of date so the private
// one has precedence
if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
// If public read receipt is older, return the private one
return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null;
}
/**
* Determines if the given user has read a particular event ID with the known
* history of the room. This is not a definitive check as it relies only on
* what is available to the room at the time of execution.
* @param {String} userId The user ID to check the read state of.
* @param {String} eventId The event ID to check if the user read.
* @returns {Boolean} True if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
const readUpToId = this.getEventReadUpTo(userId, false);
if (readUpToId === eventId) return true;
if (this.timeline.length
&& this.timeline[this.timeline.length - 1].getSender()
&& this.timeline[this.timeline.length - 1].getSender() === userId) {
// It doesn't matter where the event is in the timeline, the user has read
// it because they've sent the latest event.
return true;
}
for (let i = this.timeline.length - 1; i >= 0; --i) {
const ev = this.timeline[i];
// If we encounter the target event first, the user hasn't read it
// however if we encounter the readUpToId first then the user has read
// it. These rules apply because we're iterating bottom-up.
if (ev.getId() === eventId) return false;
if (ev.getId() === readUpToId) return true;
}
// We don't know if the user has read it, so assume not.
return false;
}
/**
* Get a list of receipts for the given event.
* @param {MatrixEvent} event the event to get receipts for
* @return {Object[]} A list of receipts with a userId, type and data keys or
* an empty list.
*/
public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] {
return this.receiptCacheByEventId[event.getId()] || [];
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
public addReceipt(event: MatrixEvent, synthetic = false): void {
this.addReceiptsToStructure(event, synthetic);
// send events after we've regenerated the structure & cache, otherwise things that
// listened for the event would read stale data.
this.emit(RoomEvent.Receipt, event, this);
}
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
* @param {Boolean} synthetic True if this event is implicit.
*/
private addReceiptsToStructure(event: MatrixEvent, synthetic: boolean): void {
const content = event.getContent<IReceiptContent>();
Object.keys(content).forEach((eventId) => {
Object.keys(content[eventId]).forEach((receiptType) => {
Object.keys(content[eventId][receiptType]).forEach((userId) => {
const receipt = content[eventId][receiptType][userId];
if (!this.receipts[receiptType]) {
this.receipts[receiptType] = {};
}
if (!this.receipts[receiptType][userId]) {
this.receipts[receiptType][userId] = [null, null];
}
const pair = this.receipts[receiptType][userId];
let existingReceipt = pair[ReceiptPairRealIndex];
if (synthetic) {
existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
}
if (existingReceipt) {
// we only want to add this receipt if we think it is later than the one we already have.
// This is managed server-side, but because we synthesize RRs locally we have to do it here too.
const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
existingReceipt.eventId,
eventId,
);
if (ordering !== null && ordering >= 0) {
return;
}
}
const wrappedReceipt: IWrappedReceipt = {
eventId,
data: receipt,
};
const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
let ordering: number | null = null;
if (realReceipt && syntheticReceipt) {
ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
realReceipt.eventId,
syntheticReceipt.eventId,
);
}
const preferSynthetic = ordering === null || ordering < 0;
// we don't bother caching just real receipts by event ID as there's nothing that would read it.
// Take the current cached receipt before we overwrite the pair elements.
const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (synthetic && preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = wrappedReceipt;
} else if (!synthetic) {
pair[ReceiptPairRealIndex] = wrappedReceipt;
if (!preferSynthetic) {
pair[ReceiptPairSyntheticIndex] = null;
}
}
const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
if (cachedReceipt === newCachedReceipt) return;
// clean up any previous cache entry
if (cachedReceipt && this.receiptCacheByEventId[cachedReceipt.eventId]) {
const previousEventId = cachedReceipt.eventId;
// Remove the receipt we're about to clobber out of existence from the cache
this.receiptCacheByEventId[previousEventId] = (
this.receiptCacheByEventId[previousEventId].filter(r => {
return r.type !== receiptType || r.userId !== userId;
})
);
if (this.receiptCacheByEventId[previousEventId].length < 1) {
delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys
}
}
// cache the new one
if (!this.receiptCacheByEventId[eventId]) {
this.receiptCacheByEventId[eventId] = [];
}
this.receiptCacheByEventId[eventId].push({
userId: userId,
type: receiptType as ReceiptType,
data: receipt,
});
});
});
});
}
/**
* Add a temporary local-echo receipt to the room to reflect in the
* client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged
* @param {ReceiptType} receiptType The type of receipt
*/
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
}
/**
* Update the room-tag event for the room. The previous one is overwritten.
* @param {MatrixEvent} event the m.tag event
+14 -2
View File
@@ -23,10 +23,10 @@ import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
export enum ThreadEvent {
New = "Thread.new",
@@ -54,7 +54,7 @@ interface IThreadOpts {
/**
* @experimental
*/
export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport: boolean;
/**
@@ -429,6 +429,18 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
nextBatch,
};
}
public getUnfilteredTimelineSet(): EventTimelineSet {
return this.timelineSet;
}
public get timeline(): MatrixEvent[] {
return this.events;
}
public addReceipt(event: MatrixEvent, synthetic: boolean): void {
throw new Error("Unsupported function on the thread model");
}
}
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
+1 -1
View File
@@ -162,7 +162,7 @@ export interface IStore {
* Get account data event by event type
* @param {string} eventType The event type being queried
*/
getAccountData(eventType: EventType | string): MatrixEvent;
getAccountData(eventType: EventType | string): MatrixEvent | undefined;
/**
* setSyncData does nothing as there is no backing data store.
+1 -1
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ISavedSync } from "./index";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "../matrix";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
export interface IIndexedDBBackend {
+29 -41
View File
@@ -18,41 +18,36 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn
import * as utils from "../utils";
import * as IndexedDBHelpers from "../indexeddb-helpers";
import { logger } from '../logger';
import { IStartClientOpts, IStateEventWithRoomId } from "..";
import { IStartClientOpts, IStateEventWithRoomId } from "../matrix";
import { ISavedSync } from "./index";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
const VERSION = 4;
type DbMigration = (db: IDBDatabase) => void;
const DB_MIGRATIONS: DbMigration[] = [
(db) => {
// Make user store, clobber based on user ID. (userId property of User objects)
db.createObjectStore("users", { keyPath: ["userId"] });
function createDatabase(db: IDBDatabase): void {
// Make user store, clobber based on user ID. (userId property of User objects)
db.createObjectStore("users", { keyPath: ["userId"] });
// Make account data store, clobber based on event type.
// (event.type property of MatrixEvent objects)
db.createObjectStore("accountData", { keyPath: ["type"] });
// Make account data store, clobber based on event type.
// (event.type property of MatrixEvent objects)
db.createObjectStore("accountData", { keyPath: ["type"] });
// Make /sync store (sync tokens, room data, etc), always clobber (const key).
db.createObjectStore("sync", { keyPath: ["clobber"] });
}
function upgradeSchemaV2(db: IDBDatabase): void {
const oobMembersStore = db.createObjectStore(
"oob_membership_events", {
keyPath: ["room_id", "state_key"],
});
oobMembersStore.createIndex("room", "room_id");
}
function upgradeSchemaV3(db: IDBDatabase): void {
db.createObjectStore("client_options",
{ keyPath: ["clobber"] });
}
function upgradeSchemaV4(db: IDBDatabase): void {
db.createObjectStore("to_device_queue", { autoIncrement: true });
}
// Make /sync store (sync tokens, room data, etc), always clobber (const key).
db.createObjectStore("sync", { keyPath: ["clobber"] });
},
(db) => {
const oobMembersStore = db.createObjectStore(
"oob_membership_events", {
keyPath: ["room_id", "state_key"],
});
oobMembersStore.createIndex("room", "room_id");
},
(db) => { db.createObjectStore("client_options", { keyPath: ["clobber"] }); },
(db) => { db.createObjectStore("to_device_queue", { autoIncrement: true }); },
// Expand as needed.
];
const VERSION = DB_MIGRATIONS.length;
/**
* Helper method to collect results from a Cursor and promiseify it.
@@ -172,20 +167,13 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
logger.log(
`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`,
);
if (oldVersion < 1) { // The database did not previously exist.
if (oldVersion < 1) {
// The database did not previously exist
this._isNewlyCreated = true;
createDatabase(db);
}
if (oldVersion < 2) {
upgradeSchemaV2(db);
}
if (oldVersion < 3) {
upgradeSchemaV3(db);
}
if (oldVersion < 4) {
upgradeSchemaV4(db);
}
// Expand as needed.
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
};
req.onblocked = () => {
+1 -1
View File
@@ -18,7 +18,7 @@ import { logger } from "../logger";
import { defer, IDeferred } from "../utils";
import { ISavedSync } from "./index";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId, ISyncResponse } from "..";
import { IStateEventWithRoomId, ISyncResponse } from "../matrix";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+8 -8
View File
@@ -66,7 +66,7 @@ interface IState {
export interface ITimeline {
events: Array<IRoomEvent | IStateEvent>;
limited?: boolean;
prev_batch: string;
prev_batch: string | null;
}
export interface IJoinedRoom {
@@ -401,7 +401,7 @@ export class SyncAccumulator {
// typing forever until someone really does start typing (which
// will prompt Synapse to send down an actual m.typing event to
// clobber the one we persisted).
if (e.type !== "m.receipt" || !e.content) {
if (e.type !== EventType.Receipt || !e.content) {
// This means we'll drop unknown ephemeral events but that
// seems okay.
return;
@@ -528,7 +528,7 @@ export class SyncAccumulator {
});
Object.keys(this.joinRooms).forEach((roomId) => {
const roomData = this.joinRooms[roomId];
const roomJson = {
const roomJson: IJoinedRoom = {
ephemeral: { events: [] },
account_data: { events: [] },
state: { events: [] },
@@ -541,12 +541,12 @@ export class SyncAccumulator {
};
// Add account data
Object.keys(roomData._accountData).forEach((evType) => {
roomJson.account_data.events.push(roomData._accountData[evType]);
roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent);
});
// Add receipt data
const receiptEvent = {
type: "m.receipt",
type: EventType.Receipt,
room_id: roomId,
content: {
// $event_id: { "m.read": { $user_id: $json } }
@@ -566,7 +566,7 @@ export class SyncAccumulator {
});
// add only if we have some receipt data
if (Object.keys(receiptEvent.content).length > 0) {
roomJson.ephemeral.events.push(receiptEvent);
roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent);
}
// Add timeline data
@@ -609,8 +609,8 @@ export class SyncAccumulator {
const rollBackState = Object.create(null);
for (let i = roomJson.timeline.events.length - 1; i >=0; i--) {
const timelineEvent = roomJson.timeline.events[i];
if (timelineEvent.state_key === null ||
timelineEvent.state_key === undefined) {
if ((timelineEvent as IStateEvent).state_key === null ||
(timelineEvent as IStateEvent).state_key === undefined) {
continue; // not a state event
}
// since we're going back in time, we need to use the previous
+43 -3
View File
@@ -1109,7 +1109,20 @@ export class SyncApi {
if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) {
const cancelledKeyVerificationTxns = [];
data.to_device.events
.map(client.getEventMapper())
.filter((eventJSON) => {
if (
eventJSON.type === EventType.RoomMessageEncrypted &&
!(["m.olm.v1.curve25519-aes-sha2"].includes(eventJSON.content?.algorithm))
) {
logger.log(
'Ignoring invalid encrypted to-device event from ' + eventJSON.sender,
);
return false;
}
return true;
})
.map(client.getEventMapper({ toDevice: true }))
.map((toDeviceEvent) => { // map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
// if there's an accompanying m.key.verification.cancel event, so
@@ -1185,6 +1198,27 @@ export class SyncApi {
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
await this.processRoomEvents(room, stateEvents);
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId())?.getSender();
if (client.isCryptoEnabled()) {
const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
for (const parked of parkedHistory) {
if (parked.senderId === inviter) {
await client.crypto.olmDevice.addInboundGroupSession(
room.roomId,
parked.senderKey,
parked.forwardingCurve25519KeyChain,
parked.sessionId,
parked.sessionKey,
parked.keysClaimed,
true,
{ sharedHistory: true, untrusted: true },
);
}
}
}
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
@@ -1235,7 +1269,9 @@ export class SyncApi {
if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
if (joinObj.timeline.prev_batch !== null) {
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
}
} else if (joinObj.timeline.limited) {
let limited = true;
@@ -1288,7 +1324,11 @@ export class SyncApi {
}
}
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
try {
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
} catch (e) {
logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
// set summary after processing events,
// because it will trigger a name calculation
+1 -1
View File
@@ -24,7 +24,7 @@ import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import type * as NodeCrypto from "crypto";
import { MatrixEvent } from ".";
import { MatrixEvent } from "./models/event";
import { M_TIMESTAMP } from "./@types/location";
import { ReceiptType } from "./@types/read_receipts";
+730 -649
View File
File diff suppressed because it is too large Load Diff