implement decryption retrying and decrypting of gap/load entries

turns out we do have to always check for replay attacks because
failing to decrypt doesn't prevent an item from being stored,
so if you reload and then load you might be decrypting it
for the first time
This commit is contained in:
Bruno Windels 2020-09-04 15:28:22 +02:00
parent 565fdb0f8c
commit 62bcb27784
6 changed files with 151 additions and 76 deletions

View file

@ -133,6 +133,8 @@ export class Sync {
storeNames.timelineFragments, storeNames.timelineFragments,
storeNames.pendingEvents, storeNames.pendingEvents,
storeNames.userIdentities, storeNames.userIdentities,
storeNames.inboundGroupSessions,
storeNames.groupSessionDecryptions,
]); ]);
const roomChanges = []; const roomChanges = [];
let sessionChanges; let sessionChanges;

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MEGOLM_ALGORITHM} from "./common.js";
import {groupBy} from "../../utils/groupBy.js"; import {groupBy} from "../../utils/groupBy.js";
import {makeTxnId} from "../common.js"; import {makeTxnId} from "../common.js";
@ -44,57 +45,43 @@ export class RoomEncryption {
return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); return await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
} }
async decryptNewSyncEvent(id, event, txn) { async decrypt(event, isSync, retryData, txn) {
const payload = await this._megolmDecryption.decryptNewEvent( if (event.content?.algorithm !== MEGOLM_ALGORITHM) {
this._room.id, event, this._megolmSyncCache, txn); throw new Error("Unsupported algorithm: " + event.content?.algorithm);
}
let sessionCache = isSync ? this._megolmSyncCache : this._megolmBackfillCache;
const payload = await this._megolmDecryption.decrypt(
this._room.id, event, sessionCache, txn);
if (!payload) { if (!payload) {
this._addMissingSessionEvent(id, event); this._addMissingSessionEvent(event, isSync, retryData);
} }
return payload; return payload;
} }
async decryptNewGapEvent(id, event, txn) { _addMissingSessionEvent(event, isSync, data) {
const payload = await this._megolmDecryption.decryptNewEvent(
this._room.id, event, this._megolmBackfillCache, txn);
if (!payload) {
this._addMissingSessionEvent(id, event);
}
return payload;
}
async decryptStoredEvent(id, event, txn) {
const payload = await this._megolmDecryption.decryptStoredEvent(
this._room.id, event, this._megolmBackfillCache, txn);
if (!payload) {
this._addMissingSessionEvent(id, event);
}
return payload;
}
_addMissingSessionEvent(id, event) {
const senderKey = event.content?.["sender_key"]; const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"]; const sessionId = event.content?.["session_id"];
const key = `${senderKey}|${sessionId}`; const key = `${senderKey}|${sessionId}`;
let eventIds = this._eventIdsByMissingSession.get(key); let eventIds = this._eventIdsByMissingSession.get(key);
if (!eventIds) { if (!eventIds) {
eventIds = new Set(); eventIds = new Map();
this._eventIdsByMissingSession.set(key, eventIds); this._eventIdsByMissingSession.set(key, eventIds);
} }
eventIds.add(id); eventIds.set(event.event_id, {data, isSync});
} }
applyRoomKeys(roomKeys) { applyRoomKeys(roomKeys) {
// retry decryption with the new sessions // retry decryption with the new sessions
const idsToRetry = []; const retryEntries = [];
for (const roomKey of roomKeys) { for (const roomKey of roomKeys) {
const key = `${roomKey.senderKey}|${roomKey.sessionId}`; const key = `${roomKey.senderKey}|${roomKey.sessionId}`;
const idsForSession = this._eventIdsByMissingSession.get(key); const entriesForSession = this._eventIdsByMissingSession.get(key);
if (idsForSession) { if (entriesForSession) {
this._eventIdsByMissingSession.delete(key); this._eventIdsByMissingSession.delete(key);
idsToRetry.push(...Array.from(idsForSession)); retryEntries.push(...entriesForSession.values());
} }
} }
return idsToRetry; return retryEntries;
} }
async encrypt(type, content, hsApi) { async encrypt(type, content, hsApi) {

View file

@ -28,19 +28,7 @@ export class Decryption {
return new SessionCache(); return new SessionCache();
} }
async decryptNewEvent(roomId, event, sessionCache, txn) { async decrypt(roomId, event, sessionCache, txn) {
const {payload, messageIndex} = this._decrypt(roomId, event, sessionCache, txn);
const sessionId = event.content?.["session_id"];
this._handleReplayAttacks(roomId, sessionId, messageIndex, event, txn);
return payload;
}
async decryptStoredEvent(roomId, event, sessionCache, txn) {
const {payload} = this._decrypt(roomId, event, sessionCache, txn);
return payload;
}
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"];
const ciphertext = event.content?.ciphertext; const ciphertext = event.content?.ciphertext;
@ -75,16 +63,18 @@ export class Decryption {
try { try {
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (err) { } catch (err) {
throw new DecryptionError("NOT_JSON", event, {plaintext, err}); throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
} }
if (payload.room_id !== roomId) { if (payload.room_id !== roomId) {
throw new DecryptionError("MEGOLM_WRONG_ROOM", event, throw new DecryptionError("MEGOLM_WRONG_ROOM", event,
{encryptedRoomId: payload.room_id, eventRoomId: roomId}); {encryptedRoomId: payload.room_id, eventRoomId: roomId});
} }
return {payload, messageIndex}; await this._handleReplayAttack(roomId, sessionId, messageIndex, event, txn);
// TODO: verify event came from said senderKey
return payload;
} }
async _handleReplayAttacks(roomId, sessionId, messageIndex, event, txn) { async _handleReplayAttack(roomId, sessionId, messageIndex, event, txn) {
const eventId = event.event_id; const eventId = event.event_id;
const timestamp = event.origin_server_ts; const timestamp = event.origin_server_ts;
const decryption = await txn.groupSessionDecryptions.get(roomId, sessionId, messageIndex); const decryption = await txn.groupSessionDecryptions.get(roomId, sessionId, messageIndex);
@ -92,7 +82,7 @@ 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_REPLAY_ATTACK", event, {badEventId, otherEventId: decryption.eventId}); throw new DecryptionError("MEGOLM_REPLAYED_INDEX", event, {badEventId, otherEventId: decryption.eventId});
} }
if (!decryption) { if (!decryption) {
txn.groupSessionDecryptions.set({ txn.groupSessionDecryptions.set({

View file

@ -25,6 +25,8 @@ import {WrappedError} from "../error.js"
import {fetchOrLoadMembers} from "./members/load.js"; 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 {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}) {
@ -45,29 +47,75 @@ export class Room extends EventEmitter {
this._roomEncryption = null; this._roomEncryption = null;
} }
notifyRoomKeys(roomKeys) { async notifyRoomKeys(roomKeys) {
if (this._roomEncryption) { if (this._roomEncryption) {
const internalIdsToRetry = this._roomEncryption.applyRoomKeys(roomKeys); // array of {data, source}
if (this._timeline) { let retryEntries = this._roomEncryption.applyRoomKeys(roomKeys);
let decryptedEntries = [];
} if (retryEntries.length) {
} // groupSessionDecryptions can be written, the other stores not
} const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
async _decryptSyncEntries(entries, txn) { this._storage.storeNames.inboundGroupSessions,
await Promise.all(entries.map(async e => { this._storage.storeNames.groupSessionDecryptions,
if (e.eventType === "m.room.encrypted") { ]);
try { try {
const decryptedEvent = await this._roomEncryption.decryptNewSyncEvent(e.internalId, e.event, txn); for (const retryEntry of retryEntries) {
if (decryptedEvent) { const {data: eventKey} = retryEntry;
e.replaceWithDecrypted(decryptedEvent); let entry = this._timeline?.findEntry(eventKey);
if (!entry) {
const storageEntry = await txn.timelineEvents.get(this._roomId, eventKey.fragmentId, eventKey.entryIndex);
if (storageEntry) {
entry = new EventEntry(storageEntry, this._fragmentIdComparer);
}
}
if (entry) {
entry = await this._decryptEntry(entry, txn, retryEntry.isSync);
decryptedEntries.push(entry);
}
} }
} catch (err) { } catch (err) {
e.setDecryptionError(err); txn.abort();
throw err;
}
await txn.complete();
}
if (this._timeline) {
// only adds if already present
this._timeline.replaceEntries(decryptedEntries);
}
// pass decryptedEntries to roomSummary
} }
} }
}));
return entries; _enableEncryption(encryptionParams) {
this._roomEncryption = this._createRoomEncryption(this, encryptionParams);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
this._timeline.enableEncryption(this._decryptEntries.bind(this));
}
}
async _decryptEntry(entry, txn, isSync) {
if (entry.eventType === "m.room.encrypted") {
try {
const {fragmentId, entryIndex} = entry;
const key = new EventKey(fragmentId, entryIndex);
const decryptedEvent = await this._roomEncryption.decrypt(
entry.event, isSync, key, txn);
if (decryptedEvent) {
entry.replaceWithDecrypted(decryptedEvent);
}
} catch (err) {
console.warn("event decryption error", err, entry.event);
entry.setDecryptionError(err);
}
}
return entry;
}
async _decryptEntries(entries, txn, isSync = false) {
return await Promise.all(entries.map(async e => this._decryptEntry(e, txn, isSync)));
} }
/** @package */ /** @package */
@ -83,7 +131,7 @@ export class Room extends EventEmitter {
// decrypt if applicable // decrypt if applicable
let entries = encryptedEntries; let entries = encryptedEntries;
if (this._roomEncryption) { if (this._roomEncryption) {
entries = await this._decryptSyncEntries(encryptedEntries, txn); entries = await this._decryptEntries(encryptedEntries, txn, true);
} }
// fetch new members while we have txn open, // fetch new members while we have txn open,
// but don't make any in-memory changes yet // but don't make any in-memory changes yet
@ -116,12 +164,8 @@ export class Room extends EventEmitter {
/** @package */ /** @package */
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) { afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) {
this._syncWriter.afterSync(newLiveKey); this._syncWriter.afterSync(newLiveKey);
// encryption got enabled
if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) { if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) {
this._roomEncryption = this._createRoomEncryption(this, summaryChanges.encryption); this._enableEncryption(summaryChanges.encryption);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
}
} }
if (memberChanges.size) { if (memberChanges.size) {
if (this._changedMembersDuringSync) { if (this._changedMembersDuringSync) {
@ -170,10 +214,7 @@ export class Room extends EventEmitter {
try { try {
this._summary.load(summary); this._summary.load(summary);
if (this._summary.encryption) { if (this._summary.encryption) {
this._roomEncryption = this._createRoomEncryption(this, this._summary.encryption); this._enableEncryption(this._summary.encryption);
if (this._roomEncryption) {
this._sendQueue.enableEncryption(this._roomEncryption);
}
} }
// need to load members for name? // need to load members for name?
if (this._summary.needsHeroes) { if (this._summary.needsHeroes) {
@ -231,11 +272,18 @@ export class Room extends EventEmitter {
} }
}).response(); }).response();
const txn = await this._storage.readWriteTxn([ let stores = [
this._storage.storeNames.pendingEvents, this._storage.storeNames.pendingEvents,
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
];
if (this._roomEncryption) {
stores = stores.concat([
this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
]); ]);
}
const txn = await this._storage.readWriteTxn(stores);
let removedPendingEvents; let removedPendingEvents;
let gapResult; let gapResult;
try { try {
@ -245,9 +293,12 @@ export class Room extends EventEmitter {
const gapWriter = new GapWriter({ const gapWriter = new GapWriter({
roomId: this._roomId, roomId: this._roomId,
storage: this._storage, storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer,
}); });
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn); gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn);
if (this._roomEncryption) {
gapResult.entries = await this._decryptEntries(gapResult.entries, false, txn);
}
} catch (err) { } catch (err) {
txn.abort(); txn.abort();
throw err; throw err;
@ -378,6 +429,9 @@ export class Room extends EventEmitter {
}, },
user: this._user, user: this._user,
}); });
if (this._roomEncryption) {
this._timeline.enableEncryption(this._decryptEntries.bind(this));
}
await this._timeline.load(); await this._timeline.load();
return this._timeline; return this._timeline;
} }

View file

@ -18,6 +18,7 @@ import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"
import {Direction} from "./Direction.js"; import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js"; import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {EventEntry} from "./entries/EventEntry.js";
export class Timeline { export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) {
@ -45,6 +46,27 @@ export class Timeline {
this._remoteEntries.setManySorted(entries); this._remoteEntries.setManySorted(entries);
} }
findEntry(eventKey) {
// a storage event entry has a fragmentId and eventIndex property, used for sorting,
// just like an EventKey, so this will work, but perhaps a bit brittle.
const entry = new EventEntry(eventKey, this._fragmentIdComparer);
try {
const idx = this._remoteEntries.indexOf(entry);
if (idx !== -1) {
return this._remoteEntries.get(idx);
}
} catch (err) {
// fragmentIdComparer threw, ignore
return;
}
}
replaceEntries(entries) {
for (const entry of entries) {
this._remoteEntries.replace(entry);
}
}
// TODO: should we rather have generic methods for // TODO: should we rather have generic methods for
// - adding new entries // - adding new entries
// - updating existing entries (redaction, relations) // - updating existing entries (redaction, relations)
@ -84,4 +106,8 @@ export class Timeline {
this._closeCallback = null; this._closeCallback = null;
} }
} }
enableEncryption(decryptEntries) {
this._timelineReader.enableEncryption(decryptEntries);
}
} }

View file

@ -41,6 +41,22 @@ export class SortedArray extends BaseObservableList {
} }
} }
replace(item) {
const idx = this.indexOf(item);
if (idx !== -1) {
this._items[idx] = item;
}
}
indexOf(item) {
const idx = sortedIndex(this._items, item, this._comparator);
if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) {
return idx;
} else {
return -1;
}
}
set(item, updateParams = null) { set(item, updateParams = null) {
const idx = sortedIndex(this._items, item, this._comparator); const idx = sortedIndex(this._items, item, this._comparator);
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {