store e2ee session values as well in localStorage

This commit is contained in:
Bruno Windels 2021-09-29 11:49:58 +02:00
parent cd071e47e0
commit 77bd0d3f3c
11 changed files with 160 additions and 23 deletions

View File

@ -233,6 +233,7 @@ export class SessionContainer {
platform: this._platform, platform: this._platform,
}); });
await this._session.load(log); await this._session.load(log);
// TODO: check instead storage doesn't have an identity
if (isNewLogin) { if (isNewLogin) {
this._status.set(LoadStatus.SessionSetup); this._status.set(LoadStatus.SessionSetup);
await log.wrap("createIdentity", log => this._session.createIdentity(log)); await log.wrap("createIdentity", log => this._session.createIdentity(log));

View File

@ -15,12 +15,12 @@ limitations under the License.
*/ */
import anotherjson from "../../../lib/another-json/index.js"; import anotherjson from "../../../lib/another-json/index.js";
import {SESSION_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js"; import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
// use common prefix so it's easy to clear properties that are not e2ee related during session clear // use common prefix so it's easy to clear properties that are not e2ee related during session clear
const ACCOUNT_SESSION_KEY = SESSION_KEY_PREFIX + "olmAccount"; const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount";
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_KEY_PREFIX + "areDeviceKeysUploaded"; const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded";
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_KEY_PREFIX + "serverOTKCount"; const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount";
export class Account { export class Account {
static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) { static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) {

View File

@ -20,7 +20,7 @@ import {createEnum} from "../../utils/enum.js";
export const DecryptionSource = createEnum("Sync", "Timeline", "Retry"); export const DecryptionSource = createEnum("Sync", "Timeline", "Retry");
// use common prefix so it's easy to clear properties that are not e2ee related during session clear // use common prefix so it's easy to clear properties that are not e2ee related during session clear
export const SESSION_KEY_PREFIX = "e2ee:"; export const SESSION_E2EE_KEY_PREFIX = "e2ee:";
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";

View File

@ -22,6 +22,7 @@ import {IDBKey} from "./Transaction";
export interface ITransaction { export interface ITransaction {
idbFactory: IDBFactory; idbFactory: IDBFactory;
IDBKeyRange: typeof IDBKeyRange; IDBKeyRange: typeof IDBKeyRange;
databaseName: string;
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined); addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined);
} }
@ -55,6 +56,10 @@ export class QueryTarget<T> {
return this._transaction.IDBKeyRange; return this._transaction.IDBKeyRange;
} }
get databaseName(): string {
return this._transaction.databaseName;
}
_openCursor(range?: IDBQuery, direction?: IDBCursorDirection): IDBRequest<IDBCursorWithValue | null> { _openCursor(range?: IDBQuery, direction?: IDBCursorDirection): IDBRequest<IDBCursorWithValue | null> {
if (range && direction) { if (range && direction) {
return this._target.openCursor(range, direction); return this._target.openCursor(range, direction);
@ -269,6 +274,7 @@ import {QueryTargetWrapper, Store} from "./Store";
export function tests() { export function tests() {
class MockTransaction extends MockIDBImpl { class MockTransaction extends MockIDBImpl {
get databaseName(): string { return "mockdb"; }
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined) {} addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined) {}
} }

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {IDOMStorage} from "./types";
import {Transaction} from "./Transaction"; import {Transaction} from "./Transaction";
import { STORE_NAMES, StoreNames, StorageError } from "../common"; import { STORE_NAMES, StoreNames, StorageError } from "../common";
import { reqAsPromise } from "./utils"; import { reqAsPromise } from "./utils";
@ -29,13 +30,15 @@ export class Storage {
readonly idbFactory: IDBFactory readonly idbFactory: IDBFactory
readonly IDBKeyRange: typeof IDBKeyRange; readonly IDBKeyRange: typeof IDBKeyRange;
readonly storeNames: typeof StoreNames; readonly storeNames: typeof StoreNames;
readonly localStorage: IDOMStorage;
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, logger: BaseLogger) { constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, localStorage: IDOMStorage, logger: BaseLogger) {
this._db = idbDatabase; this._db = idbDatabase;
this.idbFactory = idbFactory; this.idbFactory = idbFactory;
this.IDBKeyRange = _IDBKeyRange; this.IDBKeyRange = _IDBKeyRange;
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug; this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
this.storeNames = StoreNames; this.storeNames = StoreNames;
this.localStorage = localStorage;
this.logger = logger; this.logger = logger;
} }
@ -79,4 +82,8 @@ export class Storage {
close(): void { close(): void {
this._db.close(); this._db.close();
} }
get databaseName(): string {
return this._db.name;
}
} }

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {IDOMStorage} from "./types";
import {Storage} from "./Storage"; import {Storage} from "./Storage";
import { openDatabase, reqAsPromise } from "./utils"; import { openDatabase, reqAsPromise } from "./utils";
import { exportSession, importSession, Export } from "./export"; import { exportSession, importSession, Export } from "./export";
@ -23,8 +24,8 @@ import { BaseLogger } from "../../../logging/BaseLogger.js";
import { LogItem } from "../../../logging/LogItem.js"; import { LogItem } from "../../../logging/LogItem.js";
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log: LogItem) { const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, localStorage: IDOMStorage, log: LogItem) {
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log); const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, localStorage, log);
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory); return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
} }
@ -52,12 +53,14 @@ async function requestPersistedStorage(): Promise<boolean> {
export class StorageFactory { export class StorageFactory {
private _serviceWorkerHandler: ServiceWorkerHandler; private _serviceWorkerHandler: ServiceWorkerHandler;
private _idbFactory: IDBFactory; private _idbFactory: IDBFactory;
private _IDBKeyRange: typeof IDBKeyRange private _IDBKeyRange: typeof IDBKeyRange;
private _localStorage: IDOMStorage;
constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, _IDBKeyRange = window.IDBKeyRange) { constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, _IDBKeyRange = window.IDBKeyRange, localStorage: IDOMStorage = window.localStorage) {
this._serviceWorkerHandler = serviceWorkerHandler; this._serviceWorkerHandler = serviceWorkerHandler;
this._idbFactory = idbFactory; this._idbFactory = idbFactory;
this._IDBKeyRange = _IDBKeyRange; this._IDBKeyRange = _IDBKeyRange;
this._localStorage = localStorage;
} }
async create(sessionId: string, log: LogItem): Promise<Storage> { async create(sessionId: string, log: LogItem): Promise<Storage> {
@ -70,8 +73,8 @@ export class StorageFactory {
}); });
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory); const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, log.logger); return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, this._localStorage, log.logger);
} }
delete(sessionId: string): Promise<IDBDatabase> { delete(sessionId: string): Promise<IDBDatabase> {
@ -81,21 +84,22 @@ export class StorageFactory {
} }
async export(sessionId: string, log: LogItem): Promise<Export> { async export(sessionId: string, log: LogItem): Promise<Export> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return await exportSession(db); return await exportSession(db);
} }
async import(sessionId: string, data: Export, log: LogItem): Promise<void> { async import(sessionId: string, data: Export, log: LogItem): Promise<void> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return await importSession(db, data); return await importSession(db, data);
} }
} }
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise<void> { async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, localStorage: IDOMStorage, log: LogItem): Promise<void> {
const startIdx = oldVersion || 0; const startIdx = oldVersion || 0;
return log.wrap({l: "storage migration", oldVersion, version}, async log => { return log.wrap({l: "storage migration", oldVersion, version}, async log => {
for(let i = startIdx; i < version; ++i) { for(let i = startIdx; i < version; ++i) {
await log.wrap(`v${i + 1}`, log => schema[i](db, txn, log)); const migrationFunc = schema[i];
await log.wrap(`v${i + 1}`, log => migrationFunc(db, txn, localStorage, log));
} }
}); });
} }

View File

@ -73,6 +73,10 @@ export class Transaction {
return this._storage.IDBKeyRange; return this._storage.IDBKeyRange;
} }
get databaseName(): string {
return this._storage.databaseName;
}
get logger(): BaseLogger { get logger(): BaseLogger {
return this._storage.logger; return this._storage.logger;
} }
@ -94,7 +98,7 @@ export class Transaction {
} }
get session(): SessionStore { get session(): SessionStore {
return this._store(StoreNames.session, idbStore => new SessionStore(idbStore)); return this._store(StoreNames.session, idbStore => new SessionStore(idbStore, this._storage.localStorage));
} }
get roomSummary(): RoomSummaryStore { get roomSummary(): RoomSummaryStore {

View File

@ -1,3 +1,4 @@
import {IDOMStorage} from "./types";
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils"; import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js"; import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js";
@ -7,10 +8,13 @@ import {RoomStateEntry} from "./stores/RoomStateStore";
import {SessionStore} from "./stores/SessionStore"; import {SessionStore} from "./stores/SessionStore";
import {encodeScopeTypeKey} from "./stores/OperationStore"; import {encodeScopeTypeKey} from "./stores/OperationStore";
import {MAX_UNICODE} from "./stores/common"; import {MAX_UNICODE} from "./stores/common";
import {LogItem} from "../../../logging/LogItem.js";
export type MigrationFunc = (db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: LogItem) => Promise<void> | void;
// FUNCTIONS SHOULD ONLY BE APPENDED!! // FUNCTIONS SHOULD ONLY BE APPENDED!!
// the index in the array is the database version // the index in the array is the database version
export const schema = [ export const schema: MigrationFunc[] = [
createInitialStores, createInitialStores,
createMemberStore, createMemberStore,
migrateSession, migrateSession,
@ -64,7 +68,7 @@ async function createMemberStore(db: IDBDatabase, txn: IDBTransaction): Promise<
}); });
} }
//v3 //v3
async function migrateSession(db: IDBDatabase, txn: IDBTransaction): Promise<void> { async function migrateSession(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage): Promise<void> {
const session = txn.objectStore("session"); const session = txn.objectStore("session");
try { try {
const PRE_MIGRATION_KEY = 1; const PRE_MIGRATION_KEY = 1;
@ -73,7 +77,7 @@ async function migrateSession(db: IDBDatabase, txn: IDBTransaction): Promise<voi
session.delete(PRE_MIGRATION_KEY); session.delete(PRE_MIGRATION_KEY);
const {syncToken, syncFilterId, serverVersions} = entry.value; const {syncToken, syncFilterId, serverVersions} = entry.value;
// Cast ok here because only "set" is used and we don't look into return // Cast ok here because only "set" is used and we don't look into return
const store = new SessionStore(session as any); const store = new SessionStore(session as any, localStorage);
store.set("sync", {token: syncToken, filterId: syncFilterId}); store.set("sync", {token: syncToken, filterId: syncFilterId});
store.set("serverVersions", serverVersions); store.set("serverVersions", serverVersions);
} }
@ -156,7 +160,7 @@ function createTimelineRelationsStore(db: IDBDatabase) : void {
} }
//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470) //v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470)
async function fixMissingRoomsInUserIdentities(db, txn, log) { async function fixMissingRoomsInUserIdentities(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: LogItem) {
const roomSummaryStore = txn.objectStore("roomSummary"); const roomSummaryStore = txn.objectStore("roomSummary");
const trackedRoomIds: string[] = []; const trackedRoomIds: string[] = [];
await iterateCursor<SummaryData>(roomSummaryStore.openCursor(), roomSummary => { await iterateCursor<SummaryData>(roomSummaryStore.openCursor(), roomSummary => {

View File

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Store} from "../Store"; import {Store} from "../Store";
import {IDOMStorage} from "../types";
import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common.js";
export interface SessionEntry { export interface SessionEntry {
key: string; key: string;
@ -22,9 +24,11 @@ export interface SessionEntry {
export class SessionStore { export class SessionStore {
private _sessionStore: Store<SessionEntry> private _sessionStore: Store<SessionEntry>
private _localStorage: IDOMStorage;
constructor(sessionStore: Store<SessionEntry>) { constructor(sessionStore: Store<SessionEntry>, localStorage: IDOMStorage) {
this._sessionStore = sessionStore; this._sessionStore = sessionStore;
this._localStorage = localStorage;
} }
async get(key: string): Promise<any> { async get(key: string): Promise<any> {
@ -34,15 +38,60 @@ export class SessionStore {
} }
} }
_writeKeyToLocalStorage(key: string, value: any) {
// we backup to localStorage so when idb gets cleared for some reason, we don't lose our e2ee identity
try {
const lsKey = `${this._sessionStore.databaseName}.session.${key}`;
const lsValue = JSON.stringify(value);
this._localStorage.setItem(lsKey, lsValue);
} catch (err) {
console.error("could not write to localStorage", err);
}
}
writeToLocalStorage() {
this._sessionStore.iterateValues(undefined, (value: any, key: string) => {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, value);
}
return false;
});
}
tryRestoreFromLocalStorage(): boolean {
let success = false;
const lsPrefix = `${this._sessionStore.databaseName}.session.`;
const prefix = `${lsPrefix}${SESSION_E2EE_KEY_PREFIX}`;
for(let i = 0; i < this._localStorage.length; i += 1) {
const lsKey = this._localStorage.key(i)!;
if (lsKey.startsWith(prefix)) {
const value = JSON.parse(this._localStorage.getItem(lsKey)!);
const key = lsKey.substr(lsPrefix.length);
this._sessionStore.put({key, value});
success = true;
}
}
return success;
}
set(key: string, value: any): void { set(key: string, value: any): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, value);
}
this._sessionStore.put({key, value}); this._sessionStore.put({key, value});
} }
add(key: string, value: any): void { add(key: string, value: any): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, value);
}
this._sessionStore.add({key, value}); this._sessionStore.add({key, value});
} }
remove(key: string): void { remove(key: string): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._localStorage.removeItem(this._sessionStore.databaseName + key);
}
this._sessionStore.delete(key); this._sessionStore.delete(key);
} }
} }

View File

@ -0,0 +1,23 @@
/*
Copyright 2021 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.
*/
export interface IDOMStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
key(n: number): string | null;
readonly length: number;
}

View File

@ -16,12 +16,13 @@ limitations under the License.
import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js"; import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js";
import {StorageFactory} from "../matrix/storage/idb/StorageFactory"; import {StorageFactory} from "../matrix/storage/idb/StorageFactory";
import {IDOMStorage} from "../matrix/storage/idb/types";
import {Storage} from "../matrix/storage/idb/Storage"; import {Storage} from "../matrix/storage/idb/Storage";
import {Instance as nullLogger} from "../logging/NullLogger.js"; import {Instance as nullLogger} from "../logging/NullLogger.js";
import {openDatabase, CreateObjectStore} from "../matrix/storage/idb/utils"; import {openDatabase, CreateObjectStore} from "../matrix/storage/idb/utils";
export function createMockStorage(): Promise<Storage> { export function createMockStorage(): Promise<Storage> {
return new StorageFactory(null as any, new FDBFactory(), FDBKeyRange).create("1", nullLogger.item); return new StorageFactory(null as any, new FDBFactory(), FDBKeyRange, new MockLocalStorage()).create("1", nullLogger.item);
} }
export function createMockDatabase(name: string, createObjectStore: CreateObjectStore, impl: MockIDBImpl): Promise<IDBDatabase> { export function createMockDatabase(name: string, createObjectStore: CreateObjectStore, impl: MockIDBImpl): Promise<IDBDatabase> {
@ -39,3 +40,41 @@ export class MockIDBImpl {
return FDBKeyRange; return FDBKeyRange;
} }
} }
class MockLocalStorage implements IDOMStorage {
private _map: Map<string, string>;
constructor() {
this._map = new Map();
}
getItem(key: string): string | null {
return this._map.get(key) || null;
}
setItem(key: string, value: string) {
this._map.set(key, value);
}
removeItem(key: string): void {
this._map.delete(key);
}
get length(): number {
return this._map.size;
}
key(n: number): string | null {
const it = this._map.keys();
let i = -1;
let result;
while (i < n) {
result = it.next();
if (result.done) {
return null;
}
i += 1;
}
return result?.value || null;
}
}