39 changes: 37 additions & 2 deletions packages/lib/components/shared/encryption-config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import shim from '../../shim';
import { MasterKeyEntity } from '../../services/e2ee/types';
import time from '../../time';
import { masterKeyEnabled, setMasterKeyEnabled } from '../../services/synchronizer/syncInfoUtils';
import { findMasterKeyPassword } from '../../services/e2ee/utils';

class Shared {

Expand All @@ -16,12 +17,16 @@ class Shared {
public initialize(comp: any, props: any) {
comp.state = {
passwordChecks: {},
// Master keys that can be decrypted with the master password
// (normally all of them, but for legacy support we need this).
masterPasswordKeys: {},
stats: {
encrypted: null,
total: null,
},
passwords: Object.assign({}, props.passwords),
showDisabledMasterKeys: false,
masterPasswordInput: '',
};
comp.isMounted_ = false;

Expand Down Expand Up @@ -108,15 +113,37 @@ class Shared {
}
}

public async masterPasswordIsValid(comp: any, masterPassword: string = null) {
const activeMasterKey = comp.props.masterKeys.find((mk: MasterKeyEntity) => mk.id === comp.props.activeMasterKeyId);
masterPassword = masterPassword === null ? comp.props.masterPassword : masterPassword;
if (activeMasterKey && masterPassword) {
return EncryptionService.instance().checkMasterKeyPassword(activeMasterKey, masterPassword);
}

return false;
}

public async checkPasswords(comp: any) {
const passwordChecks = Object.assign({}, comp.state.passwordChecks);
const masterPasswordKeys = Object.assign({}, comp.state.masterPasswordKeys);
for (let i = 0; i < comp.props.masterKeys.length; i++) {
const mk = comp.props.masterKeys[i];
const password = comp.state.passwords[mk.id];
const password = await findMasterKeyPassword(EncryptionService.instance(), mk);
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
passwordChecks[mk.id] = ok;
masterPasswordKeys[mk.id] = password === comp.props.masterPassword;
}
comp.setState({ passwordChecks: passwordChecks });

passwordChecks['master'] = await this.masterPasswordIsValid(comp);

comp.setState({ passwordChecks, masterPasswordKeys });
}

public masterPasswordStatus(comp: any) {
// Don't translate for now because that's temporary - later it should
// always be set and the label should be replaced by a "Change master
// password" button.
return comp.props.masterPassword ? 'Master password is set' : 'Master password is not set';
}

public decryptedStatText(comp: any) {
Expand All @@ -138,6 +165,14 @@ class Shared {
comp.checkPasswords();
}

public onMasterPasswordChange(comp: any, value: string) {
comp.setState({ masterPasswordInput: value });
}

public onMasterPasswordSave(comp: any) {
Setting.setValue('encryption.masterPassword', comp.state.masterPasswordInput);
}

public onPasswordChange(comp: any, mk: MasterKeyEntity, password: string) {
const passwords = Object.assign({}, comp.state.passwords);
passwords[mk.id] = password;
Expand Down
1 change: 0 additions & 1 deletion packages/lib/models/MasterKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export default class MasterKey extends BaseItem {
}
}
return output;
// return this.modelSelectOne('SELECT * FROM master_keys WHERE created_time >= (SELECT max(created_time) FROM master_keys)');
}

static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], method: number) {
Expand Down
1 change: 1 addition & 0 deletions packages/lib/models/Setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ class Setting extends BaseModel {
'encryption.enabled': { value: false, type: SettingItemType.Bool, public: false },
'encryption.activeMasterKeyId': { value: '', type: SettingItemType.String, public: false },
'encryption.passwordCache': { value: {}, type: SettingItemType.Object, public: false, secure: true },
'encryption.masterPassword': { value: '', type: SettingItemType.String, public: false, secure: true },
'encryption.shouldReencrypt': {
value: -1, // will be set on app startup
type: SettingItemType.Int,
Expand Down
8 changes: 7 additions & 1 deletion packages/lib/services/DecryptionWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ResourceService from './ResourceService';
import Logger from '../Logger';
import shim from '../shim';
import KvStore from './KvStore';
import EncryptionService from './e2ee/EncryptionService';

const EventEmitter = require('events');

Expand All @@ -28,7 +29,7 @@ export default class DecryptionWorker {
private kvStore_: KvStore = null;
private maxDecryptionAttempts_ = 2;
private startCalls_: boolean[] = [];
private encryptionService_: any = null;
private encryptionService_: EncryptionService = null;

constructor() {
this.state_ = 'idle';
Expand Down Expand Up @@ -134,6 +135,11 @@ export default class DecryptionWorker {
this.logger().info(msg);
const ids = await MasterKey.allIds();

// Note that the current implementation means that a warning will be
// displayed even if the user has no encrypted note. Just having
// encrypted master key is sufficient. Not great but good enough for
// now.

if (ids.length) {
if (options.masterKeyNotLoadedHandler === 'throw') {
// By trying to load the master key here, we throw the "masterKeyNotLoaded" error
Expand Down
20 changes: 8 additions & 12 deletions packages/lib/services/e2ee/EncryptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import JoplinError from '../../JoplinError';
import { getActiveMasterKeyId, setActiveMasterKeyId } from '../synchronizer/syncInfoUtils';
const { padLeft } = require('../../string-utils.js');

const logger = Logger.create('EncryptionService');

function hexPad(s: string, length: number) {
return padLeft(s, length, '0');
}
Expand Down Expand Up @@ -52,7 +54,6 @@ export default class EncryptionService {
private decryptedMasterKeys_: Record<string, DecryptedMasterKey> = {};
public defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A; // public because used in tests
private defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
private logger_ = new Logger();

private headerTemplates_ = {
// Template version 1
Expand Down Expand Up @@ -80,7 +81,6 @@ export default class EncryptionService {
this.decryptedMasterKeys_ = {};
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
this.logger_ = new Logger();

this.headerTemplates_ = {
// Template version 1
Expand All @@ -97,14 +97,6 @@ export default class EncryptionService {
return this.instance_;
}

setLogger(l: Logger) {
this.logger_ = l;
}

logger() {
return this.logger_;
}

loadedMasterKeysCount() {
return Object.keys(this.decryptedMasterKeys_).length;
}
Expand Down Expand Up @@ -139,10 +131,14 @@ export default class EncryptionService {

public async loadMasterKey(model: MasterKeyEntity, password: string, 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);

this.decryptedMasterKeys_[model.id] = {
plainText: await this.decryptMasterKey_(model, password),
updatedTime: model.updated_time,
};

if (makeActive) this.setActiveMasterKeyId(model.id);
}

Expand Down Expand Up @@ -245,7 +241,7 @@ export default class EncryptionService {
return plainText;
}

async checkMasterKeyPassword(model: MasterKeyEntity, password: string) {
public async checkMasterKeyPassword(model: MasterKeyEntity, password: string) {
try {
await this.decryptMasterKey_(model, password);
} catch (error) {
Expand All @@ -255,7 +251,7 @@ export default class EncryptionService {
return true;
}

wrapSjclError(sjclError: any) {
private wrapSjclError(sjclError: any) {
const error = new Error(sjclError.message);
error.stack = sjclError.stack;
return error;
Expand Down
34 changes: 32 additions & 2 deletions packages/lib/services/e2ee/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, expectNotThrow } from '../../testing/test-utils';
import MasterKey from '../../models/MasterKey';
import { showMissingMasterKeyMessage } from './utils';
import { localSyncInfo, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
import { migrateMasterPassword, showMissingMasterKeyMessage } from './utils';
import { localSyncInfo, setActiveMasterKeyId, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
import Setting from '../../models/Setting';

describe('e2ee/utils', function() {

Expand Down Expand Up @@ -41,4 +42,33 @@ describe('e2ee/utils', function() {
expect(showMissingMasterKeyMessage(syncInfo, [mk1.id, mk2.id])).toBe(false);
});

it('should do the master password migration', async () => {
const mk1 = await MasterKey.save(await encryptionService().generateMasterKey('111111'));
const mk2 = await MasterKey.save(await encryptionService().generateMasterKey('222222'));

Setting.setValue('encryption.passwordCache', {
[mk1.id]: '111111',
[mk2.id]: '222222',
});

await migrateMasterPassword();

{
expect(Setting.value('encryption.masterPassword')).toBe('');
const newCache = Setting.value('encryption.passwordCache');
expect(newCache[mk1.id]).toBe('111111');
expect(newCache[mk2.id]).toBe('222222');
}

setActiveMasterKeyId(mk1.id);
await migrateMasterPassword();

{
expect(Setting.value('encryption.masterPassword')).toBe('111111');
const newCache = Setting.value('encryption.passwordCache');
expect(newCache[mk1.id]).toBe(undefined);
expect(newCache[mk2.id]).toBe('222222');
}
});

});
70 changes: 62 additions & 8 deletions packages/lib/services/e2ee/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import MasterKey from '../../models/MasterKey';
import Setting from '../../models/Setting';
import { MasterKeyEntity } from './types';
import EncryptionService from './EncryptionService';
import { getActiveMasterKeyId, masterKeyEnabled, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';
import { getActiveMasterKey, getActiveMasterKeyId, masterKeyEnabled, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';

const logger = Logger.create('e2ee/utils');

export async function setupAndEnableEncryption(service: EncryptionService, masterKey: MasterKeyEntity = null, password: string = null) {
export async function setupAndEnableEncryption(service: EncryptionService, masterKey: MasterKeyEntity = null, masterPassword: string = null) {
if (!masterKey) {
// May happen for example if there are master keys in info.json but none
// of them is set as active. But in fact, unless there is a bug in the
Expand All @@ -18,10 +18,8 @@ export async function setupAndEnableEncryption(service: EncryptionService, maste

setEncryptionEnabled(true, masterKey ? masterKey.id : null);

if (masterKey && password) {
const passwordCache = Setting.value('encryption.passwordCache');
passwordCache[masterKey.id] = password;
Setting.setValue('encryption.passwordCache', passwordCache);
if (masterPassword) {
Setting.setValue('encryption.masterPassword', masterPassword);
}

// Mark only the non-encrypted ones for sync since, if there are encrypted ones,
Expand All @@ -47,6 +45,8 @@ export async function setupAndDisableEncryption(service: EncryptionService) {
}

export async function toggleAndSetupEncryption(service: EncryptionService, enabled: boolean, masterKey: MasterKeyEntity, password: string) {
logger.info('toggleAndSetupEncryption: enabled:', enabled, ' Master key', masterKey);

if (!enabled) {
await setupAndDisableEncryption(service);
} else {
Expand All @@ -68,17 +68,65 @@ export async function generateMasterKeyAndEnableEncryption(service: EncryptionSe
return masterKey;
}

// Migration function to initialise the master password. Normally it is set when
// enabling E2EE, but previously it wasn't. So here we check if the password is
// set. If it is not, we set it from the active master key. It needs to be
// called after the settings have been initialized.
export async function migrateMasterPassword() {
if (Setting.value('encryption.masterPassword')) return; // Already migrated

logger.info('Master password is not set - trying to get it from the active master key...');

const mk = getActiveMasterKey();
if (!mk) return;

const masterPassword = Setting.value('encryption.passwordCache')[mk.id];
if (masterPassword) {
Setting.setValue('encryption.masterPassword', masterPassword);
logger.info('Master password is now set.');

// Also clear the key passwords that match the master password to avoid
// any confusion.
const cache = Setting.value('encryption.passwordCache');
const newCache = { ...cache };
for (const [mkId, password] of Object.entries(cache)) {
if (password === masterPassword) {
delete newCache[mkId];
}
}
Setting.setValue('encryption.passwordCache', newCache);
await Setting.saveAll();
}
}

// All master keys normally should be decryped with the master password, however
// previously any master key could be encrypted with any password, so to support
// this legacy case, we first check if the MK decrypts with the master password.
// If not, try with the master key specific password, if any is defined.
export async function findMasterKeyPassword(service: EncryptionService, masterKey: MasterKeyEntity): Promise<string> {
const masterPassword = Setting.value('encryption.masterPassword');
if (masterPassword && await service.checkMasterKeyPassword(masterKey, masterPassword)) {
logger.info('findMasterKeyPassword: Using master password');
return masterPassword;
}

logger.info('findMasterKeyPassword: No master password is defined - trying to get master key specific password');

const passwords = Setting.value('encryption.passwordCache');
return passwords[masterKey.id];
}

export async function loadMasterKeysFromSettings(service: EncryptionService) {
const masterKeys = await MasterKey.all();
const passwords = Setting.value('encryption.passwordCache');
const activeMasterKeyId = getActiveMasterKeyId();

logger.info(`Trying to load ${masterKeys.length} master keys...`);

for (let i = 0; i < masterKeys.length; i++) {
const mk = masterKeys[i];
const password = passwords[mk.id];
if (service.isMasterKeyLoaded(mk)) continue;

const password = await findMasterKeyPassword(service, mk);
if (!password) continue;

try {
Expand Down Expand Up @@ -111,3 +159,9 @@ export function showMissingMasterKeyMessage(syncInfo: SyncInfo, notLoadedMasterK

return !!notLoadedMasterKeys.length;
}

export function getDefaultMasterKey(): MasterKeyEntity {
const mk = getActiveMasterKey();
if (mk) return mk;
return MasterKey.latest();
}