2020-08-31 14:11:08 +02:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
|
|
|
|
2020-09-02 17:37:48 +02:00
|
|
|
import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
|
2020-08-31 14:11:08 +02:00
|
|
|
|
|
|
|
const TRACKING_STATUS_OUTDATED = 0;
|
|
|
|
const TRACKING_STATUS_UPTODATE = 1;
|
|
|
|
|
2021-08-27 19:39:24 +02:00
|
|
|
export function addRoomToIdentity(identity, userId, roomId) {
|
|
|
|
if (!identity) {
|
|
|
|
identity = {
|
|
|
|
userId: userId,
|
|
|
|
roomIds: [roomId],
|
|
|
|
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
|
|
|
|
};
|
|
|
|
return identity;
|
|
|
|
} else {
|
|
|
|
if (!identity.roomIds.includes(roomId)) {
|
|
|
|
identity.roomIds.push(roomId);
|
|
|
|
return identity;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-31 14:11:08 +02:00
|
|
|
// map 1 device from /keys/query response to DeviceIdentity
|
|
|
|
function deviceKeysAsDeviceIdentity(deviceSection) {
|
|
|
|
const deviceId = deviceSection["device_id"];
|
2020-08-31 16:05:04 +02:00
|
|
|
const userId = deviceSection["user_id"];
|
2020-08-31 14:11:08 +02:00
|
|
|
return {
|
|
|
|
userId,
|
|
|
|
deviceId,
|
2020-09-08 10:51:01 +02:00
|
|
|
ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
|
|
|
|
curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
|
2020-08-31 14:11:08 +02:00
|
|
|
algorithms: deviceSection.algorithms,
|
|
|
|
displayName: deviceSection.unsigned?.device_display_name,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export class DeviceTracker {
|
2020-09-03 15:28:03 +02:00
|
|
|
constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) {
|
2020-08-31 14:11:08 +02:00
|
|
|
this._storage = storage;
|
|
|
|
this._getSyncToken = getSyncToken;
|
|
|
|
this._identityChangedForRoom = null;
|
2020-09-01 17:57:59 +02:00
|
|
|
this._olmUtil = olmUtil;
|
2020-09-03 15:28:03 +02:00
|
|
|
this._ownUserId = ownUserId;
|
|
|
|
this._ownDeviceId = ownDeviceId;
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
|
2021-02-18 19:56:10 +01:00
|
|
|
async writeDeviceChanges(changed, txn, log) {
|
2020-08-31 14:11:08 +02:00
|
|
|
const {userIdentities} = txn;
|
2021-02-11 21:08:06 +01:00
|
|
|
// TODO: should we also look at left here to handle this?:
|
|
|
|
// the usual problem here is that you share a room with a user,
|
|
|
|
// go offline, the remote user leaves the room, changes their devices,
|
|
|
|
// then rejoins the room you share (or another room).
|
|
|
|
// At which point you come online, all of this happens in the gap,
|
|
|
|
// and you don't notice that they ever left,
|
|
|
|
// and so the client doesn't invalidate their device cache for the user
|
2021-02-18 19:56:10 +01:00
|
|
|
log.set("changed", changed.length);
|
|
|
|
await Promise.all(changed.map(async userId => {
|
|
|
|
const user = await userIdentities.get(userId);
|
|
|
|
if (user) {
|
|
|
|
log.log({l: "outdated", id: userId});
|
|
|
|
user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED;
|
|
|
|
userIdentities.set(user);
|
|
|
|
}
|
|
|
|
}));
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
writeMemberChanges(room, memberChanges, txn) {
|
|
|
|
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
|
|
|
return this._applyMemberChange(memberChange, txn);
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2021-02-23 19:22:59 +01:00
|
|
|
async trackRoom(room, log) {
|
2020-09-08 14:24:48 +02:00
|
|
|
if (room.isTrackingMembers || !room.isEncrypted) {
|
2020-08-31 14:11:08 +02:00
|
|
|
return;
|
|
|
|
}
|
2021-02-23 19:22:59 +01:00
|
|
|
const memberList = await room.loadMemberList(log);
|
2020-08-31 14:11:08 +02:00
|
|
|
try {
|
2021-03-04 19:47:02 +01:00
|
|
|
const txn = await this._storage.readWriteTxn([
|
2020-08-31 14:11:08 +02:00
|
|
|
this._storage.storeNames.roomSummary,
|
|
|
|
this._storage.storeNames.userIdentities,
|
|
|
|
]);
|
|
|
|
let isTrackingChanges;
|
|
|
|
try {
|
|
|
|
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
|
|
|
|
const members = Array.from(memberList.members.values());
|
2021-02-23 19:22:59 +01:00
|
|
|
log.set("members", members.length);
|
2020-08-31 14:11:08 +02:00
|
|
|
await this._writeJoinedMembers(members, txn);
|
|
|
|
} catch (err) {
|
|
|
|
txn.abort();
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
await txn.complete();
|
|
|
|
room.applyIsTrackingMembersChanges(isTrackingChanges);
|
|
|
|
} finally {
|
|
|
|
memberList.release();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async _writeJoinedMembers(members, txn) {
|
|
|
|
await Promise.all(members.map(async member => {
|
|
|
|
if (member.membership === "join") {
|
|
|
|
await this._writeMember(member, txn);
|
|
|
|
}
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
async _writeMember(member, txn) {
|
|
|
|
const {userIdentities} = txn;
|
|
|
|
const identity = await userIdentities.get(member.userId);
|
2021-08-30 15:05:57 +02:00
|
|
|
const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
|
|
|
|
if (updatedIdentity) {
|
|
|
|
userIdentities.set(updatedIdentity);
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-06 15:23:58 +02:00
|
|
|
async _removeRoomFromUserIdentity(roomId, userId, txn) {
|
|
|
|
const {userIdentities, deviceIdentities} = txn;
|
|
|
|
const identity = await userIdentities.get(userId);
|
|
|
|
if (identity) {
|
|
|
|
identity.roomIds = identity.roomIds.filter(id => id !== roomId);
|
|
|
|
// no more encrypted rooms with this user, remove
|
|
|
|
if (identity.roomIds.length === 0) {
|
|
|
|
userIdentities.remove(userId);
|
|
|
|
deviceIdentities.removeAllForUser(userId);
|
|
|
|
} else {
|
|
|
|
userIdentities.set(identity);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-31 14:11:08 +02:00
|
|
|
async _applyMemberChange(memberChange, txn) {
|
|
|
|
// TODO: depends whether we encrypt for invited users??
|
|
|
|
// add room
|
2021-05-06 15:23:58 +02:00
|
|
|
if (memberChange.hasJoined) {
|
2020-08-31 14:11:08 +02:00
|
|
|
await this._writeMember(memberChange.member, txn);
|
|
|
|
}
|
|
|
|
// remove room
|
2021-05-06 15:23:58 +02:00
|
|
|
else if (memberChange.hasLeft) {
|
|
|
|
const {roomId} = memberChange;
|
|
|
|
// if we left the room, remove room from all user identities in the room
|
|
|
|
if (memberChange.userId === this._ownUserId) {
|
|
|
|
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
|
|
|
await Promise.all(userIds.map(userId => {
|
|
|
|
return this._removeRoomFromUserIdentity(roomId, userId, txn);
|
|
|
|
}));
|
|
|
|
} else {
|
|
|
|
await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn);
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-23 19:22:59 +01:00
|
|
|
async _queryKeys(userIds, hsApi, log) {
|
2020-08-31 14:11:08 +02:00
|
|
|
// TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ...
|
|
|
|
// there are multiple requests going out for /keys/query though and only one for /members
|
|
|
|
|
2020-08-31 14:24:09 +02:00
|
|
|
const deviceKeyResponse = await hsApi.queryKeys({
|
2020-08-31 14:11:08 +02:00
|
|
|
"timeout": 10000,
|
|
|
|
"device_keys": userIds.reduce((deviceKeysMap, userId) => {
|
|
|
|
deviceKeysMap[userId] = [];
|
|
|
|
return deviceKeysMap;
|
|
|
|
}, {}),
|
|
|
|
"token": this._getSyncToken()
|
2021-02-23 19:22:59 +01:00
|
|
|
}, {log}).response();
|
2020-08-31 14:11:08 +02:00
|
|
|
|
2021-02-23 19:22:59 +01:00
|
|
|
const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
|
2021-03-04 19:47:02 +01:00
|
|
|
const txn = await this._storage.readWriteTxn([
|
2020-08-31 14:11:08 +02:00
|
|
|
this._storage.storeNames.userIdentities,
|
|
|
|
this._storage.storeNames.deviceIdentities,
|
|
|
|
]);
|
|
|
|
let deviceIdentities;
|
|
|
|
try {
|
2020-09-14 15:44:47 +02:00
|
|
|
const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => {
|
|
|
|
const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity);
|
|
|
|
return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn);
|
2020-08-31 14:11:08 +02:00
|
|
|
}));
|
2020-09-14 15:44:47 +02:00
|
|
|
deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []);
|
2021-02-23 19:22:59 +01:00
|
|
|
log.set("devices", deviceIdentities.length);
|
2020-08-31 14:11:08 +02:00
|
|
|
} catch (err) {
|
|
|
|
txn.abort();
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
await txn.complete();
|
|
|
|
return deviceIdentities;
|
|
|
|
}
|
|
|
|
|
2020-09-14 15:44:47 +02:00
|
|
|
async _storeQueriedDevicesForUserId(userId, deviceIdentities, txn) {
|
2020-09-14 18:31:54 +02:00
|
|
|
const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId);
|
2020-09-14 15:44:47 +02:00
|
|
|
// delete any devices that we know off but are not in the response anymore.
|
|
|
|
// important this happens before checking if the ed25519 key changed,
|
|
|
|
// otherwise we would end up deleting existing devices with changed keys.
|
|
|
|
for (const deviceId of knownDeviceIds) {
|
|
|
|
if (deviceIdentities.every(di => di.deviceId !== deviceId)) {
|
|
|
|
txn.deviceIdentities.remove(userId, deviceId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// all the device identities as we will have them in storage
|
|
|
|
const allDeviceIdentities = [];
|
|
|
|
const deviceIdentitiesToStore = [];
|
|
|
|
// filter out devices that have changed their ed25519 key since last time we queried them
|
|
|
|
deviceIdentities = await Promise.all(deviceIdentities.map(async deviceIdentity => {
|
|
|
|
if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
|
|
|
|
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
|
|
|
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
|
|
|
|
allDeviceIdentities.push(existingDevice);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
allDeviceIdentities.push(deviceIdentity);
|
|
|
|
deviceIdentitiesToStore.push(deviceIdentity);
|
|
|
|
}));
|
|
|
|
// store devices
|
|
|
|
for (const deviceIdentity of deviceIdentitiesToStore) {
|
|
|
|
txn.deviceIdentities.set(deviceIdentity);
|
|
|
|
}
|
|
|
|
// mark user identities as up to date
|
|
|
|
const identity = await txn.userIdentities.get(userId);
|
|
|
|
identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
|
|
|
|
txn.userIdentities.set(identity);
|
|
|
|
|
|
|
|
return allDeviceIdentities;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return {Array<{userId, verifiedKeys: Array<DeviceSection>>}
|
|
|
|
*/
|
2021-02-23 19:22:59 +01:00
|
|
|
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse, parentLog) {
|
2020-09-08 17:14:23 +02:00
|
|
|
const curve25519Keys = new Set();
|
2020-08-31 16:05:57 +02:00
|
|
|
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
|
|
|
|
const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => {
|
2020-08-31 14:11:08 +02:00
|
|
|
const deviceIdOnKeys = deviceKeys["device_id"];
|
|
|
|
const userIdOnKeys = deviceKeys["user_id"];
|
|
|
|
if (userIdOnKeys !== userId) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (deviceIdOnKeys !== deviceId) {
|
|
|
|
return false;
|
|
|
|
}
|
2020-09-08 10:51:01 +02:00
|
|
|
const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`];
|
|
|
|
const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`];
|
|
|
|
if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") {
|
|
|
|
return false;
|
|
|
|
}
|
2020-09-08 17:14:23 +02:00
|
|
|
if (curve25519Keys.has(curve25519Key)) {
|
2021-02-23 19:22:59 +01:00
|
|
|
parentLog.log({
|
|
|
|
l: "ignore device with duplicate curve25519 key",
|
|
|
|
keys: deviceKeys
|
|
|
|
}, parentLog.level.Warn);
|
2020-09-08 17:14:23 +02:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
curve25519Keys.add(curve25519Key);
|
2021-02-23 19:22:59 +01:00
|
|
|
const isValid = this._hasValidSignature(deviceKeys);
|
|
|
|
if (!isValid) {
|
|
|
|
parentLog.log({
|
|
|
|
l: "ignore device with invalid signature",
|
|
|
|
keys: deviceKeys
|
|
|
|
}, parentLog.level.Warn);
|
|
|
|
}
|
|
|
|
return isValid;
|
2020-08-31 14:11:08 +02:00
|
|
|
});
|
2020-08-31 16:05:57 +02:00
|
|
|
const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys);
|
2020-08-31 14:11:08 +02:00
|
|
|
return {userId, verifiedKeys};
|
|
|
|
});
|
|
|
|
return verifiedKeys;
|
|
|
|
}
|
|
|
|
|
2020-09-02 17:37:48 +02:00
|
|
|
_hasValidSignature(deviceSection) {
|
2020-08-31 14:11:08 +02:00
|
|
|
const deviceId = deviceSection["device_id"];
|
|
|
|
const userId = deviceSection["user_id"];
|
2020-09-02 17:37:48 +02:00
|
|
|
const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`];
|
|
|
|
return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection);
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gives all the device identities for a room that is already tracked.
|
|
|
|
* Assumes room is already tracked. Call `trackRoom` first if unsure.
|
|
|
|
* @param {String} roomId [description]
|
|
|
|
* @return {[type]} [description]
|
|
|
|
*/
|
2021-02-23 19:22:59 +01:00
|
|
|
async devicesForTrackedRoom(roomId, hsApi, log) {
|
2021-03-04 19:47:02 +01:00
|
|
|
const txn = await this._storage.readTxn([
|
2020-08-31 14:11:08 +02:00
|
|
|
this._storage.storeNames.roomMembers,
|
|
|
|
this._storage.storeNames.userIdentities,
|
|
|
|
]);
|
|
|
|
|
|
|
|
// because we don't have multiEntry support in IE11, we get a set of userIds that is pretty close to what we
|
|
|
|
// need as a good first filter (given that non-join memberships will be in there). After fetching the identities,
|
|
|
|
// we check which ones have the roomId for the room we're looking at.
|
|
|
|
|
|
|
|
// So, this will also contain non-joined memberships
|
2020-08-31 16:06:31 +02:00
|
|
|
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
2020-09-08 14:24:48 +02:00
|
|
|
|
2021-02-23 19:22:59 +01:00
|
|
|
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
2020-09-08 14:24:48 +02:00
|
|
|
}
|
|
|
|
|
2021-02-23 19:22:59 +01:00
|
|
|
async devicesForRoomMembers(roomId, userIds, hsApi, log) {
|
2021-03-04 19:47:02 +01:00
|
|
|
const txn = await this._storage.readTxn([
|
2020-09-08 14:24:48 +02:00
|
|
|
this._storage.storeNames.userIdentities,
|
|
|
|
]);
|
2021-02-23 19:22:59 +01:00
|
|
|
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
2020-09-08 14:24:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} roomId [description]
|
|
|
|
* @param {Array<string>} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId.
|
|
|
|
* @param {Transaction} userIdentityTxn to read the user identities
|
|
|
|
* @param {HomeServerApi} hsApi
|
|
|
|
* @return {Array<DeviceIdentity>}
|
|
|
|
*/
|
2021-02-23 19:22:59 +01:00
|
|
|
async _devicesForUserIds(roomId, userIds, userIdentityTxn, hsApi, log) {
|
2020-09-08 14:24:48 +02:00
|
|
|
const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId)));
|
|
|
|
const identities = allMemberIdentities.filter(identity => {
|
2020-08-31 14:11:08 +02:00
|
|
|
// identity will be missing for any userIds that don't have
|
|
|
|
// membership join in any of your encrypted rooms
|
|
|
|
return identity && identity.roomIds.includes(roomId);
|
|
|
|
});
|
|
|
|
const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE);
|
|
|
|
const outdatedIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED);
|
2021-02-23 19:22:59 +01:00
|
|
|
log.set("uptodate", upToDateIdentities.length);
|
|
|
|
log.set("outdated", outdatedIdentities.length);
|
2020-08-31 14:11:08 +02:00
|
|
|
let queriedDevices;
|
|
|
|
if (outdatedIdentities.length) {
|
|
|
|
// TODO: ignore the race between /sync and /keys/query for now,
|
|
|
|
// where users could get marked as outdated or added/removed from the room while
|
|
|
|
// querying keys
|
2021-02-23 19:22:59 +01:00
|
|
|
queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi, log);
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
|
|
|
|
2021-03-04 19:47:02 +01:00
|
|
|
const deviceTxn = await this._storage.readTxn([
|
2020-08-31 14:11:08 +02:00
|
|
|
this._storage.storeNames.deviceIdentities,
|
|
|
|
]);
|
|
|
|
const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {
|
|
|
|
return deviceTxn.deviceIdentities.getAllForUserId(identity.userId);
|
|
|
|
}));
|
|
|
|
let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []);
|
|
|
|
if (queriedDevices && queriedDevices.length) {
|
|
|
|
flattenedDevices = flattenedDevices.concat(queriedDevices);
|
|
|
|
}
|
2020-09-08 15:24:36 +02:00
|
|
|
// filter out our own device
|
2020-09-03 15:28:03 +02:00
|
|
|
const devices = flattenedDevices.filter(device => {
|
2020-09-08 18:27:35 +02:00
|
|
|
const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId;
|
|
|
|
return !isOwnDevice;
|
2020-09-03 15:28:03 +02:00
|
|
|
});
|
|
|
|
return devices;
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|
2020-09-08 10:50:39 +02:00
|
|
|
|
|
|
|
async getDeviceByCurve25519Key(curve25519Key, txn) {
|
|
|
|
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
|
|
|
|
}
|
2020-08-31 14:11:08 +02:00
|
|
|
}
|