diff --git a/spec/integ/matrix-client-crypto.spec.ts b/spec/integ/matrix-client-crypto.spec.ts index fc0e30dd949..d0f46b9e56c 100644 --- a/spec/integ/matrix-client-crypto.spec.ts +++ b/spec/integ/matrix-client-crypto.spec.ts @@ -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(); diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 9f3fb988703..a22e0a257f2 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -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 diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/megolm-integ.spec.ts index f69d2ebdc1b..ae2771f3bbb 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/megolm-integ.spec.ts @@ -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(); + }); }); diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index ba431d9d586..35971600715 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -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', () => { diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts index 93a5461b3ea..6e46b3aaaa9 100644 --- a/spec/unit/crypto.spec.ts +++ b/spec/unit/crypto.spec.ts @@ -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 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() { diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts index b9f16c742ec..f1bb6228458 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ b/spec/unit/crypto/algorithms/megolm.spec.ts @@ -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() { diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index 8e264740406..acec47fcee2 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -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', diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 8a8326867f0..9c8b253845c 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -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(); diff --git a/spec/unit/crypto/verification/sas.spec.ts b/spec/unit/crypto/verification/sas.spec.ts index f82b313fbc7..fc0a0d9bd88 100644 --- a/spec/unit/crypto/verification/sas.spec.ts +++ b/spec/unit/crypto/verification/sas.spec.ts @@ -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" }, + ); }); }); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index c0de3591b5b..49006c781c0 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -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; diff --git a/src/@types/crypto.ts b/src/@types/crypto.ts new file mode 100644 index 00000000000..3c46d993910 --- /dev/null +++ b/src/@types/crypto.ts @@ -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; +}; diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts index 794366f8b32..9b8b3d408a8 100644 --- a/src/NamespacedValue.ts +++ b/src/NamespacedValue.ts @@ -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 { // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class // so we can instantiate `NamespacedValue` as a default type for that namespace. - public findIn(obj: any): T { + public findIn(obj: any): Optional { let val: T; if (this.name) { val = obj?.[this.name]; diff --git a/src/client.ts b/src/client.ts index cf7c45082fb..07fbaecec28 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5287,6 +5287,7 @@ export class MatrixClient extends TypedEventEmitter { - const { description, uri } = M_LOCATION.findIn(content); + const location = M_LOCATION.findIn(content); const timestamp = M_TIMESTAMP.findIn(content); return { - description, - uri, + description: location?.description, + uri: location?.uri, timestamp, }; }; diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index 0b2e616a890..cca3b7db97a 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -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, exportFormat: boolean, - extraSessionData: Record = {}, + extraSessionData: OlmGroupSessionExtraData = {}, ): Promise { 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, }; }, ); diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index 0eef2ee7d50..aeb0d4596b3 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -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} ` + diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 122164f44b6..2052ee967ce 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -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>>(); // 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; + 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 { - 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 { + private async retryDecryption(senderKey: string, sessionId: string, keyTrusted?: boolean): Promise { 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); } diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index aec39d49e6e..38b1c97b304 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -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, diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 2f03865824d..c6c4d66e2b0 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -431,7 +431,6 @@ export class BackupManager { ) ); }); - ret.usable = ret.usable || ret.trusted_locally; return ret; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 65391648195..7c0092bf69e 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -2105,6 +2105,10 @@ export class Crypto extends TypedEventEmitter} 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} updated DeviceInfo */ public async setDeviceVerification( @@ -2113,6 +2117,7 @@ export class Crypto extends TypedEventEmitter, ): Promise { // 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 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 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; diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index 9d4478449b3..f108020bfcd 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -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. diff --git a/src/crypto/store/base.ts b/src/crypto/store/base.ts index 556854c717b..6b938aadf94 100644 --- a/src/crypto/store/base.ts +++ b/src/crypto/store/base.ts @@ -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; // Session key backups doTxn(mode: Mode, stores: Iterable, func: (txn: unknown) => T, log?: PrefixedLogger): Promise; @@ -203,3 +206,12 @@ export interface OutgoingRoomKeyRequest { requestBody: IRoomKeyRequestBody; state: RoomKeyRequestState; } + +export interface ParkedSharedHistory { + senderId: string; + senderKey: string; + sessionId: string; + sessionKey: string; + keysClaimed: ReturnType; // XXX: Less type dependence on MatrixEvent + forwardingCurve25519KeyChain: string[]; +} diff --git a/src/crypto/store/indexeddb-crypto-store-backend.ts b/src/crypto/store/indexeddb-crypto-store-backend.ts index 2611a5f2a35..a4d46b12fd5 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.ts +++ b/src/crypto/store/indexeddb-crypto-store-backend.ts @@ -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 { + 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( 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. } diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index ecc3d86c3cc..21f704fd85d 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -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 { + 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 diff --git a/src/crypto/store/memory-crypto-store.ts b/src/crypto/store/memory-crypto-store.ts index f62f52250be..a0195bb4453 100644 --- a/src/crypto/store/memory-crypto-store.ts +++ b/src/crypto/store/memory-crypto-store.ts @@ -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(); // 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 { + const parked = this.parkedSharedHistory.get(roomId) ?? []; + this.parkedSharedHistory.delete(roomId); + return Promise.resolve(parked); + } + // Session key backups public doTxn(mode: Mode, stores: Iterable, func: (txn?: unknown) => T): Promise { diff --git a/src/crypto/verification/Base.ts b/src/crypto/verification/Base.ts index 35100499084..ddf38f8ce91 100644 --- a/src/crypto/verification/Base.ts +++ b/src/crypto/verification/Base.ts @@ -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((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(); } } diff --git a/src/event-mapper.ts b/src/event-mapper.ts index 40ef5a824a9..38ee4267e66 100644 --- a/src/event-mapper.ts +++ b/src/event-mapper.ts @@ -22,6 +22,7 @@ export type EventMapper = (obj: Partial) => 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) { + if (options.toDevice) { + delete plainOldJsObject.room_id; + } + const room = client.getRoom(plainOldJsObject.room_id); let event: MatrixEvent; diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 25abf2842ef..92e95079778 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -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 { const content = event.getContent(); - const timestamp = M_TIMESTAMP.findIn(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) && diff --git a/src/models/event.ts b/src/models/event.ts index c56bedd2080..071134978bd 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -151,6 +151,7 @@ interface IKeyRequestRecipient { export interface IDecryptOptions { emit?: boolean; isRetry?: boolean; + keyTrusted?: boolean; } /** @@ -695,7 +696,7 @@ export class MatrixEvent extends TypedEventEmitter 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