Merge pull request #86 from vector-im/bwindels/verify-events

Verify events come from the device/fingerprint key they claim
This commit is contained in:
Bruno Windels 2020-09-08 08:59:37 +00:00 committed by GitHub
commit 7da4f5c9ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 262 additions and 55 deletions

View file

@ -71,6 +71,10 @@ export class MessageTile extends SimpleTile {
return this._isContinuation; return this._isContinuation;
} }
get isUnverified() {
return this._entry.isUnverified;
}
_getContent() { _getContent() {
return this._entry.content; return this._entry.content;
} }

View file

@ -41,13 +41,20 @@ export class DeviceMessageHandler {
// we don't handle anything other for now // we don't handle anything other for now
} }
async _writeDecryptedEvents(payloads, txn) { /**
const megOlmRoomKeysPayloads = payloads.filter(p => { * [_writeDecryptedEvents description]
return p.event?.type === "m.room_key" && p.event.content?.algorithm === MEGOLM_ALGORITHM; * @param {Array<DecryptionResult>} olmResults
* @param {[type]} txn [description]
* @return {[type]} [description]
*/
async _writeDecryptedEvents(olmResults, txn) {
const megOlmRoomKeysResults = olmResults.filter(r => {
return r.event?.type === "m.room_key" && r.event.content?.algorithm === MEGOLM_ALGORITHM;
}); });
let roomKeys; let roomKeys;
if (megOlmRoomKeysPayloads.length) { if (megOlmRoomKeysResults.length) {
roomKeys = await this._megolmDecryption.addRoomKeys(megOlmRoomKeysPayloads, txn); console.log("new room keys", megOlmRoomKeysResults);
roomKeys = await this._megolmDecryption.addRoomKeys(megOlmRoomKeysResults, txn);
} }
return {roomKeys}; return {roomKeys};
} }
@ -84,7 +91,7 @@ export class DeviceMessageHandler {
]); ]);
let changes; let changes;
try { try {
changes = await this._writeDecryptedEvents(decryptChanges.payloads, txn); changes = await this._writeDecryptedEvents(decryptChanges.results, txn);
decryptChanges.write(txn); decryptChanges.write(txn);
txn.session.remove(PENDING_ENCRYPTED_EVENTS); txn.session.remove(PENDING_ENCRYPTED_EVENTS);
} catch (err) { } catch (err) {

View file

@ -89,7 +89,7 @@ export class SessionContainer {
let sessionInfo; let sessionInfo;
try { try {
const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout}); const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout});
const loginData = await hsApi.passwordLogin(username, password).response(); const loginData = await hsApi.passwordLogin(username, password, "Hydrogen").response();
const sessionId = this.createNewSessionId(); const sessionId = this.createNewSessionId();
sessionInfo = { sessionInfo = {
id: sessionId, id: sessionId,

View file

@ -135,6 +135,7 @@ export class Sync {
storeNames.userIdentities, storeNames.userIdentities,
storeNames.inboundGroupSessions, storeNames.inboundGroupSessions,
storeNames.groupSessionDecryptions, storeNames.groupSessionDecryptions,
storeNames.deviceIdentities,
]); ]);
const roomChanges = []; const roomChanges = [];
let sessionChanges; let sessionChanges;

View file

@ -0,0 +1,70 @@
/*
Copyright 2020 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.
*/
/**
* @property {object} event the plaintext event (type and content property)
* @property {string} senderCurve25519Key the curve25519 sender key of the olm event
* @property {string} claimedEd25519Key The ed25519 fingerprint key retrieved from the decryption payload.
* The sender of the olm event claims this is the ed25519 fingerprint key
* that matches the curve25519 sender key.
* The caller needs to check if this key does indeed match the senderKey
* for a device with a valid signature returned from /keys/query,
* see DeviceTracker
*/
export class DecryptionResult {
constructor(event, senderCurve25519Key, claimedKeys) {
this.event = event;
this.senderCurve25519Key = senderCurve25519Key;
this.claimedEd25519Key = claimedKeys.ed25519;
this._device = null;
this._roomTracked = true;
}
setDevice(device) {
this._device = device;
}
setRoomNotTrackedYet() {
this._roomTracked = false;
}
get isVerified() {
if (this._device) {
const comesFromDevice = this._device.ed25519Key === this.claimedEd25519Key;
return comesFromDevice;
}
return false;
}
get isUnverified() {
if (this._device) {
return !this.isVerified;
} else if (this.isVerificationUnknown) {
return false;
} else {
return true;
}
}
get isVerificationUnknown() {
// verification is unknown if we haven't yet fetched the devices for the room
return !this._device && !this._roomTracked;
}
}

View file

@ -26,8 +26,8 @@ function deviceKeysAsDeviceIdentity(deviceSection) {
return { return {
userId, userId,
deviceId, deviceId,
ed25519Key: deviceSection.keys?.[`ed25519:${deviceId}`], ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
curve25519Key: deviceSection.keys?.[`curve25519:${deviceId}`], curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
algorithms: deviceSection.algorithms, algorithms: deviceSection.algorithms,
displayName: deviceSection.unsigned?.device_display_name, displayName: deviceSection.unsigned?.device_display_name,
}; };
@ -200,6 +200,11 @@ export class DeviceTracker {
if (deviceIdOnKeys !== deviceId) { if (deviceIdOnKeys !== deviceId) {
return false; return false;
} }
const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`];
const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`];
if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") {
return false;
}
// don't store our own device // don't store our own device
if (userId === this._ownUserId && deviceId === this._ownDeviceId) { if (userId === this._ownUserId && deviceId === this._ownDeviceId) {
return false; return false;
@ -270,4 +275,8 @@ export class DeviceTracker {
}); });
return devices; return devices;
} }
async getDeviceByCurve25519Key(curve25519Key, txn) {
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
}
} }

View file

@ -34,29 +34,47 @@ export class RoomEncryption {
this._megolmSyncCache = this._megolmDecryption.createSessionCache(); this._megolmSyncCache = this._megolmDecryption.createSessionCache();
// not `event_id`, but an internal event id passed in to the decrypt methods // not `event_id`, but an internal event id passed in to the decrypt methods
this._eventIdsByMissingSession = new Map(); this._eventIdsByMissingSession = new Map();
this._senderDeviceCache = new Map();
} }
notifyTimelineClosed() { notifyTimelineClosed() {
// empty the backfill cache when closing the timeline // empty the backfill cache when closing the timeline
this._megolmBackfillCache.dispose(); this._megolmBackfillCache.dispose();
this._megolmBackfillCache = this._megolmDecryption.createSessionCache(); this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
this._senderDeviceCache = new Map(); // purge the sender device cache
} }
async writeMemberChanges(memberChanges, txn) { async writeMemberChanges(memberChanges, txn) {
return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
} }
async decrypt(event, isSync, retryData, txn) { async decrypt(event, isSync, isTimelineOpen, retryData, txn) {
if (event.content?.algorithm !== MEGOLM_ALGORITHM) { if (event.content?.algorithm !== MEGOLM_ALGORITHM) {
throw new Error("Unsupported algorithm: " + event.content?.algorithm); throw new Error("Unsupported algorithm: " + event.content?.algorithm);
} }
let sessionCache = isSync ? this._megolmSyncCache : this._megolmBackfillCache; let sessionCache = isSync ? this._megolmSyncCache : this._megolmBackfillCache;
const payload = await this._megolmDecryption.decrypt( const result = await this._megolmDecryption.decrypt(
this._room.id, event, sessionCache, txn); this._room.id, event, sessionCache, txn);
if (!payload) { if (!result) {
this._addMissingSessionEvent(event, isSync, retryData); this._addMissingSessionEvent(event, isSync, retryData);
} }
return payload; if (result && isTimelineOpen) {
await this._verifyDecryptionResult(result, txn);
}
return result;
}
async _verifyDecryptionResult(result, txn) {
let device = this._senderDeviceCache.get(result.senderCurve25519Key);
if (!device) {
device = await this._deviceTracker.getDeviceByCurve25519Key(result.senderCurve25519Key, txn);
this._senderDeviceCache.set(result.senderCurve25519Key, device);
}
if (device) {
result.setDevice(device);
} else if (!this._room.isTrackingMembers) {
result.setRoomNotTrackedYet();
}
} }
_addMissingSessionEvent(event, isSync, data) { _addMissingSessionEvent(event, isSync, data) {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {DecryptionError} from "../common.js"; import {DecryptionError} from "../common.js";
import {DecryptionResult} from "../DecryptionResult.js";
const CACHE_MAX_SIZE = 10; const CACHE_MAX_SIZE = 10;
@ -28,6 +29,14 @@ export class Decryption {
return new SessionCache(); return new SessionCache();
} }
/**
* [decrypt description]
* @param {[type]} roomId [description]
* @param {[type]} event [description]
* @param {[type]} sessionCache [description]
* @param {[type]} txn [description]
* @return {DecryptionResult?} the decrypted event result, or undefined if the session id is not known.
*/
async decrypt(roomId, event, sessionCache, txn) { async decrypt(roomId, event, sessionCache, txn) {
const senderKey = event.content?.["sender_key"]; const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"]; const sessionId = event.content?.["session_id"];
@ -41,8 +50,13 @@ export class Decryption {
throw new DecryptionError("MEGOLM_INVALID_EVENT", event); throw new DecryptionError("MEGOLM_INVALID_EVENT", event);
} }
let session = sessionCache.get(roomId, senderKey, sessionId); let session;
if (!session) { let claimedKeys;
const cacheEntry = sessionCache.get(roomId, senderKey, sessionId);
if (cacheEntry) {
session = cacheEntry.session;
claimedKeys = cacheEntry.claimedKeys;
} else {
const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId); const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
if (sessionEntry) { if (sessionEntry) {
session = new this._olm.InboundGroupSession(); session = new this._olm.InboundGroupSession();
@ -52,7 +66,8 @@ export class Decryption {
session.free(); session.free();
throw err; throw err;
} }
sessionCache.add(roomId, senderKey, session); claimedKeys = sessionEntry.claimedKeys;
sessionCache.add(roomId, senderKey, session, claimedKeys);
} }
} }
if (!session) { if (!session) {
@ -70,8 +85,7 @@ export class Decryption {
{encryptedRoomId: payload.room_id, eventRoomId: roomId}); {encryptedRoomId: payload.room_id, eventRoomId: roomId});
} }
await this._handleReplayAttack(roomId, sessionId, messageIndex, event, txn); await this._handleReplayAttack(roomId, sessionId, messageIndex, event, txn);
// TODO: verify event came from said senderKey return new DecryptionResult(payload, senderKey, claimedKeys);
return payload;
} }
async _handleReplayAttack(roomId, sessionId, messageIndex, event, txn) { async _handleReplayAttack(roomId, sessionId, messageIndex, event, txn) {
@ -82,7 +96,11 @@ export class Decryption {
// the one with the newest timestamp should be the attack // the one with the newest timestamp should be the attack
const decryptedEventIsBad = decryption.timestamp < timestamp; const decryptedEventIsBad = decryption.timestamp < timestamp;
const badEventId = decryptedEventIsBad ? eventId : decryption.eventId; const badEventId = decryptedEventIsBad ? eventId : decryption.eventId;
throw new DecryptionError("MEGOLM_REPLAYED_INDEX", event, {badEventId, otherEventId: decryption.eventId}); throw new DecryptionError("MEGOLM_REPLAYED_INDEX", event, {
messageIndex,
badEventId,
otherEventId: decryption.eventId
});
} }
if (!decryption) { if (!decryption) {
txn.groupSessionDecryptions.set({ txn.groupSessionDecryptions.set({
@ -95,9 +113,19 @@ export class Decryption {
} }
} }
async addRoomKeys(payloads, txn) { /**
* @type {MegolmInboundSessionDescription}
* @property {string} senderKey the sender key of the session
* @property {string} sessionId the session identifier
*
* Adds room keys as inbound group sessions
* @param {Array<OlmDecryptionResult>} decryptionResults an array of m.room_key decryption results.
* @param {[type]} txn a storage transaction with read/write on inboundGroupSessions
* @return {Promise<Array<MegolmInboundSessionDescription>>} an array with the newly added sessions
*/
async addRoomKeys(decryptionResults, txn) {
const newSessions = []; const newSessions = [];
for (const {senderKey, event} of payloads) { for (const {senderCurve25519Key: senderKey, event, claimedEd25519Key} of decryptionResults) {
const roomId = event.content?.["room_id"]; const roomId = event.content?.["room_id"];
const sessionId = event.content?.["session_id"]; const sessionId = event.content?.["session_id"];
const sessionKey = event.content?.["session_key"]; const sessionKey = event.content?.["session_key"];
@ -122,7 +150,7 @@ export class Decryption {
senderKey, senderKey,
sessionId, sessionId,
session: session.pickle(this._pickleKey), session: session.pickle(this._pickleKey),
claimedKeys: event.keys, claimedKeys: {ed25519: claimedEd25519Key},
}; };
txn.inboundGroupSessions.set(sessionEntry); txn.inboundGroupSessions.set(sessionEntry);
newSessions.push(sessionEntry); newSessions.push(sessionEntry);
@ -142,6 +170,17 @@ class SessionCache {
this._sessions = []; this._sessions = [];
} }
/**
* @type {CacheEntry}
* @property {InboundGroupSession} session the unpickled session
* @property {Object} claimedKeys an object with the claimed ed25519 key
*
*
* @param {string} roomId
* @param {string} senderKey
* @param {string} sessionId
* @return {CacheEntry?}
*/
get(roomId, senderKey, sessionId) { get(roomId, senderKey, sessionId) {
const idx = this._sessions.findIndex(s => { const idx = this._sessions.findIndex(s => {
return s.roomId === roomId && return s.roomId === roomId &&
@ -155,13 +194,13 @@ class SessionCache {
this._sessions.splice(idx, 1); this._sessions.splice(idx, 1);
this._sessions.unshift(entry); this._sessions.unshift(entry);
} }
return entry.session; return entry;
} }
} }
add(roomId, senderKey, session) { add(roomId, senderKey, session, claimedKeys) {
// add new at top // add new at top
this._sessions.unshift({roomId, senderKey, session}); this._sessions.unshift({roomId, senderKey, session, claimedKeys});
if (this._sessions.length > CACHE_MAX_SIZE) { if (this._sessions.length > CACHE_MAX_SIZE) {
// free sessions we're about to remove // free sessions we're about to remove
for (let i = CACHE_MAX_SIZE; i < this._sessions.length; i += 1) { for (let i = CACHE_MAX_SIZE; i < this._sessions.length; i += 1) {

View file

@ -26,6 +26,14 @@ export class Encryption {
this._ownDeviceId = ownDeviceId; this._ownDeviceId = ownDeviceId;
} }
/**
* Encrypts a message with megolm
* @param {string} roomId
* @param {string} type event type to encrypt
* @param {string} content content to encrypt
* @param {object} encryptionParams the content of the m.room.encryption event
* @return {Promise<EncryptionResult>}
*/
async encrypt(roomId, type, content, encryptionParams) { async encrypt(roomId, type, content, encryptionParams) {
let session = new this._olm.OutboundGroupSession(); let session = new this._olm.OutboundGroupSession();
try { try {
@ -145,6 +153,14 @@ export class Encryption {
} }
} }
/**
* @property {object?} roomKeyMessage if encrypting this message
* created a new outbound session,
* this contains the content of the m.room_key message
* that should be sent out over olm.
* @property {object} content the encrypted message as the content of
* the m.room.encrypted event that should be sent out
*/
class EncryptionResult { class EncryptionResult {
constructor(content, roomKeyMessage) { constructor(content, roomKeyMessage) {
this.content = content; this.content = content;

View file

@ -17,6 +17,7 @@ limitations under the License.
import {DecryptionError} from "../common.js"; import {DecryptionError} from "../common.js";
import {groupBy} from "../../../utils/groupBy.js"; import {groupBy} from "../../../utils/groupBy.js";
import {Session} from "./Session.js"; import {Session} from "./Session.js";
import {DecryptionResult} from "../DecryptionResult.js";
const SESSION_LIMIT_PER_SENDER_KEY = 4; const SESSION_LIMIT_PER_SENDER_KEY = 4;
@ -50,6 +51,13 @@ export class Decryption {
// and also can avoid side-effects before all can be stored this way // and also can avoid side-effects before all can be stored this way
// //
// doing it one by one would be possible, but we would lose the opportunity for parallelization // doing it one by one would be possible, but we would lose the opportunity for parallelization
//
/**
* [decryptAll description]
* @param {[type]} events
* @return {Promise<DecryptionChanges>} [description]
*/
async decryptAll(events) { async decryptAll(events) {
const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]); const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]);
const timestamp = this._now(); const timestamp = this._now();
@ -61,15 +69,16 @@ export class Decryption {
try { try {
const readSessionsTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); const readSessionsTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
// decrypt events for different sender keys in parallel // decrypt events for different sender keys in parallel
const results = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => { const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => {
return this._decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn); return this._decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn);
})); }));
const payloads = results.reduce((all, r) => all.concat(r.payloads), []); const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), []);
const errors = results.reduce((all, r) => all.concat(r.errors), []); const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []);
const senderKeyDecryptions = results.map(r => r.senderKeyDecryption); const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption);
return new DecryptionChanges(senderKeyDecryptions, payloads, errors, this._account, locks); return new DecryptionChanges(senderKeyDecryptions, results, errors, this._account, locks);
} catch (err) { } catch (err) {
// make sure the locks are release if something throws // make sure the locks are release if something throws
// otherwise they will be released in DecryptionChanges after having written
for (const lock of locks) { for (const lock of locks) {
lock.release(); lock.release();
} }
@ -80,18 +89,18 @@ export class Decryption {
async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) { async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) {
const sessions = await this._getSessions(senderKey, readSessionsTxn); const sessions = await this._getSessions(senderKey, readSessionsTxn);
const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp); const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp);
const payloads = []; const results = [];
const errors = []; const errors = [];
// events for a single senderKey need to be decrypted one by one // events for a single senderKey need to be decrypted one by one
for (const event of events) { for (const event of events) {
try { try {
const payload = this._decryptForSenderKey(senderKeyDecryption, event, timestamp); const result = this._decryptForSenderKey(senderKeyDecryption, event, timestamp);
payloads.push(payload); results.push(result);
} catch (err) { } catch (err) {
errors.push(err); errors.push(err);
} }
} }
return {payloads, errors, senderKeyDecryption}; return {results, errors, senderKeyDecryption};
} }
_decryptForSenderKey(senderKeyDecryption, event, timestamp) { _decryptForSenderKey(senderKeyDecryption, event, timestamp) {
@ -118,7 +127,7 @@ export class Decryption {
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err}); throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
} }
this._validatePayload(payload, event); this._validatePayload(payload, event);
return {event: payload, senderKey}; return new DecryptionResult(payload, senderKey, payload.keys);
} else { } else {
throw new DecryptionError("OLM_NO_MATCHING_SESSION", event, throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
{knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)}); {knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)});
@ -182,9 +191,9 @@ export class Decryption {
if (!payload.content) { if (!payload.content) {
throw new DecryptionError("missing content on payload", event, {payload}); throw new DecryptionError("missing content on payload", event, {payload});
} }
// TODO: how important is it to verify the message? if (typeof payload.keys?.ed25519 !== "string") {
// we should look at payload.keys.ed25519 for that... and compare it to the key we have fetched throw new DecryptionError("Missing or invalid claimed ed25519 key on payload", event, {payload});
// from /keys/query, which we might not have done yet at this point. }
} }
} }
@ -252,11 +261,15 @@ class SenderKeyDecryption {
} }
} }
/**
* @property {Array<DecryptionResult>} results
* @property {Array<DecryptionError>} errors see DecryptionError.event to retrieve the event that failed to decrypt.
*/
class DecryptionChanges { class DecryptionChanges {
constructor(senderKeyDecryptions, payloads, errors, account, locks) { constructor(senderKeyDecryptions, results, errors, account, locks) {
this._senderKeyDecryptions = senderKeyDecryptions; this._senderKeyDecryptions = senderKeyDecryptions;
this._account = account; this._account = account;
this.payloads = payloads; this.results = results;
this.errors = errors; this.errors = errors;
this._locks = locks; this._locks = locks;
} }

View file

@ -141,14 +141,15 @@ export class HomeServerApi {
{}, {}, options); {}, {}, options);
} }
passwordLogin(username, password, options = null) { passwordLogin(username, password, initialDeviceDisplayName, options = null) {
return this._post("/login", null, { return this._post("/login", null, {
"type": "m.login.password", "type": "m.login.password",
"identifier": { "identifier": {
"type": "m.id.user", "type": "m.id.user",
"user": username "user": username
}, },
"password": password "password": password,
"initial_device_display_name": initialDeviceDisplayName
}, options); }, options);
} }

View file

@ -26,7 +26,6 @@ import {fetchOrLoadMembers} from "./members/load.js";
import {MemberList} from "./members/MemberList.js"; import {MemberList} from "./members/MemberList.js";
import {Heroes} from "./members/Heroes.js"; import {Heroes} from "./members/Heroes.js";
import {EventEntry} from "./timeline/entries/EventEntry.js"; import {EventEntry} from "./timeline/entries/EventEntry.js";
import {EventKey} from "./timeline/EventKey.js";
export class Room extends EventEmitter { export class Room extends EventEmitter {
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption}) { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption}) {
@ -58,13 +57,14 @@ export class Room extends EventEmitter {
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions, this._storage.storeNames.groupSessionDecryptions,
this._storage.storeNames.deviceIdentities,
]); ]);
try { try {
for (const retryEntry of retryEntries) { for (const retryEntry of retryEntries) {
const {data: eventKey} = retryEntry; const {data: eventKey} = retryEntry;
let entry = this._timeline?.findEntry(eventKey); let entry = this._timeline?.findEntry(eventKey);
if (!entry) { if (!entry) {
const storageEntry = await txn.timelineEvents.get(this._roomId, eventKey.fragmentId, eventKey.entryIndex); const storageEntry = await txn.timelineEvents.get(this._roomId, eventKey);
if (storageEntry) { if (storageEntry) {
entry = new EventEntry(storageEntry, this._fragmentIdComparer); entry = new EventEntry(storageEntry, this._fragmentIdComparer);
} }
@ -101,12 +101,10 @@ export class Room extends EventEmitter {
async _decryptEntry(entry, txn, isSync) { async _decryptEntry(entry, txn, isSync) {
if (entry.eventType === "m.room.encrypted") { if (entry.eventType === "m.room.encrypted") {
try { try {
const {fragmentId, entryIndex} = entry; const decryptionResult = await this._roomEncryption.decrypt(
const key = new EventKey(fragmentId, entryIndex); entry.event, isSync, !!this._timeline, entry.asEventKey(), txn);
const decryptedEvent = await this._roomEncryption.decrypt( if (decryptionResult) {
entry.event, isSync, key, txn); entry.setDecryptionResult(decryptionResult);
if (decryptedEvent) {
entry.replaceWithDecrypted(decryptedEvent);
} }
} catch (err) { } catch (err) {
console.warn("event decryption error", err, entry.event); console.warn("event decryption error", err, entry.event);
@ -283,6 +281,7 @@ export class Room extends EventEmitter {
stores = stores.concat([ stores = stores.concat([
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions, this._storage.storeNames.groupSessionDecryptions,
this._storage.storeNames.deviceIdentities,
]); ]);
} }
const txn = await this._storage.readWriteTxn(stores); const txn = await this._storage.readWriteTxn(stores);

View file

@ -22,7 +22,7 @@ export class EventEntry extends BaseEntry {
super(fragmentIdComparer); super(fragmentIdComparer);
this._eventEntry = eventEntry; this._eventEntry = eventEntry;
this._decryptionError = null; this._decryptionError = null;
this._decryptedEvent = null; this._decryptionResult = null;
} }
get event() { get event() {
@ -38,15 +38,16 @@ export class EventEntry extends BaseEntry {
} }
get content() { get content() {
return this._decryptedEvent?.content || this._eventEntry.event.content; return this._decryptionResult?.event?.content || this._eventEntry.event.content;
} }
get prevContent() { get prevContent() {
// doesn't look at _decryptionResult because state events are not encrypted
return getPrevContentFromStateEvent(this._eventEntry.event); return getPrevContentFromStateEvent(this._eventEntry.event);
} }
get eventType() { get eventType() {
return this._decryptedEvent?.type || this._eventEntry.event.type; return this._decryptionResult?.event?.type || this._eventEntry.event.type;
} }
get stateKey() { get stateKey() {
@ -73,8 +74,20 @@ export class EventEntry extends BaseEntry {
return this._eventEntry.event.event_id; return this._eventEntry.event.event_id;
} }
replaceWithDecrypted(event) { setDecryptionResult(result) {
this._decryptedEvent = event; this._decryptionResult = result;
}
get isEncrypted() {
return this._eventEntry.event.type === "m.room.encrypted";
}
get isVerified() {
return this.isEncrypted && this._decryptionResult?.isVerified;
}
get isUnverified() {
return this.isEncrypted && this._decryptionResult?.isUnverified;
} }
setDecryptionError(err) { setDecryptionError(err) {

View file

@ -38,6 +38,7 @@ export class TimelineReader {
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
this._storage.storeNames.inboundGroupSessions, this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions, this._storage.storeNames.groupSessionDecryptions,
this._storage.storeNames.deviceIdentities,
]); ]);
} else { } else {

View file

@ -14,6 +14,7 @@ export const schema = [
createInboundGroupSessionsStore, createInboundGroupSessionsStore,
createOutboundGroupSessionsStore, createOutboundGroupSessionsStore,
createGroupSessionDecryptions, createGroupSessionDecryptions,
addSenderKeyIndexToDeviceStore
]; ];
// TODO: how to deal with git merge conflicts of this array? // TODO: how to deal with git merge conflicts of this array?
@ -94,3 +95,9 @@ function createOutboundGroupSessionsStore(db) {
function createGroupSessionDecryptions(db) { function createGroupSessionDecryptions(db) {
db.createObjectStore("groupSessionDecryptions", {keyPath: "key"}); db.createObjectStore("groupSessionDecryptions", {keyPath: "key"});
} }
//v9
function addSenderKeyIndexToDeviceStore(db, txn) {
const deviceIdentities = txn.objectStore("deviceIdentities");
deviceIdentities.createIndex("byCurve25519Key", "curve25519Key", {unique: true});
}

View file

@ -38,4 +38,8 @@ export class DeviceIdentityStore {
deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId); deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
return this._store.put(deviceIdentity); return this._store.put(deviceIdentity);
} }
getByCurve25519Key(curve25519Key) {
return this._store.index("byCurve25519Key").get(curve25519Key);
}
} }

View file

@ -373,6 +373,10 @@ ul.Timeline > li.continuation time {
color: #ccc; color: #ccc;
} }
.TextMessageView.unverified .message-container {
color: #ff4b55;
}
.message-container p { .message-container p {
margin: 3px 0; margin: 3px 0;
line-height: 2.2rem; line-height: 2.2rem;

View file

@ -22,6 +22,7 @@ export function renderMessage(t, vm, children) {
"TextMessageView": true, "TextMessageView": true,
own: vm.isOwn, own: vm.isOwn,
pending: vm.isPending, pending: vm.isPending,
unverified: vm.isUnverified,
continuation: vm => vm.isContinuation, continuation: vm => vm.isContinuation,
}; };