1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All: Various improvements to E2EE

This commit is contained in:
Laurent Cozic 2017-12-26 11:38:53 +01:00
parent bef7c38724
commit 08d2655f13
16 changed files with 129 additions and 59 deletions

View File

@ -12,6 +12,10 @@ class BaseCommand {
throw new Error('Usage not defined'); throw new Error('Usage not defined');
} }
encryptionCheck(item) {
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
}
description() { description() {
throw new Error('Description not defined'); throw new Error('Description not defined');
} }

View File

@ -19,6 +19,7 @@ class Command extends BaseCommand {
let title = args['note']; let title = args['note'];
let note = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() }); let note = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
this.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', title)); if (!note) throw new Error(_('Cannot find "%s".', title));
const localFilePath = args['file']; const localFilePath = args['file'];

View File

@ -16,8 +16,9 @@ class Command extends BaseCommand {
return _('Marks a to-do as done.'); return _('Marks a to-do as done.');
} }
static async handleAction(args, isCompleted) { static async handleAction(commandInstance, args, isCompleted) {
const note = await app().loadItem(BaseModel.TYPE_NOTE, args.note); const note = await app().loadItem(BaseModel.TYPE_NOTE, args.note);
commandInstance.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', args.note)); if (!note) throw new Error(_('Cannot find "%s".', args.note));
if (!note.is_todo) throw new Error(_('Note is not a to-do: "%s"', args.note)); if (!note.is_todo) throw new Error(_('Note is not a to-do: "%s"', args.note));
@ -32,7 +33,7 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
await Command.handleAction(args, true); await Command.handleAction(this, args, true);
} }
} }

View File

@ -0,0 +1,74 @@
const { BaseCommand } = require('./base-command.js');
const { _ } = require('lib/locale.js');
const { cliUtils } = require('./cli-utils.js');
const EncryptionService = require('lib/services/EncryptionService');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const MasterKey = require('lib/models/MasterKey');
const Setting = require('lib/models/Setting.js');
class Command extends BaseCommand {
usage() {
return 'e2ee <command>';
}
description() {
return _('Manages E2EE configuration. Commands are `enable`, `disable` and `decrypt`.');
}
options() {
return [
// This is here mostly for testing - shouldn't be used
['-p, --password <password>', 'Use this password as master password (For security reasons, it is not recommended to use this option).'],
];
}
async action(args) {
// change-password
const options = args.options;
if (args.command === 'enable') {
const password = options.password ? options.password.toString() : await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
if (!password) {
this.stdout(_('Operation cancelled'));
return;
}
await EncryptionService.instance().generateMasterKeyAndEnableEncryption(password);
return;
}
if (args.command === 'disable') {
await EncryptionService.instance().disableEncryption();
return;
}
if (args.command === 'decrypt') {
while (true) {
try {
await DecryptionWorker.instance().start();
break;
} catch (error) {
if (error.code === 'masterKeyNotLoaded') {
const masterKeyId = error.masterKeyId;
const password = await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
if (!password) {
this.stdout(_('Operation cancelled'));
return;
}
Setting.setObjectKey('encryption.passwordCache', masterKeyId, password);
await EncryptionService.instance().loadMasterKeysFromSettings();
continue;
}
throw error;
}
}
return;
}
}
}
module.exports = Command;

View File

@ -44,6 +44,8 @@ class Command extends BaseCommand {
if (!app().currentFolder()) throw new Error(_('No active notebook.')); if (!app().currentFolder()) throw new Error(_('No active notebook.'));
let note = await app().loadItem(BaseModel.TYPE_NOTE, title); let note = await app().loadItem(BaseModel.TYPE_NOTE, title);
this.encryptionCheck(note);
if (!note) { if (!note) {
const ok = await this.prompt(_('Note does not exist: "%s". Create it?', title)); const ok = await this.prompt(_('Note does not exist: "%s". Create it?', title));
if (!ok) return; if (!ok) return;

View File

@ -1,47 +0,0 @@
const { BaseCommand } = require('./base-command.js');
const { _ } = require('lib/locale.js');
const { cliUtils } = require('./cli-utils.js');
const EncryptionService = require('lib/services/EncryptionService');
const MasterKey = require('lib/models/MasterKey');
const Setting = require('lib/models/Setting.js');
class Command extends BaseCommand {
usage() {
return 'encrypt-config <command>';
}
description() {
return _('Manages encryption configuration.');
}
options() {
return [
// This is here mostly for testing - shouldn't be used
['-p, --password <password>', 'Use this password as master password (For security reasons, it is not recommended to use this option).'],
];
}
async action(args) {
// init
// change-password
const options = args.options;
if (args.command === 'init') {
const password = options.password ? options.password.toString() : await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
if (!password) {
this.stdout(_('Operation cancelled'));
return;
}
const service = new EncryptionService();
let masterKey = await service.generateMasterKey(password);
masterKey = await MasterKey.save(masterKey);
await service.enableEncryption(masterKey, password);
}
}
}
module.exports = Command;

View File

@ -20,6 +20,7 @@ class Command extends BaseCommand {
const name = args['name']; const name = args['name'];
const item = await app().loadItem('folderOrNote', pattern); const item = await app().loadItem('folderOrNote', pattern);
this.encryptionCheck(item);
if (!item) throw new Error(_('Cannot find "%s".', pattern)); if (!item) throw new Error(_('Cannot find "%s".', pattern));
const newItem = { const newItem = {

View File

@ -35,6 +35,8 @@ class Command extends BaseCommand {
if (!notes.length) throw new Error(_('Cannot find "%s".', title)); if (!notes.length) throw new Error(_('Cannot find "%s".', title));
for (let i = 0; i < notes.length; i++) { for (let i = 0; i < notes.length; i++) {
this.encryptionCheck(notes[i]);
let newNote = { let newNote = {
id: notes[i].id, id: notes[i].id,
type_: notes[i].type_, type_: notes[i].type_,

View File

@ -25,6 +25,8 @@ class Command extends BaseCommand {
for (let i = 0; i < notes.length; i++) { for (let i = 0; i < notes.length; i++) {
const note = notes[i]; const note = notes[i];
this.encryptionCheck(note);
let toSave = { let toSave = {
id: note.id, id: note.id,
}; };

View File

@ -19,7 +19,7 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
await CommandDone.handleAction(args, false); await CommandDone.handleAction(this, args, false);
} }
} }

View File

@ -24,9 +24,9 @@ class FolderListWidget extends ListWidget {
if (item === '-') { if (item === '-') {
output.push('-'.repeat(this.innerWidth)); output.push('-'.repeat(this.innerWidth));
} else if (item.type_ === Folder.modelType()) { } else if (item.type_ === Folder.modelType()) {
output.push(item.title); output.push(Folder.displayTitle(item));
} else if (item.type_ === Tag.modelType()) { } else if (item.type_ === Tag.modelType()) {
output.push('[' + item.title + ']'); output.push('[' + Folder.displayTitle(item) + ']');
} else if (item.type_ === BaseModel.TYPE_SEARCH) { } else if (item.type_ === BaseModel.TYPE_SEARCH) {
output.push(_('Search:')); output.push(_('Search:'));
output.push(item.title); output.push(item.title);

View File

@ -10,7 +10,7 @@ class NoteListWidget extends ListWidget {
this.updateIndexFromSelectedNoteId_ = false; this.updateIndexFromSelectedNoteId_ = false;
this.itemRenderer = (note) => { this.itemRenderer = (note) => {
let label = note.title; // + ' ' + note.id; let label = Note.displayTitle(note); // + ' ' + note.id;
if (note.is_todo) { if (note.is_todo) {
label = '[' + (note.todo_completed ? 'X' : ' ') + '] ' + label; label = '[' + (note.todo_completed ? 'X' : ' ') + '] ' + label;
} }

View File

@ -44,7 +44,12 @@ class NoteWidget extends TextWidget {
} else if (this.noteId_) { } else if (this.noteId_) {
this.doAsync('loadNote', async () => { this.doAsync('loadNote', async () => {
this.note_ = await Note.load(this.noteId_); this.note_ = await Note.load(this.noteId_);
if (this.note_.encryption_applied) {
this.text = _('One or more items are currently encrypted and you may need to supply a master password. To do so please type `e2ee decrypt`. If you have already supplied the password, the encrypted items are being decrypted in the background and will be available soon.');
} else {
this.text = this.note_ ? this.note_.title + "\n\n" + this.note_.body : ''; this.text = this.note_ ? this.note_.title + "\n\n" + this.note_.body : '';
}
if (this.lastLoadedNoteId_ !== this.noteId_) this.scrollTop = 0; if (this.lastLoadedNoteId_ !== this.noteId_) this.scrollTop = 0;
this.lastLoadedNoteId_ = this.noteId_; this.lastLoadedNoteId_ = this.noteId_;
}); });

View File

@ -57,6 +57,9 @@ The apps handle displaying both decrypted and encrypted items, so that user is a
Enabling/disabling E2EE while two clients are in sync might have an unintuitive behaviour (although that behaviour might be correct), so below some scenarios are explained: Enabling/disabling E2EE while two clients are in sync might have an unintuitive behaviour (although that behaviour might be correct), so below some scenarios are explained:
- If client 1 enables E2EE, all items will be synced to target and will appear encrypted on target. Although all items have been re-uploaded to the target, their timestamps did *not* change (because the item data itself has not changed, only its representation). Because of this, client 2 will not be re-download the items - it does not need to do so anyway since it has already the item data. - If client 1 enables E2EE, all items will be synced to target and will appear encrypted on target. Although all items have been re-uploaded to the target, their timestamps did *not* change (because the item data itself has not changed, only its representation). Because of this, client 2 will not be re-download the items - it does not need to do so anyway since it has already the item data.
- When a client sync and download a master key for the first time, encryption will be automatically enabled (user will need to supply the master key password). In that case, all items that are not encrypted will be re-synced. Uploading only non-encrypted items is an optimisation since if an item is already encrypted locally it means it's encrytped on target too. - When a client sync and download a master key for the first time, encryption will be automatically enabled (user will need to supply the master key password). In that case, all items that are not encrypted will be re-synced. Uploading only non-encrypted items is an optimisation since if an item is already encrypted locally it means it's encrytped on target too.
- If both clients are in sync with E2EE enabled: if client 1 disable E2EE, it's going to re-upload all the items unencrypted. Client 2 again will not re-download the items for the same reason as above (data did not change, only representation). Note that user *must* manually disable E2EE on all clients otherwise some will continue to upload encrypted items. Since synchronisation is stateless, clients do not know whether other clients use E2EE or not so this step has to be manual. - If both clients are in sync with E2EE enabled: if client 1 disable E2EE, it's going to re-upload all the items unencrypted. Client 2 again will not re-download the items for the same reason as above (data did not change, only representation). Note that user *must* manually disable E2EE on all clients otherwise some will continue to upload encrypted items. Since synchronisation is stateless, clients do not know whether other clients use E2EE or not so this step has to be manual.
- Although messy, Joplin supports having some clients send encrypted and others unencrypted ones. The situation gets resolved once all the clients have the same E2EE settings. - Although messy, Joplin supports having some clients send encrypted and others unencrypted ones. The situation gets resolved once all the clients have the same E2EE settings.

View File

@ -40,12 +40,22 @@ class DecryptionWorker {
this.scheduleId_ = setTimeout(() => { this.scheduleId_ = setTimeout(() => {
this.scheduleId_ = null; this.scheduleId_ = null;
this.start(); this.start({
materKeyNotLoadedHandler: 'dispatch',
});
}, 1000); }, 1000);
} }
async start() { async start(options = null) {
if (this.state_ !== 'idle') return; if (options === null) options = {};
if (!('materKeyNotLoadedHandler' in options)) options.materKeyNotLoadedHandler = 'throw';
if (this.state_ !== 'idle') {
this.logger().info('DecryptionWorker: cannot start because state is "' + this.state_ + '"');
return;
}
this.logger().info('DecryptionWorker: starting decryption...');
this.state_ = 'started'; this.state_ = 'started';
@ -58,11 +68,12 @@ class DecryptionWorker {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
this.logger().debug('DecryptionWorker: decrypting: ' + item.id);
const ItemClass = BaseItem.itemClass(item); const ItemClass = BaseItem.itemClass(item);
try { try {
await ItemClass.decrypt(item); await ItemClass.decrypt(item);
} catch (error) { } catch (error) {
if (error.code === 'masterKeyNotLoaded') { if (error.code === 'masterKeyNotLoaded' && options.materKeyNotLoadedHandler === 'dispatch') {
excludedIds.push(item.id); excludedIds.push(item.id);
this.dispatch({ this.dispatch({
type: 'MASTERKEY_ADD_NOT_LOADED', type: 'MASTERKEY_ADD_NOT_LOADED',
@ -77,9 +88,13 @@ class DecryptionWorker {
if (!result.hasMore) break; if (!result.hasMore) break;
} }
} catch (error) { } catch (error) {
this.logger().error('DecryptionWorker::start:', error); this.logger().error('DecryptionWorker:', error);
this.state_ = 'idle';
throw error;
} }
this.logger().info('DecryptionWorker: completed decryption.');
this.state_ = 'idle'; this.state_ = 'idle';
} }

View File

@ -48,6 +48,7 @@ class EncryptionService {
masterKey = await MasterKey.save(masterKey); masterKey = await MasterKey.save(masterKey);
await this.enableEncryption(masterKey, password); await this.enableEncryption(masterKey, password);
await this.loadMasterKeysFromSettings(); await this.loadMasterKeysFromSettings();
return masterKey;
} }
async enableEncryption(masterKey, password = null) { async enableEncryption(masterKey, password = null) {
@ -220,6 +221,9 @@ class EncryptionService {
} }
async encrypt(method, key, plainText) { async encrypt(method, key, plainText) {
if (!method) throw new Error('Encryption method is required');
if (!key) throw new Error('Encryption key is required');
const sjcl = shim.sjclModule; const sjcl = shim.sjclModule;
if (method === EncryptionService.METHOD_SJCL) { if (method === EncryptionService.METHOD_SJCL) {
@ -261,6 +265,9 @@ class EncryptionService {
} }
async decrypt(method, key, cipherText) { async decrypt(method, key, cipherText) {
if (!method) throw new Error('Encryption method is required');
if (!key) throw new Error('Encryption key is required');
const sjcl = shim.sjclModule; const sjcl = shim.sjclModule;
if (method === EncryptionService.METHOD_SJCL || method === EncryptionService.METHOD_SJCL_2) { if (method === EncryptionService.METHOD_SJCL || method === EncryptionService.METHOD_SJCL_2) {