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;
}
get isUnverified() {
return this._entry.isUnverified;
}
_getContent() {
return this._entry.content;
}

View file

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

View file

@ -89,7 +89,7 @@ export class SessionContainer {
let sessionInfo;
try {
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();
sessionInfo = {
id: sessionId,

View file

@ -135,6 +135,7 @@ export class Sync {
storeNames.userIdentities,
storeNames.inboundGroupSessions,
storeNames.groupSessionDecryptions,
storeNames.deviceIdentities,
]);
const roomChanges = [];
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 {
userId,
deviceId,
ed25519Key: deviceSection.keys?.[`ed25519:${deviceId}`],
curve25519Key: deviceSection.keys?.[`curve25519:${deviceId}`],
ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
algorithms: deviceSection.algorithms,
displayName: deviceSection.unsigned?.device_display_name,
};
@ -200,6 +200,11 @@ export class DeviceTracker {
if (deviceIdOnKeys !== deviceId) {
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
if (userId === this._ownUserId && deviceId === this._ownDeviceId) {
return false;
@ -270,4 +275,8 @@ export class DeviceTracker {
});
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();
// not `event_id`, but an internal event id passed in to the decrypt methods
this._eventIdsByMissingSession = new Map();
this._senderDeviceCache = new Map();
}
notifyTimelineClosed() {
// empty the backfill cache when closing the timeline
this._megolmBackfillCache.dispose();
this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
this._senderDeviceCache = new Map(); // purge the sender device cache
}
async writeMemberChanges(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) {
throw new Error("Unsupported algorithm: " + event.content?.algorithm);
}
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);
if (!payload) {
if (!result) {
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) {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {DecryptionError} from "../common.js";
import {DecryptionResult} from "../DecryptionResult.js";
const CACHE_MAX_SIZE = 10;
@ -28,6 +29,14 @@ export class Decryption {
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) {
const senderKey = event.content?.["sender_key"];
const sessionId = event.content?.["session_id"];
@ -41,8 +50,13 @@ export class Decryption {
throw new DecryptionError("MEGOLM_INVALID_EVENT", event);
}
let session = sessionCache.get(roomId, senderKey, sessionId);
if (!session) {
let 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);
if (sessionEntry) {
session = new this._olm.InboundGroupSession();
@ -52,7 +66,8 @@ export class Decryption {
session.free();
throw err;
}
sessionCache.add(roomId, senderKey, session);
claimedKeys = sessionEntry.claimedKeys;
sessionCache.add(roomId, senderKey, session, claimedKeys);
}
}
if (!session) {
@ -70,8 +85,7 @@ export class Decryption {
{encryptedRoomId: payload.room_id, eventRoomId: roomId});
}
await this._handleReplayAttack(roomId, sessionId, messageIndex, event, txn);
// TODO: verify event came from said senderKey
return payload;
return new DecryptionResult(payload, senderKey, claimedKeys);
}
async _handleReplayAttack(roomId, sessionId, messageIndex, event, txn) {
@ -82,7 +96,11 @@ export class Decryption {
// the one with the newest timestamp should be the attack
const decryptedEventIsBad = decryption.timestamp < timestamp;
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) {
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 = [];
for (const {senderKey, event} of payloads) {
for (const {senderCurve25519Key: senderKey, event, claimedEd25519Key} of decryptionResults) {
const roomId = event.content?.["room_id"];
const sessionId = event.content?.["session_id"];
const sessionKey = event.content?.["session_key"];
@ -122,7 +150,7 @@ export class Decryption {
senderKey,
sessionId,
session: session.pickle(this._pickleKey),
claimedKeys: event.keys,
claimedKeys: {ed25519: claimedEd25519Key},
};
txn.inboundGroupSessions.set(sessionEntry);
newSessions.push(sessionEntry);
@ -142,6 +170,17 @@ class SessionCache {
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) {
const idx = this._sessions.findIndex(s => {
return s.roomId === roomId &&
@ -155,13 +194,13 @@ class SessionCache {
this._sessions.splice(idx, 1);
this._sessions.unshift(entry);
}
return entry.session;
return entry;
}
}
add(roomId, senderKey, session) {
add(roomId, senderKey, session, claimedKeys) {
// add new at top
this._sessions.unshift({roomId, senderKey, session});
this._sessions.unshift({roomId, senderKey, session, claimedKeys});
if (this._sessions.length > CACHE_MAX_SIZE) {
// free sessions we're about to remove
for (let i = CACHE_MAX_SIZE; i < this._sessions.length; i += 1) {

View file

@ -26,6 +26,14 @@ export class Encryption {
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) {
let session = new this._olm.OutboundGroupSession();
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 {
constructor(content, roomKeyMessage) {
this.content = content;

View file

@ -17,6 +17,7 @@ limitations under the License.
import {DecryptionError} from "../common.js";
import {groupBy} from "../../../utils/groupBy.js";
import {Session} from "./Session.js";
import {DecryptionResult} from "../DecryptionResult.js";
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
//
// 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) {
const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]);
const timestamp = this._now();
@ -61,15 +69,16 @@ export class Decryption {
try {
const readSessionsTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
// 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);
}));
const payloads = results.reduce((all, r) => all.concat(r.payloads), []);
const errors = results.reduce((all, r) => all.concat(r.errors), []);
const senderKeyDecryptions = results.map(r => r.senderKeyDecryption);
return new DecryptionChanges(senderKeyDecryptions, payloads, errors, this._account, locks);
const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), []);
const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []);
const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption);
return new DecryptionChanges(senderKeyDecryptions, results, errors, this._account, locks);
} catch (err) {
// make sure the locks are release if something throws
// otherwise they will be released in DecryptionChanges after having written
for (const lock of locks) {
lock.release();
}
@ -80,18 +89,18 @@ export class Decryption {
async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) {
const sessions = await this._getSessions(senderKey, readSessionsTxn);
const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp);
const payloads = [];
const results = [];
const errors = [];
// events for a single senderKey need to be decrypted one by one
for (const event of events) {
try {
const payload = this._decryptForSenderKey(senderKeyDecryption, event, timestamp);
payloads.push(payload);
const result = this._decryptForSenderKey(senderKeyDecryption, event, timestamp);
results.push(result);
} catch (err) {
errors.push(err);
}
}
return {payloads, errors, senderKeyDecryption};
return {results, errors, senderKeyDecryption};
}
_decryptForSenderKey(senderKeyDecryption, event, timestamp) {
@ -118,7 +127,7 @@ export class Decryption {
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
}
this._validatePayload(payload, event);
return {event: payload, senderKey};
return new DecryptionResult(payload, senderKey, payload.keys);
} else {
throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
{knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)});
@ -182,9 +191,9 @@ export class Decryption {
if (!payload.content) {
throw new DecryptionError("missing content on payload", event, {payload});
}
// TODO: how important is it to verify the message?
// we should look at payload.keys.ed25519 for that... and compare it to the key we have fetched
// from /keys/query, which we might not have done yet at this point.
if (typeof payload.keys?.ed25519 !== "string") {
throw new DecryptionError("Missing or invalid claimed ed25519 key on payload", event, {payload});
}
}
}
@ -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 {
constructor(senderKeyDecryptions, payloads, errors, account, locks) {
constructor(senderKeyDecryptions, results, errors, account, locks) {
this._senderKeyDecryptions = senderKeyDecryptions;
this._account = account;
this.payloads = payloads;
this.results = results;
this.errors = errors;
this._locks = locks;
}

View file

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

View file

@ -26,7 +26,6 @@ import {fetchOrLoadMembers} from "./members/load.js";
import {MemberList} from "./members/MemberList.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 {
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.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
this._storage.storeNames.deviceIdentities,
]);
try {
for (const retryEntry of retryEntries) {
const {data: eventKey} = retryEntry;
let entry = this._timeline?.findEntry(eventKey);
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) {
entry = new EventEntry(storageEntry, this._fragmentIdComparer);
}
@ -101,12 +101,10 @@ export class Room extends EventEmitter {
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);
const decryptionResult = await this._roomEncryption.decrypt(
entry.event, isSync, !!this._timeline, entry.asEventKey(), txn);
if (decryptionResult) {
entry.setDecryptionResult(decryptionResult);
}
} catch (err) {
console.warn("event decryption error", err, entry.event);
@ -283,6 +281,7 @@ export class Room extends EventEmitter {
stores = stores.concat([
this._storage.storeNames.inboundGroupSessions,
this._storage.storeNames.groupSessionDecryptions,
this._storage.storeNames.deviceIdentities,
]);
}
const txn = await this._storage.readWriteTxn(stores);

View file

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

View file

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

View file

@ -14,6 +14,7 @@ export const schema = [
createInboundGroupSessionsStore,
createOutboundGroupSessionsStore,
createGroupSessionDecryptions,
addSenderKeyIndexToDeviceStore
];
// TODO: how to deal with git merge conflicts of this array?
@ -94,3 +95,9 @@ function createOutboundGroupSessionsStore(db) {
function createGroupSessionDecryptions(db) {
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);
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;
}
.TextMessageView.unverified .message-container {
color: #ff4b55;
}
.message-container p {
margin: 3px 0;
line-height: 2.2rem;

View file

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