From 0bfa28d795c6adf7ca5a87bd9efdec04d6e0814c Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 7 Sep 2024 03:56:13 -0700 Subject: [PATCH] Mobile,Desktop,CLI: Fixes #10856: Decrypt master keys only as needed (#10990) --- packages/app-mobile/root.tsx | 8 +- packages/lib/models/BaseItem.ts | 7 ++ .../lib/services/e2ee/EncryptionService.ts | 76 +++++++++++++++---- packages/lib/services/e2ee/utils.ts | 12 +-- 4 files changed, 78 insertions(+), 25 deletions(-) diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index b025b6b2b..284cc18fa 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -207,7 +207,13 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) => setTimeLocale(Setting.value('locale')); } - if ((action.type === 'SETTING_UPDATE_ONE' && (action.key.indexOf('encryption.') === 0)) || (action.type === 'SETTING_UPDATE_ALL')) { + // Like the desktop and CLI apps, we run this whenever the sync target properties change. + // Previously, this only ran when encryption was enabled/disabled. However, after fetching + // a new key, this needs to run and so we run it when the sync target info changes. + if ( + (action.type === 'SETTING_UPDATE_ONE' && (action.key === 'syncInfoCache' || action.key.startsWith('encryption.'))) + || action.type === 'SETTING_UPDATE_ALL' + ) { await loadMasterKeysFromSettings(EncryptionService.instance()); void DecryptionWorker.instance().scheduleStart(); const loadedMasterKeyIds = EncryptionService.instance().loadedMasterKeyIds(); diff --git a/packages/lib/models/BaseItem.ts b/packages/lib/models/BaseItem.ts index 186f926ff..509e58bdc 100644 --- a/packages/lib/models/BaseItem.ts +++ b/packages/lib/models/BaseItem.ts @@ -506,6 +506,13 @@ export default class BaseItem extends BaseModel { masterKeyId: share && share.master_key_id ? share.master_key_id : '', }); } catch (error) { + if (error.code === 'masterKeyNotLoaded' && error.masterKeyId) { + this.dispatch?.({ + type: 'MASTERKEY_ADD_NOT_LOADED', + id: error.masterKeyId, + }); + } + const msg = [`Could not encrypt item ${item.id}`]; if (error && error.message) msg.push(error.message); const newError = new Error(msg.join(': ')); diff --git a/packages/lib/services/e2ee/EncryptionService.ts b/packages/lib/services/e2ee/EncryptionService.ts index cb73acffe..4ec7191a5 100644 --- a/packages/lib/services/e2ee/EncryptionService.ts +++ b/packages/lib/services/e2ee/EncryptionService.ts @@ -52,6 +52,12 @@ export interface EncryptOptions { masterKeyId?: string; } +type GetPasswordCallback = ()=> string|Promise; +interface EncryptedMasterKey { + updatedTime: number; + decrypt: ()=> Promise; +} + export default class EncryptionService { public static instance_: EncryptionService = null; @@ -73,7 +79,8 @@ export default class EncryptionService { // So making the block 10 times smaller make it 100 times faster! So for now using 5KB. This can be // changed easily since the chunk size is incorporated into the encrypted data. private chunkSize_ = 5000; - private decryptedMasterKeys_: Record = {}; + private encryptedMasterKeys_: Map = new Map(); + private decryptedMasterKeys_: Map = new Map(); public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4; @@ -96,7 +103,7 @@ export default class EncryptionService { } public loadedMasterKeysCount() { - return Object.keys(this.decryptedMasterKeys_).length; + return this.loadedMasterKeyIds().length; } public chunkSize() { @@ -123,41 +130,78 @@ export default class EncryptionService { } public isMasterKeyLoaded(masterKey: MasterKeyEntity) { - const d = this.decryptedMasterKeys_[masterKey.id]; + if (this.encryptedMasterKeys_.get(masterKey.id)) { + return true; + } + const d = this.decryptedMasterKeys_.get(masterKey.id); if (!d) return false; return d.updatedTime === masterKey.updated_time; } - public async loadMasterKey(model: MasterKeyEntity, password: string, makeActive = false) { + public async loadMasterKey(model: MasterKeyEntity, getPassword: string|GetPasswordCallback, makeActive = false) { if (!model.id) throw new Error('Master key does not have an ID - save it first'); - logger.info(`Loading master key: ${model.id}. Make active:`, makeActive); + const loadKey = async () => { + logger.info(`Loading master key: ${model.id}. Make active:`, makeActive); - this.decryptedMasterKeys_[model.id] = { - plainText: await this.decryptMasterKeyContent(model, password), - updatedTime: model.updated_time, + const password = typeof getPassword === 'string' ? getPassword : (await getPassword()); + if (!password) { + logger.info(`Loading master key ${model.id} failed. No valid password found.`); + } else { + try { + this.decryptedMasterKeys_.set(model.id, { + plainText: await this.decryptMasterKeyContent(model, password), + updatedTime: model.updated_time, + }); + + if (makeActive) this.setActiveMasterKeyId(model.id); + } catch (error) { + logger.warn(`Cannot load master key ${model.id}. Invalid password?`, error); + } + } + + this.encryptedMasterKeys_.delete(model.id); }; - if (makeActive) this.setActiveMasterKeyId(model.id); + if (!makeActive) { + this.encryptedMasterKeys_.set(model.id, { + decrypt: loadKey, + updatedTime: model.updated_time, + }); + } else { + await loadKey(); + } } public unloadMasterKey(model: MasterKeyEntity) { - delete this.decryptedMasterKeys_[model.id]; + this.decryptedMasterKeys_.delete(model.id); + this.encryptedMasterKeys_.delete(model.id); } - public loadedMasterKey(id: string) { - if (!this.decryptedMasterKeys_[id]) { + public async loadedMasterKey(id: string) { + const cachedKey = this.decryptedMasterKeys_.get(id); + if (cachedKey) return cachedKey; + + const decryptCallback = this.encryptedMasterKeys_.get(id); + if (decryptCallback) { + // TODO: Handle invalid password errors? + await decryptCallback.decrypt(); + } + + const key = this.decryptedMasterKeys_.get(id); + + if (!key) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const error: any = new Error(`Master key is not loaded: ${id}`); error.code = 'masterKeyNotLoaded'; error.masterKeyId = id; throw error; } - return this.decryptedMasterKeys_[id]; + return key; } public loadedMasterKeyIds() { - return Object.keys(this.decryptedMasterKeys_); + return [...this.decryptedMasterKeys_.keys(), ...this.encryptedMasterKeys_.keys()]; } public fsDriver() { @@ -430,7 +474,7 @@ export default class EncryptionService { const method = options.encryptionMethod; const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId(); - const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText; + const masterKeyPlainText = (await this.loadedMasterKey(masterKeyId)).plainText; const header = { encryptionMethod: method, @@ -465,7 +509,7 @@ export default class EncryptionService { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const header: any = await this.decodeHeaderSource_(source); - const masterKeyPlainText = this.loadedMasterKey(header.masterKeyId).plainText; + const masterKeyPlainText = (await this.loadedMasterKey(header.masterKeyId)).plainText; let doneSize = 0; diff --git a/packages/lib/services/e2ee/utils.ts b/packages/lib/services/e2ee/utils.ts index a2b38c092..b06808210 100644 --- a/packages/lib/services/e2ee/utils.ts +++ b/packages/lib/services/e2ee/utils.ts @@ -142,14 +142,10 @@ export async function loadMasterKeysFromSettings(service: EncryptionService) { const mk = masterKeys[i]; if (service.isMasterKeyLoaded(mk)) continue; - const password = await findMasterKeyPassword(service, mk); - if (!password) continue; - - try { - await service.loadMasterKey(mk, password, activeMasterKeyId === mk.id); - } catch (error) { - logger.warn(`Cannot load master key ${mk.id}. Invalid password?`, error); - } + await service.loadMasterKey(mk, async () => { + const password = await findMasterKeyPassword(service, mk); + return password; + }, activeMasterKeyId === mk.id); } logger.info(`Loaded master keys: ${service.loadedMasterKeysCount()}`);