Matrix Javascript SDK vulnerable to impersonation via forwarded Megolm sessions
Description
Matrix Javascript SDK is the Matrix Client-Server SDK for JavaScript. Prior to version 19.7.0, an attacker cooperating with a malicious homeserver can construct messages appearing to have come from another person. Such messages will be marked with a grey shield on some platforms, but this may be missing in others. This attack is possible due to the matrix-js-sdk implementing a too permissive key forwarding strategy on the receiving end. Starting with version 19.7.0, the default policy for accepting key forwards has been made more strict in the matrix-js-sdk. matrix-js-sdk will now only accept forwarded keys in response to previously issued requests and only from own, verified devices. The SDK now sets a trusted flag on the decrypted message upon decryption, based on whether the key used to decrypt the message was received from a trusted source. Clients need to ensure that messages decrypted with a key with trusted = false are decorated appropriately, for example, by showing a warning for such messages. This attack requires coordination between a malicious homeserver and an attacker, and those who trust your homeservers do not need a workaround.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
matrix-js-sdknpm | < 19.7.0 | 19.7.0 |
Affected products
1- Range: < 19.7.0
Patches
1a587d7c36026Resolve multiple CVEs
30 files changed · +1375 −79
spec/integ/matrix-client-crypto.spec.ts+6 −0 modified@@ -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();
spec/integ/matrix-client-syncing.spec.ts+2 −0 modified@@ -87,6 +87,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
spec/integ/megolm-integ.spec.ts+316 −1 modified@@ -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(); + }); });
spec/unit/content-helpers.spec.ts+61 −0 modified@@ -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', () => {
spec/unit/crypto/algorithms/megolm.spec.ts+6 −0 modified@@ -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() {
spec/unit/crypto/backup.spec.ts+6 −0 modified@@ -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',
spec/unit/crypto/secrets.spec.ts+6 −2 modified@@ -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 @@ -257,6 +258,7 @@ describe("Secrets", function() { "ed25519:VAX": vaxDevice.deviceEd25519Key, "curve25519:VAX": vaxDevice.deviceCurve25519Key, }, + verified: DeviceInfo.DeviceVerification.VERIFIED, }, }); vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", { @@ -280,10 +282,12 @@ describe("Secrets", function() { Object.values(otks)[0], ); + osborne2.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + osborne2.client.crypto.deviceList.getUserByIdentityKey = () => "@alice:example.com"; + const request = await secretStorage.request("foo", ["VAX"]); - const secret = await request.promise; + await request.promise; // return value not used - expect(secret).toBe("bar"); osborne2.stop(); vax.stop(); clearTestClientTimeouts();
spec/unit/crypto.spec.ts+498 −12 modified@@ -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 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); - // the room key request should be gone since we've now decrypted everything + // 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() {
spec/unit/crypto/verification/sas.spec.ts+20 −6 modified@@ -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" }, + ); }); }); });
spec/unit/models/beacon.spec.ts+22 −0 modified@@ -22,6 +22,7 @@ import { BeaconEvent, } from "../../../src/models/beacon"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon"; +import { REFERENCE_RELATION } from "matrix-events-sdk"; jest.useFakeTimers(); @@ -431,6 +432,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;
src/client.ts+1 −0 modified@@ -5287,6 +5287,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 {
src/content-helpers.ts+5 −4 modified@@ -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, }; };
src/crypto/algorithms/megolm.ts+108 −17 modified@@ -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,58 @@ 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) { + // If someone sends us an unsolicited key 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 && !fromUs) { + 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 +1473,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 +1485,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 +1541,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 +1756,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 +1784,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 +1795,12 @@ class MegolmDecryption extends DecryptionAlgorithm { * @private * @param {String} senderKey * @param {String} sessionId + * @param {Boolean} keyTrusted * - * @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, keyTrusted?: boolean): Promise<boolean> { const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents) { return true; @@ -1725,13 +1815,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, keyTrusted }); } 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); }
src/crypto/algorithms/olm.ts+20 −0 modified@@ -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,
src/crypto/backup.ts+0 −1 modified@@ -431,7 +431,6 @@ export class BackupManager { ) ); }); - ret.usable = ret.usable || ret.trusted_locally; return ret; }
src/crypto/index.ts+17 −9 modified@@ -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;
src/crypto/OlmDevice.ts+51 −14 modified@@ -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, }; }, );
src/crypto/olmlib.ts+18 −0 modified@@ -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.
src/crypto/SecretStorage.ts+24 −0 modified@@ -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} ` +
src/crypto/store/base.ts+12 −0 modified@@ -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[]; +}
src/crypto/store/indexeddb-crypto-store-backend.ts+49 −0 modified@@ -25,6 +25,7 @@ import { IWithheld, Mode, OutgoingRoomKeyRequest, + ParkedSharedHistory, } from "./base"; import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; import { ICrossSigningKey } from "../../client"; @@ -873,6 +874,49 @@ 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([]); + } + const data = cursor.value; + cursor.delete(); + resolve(data); + }; + cursorReq.onerror = reject; + }); + } + public doTxn<T>( mode: Mode, stores: string | string[], @@ -958,6 +1002,11 @@ export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void { keyPath: ["roomId"], }); } + if (oldVersion < 11) { + db.createObjectStore("parked_shared_history", { + keyPath: ["roomId"], + }); + } // Expand as needed. }
src/crypto/store/indexeddb-crypto-store.ts+23 −0 modified@@ -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
src/crypto/store/memory-crypto-store.ts+14 −0 modified@@ -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> {
src/crypto/verification/Base.ts+19 −6 modified@@ -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(); } }
src/event-mapper.ts+5 −0 modified@@ -22,13 +22,18 @@ export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent; export interface MapperOpts { preventReEmit?: boolean; decrypt?: boolean; + toDevice?: boolean; } export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper { let preventReEmit = Boolean(options.preventReEmit); 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;
src/models/beacon.ts+3 −2 modified@@ -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) &&
src/models/event.ts+2 −1 modified@@ -151,6 +151,7 @@ interface IKeyRequestRecipient { export interface IDecryptOptions { emit?: boolean; isRetry?: boolean; + keyTrusted?: boolean; } /** @@ -695,7 +696,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan throw new Error("Attempt to decrypt event which isn't encrypted"); } - if (this.clearEvent && !this.isDecryptionFailure()) { + if (this.clearEvent && !this.isDecryptionFailure() && !(this.isKeySourceUntrusted() && options.keyTrusted)) { // 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",
src/NamespacedValue.ts+4 −2 modified@@ -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];
src/sync.ts+37 −2 modified@@ -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,24 @@ 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(); + const parkedHistory = await client.crypto.cryptoStore.takeParkedSharedHistory(room.roomId); + for (const parked of parkedHistory) { + if (parked.senderId === inviter) { + await this.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); @@ -1288,7 +1319,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
src/@types/crypto.ts+20 −0 added@@ -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; +};
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-6263-x97c-c4ggghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-39249ghsaADVISORY
- security.gentoo.org/glsa/202210-35ghsavendor-advisoryWEB
- github.com/matrix-org/matrix-js-sdk/commit/a587d7c36026fe1fcf93dfff63588abee359be76ghsaWEB
- github.com/matrix-org/matrix-js-sdk/releases/tag/v19.7.0ghsaWEB
- github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-6263-x97c-c4ggghsaWEB
- github.com/matrix-org/matrix-spec-proposals/pull/3061ghsaWEB
- matrix.org/blog/2022/09/28/upgrade-now-to-address-encryption-vulns-in-matrix-sdks-and-clientsghsaWEB
News mentions
0No linked articles in our index yet.