diff --git a/CliClient/app/gui/FolderListWidget.js b/CliClient/app/gui/FolderListWidget.js
index 8b9f5e15e..b030eee2c 100644
--- a/CliClient/app/gui/FolderListWidget.js
+++ b/CliClient/app/gui/FolderListWidget.js
@@ -1,6 +1,6 @@
-const Folder = require('lib/models/folder.js').Folder;
-const Tag = require('lib/models/tag.js').Tag;
-const BaseModel = require('lib/base-model.js').BaseModel;
+const Folder = require('lib/models/Folder.js');
+const Tag = require('lib/models/Tag.js');
+const BaseModel = require('lib/BaseModel.js');
const ListWidget = require('tkwidgets/ListWidget.js');
const _ = require('lib/locale.js')._;
diff --git a/CliClient/app/gui/NoteListWidget.js b/CliClient/app/gui/NoteListWidget.js
index 81341d25c..aaa46be69 100644
--- a/CliClient/app/gui/NoteListWidget.js
+++ b/CliClient/app/gui/NoteListWidget.js
@@ -1,4 +1,4 @@
-const Note = require('lib/models/note.js').Note;
+const Note = require('lib/models/Note.js');
const ListWidget = require('tkwidgets/ListWidget.js');
class NoteListWidget extends ListWidget {
diff --git a/CliClient/app/gui/NoteMetadataWidget.js b/CliClient/app/gui/NoteMetadataWidget.js
index fc29e04f2..ff68e189d 100644
--- a/CliClient/app/gui/NoteMetadataWidget.js
+++ b/CliClient/app/gui/NoteMetadataWidget.js
@@ -1,4 +1,4 @@
-const Note = require('lib/models/note.js').Note;
+const Note = require('lib/models/Note.js');
const TextWidget = require('tkwidgets/TextWidget.js');
class NoteMetadataWidget extends TextWidget {
diff --git a/CliClient/app/gui/NoteWidget.js b/CliClient/app/gui/NoteWidget.js
index eb565457f..d36cc75d1 100644
--- a/CliClient/app/gui/NoteWidget.js
+++ b/CliClient/app/gui/NoteWidget.js
@@ -1,4 +1,4 @@
-const Note = require('lib/models/note.js').Note;
+const Note = require('lib/models/Note.js');
const TextWidget = require('tkwidgets/TextWidget.js');
class NoteWidget extends TextWidget {
diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx
index b0c361b54..f4c5859bb 100644
--- a/ElectronClient/app/gui/MainScreen.jsx
+++ b/ElectronClient/app/gui/MainScreen.jsx
@@ -288,8 +288,7 @@ class MainScreenComponent extends React.Component {
const promptOptions = this.state.promptOptions;
const folders = this.props.folders;
const notes = this.props.notes;
- const messageBoxVisible = this.props.hasDisabledSyncItems;
-
+ const messageBoxVisible = this.props.hasDisabledSyncItems || this.props.missingMasterKeys.length;
const styles = this.styles(this.props.theme, style.width, style.height, messageBoxVisible);
const theme = themeStyle(this.props.theme);
@@ -343,13 +342,31 @@ class MainScreenComponent extends React.Component {
});
}
- const messageComp = messageBoxVisible ? (
-
@@ -383,6 +400,7 @@ const mapStateToProps = (state) => {
folders: state.folders,
notes: state.notes,
hasDisabledSyncItems: state.hasDisabledSyncItems,
+ missingMasterKeys: state.missingMasterKeys,
};
};
diff --git a/ElectronClient/app/gui/MasterKeysScreen.jsx b/ElectronClient/app/gui/MasterKeysScreen.jsx
new file mode 100644
index 000000000..47c8e633d
--- /dev/null
+++ b/ElectronClient/app/gui/MasterKeysScreen.jsx
@@ -0,0 +1,64 @@
+const React = require('react');
+const { connect } = require('react-redux');
+const MasterKeys = require('lib/models/MasterKey');
+const { Header } = require('./Header.min.js');
+const { themeStyle } = require('../theme.js');
+const { _ } = require('lib/locale.js');
+const { time } = require('lib/time-utils.js');
+
+class MasterKeysScreenComponent extends React.Component {
+
+ renderMasterKey(mk) {
+ return (
+
+ {mk.id} |
+ {mk.source_application} |
+ {time.formatMsToLocal(mk.created_time)} |
+ {time.formatMsToLocal(mk.update_time)} |
+
+ );
+ }
+
+ render() {
+ const style = this.props.style;
+ const theme = themeStyle(this.props.theme);
+ const masterKeys = this.props.masterKeys;
+
+ const headerStyle = {
+ width: style.width,
+ };
+
+ const mkComps = [];
+
+ for (let i = 0; i < masterKeys.length; i++) {
+ const mk = masterKeys[i];
+ mkComps.push(this.renderMasterKey(mk));
+ }
+
+ return (
+
+
+
+
+
+ {_('ID')} | {_('Source')} | {_('Created')} | {_('Updated')} |
+
+ {mkComps}
+
+
+
+ );
+ }
+
+}
+
+const mapStateToProps = (state) => {
+ return {
+ theme: state.settings.theme,
+ masterKeys: state.masterKeys,
+ };
+};
+
+const MasterKeysScreen = connect(mapStateToProps)(MasterKeysScreenComponent);
+
+module.exports = { MasterKeysScreen };
\ No newline at end of file
diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js
index f48dcf39f..2b67901a1 100644
--- a/ReactNativeClient/lib/BaseApplication.js
+++ b/ReactNativeClient/lib/BaseApplication.js
@@ -27,6 +27,7 @@ const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js');
const EncryptionService = require('lib/services/EncryptionService');
+const DecryptionWorker = require('lib/services/DecryptionWorker');
SyncTargetRegistry.addClass(SyncTargetFilesystem);
SyncTargetRegistry.addClass(SyncTargetOneDrive);
@@ -307,6 +308,7 @@ class BaseApplication {
FoldersScreenUtils.dispatch = this.store().dispatch;
reg.dispatch = this.store().dispatch;
BaseSyncTarget.dispatch = this.store().dispatch;
+ DecryptionWorker.instance().dispatch = this.store().dispatch;
}
async readFlagsFromFile(flagPath) {
@@ -394,7 +396,9 @@ class BaseApplication {
}
BaseItem.encryptionService_ = EncryptionService.instance();
+ DecryptionWorker.encryptionService_ = EncryptionService.instance();
await EncryptionService.instance().loadMasterKeysFromSettings();
+ DecryptionWorker.instance().start();
let currentFolderId = Setting.value('activeFolderId');
let currentFolder = null;
diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js
index c61e16148..574a95ca5 100644
--- a/ReactNativeClient/lib/joplin-database.js
+++ b/ReactNativeClient/lib/joplin-database.js
@@ -271,12 +271,24 @@ class JoplinDatabase extends Database {
}
if (targetVersion == 9) {
- queries.push('CREATE TABLE master_keys (id TEXT PRIMARY KEY, created_time INT NOT NULL, updated_time INT NOT NULL, encryption_method INT NOT NULL, checksum TEXT NOT NULL, content TEXT NOT NULL);');
+ const newTableSql = `
+ CREATE TABLE master_keys (
+ id TEXT PRIMARY KEY,
+ created_time INT NOT NULL,
+ updated_time INT NOT NULL,
+ source_application TEXT NOT NULL,
+ encryption_method INT NOT NULL,
+ checksum TEXT NOT NULL,
+ content TEXT NOT NULL
+ );
+ `;
+ queries.push(this.sqlStringToLines(newTableSql)[0]);
const tableNames = ['notes', 'folders', 'tags', 'note_tags', 'resources'];
for (let i = 0; i < tableNames.length; i++) {
const n = tableNames[i];
queries.push('ALTER TABLE ' + n + ' ADD COLUMN encryption_cipher_text TEXT NOT NULL DEFAULT ""');
queries.push('ALTER TABLE ' + n + ' ADD COLUMN encryption_applied INT NOT NULL DEFAULT 0');
+ queries.push('CREATE INDEX ' + n + '_encryption_applied ON ' + n + ' (encryption_applied)');
}
}
diff --git a/ReactNativeClient/lib/models/BaseItem.js b/ReactNativeClient/lib/models/BaseItem.js
index 01e2f77d1..99586ad56 100644
--- a/ReactNativeClient/lib/models/BaseItem.js
+++ b/ReactNativeClient/lib/models/BaseItem.js
@@ -249,14 +249,17 @@ class BaseItem extends BaseModel {
return temp.join("\n\n");
}
+ static encryptionService() {
+ if (!this.encryptionService_) throw new Error('BaseItem.encryptionService_ is not set!!');
+ return this.encryptionService_;
+ }
+
static async serializeForSync(item) {
const ItemClass = this.itemClass(item);
let serialized = await ItemClass.serialize(item);
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported()) return serialized;
- if (!BaseItem.encryptionService_) throw new Error('BaseItem.encryptionService_ is not set!!');
-
- const cipherText = await BaseItem.encryptionService_.encryptString(serialized);
+ const cipherText = await this.encryptionService().encryptString(serialized);
const reducedItem = Object.assign({}, item);
@@ -284,7 +287,7 @@ class BaseItem extends BaseModel {
if (!item.encryption_cipher_text) throw new Error('Item is not encrypted: ' + item.id);
const ItemClass = this.itemClass(item);
- const plainText = await BaseItem.encryptionService_.decryptString(item.encryption_cipher_text);
+ const plainText = await this.encryptionService().decryptString(item.encryption_cipher_text);
// Note: decryption does not count has a change, so don't update any timestamp
const plainItem = await ItemClass.unserialize(plainText);
@@ -339,6 +342,39 @@ class BaseItem extends BaseModel {
return output;
}
+ static async itemsThatNeedDecryption(exclusions = [], limit = 100) {
+ const classNames = this.encryptableItemClassNames();
+
+ for (let i = 0; i < classNames.length; i++) {
+ const className = classNames[i];
+ const ItemClass = this.getClass(className);
+
+ const whereSql = ['encryption_applied = 1'];
+ if (exclusions.length) whereSql.push('id NOT IN ("' + exclusions.join('","') + '")');
+
+ const sql = sprintf(`
+ SELECT *
+ FROM %s
+ WHERE %s
+ LIMIT %d
+ `,
+ this.db().escapeField(ItemClass.tableName()),
+ whereSql.join(' AND '),
+ limit
+ );
+
+ const items = await ItemClass.modelSelectAll(sql);
+
+ if (i >= classNames.length - 1) {
+ return { hasMore: items.length >= limit, items: items };
+ } else {
+ if (items.length) return { hasMore: true, items: items };
+ }
+ }
+
+ throw new Error('Unreachable');
+ }
+
static async itemsThatNeedSync(syncTarget, limit = 100) {
const classNames = this.syncItemClassNames();
@@ -419,6 +455,16 @@ class BaseItem extends BaseModel {
});
}
+ static encryptableItemClassNames() {
+ const temp = this.syncItemClassNames();
+ let output = [];
+ for (let i = 0; i < temp.length; i++) {
+ if (temp[i] === 'MasterKey') continue;
+ output.push(temp[i]);
+ }
+ return output;
+ }
+
static syncItemTypes() {
return BaseItem.syncItemDefinitions_.map((def) => {
return def.type;
diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js
index 4fda8d1c5..0a8f689a2 100644
--- a/ReactNativeClient/lib/reducer.js
+++ b/ReactNativeClient/lib/reducer.js
@@ -9,6 +9,7 @@ const defaultState = {
folders: [],
tags: [],
masterKeys: [],
+ missingMasterKeys: [],
searches: [],
selectedNoteIds: [],
selectedFolderId: null,
@@ -369,6 +370,14 @@ const reducer = (state = defaultState, action) => {
newState.masterKeys = action.items;
break;
+ case 'MASTERKEY_ADD_MISSING':
+
+ if (state.missingMasterKeys.indexOf(action.id) < 0) {
+ newState = Object.assign({}, state);
+ newState.missingMasterKeys.push(action.id);
+ }
+ break;
+
case 'SYNC_STARTED':
newState = Object.assign({}, state);
diff --git a/ReactNativeClient/lib/services/DecryptionWorker.js b/ReactNativeClient/lib/services/DecryptionWorker.js
index 7d54eba2d..737eafb3d 100644
--- a/ReactNativeClient/lib/services/DecryptionWorker.js
+++ b/ReactNativeClient/lib/services/DecryptionWorker.js
@@ -1,13 +1,57 @@
+const BaseItem = require('lib/models/BaseItem');
+
class DecryptionWorker {
constructor() {
this.state_ = 'idle';
+
+ this.dispatch = (action) => {
+ console.warn('DecryptionWorker.dispatch is not defined');
+ };
}
- start() {
+ static instance() {
+ if (this.instance_) return this.instance_;
+ this.instance_ = new DecryptionWorker();
+ return this.instance_;
+ }
+
+ static encryptionService() {
+ if (!this.encryptionService_) throw new Error('DecryptionWorker.encryptionService_ is not set!!');
+ return this.encryptionService_;
+ }
+
+ async start() {
if (this.state_ !== 'idle') return;
this.state_ = 'started';
+
+ let excludedIds = [];
+
+ while (true) {
+ const result = await BaseItem.itemsThatNeedDecryption(excludedIds);
+ const items = result.items;
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ const ItemClass = BaseItem.itemClass(item);
+ try {
+ await ItemClass.decrypt(item);
+ } catch (error) {
+ if (error.code === 'missingMasterKey') {
+ excludedIds.push(item.id);
+ this.dispatch({
+ type: 'MASTERKEY_ADD_MISSING',
+ id: error.masterKeyId,
+ });
+ continue;
+ }
+ throw error;
+ }
+ }
+
+ if (!result.hasMore) break;
+ }
}
}
diff --git a/ReactNativeClient/lib/services/EncryptionService.js b/ReactNativeClient/lib/services/EncryptionService.js
index b079c3482..f58ba8ef9 100644
--- a/ReactNativeClient/lib/services/EncryptionService.js
+++ b/ReactNativeClient/lib/services/EncryptionService.js
@@ -33,6 +33,7 @@ class EncryptionService {
for (let i = 0; i < masterKeys.length; i++) {
const mk = masterKeys[i];
const password = passwords[mk.id];
+ if (this.isMasterKeyLoaded(mk.id)) continue;
if (!password) continue;
await this.loadMasterKey(mk, password, activeMasterKeyId === mk.id);
@@ -56,6 +57,10 @@ class EncryptionService {
return this.activeMasterKeyId_;
}
+ isMasterKeyLoaded(id) {
+ return !!this.loadedMasterKeys_[id];
+ }
+
async loadMasterKey(model, password, makeActive = false) {
if (!model.id) throw new Error('Master key does not have an ID - save it first');
this.loadedMasterKeys_[model.id] = await this.decryptMasterKey(model, password);
@@ -67,7 +72,12 @@ class EncryptionService {
}
loadedMasterKey(id) {
- if (!this.loadedMasterKeys_[id]) throw new Error('Master key is not loaded: ' + id);
+ if (!this.loadedMasterKeys_[id]) {
+ const error = new Error('Master key is not loaded: ' + id);
+ error.code = 'missingMasterKey';
+ error.masterKeyId = id;
+ throw error;
+ }
return this.loadedMasterKeys_[id];
}
@@ -105,6 +115,7 @@ class EncryptionService {
return {
created_time: now,
updated_time: now,
+ source_application: Setting.value('appId'),
encryption_method: encryptionMethod,
checksum: checksum,
content: cipherText,