Store device keys in format needed to sign/verify, convert to TS

In order to sign and verify signatures of design keys, we need
to have them in the format as they are uploaded and downloaded
from the homeserver. So, like the cross-signing keys, we store
them in locally in the same format to avoid constant convertions.

I also renamed deviceIdentities to deviceKeys, analogue to
crossSigningKeys. In order to prevent mistakes in this refactor,
I also converted DeviceTracker to typescript.
This commit is contained in:
Bruno Windels 2023-02-27 18:13:53 +01:00
parent 151090527b
commit b8fb2b6df1
17 changed files with 360 additions and 293 deletions

View File

@ -218,7 +218,7 @@ export class Sync {
_openPrepareSyncTxn() {
const storeNames = this._storage.storeNames;
return this._storage.readTxn([
storeNames.deviceIdentities, // to read device from olm messages
storeNames.deviceKeys, // to read device from olm messages
storeNames.olmSessions,
storeNames.inboundGroupSessions,
// to read fragments when loading sync writer when rejoining archived room
@ -329,7 +329,7 @@ export class Sync {
storeNames.pendingEvents,
storeNames.userIdentities,
storeNames.groupSessionDecryptions,
storeNames.deviceIdentities,
storeNames.deviceKeys,
// to discard outbound session when somebody leaves a room
// and to create room key messages when somebody joins
storeNames.outboundGroupSessions,

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import anotherjson from "another-json";
import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common";
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount";

View File

@ -26,7 +26,8 @@ limitations under the License.
* see DeviceTracker
*/
import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore";
import {getDeviceEd25519Key} from "./common";
import type {DeviceKey} from "./common";
import type {TimelineEvent} from "../storage/types";
type DecryptedEvent = {
@ -35,7 +36,7 @@ type DecryptedEvent = {
}
export class DecryptionResult {
private device?: DeviceIdentity;
private device?: DeviceKey;
constructor(
public readonly event: DecryptedEvent,
@ -44,13 +45,13 @@ export class DecryptionResult {
public readonly encryptedEvent?: TimelineEvent
) {}
setDevice(device: DeviceIdentity): void {
setDevice(device: DeviceKey): void {
this.device = device;
}
get isVerified(): boolean {
if (this.device) {
const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key;
const comesFromDevice = getDeviceEd25519Key(this.device) === this.claimedEd25519Key;
return comesFromDevice;
}
return false;
@ -65,11 +66,11 @@ export class DecryptionResult {
}
get userId(): string | undefined {
return this.device?.userId;
return this.device?.user_id;
}
get deviceId(): string | undefined {
return this.device?.deviceId;
return this.device?.device_id;
}
get isVerificationUnknown(): boolean {

View File

@ -15,23 +15,38 @@ limitations under the License.
*/
import {verifyEd25519Signature, getEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
import {HistoryVisibility, shouldShareKey} from "./common.js";
import {HistoryVisibility, shouldShareKey, DeviceKey, getDeviceEd25519Key, getDeviceCurve25519Key} from "./common";
import {RoomMember} from "../room/members/RoomMember.js";
import {getKeyUsage, getKeyEd25519Key, getKeyUserId, KeyUsage} from "../verification/CrossSigning";
import {MemberChange} from "../room/members/RoomMember";
import type {CrossSigningKey} from "../storage/idb/stores/CrossSigningKeyStore";
import type {HomeServerApi} from "../net/HomeServerApi";
import type {ObservableMap} from "../../observable/map";
import type {Room} from "../room/Room";
import type {ILogItem} from "../../logging/types";
import type {Storage} from "../storage/idb/Storage";
import type {Transaction} from "../storage/idb/Transaction";
import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace;
const TRACKING_STATUS_OUTDATED = 0;
const TRACKING_STATUS_UPTODATE = 1;
function createUserIdentity(userId, initialRoomId = undefined) {
export type UserIdentity = {
userId: string,
roomIds: string[],
deviceTrackingStatus: number,
}
function createUserIdentity(userId: string, initialRoomId?: string): UserIdentity {
return {
userId: userId,
roomIds: initialRoomId ? [initialRoomId] : [],
crossSigningKeys: undefined,
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
};
}
function addRoomToIdentity(identity, userId, roomId) {
function addRoomToIdentity(identity: UserIdentity | undefined, userId: string, roomId: string): UserIdentity | undefined {
if (!identity) {
identity = createUserIdentity(userId, roomId);
return identity;
@ -43,31 +58,22 @@ function addRoomToIdentity(identity, userId, roomId) {
}
}
// map 1 device from /keys/query response to DeviceIdentity
function deviceKeysAsDeviceIdentity(deviceSection) {
const deviceId = deviceSection["device_id"];
const userId = deviceSection["user_id"];
return {
userId,
deviceId,
ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
algorithms: deviceSection.algorithms,
displayName: deviceSection.unsigned?.device_display_name,
};
}
export class DeviceTracker {
constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) {
this._storage = storage;
this._getSyncToken = getSyncToken;
this._identityChangedForRoom = null;
this._olmUtil = olmUtil;
this._ownUserId = ownUserId;
this._ownDeviceId = ownDeviceId;
private readonly _storage: Storage;
private readonly _getSyncToken: () => string;
private readonly _olmUtil: Olm.Utility;
private readonly _ownUserId: string;
private readonly _ownDeviceId: string;
constructor(options: {storage: Storage, getSyncToken: () => string, olmUtil: Olm.Utility, ownUserId: string, ownDeviceId: string}) {
this._storage = options.storage;
this._getSyncToken = options.getSyncToken;
this._olmUtil = options.olmUtil;
this._ownUserId = options.ownUserId;
this._ownDeviceId = options.ownDeviceId;
}
async writeDeviceChanges(changed, txn, log) {
async writeDeviceChanges(changedUserIds: ReadonlyArray<string>, txn: Transaction, log: ILogItem): Promise<void> {
const {userIdentities} = txn;
// TODO: should we also look at left here to handle this?:
// the usual problem here is that you share a room with a user,
@ -76,8 +82,8 @@ export class DeviceTracker {
// 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
log.set("changed", changed.length);
await Promise.all(changed.map(async userId => {
log.set("changed", changedUserIds.length);
await Promise.all(changedUserIds.map(async userId => {
const user = await userIdentities.get(userId);
if (user) {
log.log({l: "outdated", id: userId});
@ -90,9 +96,9 @@ export class DeviceTracker {
/** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity,
* and with who a key should be now be shared
**/
async writeMemberChanges(room, memberChanges, historyVisibility, txn) {
const added = [];
const removed = [];
async writeMemberChanges(room: Room, memberChanges: Map<string, MemberChange>, historyVisibility: HistoryVisibility, txn: Transaction): Promise<{added: string[], removed: string[]}> {
const added: string[] = [];
const removed: string[] = [];
await Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
// keys should now be shared with this member?
// add the room to the userIdentity if so
@ -118,7 +124,7 @@ export class DeviceTracker {
return {added, removed};
}
async trackRoom(room, historyVisibility, log) {
async trackRoom(room: Room, historyVisibility: HistoryVisibility, log: ILogItem): Promise<void> {
if (room.isTrackingMembers || !room.isEncrypted) {
return;
}
@ -126,13 +132,13 @@ export class DeviceTracker {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.roomSummary,
this._storage.storeNames.userIdentities,
this._storage.storeNames.deviceIdentities, // to remove all devices in _removeRoomFromUserIdentity
this._storage.storeNames.deviceKeys, // to remove all devices in _removeRoomFromUserIdentity
]);
try {
let isTrackingChanges;
try {
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
const members = Array.from(memberList.members.values());
const members = Array.from((memberList.members as ObservableMap<string, RoomMember>).values());
log.set("members", members.length);
// TODO: should we remove any userIdentities we should not share the key with??
// e.g. as an extra security measure if we had a mistake in other code?
@ -154,14 +160,15 @@ export class DeviceTracker {
}
}
async getCrossSigningKeyForUser(userId, usage, hsApi, log) {
return await log.wrap("DeviceTracker.getMasterKeyForUser", async log => {
async getCrossSigningKeyForUser(userId: string, usage: KeyUsage, hsApi: HomeServerApi, log: ILogItem) {
return await log.wrap({l: "DeviceTracker.getCrossSigningKeyForUser", id: userId, usage}, async log => {
let txn = await this._storage.readTxn([
this._storage.storeNames.userIdentities
this._storage.storeNames.userIdentities,
this._storage.storeNames.crossSigningKeys,
]);
let userIdentity = await txn.userIdentities.get(userId);
if (userIdentity && userIdentity.deviceTrackingStatus !== TRACKING_STATUS_OUTDATED) {
return userIdentity.crossSigningKeys;
return await txn.crossSigningKeys.get(userId, usage);
}
// fetch from hs
const keys = await this._queryKeys([userId], hsApi, log);
@ -172,19 +179,19 @@ export class DeviceTracker {
return keys.selfSigningKeys.get(userId);
case KeyUsage.UserSigning:
return keys.userSigningKeys.get(userId);
}
});
}
async writeHistoryVisibility(room, historyVisibility, syncTxn, log) {
const added = [];
const removed = [];
async writeHistoryVisibility(room: Room, historyVisibility: HistoryVisibility, syncTxn: Transaction, log: ILogItem): Promise<{added: string[], removed: string[]}> {
const added: string[] = [];
const removed: string[] = [];
if (room.isTrackingMembers && room.isEncrypted) {
await log.wrap("rewriting userIdentities", async log => {
// TODO: how do we know that we won't fetch the members from the server here and hence close the syncTxn?
const memberList = await room.loadMemberList(syncTxn, log);
try {
const members = Array.from(memberList.members.values());
const members = Array.from((memberList.members as ObservableMap<string, RoomMember>).values());
log.set("members", members.length);
await Promise.all(members.map(async member => {
if (shouldShareKey(member.membership, historyVisibility)) {
@ -205,7 +212,7 @@ export class DeviceTracker {
return {added, removed};
}
async _addRoomToUserIdentity(roomId, userId, txn) {
async _addRoomToUserIdentity(roomId: string, userId: string, txn: Transaction): Promise<boolean> {
const {userIdentities} = txn;
const identity = await userIdentities.get(userId);
const updatedIdentity = addRoomToIdentity(identity, userId, roomId);
@ -216,15 +223,15 @@ export class DeviceTracker {
return false;
}
async _removeRoomFromUserIdentity(roomId, userId, txn) {
const {userIdentities, deviceIdentities} = txn;
async _removeRoomFromUserIdentity(roomId: string, userId: string, txn: Transaction): Promise<boolean> {
const {userIdentities, deviceKeys} = 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);
deviceKeys.removeAllForUser(userId);
} else {
userIdentities.set(identity);
}
@ -233,7 +240,12 @@ export class DeviceTracker {
return false;
}
async _queryKeys(userIds, hsApi, log) {
async _queryKeys(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<{
deviceKeys: Map<string, DeviceKey[]>,
masterKeys: Map<string, CrossSigningKey>,
selfSigningKeys: Map<string, CrossSigningKey>,
userSigningKeys: Map<string, CrossSigningKey>
}> {
// 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
// So, while doing /keys/query, writeDeviceChanges should add userIds marked as outdated to a list
@ -252,10 +264,10 @@ export class DeviceTracker {
const masterKeys = log.wrap("master keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["master_keys"], KeyUsage.Master, undefined, log));
const selfSigningKeys = log.wrap("self-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["self_signing_keys"], KeyUsage.SelfSigning, masterKeys, log));
const userSigningKeys = log.wrap("user-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["user_signing_keys"], KeyUsage.UserSigning, masterKeys, log));
const verifiedKeysPerUser = log.wrap("device keys", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
const deviceKeys = log.wrap("device keys", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.userIdentities,
this._storage.storeNames.deviceIdentities,
this._storage.storeNames.deviceKeys,
this._storage.storeNames.crossSigningKeys,
]);
let deviceIdentities;
@ -269,54 +281,59 @@ export class DeviceTracker {
for (const key of userSigningKeys.values()) {
txn.crossSigningKeys.set(key);
}
const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => {
const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity);
return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn);
let totalCount = 0;
await Promise.all(Array.from(deviceKeys.keys()).map(async (userId) => {
let deviceKeysForUser = deviceKeys.get(userId)!;
totalCount += deviceKeysForUser.length;
// check for devices that changed their keys and keep the old key
deviceKeysForUser = await this._storeQueriedDevicesForUserId(userId, deviceKeysForUser, txn);
deviceKeys.set(userId, deviceKeysForUser);
}));
deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []);
log.set("devices", deviceIdentities.length);
log.set("devices", totalCount);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
return {
deviceIdentities,
deviceKeys,
masterKeys,
selfSigningKeys,
userSigningKeys
};
}
async _storeQueriedDevicesForUserId(userId, deviceIdentities, txn) {
const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId);
async _storeQueriedDevicesForUserId(userId: string, deviceKeys: DeviceKey[], txn: Transaction): Promise<DeviceKey[]> {
// TODO: we should obsolete (flag) the device keys that have been removed,
// but keep them to verify messages encrypted with it?
const knownDeviceIds = await txn.deviceKeys.getAllDeviceIds(userId);
// 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);
if (deviceKeys.every(di => di.device_id !== deviceId)) {
txn.deviceKeys.remove(userId, deviceId);
}
}
// all the device identities as we will have them in storage
const allDeviceIdentities = [];
const deviceIdentitiesToStore = [];
const allDeviceKeys: DeviceKey[] = [];
const deviceKeysToStore: DeviceKey[] = [];
// filter out devices that have changed their ed25519 key since last time we queried them
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);
await Promise.all(deviceKeys.map(async deviceKey => {
if (knownDeviceIds.includes(deviceKey.device_id)) {
const existingDevice = await txn.deviceKeys.get(deviceKey.user_id, deviceKey.device_id);
if (existingDevice && getDeviceEd25519Key(existingDevice) !== getDeviceEd25519Key(deviceKey)) {
allDeviceKeys.push(existingDevice);
return;
}
}
allDeviceIdentities.push(deviceIdentity);
deviceIdentitiesToStore.push(deviceIdentity);
allDeviceKeys.push(deviceKey);
deviceKeysToStore.push(deviceKey);
}));
// store devices
for (const deviceIdentity of deviceIdentitiesToStore) {
txn.deviceIdentities.set(deviceIdentity);
for (const deviceKey of deviceKeysToStore) {
txn.deviceKeys.set(deviceKey);
}
// mark user identities as up to date
let identity = await txn.userIdentities.get(userId);
@ -331,11 +348,11 @@ export class DeviceTracker {
identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
txn.userIdentities.set(identity);
return allDeviceIdentities;
return allDeviceKeys;
}
_filterVerifiedCrossSigningKeys(crossSigningKeysResponse, usage, parentKeys, log) {
const keys = new Map();
_filterVerifiedCrossSigningKeys(crossSigningKeysResponse: {[userId: string]: CrossSigningKey}, usage, parentKeys: Map<string, CrossSigningKey> | undefined, log): Map<string, CrossSigningKey> {
const keys: Map<string, CrossSigningKey> = new Map();
if (!crossSigningKeysResponse) {
return keys;
}
@ -344,14 +361,14 @@ export class DeviceTracker {
const parentKeyInfo = parentKeys?.get(userId);
const parentKey = parentKeyInfo && getKeyEd25519Key(parentKeyInfo);
if (this._validateCrossSigningKey(userId, keyInfo, usage, parentKey, log)) {
keys.set(getKeyUserId(keyInfo), keyInfo);
keys.set(getKeyUserId(keyInfo)!, keyInfo);
}
});
}
return keys;
}
_validateCrossSigningKey(userId, keyInfo, usage, parentKey, log) {
_validateCrossSigningKey(userId: string, keyInfo: CrossSigningKey, usage: KeyUsage, parentKey: string | undefined, log: ILogItem): boolean {
if (getKeyUserId(keyInfo) !== userId) {
log.log({l: "user_id mismatch", userId: keyInfo["user_id"]});
return false;
@ -389,51 +406,67 @@ export class DeviceTracker {
/**
* @return {Array<{userId, verifiedKeys: Array<DeviceSection>>}
*/
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse, parentLog) {
const curve25519Keys = new Set();
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => {
const deviceIdOnKeys = deviceKeys["device_id"];
const userIdOnKeys = deviceKeys["user_id"];
if (userIdOnKeys !== userId) {
return false;
}
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;
_filterVerifiedDeviceKeys(
keyQueryDeviceKeysResponse: {[userId: string]: {[deviceId: string]: DeviceKey}},
parentLog: ILogItem
): Map<string, DeviceKey[]> {
const curve25519Keys: Set<string> = new Set();
const keys: Map<string, DeviceKey[]> = new Map();
if (!keyQueryDeviceKeysResponse) {
return keys;
}
for (const [userId, keysByDevice] of Object.entries(keyQueryDeviceKeysResponse)) {
parentLog.wrap(userId, log => {
const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKey]) => {
return log.wrap(deviceId, log => {
if (this._validateDeviceKey(userId, deviceId, deviceKey, log)) {
const curve25519Key = getDeviceCurve25519Key(deviceKey);
if (curve25519Keys.has(curve25519Key)) {
parentLog.log({
l: "ignore device with duplicate curve25519 key",
keys: deviceKeys
keys: deviceKey
}, parentLog.level.Warn);
return false;
}
curve25519Keys.add(curve25519Key);
const isValid = this._hasValidSignature(deviceKeys, parentLog);
if (!isValid) {
parentLog.log({
l: "ignore device with invalid signature",
keys: deviceKeys
}, parentLog.level.Warn);
return true;
} else {
return false;
}
return isValid;
});
});
const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys);
return {userId, verifiedKeys};
keys.set(userId, verifiedKeys);
});
return verifiedKeys;
}
return keys;
}
_hasValidSignature(deviceSection, parentLog) {
const deviceId = deviceSection["device_id"];
const userId = deviceSection["user_id"];
const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`];
return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection, parentLog);
_validateDeviceKey(userIdFromServer: string, deviceIdFromServer: string, deviceKey: DeviceKey, log: ILogItem): boolean {
const deviceId = deviceKey["device_id"];
const userId = deviceKey["user_id"];
if (userId !== userIdFromServer) {
log.log("user_id mismatch");
return false;
}
if (deviceId !== deviceIdFromServer) {
log.log("device_id mismatch");
return false;
}
const ed25519Key = getDeviceEd25519Key(deviceKey);
const curve25519Key = getDeviceCurve25519Key(deviceKey);
if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") {
log.log("ed25519 and/or curve25519 key invalid").set({deviceKey});
return false;
}
const isValid = verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceKey, log);
if (!isValid) {
log.log({
l: "ignore device with invalid signature",
keys: deviceKey
}, log.level.Warn);
}
return isValid;
}
/**
@ -443,7 +476,7 @@ export class DeviceTracker {
* @param {String} roomId [description]
* @return {[type]} [description]
*/
async devicesForTrackedRoom(roomId, hsApi, log) {
async devicesForTrackedRoom(roomId: string, hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
const txn = await this._storage.readTxn([
this._storage.storeNames.roomMembers,
this._storage.storeNames.userIdentities,
@ -463,7 +496,7 @@ export class DeviceTracker {
* Can be used to decide which users to share keys with.
* Assumes room is already tracked. Call `trackRoom` first if unsure.
*/
async devicesForRoomMembers(roomId, userIds, hsApi, log) {
async devicesForRoomMembers(roomId: string, userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
const txn = await this._storage.readTxn([
this._storage.storeNames.userIdentities,
]);
@ -474,13 +507,13 @@ export class DeviceTracker {
* Cannot be used to decide which users to share keys with.
* Does not assume membership to any room or whether any room is tracked.
*/
async devicesForUsers(userIds, hsApi, log) {
async devicesForUsers(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
const txn = await this._storage.readTxn([
this._storage.storeNames.userIdentities,
]);
const upToDateIdentities = [];
const outdatedUserIds = [];
const upToDateIdentities: UserIdentity[] = [];
const outdatedUserIds: string[] = [];
await Promise.all(userIds.map(async userId => {
const i = await txn.userIdentities.get(userId);
if (i && i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE) {
@ -495,12 +528,12 @@ export class DeviceTracker {
}
/** gets a single device */
async deviceForId(userId, deviceId, hsApi, log) {
async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem) {
const txn = await this._storage.readTxn([
this._storage.storeNames.deviceIdentities,
this._storage.storeNames.deviceKeys,
]);
let device = await txn.deviceIdentities.get(userId, deviceId);
if (device) {
let deviceKey = await txn.deviceKeys.get(userId, deviceId);
if (deviceKey) {
log.set("existingDevice", true);
} else {
//// BEGIN EXTRACT (deviceKeysMap)
@ -514,29 +547,26 @@ export class DeviceTracker {
// verify signature
const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
//// END EXTRACT
// TODO: what if verifiedKeysPerUser is empty or does not contain userId?
const verifiedKeys = verifiedKeysPerUser
.find(vkpu => vkpu.userId === userId).verifiedKeys
.find(vk => vk["device_id"] === deviceId);
const verifiedKey = verifiedKeysPerUser.get(userId)?.find(d => d.device_id === deviceId);
// user hasn't uploaded keys for device?
if (!verifiedKeys) {
if (!verifiedKey) {
return undefined;
}
device = deviceKeysAsDeviceIdentity(verifiedKeys);
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.deviceIdentities,
this._storage.storeNames.deviceKeys,
]);
// check again we don't have the device already.
// when updating all keys for a user we allow updating the
// device when the key hasn't changed so the device display name
// can be updated, but here we don't.
const existingDevice = await txn.deviceIdentities.get(userId, deviceId);
const existingDevice = await txn.deviceKeys.get(userId, deviceId);
if (existingDevice) {
device = existingDevice;
deviceKey = existingDevice;
log.set("existingDeviceAfterFetch", true);
} else {
try {
txn.deviceIdentities.set(device);
txn.deviceKeys.set(verifiedKey);
deviceKey = verifiedKey;
log.set("newDevice", true);
} catch (err) {
txn.abort();
@ -545,7 +575,7 @@ export class DeviceTracker {
await txn.complete();
}
}
return device;
return deviceKey;
}
/**
@ -555,9 +585,9 @@ export class DeviceTracker {
* @param {Array<string>} userIds a set of user ids to try and find the identity for.
* @param {Transaction} userIdentityTxn to read the user identities
* @param {HomeServerApi} hsApi
* @return {Array<DeviceIdentity>} all devices identities for the given users we should share keys with.
* @return {Array<DeviceKey>} all devices identities for the given users we should share keys with.
*/
async _devicesForUserIdsInTrackedRoom(roomId, userIds, userIdentityTxn, hsApi, log) {
async _devicesForUserIdsInTrackedRoom(roomId: string, userIds: string[], userIdentityTxn: Transaction, hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId)));
const identities = allMemberIdentities.filter(identity => {
// we use roomIds to decide with whom we should share keys for a given room,
@ -566,7 +596,7 @@ export class DeviceTracker {
// Given we assume the room is tracked,
// also exclude any userId which doesn't have a userIdentity yet.
return identity && identity.roomIds.includes(roomId);
});
}) as UserIdentity[]; // undefined has been filter out
const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE);
const outdatedUserIds = identities
.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED)
@ -574,7 +604,7 @@ export class DeviceTracker {
let devices = await this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log);
// filter out our own device as we should never share keys with it.
devices = devices.filter(device => {
const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId;
const isOwnDevice = device.user_id === this._ownUserId && device.device_id === this._ownDeviceId;
return !isOwnDevice;
});
return devices;
@ -584,43 +614,44 @@ export class DeviceTracker {
* are known to be up to date, and a set of userIds that are known
* to be absent from our store our outdated. The outdated user ids
* will have their keys fetched from the homeserver. */
async _devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log) {
async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<DeviceKey[]> {
log.set("uptodate", upToDateIdentities.length);
log.set("outdated", outdatedUserIds.length);
let queriedDevices;
let queriedDeviceKeys: Map<string, DeviceKey[]> | undefined;
if (outdatedUserIds.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
const {deviceIdentities} = await this._queryKeys(outdatedUserIds, hsApi, log);
queriedDevices = deviceIdentities;
const {deviceKeys} = await this._queryKeys(outdatedUserIds, hsApi, log);
queriedDeviceKeys = deviceKeys;
}
const deviceTxn = await this._storage.readTxn([
this._storage.storeNames.deviceIdentities,
this._storage.storeNames.deviceKeys,
]);
const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {
return deviceTxn.deviceIdentities.getAllForUserId(identity.userId);
return deviceTxn.deviceKeys.getAllForUserId(identity.userId);
}));
let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []);
if (queriedDevices && queriedDevices.length) {
flattenedDevices = flattenedDevices.concat(queriedDevices);
if (queriedDeviceKeys && queriedDeviceKeys.size) {
for (const deviceKeysForUser of queriedDeviceKeys.values()) {
flattenedDevices = flattenedDevices.concat(deviceKeysForUser);
}
}
return flattenedDevices;
}
async getDeviceByCurve25519Key(curve25519Key, txn) {
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise<DeviceKey | undefined> {
return await txn.deviceKeys.getByCurve25519Key(curve25519Key);
}
}
import {createMockStorage} from "../../mocks/Storage";
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
import {MemberChange} from "../room/members/RoomMember";
export function tests() {
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
function createUntrackedRoomMock(roomId: string, joinedUserIds: string[], invitedUserIds: string[] = []) {
return {
id: roomId,
isTrackingMembers: false,
@ -649,11 +680,11 @@ export function tests() {
}
}
function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) {
function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`): HomeServerApi {
return {
queryKeys(payload) {
const {device_keys: deviceKeys} = payload;
const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => {
const userKeys = Object.entries(deviceKeys as {[userId: string]: string[]}).reduce((userKeys, [userId, deviceIds]) => {
if (deviceIds.length === 0) {
deviceIds = ["device1"];
}
@ -689,7 +720,7 @@ export function tests() {
}
};
}
};
} as unknown as HomeServerApi;
}
async function writeMemberListToStorage(room, storage) {
@ -718,7 +749,7 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -727,14 +758,12 @@ export function tests() {
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
userId: "@alice:hs.tld",
crossSigningKeys: undefined,
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), {
userId: "@bob:hs.tld",
roomIds: [roomId],
crossSigningKeys: undefined,
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined);
@ -744,7 +773,7 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -753,15 +782,15 @@ export function tests() {
const hsApi = createQueryKeysHSApiMock();
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key");
},
"device with changed key is ignored": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -779,18 +808,18 @@ export function tests() {
});
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key");
const txn2 = await storage.readTxn([storage.storeNames.deviceKeys]);
// also check the modified key was not stored
assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key((await txn2.deviceKeys.get("@alice:hs.tld", "device1"))!), "ed25519:@alice:hs.tld:device1:key");
},
"change history visibility from joined to invited adds invitees": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -798,10 +827,10 @@ export function tests() {
const room = await createUntrackedRoomMock(roomId,
["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld");
assert.deepEqual(added, ["@bob:hs.tld"]);
assert.deepEqual(removed, []);
},
@ -810,7 +839,7 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -818,8 +847,8 @@ export function tests() {
const room = await createUntrackedRoomMock(roomId,
["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld");
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
assert.deepEqual(added, []);
@ -830,32 +859,32 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
// inviting a new member
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn);
const {added, removed} = await tracker.writeMemberChanges(room, new Map([[inviteChange.userId, inviteChange]]), HistoryVisibility.Invited, txn);
assert.deepEqual(added, ["@bob:hs.tld"]);
assert.deepEqual(removed, []);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld");
},
"adding invitee with history visibility of joined doesn't add room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
// inviting a new member
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
@ -869,7 +898,7 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -881,22 +910,22 @@ export function tests() {
await writeMemberListToStorage(room, storage);
const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key");
assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key");
},
"rejecting invite with history visibility of invited removes room from user identity": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
// reject invite
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite");
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
@ -910,7 +939,7 @@ export function tests() {
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -920,21 +949,21 @@ export function tests() {
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join");
const memberChanges = new Map([[leaveChange.userId, leaveChange]]);
const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]);
await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2);
await txn2.complete();
const txn3 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]);
},
"add room to user identity sharing multiple rooms with us preserves other room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
@ -943,40 +972,40 @@ export function tests() {
const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]);
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn2 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
},
"devicesForUsers fetches users even though they aren't in any tracked room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const hsApi = createQueryKeysHSApiMock();
const devices = await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 1);
assert.equal(devices[0].curve25519Key, "curve25519:@bob:hs.tld:device1:key");
assert.equal(getDeviceCurve25519Key(devices[0]), "curve25519:@bob:hs.tld:device1:key");
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []);
},
"devicesForUsers doesn't add any roomId when creating userIdentity": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const hsApi = createQueryKeysHSApiMock();
await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item);
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []);
}
}
}

View File

@ -235,7 +235,7 @@ export class RoomEncryption {
// Use devicesForUsers rather than devicesForRoomMembers as the room might not be tracked yet
await this._deviceTracker.devicesForUsers(sendersWithoutDevice, hsApi, log);
// now that we've fetched the missing devices, try verifying the results again
const txn = await this._storage.readTxn([this._storage.storeNames.deviceIdentities]);
const txn = await this._storage.readTxn([this._storage.storeNames.deviceKeys]);
await this._verifyDecryptionResults(resultsWithoutDevice, txn);
const resultsWithFoundDevice = resultsWithoutDevice.filter(r => !r.isVerificationUnknown);
const resultsToEventIdMap = resultsWithFoundDevice.reduce((map, r) => {

View File

@ -15,9 +15,15 @@ limitations under the License.
*/
import anotherjson from "another-json";
import {createEnum} from "../../utils/enum";
export const DecryptionSource = createEnum("Sync", "Timeline", "Retry");
import type {UnsentStateEvent} from "../room/common";
import type {ILogItem} from "../../logging/types";
import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace;
export enum DecryptionSource {
Sync, Timeline, Retry
};
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
export const SESSION_E2EE_KEY_PREFIX = "e2ee:";
@ -25,29 +31,52 @@ export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
export class DecryptionError extends Error {
constructor(code, event, detailsObj = null) {
constructor(private readonly code: string, private readonly event: object, private readonly detailsObj?: object) {
super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`);
this.code = code;
this.event = event;
this.details = detailsObj;
}
}
export const SIGNATURE_ALGORITHM = "ed25519";
export function getEd25519Signature(signedValue, userId, deviceOrKeyId) {
export type SignedValue = {
signatures: {[userId: string]: {[keyId: string]: string}}
unsigned?: object
}
// we store device keys (and cross-signing) in the format we get them from the server
// as that is what the signature is calculated on, so to verify and sign, we need
// it in this format anyway.
export type DeviceKey = SignedValue & {
readonly user_id: string;
readonly device_id: string;
readonly algorithms: ReadonlyArray<string>;
readonly keys: {[keyId: string]: string};
readonly unsigned: {
device_display_name?: string
}
}
export function getDeviceEd25519Key(deviceKey: DeviceKey): string {
return deviceKey.keys[`ed25519:${deviceKey.device_id}`];
}
export function getDeviceCurve25519Key(deviceKey: DeviceKey): string {
return deviceKey.keys[`curve25519:${deviceKey.device_id}`];
}
export function getEd25519Signature(signedValue: SignedValue, userId: string, deviceOrKeyId: string) {
return signedValue?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`];
}
export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Key, value, log = undefined) {
export function verifyEd25519Signature(olmUtil: Olm.Utility, userId: string, deviceOrKeyId: string, ed25519Key: string, value: SignedValue, log?: ILogItem) {
const signature = getEd25519Signature(value, userId, deviceOrKeyId);
if (!signature) {
log?.set("no_signature", true);
return false;
}
const clone = Object.assign({}, value);
delete clone.unsigned;
delete clone.signatures;
const clone = Object.assign({}, value) as object;
delete clone["unsigned"];
delete clone["signatures"];
const canonicalJson = anotherjson.stringify(clone);
try {
// throws when signature is invalid
@ -63,7 +92,7 @@ export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Ke
}
}
export function createRoomEncryptionEvent() {
export function createRoomEncryptionEvent(): UnsentStateEvent {
return {
"type": "m.room.encryption",
"state_key": "",
@ -75,16 +104,14 @@ export function createRoomEncryptionEvent() {
}
}
export enum HistoryVisibility {
Joined = "joined",
Invited = "invited",
WorldReadable = "world_readable",
Shared = "shared",
};
// Use enum when converting to TS
export const HistoryVisibility = Object.freeze({
Joined: "joined",
Invited: "invited",
WorldReadable: "world_readable",
Shared: "shared",
});
export function shouldShareKey(membership, historyVisibility) {
export function shouldShareKey(membership: string, historyVisibility: HistoryVisibility) {
switch (historyVisibility) {
case HistoryVisibility.WorldReadable:
return true;

View File

@ -42,7 +42,7 @@ export type SessionInfo = {
}
export type MegOlmSessionKeyInfo = {
algorithm: MEGOLM_ALGORITHM,
algorithm: typeof MEGOLM_ALGORITHM,
sender_key: string,
sender_claimed_keys: {[algorithm: string]: string},
forwarding_curve25519_key_chain: string[],

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import {groupByWithCreator} from "../../../utils/groupBy";
import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js";
import {verifyEd25519Signature, OLM_ALGORITHM, getDeviceCurve25519Key, getDeviceEd25519Key} from "../common.js";
import {createSessionEntry} from "./Session";
import type {OlmMessage, OlmPayload, OlmEncryptedMessageContent} from "./types";
@ -24,7 +24,7 @@ import type {LockMap} from "../../../utils/LockMap";
import {Lock, MultiLock, ILock} from "../../../utils/Lock";
import type {Storage} from "../../storage/idb/Storage";
import type {Transaction} from "../../storage/idb/Transaction";
import type {DeviceIdentity} from "../../storage/idb/stores/DeviceIdentityStore";
import type {DeviceKey} from "../common";
import type {HomeServerApi} from "../../net/HomeServerApi";
import type {ILogItem} from "../../../logging/types";
import type * as OlmNamespace from "@matrix-org/olm";
@ -99,7 +99,7 @@ export class Encryption {
return new MultiLock(locks);
}
async encrypt(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
async encrypt(type: string, content: Record<string, any>, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
let messages: EncryptedMessage[] = [];
for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) {
const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE);
@ -115,12 +115,12 @@ export class Encryption {
return messages;
}
async _encryptForMaxDevices(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
async _encryptForMaxDevices(type: string, content: Record<string, any>, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
// TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed)
// take a lock on all senderKeys so decryption and other calls to encrypt (should not happen)
// don't modify the sessions at the same time
const locks = await Promise.all(devices.map(device => {
return this.senderKeyLock.takeLock(device.curve25519Key);
return this.senderKeyLock.takeLock(getDeviceCurve25519Key(device));
}));
try {
const {
@ -158,10 +158,10 @@ export class Encryption {
}
}
async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> {
async _findExistingSessions(devices: DeviceKey[]): Promise<{devicesWithoutSession: DeviceKey[], existingEncryptionTargets: EncryptionTarget[]}> {
const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]);
const sessionIdsForDevice = await Promise.all(devices.map(async device => {
return await txn.olmSessions.getSessionIds(device.curve25519Key);
return await txn.olmSessions.getSessionIds(getDeviceCurve25519Key(device));
}));
const devicesWithoutSession = devices.filter((_, i) => {
const sessionIds = sessionIdsForDevice[i];
@ -184,36 +184,36 @@ export class Encryption {
const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device));
const message = session!.encrypt(plaintext);
const encryptedContent = {
algorithm: OLM_ALGORITHM,
algorithm: OLM_ALGORITHM as typeof OLM_ALGORITHM,
sender_key: this.account.identityKeys.curve25519,
ciphertext: {
[device.curve25519Key]: message
[getDeviceCurve25519Key(device)]: message
}
};
return encryptedContent;
}
_buildPlainTextMessageForDevice(type: string, content: Record<string, any>, device: DeviceIdentity): OlmPayload {
_buildPlainTextMessageForDevice(type: string, content: Record<string, any>, device: DeviceKey): OlmPayload {
return {
keys: {
"ed25519": this.account.identityKeys.ed25519
},
recipient_keys: {
"ed25519": device.ed25519Key
"ed25519": getDeviceEd25519Key(device)
},
recipient: device.userId,
recipient: device.user_id,
sender: this.ownUserId,
content,
type
}
}
async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise<EncryptionTarget[]> {
async _createNewSessions(devicesWithoutSession: DeviceKey[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise<EncryptionTarget[]> {
const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log));
try {
for (const target of newEncryptionTargets) {
const {device, oneTimeKey} = target;
target.session = await this.account.createOutboundOlmSession(device.curve25519Key, oneTimeKey);
target.session = await this.account.createOutboundOlmSession(getDeviceCurve25519Key(device), oneTimeKey);
}
await this._storeSessions(newEncryptionTargets, timestamp);
} catch (err) {
@ -225,16 +225,16 @@ export class Encryption {
return newEncryptionTargets;
}
async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise<EncryptionTarget[]> {
async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceKey[], log: ILogItem): Promise<EncryptionTarget[]> {
// create a Map<userId, Map<deviceId, deviceIdentity>>
const devicesByUser = groupByWithCreator(deviceIdentities,
(device: DeviceIdentity) => device.userId,
(): Map<string, DeviceIdentity> => new Map(),
(deviceMap: Map<string, DeviceIdentity>, device: DeviceIdentity) => deviceMap.set(device.deviceId, device)
(device: DeviceKey) => device.user_id,
(): Map<string, DeviceKey> => new Map(),
(deviceMap: Map<string, DeviceKey>, device: DeviceKey) => deviceMap.set(device.device_id, device)
);
const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => {
usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => {
devicesObj[device.deviceId] = OTK_ALGORITHM;
devicesObj[device.device_id] = OTK_ALGORITHM;
return devicesObj;
}, {});
return usersObj;
@ -250,7 +250,7 @@ export class Encryption {
return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log);
}
_verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map<string, Map<string, DeviceIdentity>>, log: ILogItem): EncryptionTarget[] {
_verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map<string, Map<string, DeviceKey>>, log: ILogItem): EncryptionTarget[] {
const verifiedEncryptionTargets: EncryptionTarget[] = [];
for (const [userId, userSection] of Object.entries(userKeyMap)) {
for (const [deviceId, deviceSection] of Object.entries(userSection)) {
@ -260,7 +260,7 @@ export class Encryption {
const device = devicesByUser.get(userId)?.get(deviceId);
if (device) {
const isValidSignature = verifyEd25519Signature(
this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log);
this.olmUtil, userId, deviceId, getDeviceEd25519Key(device), keySection, log);
if (isValidSignature) {
const target = EncryptionTarget.fromOTK(device, keySection.key);
verifiedEncryptionTargets.push(target);
@ -281,7 +281,7 @@ export class Encryption {
try {
await Promise.all(encryptionTargets.map(async encryptionTarget => {
const sessionEntry = await txn.olmSessions.get(
encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!);
getDeviceCurve25519Key(encryptionTarget.device), encryptionTarget.sessionId!);
if (sessionEntry && !failed) {
const olmSession = new this.olm.Session();
olmSession.unpickle(this.pickleKey, sessionEntry.session);
@ -303,7 +303,7 @@ export class Encryption {
try {
for (const target of encryptionTargets) {
const sessionEntry = createSessionEntry(
target.session!, target.device.curve25519Key, timestamp, this.pickleKey);
target.session!, getDeviceCurve25519Key(target.device), timestamp, this.pickleKey);
txn.olmSessions.set(sessionEntry);
}
} catch (err) {
@ -323,16 +323,16 @@ class EncryptionTarget {
public session: Olm.Session | null = null;
constructor(
public readonly device: DeviceIdentity,
public readonly device: DeviceKey,
public readonly oneTimeKey: string | null,
public readonly sessionId: string | null
) {}
static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget {
static fromOTK(device: DeviceKey, oneTimeKey: string): EncryptionTarget {
return new EncryptionTarget(device, oneTimeKey, null);
}
static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget {
static fromSessionId(device: DeviceKey, sessionId: string): EncryptionTarget {
return new EncryptionTarget(device, null, sessionId);
}
@ -346,6 +346,6 @@ class EncryptionTarget {
export class EncryptedMessage {
constructor(
public readonly content: OlmEncryptedMessageContent,
public readonly device: DeviceIdentity
public readonly device: DeviceKey
) {}
}

View File

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type {OLM_ALGORITHM} from "../common";
export const enum OlmPayloadType {
PreKey = 0,
Normal = 1
@ -25,7 +27,7 @@ export type OlmMessage = {
}
export type OlmEncryptedMessageContent = {
algorithm?: "m.olm.v1.curve25519-aes-sha2"
algorithm?: typeof OLM_ALGORITHM
sender_key?: string,
ciphertext?: {
[deviceCurve25519Key: string]: OlmMessage

View File

@ -173,7 +173,7 @@ export class BaseRoom extends EventEmitter {
const isTimelineOpen = this._isTimelineOpen;
if (isTimelineOpen) {
// read to fetch devices if timeline is open
stores.push(this._storage.storeNames.deviceIdentities);
stores.push(this._storage.storeNames.deviceKeys);
}
const writeTxn = await this._storage.readWriteTxn(stores);
let decryption;

View File

@ -20,7 +20,7 @@ import {MediaRepository} from "../net/MediaRepository";
import {EventEmitter} from "../../utils/EventEmitter";
import {AttachmentUpload} from "./AttachmentUpload";
import {loadProfiles, Profile, UserIdProfile} from "../profile";
import {RoomType} from "./common";
import {RoomType, UnsentStateEvent} from "./common";
import type {HomeServerApi} from "../net/HomeServerApi";
import type {ILogItem} from "../../logging/types";
@ -37,7 +37,7 @@ type CreateRoomPayload = {
invite?: string[];
room_alias_name?: string;
creation_content?: {"m.federate": boolean};
initial_state: { type: string; state_key: string; content: Record<string, any> }[];
initial_state: UnsentStateEvent[];
power_level_content_override?: Record<string, any>;
}

View File

@ -28,6 +28,8 @@ export function isRedacted(event) {
return !!event?.unsigned?.redacted_because;
}
export type UnsentStateEvent = { type: string; state_key: string; content: Record<string, any> };
export enum RoomStatus {
None = 1 << 0,
BeingCreated = 1 << 1,

View File

@ -26,7 +26,7 @@ export enum StoreNames {
timelineFragments = "timelineFragments",
pendingEvents = "pendingEvents",
userIdentities = "userIdentities",
deviceIdentities = "deviceIdentities",
deviceKeys = "deviceKeys",
olmSessions = "olmSessions",
inboundGroupSessions = "inboundGroupSessions",
outboundGroupSessions = "outboundGroupSessions",

View File

@ -29,7 +29,7 @@ import {RoomMemberStore} from "./stores/RoomMemberStore";
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore";
import {PendingEventStore} from "./stores/PendingEventStore";
import {UserIdentityStore} from "./stores/UserIdentityStore";
import {DeviceIdentityStore} from "./stores/DeviceIdentityStore";
import {DeviceKeyStore} from "./stores/DeviceKeyStore";
import {CrossSigningKeyStore} from "./stores/CrossSigningKeyStore";
import {OlmSessionStore} from "./stores/OlmSessionStore";
import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore";
@ -142,8 +142,8 @@ export class Transaction {
return this._store(StoreNames.userIdentities, idbStore => new UserIdentityStore(idbStore));
}
get deviceIdentities(): DeviceIdentityStore {
return this._store(StoreNames.deviceIdentities, idbStore => new DeviceIdentityStore(idbStore));
get deviceKeys(): DeviceKeyStore {
return this._store(StoreNames.deviceKeys, idbStore => new DeviceKeyStore(idbStore));
}
get crossSigningKeys(): CrossSigningKeyStore {

View File

@ -35,7 +35,7 @@ export const schema: MigrationFunc[] = [
addInboundSessionBackupIndex,
migrateBackupStatus,
createCallStore,
createCrossSigningKeyStore
createCrossSigningKeyStoreAndRenameDeviceIdentities
];
// TODO: how to deal with git merge conflicts of this array?
@ -277,7 +277,10 @@ function createCallStore(db: IDBDatabase) : void {
db.createObjectStore("calls", {keyPath: "key"});
}
//v18 create calls store
function createCrossSigningKeyStore(db: IDBDatabase) : void {
//v18 create calls store and rename deviceIdentities to deviceKeys
function createCrossSigningKeyStoreAndRenameDeviceIdentities(db: IDBDatabase) : void {
db.createObjectStore("crossSigningKeys", {keyPath: "key"});
db.deleteObjectStore("deviceIdentities");
const deviceKeys = db.createObjectStore("deviceKeys", {keyPath: "key"});
deviceKeys.createIndex("byCurve25519Key", "curve25519Key", {unique: true});
}

View File

@ -16,15 +16,15 @@ limitations under the License.
import {MAX_UNICODE, MIN_UNICODE} from "./common";
import {Store} from "../Store";
import type {SignedValue} from "../../../e2ee/common";
// we store cross-signing keys in the format we get them from the server
// as that is what the signature is calculated on, so to verify, we need
// we store cross-signing (and device) keys in the format we get them from the server
// as that is what the signature is calculated on, so to verify and sign, we need
// it in this format anyway.
export type CrossSigningKey = {
export type CrossSigningKey = SignedValue & {
readonly user_id: string;
readonly usage: ReadonlyArray<string>;
readonly keys: {[keyId: string]: string};
readonly signatures: {[userId: string]: {[keyId: string]: string}}
}
type CrossSigningKeyEntry = CrossSigningKey & {

View File

@ -16,15 +16,13 @@ limitations under the License.
import {MAX_UNICODE, MIN_UNICODE} from "./common";
import {Store} from "../Store";
import {getDeviceCurve25519Key} from "../../../e2ee/common";
import type {DeviceKey} from "../../../e2ee/common";
export interface DeviceIdentity {
userId: string;
deviceId: string;
ed25519Key: string;
type DeviceKeyEntry = {
key: string; // key in storage, not a crypto key
curve25519Key: string;
algorithms: string[];
displayName: string;
key: string;
deviceKey: DeviceKey
}
function encodeKey(userId: string, deviceId: string): string {
@ -36,23 +34,24 @@ function decodeKey(key: string): { userId: string, deviceId: string } {
return {userId, deviceId};
}
export class DeviceIdentityStore {
private _store: Store<DeviceIdentity>;
export class DeviceKeyStore {
private _store: Store<DeviceKeyEntry>;
constructor(store: Store<DeviceIdentity>) {
constructor(store: Store<DeviceKeyEntry>) {
this._store = store;
}
getAllForUserId(userId: string): Promise<DeviceIdentity[]> {
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, ""));
return this._store.selectWhile(range, device => {
return device.userId === userId;
async getAllForUserId(userId: string): Promise<DeviceKey[]> {
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE));
const entries = await this._store.selectWhile(range, device => {
return device.deviceKey.user_id === userId;
});
return entries.map(e => e.deviceKey);
}
async getAllDeviceIds(userId: string): Promise<string[]> {
const deviceIds: string[] = [];
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, ""));
const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE));
await this._store.iterateKeys(range, key => {
const decodedKey = decodeKey(key as string);
// prevent running into the next room
@ -65,17 +64,21 @@ export class DeviceIdentityStore {
return deviceIds;
}
get(userId: string, deviceId: string): Promise<DeviceIdentity | undefined> {
return this._store.get(encodeKey(userId, deviceId));
async get(userId: string, deviceId: string): Promise<DeviceKey | undefined> {
return (await this._store.get(encodeKey(userId, deviceId)))?.deviceKey;
}
set(deviceIdentity: DeviceIdentity): void {
deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
this._store.put(deviceIdentity);
set(deviceKey: DeviceKey): void {
this._store.put({
key: encodeKey(deviceKey.user_id, deviceKey.device_id),
curve25519Key: getDeviceCurve25519Key(deviceKey)!,
deviceKey
});
}
getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | undefined> {
return this._store.index("byCurve25519Key").get(curve25519Key);
async getByCurve25519Key(curve25519Key: string): Promise<DeviceKey | undefined> {
const entry = await this._store.index("byCurve25519Key").get(curve25519Key);
return entry?.deviceKey;
}
remove(userId: string, deviceId: string): void {